8896 lines
401 KiB
Go
8896 lines
401 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"
|
||
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 || m.Module == catalogue.RuntimeModule {
|
||
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
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
// **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.
|
||
if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s: %q is compiled to a binary and says no system, so it would be built for "+
|
||
"whatever the build machine happens to be. Declare one artifact per "+
|
||
"system: %s", module, a.Name, spokenSystems()))
|
||
} else if !compiled && strings.TrimSpace(a.System) != "" {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s: %q names the system %q and is written in %q, which compiles to code that "+
|
||
"runs anywhere — a system that decides nothing reads as though it did",
|
||
module, a.Name, a.System, a.Language))
|
||
} else if compiled && !knownSystem(a.System) {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s: %q is built for %q, and a system is %s",
|
||
module, a.Name, a.System, spokenSystems()))
|
||
}
|
||
} else {
|
||
if a.From == "" && a.URL == "" {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s: %q says nothing about what it is built from", module, a.Name))
|
||
}
|
||
if a.Language != "" {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s: %q is a %q and names a language. Only a bundle is compiled by the mesh; "+
|
||
"everything else brings its own recipe", module, a.Name, a.Kind))
|
||
}
|
||
}
|
||
if a.Compiles != "" {
|
||
if a.Kind != 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
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,
|
||
// 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 and invokes are refused only for a bus
|
||
// module with no bundle to say or ask them (issue 774: nats's bundle records its hand act).
|
||
if _, account := m.OwnSecrets["broker"]; account && m.ClaimsSeat(catalogue.BrokerSeat) {
|
||
var said []string
|
||
bundled := manifestShipsABundle(m)
|
||
if len(m.EmitsAll()) > 0 && !bundled {
|
||
said = append(said, "emits")
|
||
}
|
||
if len(m.Invokes) > 0 && !bundled {
|
||
said = append(said, "invokes")
|
||
}
|
||
for _, f := range []struct {
|
||
name string
|
||
n int
|
||
}{{"consumes", len(m.Consumes)}, {"uses", len(m.Uses)},
|
||
{"state", len(m.State)}, {"logs", len(m.Logs)}, {"reads", len(m.Reads)},
|
||
{"streams", len(m.Streams)}} {
|
||
if f.n > 0 {
|
||
said = append(said, f.name)
|
||
}
|
||
}
|
||
if len(said) > 0 {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s holds %s and declares a bus account, which is granted the bus's snapshot API and nothing "+
|
||
"else (novox/hq ADR 0235); its %s would be granted nothing", m.Module, catalogue.BrokerSeat, strings.Join(said, ", ")))
|
||
}
|
||
}
|
||
for _, r := range m.Requires {
|
||
if r == "amqp" {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s requires %q, which is not a provision: a module reaches the mesh's bus through "+
|
||
"the sdk, and depends on the mesh-broker seat, not on a protocol (novox/hq ADR 0131)",
|
||
m.Module, r))
|
||
}
|
||
}
|
||
if m.Instances != "" && m.Instances != 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)...)
|
||
// A file's text in a template file of the module's repository (novox/hq ADR 0349 §2): only a file, never beside
|
||
// content, only under files/, and packed by the module's build.
|
||
problems = append(problems, templateProblems(m)...)
|
||
problems = append(problems, movedProblems(m)...)
|
||
problems = append(problems, collectionProblems(m)...)
|
||
problems = append(problems, undeliveredBundles(m)...)
|
||
// **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029).
|
||
//
|
||
// Building publishes to the store, and the builder will not start without one. So a module
|
||
// that provides the store and also builds something asks the mesh to put an artifact into the
|
||
// thing that artifact is needed to create.
|
||
//
|
||
// It is the question the foundation record asks of every candidate — can it grant itself the
|
||
// thing it provides? The store cannot create its own database, the broker cannot create its
|
||
// own virtual host, and a registry cannot grant itself a repository. Such a module names its
|
||
// image, exactly as the bundle names the three a first node starts from.
|
||
//
|
||
// Refused here because the alternative is a build that never returns, on a mesh new enough
|
||
// that nobody is watching it yet.
|
||
if m.Build != nil && len(m.Bundles) > 0 {
|
||
// The output of a build, written beside the build that produces it (ADR 0175). A resource
|
||
// naming a digest beside an `artifact` would be the same mistake, and is caught the same way:
|
||
// what the mesh derives, a repository does not state.
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s writes `bundles` beside its build. The mesh derives that from what the build "+
|
||
"produced; a manifest states `build.artifacts` and nothing about what came out",
|
||
m.Module))
|
||
}
|
||
if m.Build != nil && len(m.Build.Artifacts) > 0 {
|
||
for _, o := range m.Offers() {
|
||
if o != catalogue.ArtifactStoreProvision {
|
||
continue
|
||
}
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s provides %q and also builds %d artifact(s), which cannot both be true: "+
|
||
"building publishes to the artifact store, so this asks the mesh to put an "+
|
||
"artifact into the thing that artifact is needed to create. A module that "+
|
||
"provides the store names its image instead. One that wants more beside it "+
|
||
"— an interface, a tool server — is a second module, mirrored in the "+
|
||
"ordinary way once this one is running",
|
||
m.Module, catalogue.ArtifactStoreProvision, len(m.Build.Artifacts)))
|
||
}
|
||
}
|
||
for to := range m.Serves {
|
||
var offered bool
|
||
for _, o := range m.Offers() {
|
||
if o == to {
|
||
offered = true
|
||
}
|
||
}
|
||
if !offered {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s serves %q to whoever requires it, and does not provide it", m.Module, to))
|
||
}
|
||
}
|
||
// A served value may be derived for the consumer it is served to (novox/hq ADR 0202). Read
|
||
// here, where the definition is, rather than when somebody first requires it: a rule that
|
||
// would be refused at the first consumer is wrong from the moment it is written.
|
||
problems = append(problems, CheckServes(m)...)
|
||
for to, where := range m.Binds {
|
||
if !placedOrAbsolute(where) {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s binds %q at %q, which is neither an absolute path nor a placed one", m.Module, to, where))
|
||
}
|
||
var wanted bool
|
||
for _, w := range m.Wants() {
|
||
if w == to {
|
||
wanted = true
|
||
}
|
||
}
|
||
if !wanted {
|
||
// Being told about something you never asked for would write a file describing a
|
||
// machine this one has no business talking to.
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s binds %q and does not require it", m.Module, to))
|
||
}
|
||
}
|
||
if f := m.Filtering; f != nil && strings.TrimSpace(f.Into) == "" {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s asks for the computed rule set and does not say where to put it", m.Module))
|
||
}
|
||
for _, l := range m.Listens {
|
||
if l.Port < 1 || l.Port > 65535 {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s listens on port %d, which is not a port", m.Module, l.Port))
|
||
}
|
||
switch l.From {
|
||
case 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)...)
|
||
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 m.Module != catalogue.RuntimeModule {
|
||
return []string{fmt.Sprintf("%s says what a session waits on; only %s, the tool runner's module, "+
|
||
"declares waits (novox/hq ADR 0367 §2)", m.Module, catalogue.RuntimeModule)}
|
||
}
|
||
var problems []string
|
||
seen := map[string]bool{}
|
||
for _, w := range m.Waits {
|
||
if strings.ContainsAny(w, "*>") {
|
||
problems = append(problems, fmt.Sprintf("%s waits on %q: a family is named one by one, never a "+
|
||
"wildcard (novox/hq ADR 0367 §2)", m.Module, w))
|
||
continue
|
||
}
|
||
tokens := strings.Split(w, ".")
|
||
whole := len(tokens) >= 2 && name.MatchString(tokens[0])
|
||
for _, t := range tokens[1:] {
|
||
whole = whole && waitToken.MatchString(t)
|
||
}
|
||
if !whole {
|
||
problems = append(problems, fmt.Sprintf("%s waits on %q, which does not name an emitter and an "+
|
||
"event: a family is <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)}
|
||
}
|
||
|
||
// 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 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, len(seats))
|
||
for _, s := range 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 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
|
||
}
|
||
|
||
// 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(catalogue.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
|
||
}
|
||
|
||
var seats = defaultSeats
|
||
|
||
// 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}
|
||
|
||
var defaultSeats = append([]catalogue.Seat{
|
||
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
|
||
// served by whichever module holds the seat with tools of these names.
|
||
{Name: "mesh-store", Scope: catalogue.ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079",
|
||
Serves: []catalogue.Verb{
|
||
{Name: "databases", Description: "Every database the store holds, with its on-disk size.",
|
||
Input: schema(map[string]string{}, nil), Replaces: []string{"psql -l"}},
|
||
{Name: "query", Description: "One read-only statement against one database the store holds.",
|
||
Input: schema(map[string]string{"database": "the database to query", "sql": "the read-only statement"},
|
||
[]string{"database", "sql"}), Replaces: []string{"psql"}},
|
||
}},
|
||
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
|
||
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
|
||
// the mesh's own transport. ADR 0128 then made that connection something a module requires
|
||
// rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient
|
||
// connection would mint a credential for each.
|
||
{Name: catalogue.BrokerSeat, Scope: catalogue.ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"},
|
||
// The vault: the controller seals every minted credential with what it provides, which is the
|
||
// test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a
|
||
// second claimant, refused by name, rather than a candidate for a pin.
|
||
//
|
||
// Its verb `get` hands a secret on request and reply, sealed to the key the asker sends and only to an
|
||
// asker the vault allows (novox/hq ADR 0349 §11, issue 674). `accept`, `secret-ask`, `issued` and `rotate`
|
||
// take a value in and make one anew (ADR 0349 clauses 8 to 10, issue 675): the first three in two steps from
|
||
// the controller alone, which carries the value sealed to a key the vault made for the call and cannot open
|
||
// it. All optional until the vault that runs serves them (design 33 §7).
|
||
{Name: "mesh-vault", Scope: catalogue.ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161",
|
||
Serves: []catalogue.Verb{
|
||
{Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " +
|
||
"sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the " +
|
||
"bus proved it. Never the value in the clear, and never to a private key.",
|
||
Input: schema(map[string]string{"name": "the secret's name",
|
||
"public_key": "the asker's X25519 public key as SPKI PEM — never a private key"},
|
||
[]string{"name", "public_key"})},
|
||
{Name: "accept", Optional: true, Description: "Take a value given at the controller's terminal, from the " +
|
||
"controller alone: name the secret and get a call and a key made for it, then send the call and the " +
|
||
"value sealed to that key. The vault alone opens it, and never remakes an accepted credential.",
|
||
Input: schema(vaultIntake(map[string]string{"node": "the machine, for a module's own secret",
|
||
"module": "the module, for a module's own secret", "name": "the secret's name in the module",
|
||
"provider": "the provider's machine, for a provision's credential", "provision": "the provision",
|
||
"by": "who gave it at the terminal",
|
||
"origin": "issued for a bus account the controller minted for a core or moved module and hands " +
|
||
"at once (ADR 0374, issue 787); left out for a value given at the terminal"}), nil)},
|
||
{Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " +
|
||
"from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR " +
|
||
"0563 §8): the ask is recorded in the vault's store and bounded before the prompt " +
|
||
"opens, and what is typed is sealed to a key the vault made for the call and opens there alone.",
|
||
Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for",
|
||
"module": "the module", "name": "the secret's name in the module",
|
||
"desk": "the machine whose prompt asks", "asked_by": "who asked, as the bus named its caller",
|
||
"outcome": "instead of sealed: how the prompt ended"}), nil)},
|
||
{Name: "issued", Optional: true, Description: "Take the credential a provider's software issued for a " +
|
||
"provision, from the controller alone, which asks the provider's tool with a key the vault made for " +
|
||
"the call; kept as the provision's one credential, or, when the tool answers a value per holder named, " +
|
||
"each as that holder's own; the same value again writes nothing.",
|
||
Input: withHolders(schema(vaultIntake(map[string]string{"provision": "the provision",
|
||
"provider": "the provider's machine", "module": "the provider's module"}), nil))},
|
||
{Name: "rotate", Optional: true, Description: "Make a secret anew. Refused for a value a provider's " +
|
||
"software issued and for an accepted credential. Never answers the value.",
|
||
Input: schema(map[string]string{"node": "the machine, for a module's own secret", "module": "the module",
|
||
"name": "the secret's name in the module", "provider": "the provider's machine",
|
||
"provision": "the provision", "by": "who asked, when the controller carries the act"}, nil)},
|
||
// The move of the controller's secrets (design 57's V3, issue 676): each machine's node-engine hands in what
|
||
// was sealed to its machine, which the controller cannot open; the controller copies its record of asks.
|
||
{Name: "move-in", Optional: true, Description: "Take one value of the controller's store in, as it is, " +
|
||
"from the node-engine of a machine it was sealed to and no other caller: name the row, its origin " +
|
||
"and generation and get a call and a key made for it, then send the call, the value sealed to that " +
|
||
"key and a key to read back to. Never replaces a value the vault holds; answers what it kept, read " +
|
||
"back sealed to that key.",
|
||
Input: schema(vaultIntake(map[string]string{"node": "a module's own secret: its machine",
|
||
"module": "its module", "name": "its name in the module",
|
||
"provider": "a pair credential: the provider's machine", "provision": "the provision",
|
||
"consumer": "the consumer's machine", "consumer_module": "the consuming module",
|
||
"local": "the credential's local name, when it has one",
|
||
"origin": "made, accepted or issued, as the controller's store says",
|
||
"generation": "the controller's generation for the value", "made_at": "when it was made (RFC 3339)",
|
||
"read_back_to": "the second step: a raw X25519 public key the read-back is sealed to"}), nil)},
|
||
// A machine's node-engine states its own sealing key (novox/hq ADR 0372, issue 786).
|
||
{Name: "node-key", Optional: true, Description: "State this machine's sealing key to the vault, in its " +
|
||
"own name, from the node-engine of the machine alone: at its start and whenever the key changes. A " +
|
||
"first or changed key is recorded and the machine's core secrets are said changed; the same key " +
|
||
"again changes nothing.",
|
||
Input: schema(map[string]string{"key": "the machine's sealing key, a raw X25519 public key in base64"}, nil)},
|
||
// A machine's node-engine composes its own modules and asks their secrets sealed to its key (novox/hq ADR 0547
|
||
// §2, ADR 0550 §5, issue 1126); granted as buscompose's Principal.AsksItsOwnSecrets says.
|
||
{Name: "own", Optional: true, Description: "Answer the node-engine of one machine, about its own machine " +
|
||
"alone (as the bus proved the caller, never an argument), its modules' own secrets and the pair " +
|
||
"credentials it consumes or provides that it names, each sealed to the key it stated with node-key. A " +
|
||
"credential the vault does not hold is named missing; never a value in the clear.",
|
||
Input: map[string]any{"type": "object", "properties": map[string]any{"credentials": map[string]any{
|
||
"type": "array", "items": map[string]any{"type": "object"},
|
||
"description": "the pair credentials the machine's composition reads, each {role: consumer|provider, " +
|
||
"provision, consumer, consumer_module, provider, local}"}}}},
|
||
{Name: "move-asks", Optional: true, Description: "Copy rows of the controller's record of asks at a desk " +
|
||
"into the vault's, as they are and once, from the controller alone. No value is in any of them.",
|
||
Input: map[string]any{"type": "object", "properties": map[string]any{"asks": map[string]any{
|
||
"type": "array", "items": map[string]any{"type": "object"},
|
||
"description": "the controller's rows: id, node, module, name, desk, asked_by, opened_at, ended_at, outcome"}}}},
|
||
{Name: "issue-account", Optional: true, Description: "Mint one bus account the grants part wants, now (novox/hq " +
|
||
"ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the " +
|
||
"controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.",
|
||
Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})},
|
||
{Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " +
|
||
"kind node-certificate signs the certificate of the calling machine's internal name for the serving key " +
|
||
"its record states, built from bus state alone and asked by that machine's node-engine only; kind " +
|
||
"enrolment signs an enrolment reply under its own context, for a caller the adoption record names. " +
|
||
"Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.",
|
||
Input: schema(map[string]string{"kind": "node-certificate or enrolment",
|
||
"node": "the machine, for a node certificate: the caller's own",
|
||
"reply": "the enrolment reply's bytes in base64, for an enrolment"}, []string{"kind"})},
|
||
}},
|
||
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
||
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
||
// Optional until the mesh-config that runs serves it (design 33 §7).
|
||
{Name: "mesh-config", Scope: catalogue.ScopeMesh, Decision: "novox/hq ADR 0348, ADR 0565",
|
||
Serves: []catalogue.Verb{
|
||
{Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " +
|
||
"mesh config or node config for a predefined key, or a module's own value on one node, each set " +
|
||
"with why, validated, and naming each module on each node whose value moves.",
|
||
Input: configInput()},
|
||
}},
|
||
// The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which
|
||
// domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect,
|
||
// which it and each machine's provisioner client (for its own machine's keys) alone write. Its one verb reads
|
||
// them. Optional until the mesh-provisioner that runs serves it (design 33 §7).
|
||
{Name: "mesh-provisioner", Scope: catalogue.ScopeMesh, Decision: "novox/hq ADR 0349",
|
||
Serves: []catalogue.Verb{
|
||
{Name: "provisioning", Optional: true, Description: "Read what the provisioner keeps: the registry " +
|
||
"(which domain answers which kind of placeholder), the needs each module on each node recorded, what " +
|
||
"is applied where, or the items of every collection, narrowed by node and module. Reads only.",
|
||
Input: schema(map[string]string{"what": "registry (when not given), needs, applied or collect",
|
||
"node": "a node", "module": "a module: for collect, a contributor", "collection": "for collect: a collection"}, nil)},
|
||
}},
|
||
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
|
||
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
|
||
// images and archives, by digest — which is why the provision is the artifact store and not an
|
||
// image registry.
|
||
{Name: "mesh-artifact-store", Scope: catalogue.ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
|
||
{Name: "mesh-catalog", Scope: catalogue.ScopeMesh, Decision: "novox/hq ADR 0121"},
|
||
// The delivery's owner (novox/hq ADR 0239): one commit's journey from its pull request's head to every
|
||
// machine, and a group of them, recorded and ordered by its holder, which asks the controller for each act.
|
||
// What it says it says as its module (`mesh-delivery.transition`), through the runtime that launches it.
|
||
{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh, Serves: deliveryVerbs(), Decision: "novox/hq ADR 0239"},
|
||
// Placement (novox/hq ADR 0567): which module runs on which node, state on the bus held by the assigner, changed only by
|
||
// a proposal the nodes answer and the operator approves. Its states and events are the holder module's.
|
||
{Name: catalogue.PlacementSeat, Scope: catalogue.ScopeMesh, Serves: placementVerbs(), Decision: "novox/hq ADR 0567"},
|
||
// The delivery's checker (novox/hq ADR 0547 §10, issue 774): the try is its verb, not the controller's.
|
||
{Name: catalogue.CheckerSeat, Scope: catalogue.ScopeMesh, Serves: checkerVerbs(), Decision: "novox/hq ADR 0547"},
|
||
// The mesh's health, out of the controller (novox/hq ADR 0564 clause 1, health_seats.go): the condition keeper, the
|
||
// watchman's watchdogs, the self-check and the healers.
|
||
{Name: catalogue.ConditionsSeat, Scope: catalogue.ScopeMesh, Decision: healthDecision,
|
||
Emits: []string{"condition-raised", "condition-changed", "condition-cleared"}, Serves: conditionsVerbs()},
|
||
{Name: catalogue.WatchdogsSeat, Scope: catalogue.ScopeMesh, Decision: healthDecision, Serves: watchdogsVerbs()},
|
||
{Name: catalogue.SelfCheckSeat, Scope: catalogue.ScopeMesh, Decision: healthDecision, Emits: []string{"checked"}, Serves: selfCheckVerbs()},
|
||
{Name: catalogue.HealersSeat, Scope: catalogue.ScopeMesh, Decision: healthDecision, Emits: []string{"healer-acted"}, Serves: healersVerbs()},
|
||
// The control records, out of the controller (novox/hq ADR 0565 clause 1, control_records_seats.go): a person's
|
||
// policy and the bus step at the assigner, and the hand-act log.
|
||
{Name: catalogue.WantsSeat, Scope: catalogue.ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()},
|
||
{Name: catalogue.HandActsSeat, Scope: catalogue.ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()},
|
||
// The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go).
|
||
{Name: catalogue.ArtifactSweepSeat, Scope: catalogue.ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"},
|
||
Serves: artifactSweepVerbs()},
|
||
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
|
||
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
|
||
// They keep their names until that migration is done deliberately, apart from the node-* pass.
|
||
{Name: "npm-package-registry", Scope: catalogue.ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"},
|
||
{Name: "git", Scope: catalogue.ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"},
|
||
// **A build node's work, one seat per build language** (novox/hq ADR 0525 §2, §10; ADR 0536 §4; issue 1887). The
|
||
// build module of each language claims its seat on every node it runs on; the builder and the checker put their
|
||
// asks on the seat's accepted kinds, which the broker derives as one work queue with a shared worker per kind, and
|
||
// each holder pulls delivery first, then check, then recheck, at most its node's build capacity at once. Its verbs
|
||
// say what the node runs and pause or resume it there: every holder pulls from the same worker, so a node's pause is
|
||
// its holder taking nothing, kept on the module's own pause key (issue 1887's design limit 2). They replace
|
||
// node-build-agent, retired with build-agent (ADR 0524 §5).
|
||
{Name: "node-build-go", Scope: catalogue.ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Go"),
|
||
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
|
||
{Name: "node-build-node", Scope: catalogue.ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Node"),
|
||
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
|
||
{Name: "node-build-image", Scope: catalogue.ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("image"),
|
||
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
|
||
// **The mesh's resolvers** (novox/hq ADR 0194, 0196, 0223): every node's internal domain, and
|
||
// every node and container asks them and nothing else. Replicated since ADR 0223: held on more
|
||
// than one machine, each answering the same names from the same roster, and every machine's
|
||
// resolver file lists every holder — its own first — and no public resolver, so whichever answers
|
||
// first gives the one answer. Delivers what a machine's resolver configuration requires, so that
|
||
// requirement resolves to a holder wherever they are placed.
|
||
{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Delivers: "wildcard-resolution", Replicated: true,
|
||
Decision: "novox/hq ADR 0194, ADR 0223"},
|
||
// **A machine's names are one module's** (novox/hq ADR 0199, ADR 0223): its holder writes
|
||
// /etc/hostname and the machine's own lines in /etc/hosts, and keeps every other line of the hosts
|
||
// file as the operator's, changed through these three verbs on that machine alone. The controller
|
||
// holds none of it. Named node-hosts-file until ADR 0223; the former name resolves to it as an
|
||
// alias on a mesh that knew it.
|
||
{Name: "node-hostname", Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0199, ADR 0223",
|
||
// The machine's names are the role's files, whatever program fills it (novox/hq ADR 0287).
|
||
Paths: []catalogue.SeatPath{{Path: "/etc/hosts"}, {Path: "/etc/hostname"}},
|
||
Serves: []catalogue.Verb{
|
||
{Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " +
|
||
"the operator's, or the block of the module or tool that writes it.",
|
||
Input: schema(map[string]string{}, nil), Replaces: []string{"cat /etc/hosts", "getent hosts"}},
|
||
{Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " +
|
||
"/etc/hosts — a name for this machine's own programs, not the mesh's.",
|
||
Input: schema(map[string]string{"address": "the IPv4 or IPv6 address",
|
||
"names": "the names for it, separated by spaces"}, []string{"address", "names"}),
|
||
Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}},
|
||
{Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " +
|
||
"lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
|
||
Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"},
|
||
[]string{"name"}), Replaces: []string{"sed -i /etc/hosts"}},
|
||
}},
|
||
// The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list
|
||
// whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all
|
||
// four; the jails themselves are composed from the modules the machine runs (to-be 31).
|
||
{Name: "node-intrusion-prevention", Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0121",
|
||
Serves: []catalogue.Verb{
|
||
{Name: "status", Description: "Every jail on this machine with how many it is watching and " +
|
||
"holding now, and the totals since the jail started; one jail's detail when named.",
|
||
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client status"}},
|
||
{Name: "banned", Description: "Every address banned on this machine right now, with the jail " +
|
||
"that holds it and when the ban ends.",
|
||
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client banned"}},
|
||
{Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " +
|
||
"operator's act on the live ban list, which the mesh never writes itself.",
|
||
Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"}),
|
||
Replaces: []string{"fail2ban-client set banip"}},
|
||
{Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.",
|
||
Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"}),
|
||
Replaces: []string{"fail2ban-client unban", "fail2ban-client set unbanip"}},
|
||
}},
|
||
// The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever
|
||
// filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did
|
||
// not write. Every holder serves all three; what differs by filter is the holder's own tools.
|
||
{Name: "node-packet-filter", Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0121",
|
||
Serves: []catalogue.Verb{
|
||
{Name: "rules", Description: "The packet filter as this machine enforces it now: the nftables " +
|
||
"ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or " +
|
||
"chain when asked.",
|
||
Input: schema(map[string]string{"table": "one nftables table, as `family name` (optional)",
|
||
"chain": "one chain of that table (optional)"}, nil),
|
||
Replaces: []string{"nft list ruleset", "iptables -L", "iptables-save"}},
|
||
{Name: "reload", Description: "Load the mesh's own filter again from the file the mesh writes, " +
|
||
"and answer with the mesh's table as loaded.",
|
||
Input: schema(map[string]string{}, nil), Replaces: []string{"nft -f"}},
|
||
{Name: "remove", Description: "Remove one rule set the mesh did not write, named exactly as the " +
|
||
"host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain " +
|
||
"DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an " +
|
||
"active found firewall's chains. An operator's act, by name, never a flush.",
|
||
Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"}),
|
||
Replaces: []string{"nft delete", "iptables -X"}},
|
||
}},
|
||
// The machine's service manager (novox/hq ADR 0177). The host applies every declared unit,
|
||
// system or user scope; the holder answers questions and operator acts about them, each verb
|
||
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
|
||
// served by the node tools runtime (ADR 0175).
|
||
{Name: catalogue.ServiceManagerSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0177",
|
||
Serves: serviceManagerVerbs()},
|
||
// The machine's package manager (novox/hq ADR 0207). A module declaring a `package` depends on
|
||
// it being held on its node, as one declaring a `service` depends on node-service-manager: the
|
||
// mesh's word for "something on this machine answers for installing", where a capability only
|
||
// says the software is there. No verbs yet — the seat says who answers, and what may be asked
|
||
// of it is decided when someone needs to ask.
|
||
{Name: catalogue.PackageManagerSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0207"},
|
||
// The machine's container runtime (novox/hq ADR 0166, seeded now by ADR 0207): a module
|
||
// declaring a `container` depends on it being held on its node. Its verbs, and the host creating
|
||
// containers through its holder, wait for ADR 0166's acceptance — seeded without them so the
|
||
// dependency has a seat to name and the runtime's module has one to claim.
|
||
{Name: catalogue.ContainerRuntimeSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0166, ADR 0207"},
|
||
// The operator account's environment (novox/hq ADR 0203): one module per machine writes it, and
|
||
// every module contributes to it. No verbs — the seat says who places the environment's files,
|
||
// and their path is its protocol: a shell sources ~/.config/mesh/environment.sh without knowing
|
||
// which module wrote it.
|
||
{Name: catalogue.EnvironmentSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0203"},
|
||
// The provisioner client on every machine (novox/hq ADR 0349 clause 3, design 57 P5): a service of its own on a
|
||
// bus account of its own (mesh-controller internal/broker NodeProvisionerSeat, design 57 P3), which fills the
|
||
// machine's templates. It serves no verb an agent calls.
|
||
{Name: "node-provisioner", Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0349"},
|
||
// The login shell (novox/hq ADR 0204, replacing the module-declared `login-shell` of ADR 0176):
|
||
// the mesh's, so a second shell module claims the seat rather than declaring a second one, and
|
||
// the seat exists whether or not zsh's definition is registered. `execute` is the contract a caller
|
||
// may call where the machine serves it (optional: ADR 0268); the holder places every module's shell
|
||
// code in its slots.
|
||
{Name: catalogue.LoginShellSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0204",
|
||
Serves: loginShellVerbs()},
|
||
// A machine's power (novox/hq ADR 0211): its holder owns logind's power handling, places the
|
||
// code modules contribute for the power moments, and publishes the machine's power states as
|
||
// its events. Every machine has one — every machine boots and shuts down. No verbs yet.
|
||
{Name: catalogue.PowerSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0211"},
|
||
// The machine's hotkeys (novox/hq ADR 0212): the daemon that sees the keys the window manager
|
||
// does not — a laptop's vendor keys — run by one module per machine, which owns its
|
||
// configuration. Every other module with keys contributes trigger lines to it.
|
||
{Name: catalogue.HotkeysSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0212",
|
||
Receives: []catalogue.Receivable{{Kind: "trigger", Comment: "#"}}},
|
||
// The machine's message bus (novox/hq ADR 0215): its holder owns the D-Bus implementation and its
|
||
// system service, never restarts it live, and publishes curated events about it, never its traffic.
|
||
// It receives nothing yet: packages ship their own policies.
|
||
{Name: catalogue.MessageBusSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0215"},
|
||
// The machine's backups (novox/hq ADR 0214, to-be 43): its holder keeps nightly restore points of
|
||
// the data every module on the machine declares, on the machine, against mistakes rather than
|
||
// disasters. A module contributes `backup` lines — what to run to take a consistent copy, and
|
||
// which of its directories to keep.
|
||
{Name: catalogue.BackupSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0214",
|
||
Serves: backupVerbs(),
|
||
// `backup` is what to run and which paths to keep; `data` every item a module declares, with its
|
||
// class, for the holder to measure (novox/hq ADR 0233). Both derived from modules' data sections.
|
||
Receives: []catalogue.Receivable{{Kind: catalogue.BackupKindBackup, Comment: "#", Dirs: true}, {Kind: catalogue.BackupKindData, Comment: "#", Dirs: true}}},
|
||
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
||
// model change, not a rename, so it stays until that is built.
|
||
{Name: "the-private-network", Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0110"},
|
||
// The program that manages the machine's own network. It delivers nothing: its holder keeps the
|
||
// manager and the mesh from contradicting each other — the private network's interface left
|
||
// alone — and writes the machine's resolver file itself, because the manager is what would
|
||
// otherwise rewrite it (novox/hq ADR 0223, which retired node-resolver-config into this seat).
|
||
// It never declares a link, an address or a wireless network, because the link is the only
|
||
// channel a fix could arrive on. A seat
|
||
// rather than a condition in the resolver's module, so a machine running two managers is
|
||
// refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117).
|
||
//
|
||
// Its verbs say what the machine resolves through and over which links (novox/hq ADR 0241): the
|
||
// resolver file as it is and who wrote it, and every link with its addresses, routes and the resolvers
|
||
// the manager knows for it — the same for every holder, so a question about a machine's names is asked
|
||
// the same way whatever manages its network. Optional until both holders serve them.
|
||
//
|
||
// It owns the resolver file, stepping back for node-resolver where that is held (novox/hq ADR 0287,
|
||
// ADR 0247).
|
||
{Name: "node-uplink", Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs(),
|
||
Paths: []catalogue.SeatPath{{Path: catalogue.ResolverFile, StepsBackFor: catalogue.ResolverSeat}}},
|
||
// The machine's own resolver (novox/hq ADR 0247): held only where something requires `split-dns` — a
|
||
// VPN client whose domains must go to its own servers while every other name goes to the mesh's.
|
||
// Where it is held, its holder writes the resolver file and the uplink's holder steps back from it
|
||
// (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link.
|
||
{Name: catalogue.ResolverSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs(),
|
||
Paths: []catalogue.SeatPath{{Path: catalogue.ResolverFile}}},
|
||
// A machine's shares and the shares it mounts (novox/hq ADR 0263): the holder of node-nfs-server
|
||
// exports a machine's folders to the private network and provides each as `nfs-share`; the holder of
|
||
// node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go).
|
||
{Name: catalogue.NFSServerSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()},
|
||
{Name: catalogue.MountsSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()},
|
||
// A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds
|
||
// no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool.
|
||
{Name: catalogue.ReadsSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()},
|
||
// A node's files, read and managed (novox/hq ADR 0394, files.go): beside node-reads until mesh-files is assigned
|
||
// on every node, then node-reads goes (ADR 0394 §9).
|
||
{Name: catalogue.FilesSeat, Scope: catalogue.ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()},
|
||
// The node's engine's own verbs and asks (novox/hq ADR 0563 §1): held by the engine on every node, by no module.
|
||
engineSeat(),
|
||
},
|
||
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
|
||
graphicalSessionSeats()...)
|
||
|
||
// 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
|
||
}
|
||
|
||
// deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2
|
||
// on a merge (ADR 0539 clause 17).
|
||
func deliveryVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " +
|
||
"each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed " +
|
||
"by state, repository or group.",
|
||
Input: schema(map[string]string{"state": "one state, e.g. delivering or held",
|
||
"repository": "owner/repository", "group": "a group's id (its branch name)",
|
||
"all": "\"true\": the final ones of the last thirty days too"}, nil, "all")},
|
||
deliveryRemovedInR6("times"),
|
||
{Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " +
|
||
"machine receives, what is not an ordinary send), every transition with when and why, the machine " +
|
||
"steps of its walk, its group and its order.",
|
||
Input: schema(map[string]string{"id": "a delivery's id, or a group's"}, []string{"id"})},
|
||
{Name: "groups", Description: "Every delivery group: its members in order, why each pair is ordered " +
|
||
"(declared, built by, version skew, engine before controller, by name), its composed check and its " +
|
||
"state, derived from its members.",
|
||
Input: schema(map[string]string{"all": "\"true\": the groups that ended too"}, nil, "all")},
|
||
deliveryRemovedInR6("what-if"),
|
||
{Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " +
|
||
"of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, " +
|
||
"mesh/delivery, …) with its state, " +
|
||
"description and when it was set; the merge check's full verdict as the controller said it — each " +
|
||
"layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " +
|
||
"protection would let it merge, every required status being success.",
|
||
Input: schema(map[string]string{"repository": "owner/repository",
|
||
"number": "a pull request's number: its head is read",
|
||
"commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}),
|
||
// Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere.
|
||
Optional: true},
|
||
{Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " +
|
||
"each state's bound and what healer H2 may do once it has passed; and the machine steps' table.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
{Name: "stalled", Description: "Every delivery held past its state's bound, with the transition the table " +
|
||
"lets healer H2 take for it, or none.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
{Name: "recheck", Description: "Check a rejected or ready delivery again: it goes back to proposed and the " +
|
||
"controller is asked for its check. With why.",
|
||
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
||
[]string{"id", "why"})},
|
||
deliveryRemovedInR6("release"),
|
||
deliveryRemovedInR6("stop"),
|
||
{Name: "close", Description: "Healer H2's verb: take the one transition the table names for a delivery held " +
|
||
"past its bound. Refused for any other.",
|
||
Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"},
|
||
[]string{"id", "why"})},
|
||
deliveryRemovedInR6("retire-history"),
|
||
{Name: "settle", Description: "Ask the operator to settle deliveries held because their merge reached the " +
|
||
"trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. " +
|
||
"The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines " +
|
||
"is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another " +
|
||
"reason, naming why.",
|
||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"},
|
||
"properties": map[string]any{
|
||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1,
|
||
"description": "the held deliveries' ids (owner/repository@commit)"},
|
||
"why": map[string]any{"type": "string", "description": "why they may be settled, shown to the operator"},
|
||
}},
|
||
// Added after the seat's release 6 holder shipped: optional until the gate serves it everywhere.
|
||
Optional: true},
|
||
}
|
||
}
|
||
|
||
// placementVerbs are the seat's verbs: what reads all it keeps and does by hand all it does by itself (ADR 0567 §10).
|
||
// Optional while the holder that serves them is not yet on every node (design 33 §7): the seat is new in the same
|
||
// delivery as its holder.
|
||
func placementVerbs() []catalogue.Verb {
|
||
changes := map[string]any{"type": "array", "minItems": 1, "description": "the changes, each an object with `op`: " +
|
||
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
|
||
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
|
||
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
|
||
"layer, mesh-wide-values, values)",
|
||
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
|
||
return []catalogue.Verb{
|
||
{Name: catalogue.PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
|
||
"module is placed on, or every key, each with its revision, and how full the state is against its cap. With " +
|
||
"history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the " +
|
||
"proposal's changes from its placement-changed event.",
|
||
Input: schema(map[string]string{"node": "one node", "module": "one module: the nodes it is placed on",
|
||
"history": "\"true\": the changes in order, from the stream and the state's revisions",
|
||
// ADR 0571 clause 4: who holds which seat on which node, from the state seat-holders.
|
||
"seats": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's"},
|
||
nil, "history", "seats")},
|
||
{Name: catalogue.PlacementVerbProposals, Optional: true, Description: "Every open proposal of placement: its changes, " +
|
||
"the revision of each key it was laid over, its candidates, every node's answer, the differences computed and " +
|
||
"answered, its verdict and its ask. Narrowed to one by id.",
|
||
Input: schema(map[string]string{"id": "one proposal's id"}, nil)},
|
||
{Name: catalogue.PlacementVerbPropose, Optional: true, Description: "Propose a change of placement, with why: laid over " +
|
||
"the current keys and refused at once, naming every finding, when the result cannot be placed (two modules " +
|
||
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
|
||
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
|
||
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
|
||
"caller, as the runtime proves it.",
|
||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
|
||
"properties": map[string]any{
|
||
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
|
||
"changes": changes,
|
||
}}},
|
||
{Name: catalogue.PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
|
||
"candidates are deleted and placement-withdrawn carries it whole. With why.",
|
||
Input: schema(map[string]string{"id": "the proposal's id", "why": "why, kept in the event"}, []string{"id", "why"})},
|
||
{Name: catalogue.PlacementVerbAgain, Optional: true, Description: "Judge an open proposal now with the answers that " +
|
||
"stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. " +
|
||
"Nothing more: the recovery of a proposal whose judgement or ask was lost.",
|
||
Input: schema(map[string]string{"id": "the proposal's id"}, []string{"id"})},
|
||
}
|
||
}
|
||
|
||
// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them
|
||
// everywhere.
|
||
func checkerVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " +
|
||
"judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " +
|
||
"levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " +
|
||
"reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " +
|
||
"4 wait or run at once, mesh-wide.",
|
||
Input: schema(map[string]string{"repository": "<owner>/<name>",
|
||
"head": "the full commit to try, 40 or 64 hex digits",
|
||
"trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent",
|
||
"tests": "a go test -run pattern: only those tests run, and nothing is kept",
|
||
"level": "unit or integration: only this level; both when absent"},
|
||
[]string{"repository", "head"}),
|
||
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
|
||
{Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " +
|
||
"(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " +
|
||
"it was merged onto, the time it took and what failed first. Read-only.",
|
||
Input: schema(map[string]string{"id": "the try's id, try-<digits>"}, []string{"id"})},
|
||
{Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " +
|
||
"what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " +
|
||
"id. A try that ended already is refused.",
|
||
Input: schema(map[string]string{"id": "the try's id, try-<digits>", "why": "why it is ended, in words"},
|
||
[]string{"id", "why"})},
|
||
}
|
||
}
|
||
|
||
const healthDecision = "novox/hq ADR 0564"
|
||
|
||
// conditionsVerbs are the condition keeper's verbs (ADR 0564 clauses 3, 9, 12).
|
||
func conditionsVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " +
|
||
"raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " +
|
||
"the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " +
|
||
"lately.",
|
||
Input: schema(map[string]string{
|
||
"key": "a condition's key: that one whole; with history, only its transitions",
|
||
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module",
|
||
"severity": "only urgent, or only warning",
|
||
"machine": "only those about this machine",
|
||
"history": "\"true\": every raising, change, silence and clearing lately, oldest first",
|
||
"days": "with history: how many days back (default 7, at most 90)",
|
||
}, nil, "history")},
|
||
{Name: "silence", Description: "Stop one condition's messages for a while, a hand act with why: it stays open and in " +
|
||
"status, and the silence is kept as its transition in history.",
|
||
Input: schema(map[string]string{
|
||
"key": "the condition's key",
|
||
"for": "how long: 30m, 4h, 2d; at most 7d",
|
||
"why": "why, required and recorded",
|
||
"cause": "the cause in a word (the condition's kind when absent)",
|
||
}, []string{"key", "for", "why"})},
|
||
{Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " +
|
||
"conditions: the recovery of a keeper that missed a change.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
}
|
||
}
|
||
|
||
// watchdogsVerbs are the watchman's verbs (ADR 0564 clauses 4, 5, 12).
|
||
func watchdogsVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "signals", Description: "Every watchdog of the signals table the watchman keeps, each a key that expires unless " +
|
||
"its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its " +
|
||
"expiry wrote.",
|
||
Input: schema(map[string]string{"row": "one row, e.g. S1"}, nil)},
|
||
{Name: "stuck", Description: "Every want, build or step the watchman says stopped moving past its bound, with since " +
|
||
"when and the words of its finding.",
|
||
Input: schema(map[string]string{"machine": "only those on this machine"}, nil)},
|
||
{Name: "presence", Description: "Every machine and tool runner the watchman sees connected, with its last word, and " +
|
||
"those it says silent; a machine that said it sleeps is shown as sleeping, not silent.",
|
||
Input: schema(map[string]string{"machine": "one machine"}, nil)},
|
||
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " +
|
||
"consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " +
|
||
"whole. With deliver or drop, a hand act with why.",
|
||
Input: schema(map[string]string{
|
||
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
|
||
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
|
||
"limit": "how many to list, newest first (default 50); only when listing",
|
||
"deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)",
|
||
"drop": "a dead letter's id: drop it for good (needs why)",
|
||
"why": "with deliver or drop: why, required and recorded",
|
||
"cause": "with deliver or drop: the cause in a word (dead-letter when absent)",
|
||
}, nil)},
|
||
{Name: "resync", Description: "Rebuild presence from the bus's connections, and the pulses and findings from the keys " +
|
||
"the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
}
|
||
}
|
||
|
||
// selfCheckVerbs are the self-check's verbs (ADR 0564 clause 6).
|
||
func selfCheckVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "doctor", Description: "The self-check: the last run's verdict at once, each probe passed, failed, deferred " +
|
||
"(its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one " +
|
||
"probe's verdict whole. Runs every five minutes on its own; each failure is a finding.",
|
||
Input: schema(map[string]string{
|
||
"run": "\"true\": run every probe now and answer the verdict",
|
||
"probe": "one probe's id (D6): its verdict alone, with every finding",
|
||
}, nil, "run")},
|
||
{Name: "probes", Description: "The registry: what each probe asserts, where it reads its facts, and the finding it writes.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
}
|
||
}
|
||
|
||
// healersVerbs are the healers' verbs (ADR 0564 clauses 7, 8).
|
||
func healersVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " +
|
||
"budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.",
|
||
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
|
||
{Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.",
|
||
Input: schema(map[string]string{
|
||
"key": "the condition's key",
|
||
"why": "why now, kept with the act",
|
||
}, []string{"key", "why"})},
|
||
{Name: "drill", Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with " +
|
||
"what it tests. A drill is never a repair: no healer is wanted for it however often it is run.",
|
||
Input: schema(map[string]string{
|
||
"what": "what was done on purpose, in a line",
|
||
"why": "what the drill tests",
|
||
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
|
||
}, []string{"what", "why"})},
|
||
}
|
||
}
|
||
|
||
const controlRecordsDecision = "novox/hq ADR 0565"
|
||
|
||
// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
|
||
func wantsVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
|
||
"and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
|
||
"rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
|
||
"with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.",
|
||
Input: schema(map[string]string{
|
||
"module": "one module",
|
||
"policy": "with module: roll, together, record, or default to take a person's choice back",
|
||
"why": "with policy: why, required and kept",
|
||
"compare": "\"true\": each node's own composition, by its digest, and the assigner's findings",
|
||
}, nil, "compare")},
|
||
{Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
|
||
"indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
|
||
"the step itself is the put on the operator's warrant.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
}
|
||
}
|
||
|
||
// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them.
|
||
func handActsVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " +
|
||
"(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " +
|
||
"runtime proves, never said by the caller. Said as act-recorded.",
|
||
Input: schema(map[string]string{
|
||
"what": "what was done, in a line",
|
||
"why": "why it had to be done by hand",
|
||
"cause": "the cause in a word, or a condition's kind",
|
||
"condition": "the key of the condition it addressed, if any (optional)",
|
||
"verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
|
||
}, []string{"what", "why"})},
|
||
{Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " +
|
||
"cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
|
||
Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause",
|
||
"who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")},
|
||
{Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " +
|
||
"with which proofs and which answer read from the router's own record of that module's ask, never from the " +
|
||
"caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.",
|
||
Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})},
|
||
}
|
||
}
|
||
|
||
// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
|
||
func artifactSweepVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
|
||
"repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
|
||
"newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
|
||
"unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
|
||
Input: schema(map[string]string{
|
||
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
|
||
"collected": "\"true\": list the collected references too (counted always)",
|
||
}, nil, "collected")},
|
||
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
|
||
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " +
|
||
"Reads only.",
|
||
Input: listed(schema(map[string]string{
|
||
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
|
||
"or one text separated by commas",
|
||
}, []string{"references"}), "references")},
|
||
{Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " +
|
||
"dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
|
||
"then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
|
||
"names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
|
||
"seconds; what is left is said. Never touches a digest the mesh did not make.",
|
||
Input: schema(map[string]string{
|
||
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
|
||
"why": "with confirm: why, required and recorded",
|
||
"most": "let go of at most this many (500 by default, at most 5000)",
|
||
}, nil, "confirm")},
|
||
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
|
||
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
|
||
"it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
{Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
|
||
"naming it: what the machine keeps when it prunes its images. Reads only.",
|
||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||
}
|
||
}
|
||
|
||
// buildAskKinds are a build seat's accepted kinds, in the order a holder pulls them (ADR 0536 §4; mesh-sdk
|
||
// delivery.AskKinds).
|
||
func buildAskKinds() []string { return []string{"delivery", "check", "recheck"} }
|
||
|
||
// buildSeatVerbs are what a build module answers on its own node for its seat (ADR 0525 §10).
|
||
func buildSeatVerbs(language string) []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "current", Description: "The " + language + " asks this node runs now (each ask's id, kind, module and when it " +
|
||
"started), its build capacity, and whether it is paused, taking no new ask.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
{Name: "pause", Description: "Take no new " + language + " ask on this node until resumed; an ask running here " +
|
||
"ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the " +
|
||
"lasting way to keep a node from building.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
{Name: "resume", Description: "Take " + language + " asks again on this node.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
}
|
||
}
|
||
|
||
// serviceManagerVerbs is the contract every holder of node-service-manager serves (novox/hq ADR
|
||
// 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an
|
||
// optional scope — "system" when absent, "user" for the operator account's own manager — so a
|
||
// caller asks for a user unit the way it asks for a system one; `failed` alone reads both managers
|
||
// when none is named. `failed`, `reset-failed`, `wanted-by` and `unlink-dangling` are optional
|
||
// (Verb.Optional) until every holder serves them (ADR 0246).
|
||
func serviceManagerVerbs() []catalogue.Verb {
|
||
scoped := func(more map[string]string, required []string) map[string]any {
|
||
props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"}
|
||
for k, v := range more {
|
||
props[k] = v
|
||
}
|
||
return schema(props, required)
|
||
}
|
||
unit := map[string]string{"unit": "the unit's name, as the service manager knows it"}
|
||
return []catalogue.Verb{
|
||
{Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.",
|
||
Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil), Replaces: []string{"systemctl list-units"}},
|
||
{Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.",
|
||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl status", "systemctl is-active"}},
|
||
{Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.",
|
||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl start"}},
|
||
{Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.",
|
||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl stop"}},
|
||
{Name: "restart", Description: "Restart one unit.",
|
||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl restart"}},
|
||
{Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).",
|
||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl enable"}},
|
||
{Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).",
|
||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl disable"}},
|
||
// **A window, not only a tail** (the operator's direction 2026-10-07): an incident is read for the
|
||
// minutes it happened in, and with no window on the verb a person reached for a shell. Every
|
||
// argument is the holder's to validate — passed to journalctl as one word of its own, never through
|
||
// a shell — and what the unit printed of a secret is redacted before it is answered.
|
||
{Name: "journal", Description: "The last lines of one unit's journal (at most 2000), in a time window and " +
|
||
"narrowed to a priority and to lines holding a text when asked. A secret the unit printed is shown as " +
|
||
"[redacted: <what it was>].",
|
||
Input: scoped(map[string]string{
|
||
"unit": unit["unit"],
|
||
"lines": "how many lines from the end of what matches (default 100, at most 2000)",
|
||
"since": "the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)",
|
||
"until": "the window's end, in the same forms (optional; now when absent)",
|
||
"match": "only the lines holding this text, as written — a fixed string, not a pattern (optional)",
|
||
"priority": "only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)",
|
||
}, []string{"unit"}),
|
||
Replaces: []string{"journalctl"}},
|
||
// What has failed, on the seat rather than as one holder's own tool: whatever holds the role answers
|
||
// it, so a caller asks every machine the same way. **Optional while its holders catch up**: the
|
||
// systemd module running today serves it as its own systemd_failed, and a required verb would
|
||
// refuse it before the version serving `failed` could be delivered.
|
||
{Name: "failed", Optional: true, Description: "Every failed unit on this machine, in the system manager and in the operator " +
|
||
"account's; a manager that does not answer is reported with its error, never as nothing failed.",
|
||
Input: schema(map[string]string{"scope": "\"system\" or \"user\": only that manager (both when absent)"}, nil),
|
||
Replaces: []string{"systemctl --failed"}},
|
||
// **Clearing a failed record, and finding what starts a unit** (novox/hq issue 332): a unit whose
|
||
// file is gone stays failed in its manager until the record is reset, and what keeps asking for it
|
||
// is a dependency or an enable link no verb could show. Required since every holder serves them
|
||
// (ADR 0246 step 3).
|
||
{Name: "reset-failed", Description: "Clear one unit's failed record in its manager — " +
|
||
"its failed state and its start-limit count — and answer its state after. It removes no file and " +
|
||
"starts nothing.",
|
||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl reset-failed"}},
|
||
{Name: "wanted-by", Description: "What wants, requires or triggers one unit, read-only: the " +
|
||
"manager's reverse dependencies (WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest) and " +
|
||
"every enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds), " +
|
||
"each with where it points and whether it dangles — the account's own directory included in user scope.",
|
||
Input: scoped(unit, []string{"unit"}),
|
||
Replaces: []string{"systemctl list-dependencies --reverse", "systemctl show -p WantedBy",
|
||
"ls ~/.config/systemd/user/*.wants"}},
|
||
// **A dangling enable link removed on the person's word** (novox/hq issue 332): `systemctl disable`
|
||
// leaves a link whose unit file is gone, so the manager keeps asking for the unit. Required since
|
||
// every holder serves it (ADR 0246 step 3).
|
||
{Name: "unlink-dangling", Description: "Remove the dangling enable links named for one " +
|
||
"unit — links in a *.wants, *.requires or *.upholds directory of the account's or the machine's own " +
|
||
"configuration whose target does not exist, which `disable` leaves once the unit's file is gone — on " +
|
||
"the person's word: `why` is required. Each is recorded (its path, its target, the reason) before it is " +
|
||
"removed, and the manager reloaded; a link whose target exists, or one a package, a generator or the " +
|
||
"runtime placed, is left and said.",
|
||
Input: scoped(map[string]string{"unit": unit["unit"],
|
||
"why": "the person's reason for removing the links, kept in the record"}, []string{"unit", "why"}),
|
||
Replaces: []string{"rm ~/.config/systemd/user/*.wants/<unit>", "unlink /etc/systemd/system/*.wants/<unit>"}},
|
||
}
|
||
}
|
||
|
||
// loginShellVerbs is the contract of node-login-shell (novox/hq ADR 0176, ADR 0204): one command, run
|
||
// the way the operator's own terminal would run it, bounded below the runtime's thirty-second call limit
|
||
// so a hung command answers rather than times the caller out.
|
||
//
|
||
// **`execute` is optional** (novox/hq ADR 0268). It runs any command as the operator account, which can
|
||
// become root without a person, so a machine may withhold it: the control-node does, through the
|
||
// holder's own `execute` setting, until a call to it needs a person's approval (hq research 039). The mark
|
||
// is ADR 0246's: a holder that does not serve the verb on a machine still holds the seat there, and its
|
||
// silence on the bus is not judged — a holder withholding it serves nothing on the seat.
|
||
func loginShellVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "execute", Optional: true, Description: "Run one command on this machine as the operator account, in a " +
|
||
"non-interactive login shell in its home; answers with what it printed and how it exited.",
|
||
Input: schema(map[string]string{
|
||
"command": "the command line, as you would type it",
|
||
"timeout_seconds": "give up after this long, at most 25 (default 20)",
|
||
}, []string{"command"})},
|
||
}
|
||
}
|
||
|
||
// backupVerbs is the node-backup seat's protocol (novox/hq to-be 43): what is kept, take one now,
|
||
// and restore beside the live data — never over it.
|
||
func backupVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "backed-up", Description: "What this machine backs up: each module, what it declared, " +
|
||
"its last good night, how many restore points are kept and the repository's size.",
|
||
Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic snapshots"}},
|
||
{Name: "now", Description: "Take a backup now, of one module or of every module on this " +
|
||
"machine — before a migration, a retirement or anything else that could go wrong.",
|
||
Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic backup"}},
|
||
{Name: "restore", Description: "Restore one module's data from a restore point BESIDE the live " +
|
||
"data, never over it: each directory as <path>.restored-<date>. Swapping it in is a " +
|
||
"person's act. Lists the restore points when none is named.",
|
||
Input: schema(map[string]string{
|
||
"module": "the module",
|
||
"snapshot": "the restore point (from `backed-up`; the newest when omitted)",
|
||
"path": "one of the module's directories (all of them when omitted)",
|
||
}, []string{"module"}), Replaces: []string{"restic restore"}},
|
||
}
|
||
}
|
||
|
||
// uplinkVerbs is the contract every holder of node-uplink serves (novox/hq ADR 0241): read-only answers
|
||
// about what the machine resolves through and over which links, whatever program manages its network.
|
||
func uplinkVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "resolvers", Description: "The machine's resolver file as it is now: the resolvers, search " +
|
||
"domains and options it lists, whether it is the file the mesh declares, and when it is not, who " +
|
||
"wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link " +
|
||
"in its place, a known writer running.",
|
||
Input: schema(nil, nil), Optional: true},
|
||
{Name: "links", Description: "Every network link on the machine: its state, its addresses, whether " +
|
||
"the default route leaves through it, and the resolvers and search domains the network manager " +
|
||
"knows for it — a VPN's tunnel included.",
|
||
Input: schema(nil, nil), Optional: true},
|
||
}
|
||
}
|
||
|
||
// resolverVerbs is the contract every holder of node-resolver serves (novox/hq ADR 0247): what is routed
|
||
// where, route a set of domains, and take a route away. The same verbs are served on the machine itself to
|
||
// the modules there, so what a VPN pushed never crosses the bus to be routed; on the mesh they are the
|
||
// operator's way to read and correct the same.
|
||
func resolverVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "routes", Description: "What this machine's own resolver sends where: the mesh's resolvers, " +
|
||
"which answer every name not routed elsewhere, and each link given servers of its own with the " +
|
||
"domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who " +
|
||
"wrote it as far as the file says, whether a module took it, and when the resolver's own file was " +
|
||
"put back.",
|
||
Input: schema(map[string]string{}, nil),
|
||
Replaces: []string{"resolvectl status", "resolvectl dns", "resolvectl domain"}},
|
||
{Name: "route", Description: "Send these domains, and every name under them, to these servers over " +
|
||
"this link — and only them: the link is never the machine's default route for names, and the " +
|
||
"mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away " +
|
||
"takes its route with it.",
|
||
Input: schema(map[string]string{
|
||
"link": "the network link the servers are reached over, by name (a VPN's tunnel interface)",
|
||
"domains": "the domains to route there, separated by spaces or commas",
|
||
"servers": "the servers' addresses, separated by spaces or commas",
|
||
}, []string{"link", "domains", "servers"}),
|
||
Replaces: []string{"resolvectl dns", "resolvectl domain", "resolvectl default-route"}},
|
||
{Name: "unroute", Description: "Take one link's route away: its domains go back to the mesh's " +
|
||
"resolvers. Nothing changes when the link has none.",
|
||
Input: schema(map[string]string{"link": "the network link, by name"}, []string{"link"}),
|
||
Replaces: []string{"resolvectl revert"}},
|
||
}
|
||
}
|
||
|
||
// nfsServerVerbs is the contract every holder of node-nfs-server serves (novox/hq ADR 0263).
|
||
func nfsServerVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " +
|
||
"read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " +
|
||
"node's private address and access (only the nodes the server grants it to and that ask for it), " +
|
||
"what is granted and not asked for or asked for and not granted, and whether the kernel holds it " +
|
||
"now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " +
|
||
"/etc/exports.",
|
||
Input: schema(map[string]string{}, nil),
|
||
Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}},
|
||
{Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " +
|
||
"knows its clients.",
|
||
Input: schema(map[string]string{}, nil),
|
||
Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}},
|
||
{Name: "test", Description: "Whether this machine exports one share for the mesh now, and whether its " +
|
||
"NFS service is up; with an address, also whether the share is exported to that address: a node's " +
|
||
"own private address, when the server grants it the share and the node asks for it. What a machine " +
|
||
"mounting the share asks before it mounts.",
|
||
Input: schema(map[string]string{
|
||
"share": "the share, by its name",
|
||
"address": "a node's private address, to ask whether the share is exported to it (optional)",
|
||
}, []string{"share"}),
|
||
Replaces: []string{"showmount -e"}},
|
||
{Name: "reload", Description: "Have the kernel read this machine's export files again now (exportfs " +
|
||
"-ra) and answer the shares as it then holds them. The module's own process writes its export " +
|
||
"file; this is for after a change made outside it. Changes no shared path.",
|
||
Input: schema(map[string]string{}, nil),
|
||
Replaces: []string{"exportfs -ra"}},
|
||
{Name: "adopt", Description: "Take over an export a person made by hand: clear a dataset's sharenfs " +
|
||
"property for a path the mesh's own export now serves — only when that export is live. Without " +
|
||
"confirm, says what it would do and changes nothing.",
|
||
Input: schema(map[string]string{
|
||
"path": "the shared path whose hand-made export is taken over",
|
||
"confirm": "\"true\": change it (needs why); anything else is a dry run",
|
||
"why": "why, for the record",
|
||
}, []string{"path"}, "confirm"),
|
||
Replaces: []string{"zfs set sharenfs=off"}},
|
||
}
|
||
}
|
||
|
||
// mountsVerbs is the contract every holder of node-mounts serves (novox/hq ADR 0263).
|
||
func mountsVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "list", Description: "Every mount point on this machine: its fstab line, the mesh's mount and " +
|
||
"automount units for it, its state (armed, mounted, absent, unreachable, failed) and since when, " +
|
||
"and which settings asked for it. A password in a mount's options is never shown.",
|
||
Input: schema(map[string]string{}, nil),
|
||
Replaces: []string{"cat /etc/fstab", "findmnt", "systemctl list-units --type=mount"}},
|
||
{Name: "test", Description: "Whether one share's or occasional source's server answers from this " +
|
||
"machine now, without touching its mount point.",
|
||
Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}),
|
||
Replaces: []string{"showmount -e", "ping"}},
|
||
{Name: "mount", Description: "Mount one share or occasional source now, rather than at its first " +
|
||
"access.",
|
||
Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}),
|
||
Replaces: []string{"mount"}},
|
||
{Name: "unmount", Description: "Release one share or occasional source now; its automount stays " +
|
||
"armed, so the next access mounts it again.",
|
||
Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}),
|
||
Replaces: []string{"umount"}},
|
||
{Name: "adopt", Description: "Take over a mount a person wrote by hand: comment out the /etc/fstab " +
|
||
"line for a mount point the mesh's own units now serve, keeping a copy of the file — only when " +
|
||
"the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.",
|
||
Input: schema(map[string]string{
|
||
"mountpoint": "the mount point whose fstab line is taken over",
|
||
"confirm": "\"true\": change it (needs why); anything else is a dry run",
|
||
"why": "why, for the record",
|
||
}, []string{"mountpoint"}, "confirm"),
|
||
Replaces: []string{"sed -i /etc/fstab"}},
|
||
}
|
||
}
|
||
|
||
// readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the
|
||
// build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in
|
||
// every answer it cuts.
|
||
func readsVerbs() []catalogue.Verb {
|
||
return []catalogue.Verb{
|
||
{Name: "stat", Description: "Each component of one path or several, from / down: its type, owner and group " +
|
||
"(name and number), mode, whether it is a link and to what, and whether it is a mount point and of which " +
|
||
"file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which " +
|
||
"holds no secret: a component it may not reach says so. At most 200 paths in one call.",
|
||
Input: schema(map[string]string{
|
||
"paths": "one absolute path, or several separated by line breaks",
|
||
"follow": "\"true\": also say where the path leads once every link is followed",
|
||
}, []string{"paths"}, "follow"),
|
||
Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}},
|
||
{Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " +
|
||
"link is followed, and whether that exists.",
|
||
Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}),
|
||
Replaces: []string{"readlink", "readlink -f", "realpath"}},
|
||
{Name: "list", Description: "Every entry of one directory with its type, owner, group, mode, size and, for a " +
|
||
"link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept " +
|
||
"is refused by name.",
|
||
Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}),
|
||
Replaces: []string{"ls", "ls -la"}},
|
||
{Name: "read", Description: "One text file, or a part of it by line: at most 256 KiB in one answer " +
|
||
"(64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is " +
|
||
"kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its " +
|
||
"number said. /proc and /sys are read as far as the holder's account may.",
|
||
Input: schema(map[string]string{
|
||
"path": "an absolute path to a file",
|
||
"from": "the first line to answer, counted from 1 (optional)",
|
||
"lines": "how many lines to answer (optional)",
|
||
"bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)",
|
||
}, []string{"path"}),
|
||
Replaces: []string{"cat", "head", "tail", "less"}},
|
||
{Name: "search", Description: "Find a fixed string (never a pattern) in one file or under one directory: " +
|
||
"every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), " +
|
||
"8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and " +
|
||
"binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a " +
|
||
"secret is withheld. The answer says which bound it met.",
|
||
Input: schema(map[string]string{
|
||
"text": "the fixed string to find",
|
||
"path": "an absolute path to a file or a directory",
|
||
"depth": "how many directories deep, up to 8 (optional)",
|
||
"matches": "the most matches to answer, up to 500 (optional)",
|
||
"case": "\"false\": match whatever the case (optional)",
|
||
}, []string{"text", "path"}, "case"),
|
||
Replaces: []string{"grep", "grep -r", "grep -F"}},
|
||
{Name: "account", Description: "One account or group by name or number, as this node's account files say: " +
|
||
"an account's number, group, home, shell and every group it is in; a group's number and members. With " +
|
||
"neither, every account and group. Never a password field.",
|
||
Input: schema(map[string]string{
|
||
"name": "an account's name or number (optional)",
|
||
"group": "a group's name or number (optional)",
|
||
}, nil),
|
||
Replaces: []string{"getent passwd", "getent group", "id", "groups"}},
|
||
}
|
||
}
|
||
|
||
// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads,
|
||
// with their descriptions restated for the operator's account, and the writes.
|
||
func filesVerbs() []catalogue.Verb {
|
||
var verbs []catalogue.Verb
|
||
for _, v := range readsVerbs() {
|
||
v.Description = strings.Replace(v.Description,
|
||
"Read as the holder's own account, which holds no secret: a component it may not reach says so.",
|
||
"", 1)
|
||
v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "."
|
||
verbs = append(verbs, v)
|
||
}
|
||
write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) catalogue.Verb {
|
||
in["reason"] = "why the file is changed, for the audit trail"
|
||
in["override"] = overrideWord
|
||
return catalogue.Verb{Name: name, Description: description + " Refuses a path a module owns " +
|
||
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
|
||
Input: schema(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces}
|
||
}
|
||
return append(verbs,
|
||
write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.",
|
||
map[string]string{"path": "an absolute path", "content": "the text to write",
|
||
"mode": "the file's mode, such as 0644 (optional)"},
|
||
[]string{"path", "content"}, []string{"mode"}, "tee", ">"),
|
||
write("copy", "Copy a file or directory to a path on this node. A target on another node (<node>:/path) is "+
|
||
"refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+
|
||
"kept in the node's trash for 7 days.",
|
||
map[string]string{"from": "an absolute path", "to": "an absolute path on this node"},
|
||
[]string{"from", "to"}, nil, "cp", "cp -a", "ssh <node> cp"),
|
||
write("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+
|
||
"comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.",
|
||
map[string]string{"from": "an absolute path", "to": "an absolute path"},
|
||
[]string{"from", "to"}, nil, "mv"),
|
||
write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.",
|
||
map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"},
|
||
[]string{"path"}, []string{"recursive"}, "rm", "rm -r"),
|
||
write("mkdir", "Make a directory.",
|
||
map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"},
|
||
[]string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"),
|
||
write("chmod", "Change the mode of a file or directory.",
|
||
map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x",
|
||
"recursive": "\"true\": also everything under a directory"},
|
||
[]string{"path", "mode"}, []string{"recursive"}, "chmod"),
|
||
write("chown", "Change the owner and group of a file or directory.",
|
||
map[string]string{"path": "an absolute path", "owner": "an account's name or number",
|
||
"group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"},
|
||
[]string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"),
|
||
catalogue.Verb{Name: "upload", Description: "Send one file of this node into a bucket of the object store, over a short signed link " +
|
||
"and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. " +
|
||
"At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, " +
|
||
"before anything is sent. Read " + filesAsAccount + ".",
|
||
Input: schema(map[string]string{"path": "an absolute path to a file", "bucket": "the bucket the file goes into",
|
||
"key": "the object's key (optional; the file's SHA-256 when not given)"}, []string{"path", "bucket"})},
|
||
// download is a plain Verb, not through write(): write() types its optional arguments as true/false
|
||
// switches, which a hash and a reason are not (issue 1719).
|
||
catalogue.Verb{Name: "download", Description: "Write one object of a bucket to a file of this node, over a short signed link and " +
|
||
"never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to " +
|
||
"the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns " +
|
||
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
|
||
Input: schema(map[string]string{"bucket": "the bucket", "key": "the object's key", "path": "an absolute path",
|
||
"sha256": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)",
|
||
"reason": "why the file is changed, for the audit trail",
|
||
"override": overrideWord}, []string{"bucket", "key", "path", "reason"})},
|
||
catalogue.Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " +
|
||
"original path and when it went.",
|
||
Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}},
|
||
write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.",
|
||
map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"),
|
||
)
|
||
}
|
||
|
||
// engineSeat is the seat's row in the closed set.
|
||
func engineSeat() catalogue.Seat {
|
||
return catalogue.Seat{Name: catalogue.EngineSeat, Scope: catalogue.ScopeNode, Serves: catalogue.EngineVerbs(), Emits: append([]string(nil), catalogue.EngineEvents...),
|
||
HeldByTheEngine: true, Decision: "novox/hq ADR 0563"}
|
||
}
|
||
|
||
// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs
|
||
// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead,
|
||
// ADR 0255), the compositor and the secret
|
||
// service are roles a second holder competes for, and nothing has needed to ask them anything.
|
||
func graphicalSessionSeats() []catalogue.Seat {
|
||
const decided = "novox/hq ADR 0208"
|
||
return []catalogue.Seat{
|
||
{Name: catalogue.LoginManagerSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{
|
||
{Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " +
|
||
"one the operator account starts by default.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
}},
|
||
{Name: catalogue.DisplayServerSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{
|
||
{Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " +
|
||
"where it is placed; and the layout profile in force, if one matches.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
// Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6).
|
||
{Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " +
|
||
"identities: list them, save the current arrangement under a name, or apply one.",
|
||
Input: withEnum(schema(map[string]string{
|
||
"action": "list, save or apply",
|
||
"name": "the profile to save or apply (save and apply only)",
|
||
}, []string{"action"}), "action", "list", "save", "apply")},
|
||
}},
|
||
// It receives window-manager configuration lines from every other module (novox/hq ADR 0212):
|
||
// bindings, start-up commands, rules — placed by the session's holder, never written into
|
||
// its directory by the contributor.
|
||
{Name: catalogue.DisplaySessionSeat, Scope: catalogue.ScopeNode, Decision: decided,
|
||
Receives: []catalogue.Receivable{{Kind: "config", Comment: "#"}}, Serves: []catalogue.Verb{
|
||
{Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
{Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " +
|
||
"it is visible or focused.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
{Name: "windows", Description: "The session's windows: each one's title, class, workspace and " +
|
||
"whether it has focus; narrowed to one workspace when named.",
|
||
Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)},
|
||
}},
|
||
{Name: catalogue.TerminalEmulatorSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{
|
||
{Name: "open", Description: "Open a terminal window in the operator's session, running a command " +
|
||
"or the login shell, in a directory or the account's home.",
|
||
Input: schema(map[string]string{
|
||
"command": "what to run in it (optional; the login shell when absent)",
|
||
"directory": "where it starts (optional; the account's home when absent)",
|
||
}, nil)},
|
||
}},
|
||
{Name: catalogue.LauncherSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{
|
||
{Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " +
|
||
"one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.",
|
||
Input: map[string]any{"type": "object", "required": []string{"choices"},
|
||
"properties": map[string]any{
|
||
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||
"description": "the lines to choose between, in order"},
|
||
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
||
}}},
|
||
// A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer
|
||
// is sealed to the asker's key, so it is never plaintext on the bus or in any call's record.
|
||
// **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live.
|
||
{Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " +
|
||
"does not show what is typed, and answer it sealed to the key the asker gives — never in " +
|
||
"the clear — or cancelled when the prompt was dismissed or not answered in time.",
|
||
// By name, never by words (the review of 2026-10-09, M4): the holder writes the prompt from the
|
||
// module, the secret and the machine, and says the controller asks — the bus lets nobody else
|
||
// ask it (broker.ControllerOnly) — so no caller puts words of its own before the operator.
|
||
Input: schema(map[string]string{
|
||
"module": "the module whose own secret is asked for",
|
||
"secret": "the own secret's name",
|
||
"node": "the machine the module runs on",
|
||
"seal_to": "the asker's public sealing key: the answer is sealed to it",
|
||
"timeout_seconds": "give up after this long (optional)",
|
||
}, []string{"module", "secret", "node", "seal_to"})},
|
||
}},
|
||
{Name: catalogue.NotifierSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{
|
||
{Name: "send", Description: "Show the operator a notification.",
|
||
Input: withEnum(schema(map[string]string{
|
||
"title": "the notification's summary",
|
||
"body": "its text (optional)",
|
||
"urgency": "low, normal (the default) or critical",
|
||
}, []string{"title"}), "urgency", "low", "normal", "critical")},
|
||
{Name: "history", Description: "The notifications shown lately, newest first.",
|
||
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
||
}},
|
||
{Name: catalogue.LockScreenSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{
|
||
{Name: "lock", Description: "Lock the operator's session now.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
}},
|
||
{Name: catalogue.ClipboardSeat, Scope: catalogue.ScopeNode, Decision: decided, Serves: []catalogue.Verb{
|
||
{Name: "history", Description: "What the clipboard held lately, newest first.",
|
||
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
||
{Name: "copy", Description: "Put text on the operator's clipboard.",
|
||
Input: schema(map[string]string{"text": "the text"}, []string{"text"})},
|
||
}},
|
||
// It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which
|
||
// the holder renders in its own grammar. A module that knows something about the machine — the
|
||
// power module its battery — gives the block; the bar knows no machine's hardware.
|
||
{Name: catalogue.BarSeat, Scope: catalogue.ScopeNode, Decision: decided,
|
||
Receives: []catalogue.Receivable{{Kind: catalogue.BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}},
|
||
{Name: catalogue.CompositorSeat, Scope: catalogue.ScopeNode, Decision: decided},
|
||
{Name: catalogue.SecretServiceSeat, Scope: catalogue.ScopeNode, Decision: decided},
|
||
}
|
||
}
|
||
|
||
// deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat.
|
||
func deliveryRemovedInR6(name string) catalogue.Verb {
|
||
return catalogue.Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " +
|
||
catalogue.RemovedDeliveryVerbs[name], Input: schema(nil, nil), Optional: true}
|
||
}
|
||
|
||
// 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
|
||
}
|