Vendor mesh-sdk's catalogue, overlay, validator, declaration and the new delivery parts, copied from its main 14fd5cb6 (issue 1381)

The files go/mod vendor writes for the pin that follows in the next commit, copied on the forge from mesh-sdk at 14fd5cb68fd1 (each equals the blob there; the vendored tree excludes tests). Split from the pin because one commit cannot carry both a patch and copies.
This commit is contained in:
2026-10-20 08:19:32 +00:00
parent b280b9de10
commit f64e00e83c
90 changed files with 33281 additions and 49 deletions
@@ -0,0 +1,242 @@
package catalogue
import (
"fmt"
"sort"
"strconv"
"strings"
)
// What an adopted node is declared in place of a filter (novox/hq ADR 0100).
//
// On an adopted node the firewall found on the machine stays in force: the mesh loads no table
// that drops by default or holds an accept. What the mesh needs reachable is declared as
// openings, which the host converges through the found firewall in its own terms; and the mesh
// guards its own foundation ports itself, in a table that only refuses.
// AdoptionPrefix is the id prefix of what the mesh declares of its own on an adopted node. It is
// never a module's, so none of it is ever held as found.
const AdoptionPrefix = "adoption."
// Where an opening admits from, on the wire.
const (
OpeningFromEverywhere = "everywhere"
OpeningFromMesh = "mesh"
)
// The two paths a packet reaches a port by: received by the machine, or forwarded to a
// container that publishes it.
const (
PathIncoming = "incoming"
PathForwarded = "forwarded"
)
// Guard resources: the refusal-only table, the unit that loads it, and that unit running.
const (
GuardPath = "/etc/mesh/guard.nft"
GuardUnit = "mesh-guard.service"
// GuardUnitPath is where the unit is written.
GuardUnitPath = "/etc/systemd/system/" + GuardUnit
)
// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer
// raises the same three on an adopted genesis, so the first push finds them already there.
func GuardID() string { return AdoptionPrefix + "guard" }
func GuardUnitID() string { return AdoptionPrefix + "guard-unit" }
func GuardRunningID() string { return AdoptionPrefix + "guard-running" }
// GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has
// no filter module and may have no nft at all, and a table nothing can load guards nothing.
func GuardPackageID() string { return AdoptionPrefix + "guard-package" }
// GuardPackage is the package that carries nft.
const GuardPackage = "nftables"
// OpeningID is an opening's resource identity: its protocol, port and path say what it is.
func OpeningID(protocol string, port int, path string) string {
return fmt.Sprintf("%sopening-%s-%d-%s", AdoptionPrefix, protocol, port, path)
}
// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the
// filter it would load were the node converged, each from where that filter would admit it.
//
// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure —
// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine
// only opens nothing. `published` maps a machine port a container publishes to the container's
// port: a published port is forwarded, not received, so its opening names the forwarded path and
// the port the packet is forwarded to.
func Openings(rules []Rule, foundation []int, published map[string]map[int]int) []map[string]any {
type key struct {
protocol string
port int
}
from := map[key]string{}
var order []key
widen := func(k key, f string) {
was, seen := from[k]
if !seen {
order = append(order, k)
}
if !seen || was != OpeningFromEverywhere {
from[k] = f
}
}
for _, rule := range rules {
switch rule.From {
case FromEverywhere:
widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere)
case FromMesh:
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
case FromLocalNetwork:
// The private network's part only (novox/hq ADR 0340). An adopted node's found firewall
// stays, and it is what admits the machine's local network; an opening has no word for a
// local network, and widening it to everywhere would open the port to the internet.
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
}
}
for _, port := range foundation {
widen(key{"tcp", port}, OpeningFromEverywhere)
}
sort.Slice(order, func(a, b int) bool {
if order[a].port != order[b].port {
return order[a].port < order[b].port
}
return order[a].protocol < order[b].protocol
})
out := make([]map[string]any, 0, len(order))
for _, k := range order {
opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol,
"from": from[k]}
if to, forwarded := published[k.protocol][k.port]; forwarded {
opening["id"] = OpeningID(k.protocol, k.port, PathForwarded)
opening["path"] = PathForwarded
opening["to"] = to
} else {
opening["id"] = OpeningID(k.protocol, k.port, PathIncoming)
opening["path"] = PathIncoming
}
out = append(out, opening)
}
return out
}
// Published is every port the given containers publish on the machine, by protocol and machine
// port, mapped to the container's own port. A mapping bound to loopback is left out: nothing off
// the machine reaches it, forwarded or not.
func Published(resources []map[string]any) map[string]map[int]int {
out := map[string]map[int]int{}
for _, r := range resources {
if fmt.Sprint(r["type"]) != "container" {
continue
}
listed, _ := r["ports"].([]any)
for _, entry := range listed {
written := strings.TrimSpace(fmt.Sprint(entry))
protocol := "tcp"
if cut := strings.LastIndex(written, "/"); cut >= 0 {
protocol = written[cut+1:]
}
// Indexed from the end, so an IPv6 address's own colons never shift the ports.
outer, inner, address, ok := mapping(written)
if !ok {
continue
}
switch strings.Trim(address, "[]") {
case "127.0.0.1", "localhost", "::1":
continue
}
if out[protocol] == nil {
out[protocol] = map[int]int{}
}
out[protocol][outer] = inner
}
}
return out
}
// AsGuard renders the mesh's refusal-only table for the given machine ports.
//
// It passes everything by default and holds nothing but a refusal, so it cannot close anything
// the machine serves; and it is the mesh's own table, so the found firewall reloading does not
// touch it. It refuses only packets addressed to this machine, and the ports except from the
// machine itself — its loopback and the container runtime's own networks — and from the private
// network, known by the interface a packet arrives on and never by its source address. At
// prerouting, ahead of the runtime's destination translation, so it matches the port the packet
// was sent to; in the inet family, so both address families.
//
// **The machine's own interfaces are named, where the derived filter names address ranges.** The
// filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo,
// docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is
// named anything else (a podman or libvirt bridge, or a docker network created with a fixed name)
// would have its containers' traffic to a guarded port refused, which reads as the port being
// down. Names rather than addresses is deliberate: a source address can be claimed by whoever
// sends the packet, and this table exists to refuse what the found firewall never sees. Widening
// it means adding names here and in the installer's copy together, which the golden test holds to
// one text.
//
// The same text the installer raises on an adopted genesis; a test holds both to it.
func AsGuard(ports []int) string {
sorted := append([]int{}, ports...)
sort.Ints(sorted)
listed := make([]string, len(sorted))
for i, p := range sorted {
listed[i] = strconv.Itoa(p)
}
var b strings.Builder
b.WriteString("table inet mesh_guard {}\n")
b.WriteString("delete table inet mesh_guard\n")
b.WriteString("table inet mesh_guard {\n")
b.WriteString("\tchain prerouting {\n")
b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n")
// Only packets addressed to this machine: traffic it routes for others — a predecessor's hub,
// say — is never the guard's business (novox/hq ADR 0103).
fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+
"iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n",
strings.Join(listed, ", "))
b.WriteString("\t}\n")
b.WriteString("}\n")
return b.String()
}
// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never
// a flush, which would take the container runtime's rules and the found firewall with it.
func GuardUnitText() string {
return "[Unit]\n" +
"Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" +
// Early, before the network is up, and without the default dependencies that would
// order it after the network; stopped at shutdown like any unit.
"DefaultDependencies=no\n" +
"Wants=network-pre.target\n" +
"Before=network-pre.target shutdown.target\n" +
"Conflicts=shutdown.target\n" +
"\n" +
"[Service]\n" +
"Type=oneshot\n" +
"RemainAfterExit=yes\n" +
"ExecStart=nft -f " + GuardPath + "\n" +
"ExecReload=nft -f " + GuardPath + "\n" +
"ExecStop=nft delete table inet mesh_guard\n" +
"\n" +
"[Install]\n" +
"WantedBy=multi-user.target\n"
}
// GuardResources are the guard as four resources of the existing kinds: the tool that loads it,
// the table, the unit, and the unit running — reloaded when the table changes, so the new table
// replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and
// restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty
// set is not a table nft loads.
func GuardResources(ports []int) []map[string]any {
if len(ports) == 0 {
return nil
}
return []map[string]any{
{"id": GuardPackageID(), "type": "package", "package": GuardPackage},
{"id": GuardID(), "type": "file", "path": GuardPath, "content": AsGuard(ports),
"mode": "0644"},
{"id": GuardUnitID(), "type": "file", "path": GuardUnitPath, "content": GuardUnitText(),
"mode": "0644"},
{"id": GuardRunningID(), "type": "service", "unit": GuardUnit, "state": "running",
"boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}},
}
}
@@ -0,0 +1,145 @@
package catalogue
import (
"fmt"
"strings"
)
// What the mesh built, named by what it is rather than by where it was pushed.
//
// **An image is recorded by its digest and its path; the registry's address is a route to it**
// (novox/hq 04-ISSUES/102). A build used to be recorded as `<registry>:<port>/<module>/<artifact>@sha256:…`
// — the reference the builder pushed to, kept whole — and every declaration carried that literal.
// Move the registry's port, or the registry, and every fresh pull of a mesh image fails: a new
// node, a recreate after eviction. The digest is the identity; the address is the node's setting
// for the module that serves the artifact store, and it is read from there when a reference is
// composed, never written into a record.
//
// So a kept reference has a scheme of the mesh's own, named after the provision that answers it:
//
// artifact-store://<module>/<artifact>@sha256:<hex> an image
// artifact-store://<module>/<artifact>/blobs/sha256:<hex> an archive
//
// No runtime knows the scheme. That is the point of it being one: a reference that leaks to a
// machine uncomposed is refused by the runtime as malformed, in front of whoever sent it, rather
// than pulled from a public registry that happens to have a repository by that name — which is
// what an address-less `<module>/<artifact>@sha256:…` would be.
// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without
// the store's address.
const ArtifactStoreScheme = ArtifactStoreProvision + "://"
// Recorded is a reference as the mesh records it: the artifact store's address, if the builder wrote
// one, taken off.
//
// For a reference the builder announced — one it pushed to the store — which is the only kind
// this is called on. An image the builder named `<host>/<path>@sha256:…` is kept as its path; an
// archive it named `http://<host>/v2/<path>/blobs/<digest>` likewise. A reference with no address
// in it — an image id from a genesis build that had nowhere to publish, a package version — is
// what it was.
func Recorded(reference string) string {
if strings.HasPrefix(reference, ArtifactStoreScheme) {
return reference
}
if rest, isURL := strings.CutPrefix(reference, "http://"); isURL {
if _, path, ok := strings.Cut(rest, "/v2/"); ok && strings.Contains(path, "/blobs/") {
return ArtifactStoreScheme + path
}
return reference
}
host, path, ok := strings.Cut(reference, "/")
if !ok || !isRegistryHost(host) || !strings.Contains(path, "@sha256:") {
return reference
}
return ArtifactStoreScheme + path
}
// isRegistryHost is the runtime's own rule for reading the first component of a reference as a
// registry rather than as a namespace: it has a dot or a port in it, or it is localhost.
func isRegistryHost(component string) bool {
return component == "localhost" || strings.ContainsAny(component, ".:")
}
// InArtifactStore reports whether a reference is a kept one, and what it names there.
func InArtifactStore(reference string) (path string, kept bool) {
return strings.CutPrefix(reference, ArtifactStoreScheme)
}
// Routed is a kept reference as a machine fetches it, through the artifact store at `address`
// (host:port). A reference that is not a kept one is what it was.
func Routed(reference, address string) string {
path, kept := InArtifactStore(reference)
if !kept {
return reference
}
if strings.Contains(path, "/blobs/") {
return "http://" + address + "/v2/" + path
}
return address + "/" + path
}
// Rerouted is a reference the mesh recorded, whichever way it was recorded, as a machine fetches
// it now: a kept one composed with the store's address, and one recorded before references were
// kept without their address — the builder's own `<host>/<path>@sha256:…` — re-routed to where
// the store is now. Only for references that are the mesh's own: everything a build record
// holds is, by construction.
func Rerouted(reference, address string) string {
return Routed(Recorded(reference), address)
}
// artifactsInto composes the artifact store's address into a resource's `image` and `source`.
//
// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is
// routed through the store as this network reaches it now. A reference recorded with an address
// before references were kept without one is re-routed the same way — but only when the mesh
// built it (`with.Built` names every `<module>/<artifact>` it has), because a module may run an
// image from a public registry under its own name and that one is exactly where it says.
//
// A kept reference with no store to route it through is refused: sent as it is, the runtime would
// refuse the scheme on the machine, one push away from the reason.
//
// **What this does not reach: the images genesis pinned.** The installer builds the control plane
// and the builder before the mesh exists, pushes them itself and pins their manifests to
// `<registry>:<port>/mesh-controller@…` and `<registry>:<port>/mesh-builder@…` — single-segment
// repositories with no build record, so `with.Built` does not name them and they are left as
// written until each is rebuilt through the mesh, which records it by digest and path. Until then
// a registry that moves strands exactly those two on a recreate, and the control plane's is the
// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the
// store (novox/hq 04-ISSUES/102, finding F4).
func artifactsInto(resource map[string]any, module string, with Rendering) error {
for _, key := range []string{"image", "source"} {
written, ok := resource[key].(string)
if !ok {
continue
}
if _, kept := InArtifactStore(written); kept {
if with.ArtifactStore == "" {
return fmt.Errorf(
"%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+
"artifact store on its network to fetch it from — nothing assigned offers "+
"%s, or the machine offering it is not on the private network",
module, resource["id"], written, ArtifactStoreProvision)
}
resource[key] = Routed(written, with.ArtifactStore)
continue
}
if with.ArtifactStore == "" {
continue
}
recorded := Recorded(written)
if recorded == written {
continue
}
path, _ := InArtifactStore(recorded)
repository := path
if at := strings.IndexAny(path, "@"); at >= 0 {
repository = path[:at]
} else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 {
repository = path[:blobs]
}
if with.Built[repository] {
resource[key] = Routed(recorded, with.ArtifactStore)
}
}
return nil
}
@@ -0,0 +1,53 @@
package catalogue
import (
"fmt"
"regexp"
)
// What the machine's modules back up is placement (design 57 L3, issue 794). The holder of node-backup names it as
// `${backup:lines}` (what to run and which paths to keep) and `${backup:data}` (every item with its class and
// protection): the text `${contribution:node-backup:backup}` and `${contribution:node-backup:data}` render, from the
// same function, derived from each placed module's declared data (novox/hq ADR 0233). A handed holder has the text in
// its plan's placement for its client to fill; one not handed has the controller fill it where it stands.
// BackupNamespace is the placeholder kind of the machine's backup lines and data.
const BackupNamespace = "backup"
// ofBackup is where a holder places them: ${backup:<key>}. Loose inside the braces, so a misspelt key is found and
// refused rather than written out as text.
var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`)
// backupKinds is the contribution kind each key of the backup placement renders.
var backupKinds = map[string]string{"lines": BackupKindBackup, "data": BackupKindData}
// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are.
type backupPlacer struct {
modules []Manifest
with Rendering
facts map[string]string
caps map[string]bool
unplaced *[]string
}
// placed is the text of one key for a holder, or what is wrong with naming it.
func (b backupPlacer) placed(holder Manifest, key string, item bool) (string, string) {
kind, known := backupKinds[key]
switch {
case item:
return "", "an item is kept per item; the machine's backup lines are placed only in a file"
case !known:
return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key)
case !holder.ClaimsSeat(BackupSeat):
return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+
"(novox/hq ADR 0212)", holder.Module, BackupSeat)
}
text, left, err := seatContributions(b.modules, holder, BackupSeat+":"+kind, b.with, b.facts, b.caps)
if b.unplaced != nil {
*b.unplaced = append(*b.unplaced, left...)
}
if err != nil {
return "", err.Error()
}
return text, ""
}
+186
View File
@@ -0,0 +1,186 @@
package catalogue
import (
"fmt"
"slices"
"strings"
)
// A consumer of a provision that keeps its data stays bound where its data is (novox/hq ADR 0232).
//
// **What a resolution chooses is not where a consumer's data is.** Resolving answers "which provider
// would I pick now", from the seats' holders, the pins and what is assigned where, and every one of
// those can change under a consumer without anybody meaning to move it. For a resolver that is the
// point: any provider answers alike. For a database it is the consumer's whole state: on 2026-10-05
// one change to how a seat's holder answers (issue 258) re-bound five database consumers on one
// machine to the store on another, each was given a fresh, empty database there, and nothing warned
// for twenty hours (issue 273). Nothing was lost only because the old provider kept everything.
//
// So the mesh records where each such consumer was last sent (the store's `binding` table), and a
// resolution that would answer it from anywhere else keeps the recorded provider instead and says so.
// **Only a pin moves it**, because a pin is a person: the one act that says "answer this machine's
// consumers from there", taken knowing the data must go first.
// KeptBinding is one consumer this resolution would have moved, and did not.
type KeptBinding struct {
// Machine is where the consumer runs, and Consumer the module there that is bound.
Machine string
Consumer string
// Provision is what it is bound for.
Provision string
// Bound is the provider it was recorded at, and still is; Would is the one the resolution chose.
Bound Chosen
Would Chosen
}
// String is the condition's sentence: the move, where the data is, and the act that confirms it.
func (k KeptBinding) String() string {
return fmt.Sprintf("would move %s's %s from %s to %s — its data is on %s; kept there. "+
"`pin %s %s %s %s` to confirm a move (and move the data first)",
k.Consumer, k.Provision, k.Bound, k.Would, k.Bound, k.Machine, k.Provision, k.Would.Node, k.Would.Module)
}
// keepBound holds every need for a provision that keeps its consumers' data at the provider its
// consumer was bound to, where the resolution chose another.
//
// A need with no record is a binding being made, and is left as resolved: it is recorded when it is
// first sent. A pin naming the provider the resolution chose is a person moving it, and is left too.
// Otherwise the recorded provider answers, if it still provides the provision — beside the consumer,
// or offered from elsewhere — and the move is returned as kept. **One that no longer does is refused,
// never answered by the provider chosen**: answering it there is exactly the silent move this exists
// to stop, and the consumer's data is still wherever it was.
func keepBound(needs []Needed, catalogue map[string]Manifest, node Node, world World,
keeps map[string]bool, here func(string) bool) ([]Needed, []KeptBinding, []string) {
var kept []KeptBinding
var problems []string
refused := map[[2]string]bool{}
out := make([]Needed, 0, len(needs))
for _, n := range needs {
if n.ByRecord || !keeps[n.Name] {
out = append(out, n)
continue
}
n.KeepsData = true
bound, recorded := world.Bound[n.For][n.Name]
chose := Chosen{Node: n.From, Module: n.Module}
if !recorded || sameProvider(bound, chose) {
out = append(out, n)
continue
}
if pin, pinned := world.Pinned[n.Name]; pinned && pin.matches(Provider{Node: chose.Node, Module: chose.Module}) {
out = append(out, n)
continue
}
held, ok, why := boundNeed(n, bound, catalogue, node, world, here)
if !ok {
if key := [2]string{n.For, n.Name}; !refused[key] {
refused[key] = true
problems = append(problems, fmt.Sprintf(
"%s on %s is bound to %s for %q, which keeps its data, and %s — it would move to %s, "+
"which holds none of it. Move its data and say so with `pin %s %s %s %s`, or give "+
"%s back what it provided",
n.For, node.Name, bound, n.Name, why, chose, node.Name, n.Name, chose.Node, chose.Module,
bound.Node))
}
continue
}
out = append(out, held)
kept = append(kept, KeptBinding{Machine: node.Name, Consumer: n.For, Provision: n.Name, Bound: bound, Would: chose})
}
return out, kept, problems
}
// sameProvider is whether a recorded provider is the one chosen. A record naming no module (none
// is written without one, but a person's hand might) matches any module on its node.
func sameProvider(bound, chose Chosen) bool {
return bound.Node == chose.Node && (bound.Module == "" || bound.Module == chose.Module)
}
// boundNeed is the need answered by the recorded provider, or why it cannot be.
func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node, world World,
here func(string) bool) (Needed, bool, string) {
held := Needed{Name: n.Name, For: n.For, Local: n.Local, KeepsData: true}
if bound.Node == node.Name {
m, known := catalogue[bound.Module]
if !known || !here(bound.Module) || !providesAt(m, n.Name, ScopeMesh) {
return Needed{}, false, fmt.Sprintf("%s no longer runs on %s", bound.Module, node.Name)
}
at := node.At
if at == "" {
at = "127.0.0.1"
}
held.From, held.At, held.Module = node.Name, at, m.Module
held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name)
held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name)
return held, true, ""
}
matching := bound.among(world.Offered[n.Name])
if len(matching) != 1 {
return Needed{}, false, fmt.Sprintf("%s no longer provides it", bound)
}
p := matching[0]
if node.At == "" || p.At == "" {
return Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node)
}
identity := DefaultIdentityBound
if pm, known := catalogue[p.Module]; known {
held.SharedOwn, _ = pm.SharedCredentialOf(n.Name)
held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name)
identity = pm.IdentityBoundOf(n.Name)
}
held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity
held.LeftOut = p.LeftOut
return held, true, ""
}
// Unbound is one consumer that still asks for a provision and whose own resolution binds it to
// another provider than the one a pair credential on record was made with (novox/hq issue 274).
//
// **A provider is granted exactly the consumers bound to it.** The credential from the old provider
// stays on record — it is the key to a login that provider keeps, disabled, with the consumer's data,
// until a person deletes it with `cleanup delete` (ADR 0230), and a pin back must find it — but it is
// no longer granted, so the provider stops being asked for it and retires it. Said on every plan and
// push of the provider, so a credential the mesh keeps and does not use is never kept silently.
type Unbound struct {
// Provision is what was required, Provider the machine the credential on record is from.
Provision string `json:"provision"`
Provider string `json:"provider"`
// Consumer is the machine, Module the module on it that requires it, Local the credential's
// name inside it where it keeps several (ADR 0094).
Consumer string `json:"consumer"`
Module string `json:"module"`
Local string `json:"local,omitempty"`
// BoundTo is every provider the consumer's resolution binds this credential to now; empty when
// it binds it nowhere — the module asks for the provision under other local names.
BoundTo []string `json:"bound_to,omitempty"`
}
func (u Unbound) String() string {
who := u.Module
if u.Local != "" {
who += " (as " + u.Local + ")"
}
now := "is bound to no provider under that name"
if len(u.BoundTo) > 0 {
now = "is bound to " + strings.Join(u.BoundTo, ", ")
}
return fmt.Sprintf("%s on %s %s for %s, not to %s — %s no longer grants it, so it retires that "+
"login and keeps its data until `cleanup delete` (ADR 0230); the credential from %s stays on "+
"record while that login does", who, u.Consumer, now, u.Provision, u.Provider, u.Provider, u.Provider)
}
// BindsFrom is the providers this resolution binds a pair credential's need to — the provision, the
// module that requires it and the credential's local name — answered by a machine rather than by a
// record. None means this machine states no such binding.
func (r Resolution) BindsFrom(provision, module, local string) []string {
var from []string
for _, n := range r.Needs {
if n.ByRecord || n.Name != provision || n.For != module || n.Local != local {
continue
}
if !slices.Contains(from, n.From) {
from = append(from, n.From)
}
}
return from
}
@@ -0,0 +1,192 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// The half of a connection that is not secret, put where the program reading it can find it.
//
// **The asymmetry this removes was backwards** (novox/hq 04-ISSUES/023). A sealed credential can
// be placed inside any configuration file a module writes: the module leaves a hole, the mesh
// delivers the value sealed beside it, and the host — the only thing that sees both — fills it in.
// The host and the port and the name to present are ordinary facts the mesh holds in the clear,
// and they were the ones stuck: readable only inside a JSON binding, which a program reading
// `KEY=value` cannot use.
//
// So the same shape, and simpler. These values are not secret, so **the control plane substitutes
// them itself** before the declaration is sent. Nothing new reaches the host, which learns no
// formats and gains no fields.
//
// **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is:
// `at`, `as`, `from` and `address` (the providing machine's private address, novox/hq ADR 0194) are
// facts about any provision at all, and everything else comes from what the provider said it
// serves — whose keys are agreed by the requirement's name, not by this file.
// bound is where a module says a value from one of its bindings belongs:
// ${bound:<provision>:<key>}, and for an optional requirement the value it takes while absent,
// ${bound:<provision>:<key>|default:<value>} (novox/hq ADR 0330 §4). A default is never empty: an
// absent requirement is never rendered as an empty string.
var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`)
// knownFor is everything a module may name from one of its bindings.
//
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
// module may not reach a binding it does not have — the same boundary as a secret, for the same
// reason.
func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]string, error) {
out := map[string]map[string]string{}
for _, want := range m.Wants() {
for i := range needs {
n := needs[i]
if n.Name != want || n.For != m.Module {
continue
}
as := ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
values := map[string]string{
"at": n.At,
"from": n.From,
"as": as,
}
// What the provider derives for this consumer rather than for all of them
// (novox/hq ADR 0202). Filled here, the one place a provision and the module
// requiring it are both in hand.
served, err := ServedTo(n.Serves, as)
if err != nil {
return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
}
for key, value := range served {
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
// which is what a float would write and what a connection string would refuse.
values[key] = plainly(value)
}
out[want] = values
}
}
return out, nil
}
// withOwnNames adds a module's own composed names to what it may name from one binding:
// `${bound:<provision>:name}` and `:internal-name`, and for several contributions to one requirement
// `:name-<local>` / `:internal-name-<local>`. Set over anything the provider serves under those keys:
// what the module is called is the mesh's statement, not the provider's.
func withOwnNames(values map[string]string, own map[string]any) {
for _, key := range []string{"name", "internal-name"} {
if v, ok := own[key].(string); ok {
values[key] = v
}
}
many, _ := own["names"].(map[string]any)
for local, raw := range many {
names, _ := raw.(map[string]any)
for _, key := range []string{"name", "internal-name"} {
if v, ok := names[key].(string); ok {
values[key+"-"+local] = v
}
}
}
}
// plainly renders a served value as a program would expect to read it.
func plainly(value any) string {
switch v := value.(type) {
case string:
return v
case float64:
if v == float64(int64(v)) {
return fmt.Sprintf("%d", int64(v))
}
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".")
case bool:
return fmt.Sprintf("%t", v)
case nil:
return ""
default:
return fmt.Sprint(v)
}
}
// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows.
//
// A placeholder naming something the module does not require, or a key the provider does not
// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a
// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing
// somewhere that names neither the module nor the mesh.
//
// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR
// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses
// that already; this is the same rule where the file is made, for a manifest registered before it.
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
content, ok := resource["content"].(string)
if !ok {
return nil
}
var refused error
filled := bound.ReplaceAllStringFunc(content, func(token string) string {
if refused != nil {
return token
}
m := bound.FindStringSubmatch(token)
provision, key, fallback := m[1], m[2], m[3]
values, has := known[provision]
if !has {
if absent[provision] {
if fallback != "" {
return fallback
}
refused = fmt.Errorf(
"%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+
"value for then (${bound:%s:%s|default:<value>}), so it would be written empty "+
"(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key)
return token
}
refused = fmt.Errorf(
"%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s",
module, provision, key, module, provision, orNothing(namesOfBindings(known)))
return token
}
value, said := values[key]
if !said {
refused = fmt.Errorf(
"%s asks its %s binding for %q, and what answers it says %s",
module, provision, key, orNothing(namesOfKeys(values)))
return token
}
return value
})
if refused != nil {
return refused
}
resource["content"] = filled
return nil
}
func namesOfBindings(known map[string]map[string]string) []string {
var names []string
for name := range known {
names = append(names, fmt.Sprintf("%q", name))
}
sort.Strings(names)
return names
}
func namesOfKeys(values map[string]string) []string {
var names []string
for key := range values {
names = append(names, fmt.Sprintf("%q", key))
}
sort.Strings(names)
return names
}
func orNothing(names []string) string {
if len(names) == 0 {
return "nothing"
}
return join(names)
}
+651
View File
@@ -0,0 +1,651 @@
package catalogue
import (
"fmt"
"net"
"net/url"
"path"
"regexp"
"sort"
"strings"
)
// Turning a manifest that names artifacts into one that names digests.
//
// **Two documents, deliberately.** The manifest in a repository says *this resource uses the
// archive called `config`*; the manifest the mesh holds says *this resource is sha256:…*. A digest
// is not knowable until something is built, so a repository carrying one would be a repository
// whose file is wrong the moment anybody edits anything — and the mesh would be pinning a value
// nobody could have checked.
//
// So the built manifest is **derived**, and the record of which commit it was derived from is what
// makes "is this current?" answerable without building (novox/hq ADR 0009).
// Built is one artifact after it exists: where it is and what it hashes to.
type Built struct {
// Name is what the manifest called it.
Name string
// Kind is "image" or "archive".
Kind string
// Reference is what a machine uses to get it — an image reference for an image, a URL for an
// archive. Both already carry the digest for an image; an archive carries it separately.
Reference string
// Digest is "sha256:<hex>", for an archive. An image reference already ends in one.
Digest string
// Launchers are, for a bundle in an interpreted language, the executable the build wrote beside
// each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it.
Launchers map[string]string
// Texts are, for the archive a module packs its templates into (TemplatesArtifact), each file's text as the
// build read it back from the packed archive, by its path inside it (novox/hq ADR 0349 §2). Never carried on
// the wire or kept: Resolve reads them into the files' content, which is what the mesh holds.
Texts map[string]string `json:"-"`
}
// Resolve fills a manifest's resources in from what was built.
//
// Every resource naming an artifact is rewritten to name the thing itself, and the `artifact` key
// is removed — because it is a build-time word and the host has never heard of it. A resource
// naming an artifact nothing produced is refused: it would otherwise reach a machine with an
// empty image or an unpinned archive, which is the shape of failure that looks like success.
func (m Manifest) Resolve(built []Built) (Manifest, error) {
if m.Build == nil && len(built) == 0 {
return m, nil
}
by := map[string]Built{}
for _, b := range built {
by[b.Name] = b
}
// Declared and not produced is a build that did not do what the manifest asked, and saying so
// here beats a machine reporting it later.
var missing []string
if m.Build != nil {
for _, a := range m.Build.Artifacts {
if _, ok := by[a.Name]; !ok {
missing = append(missing, a.Name)
}
}
}
if len(missing) > 0 {
sort.Strings(missing)
return Manifest{}, fmt.Errorf(
"%s says it builds %s and the build did not produce %s — the build did not do what "+
"the manifest asked, which is a different fault from a resource asking for the "+
"wrong thing",
m.Module, strings.Join(missing, " and "), oneOrOther(len(missing)))
}
// **Each template's text, as the build read it back from its templates archive** (novox/hq ADR 0349 §2): into
// the file's content, so what the mesh holds and composes is what the same text inline would be.
var handed *Handed
if m.Templated() {
name := TemplatesArtifact(m)
texts := by[name].Texts
if name == "" || texts == nil {
return Manifest{}, fmt.Errorf("%s names templates, and the build carried no text read back from the "+
"archive it packs %s/ into (%q): the builder that built it does not read templates",
m.Module, m.TemplatesDir(), name)
}
dir := m.TemplatesDir()
filled, err := fillTemplates(m.Module, dir, m.Resources, func(template string) (string, bool) {
t, ok := texts[strings.TrimPrefix(template, dir+"/")]
return t, ok
}, fmt.Sprintf("the archive %q", name))
if err != nil {
return Manifest{}, err
}
items, err := fillItemTemplates(m, m.Items, func(template string) (string, bool) {
t, ok := texts[strings.TrimPrefix(template, dir+"/")]
return t, ok
}, fmt.Sprintf("the archive %q", name))
if err != nil {
return Manifest{}, err
}
// And, for a moved module, where its templates are and which one each file and item is filled from
// (handed.go): read while its resources and items still name their templates.
handed = handedBy(m, by[name])
m.Resources = filled
m.Items = items
}
out := m
out.Handed = handed
out.Build = nil
// Read with the build, like Build: the manifest the mesh holds carries each file's text (issue 731).
out.Templates = ""
out.Resources = nil
// What the build compiled, kept on the resolved manifest (novox/hq ADR 0175): a tools bundle is
// named by no resource of the module's own — the node's runtime loads it — so this is the only
// place the mesh would otherwise not have it. In artifact order, so two resolutions of one
// build compare equal.
out.Bundles = nil
if m.Build != nil {
run := runByAResource(m)
for _, a := range m.Build.Artifacts {
if a.Kind != ArtifactBundle {
continue
}
made := by[a.Name]
// What the runtime loads: what the artifact said, else every entrypoint of a module
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
//
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
// A process the host runs is the module's service, not its tools: the controller declares
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
// have been launched a second time by the node's runtime, as an MCP child it is not.
loads := append([]string(nil), a.Loads...)
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
loads = append([]string(nil), a.Entrypoints...)
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
if bin := BinaryOf(a); bin != "" {
loads = []string{bin}
}
}
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
// it reached it by, and a manifest carrying that address names an installation —
// registration refused node-tools for exactly this on 2026-10-02. The build record
// already keeps the store-relative form; the resolved manifest keeps the same, and
// composition routes it through the store a machine reaches (Routed).
out.Bundles = append(out.Bundles, Bundle{
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
Binary: BinaryOf(a),
})
}
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
}
for _, r := range m.Resources {
named, _ := r["artifact"].(string)
if named == "" {
out.Resources = append(out.Resources, r)
continue
}
artifact, ok := by[named]
if !ok {
return Manifest{}, fmt.Errorf(
"%s: %v uses the artifact %q, and this module builds no such thing",
m.Module, r["id"], named)
}
filled := map[string]any{}
for k, v := range r {
filled[k] = v
}
delete(filled, "artifact")
switch artifact.Kind {
case ArtifactPackage:
// A package is not a resource on any machine; it is consumed by other builds. A
// resource that names one is a manifest error, named here rather than shipped.
return Manifest{}, fmt.Errorf(
"%s: %v uses %q, which is a package — a build input, not a resource a machine runs",
m.Module, r["id"], named)
case ArtifactImage, ArtifactUpstream:
filled["image"] = artifact.Reference
// An image is not unpacked anywhere, so it has no directory to be named for its
// version and `${version}` has nothing to mean. Refused rather than left as literal
// text in a path, which is how it would reach a machine and be created as a directory
// called `${version}`.
for key, value := range filled {
if text, isText := value.(string); isText && strings.Contains(text, versionRef) {
return Manifest{}, fmt.Errorf(
"%s: %v says %s in %q, and %q is an image — an image is not unpacked, so "+
"it has no versioned place. %s is for an archive or a bundle",
m.Module, r["id"], versionRef, key, named, versionRef)
}
}
case ArtifactArchive, ArtifactBundle:
// The same on the wire: both are bytes fetched by digest and unpacked. They differ in
// how they were made — one packed as it stood, the other compiled first — and a
// machine has no reason to care which.
// Kept, not routed, for the reason the bundles above are (ADR 0155).
filled["source"] = Recorded(artifact.Reference)
filled["digest"] = artifact.Digest
// **And `${version}`, so a resource can name a place that is this build's alone**
// (novox/hq ADR 0141, 04-ISSUES/142). A component is unpacked into a directory named
// for its version so it can read its own version from its path — and until this,
// nothing could compose that path: an archive named a fixed one in the manifest and
// nothing interpolated the build into it, so nothing could ask for
// `…/versions/<version>/` and every machine took a hand-placed fallback.
//
// The version is the artifact's own digest, short. Not the commit: two builds of one
// commit are meant to be the same bytes (the toolchains are `-trimpath` for this), and
// a content-addressed version means an unchanged build resolves to the path it already
// had — so re-composing a declaration moves nothing, where a commit would move the
// path of an identical binary and recreate everything that reads it.
for key, value := range filled {
filled[key] = withVersion(value, versionOf(artifact.Digest))
}
default:
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
ArtifactBundle)
}
out.Resources = append(out.Resources, filled)
}
return out, nil
}
// withVersion is a resource's value with `${version}` filled: in a string, and in each string of a map —
// a process's env, where the controller is told which build it is (novox/hq issue 352). Anything else is
// left as it is.
func withVersion(value any, version string) any {
switch v := value.(type) {
case string:
if strings.Contains(v, versionRef) {
return strings.ReplaceAll(v, versionRef, version)
}
case map[string]any:
out := make(map[string]any, len(v))
for k, x := range v {
out[k] = withVersion(x, version)
}
return out
case map[string]string:
out := make(map[string]string, len(v))
for k, x := range v {
out[k] = strings.ReplaceAll(x, versionRef, version)
}
return out
}
return value
}
// checkBuild is the manifest's own account of what it builds.
func (b *Build) problems(module string) []string {
if b == nil {
return nil
}
var problems []string
seen := map[string]bool{}
for _, a := range b.Artifacts {
if a.Name == "" {
problems = append(problems, module+" builds an artifact with no name")
continue
}
if seen[a.Name] {
problems = append(problems, fmt.Sprintf(
"%s builds two artifacts called %q, and a resource naming it could mean either",
module, a.Name))
}
seen[a.Name] = true
switch a.Kind {
case ArtifactImage, ArtifactArchive, ArtifactUpstream, ArtifactBundle, ArtifactPackage:
default:
problems = append(problems, fmt.Sprintf(
"%s: %q is a %q, and an artifact is %q, %q, %q or %q",
module, a.Name, a.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
ArtifactBundle+", "+ArtifactPackage))
}
// **A bundle is built from the module itself, so it says a language instead.** Everything
// else names what it is built from: a Dockerfile, a directory, somebody else's reference.
// A bundle's source is the module's own directory by definition, and what it needs to say
// is which compiler — because the mesh chooses that, and cannot choose for a module that
// has not said.
if len(a.External) > 0 && (a.Kind != ArtifactBundle || a.Language != "typescript") {
problems = append(problems, fmt.Sprintf(
"%s: %q names packages it keeps external, and only a TypeScript bundle is bundled into "+
"one file with some kept out (novox/hq ADR 0193)", module, a.Name))
}
if len(a.Env) > 0 && a.Kind != ArtifactBundle {
problems = append(problems, fmt.Sprintf(
"%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+
"serves is given words (novox/hq ADR 0192); a container says its own environment",
module, a.Name, a.Kind))
}
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
// **Except for a language that compiles to a binary, where it names which one**
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
// directory compiled whole, and naming a source would be describing its own build. A
// repository written in a compiled language holds several commands — the host and its
// bootstrap live in one, and the mesh needs the host — and "the module's own directory"
// is then not a package at all. So the compiled case may say which package, and says
// the module root by saying nothing.
if a.From != "" && !compilesToABinary(a.Language) {
problems = append(problems, fmt.Sprintf(
"%s: %q is a bundle and names what it is built from (%q). A bundle is built "+
"from the module's own directory; what it says is the language",
module, a.Name, a.From))
}
if strings.TrimSpace(a.Language) == "" {
problems = append(problems, fmt.Sprintf(
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
"for it", module, a.Name))
}
problems = append(problems, bundleEnvProblems(module, a)...)
// What the runtime loads is among what was compiled (ADR 0175): a name here that is
// not an entrypoint is a file the bundle does not contain, and the runtime would
// fail to import it on every machine rather than here.
for _, load := range a.Loads {
found := false
for _, e := range a.Entrypoints {
found = found || e == load
}
// A bundle compiled to a binary is one executable: the runtime loads that or nothing
// (novox/hq ADR 0193).
if bin := BinaryOf(a); bin != "" {
found = load == bin
}
if !found {
problems = append(problems, fmt.Sprintf(
"%s: %q says the runtime loads %q, which is not among its entrypoints — "+
"what is loaded is compiled, so it is named there too", module, a.Name, load))
}
}
// **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary
// is pinned to one operating system at link time so a host refuses to touch a machine
// it was not built for (novox/hq ADR 0005); an artifact that says nothing would be
// compiled for whatever the build machine happened to be, which reads as portable and
// is not.
if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" {
problems = append(problems, fmt.Sprintf(
"%s: %q is compiled to a binary and says no system, so it would be built for "+
"whatever the build machine happens to be. Declare one artifact per "+
"system: %s", module, a.Name, spokenSystems()))
} else if !compiled && strings.TrimSpace(a.System) != "" {
problems = append(problems, fmt.Sprintf(
"%s: %q names the system %q and is written in %q, which compiles to code that "+
"runs anywhere — a system that decides nothing reads as though it did",
module, a.Name, a.System, a.Language))
} else if compiled && !knownSystem(a.System) {
problems = append(problems, fmt.Sprintf(
"%s: %q is built for %q, and a system is %s",
module, a.Name, a.System, spokenSystems()))
}
} else {
if a.From == "" && a.URL == "" {
problems = append(problems, fmt.Sprintf(
"%s: %q says nothing about what it is built from", module, a.Name))
}
if a.Language != "" {
problems = append(problems, fmt.Sprintf(
"%s: %q is a %q and names a language. Only a bundle is compiled by the mesh; "+
"everything else brings its own recipe", module, a.Name, a.Kind))
}
}
if a.Compiles != "" {
if a.Kind != ArtifactImage {
problems = append(problems, fmt.Sprintf(
"%s: %q is a %q and names a Go package it compiles. Only an image's recipe is "+
"told which; a bundle names its package in from (novox/hq ADR 0267)", module, a.Name, a.Kind))
} else if c := strings.TrimSpace(a.Compiles); strings.HasPrefix(c, "/") || c == ".." ||
strings.HasPrefix(c, "../") || strings.Contains(c, "/../") || strings.HasSuffix(c, "/..") {
problems = append(problems, fmt.Sprintf(
"%s: %q compiles %q, which leaves the tree it is built in", module, a.Name, a.Compiles))
}
}
// An upstream image is named, not read from the repository, so the path rule does not
// apply to it — and applying it anyway would refuse every reference with a registry host
// in it.
if a.Kind != ArtifactUpstream &&
(strings.HasPrefix(a.From, "/") || strings.Contains(a.From, "..")) {
// A build reads its own repository and nothing else. A path leaving it would make
// what gets built depend on whatever happens to be on the machine building it.
problems = append(problems, fmt.Sprintf(
"%s: %q is built from %q, which is outside its own repository",
module, a.Name, a.From))
}
if a.Kind == ArtifactUpstream && !strings.Contains(a.From, ":") {
// Without a tag or digest, what gets mirrored is whatever `latest` means today, and
// a module pinned to that is not pinned.
problems = append(problems, fmt.Sprintf(
"%s: %q mirrors %q, which names no tag or digest", module, a.Name, a.From))
}
problems = append(problems, upstreamFileProblems(module, a)...)
}
return problems
}
// reservedSuffixes are names no public vendor publishes under: an installation's own and documentation's.
var reservedSuffixes = []string{".internal", ".local", ".lan", ".home", ".corp", ".localhost", ".home.arpa",
".test", ".invalid", ".example", ".intranet", ".private"}
// notAPublicHost says why a host is not a public vendor's name, or "".
func notAPublicHost(host string) string {
h := strings.ToLower(strings.TrimSuffix(host, "."))
if net.ParseIP(h) != nil {
return "an address rather than a name"
}
if !strings.Contains(h, ".") {
return "a name with no domain"
}
for _, s := range reservedSuffixes {
if strings.HasSuffix(h, s) {
return "a name under " + s + ", which is nobody's public domain"
}
}
return ""
}
// sha256Hex is a pinned sha256 as a manifest writes it: 64 lower-case hexadecimal characters.
var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// upstreamFileProblems is what is wrong with an archive pinned by URL (novox/hq ADR 0316 §2, §6): it is an
// archive, fetched over https, pinned by a sha256 and a version, and it names no directory of the repository
// besides; a file name or a directory within is a plain path that stays where it is put.
func upstreamFileProblems(module string, a Artifact) []string {
pinned := a.URL != "" || a.SHA256 != "" || a.Version != "" || a.File != "" || a.Within != ""
if !pinned {
return nil
}
var problems []string
say := func(format string, args ...any) {
problems = append(problems, fmt.Sprintf("%s: %q ", module, a.Name)+fmt.Sprintf(format, args...))
}
if a.Kind != ArtifactArchive {
say("is a %q and names an upstream file (url, sha256, version, file, within): only an archive is "+
"mirrored from one (novox/hq ADR 0316)", a.Kind)
return problems
}
if a.URL == "" {
say("names a sha256, a version, a file or a directory within, and no url to fetch it from")
return problems
}
if a.From != "" {
say("is built from %q and fetched from %s: an archive is one or the other", a.From, a.URL)
}
if u, err := url.Parse(a.URL); err != nil || u.Scheme != "https" || u.Host == "" {
say("is fetched from %q, and an upstream file is fetched over https from a named host", a.URL)
} else if why := notAPublicHost(u.Hostname()); why != "" {
say("is fetched from %s, %s: an upstream file is somebody else's release, published under a public name", a.URL, why)
}
if !sha256Hex.MatchString(a.SHA256) {
say("is fetched from %s without a pinned sha256 (64 lower-case hexadecimal characters): what that "+
"address serves changes under the mesh, and only the digest says it is what was reviewed", a.URL)
}
if strings.TrimSpace(a.Version) == "" {
say("pins %s and says no version, so a person reading the pin cannot tell which release it is", a.URL)
}
if a.File != "" && a.Within != "" {
say("names a file (%q) and a directory within (%q): a file is kept whole, a directory is taken out "+
"of an archive", a.File, a.Within)
}
for field, p := range map[string]string{"file": a.File, "within": a.Within} {
if p == "" {
continue
}
clean := path.Clean(p)
if clean != p || strings.HasPrefix(p, "/") || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") ||
(field == "file" && strings.Contains(p, "/")) {
say("names %s %q, which is not a plain relative %s", field, p, map[string]string{
"file": "file name", "within": "directory"}[field])
}
}
return problems
}
// oneOrOther keeps the message readable for one artifact and for several, because a message that
// says "neither" about one thing reads as a bug in the message.
func oneOrOther(n int) string {
if n == 1 {
return "it"
}
return "them"
}
// Systems the mesh builds binaries for, which is the set a host may be pinned to (novox/hq ADR 0005).
//
// **A closed list, and the host's own, not the compiler's.** These are not the values a Go toolchain
// would call an operating system — the difference between two of them is a C library, not a kernel.
// They are what a machine reports itself to be and what a host is linked to refuse, so the list that
// matters is the one the host understands.
var systems = []string{"alpine", "android", "arch"}
// knownSystem is whether the mesh builds for it.
func knownSystem(system string) bool {
want := strings.ToLower(strings.TrimSpace(system))
for _, s := range systems {
if s == want {
return true
}
}
return false
}
// spokenSystems is the list as a refusal says it, so a reader is one edit from right.
func spokenSystems() string {
return strings.Join(systems, ", ")
}
// compilesToABinary is whether this language's bundle is a binary for one operating system rather
// than code that runs wherever its interpreter does.
//
// **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
}
}
// versionRef is how a resource names the version of the artifact it uses: ${version}.
//
// No artifact name in it, because the resource already says which artifact it is for — a second
// name would be a second thing to keep in step with the first.
const versionRef = "${version}"
// versionOf is an artifact's version as a path names it: its digest, short.
//
// **Content-addressed on purpose.** The alternative is the commit a build came from, and two builds
// of one commit are meant to produce the same bytes — every toolchain here is `-trimpath` for that
// reason. A commit-named path would move for an identical binary, and everything reading that path
// would be recreated for a change that is not one. A digest-named path moves exactly when the bytes
// do.
//
// Twelve hex characters: enough that two of this mesh's builds will not collide, short enough to
// read in a path and in a journal line. The `sha256:` prefix goes, because a directory name carrying
// a colon is a directory name people quote wrong.
func versionOf(digest string) string {
hex := strings.TrimPrefix(strings.TrimSpace(digest), "sha256:")
if len(hex) > 12 {
return hex[:12]
}
return hex
}
// bundleEnvWords are the words the runtime sets for itself; a bundle that named one would be
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
var bundleEnvWords = map[string]bool{
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
RuntimeOperatorHome: true, RuntimeToolEnv: true, RuntimeAgentAccount: true, RuntimeAgentHome: true,
}
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
// a value is a path or a constant written with the references a container's environment may use
// for a place or a port, and never a secret's content or another module's binding — a secret
// reaches a tool as a file whose path is named.
func bundleEnvProblems(module string, a Artifact) []string {
if len(a.Env) == 0 {
return nil
}
var problems []string
for _, word := range sortedKeys(a.Env) {
value := a.Env[word]
if bundleEnvWords[word] {
problems = append(problems, fmt.Sprintf(
"%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+
"given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)",
module, a.Name, word))
}
rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "")
if strings.Contains(rest, "${") {
problems = append(problems, fmt.Sprintf(
"%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+
"${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+
"named by its path, never as its content (novox/hq ADR 0192)",
module, a.Name, word, value))
}
}
return problems
}
func copyWords(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// BinaryOf is what a bundle compiled to a binary is called once built: what the artifact names, else
// the package it is built from, else the artifact's own name (novox/hq 04-ISSUES/142). Empty for a
// language that does not compile to one. The builder writes the binary under this name, and the
// composer runs it by it, so both ask here.
func BinaryOf(a Artifact) string {
if !compilesToABinary(a.Language) {
return ""
}
if name := strings.TrimSpace(a.Binary); name != "" {
return name
}
if from := strings.Trim(a.From, "./"); from != "" {
return path.Base(from)
}
return a.Name
}
// 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 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
}
// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq
// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says
// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or
// it is the runtime itself. One reached by none of them was built, recorded and pushed as success,
// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here,
// naming the field that would deliver it.
func undeliveredBundles(m Manifest) []string {
if m.Build == nil || m.Module == RuntimeModule {
return nil
}
named := runByAResource(m)
var problems []string
for _, a := range m.Build.Artifacts {
if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 {
continue
}
problems = append(problems, fmt.Sprintf(
"%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+
"unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+
"module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)",
m.Module, a.Name))
}
return problems
}
+124
View File
@@ -0,0 +1,124 @@
package catalogue
import (
"fmt"
"sort"
"strings"
"time"
)
// A long-running container may say it is busy (novox/hq ADR 0335 §1).
//
// **Beside its health, on the container**: a command run inside it, by its shell, as an exec health check
// is, that exits 0 while the container holds work a replacement would lose and non-zero when it is idle;
// and how long one ask may take. Being asked is also its notice that it is about to be replaced: from the
// first ask it takes no new work. The node-engine asks before it replaces the container, leaves it running
// while it says busy, and replaces it on the first pass it says idle, or at 15 minutes — the engine's bound,
// never the module's.
//
// Sent only to a node-engine that reads it (link.BusyContract): an older, strict engine refuses a field it
// does not know, whole, and to it the container is replaced at once, as before.
// BusyField is the resource field.
const BusyField = "busy"
// The bounds of one ask (ADR 0335), the node-engine's own, held here near the author.
const (
BusyTimeoutDefault = 10 * time.Second
BusyTimeoutCeiling = time.Minute
)
// Busy is one container's busy, read.
type Busy struct {
Command string
Timeout time.Duration
}
// ReadBusy reads a resource's `busy` field, its default applied. False when it carries none.
func ReadBusy(r map[string]any) (Busy, bool, []string) {
raw, present := r[BusyField]
if !present {
return Busy{}, false, nil
}
id := fmt.Sprint(r["id"])
fields, ok := raw.(map[string]any)
if !ok {
return Busy{}, true, []string{fmt.Sprintf("%s: busy is %T; it is an object with a command", id, raw)}
}
var problems []string
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if k != "command" && k != "timeout" {
problems = append(problems, fmt.Sprintf("%s: its busy says %q; busy has a command and a timeout", id, k))
}
}
b := Busy{Timeout: BusyTimeoutDefault}
if v, has := fields["command"]; has {
s, ok := v.(string)
if !ok {
problems = append(problems, fmt.Sprintf("%s: its busy's command is %T; it is text", id, v))
}
b.Command = strings.TrimSpace(s)
}
if v, has := fields["timeout"]; has {
s, _ := v.(string)
d, err := time.ParseDuration(strings.TrimSpace(s))
switch {
case err != nil:
problems = append(problems, fmt.Sprintf("%s: its busy's timeout is %v; it is a duration such as \"10s\"", id, v))
case d <= 0 || d > BusyTimeoutCeiling:
problems = append(problems, fmt.Sprintf("%s: an ask of its busy takes more than nothing and at most %s, not %s",
id, BusyTimeoutCeiling, d))
default:
b.Timeout = d
}
}
return b, true, problems
}
// busyProblems is everything wrong with a manifest's `busy` fields, in the manifest's words: a command on
// one line, a timeout within its bounds, and only on a container that stays up.
func busyProblems(m Manifest) []string {
var problems []string
for _, r := range m.Resources {
b, has, read := ReadBusy(r)
if !has {
continue
}
where := m.Module + ": " + fmt.Sprint(r["id"])
for _, p := range read {
problems = append(problems, m.Module+": "+p)
}
if fmt.Sprint(r["type"]) != "container" || !LongRunning(r) {
problems = append(problems, fmt.Sprintf("%s declares busy and is not a container that stays up: busy is "+
"asked of a running container before it is replaced (ADR 0335)", where))
}
if b.Command == "" {
problems = append(problems, where+"'s busy names no command: one run inside the container, exiting 0 "+
"while it is busy")
}
if strings.ContainsAny(b.Command, "\n\r") {
problems = append(problems, where+"'s busy command is one line")
}
}
return problems
}
// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that
// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the
// container is replaced at once, as before.
func busyInto(resource map[string]any, with Rendering) {
if _, has := resource[BusyField]; !has {
return
}
if !with.ReadsBusy {
delete(resource, BusyField)
return
}
b, _, _ := ReadBusy(resource)
resource[BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()}
}
@@ -0,0 +1,100 @@
package catalogue
import (
"sort"
)
// Chosen is the provider somebody named for a provision: the module, and the node it runs on. Both,
// always (novox/hq #258) — a provision comes from a module, and the same module on two machines is
// two answers, so neither half alone says which. Module is empty only on a record made before this
// was asked, and such a record is honoured exactly as long as it is unambiguous.
type Chosen struct {
Node string
Module string
}
func (c Chosen) String() string {
if c.Module == "" {
return c.Node
}
return c.Node + "/" + c.Module
}
// matches is whether this provider is the one chosen.
func (c Chosen) matches(p Provider) bool {
return p.Node == c.Node && (c.Module == "" || p.Module == c.Module)
}
// among is every offered provider the choice names — one, when the choice is whole.
func (c Chosen) among(where []Provider) []Provider {
var out []Provider
for _, p := range where {
if c.matches(p) {
out = append(out, p)
}
}
return out
}
// nameOf is how a refusal names a provider: the node and the module on it.
func nameOf(p Provider) string {
return Chosen{Node: p.Node, Module: p.Module}.String()
}
// providerNames is every provider named, sorted, for a refusal to list.
func providerNames(where []Provider) []string {
out := make([]string, 0, len(where))
for _, p := range where {
out = append(out, nameOf(p))
}
sort.Strings(out)
return out
}
// providersHere is which modules in this node's own set offer a provision, sorted.
func providersHere(catalogue map[string]Manifest, here func(string) bool, want string) []string {
var out []string
for name, m := range catalogue {
if !here(name) {
continue
}
for _, o := range m.Offers() {
if o == want {
out = append(out, name)
break
}
}
}
sort.Strings(out)
return out
}
// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing.
//
// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq
// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest
// alone answers that; the values are settled later, with the node's settings.
func servedByOne(m Manifest, want string) map[string]any {
if _, ok := m.Serves[want]; ok {
return ServedOn(m, want, nil)
}
return nil
}
// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it
// gives each consumer its own.
func sharedByOne(m Manifest, want string) string {
if own, shared := m.SharedCredentialOf(want); shared {
return own
}
return ""
}
func oneOf(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
@@ -0,0 +1,635 @@
package catalogue
import (
"fmt"
"path"
"regexp"
"slices"
"sort"
"strconv"
"strings"
)
// Collections (novox/hq ADR 0349 clause 13, design 57 L1, issue 684): a provision with many providers.
//
// **A collector requires a collection; a contributor provides an item from a template.** A module taking additions
// from others names a collection in a file of its own, `${collected:<collection>}`, and declares it under
// `collections`, with the comment its tool writes a line in. Each contributor ships its item as a template of its
// own repository and declares it under `items`: `{"collection": …, "template": "files/<name>"}`. Its build packs the
// template with the module's others, and the mesh holds its text as it holds a file's (template.go).
//
// **The node's provisioner client fills both.** An item is filled on the node by the client of the node its
// contributor is placed on, from the same owners as a file, and recorded in mesh-provisioner_collect; a collector's
// file is filled by the same client from every item of its collection recorded for that node, so an item goes when
// its contributor leaves. So a module contributing an item is a moved module (handed.go), and the controller renders
// no item: the plan of a contributor names its items and their templates, and the plan of a collector names each
// collection it reads and its comment. Neither carries a contributor's text.
//
// **The three old namespaces read as collections.** `${shell:<shell>:<slot>}`, `${environment:<format>}` and
// `${contribution:<seat>:<kind>}` name, in a handed collector's file, the collection of the same name —
// `shell:zsh:normal` — with the comment the controller wrote before each module's piece. An item is contributed to
// one by that name, by a module the old form would have let contribute it. Each file is filled one way, never
// both (ADR 0349 clause 17): a handed collector is filled from items alone and an unhanded one from the old forms
// alone, and a piece of the other form on the same machine is said as not placed, never dropped quietly.
// Item is one item a module contributes to a collection: its template in the module's repository, or, in the
// manifest the mesh holds, the template's text, read back from the build's templates archive like a file's.
//
// **Attributes, a capability and a name** (design 57 L3, issue 777): an item may carry `attributes` (string keys and
// values; `order`, a whole number 0–99 defaulting to 50, orders the items of one placeholder) and `if-capability`,
// the capability a machine must report for the item to be placed on it. A module may give several items to one
// collection, each then with a `name` unique within the module and the collection. A collector takes only the items
// whose attributes match, `${collected:<collection>:<k>=<v>,…}`, and all of them without the selector.
type Item struct {
Collection string `json:"collection"`
Name string `json:"name,omitempty"`
Template string `json:"template,omitempty"`
Content string `json:"content,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
IfCapability string `json:"if-capability,omitempty"`
}
// ItemKey is an item's key among its module's: the collection, and `/<name>` for a named item.
func ItemKey(collection, name string) string {
if name == "" {
return collection
}
return collection + "/" + name
}
func (it Item) key() string { return ItemKey(it.Collection, it.Name) }
// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault.
func (it Item) order() int {
if n, err := strconv.Atoi(it.Attributes[OrderAttribute]); err == nil && n >= 0 && n <= 99 {
return n
}
return OrderDefault
}
// OrderAttribute is the attribute that orders items.
const OrderAttribute = "order"
// matches is whether the item's attributes carry every pair of the selector.
func (it Item) matches(where map[string]string) bool {
for k, v := range where {
if got, has := it.Attributes[k]; !has || got != v {
return false
}
}
return true
}
// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in.
func itemsInOrder(items []Item) []Item {
out := append([]Item(nil), items...)
sort.SliceStable(out, func(a, b int) bool {
if oa, ob := out[a].order(), out[b].order(); oa != ob {
return oa < ob
}
return out[a].Name < out[b].Name
})
return out
}
// Collection is one collection a module collects, and the comment its tool writes a line in: each contributor's item
// is introduced by `<comment> <contributor>`, and by nothing without one.
type Collection struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty"`
}
// CollectedNamespace is a collection's placeholder namespace, `${collected:<collection>}`.
const CollectedNamespace = "collected"
// EnvironmentCollection is the one collection of the session environment (design 57 L3, issue 795): its items are
// typed by attributes, a variable (`kind=variable`, `name`) or a PATH entry (`kind=path`, `at=start|end`), and the
// template's text is the value or the entry. A collector names the rendering, `${collected:environment:posix}` or
// `${collected:environment:systemd}`, which is not a selector.
const EnvironmentCollection = "environment"
// oldCollectionNamespaces are the namespaces read as collections of their own name.
var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
var (
// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused.
ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
// collectionName is words joined by colons, so an old namespace's placeholder names its collection as it reads.
collectionName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`)
// itemName, attributeKey and attributeValue are the words an item's name and attributes are made of; none holds
// a colon, a comma or an equals sign, which tell a selector from a collection's name.
itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
)
// 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:]
}
// 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
}
// 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 == CollectedNamespace && strings.HasPrefix(key, EnvironmentCollection+":"):
// The environment is one collection, and the rest of the name picks the rendering (issue 795).
return EnvironmentCollection, true
case namespace == CollectedNamespace:
return key, true
case namespace == "environment":
return EnvironmentCollection, true
case slices.Contains(oldCollectionNamespaces, namespace):
return namespace + ":" + key, true
}
return "", false
}
// 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 != EnvironmentPOSIX && key != EnvironmentSystemd {
return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s",
name, EnvironmentPOSIX, 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
}
// placerOfOld is the seat whose holder alone may collect an old namespace's collection (ADR 0203, 0204, 0208,
// 0211, 0212): the same seat that places it the old way.
func placerOfOld(name string) string {
namespace, key, _ := strings.Cut(name, ":")
switch namespace {
case "shell":
target, _, _ := strings.Cut(key, ":")
return placerOf(target)
case "environment":
return EnvironmentSeat
}
seat, _, _ := strings.Cut(key, ":")
return canonicalSeat(seat)
}
// collectionProblems is what is wrong with a module's collections and items, from the manifest alone.
func collectionProblems(m Manifest) []string {
var problems []string
declared := map[string]bool{}
for i, c := range m.Collections {
switch {
case !collectionName.MatchString(c.Name):
problems = append(problems, fmt.Sprintf("%s's collection %d is %q, which is not a collection's name: "+
"words of lower-case letters, digits and hyphens, joined by colons", m.Module, i+1, c.Name))
case declared[c.Name]:
problems = append(problems, fmt.Sprintf("%s collects %s twice", m.Module, c.Name))
}
if _, old, _ := oldCollection(c.Name, false); old {
problems = append(problems, fmt.Sprintf("%s collects %s, which is read from its placeholder: a module "+
"places it as ${%s} where it holds the seat that places it, and declares nothing", m.Module, c.Name,
c.Name))
}
if strings.ContainsAny(c.Comment, "\n\r") {
problems = append(problems, fmt.Sprintf("%s's collection %s has a comment holding a line break",
m.Module, c.Name))
}
declared[c.Name] = true
}
// A collection declared and placed nowhere is a word read by nothing; judged where every file's text is here.
if !m.Templated() {
named := map[string]bool{}
for _, r := range m.Resources {
if text, ok := r["content"].(string); ok {
for _, f := range ofCollected.FindAllStringSubmatch(text, -1) {
base, _ := splitSelector(f[1])
named[base] = true
}
}
}
for _, c := range m.Collections {
if !named[c.Name] && collectionName.MatchString(c.Name) {
problems = append(problems, fmt.Sprintf("%s collects %s and no file of its places it as "+
"${collected:%s}", m.Module, c.Name, c.Name))
}
}
}
contributed := map[string]bool{}
for i, it := range m.Items {
where := fmt.Sprintf("%s's item %d", m.Module, i+1)
if !collectionName.MatchString(it.Collection) {
problems = append(problems, fmt.Sprintf("%s is to %q, which is not a collection's name", where,
it.Collection))
} else if _, old, wrong := oldCollection(it.Collection, true); old && wrong != nil {
problems = append(problems, fmt.Sprintf("%s: %v", where, wrong))
}
switch {
case it.Name != "" && !itemName.MatchString(it.Name):
problems = append(problems, fmt.Sprintf("%s: the name %q is not a name: lower-case letters, digits and "+
"hyphens", where, it.Name))
case contributed[it.key()]:
problems = append(problems, fmt.Sprintf("%s contributes to %s twice; a module contributes one item to "+
"a collection, or several each with a name of its own", m.Module, it.key()))
case it.Name == "" && slices.ContainsFunc(m.Items, func(o Item) bool {
return o.Collection == it.Collection && o.Name != ""
}):
problems = append(problems, fmt.Sprintf("%s contributes several items to %s and %s has no name; each "+
"of several items has a name of its own", m.Module, it.Collection, where))
}
contributed[it.key()] = true
problems = append(problems, itemAttributeProblems(m.Module, where, it)...)
if it.Collection == EnvironmentCollection {
problems = append(problems, environmentItemProblems(where, it)...)
}
switch {
case it.Template == "" && it.Content == "":
problems = append(problems, fmt.Sprintf("%s names no template: an item is a template of the module's "+
"own, \"template\": \"%s/<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
}
// itemAttributeProblems is what is wrong with an item's attributes and capability. The attributes of an item of a kind
// received as data are the fields its shape places by (and `order`), each with a value the shape takes.
func itemAttributeProblems(module, where string, it Item) []string {
var problems []string
for _, k := range sortedKeys(it.Attributes) {
v := it.Attributes[k]
switch {
case !attributeKey.MatchString(k):
problems = append(problems, fmt.Sprintf("%s: the attribute %q is not a name: lower-case letters, digits "+
"and hyphens", where, k))
continue
case !attributeFits(it.Collection, k, v):
problems = append(problems, fmt.Sprintf("%s: %s=%q is not a value: lower-case letters, digits, hyphens, "+
"dots and underscores (an environment variable's name, letters, digits and underscores)", where, k, v))
continue
}
if n, err := strconv.Atoi(v); k == OrderAttribute && (err != nil || n < 0 || n > 99) {
problems = append(problems, fmt.Sprintf("%s: order=%q is not a whole number from 0 to 99", where, v))
}
}
if namespace, key, _ := strings.Cut(it.Collection, ":"); namespace == "contribution" {
seat, kind, _ := strings.Cut(key, ":")
if _, r, ok := receivable(seat, kind); ok && r.Shape != nil {
for _, k := range sortedKeys(it.Attributes) {
var place *PlaceField
for i := range r.Shape.Places {
if r.Shape.Places[i].Field == k {
place = &r.Shape.Places[i]
}
}
switch {
case k == OrderAttribute:
case place == nil:
problems = append(problems, fmt.Sprintf("%s: %s is placed by %s, and %q is none of them", where,
it.Collection, placeFields(r), k))
case !oneOf(place.Values, it.Attributes[k]):
problems = append(problems, fmt.Sprintf("%s: %s=%s; %s is one of %s", where, k, it.Attributes[k], k,
strings.Join(place.Values, ", ")))
}
}
}
}
switch {
case it.IfCapability == "":
case !capabilityName.MatchString(it.IfCapability):
problems = append(problems, fmt.Sprintf("%s: if-capability %q is not a capability's name", where, it.IfCapability))
case !oneOf(KnownCapabilities, it.IfCapability):
problems = append(problems, fmt.Sprintf("%s: if-capability %q is one no machine reports; the node-engine "+
"detects %s", where, it.IfCapability, strings.Join(KnownCapabilities, ", ")))
}
return problems
}
// AttributeFits is whether an item's attribute is of the shape its collection allows: words of lower case, except
// the environment's `name`, which is a variable's name (design 57 L3, issue 795). The same rule is mesh-catalog's
// provisioner client's, and the inventory's.
func attributeFits(collection, k, v string) bool {
if collection == EnvironmentCollection && k == "name" {
return variableName.MatchString(v)
}
return attributeKey.MatchString(k) && attributeValue.MatchString(v)
}
// environmentItemProblems is what is wrong with an item of the environment collection: typed by `kind`, a variable
// with its `name`, a PATH entry `at` the start or the end, and no attribute besides `order`.
func environmentItemProblems(where string, it Item) []string {
var problems []string
for _, k := range sortedKeys(it.Attributes) {
if !slices.Contains([]string{"kind", "name", "at", OrderAttribute}, k) {
problems = append(problems, fmt.Sprintf("%s: %s is typed by kind, name and at, and %q is none of them",
where, it.Collection, k))
}
}
switch kind := it.Attributes["kind"]; kind {
case "variable":
if it.Attributes["name"] == "" || it.Attributes["at"] != "" {
problems = append(problems, fmt.Sprintf("%s: a variable names itself with name=<NAME> and has no at", where))
} else if it.Attributes["name"] == "PATH" {
problems = append(problems, fmt.Sprintf("%s: PATH is not a variable; a PATH entry is kind=path", where))
}
case "path":
if it.Attributes["name"] != "" || (it.Attributes["at"] != PathAtStart && it.Attributes["at"] != PathAtEnd) {
problems = append(problems, fmt.Sprintf("%s: a PATH entry is at=%s or at=%s and has no name", where,
PathAtStart, PathAtEnd))
}
default:
problems = append(problems, fmt.Sprintf("%s: an item of %s is kind=variable or kind=path, not %q", where,
it.Collection, kind))
}
return problems
}
// itemTemplateProblem is what is wrong with an item's template path, or "".
func itemTemplateProblem(m Manifest, name string) string {
dir := m.TemplatesDir()
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 ""
}
// itemTextProblems is what an item's text may not name: a secret or a bound value, since an item is kept in a
// bucket and no secret is (ADR 0349 clause 7), and a collection, since an item is not a collector. And every
// placeholder no pass fills, as in a file's content.
func itemTextProblems(module string, it Item) []string {
var problems []string
for _, f := range templatePlaceholder.FindAllStringSubmatch(it.Content, -1) {
namespace, key := f[1], f[2]
switch _, collection := collectionOfPlaceholder(namespace, key); {
case namespace == "secret" || namespace == "bound":
problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is kept in a bucket, where "+
"no secret is (novox/hq ADR 0349 §7)", module, it.Collection, f[0]))
case collection:
problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is not a collector",
module, it.Collection, f[0]))
}
}
return append(problems, unconsumedPlaceholders(module, map[string]any{"id": "item:" + it.key(),
"type": "file", "content": it.Content})...)
}
// isOld is whether a collection's name is an old namespace's.
func isOld(name string) bool {
_, old, _ := oldCollection(name, true)
return old
}
// 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 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 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
}
// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's.
func collectionComment(m Manifest, name string) (string, error) {
if comment, old, wrong := oldCollection(name, false); old {
return comment, wrong
}
for _, c := range m.Collections {
if c.Name == name {
return c.Comment, nil
}
}
return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name)
}
// itemsLeftOut says each item of a collection a file is filled with the old way that the controller could not render
// there (heldItemText): the collector is not handed to the provisioner on this machine (ADR 0349 clause 17). Said,
// never dropped quietly.
func itemsLeftOut(modules []Manifest, holder Manifest, collection string, facts map[string]string) []string {
return itemsLeftOutFor(modules, holder, collection, nil, facts, nil)
}
// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these
// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not
// wanted here.
func itemsLeftOutFor(modules []Manifest, holder Manifest, collection string, where map[string]string,
facts map[string]string, caps map[string]bool) []string {
var out []string
for _, m := range inModuleOrder(modules) {
for _, it := range itemsInOrder(m.Items) {
if it.Collection != collection || !it.matches(where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
continue
}
if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) {
continue
}
out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+
"machine, from contributions, and an item only by the provisioner's client (design 57 L1)",
m.Module, it.key(), holder.Module))
}
}
return out
}
// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is
// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17).
func oldFormsLeftOut(modules []Manifest, holder Manifest, collections []PlanCollection) []string {
var out []string
for _, c := range collections {
namespace, key, _ := strings.Cut(c.Name, ":")
for _, m := range inModuleOrder(modules) {
old := false
switch namespace {
case "shell":
target, slot, _ := strings.Cut(key, ":")
old = slices.ContainsFunc(m.Shell, func(s ShellCode) bool { return s.For == target && s.Slot == slot })
case "environment":
old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0)
case "contribution":
seat, kind, _ := strings.Cut(key, ":")
old = slices.ContainsFunc(m.allContributions(), func(s SeatContribution) bool {
return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind
})
}
if old {
out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+
"this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name,
holder.Module, m.Module))
}
}
}
return out
}
// heldItemText is a moved contributor's items of an old namespace's collection as the controller renders them into a
// collector not handed on this machine (design 57 L2, issue 681): the text the mesh holds, read back from the
// contributor's templates archive, with the machine's facts filled, each ending its line. As a moved module's file
// keeps its text for a machine that cannot take it handed (handed.go), an item keeps its place in a collector filled
// the old way, so the file stays what its old form made it. Only `shell:` and `contribution:` collections, which the
// controller renders as text; false for a module with no such item, or an item whose text the mesh does not hold or
// which names anything but a machine fact this machine has, which is then said as not placed.
func heldItemText(m Manifest, collection string, facts map[string]string) (string, bool) {
items := heldItems(m, collection, nil, facts, nil)
var b strings.Builder
for _, h := range items {
b.WriteString(h.text)
}
return b.String(), len(items) > 0
}
// renderedOldWay is whether the controller renders a collection's items into a collector not handed.
func renderedOldWay(collection string) bool {
namespace, _, _ := strings.Cut(collection, ":")
return namespace == "shell" || namespace == "contribution" || namespace == EnvironmentCollection
}
// heldItem is one item of a module rendered the old way.
type heldItem struct {
item Item
text string
}
// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by
// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so).
func heldItems(m Manifest, collection string, where map[string]string, facts map[string]string,
caps map[string]bool) []heldItem {
if !renderedOldWay(collection) {
return nil
}
var out []heldItem
for _, it := range itemsInOrder(m.Items) {
if it.Collection != collection || !it.matches(where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
continue
}
if text, ok := heldText(it, facts); ok {
out = append(out, heldItem{item: it, text: text})
}
}
return out
}
// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh
// does not hold or which names anything but a machine fact this machine has.
func heldText(it Item, facts map[string]string) (string, bool) {
if it.Content == "" {
return "", false
}
text := it.Content
for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) {
value, has := facts[f[2]]
if f[1] != "machine" || !has {
return "", false
}
text = strings.ReplaceAll(text, f[0], value)
}
if !strings.HasSuffix(text, "\n") {
text += "\n"
}
return text, true
}
@@ -0,0 +1,504 @@
package catalogue
import (
"encoding/json"
"fmt"
"regexp"
"slices"
"strings"
)
// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660).
//
// **One concept, two spellings for a while.** "Config" replaces "settings": the manifest says `config` for
// `settings`, `takes-config` for `takes-settings` and `${config:<key>}` for `${setting:<key>}`. The old names stay
// accepted as aliases until every module has moved (ADR 0348 §1), and a module uses one form or the other, never
// both. The config form is read into the very fields the settings form fills — Settings, a mergeable file's
// takes-settings and `${setting:<key>}` — so composition has one path and `${config:<key>}` fills exactly as
// `${setting:<key>}` does: a module rewritten from settings to config composes byte-identically. It is written
// back in the form it was read in, so what the mesh holds is what its author wrote.
//
// **A moved module is held to the whole declaration.** Under `config` every value has a type (number, string or
// boolean; an object or a list only when trusted), a description including its unit, its validation (enum,
// minimum and maximum, pattern), whether it is trusted, optionally the predefined key it falls back to, and either
// a default or `required`; it lands in a file or is read at runtime (`runtime: true`); every mergeable file names
// the keys it takes; a `reload-on` or `restart-on` names a file the module declares. A module not yet moved keeps
// today's form and today's rules (SettingProblems, TakenSettingsProblems) until the catalogue's list of such
// modules is empty (ADR 0348 §11).
//
// **A required value nothing sets** is the operator's own: it composes as an operator's setting nobody gave does
// today, refused by name at composition (UnsetSettingError). Who reports it once mesh-config answers values is the
// provisioner's (ADR 0349), not this file's.
// ConfigField is the manifest key a module declares its config values under.
const ConfigField = "config"
// TakesConfigField is the key a mergeable file names the config values it takes by.
const TakesConfigField = "takes-config"
// SettingsField is the old name of ConfigField, read as its alias.
const SettingsField = "settings"
// configRef is how a file asks for a config value: ${config:<key>}, the same keys as settingRef.
var configRef = regexp.MustCompile(`\$\{config:([a-z0-9][a-z0-9_.-]*)\}`)
// takesConfigKey and takesSettingsKey are the two spellings as a key of a JSON object. A string value is never
// followed by a colon, and a quote inside a string is escaped, so these match only keys.
var (
takesConfigKey = regexp.MustCompile(`"` + TakesConfigField + `"(\s*):`)
takesSettingsKey = regexp.MustCompile(`"` + TakesSettingsField + `"(\s*):`)
)
// The types a config value may have (ADR 0348 §4).
const (
ConfigNumber = "number"
ConfigString = "string"
ConfigBoolean = "boolean"
ConfigObject = "object"
ConfigList = "list"
)
// accountFacts are what the mesh places for a module's account, never config values (ADR 0348 §11).
var accountFacts = map[string]bool{"puid": true, "pgid": true}
// configForms is which of the two forms a manifest was written in, by the words of each it uses.
type configForms struct {
config, settings []string
}
// DeclaresConfig is whether the manifest is written in the config form: `config`, `takes-config` or
// `${config:<key>}` (ADR 0348 §1, §11).
func (m Manifest) DeclaresConfig() bool { return len(m.forms.config) > 0 }
// toSettingsForm is raw with the config form's placeholders and takes-config read as the settings form's, and
// which words of each form it used. `config` itself is a top-level key, renamed where the keys are read
// (UnmarshalJSON).
func toSettingsForm(raw []byte) ([]byte, configForms) {
var f configForms
if configRef.Match(raw) {
f.config = append(f.config, "${config:…}")
}
if takesConfigKey.Match(raw) {
f.config = append(f.config, TakesConfigField)
}
if settingRef.Match(raw) {
f.settings = append(f.settings, "${setting:…}")
}
if takesSettingsKey.Match(raw) {
f.settings = append(f.settings, TakesSettingsField)
}
if len(f.config) == 0 {
return raw, f
}
raw = configRef.ReplaceAllFunc(raw, func(ref []byte) []byte {
return []byte("${setting:" + string(configRef.FindSubmatch(ref)[1]) + "}")
})
raw = takesConfigKey.ReplaceAllFunc(raw, func(key []byte) []byte {
return []byte(`"` + TakesSettingsField + `"` + string(takesConfigKey.FindSubmatch(key)[1]) + ":")
})
return raw, f
}
// toConfigForm is a manifest written in the settings form's words, as JSON, in the config form's: what
// toSettingsForm and UnmarshalJSON read, written back.
func toConfigForm(raw []byte) ([]byte, error) {
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return nil, err
}
if settings, has := keys[SettingsField]; has {
keys[ConfigField] = settings
delete(keys, SettingsField)
var err error
if raw, err = json.Marshal(keys); err != nil {
return nil, err
}
}
raw = settingRef.ReplaceAllFunc(raw, func(ref []byte) []byte {
return []byte("${config:" + string(settingRef.FindSubmatch(ref)[1]) + "}")
})
raw = takesSettingsKey.ReplaceAllFunc(raw, func(key []byte) []byte {
return []byte(`"` + TakesConfigField + `"` + string(takesSettingsKey.FindSubmatch(key)[1]) + ":")
})
return raw, nil
}
// 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, TakesSettingsField, TakesConfigField)
}
// MixedFormProblems is a manifest using both forms, said with the words of each (ADR 0348 §1).
func MixedFormProblems(m Manifest) []string {
if len(m.forms.config) == 0 || len(m.forms.settings) == 0 {
return nil
}
return []string{fmt.Sprintf("%s mixes the config form (%s) with the settings form (%s): a module uses one form "+
"or the other, never both; write %s, %s and ${config:<key>} throughout (novox/hq ADR 0348 §1)",
m.Module, strings.Join(m.forms.config, ", "), strings.Join(m.forms.settings, ", "),
ConfigField, TakesConfigField)}
}
// configDeclaration is the config form's declaration as written: nothing of the settings form's, and nothing
// unsaid.
type configDeclaration struct {
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Enum []any `json:"enum,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Pattern string `json:"pattern,omitempty"`
Trusted bool `json:"trusted,omitempty"`
Predefined string `json:"predefined,omitempty"`
Default any `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Runtime bool `json:"runtime,omitempty"`
}
// MarshalJSON writes a settings declaration exactly as before, and a config declaration without the settings
// form's kind and why, and without a default it does not have.
func (d SettingDeclaration) MarshalJSON() ([]byte, error) {
type plain SettingDeclaration
if d.Kind != "" || d.Why != "" {
return json.Marshal(plain(d))
}
return json.Marshal(configDeclaration{Type: d.Type, Description: d.Description, Enum: d.Enum,
Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern, Trusted: d.Trusted, Predefined: d.Predefined,
Default: d.Default, Required: d.Required, Runtime: d.Runtime})
}
// configShaped is whether a declaration says anything of the config form's.
func configShaped(d SettingDeclaration) bool {
return d.Type != "" || d.Description != "" || len(d.Enum) > 0 || d.Minimum != nil || d.Maximum != nil ||
d.Pattern != "" || d.Trusted || d.Predefined != "" || d.Required || d.Runtime
}
// TypedSettingProblems is a declaration under `settings` that says what only `config` says: a value with a type,
// a description and its validation is declared in the config form (ADR 0348 §1, §4).
func TypedSettingProblems(m Manifest) []string {
var problems []string
for _, key := range sortedSettingKeys(m.Settings) {
if configShaped(m.Settings[key]) {
problems = append(problems, fmt.Sprintf("%s's setting %q says its type, description or validation "+
"under %s: a value declared with those is declared under config, with ${config:<key>} and "+
"takes-config, never under %s (novox/hq ADR 0348 §1, §4)", m.Module, key, SettingsField, SettingsField))
}
}
return problems
}
// ConfigProblems is every way a module in the config form declares its config wrongly, each said by name with
// what to do (ADR 0348 How it is checked, design 57 package K1).
func ConfigProblems(m Manifest) []string {
if !m.DeclaresConfig() {
return nil
}
var problems []string
placed := configPlaced(m)
fallsBack := fileFallsBack(m)
renamed := renamedTaken(m)
for _, key := range sortedSettingKeys(m.Settings) {
d := m.Settings[key]
say := func(format string, args ...any) {
problems = append(problems, fmt.Sprintf("%s's config value %q: ", m.Module, key)+fmt.Sprintf(format, args...))
}
if !settingRef.MatchString("${setting:" + key + "}") {
say("not a usable key: lower-case letters, digits, dots, dashes and underscores")
continue
}
if meshWords[key] || accountFacts[key] {
say("%q is placement, which the mesh decides, never a config value (novox/hq ADR 0348 §11)", key)
continue
}
if d.Kind != "" || d.Why != "" {
say("kind and why are the settings form's; under config a value says its type and its description " +
"(novox/hq ADR 0348 §1, §4)")
}
switch d.Type {
case ConfigNumber, ConfigString, ConfigBoolean:
case ConfigObject, ConfigList:
if !d.Trusted {
say("is %s and not trusted: an object or a list decides more than one value can say, so it is "+
"trusted, \"trusted\": true, or split into numbers, strings and booleans (novox/hq ADR 0348 §4)", d.Type)
}
case "":
say("no type: say \"type\": number, string or boolean, or object or list when trusted (novox/hq ADR 0348 §4)")
default:
say("type %q is none of number, string, boolean, object and list (novox/hq ADR 0348 §4)", d.Type)
}
if strings.TrimSpace(d.Description) == "" {
say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §4)")
}
switch {
case d.Required && d.Default != nil:
say("both a default and required: a required value is the operator's own and has no default; give " +
"one or the other (novox/hq ADR 0348 §4)")
case !d.Required && d.Default == nil:
say("neither a default nor required: give a default, or say \"required\": true for a value that is " +
"the operator's own (novox/hq ADR 0348 §4)")
}
if as := renamed[key]; as != "" && d.Required {
say("is laid into its file as %q, and only when it is set, so the file and its code fall back when "+
"it is not: it is never required; give it the file's default, the empty value of its type "+
"(novox/hq issue 751)", as)
}
for _, wrong := range validationProblems(d) {
say("%s", wrong)
}
if word := operatorsWord(key); word != "" && d.Default != nil && !fallsBack[key] {
say("a key naming %q is the operator's value: declare it \"required\": true, with no default, "+
"unless a mergeable file takes it and no placeholder asks for it, where the file falls back "+
"(novox/hq ADR 0348 §4, ADR 0112, issue 735)", word)
}
if d.Predefined != "" {
p, listed := m.predefined[d.Predefined]
switch {
case m.predefined == nil:
say("falls back to the predefined key %q, and the list of predefined keys (%s at the root of "+
"the catalogue's repository) is not present where it is judged: a value falls back only to a "+
"key on that list; judge it in the catalogue, or leave \"predefined\" out (novox/hq ADR 0348 §7)",
d.Predefined, PredefinedFile)
case !listed:
say("falls back to %q, which is not on the list of predefined keys: name a key on it, or leave "+
"\"predefined\" out (novox/hq ADR 0348 §7)", d.Predefined)
default:
if p.Type != d.Type {
say("falls back to the predefined key %q, which is a %s, and this value is a %s: a value falls "+
"back only to a key of its own type (novox/hq ADR 0348 §4)", d.Predefined, p.Type, d.Type)
}
if p.Trusted && !d.Trusted {
say("falls back to the trusted predefined key %q and is not trusted itself: a value that falls "+
"back to a trusted key is trusted, \"trusted\": true (novox/hq ADR 0348 §8)", d.Predefined)
}
}
}
// A template's text is not here yet. Resolve fills it from the build, WithTemplatesFrom from the tree; neither
// judges again itself, but WithTemplatesFrom parses its result again, and a resolved build is parsed again
// where it is registered, and this is judged then.
if !placed[key] && !d.Runtime && !m.Templated() {
say("placed nowhere: no file says ${config:%s} or takes it under %s, and it is not marked "+
"\"runtime\": true for a value the module's code reads itself (novox/hq ADR 0348 §5)", key, TakesConfigField)
}
}
used := settingKeysUsedBy(m)
for _, key := range sortedKeys(used) {
if _, declared := m.Settings[key]; !declared {
problems = append(problems, fmt.Sprintf("%s says ${config:%s} and declares no such value under %s: "+
"declare %q with its type, description and a default or required (novox/hq ADR 0348 §4, §5)",
m.Module, key, ConfigField, key))
}
}
problems = append(problems, configTakesProblems(m)...)
problems = append(problems, reflectsProblems(m)...)
return problems
}
// configPlaced is every value a file, a unit, a directory's owner, a contribution or a served fact asks for, and
// every value a mergeable file takes.
func configPlaced(m Manifest) map[string]bool {
placed := settingKeysUsedBy(m)
for _, r := range m.Resources {
for _, k := range takenKeyList(r) {
placed[k] = true
}
}
return placed
}
// fileFallsBack is every value a mergeable file takes and no placeholder asks for. Unset, such a value is simply
// not laid over the file: the file's own content, or the module's code reading the file, falls back. So one named
// after the operator's value (a url, a user, an address) is not `required`, and its default is the file's: its
// value in the content, else the empty value of its type, never laid over the file (the operator's decision on
// issue 664, issue 735). A placeholder has no such fallback: its default would be filled in where the operator's
// value belongs (ADR 0112).
func fileFallsBack(m Manifest) map[string]bool {
asked := settingKeysUsedBy(m)
out := map[string]bool{}
for _, r := range m.Resources {
if how, _ := r["merge"].(string); how == "" {
continue
}
for _, k := range takenKeyList(r) {
if !asked[k] {
out[k] = true
}
}
}
return out
}
// renamedTaken is every value a mergeable file takes under a name of its own, with that name (issue 751).
func renamedTaken(m Manifest) map[string]string {
out := map[string]string{}
for _, r := range m.Resources {
if how, _ := r["merge"].(string); how == "" {
continue
}
for _, e := range takenEntries(r[TakesSettingsField]) {
if e.renamed {
out[e.key] = e.as
}
}
}
return out
}
// validationProblems is what is wrong with a declaration's validation, and its default against it.
func validationProblems(d SettingDeclaration) []string {
var problems []string
isNumber := d.Type == ConfigNumber
if len(d.Enum) > 0 && d.Type != ConfigNumber && d.Type != 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 != 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
}
// 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 == ConfigNumber
case string:
return kind == ConfigString
case bool:
return kind == ConfigBoolean
case map[string]any:
return kind == ConfigObject
case []any:
return kind == ConfigList
}
return false
}
// configTakesProblems is what ADR 0348 §11 adds for a module in the config form: an account fact in
// takes-config (the mesh's own words are refused for both forms by TakenSettingsProblems), and a mergeable file
// naming no keys where none of the module's does (where one does, TakenSettingsProblems names every one that
// does not).
func configTakesProblems(m Manifest) []string {
var problems, silent []string
naming := false
for _, r := range m.Resources {
if how, _ := r["merge"].(string); how == "" {
continue
}
if _, said := r[TakesSettingsField]; !said {
silent = append(silent, fmt.Sprint(r["id"]))
continue
}
naming = true
for _, k := range takenKeyList(r) {
_, declared := m.Settings[k]
switch {
case accountFacts[k]:
problems = append(problems, fmt.Sprintf("%s's %v names %q in %s, and %q is an account fact the "+
"mesh places, never a config value (novox/hq ADR 0348 §11)", m.Module, r["id"], k,
TakesConfigField, k))
case meshWords[k]:
// Refused for both forms by TakenSettingsProblems, in its words.
case !declared:
problems = append(problems, fmt.Sprintf("%s's %v names %q in %s and declares no such value under "+
"%s: a value a file takes is declared like every other, with its type, description and a "+
"default or required, or the config verb could never set it (novox/hq ADR 0348 §4, §11)",
m.Module, r["id"], k, TakesConfigField, ConfigField))
}
}
}
if !naming {
for _, id := range silent {
problems = append(problems, fmt.Sprintf("%s's mergeable %s names no %s: a module that declares config "+
"names the keys each mergeable file takes, an empty list for none (novox/hq ADR 0348 §11)",
m.Module, id, TakesConfigField))
}
}
return problems
}
// meshMadeIDs are the resources the mesh composes into a module's own set, which its `restart-on` may name
// without declaring them; meshMadePrefixes are those named after what they are for.
var (
meshMadeIDs = []string{ComposedJailsID(), FilteringID(), CertificateID(), AuthorityID(), BusUsersID()}
meshMadePrefixes = []string{NeedID(""), SecretID(""), strings.TrimSuffix(GrantID("", ""), "-"), BoundID(""), ReceivedID(""), AccessID("/")}
)
// reflectsProblems is a `reload-on` or `restart-on` naming a file the module does not declare (ADR 0348 §6): not
// one of its own resources, not one the mesh composes for it, and not another module's, which is named
// `<module>.<id>`.
func reflectsProblems(m Manifest) []string {
own := map[string]bool{}
for _, r := range m.Resources {
own[fmt.Sprint(r["id"])] = true
}
meshMade := func(id string) bool {
if slices.Contains(meshMadeIDs, id) {
return true
}
for _, p := range meshMadePrefixes {
if strings.HasPrefix(id, p) && len(id) > len(p) {
return true
}
}
return false
}
var problems []string
for _, r := range m.Resources {
for _, field := range []string{"reload-on", "restart-on"} {
for _, id := range stringsOf(r[field]) {
if strings.Contains(id, ".") || own[id] || meshMade(id) {
continue
}
problems = append(problems, fmt.Sprintf("%s's %v names %q in its %s, and %s declares no such "+
"file: name a file of its own, or another module's as <module>.<id> (novox/hq ADR 0348 §6)",
m.Module, r["id"], id, field, m.Module))
}
}
}
return problems
}
@@ -0,0 +1,40 @@
package catalogue
import "strings"
// What the move of config reads of a module's declaration (novox/hq ADR 0348 §9, design 57 K5, issue 664): where a
// layer's key goes, how two layers' values become one, and whether a value is one its declaration takes.
// TakenAs is each name a mergeable file takes a config value under that is not the value's config key, with that
// key (issue 751): a layer set before the module moved may still say the file's name, and the move sets it under
// the config key.
func TakenAs(m Manifest) map[string]string {
out := map[string]string{}
for key, as := range renamedTaken(m) {
out[as] = key
}
return out
}
// AskedKeys is every value a placeholder of the module asks for: a file, a unit, a directory's owner, a
// contribution or a served fact. Such a value is taken whole from the last layer that sets it, where a mergeable
// file lays one object over another key by key.
func AskedKeys(m Manifest) map[string]bool { return settingKeysUsedBy(m) }
// LaidOver is a value laid over another as a later layer is laid over an earlier: two objects key by key, anything
// else replaced whole.
func LaidOver(under, over any) any { return mergeValue(under, over) }
// ValueProblems is what is wrong with a value for a declaration: its type, and its enum, bounds or pattern.
func ValueProblems(d SettingDeclaration, v any) []string {
if v == nil {
return []string{"no value"}
}
d.Default = v
d.Required = false
var problems []string
for _, p := range validationProblems(d) {
problems = append(problems, strings.ReplaceAll(p, "its default", "the value"))
}
return problems
}
@@ -0,0 +1,338 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// What a provider derives for one consumer, said once in the provider's definition and delivered
// to both ends (novox/hq ADR 0202, issue 124).
//
// A `serves` block is otherwise literal: the same values for every consumer. Where the provider
// *names the resource* — a bucket, a database, a vhost — the name is derived from who is asking,
// and before this the mesh had no channel for it. The provider recomputed it in its own code and
// every consumer transcribed it into its own definition by hand, which is a copy of somebody
// else's rule kept in agreement by nobody. One of three transcriptions was wrong for months.
//
// **The mesh learns no protocol here; it spells its own name in an alphabet it already knows.**
// The only fact a served value may name is the identity the mesh itself minted for the consumer,
// in one of two alphabets: as it was minted, and as a DNS label. Everything a provider wants
// around it — a prefix, a suffix, a separator — it writes around the placeholder, because a
// served value is a string.
// consumerFact is `${consumer:<fact>}` or `${consumer:<fact>:<alphabet>}`.
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
// consumerFacts are what a served value may name about the consumer it is being derived for.
// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose,
// so it is the one thing the mesh can hand to a provider without either end guessing.
var consumerFacts = []string{"as"}
// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own
// identifier with its separator written `-` instead of `_` — the whole of the difference between
// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept.
var consumerAlphabets = []string{"dns"}
// ServedTo fills a provider's served values for one consumer.
//
// `as` is the identity the mesh minted for that consumer — the same string it is told to present
// as a login. Values with no placeholder are returned exactly as they were, and a block with no
// placeholder at all is returned unchanged, so this costs nothing for the providers that derive
// nothing.
//
// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider
// stated outright, and is left alone.
func ServedTo(serves map[string]any, as string) (map[string]any, error) {
if len(serves) == 0 {
return serves, nil
}
var out map[string]any
for _, key := range sortedAnyKeys(serves) {
text, ok := serves[key].(string)
if !ok || !strings.Contains(text, "${consumer:") {
continue
}
filled, err := consumerInto(text, as)
if err != nil {
return nil, fmt.Errorf("the value served as %q: %w", key, err)
}
if out == nil {
// Copied only once something actually changes: the caller's map is the manifest's,
// and a provider that derives nothing must not have it rewritten underneath it.
out = make(map[string]any, len(serves))
for k, v := range serves {
out[k] = v
}
}
out[key] = filled
}
if out == nil {
return serves, nil
}
return out, nil
}
// 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
}
// 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, "_", "-")
}
// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not
// have, when the definition is parsed rather than when a consumer is resolved.
//
// A provision nobody consumes yet still has its rule read: a definition that would be refused the
// first time somebody required it is a definition that is wrong now.
func CheckServes(m Manifest) []string {
var problems []string
for _, provision := range sortedServes(m.Serves) {
for _, key := range sortedAnyKeys(m.Serves[provision]) {
text, ok := m.Serves[provision][key].(string)
if !ok {
// A provider's own name is filled only in a value of its own (novox/hq ADR 0346): one
// nested in a list or an object would be served as the placeholder, unsaid.
problems = append(problems, nestedOwnNames(m, provision, key, m.Serves[provision][key])...)
continue
}
// A probe identity, because what is checked is the shape of the statement and not
// what any consumer is called.
if _, err := consumerInto(text, "mesh_node_module"); err != nil {
problems = append(problems, fmt.Sprintf(
"%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err))
}
// And the provider's own names it may serve (novox/hq ADR 0346).
problems = append(problems, checkServedOwnNames(m, provision, key, text)...)
// A label longer than DNS keeps is not truncated here (asDNSLabel), so the offer's own
// bound has to keep the identity inside one (ADR 0225).
if strings.Contains(text, "${consumer:as:dns}") {
if b := m.IdentityBoundOf(provision); !b.Bounded() || b.Max > dnsLabelLimit {
problems = append(problems, fmt.Sprintf(
"%s serves %s's consumers their identity as a DNS label in %q, and bounds "+
"that identity at %s: a label keeps %d — state an `identity` of at most %d",
m.Module, provision, key, boundWords(b), dnsLabelLimit, dnsLabelLimit))
}
}
}
}
return problems
}
// dnsLabelLimit is the longest DNS label (RFC 1035 §2.3.4).
const dnsLabelLimit = 63
func boundWords(b IdentityBound) string {
if !b.Bounded() {
return "nothing"
}
return fmt.Sprintf("%d", b.Max)
}
func sortedServes(serves map[string]map[string]any) []string {
out := make([]string, 0, len(serves))
for k := range serves {
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
}
// derivedFor is what the provider on this machine derives for one consumer of one provision
// (novox/hq ADR 0202).
//
// Settled first, then derived: an operator may set a prefix on what the provider serves and the
// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the
// consumer are returned — the rest of a `serves` block is the same for every consumer and is
// already in the provider's own definition, so repeating it here would be a second copy to go
// stale.
//
// The first module in the resolved order that says it serves the provision answers, which is the
// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is
// not an error: a contribution can reach a machine whose provider is a record or an adapter, and
// then there is nothing derived to tell.
func (r Resolution) derivedFor(provision, as, consumer, local string, settings SettingsBy) (map[string]any, error) {
for _, m := range r.Modules {
serves, said := m.Serves[provision]
if !said {
continue
}
var names map[string]any
for key, value := range serves {
if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") {
if names == nil {
names = map[string]any{}
}
names[key] = value
}
}
if names == nil {
return nil, nil
}
// **A consumer that keeps several holders of this provision is refused** — this is issue
// 124's own failure one case to the side, and it would be just as quiet.
//
// Each holder gets its own login, `…_<local>` (ADR 0094), and a provider derives from the
// login, so it would make one resource per holder. The consumer's side has no such
// dimension: one binding file per provision, one `${bound:<provision>:<key>}`, both
// derived from the un-suffixed identity. So the provider would create the holder's
// resource and the consumer would be configured against a name nothing made — it would
// authenticate successfully and be refused on every object, which reads like a credential
// fault and is not one.
//
// Lifting this means giving the consumer's side a local dimension. That is a decision,
// not an omission, and until it is taken the mesh says so rather than guessing.
if local != "" {
return nil, fmt.Errorf(
"%s keeps several holders of %s (this one is %q), and %s derives %s for each "+
"consumer from the login the mesh minted. Each holder has its own login, and a "+
"consumer is told one value per requirement — so the two ends would name "+
"different things and nothing would compare them (novox/hq ADR 0202)",
consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names)))
}
settled, err := Settle(names, WithDefaults(m, settings[m.Module]))
if err != nil {
return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
}
derived, err := ServedTo(settled, as)
if err != nil {
return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err)
}
return derived, nil
}
return nil, nil
}
// notTranscribed refuses a consumer's file that writes out the value its provider derives for it,
// instead of asking for it (novox/hq ADR 0202, issue 124).
//
// **What would have caught the one wrong instance.** The object store's three consumers each wrote
// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and
// nothing compared it to what the provider would actually create: the module would have
// authenticated successfully and been refused on every object, which reads like a credential fault
// and is not one. It looked authoritative for months.
//
// The test is exact and costs one string search: a definition whose file already contains the
// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a
// coincidence — a derived value carries the identity the mesh minted for this very consumer on
// this very machine, which nothing else would spell out — and it cannot be checked afterwards,
// because after substitution every consumer's file contains it legitimately.
//
// Only values that actually name the consumer are judged. A provider that serves a constant under
// the same key serves the same constant to everyone, and a consumer repeating it is redundant
// rather than wrong.
func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
content, ok := resource["content"].(string)
if !ok || content == "" {
return nil
}
for _, provision := range sortedKnown(known) {
values := known[provision]
identity := values["as"]
if identity == "" {
continue
}
for _, key := range sortedStringKeys(values) {
if key == "as" {
// The login is not derived from itself, and a consumer that must present it in a
// connection string legitimately has it from `${bound:…}` — which is what it will
// be after substitution, so this would judge the substitution, not the module.
continue
}
value := values[key]
if value == "" || !namesTheConsumer(value, identity) {
continue
}
if !strings.Contains(content, value) {
continue
}
return fmt.Errorf(
"%s writes %q into %v, and that is exactly what %s derives for it — a definition "+
"keeping its own copy of somebody else's naming rule is one that can disagree "+
"with it, silently. Say ${bound:%s:%s} and be told",
module, value, resource["id"], provision, provision, key)
}
}
return nil
}
// namesTheConsumer is whether a derived value was built from this consumer's identity — in the
// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived
// from it, whatever else it may be.
func namesTheConsumer(value, identity string) bool {
return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity))
}
func sortedKnown(known map[string]map[string]string) []string {
out := make([]string, 0, len(known))
for k := range known {
out = append(out, k)
}
sort.Strings(out)
return out
}
func sortedStringKeys(values map[string]string) []string {
out := make([]string, 0, len(values))
for k := range values {
out = append(out, k)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,110 @@
package catalogue
import (
"fmt"
"regexp"
"slices"
)
// A provider's consumers' credentials, in a file of the provider's own (novox/hq ADR 0373, issue 679).
//
// A provider in the tool runner may not ask the vault (ADR 0349 clause 11, ADR 0370 clause 2), so its template names
// `${consumers:<provision>}` and its machine's provisioner client fills it: one entry per consumer bound to the
// provider for that provision, each carrying the credential the vault keeps for the pair. Only the client fills it,
// so the placeholder is a provider's alone, for a provision it provides, in a file only the provider reads (mode 0600
// or 0400, owned at runtime, so a change on disk is written back); and a module not handed to the client on a machine
// is refused there, never written the placeholder's text, nor an empty list the provider would read as no consumers.
// ConsumersNamespace is a provider's consumers' credentials' placeholder namespace, `${consumers:<provision>}`.
const ConsumersNamespace = "consumers"
// ofConsumers is where a provider places its consumers' credentials. Loose inside the braces, so a misspelt name is
// refused rather than passed over.
var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`)
// 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 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 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 != 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
}
// consumersHandedOnly says what a file naming ${consumers:…} is where it would be written the old way: the provisioner's
// client alone fills it, so on a machine that does not hand the module it is **left out** of the declaration, never
// written as that text or as an empty list the provider would read as no consumers, and never the reason the module is
// refused: a provider not handed there keeps working as it did, from its grant files (novox/hq ADR 0373, issue 780).
// A moved module judged from its repository's tree (WithTemplatesFrom: `module check`), on a machine
// that would hand it, has no templates archive to hand and is judged as it composes; there the placeholder is judged
// and taken out of the text, since what is composed so is never sent (judged answers whether the file is so judged).
func consumersHandedOnly(m Manifest, r map[string]any, with Rendering) (judged, leftOut bool) {
content, _ := r["content"].(string)
switch {
case ofConsumers.FindString(content) == "":
return false, false
case m.Moved && m.Handed == nil && with.Hands && with.Provisioner != "":
return true, false
}
return false, true
}
// consumersLeftOutLine is the plan's one line for a file left out because the module is not handed.
func consumersLeftOutLine(m Manifest, r map[string]any) string {
return fmt.Sprintf("%s's %v names ${consumers:…}, which the provisioner's client alone fills: left out, handed only "+
"(novox/hq ADR 0373)", m.Module, r["id"])
}
// consumersNotHanded is consumersHandedOnly as a refusal, for a caller that has no way to leave the file out.
func consumersNotHanded(m Manifest, r map[string]any, with Rendering) (judged bool, err error) {
judged, left := consumersHandedOnly(m, r, with)
if left {
return false, fmt.Errorf("%s's resource %v names %s, which the provisioner's client alone fills, and %s is not "+
"handed to it on this machine: it would be written as that text, or empty, and the provider would read no "+
"consumers (novox/hq ADR 0373)", m.Module, r["id"], ofConsumers.FindString(fmt.Sprint(r["content"])), m.Module)
}
return judged, nil
}
// consumersOfTemplate is what is wrong with a handed file's template naming ${consumers:…}, judged on the file's
// resource as if its text were inline.
func consumersOfTemplate(m Manifest, id, text string) []string {
for _, r := range m.Resources {
if fmt.Sprint(r["id"]) != id {
continue
}
withText := map[string]any{}
for k, v := range r {
withText[k] = v
}
withText["content"] = text
return consumersPlaceholderProblems(m, withText)
}
return nil
}
@@ -0,0 +1,33 @@
package catalogue
import (
"encoding/json"
"slices"
)
// The core's own credentials (novox/hq ADR 0349 §16, issue 755). The bus, the node-engine, the controller and
// mesh-provisioner need their files before any provisioner client runs, so their files stay placement and their
// credentials travel in the declaration as values the vault sealed to the machine they run on, which the controller
// carries and cannot open; the node-engine opens them there (mesh-host's VaultBoxContract).
// CoreCredentialModules are the modules whose own secrets are the core's credentials: the bus, mesh-provisioner and
// its client. The node-engine and the controller hold no own secret of a module's. **Fixed here, in the controller's
// code, as the clause says** — and the vault keeps the same list (mesh-vault's CoreModules), answering the controller
// for these alone.
var CoreCredentialModules = []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}
// CarriesVaultSealed says whether module's own secrets are the core's credentials.
func CarriesVaultSealed(module string) bool { return slices.Contains(CoreCredentialModules, module) }
// VaultSealedField is the field of a file the vault sealed to its machine (mesh-host's File.VaultSealed).
const VaultSealedField = "vault-sealed"
// ForeseenVaultBox stands for a core credential's box in a composition asked ahead of the send, when none is kept
// yet: the send asks the vault for it. Never sent.
var ForeseenVaultBox = json.RawMessage(`{"v":1,"eph":"","iv":"","tag":"","ct":"","foreseen":"asked of the vault by the next send"}`)
// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one.
func (with Rendering) vaultSealedOf(module, name string) (json.RawMessage, bool) {
box, ok := with.VaultSealed[module][name]
return box, ok && len(box) > 0
}
+92
View File
@@ -0,0 +1,92 @@
package catalogue
// A five-field cron validator, enough to refuse a malformed schedule near its author
// (novox/hq ADR 0053).
//
// **Validation only, and written rather than pulled in.** The control plane's part in a scheduled
// step is small and exact: carry the field to the host unchanged, and refuse a `schedule` that is
// not a well-formed cron here rather than on the machine — the same near-versus-far discipline the
// manifest keeps for an action a module may not declare and a run-once that is not a boolean. What
// *when to run it again* means is the host's to evaluate; the control plane only checks the string
// is one it could. A cron library would be a dependency carried for evaluation nobody does here.
//
// The ordinary five fields — minute, hour, day-of-month, month, day-of-week — with `*`, lists
// (`,`), ranges (`-`) and steps (`/`). No names (jan, mon): a numeric cron is what the host
// evaluates, and refusing a name here is refusing it near whoever wrote it.
import (
"fmt"
"strconv"
"strings"
)
// validateCron reports why a string is not a five-field cron expression, or nil if it is one.
func validateCron(expr string) error {
fields := strings.Fields(expr)
if len(fields) != 5 {
return fmt.Errorf(
"a schedule is a five-field cron expression (minute hour day-of-month month "+
"day-of-week), and this has %d field(s)", len(fields))
}
bounds := []struct {
name string
min, max int
}{
{"minute", 0, 59},
{"hour", 0, 23},
{"day-of-month", 1, 31},
{"month", 1, 12},
{"day-of-week", 0, 7}, // 0 and 7 are both Sunday, the convention every cron keeps
}
for i, b := range bounds {
if err := validateCronField(fields[i], b.min, b.max); err != nil {
return fmt.Errorf("%s field: %w", b.name, err)
}
}
return nil
}
// validateCronField checks one field's elements are within range and well-formed.
func validateCronField(spec string, min, max int) error {
if spec == "" {
return fmt.Errorf("is empty")
}
for _, part := range strings.Split(spec, ",") {
if part == "" {
return fmt.Errorf("%q has an empty element between commas", spec)
}
rangePart := part
if slash := strings.IndexByte(part, '/'); slash >= 0 {
rangePart = part[:slash]
n, err := strconv.Atoi(part[slash+1:])
if err != nil || n < 1 {
return fmt.Errorf("step in %q is not a positive number", part)
}
}
switch {
case rangePart == "*":
// Any value; nothing to bound.
case strings.IndexByte(rangePart, '-') >= 0:
dash := strings.IndexByte(rangePart, '-')
lo, errLo := strconv.Atoi(rangePart[:dash])
hi, errHi := strconv.Atoi(rangePart[dash+1:])
if errLo != nil || errHi != nil {
return fmt.Errorf("range %q is not two numbers", rangePart)
}
if lo < min || hi > max || lo > hi {
return fmt.Errorf("range %q is outside the allowed %d-%d", part, min, max)
}
default:
v, err := strconv.Atoi(rangePart)
if err != nil {
return fmt.Errorf("%q is not a number", rangePart)
}
if v < min || v > max {
return fmt.Errorf("%q is outside the allowed range %d-%d", part, min, max)
}
}
}
return nil
}
+791
View File
@@ -0,0 +1,791 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// A module declares the data it holds, and the mesh protects and watches it from that declaration
// (novox/hq ADR 0233).
//
// **One section, `data`, for every kind of data a module keeps:** its own, by directory — a store's
// files, a mail spool, an application's uploads — or by an operator's path it was given; what it keeps
// for its consumers, by the provision they reach it through; and what of its own lives with a
// provider, by the provision it requires. Each entry has a class — how precious it is — and, for its
// own data, how it is protected; everything the mesh does is derived from those, never written per
// module:
//
// - a binding to a consumer's data does not move (ADR 0232) where the provision's class keeps data;
// - the backup holder's lines are composed from it — a module no longer writes them by hand;
// - what an unassignment leaves behind of an irreplaceable or valuable item is retired, listed by
// `cleanup list` and deleted only by `cleanup delete` (ADR 0230);
// - the self-check measures every item and says when one shrinks, disappears, stops being written,
// goes without its backup, sits on a degraded array, or is replaced by an empty copy of itself —
// urgent for what is irreplaceable, a warning for what is valuable.
// The classes: how precious the data is. A fixed vocabulary, ranked by the operator (2026-10-06): a
// class is what the protections are derived from, so a new one is a decision, not a manifest's choice.
const (
// ClassIrreplaceable is what must never be lost: the operator names it (the media library, the
// photo sites' storage). Protected by a backup or by a declared redundancy — one is required —
// retired rather than removed, and every alert about it is urgent.
ClassIrreplaceable = "irreplaceable"
// ClassValuable is anybody's work that would be painful to lose: in the nightly backup by default,
// retired rather than removed, and every alert about it a warning.
ClassValuable = "valuable"
// ClassRebuildable can be made again from something kept elsewhere — a clone, an index, a
// download, a night's dump — at a cost in time, not in data. In the nightly backup by default;
// forgotten when its module goes, and never alerted on.
ClassRebuildable = "rebuildable"
// ClassCache may be emptied at any moment with nothing lost but speed: never backed up, never
// measured, never alerted on.
ClassCache = "cache"
// ClassNone is a provision that keeps nothing of its consumers' (consumers only): a resolver, a
// certificate authority, an artifact store.
ClassNone = "none"
)
// classRank orders the classes by how precious they are, so the stricter of two is chosen.
var classRank = map[string]int{ClassNone: 0, ClassCache: 1, ClassRebuildable: 2, ClassValuable: 3, ClassIrreplaceable: 4}
// StricterClass is the more precious of two classes.
func StricterClass(a, b string) string {
if classRank[b] > classRank[a] {
return b
}
return a
}
// Retires is whether a class's data is retired, not forgotten, when its machine no longer declares it.
func Retires(class string) bool { return class == ClassIrreplaceable || class == ClassValuable }
// Watched is whether a class's data raises conditions: irreplaceable and valuable.
func Watched(class string) bool { return Retires(class) }
// DefaultBackupWithin is how old the last good backup of an item may be before the self-check says so
// (novox/hq ADR 0214: a machine with data and no good backup in 48 hours).
const DefaultBackupWithin = 48 * time.Hour
// Data is a manifest's `data` section.
type Data struct {
// Own is the data this module keeps itself.
Own []DataItem `json:"own,omitempty"`
// Consumers is what it keeps for its consumers, per provision it grants.
Consumers map[string]ConsumerData `json:"consumers,omitempty"`
// KeptBy is what of its own lives with the provider of a provision it requires — its rows, its
// objects — and how precious that is. The provider's protections follow the stricter of its own
// class for its consumers and this.
KeptBy map[string]KeptData `json:"kept-by,omitempty"`
}
// DataItem is one piece of a module's own data.
type DataItem struct {
// ID names it within the module: what `data`, `cleanup` and a condition call it.
ID string `json:"id"`
// Path is one of the module's directories, `${dir:<id>}`, or an operator's path it was given,
// `${access:<id>}`, or a path beneath either. Never a machine path (novox/hq ADR 0112).
Path string `json:"path"`
Class string `json:"class"`
// Backup is how it is copied: "copy" (the holder reads it as it stands), "none", or a dump — a
// command that writes a consistent copy into another item, which is copied. Unsaid, anything but a
// cache is copied, unless it says it is protected by redundancy instead.
Backup *DataBackup `json:"backup,omitempty"`
// Redundancy says it is protected by the redundancy of the storage it lives on rather than by a
// copy, and why that is enough: the media library on an array there is no room to copy. The
// backup holder then watches that array, and a degraded one is said.
Redundancy string `json:"redundancy,omitempty"`
// Within is how old its last good backup may be; unsaid, DefaultBackupWithin.
Within string `json:"within,omitempty"`
// Measure is how the backup holder measures it: `walk` (the default — every file, at most daily and
// bounded, for small items), `dataset` (a ZFS dataset's own counters, hourly, nothing walked) or
// `shallow` (its top-level entries only, no size). A large item never says walk.
Measure string `json:"measure,omitempty"`
// Active is how long it may go unwritten before that is a fault — for data something is
// expected to write all the time. Unsaid, a quiet item is not a fault.
Active string `json:"active,omitempty"`
// Why is a line for the reviewer: why this class.
Why string `json:"why,omitempty"`
}
// ConsumerData is what a provider keeps for the consumers of one provision.
type ConsumerData struct {
Class string `json:"class"`
// In is where it lives: one of the module's own items (whose protection covers it), or a
// provision this module requires (whose provider keeps it, as its consumer). Unsaid only for none
// and cache.
In string `json:"in,omitempty"`
Why string `json:"why,omitempty"`
}
// KeptData is how precious what a module keeps with a provider is.
type KeptData struct {
Class string `json:"class"`
Why string `json:"why,omitempty"`
}
// DataBackup is how an item is copied.
type DataBackup struct {
Copy bool
None bool
// Dump is the command writing a consistent copy into the item Into.
Dump string
Into string
}
// UnmarshalJSON reads "copy", "none" or {"dump": "...", "into": "<item>"}.
func (b *DataBackup) UnmarshalJSON(raw []byte) error {
var word string
if err := json.Unmarshal(raw, &word); err == nil {
switch word {
case "copy":
*b = DataBackup{Copy: true}
case "none":
*b = DataBackup{None: true}
default:
return fmt.Errorf("a data item's backup is \"copy\", \"none\" or {dump, into}, not %q", word)
}
return nil
}
var full struct {
Dump string `json:"dump"`
Into string `json:"into"`
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&full); err != nil {
return fmt.Errorf("a data item's backup is \"copy\", \"none\" or {dump, into}: %w", typedUnknown(err))
}
*b = DataBackup{Dump: full.Dump, Into: full.Into}
return nil
}
// MarshalJSON writes it back in the form it was written.
func (b DataBackup) MarshalJSON() ([]byte, error) {
switch {
case b.Copy:
return json.Marshal("copy")
case b.None:
return json.Marshal("none")
}
return json.Marshal(struct {
Dump string `json:"dump"`
Into string `json:"into"`
}{b.Dump, b.Into})
}
// IsDump is whether the item is copied by a dump.
func (b *DataBackup) IsDump() bool { return b != nil && b.Dump != "" }
// BackedUp is whether the holder keeps restore points of this item: anything but a cache by default —
// the nightly backup is the standard plan — unless it says "none", or says it is protected by
// redundancy and says nothing of a backup.
func (it DataItem) BackedUp() bool {
switch {
case it.Backup == nil:
return it.Class != ClassCache && it.Redundancy == ""
case it.Backup.None:
return false
}
return true
}
// Protection is how the item is protected, in one word: "backup", "redundancy", both joined by "+",
// or "none".
func (it DataItem) Protection() string {
var by []string
if it.BackedUp() {
by = append(by, "backup")
}
if it.Redundancy != "" {
by = append(by, "redundancy")
}
if len(by) == 0 {
return "none"
}
return strings.Join(by, "+")
}
// The ways an item is measured.
const (
MeasureWalk = "walk"
MeasureDataset = "dataset"
MeasureShallow = "shallow"
)
// MeasuredBy is how the item is measured, with the default applied.
func (it DataItem) MeasuredBy() string {
if it.Measure == "" {
return MeasureWalk
}
return it.Measure
}
// OwnedByModule is whether the item is in one of the module's own directories — the mesh's to retire —
// rather than an operator's path it was given, which the mesh never retires and never deletes.
func (it DataItem) OwnedByModule() bool { return strings.HasPrefix(it.Path, "${dir:") }
// BackupWithin is the item's bound on its last good backup.
func (it DataItem) BackupWithin() time.Duration {
if d, err := ParseDataDuration(it.Within); err == nil && d > 0 {
return d
}
return DefaultBackupWithin
}
// ActiveWithin is how long the item may go unwritten; zero when quiet is not a fault.
func (it DataItem) ActiveWithin() time.Duration {
d, _ := ParseDataDuration(it.Active)
return d
}
// ParseDataDuration reads "48h", "90m" or "7d"; empty is zero.
func ParseDataDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, nil
}
if days, ok := strings.CutSuffix(s, "d"); ok {
n, err := strconv.Atoi(days)
if err != nil || n <= 0 {
return 0, fmt.Errorf("%q is not a number of days", s)
}
return time.Duration(n) * 24 * time.Hour, nil
}
d, err := time.ParseDuration(s)
if err != nil || d <= 0 {
return 0, fmt.Errorf("%q is not a duration (48h, 90m, 7d)", s)
}
return d, nil
}
// DataItems is the module's own data, in the order declared.
func (m Manifest) DataItems() []DataItem {
if m.Data == nil {
return nil
}
return m.Data.Own
}
// DataItem is one own item by id.
func (m Manifest) DataItem(id string) (DataItem, bool) {
for _, it := range m.DataItems() {
if it.ID == id {
return it, true
}
}
return DataItem{}, false
}
// ConsumerDataOf is what this module says it keeps for a provision's consumers, and whether it says.
func (m Manifest) ConsumerDataOf(provision string) (ConsumerData, bool) {
if m.Data == nil {
return ConsumerData{}, false
}
c, ok := m.Data.Consumers[provision]
return c, ok
}
// KeptByOf is how precious what this module keeps with a provision's provider is, and whether it says.
func (m Manifest) KeptByOf(provision string) (KeptData, bool) {
if m.Data == nil {
return KeptData{}, false
}
k, ok := m.Data.KeptBy[provision]
return k, ok
}
// keepsByClass is whether a class keeps something a binding must not move away from.
func keepsByClass(class string) bool {
return class == ClassIrreplaceable || class == ClassValuable || class == ClassRebuildable
}
// dataID is what an item's id may be: it is a token in a condition's key and a word on a line.
var dataID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// dataPathRef is an item's path: one of the module's directories or accesses, and optionally a path
// beneath it.
var dataPathRef = regexp.MustCompile(`^\$\{(dir|access):([a-z0-9][a-z0-9-]*)\}(/[^\s$]*)?$`)
// dataProblems is what is wrong with the `data` section itself, from the manifest alone: judged at
// registration as every other per-manifest problem is. Whether a module declares what it should is
// the catalogue check's (DataProblems), because a module already running was written before it.
func (m Manifest) dataProblems() []string {
if m.Data == nil {
return nil
}
var problems []string
say := func(format string, args ...any) {
problems = append(problems, fmt.Sprintf("%s's data: ", m.Module)+fmt.Sprintf(format, args...))
}
dirs := map[string]bool{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "directory" {
dirs[fmt.Sprint(r["id"])] = true
}
}
accesses := map[string]bool{}
for _, a := range m.Accesses {
if a.ID != "" {
accesses[a.ID] = true
}
}
ids := map[string]DataItem{}
paths := map[string]string{}
for i, it := range m.Data.Own {
label := it.ID
if label == "" {
label = fmt.Sprintf("item %d", i+1)
}
if !dataID.MatchString(it.ID) {
say("%s has no usable id: lower-case letters, digits and dashes", label)
} else if _, twice := ids[it.ID]; twice {
say("%s is declared twice", it.ID)
}
ids[it.ID] = it
ref := dataPathRef.FindStringSubmatch(it.Path)
switch {
case ref == nil:
say("%s's path %q is not one of the module's directories or accesses: ${dir:<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 ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache:
case ClassNone:
say("%s is class none, which only a provision keeping nothing of its consumers' is; own data "+
"that is disposable is cache", label)
default:
say("%s's class %q is not one the mesh protects by: irreplaceable, valuable, rebuildable or cache",
label, it.Class)
}
if it.Class == ClassIrreplaceable && !it.BackedUp() && strings.TrimSpace(it.Redundancy) == "" {
say("%s is irreplaceable and is neither backed up nor said to be protected by redundancy; the only "+
"copy of something is protected one way or the other (novox/hq ADR 0233) — copy it, dump it into "+
"another item, or say `redundancy` with why that is enough", label)
}
if it.Class == ClassCache && it.Backup != nil && !it.Backup.None {
say("%s is a cache and asks to be backed up; a cache is disposable, or it is not a cache", label)
}
if it.Class == ClassCache && it.Redundancy != "" {
say("%s is a cache and says it is protected by redundancy; a cache is not protected", label)
}
switch it.Measure {
case "", MeasureWalk, MeasureDataset, MeasureShallow:
default:
say("%s's measure %q is walk, dataset or shallow", label, it.Measure)
}
if _, err := ParseDataDuration(it.Within); err != nil {
say("%s's within: %v", label, err)
}
if _, err := ParseDataDuration(it.Active); err != nil {
say("%s's active: %v", label, err)
}
if it.Within != "" && !it.BackedUp() {
say("%s states within, and is not backed up", label)
}
if it.Active != "" && !Watched(it.Class) {
say("%s is %s and expects writes; only irreplaceable and valuable data is watched", label, it.Class)
}
}
// Dumps go into an item of the same module, declared, and not into themselves.
for _, it := range m.Data.Own {
if it.Backup == nil || it.Backup.Copy || it.Backup.None {
continue
}
switch into, ok := ids[it.Backup.Into]; {
case strings.TrimSpace(it.Backup.Dump) == "":
say("%s's backup names no dump command", it.ID)
case it.Backup.Into == "":
say("%s's dump says no item it writes into", it.ID)
case !ok:
say("%s's dump writes into %q, which is not one of the module's data items", it.ID, it.Backup.Into)
case into.ID == it.ID:
say("%s's dump writes into itself; a dump is copied from where it lands", it.ID)
case into.Class == ClassCache:
say("%s's dump writes into %s, a cache; what is copied must not be disposable", it.ID, into.ID)
}
if it.Backup.Dump != "" {
declared := map[string]string{}
for d := range dirs {
declared[d] = ""
}
if _, err := dirFill(it.Backup.Dump, declared, m.Module); err != nil {
say("%s's dump: %v", it.ID, err)
}
}
}
provided := map[string]bool{}
for _, o := range m.Provides {
provided[o.Name] = true
}
wants := map[string]bool{}
for _, w := range m.Wants() {
wants[w] = true
}
for _, provision := range sortedKeys(m.Data.Consumers) {
c := m.Data.Consumers[provision]
if !provided[provision] {
say("says what it keeps for the consumers of %q, which it does not provide", provision)
}
switch c.Class {
case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache, ClassNone:
default:
say("its consumers' %s is class %q; one of irreplaceable, valuable, rebuildable, cache or none", provision, c.Class)
}
switch {
case c.Class == ClassNone && c.In != "":
say("its consumers' %s keeps nothing and says where it is kept (%s)", provision, c.In)
case keepsByClass(c.Class) && c.In == "":
say("its consumers' %s is %s and says nowhere it lives: `in` names one of the module's items, or a "+
"provision it requires", provision, c.Class)
case c.In != "":
if own, ok := ids[c.In]; ok {
if c.Class == ClassIrreplaceable && !own.BackedUp() && own.Redundancy == "" {
say("its consumers' %s is irreplaceable and lives in %s, which is not protected", provision, c.In)
}
if classRank[own.Class] < classRank[c.Class] {
say("its consumers' %s is %s and lives in %s, which is only %s", provision, c.Class, c.In, own.Class)
}
} else if !wants[c.In] {
say("its consumers' %s lives in %q, which is neither one of its data items nor a provision it "+
"requires", provision, c.In)
}
}
}
for _, provision := range sortedKeys(m.Data.KeptBy) {
k := m.Data.KeptBy[provision]
if !wants[provision] {
say("says it keeps data with the provider of %q, which it does not require", provision)
}
switch k.Class {
case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache:
default:
say("what it keeps with %s is class %q; one of irreplaceable, valuable, rebuildable or cache", provision, k.Class)
}
}
return problems
}
// KeepsConsumerData is whether this module, providing a provision, keeps what each consumer writes
// there (novox/hq ADR 0232, ADR 0233): whether a consumer bound to it is bound to its data.
//
// **What the data section says, it gets**: irreplaceable, valuable or rebuildable keeps; cache and none
// do not — a cache lost in a move costs speed, not data. Before the section, an offer said it with
// `keeps-consumer-data`, which is still read; unsaid in both, a provider that grants each consumer a
// credential of its own keeps that consumer's data (ADR 0232 §1). The catalogue check refuses the
// unsaid case for a provider that grants (DataProblems), so the inference is what an older definition
// on the shelf gets, never a new one.
func (m Manifest) KeepsConsumerData(provision string) bool {
if c, ok := m.ConsumerDataOf(provision); ok {
return keepsByClass(c.Class)
}
for _, o := range m.Provides {
if o.Name == provision && o.KeepsConsumerData != nil {
return *o.KeepsConsumerData
}
}
_, grants := m.Grants[provision]
return grants
}
// NeedsBackupHolder is whether a module's data needs the machine's backup holder to be there: it keeps
// something irreplaceable — backed up by the holder, or protected by an array the holder watches. A
// module keeping only valuable or rebuildable data is backed up where a holder is, and refused nowhere
// for want of one: the standard plan, not a requirement.
func (m Manifest) NeedsBackupHolder() bool {
for _, it := range m.DataItems() {
if it.Class == ClassIrreplaceable {
return true
}
}
return false
}
// --- derived: the backup holder's lines ---------------------------------------------------------
// The node-backup seat's kinds (novox/hq to-be 43, ADR 0233): `backup` is what to run and which paths
// to keep, `data` every item with its class and protection, for the holder to measure and watch.
const (
BackupKindBackup = "backup"
BackupKindData = "data"
)
// derivedContributions is what a module's data section gives the backup holder: its backup lines and
// its items. Every item is listed, so a valuable one on a machine is measured whether or not it is
// copied; a cache is listed and not measured.
func (m Manifest) derivedContributions() []SeatContribution {
items := m.DataItems()
if len(items) == 0 {
return nil
}
var lines []string
kept := map[string]bool{}
keep := func(path string) {
if !kept[path] {
kept[path] = true
lines = append(lines, "path "+path)
}
}
for _, it := range items {
if !it.BackedUp() {
continue
}
if it.Backup.IsDump() {
lines = append(lines, "run "+strings.TrimSpace(it.Backup.Dump))
if into, ok := m.DataItem(it.Backup.Into); ok {
keep(into.Path)
}
continue
}
keep(it.Path)
}
var described []string
for _, it := range items {
covered := "-"
switch {
case it.Backup.IsDump():
if into, ok := m.DataItem(it.Backup.Into); ok {
covered = into.Path
}
case it.BackedUp():
covered = it.Path
}
described = append(described, fmt.Sprintf("item %s %s %s %s %s %s", it.ID, it.Class, it.Path, covered,
it.Protection(), it.MeasuredBy()))
}
var out []SeatContribution
if len(lines) > 0 {
out = append(out, SeatContribution{Seat: BackupSeat, Kind: BackupKindBackup, Content: strings.Join(lines, "\n") + "\n"})
}
return append(out, SeatContribution{Seat: BackupSeat, Kind: BackupKindData, Content: strings.Join(described, "\n") + "\n"})
}
// allContributions is every contribution a module makes to a seat's holder: what it wrote, and what
// its data section derives. **One list**: a module that declares its data has its backup lines
// composed from it, and a backup line it still writes by hand is not placed — the catalogue check
// refuses it — so the holder never copies two lists that disagree.
func (m Manifest) allContributions() []SeatContribution {
if m.Data == nil {
return m.Contributions
}
derived := m.derivedContributions()
out := make([]SeatContribution, 0, len(m.Contributions)+len(derived))
for _, c := range m.Contributions {
if s, known := SeatNamed(c.Seat); known && s.Name == BackupSeat {
continue
}
out = append(out, c)
}
return append(out, derived...)
}
// --- the catalogue check ------------------------------------------------------------------------
// DataProblems is what the catalogue check refuses about the data a module declares (novox/hq ADR
// 0233), judged over the manifests given together:
//
// - a provider that grants a provision says what it keeps for that provision's consumers;
// - nobody says it on the offer any more (`keeps-consumer-data`): the data section is the one place;
// - nobody writes a backup line by hand: it is derived from the data section;
// - a directory a container writes, mounted whole, is a data item of some class;
// - consumer data said to live in a required provision lives where that provision's provider keeps
// its consumers' data, as preciously, when the provider is given;
// - data a consumer keeps with a provider as irreplaceable is protected there, when the provider is
// given.
//
// **The check's, not registration's**, as ADR 0214's backup rule was: a module already on the shelf
// was written before the rule, and refusing it there would refuse the very providers whose data the
// rule protects. Each module meets it where it is written.
func DataProblems(shelf Shelf) []string {
var problems []string
for _, name := range shelfOrder(shelf) {
m := shelf[name]
for _, provision := range sortedKeys(m.Grants) {
if _, said := m.ConsumerDataOf(provision); !said {
problems = append(problems, fmt.Sprintf(
"%s grants %s and does not say what it keeps for its consumers: data.consumers.%s with a "+
"class — irreplaceable, valuable, rebuildable, cache, or none (novox/hq ADR 0233)", name, provision, provision))
}
}
for _, o := range m.Provides {
if o.KeepsConsumerData != nil {
problems = append(problems, fmt.Sprintf(
"%s says keeps-consumer-data on its offer of %s; it is said once, in data.consumers.%s, with a "+
"class (novox/hq ADR 0233)", name, o.Name, o.Name))
}
}
for i, c := range m.Contributions {
if s, known := SeatNamed(c.Seat); known && s.Name == BackupSeat {
problems = append(problems, fmt.Sprintf(
"%s's contribution %d is a backup line written by hand; backups are derived from the data "+
"section — declare the data, with its class and how it is protected (novox/hq ADR 0233)", name, i+1))
}
}
for _, dir := range writtenDirectories(m) {
if !coversDirectory(m, dir) {
problems = append(problems, fmt.Sprintf(
"%s mounts its directory %q into a container to be written, and declares no data in it: "+
"data.own with a class — cache if it is disposable (novox/hq ADR 0233)", name, dir))
}
}
if m.Data == nil {
continue
}
for _, provision := range sortedKeys(m.Data.Consumers) {
c := m.Data.Consumers[provision]
if c.In == "" {
continue
}
if _, own := m.DataItem(c.In); own {
continue
}
for _, pname := range shelfOrder(shelf) {
p := shelf[pname]
pc, said := p.ConsumerDataOf(c.In)
if !said || !providesName(p, c.In) {
continue
}
if classRank[pc.Class] < classRank[c.Class] {
problems = append(problems, fmt.Sprintf(
"%s keeps its consumers' %s, %s, in %s — and %s keeps its consumers' %s as %s, "+
"so it is not protected as %s", name, provision, c.Class, c.In, pname, c.In, pc.Class, c.Class))
}
}
}
for _, provision := range sortedKeys(m.Data.KeptBy) {
k := m.Data.KeptBy[provision]
if k.Class != ClassIrreplaceable {
continue
}
for _, pname := range shelfOrder(shelf) {
p := shelf[pname]
pc, said := p.ConsumerDataOf(provision)
if !said || !providesName(p, provision) {
continue
}
if !keepsByClass(pc.Class) {
problems = append(problems, fmt.Sprintf(
"%s keeps irreplaceable data with %s, and %s keeps its consumers' %s as %s — nothing of it is "+
"protected there", name, provision, pname, provision, pc.Class))
continue
}
if in, own := p.DataItem(pc.In); own && !in.BackedUp() && in.Redundancy == "" {
problems = append(problems, fmt.Sprintf(
"%s keeps irreplaceable data with %s, and %s keeps it in %s, which is neither backed up nor "+
"on declared redundancy", name, provision, pname, pc.In))
}
}
}
}
return problems
}
func providesName(m Manifest, provision string) bool {
for _, o := range m.Provides {
if o.Name == provision {
return true
}
}
return false
}
// writtenDirectories are the module's directories a container mounts whole and may write: a volume
// `${dir:<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 Manifest) []string {
absolute := map[string]string{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "directory" {
continue
}
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
absolute[strings.TrimRight(p, "/")] = fmt.Sprint(r["id"])
}
}
seen := map[string]bool{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "container" {
continue
}
vols, _ := r["volumes"].([]any)
for _, v := range vols {
s, _ := v.(string)
mount := volumeMount.FindStringSubmatch(s)
if mount == nil || volumeReadOnly(mount[3]) {
continue
}
src := strings.TrimRight(mount[1], "/")
if ref := dirPlain.FindStringSubmatch(src); ref != nil {
seen[ref[1]] = true
} else if id, ok := absolute[src]; ok {
seen[id] = true
}
}
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
sort.Strings(out)
return out
}
// volumeMount is a container's volume, `<source>:<target>[:<options>]`, its source possibly a
// `${dir:<id>}` — whose own colon is not the separator.
var volumeMount = regexp.MustCompile(`^(\$\{(?:dir|access):[a-z0-9][a-z0-9-]*\}[^:]*|[^:]+):([^:]+)(?::(.*))?$`)
// volumeReadOnly is whether a volume's options mount it read-only.
func volumeReadOnly(options string) bool {
for _, o := range strings.Split(options, ",") {
if strings.TrimSpace(o) == "ro" {
return true
}
}
return false
}
// dirPlain is a whole directory, `${dir:<id>}` and nothing after it.
var dirPlain = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9-]*)\}$`)
// coversDirectory is whether a data item names the directory, a path within it, or a directory it
// is placed beneath.
func coversDirectory(m Manifest, dir string) bool {
parents := map[string]string{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "directory" {
continue
}
if p, ok := r["path"].(string); ok {
if ref := dirRef.FindStringSubmatch(p); ref != nil && strings.HasPrefix(p, "${dir:") {
parents[fmt.Sprint(r["id"])] = ref[1]
}
}
}
for _, it := range m.DataItems() {
ref := dataPathRef.FindStringSubmatch(it.Path)
if ref == nil || ref[1] != "dir" {
continue
}
for d, hops := dir, 0; d != "" && hops < 4; d, hops = parents[d], hops+1 {
if ref[2] == d {
return true
}
}
}
return false
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
package catalogue
// The delivery's owner (novox/hq ADR 0239, to-be 47): one holder for the mesh, the module mesh-delivery,
// which owns a delivery — one commit in one repository, from its pull request's head to every machine —
// and a delivery group, and asks the controller for every act. A seat of the mesh's own, so the role has
// one name whoever holds it, and the controller can tell whether anything holds it: while nothing does,
// the controller starts every walk itself, as it did before there was a delivery to own.
// DeliverySeat is the seat mesh-delivery holds.
const DeliverySeat = "mesh-delivery"
// deliveryVerbs are the delivery seat's tools: six that read, and the acts of a person, a reviewer and healer H2.
func deliveryVerbs() []Verb {
return []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")},
// Delivery time against the delivery budgets (novox/hq ADR 0282): what probe D16 reads, optional until its
// holder serves it.
{Name: "times", Description: "Delivery time: from a merge to every machine of its walk running the build. " +
"Each class's delivery budget (a leaf module three minutes, a core module ten), the last days' median and " +
"worst, how many within and over, the newest delivery of each class, every delivery over its budget with its " +
"longest phase, and the delivered ones whose time is not known. Given a delivery's id, its time phase by phase.",
Input: schema(map[string]string{"days": "how many days back (default 7)", "id": "one delivery's id: its phases"}, nil),
// Optional until mesh-delivery serves it: its holder lives in the catalogue (design 33 §7).
Optional: true},
{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")},
{Name: "what-if", Description: "The delivery plan a change would have, asked of the controller's planner " +
"and kept nowhere: the modules it moves and their dependents in tiers, what each machine would receive, " +
"the steps that are not an ordinary send.",
Input: schema(map[string]string{"repository": "owner/repository",
"paths": "the files it changes, comma-separated, from the repository's root",
"base": "the branch it merges into (default main)"}, []string{"repository", "paths"})},
{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/merge-gate, 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"})},
{Name: "release", Description: "A person's word that a held delivery goes on: it starts delivering. With why.",
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
[]string{"id", "why"})},
{Name: "stop", Description: "Stop a delivery that is not final, with why; its walk is ended through the " +
"controller, and in a group every member after it is stopped too, naming it.",
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
[]string{"id", "why"})},
{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"})},
{Name: "retire-history", Description: "Retire held deliveries that are history, on a person's word, with " +
"why: each made from the forge's word for a merge on the trunk heard long after it happened — replayed " +
"when the holder first held the seat — with no walk of it open. Never released, delivered or put back: " +
"retired, final. One by id, refused when the rule does not take it; or every held one, of one " +
"repository when named, answering how many it retired and naming those it left held. dry \"true\" " +
"answers what it would do and changes nothing.",
Input: schema(map[string]string{"why": "why, kept with every transition",
"id": "one delivery's id; every held one when empty",
"repository": "owner/repository: only its held deliveries",
"dry": "\"true\": what it would retire, changing nothing"}, []string{"why"}, "dry"),
// Added after the seat's holder shipped (novox/hq issue 313): optional until mesh-delivery serves it.
Optional: true},
{Name: "mark-ready", Description: "A reviewer's word that a delivery's head is approved: the holder puts the " +
"label mesh/ready on its pull request and then writes \"ready at <head>\", the order the forge counts, " +
"as the mesh's own forge account, naming who reviewed it and why. Refused when the delivery's commit is " +
"no longer its pull request's head. mesh-delivery then merges it once its checks pass (novox/hq ADR 0364).",
Input: schema(map[string]string{"id": "the delivery's id (owner/repository@commit): the head reviewed",
"reviewed-by": "who reviewed it, as who@node", "why": "the review's verdict in a line"},
[]string{"id", "reviewed-by", "why"}),
// Kept one more release, optional (novox/hq issue 1022): mesh-delivery no longer serves it, but the manifest
// it still has on the mesh does until its catalogue member merges, and a seat refuses a holder that serves
// a verb it does not promise. The next release removes this entry.
Optional: true},
}
}
// SeatSays is whether a consumed `<seat>.<event>` names one of the seat's own events: the seat emits it,
// exactly or by one of its patterns (`log.*`). A consumed name whose emitter is a seat and whose event the
// seat does not emit is the event of the module of that name (novox/hq ADR 0239): mesh-delivery is a seat
// and the module holding it, and what the module says it says as itself, through the runtime that launches
// it — read as the seat's, a consumer would subscribe a subject nothing may publish.
func SeatSays(emits []string, event string) bool {
for _, e := range emits {
if e == event || topicMatches(e, event) {
return true
}
}
return false
}
// topicMatches is whether an event's name matches a declared pattern, `*` one dot-separated segment and
// `>` or `**` the rest.
func topicMatches(pattern, event string) bool {
p, e := splitDots(pattern), splitDots(event)
for i, part := range p {
if part == ">" || part == "**" {
return len(e) > i
}
if i >= len(e) || (part != "*" && part != e[i]) {
return false
}
}
return len(p) == len(e)
}
func splitDots(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '.' {
out = append(out, s[start:i])
start = i + 1
}
}
return append(out, s[start:])
}
@@ -0,0 +1,373 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// A directory the mesh places (novox/hq ADR 0112, to-be 27, issue 119).
//
// **A module definition names no host path.** A directory resource may omit `path`; the mesh
// resolves where it lands when the declaration is composed — `<root>/<module>/<id>`, the root a
// node's own setting with /var/lib as the default. From then on the module's own files, mounts
// and environment name the place as `${dir:<id>}`, the same shape as `${bound:…}` and
// `${secret:…}`: a fact the module asks for by name and never states.
//
// **A directory that states a path keeps it, and still answers `${dir:<id>}`.** That is the
// placement for an adopted machine: data that must sit where the predecessor already put it is
// declared with the path as the exception it is, and everything else in the module names it by
// id — so moving it later is one line, not a search.
//
// **The mesh's own files for a module are placed too** (novox/hq issue 174). What the mesh writes
// *for* a module — its sealed bus credential, its merged configuration, its bindings — is the
// mesh's plumbing, not the module's data, and sits under `<root>/mesh/<module>`. A directory
// saying `"place": "mesh"` is that place; the definition names the files beneath it by
// `${dir:<id>}` and states no path.
//
// **Resolved here, not on the machine.** The host receives concrete paths exactly as it always
// has; nothing new reaches it and it learns no field. Which also means a resolved path changing
// is a spec change like any other — and the spec comparison must see it (novox/hq issue 126).
// defaultDataRoot is where module data lands when a node states no root of its own.
const defaultDataRoot = "/var/lib"
// The two places a pathless directory may name, beside its own id.
const (
// placeOwn is the assignment's own root, <root>/<module> — to-be 27's one directory per
// assignment, which every other placed thing of the module sits beneath.
placeOwn = "."
// placeMesh is where the mesh keeps what it writes for the module, <root>/mesh/<module>.
placeMesh = "mesh"
)
// dirRef is how a module names one of its placed directories: ${dir:<id>}.
var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`)
// dataRoot is the root this node keeps placed directories under.
func dataRoot(with Rendering) string {
if root := strings.TrimRight(strings.TrimSpace(with.DataRoot), "/"); root != "" {
return root
}
return defaultDataRoot
}
// 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 Manifest, with 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, _ := 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
}
// placedOrAbsolute says a path is usable where the mesh needs one: absolute already, or
// beginning with a placed reference — resolution makes it absolute before anything reads it.
// (unknownDirRefs is what checks the reference names a real directory.)
func placedOrAbsolute(path string) bool {
return strings.HasPrefix(path, "/") ||
(strings.HasPrefix(path, "${dir:") && dirRef.MatchString(path))
}
// dirFill resolves every ${dir:…} in one string, or refuses a reference naming no directory.
func dirFill(s string, dirs map[string]string, module string) (string, error) {
var missing error
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
}
// placedManifest is the manifest with every path the mesh resolves already resolved: the maps
// naming where bindings, credentials and contributions land are filled against this node's
// placed directories, so everything downstream — the generated binding files, the sealed
// secrets, the grant directories — reads a concrete place and learns nothing new.
func placedManifest(m Manifest, with Rendering) (Manifest, error) {
dirs := dirsFor(m, with)
fillMap := func(in map[string]string) (map[string]string, error) {
if len(in) == 0 {
return in, nil
}
out := make(map[string]string, len(in))
for key, value := range in {
filled, err := dirFill(value, dirs, m.Module)
if err != nil {
return nil, err
}
out[key] = filled
}
return out, nil
}
var err error
if m.Receives, err = fillMap(m.Receives); err != nil {
return m, err
}
if m.Binds, err = fillMap(m.Binds); err != nil {
return m, err
}
if m.Secrets, err = fillMap(m.Secrets); err != nil {
return m, err
}
if len(m.OwnSecrets) > 0 {
own := make(OwnSecrets, len(m.OwnSecrets))
for name, s := range m.OwnSecrets {
filled, err := dirFill(s.Path, dirs, m.Module)
if err != nil {
return m, err
}
own[name] = OwnSecret{Path: filled, Taken: s.Taken, IssuedBy: s.IssuedBy, Prompt: s.Prompt, Owner: s.Owner}
}
m.OwnSecrets = own
}
if m.Grants, err = fillMap(m.Grants); err != nil {
return m, err
}
if len(m.SecretsMany) > 0 {
many := make(map[string]map[string]string, len(m.SecretsMany))
for to, locals := range m.SecretsMany {
if many[to], err = fillMap(locals); err != nil {
return m, err
}
}
m.SecretsMany = many
}
return m, nil
}
// dirInto places a resource: a pathless directory is given the path the mesh resolved for it,
// and every ${dir:…} the resource carries — in its path, its content, its mounts, its
// environment and its env-files — becomes that path.
//
// A reference naming no directory of this module is refused. Left as written, the literal
// `${dir:x}` would reach the machine as a path, and the runtime would create and mount a
// directory called `${dir:x}` — real, wrong, and named after the mistake.
func dirInto(resource map[string]any, dirs map[string]string, module string) error {
fill := func(s string) (string, error) { return dirFill(s, dirs, module) }
if fmt.Sprint(resource["type"]) == "directory" {
id := fmt.Sprint(resource["id"])
if path, stated := resource["path"].(string); !stated || path == "" {
resource["path"] = dirs[id]
}
// Said in the catalogue, not on the machine: the host parses strictly and knows no
// such field — resolved, the place IS the path.
delete(resource, "place")
}
var err error
if path, ok := resource["path"].(string); ok {
if resource["path"], err = fill(path); err != nil {
return err
}
}
if content, ok := resource["content"].(string); ok {
if resource["content"], err = fill(content); err != nil {
return err
}
}
// Nested values are rebuilt, never written into: the resource is a shallow copy of the
// manifest's own map, and the manifest is composed once per node — a fill written in place
// would leave the first node's paths inside every later composition.
if volumes, ok := resource["volumes"].([]any); ok {
filled := make([]any, len(volumes))
for i, v := range volumes {
filled[i] = v
if mount, ok := v.(string); ok {
if filled[i], err = fill(mount); err != nil {
return err
}
}
}
resource["volumes"] = filled
}
if env, ok := resource["env"].(map[string]any); ok {
filled := make(map[string]any, len(env))
for key, v := range env {
filled[key] = v
if value, ok := v.(string); ok {
if filled[key], err = fill(value); err != nil {
return err
}
}
}
resource["env"] = filled
}
if files, ok := resource["env-file"].([]any); ok {
filled := make([]any, len(files))
for i, v := range files {
filled[i] = v
if path, ok := v.(string); ok {
if filled[i], err = fill(path); err != nil {
return err
}
}
}
resource["env-file"] = filled
}
return nil
}
// unknownDirRefs is every ${dir:…} in the definition that names no directory the definition
// declares — refused where the author is, not at composition on some later day (the same
// near-versus-far reasoning as the host's strict parse).
func (m Manifest) unknownDirRefs() []string {
declared := map[string]bool{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "directory" {
declared[fmt.Sprint(r["id"])] = true
}
}
referenced := func(s string) []string {
var ids []string
for _, match := range dirRef.FindAllStringSubmatch(s, -1) {
ids = append(ids, match[1])
}
return ids
}
var problems []string
for _, r := range m.Resources {
place, said := r["place"].(string)
if !said {
continue
}
if fmt.Sprint(r["type"]) != "directory" {
problems = append(problems, fmt.Sprintf(
"%s says place on %v, which is not a directory — only a directory is placed",
m.Module, r["id"]))
continue
}
if path, stated := r["path"].(string); stated && path != "" {
problems = append(problems, fmt.Sprintf(
"%s states both path and place on %v — a stated path IS the placement",
m.Module, r["id"]))
}
if place != placeOwn && place != placeMesh {
problems = append(problems, fmt.Sprintf(
"%s says place %q on %v, and the places are %q — the assignment's own root — and "+
"%q — where the mesh keeps what it writes for the module",
m.Module, place, r["id"], placeOwn, placeMesh))
}
}
seen := map[string]bool{}
refuse := func(id string, where any) {
if declared[id] || seen[id] {
return
}
seen[id] = true
problems = append(problems, fmt.Sprintf(
"%s says ${dir:%s} in %v, and declares no directory %q — a reference the mesh "+
"cannot place would reach the machine as a literal path",
m.Module, id, where, id))
}
for _, r := range m.Resources {
for _, field := range []string{"path", "content"} {
if s, ok := r[field].(string); ok {
for _, id := range referenced(s) {
refuse(id, r["id"])
}
}
}
for _, field := range []string{"volumes", "env-file"} {
if list, ok := r[field].([]any); ok {
for _, v := range list {
if s, ok := v.(string); ok {
for _, id := range referenced(s) {
refuse(id, r["id"])
}
}
}
}
}
if env, ok := r["env"].(map[string]any); ok {
for _, v := range env {
if s, ok := v.(string); ok {
for _, id := range referenced(s) {
refuse(id, r["id"])
}
}
}
}
}
maps := map[string]map[string]string{
"receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets,
"own-secrets": m.OwnSecrets.Paths(), "grants": m.Grants,
}
for field, entries := range maps {
for _, value := range entries {
for _, id := range referenced(value) {
refuse(id, field)
}
}
}
for to, locals := range m.SecretsMany {
for _, value := range locals {
for _, id := range referenced(value) {
refuse(id, "secrets."+to)
}
}
}
sort.Strings(problems)
return problems
}
func namesOfDirs(dirs map[string]string) []string {
var names []string
for id := range dirs {
names = append(names, fmt.Sprintf("%q", id))
}
sort.Strings(names)
return names
}
@@ -0,0 +1,7 @@
// Copied from mesh-controller internal/catalogue at commit 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (novox/hq ADR 0453
// clause 3, ADR 0446 clause 4), so the builder, the checker and the assigner can read and resolve a manifest. The files
// are unchanged but for import paths, this note and KnownCapabilities, which gains user-namespaces (issue 1308's name).
// The test files that read mesh-controller's other packages or its own module.json stay in mesh-controller, as do two
// tests of single files here that read its module.json (TestTheControllersOwnManifestClaimsItsSeat and
// TestTheControlPlanesOwnAddressesFollowTheNodesPorts). mesh-controller keeps its own copy until release 4b.
package catalogue
@@ -0,0 +1,731 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// The account's environment and the login shell's code, composed from the modules a node runs
// (novox/hq ADR 0203, ADR 0204).
//
// **The same shape as the jails.** Every module may contribute — a toolchain its directory on PATH,
// a version manager a variable naming its home, a prompt the code that loads it — naming no node, no
// path and no file of the shell's (ADR 0112). The one module holding the matching seat places the
// result with a placeholder in its own file, and the controller fills it from every module on the
// node. A node not running a module has none of its contribution, and unassigning one takes its
// lines away at the next composition.
//
// **Two kinds of contribution, kept apart on purpose.** The environment is facts, which the
// controller writes in two standard formats — POSIX assignment and the service manager's
// environment.d — so a terminal, a script, the login shell's `execute` and a graphical session all
// read the same values (ADR 0203). Shell code is not a fact: it is text in one shell's syntax, which
// the controller sorts into a slot and pastes without reading, as it pastes a jail's stanza (ADR
// 0204).
// EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules
// contributed: the account's environment, and the login shell's code.
const (
EnvironmentSeat = "node-environment"
LoginShellSeat = "node-login-shell"
// PowerSeat is the seat whose holder places code for the power moments (novox/hq ADR 0211).
PowerSeat = "node-power"
)
// Where an environment entry on PATH goes: before the account's existing PATH, or after it.
const (
PathAtStart = "start"
PathAtEnd = "end"
)
// Environment is what one module adds to the account's environment (novox/hq ADR 0203).
type Environment struct {
// Variables are names and literal values. A value may name the machine's own facts with
// ${machine:…}, resolved before anything is written, and nothing else that expands.
Variables map[string]string `json:"variables,omitempty"`
// Path is entries on the account's PATH, each at its start or its end, in the order declared.
Path []PathEntry `json:"path,omitempty"`
}
// PathEntry is one directory a module puts on the account's PATH.
type PathEntry struct {
Entry string `json:"entry"`
At string `json:"at"`
}
// ShellCode is one piece of code a module adds to a shell's startup (novox/hq ADR 0204).
type ShellCode struct {
// For is the shell the code is written in.
For string `json:"for"`
// Slot is where it runs among the other modules' code: first, normal or last. Named rather
// than numbered, because every contributor would guess a number and a collision says nothing.
Slot string `json:"slot"`
// Code is never interpreted — it is the shell's syntax, and only the shell reads it.
Code string `json:"code"`
}
// The shells and slots a contribution may name (novox/hq ADR 0204). Closed, so a typo is a refusal
// at the check rather than code that silently lands in no placeholder.
var (
knownShells = []string{"zsh", "bash", "fish"}
knownSlots = []string{"first", "normal", "last"}
// sessionFiles are the two files of the graphical session's start that read no directory, so a
// contribution to them is a slot rather than a drop-in (novox/hq ADR 0208 §4): `xinitrc` is POSIX
// code the session's start runs, `xresources` X resources merged at its start. Placed by the
// display server's holder, as a shell's slots are placed by the login shell's.
sessionFiles = []string{"xinitrc", "xresources"}
// powerMoments are the moments of a machine's power a module may run code at (novox/hq ADR
// 0211 §3): POSIX code run as root by node-power's holder, in module order, each piece bounded.
powerMoments = []string{"after-boot", "before-sleep", "after-wake", "before-shutdown", "on-mains", "on-battery"}
)
// contributionTargets is every name a contribution's `for` may take.
func contributionTargets() []string {
out := append(append([]string(nil), knownShells...), sessionFiles...)
return append(out, powerMoments...)
}
// 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 DisplayServerSeat
}
if oneOf(powerMoments, target) {
return PowerSeat
}
return LoginShellSeat
}
// The two renderings of the environment a holder may place (novox/hq ADR 0203, decision 3).
const (
EnvironmentPOSIX = "posix"
EnvironmentSystemd = "systemd"
)
// ofEnvironment and ofShell are where a holder places what was contributed: ${environment:posix},
// ${environment:systemd} and ${shell:<shell>:<slot>}. Loose inside the braces on purpose, so a
// misspelt key is found and refused rather than left in a file as a literal nobody reads.
var (
ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`)
ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`)
// ofContributed is either kind of contributed text, matched together so both fill in one pass.
ofContributed = regexp.MustCompile(`\$\{(shell|contribution|backup):([^}]*)\}`)
)
// variableName is a POSIX shell variable name, which is also what environment.d accepts.
var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// environmentProblems is what is wrong with this module's environment contribution, from the
// manifest alone.
func (m Manifest) environmentProblems() []string {
if m.Environment == nil {
return nil
}
var problems []string
for _, n := range sortedKeys(m.Environment.Variables) {
switch {
case !variableName.MatchString(n):
problems = append(problems, fmt.Sprintf(
"%s sets the variable %q, which is not a name a shell accepts: a letter or an "+
"underscore, then letters, digits and underscores", m.Module, n))
continue
case n == "PATH":
// PATH is the one variable every module shares, so no module may set it whole: a second
// setter would replace the first's entries, and the account's own PATH with them.
problems = append(problems, fmt.Sprintf(
"%s sets PATH as a variable; a module adds an entry under environment.path, at the "+
"start or the end, and PATH is composed from every module's (novox/hq ADR 0203)", m.Module))
continue
}
if why := literalProblem(m.Environment.Variables[n]); why != "" {
problems = append(problems, fmt.Sprintf(
"%s sets %s to %q, which %s — %s", m.Module, n, m.Environment.Variables[n], why, literalRule))
}
}
seen := map[string]bool{}
for i, p := range m.Environment.Path {
switch {
case p.Entry == "":
problems = append(problems, fmt.Sprintf("%s's PATH entry %d names no directory", m.Module, i+1))
case strings.Contains(ofMachine.ReplaceAllString(p.Entry, ""), ":"):
// A colon is PATH's own separator, so an entry holding one is two entries, and the
// check that it is already present would look for the wrong thing.
problems = append(problems, fmt.Sprintf(
"%s puts %q on PATH, which holds a colon, PATH's own separator", m.Module, p.Entry))
case seen[p.Entry]:
problems = append(problems, fmt.Sprintf("%s puts %q on PATH twice", m.Module, p.Entry))
default:
if why := literalProblem(p.Entry); why != "" {
problems = append(problems, fmt.Sprintf(
"%s puts %q on PATH, which %s — %s", m.Module, p.Entry, why, literalRule))
}
}
seen[p.Entry] = true
if p.At != PathAtStart && p.At != PathAtEnd {
problems = append(problems, fmt.Sprintf(
"%s puts %q on PATH at %q; an entry goes at %q or %q of the account's PATH",
m.Module, p.Entry, p.At, PathAtStart, PathAtEnd))
}
}
return problems
}
// literalRule is why a value must be literal, said with every refusal of one.
const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " +
"names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)"
// 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 ""
}
// shellProblems is what is wrong with this module's shell code, from the manifest alone. The code
// itself is not judged: it is the shell's syntax, which the controller does not read.
func (m Manifest) shellProblems() []string {
var problems []string
for i, c := range m.Shell {
if !oneOf(contributionTargets(), c.For) {
problems = append(problems, fmt.Sprintf(
"%s's shell code %d is for %q; the shells are %s, the session's files %s, and the power "+
"moments %s", m.Module, i+1, c.For, strings.Join(knownShells, ", "),
strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", ")))
}
if !oneOf(knownSlots, c.Slot) {
problems = append(problems, fmt.Sprintf(
"%s's shell code %d goes in the slot %q; the slots are %s", m.Module, i+1, c.Slot,
strings.Join(knownSlots, ", ")))
}
if strings.TrimSpace(c.Code) == "" {
problems = append(problems, fmt.Sprintf("%s's shell code %d has no code", m.Module, i+1))
}
}
return problems
}
// contributionPlaceholderProblems is every place this module's resources name the environment or
// the shell's code and may not — judged from the manifest, so the catalogue check refuses it before
// a mesh does, and again at composition in the same words.
func (m Manifest) contributionPlaceholderProblems() []string {
var problems []string
for _, r := range m.Resources {
problems = append(problems, placeholderProblems(m, r)...)
problems = append(problems, seatPlaceholderProblems(m, r)...)
problems = append(problems, collectedPlaceholderProblems(m, r)...)
problems = append(problems, consumersPlaceholderProblems(m, r)...)
// And every placeholder no pass fills where it stands: a misspelt namespace, a key its
// namespace cannot take, or a field its pass does not read (novox/hq issue 231).
problems = append(problems, unconsumedPlaceholders(m.Module, r)...)
}
return problems
}
// placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}.
//
// **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat
// 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 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] != EnvironmentPOSIX && e[1] != 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], EnvironmentPOSIX, EnvironmentSystemd))
}
}
if len(env) > 0 && !m.ClaimsSeat(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, 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 == 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 == 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
}
func placeholderOf(env, code [][]string) string {
if len(env) > 0 {
return env[0][0]
}
return code[0][0]
}
// contributedEnvironment is one node's environment, gathered and in the order it is written.
type contributedEnvironment struct {
// variables is by module in name order, each module's sorted by name.
variables []setBy
// start and end are PATH's entries in their final order, each once.
start, end []placedOn
}
type setBy struct {
module string
names []string
values map[string]string
}
type placedOn struct {
module, entry string
}
// inModuleOrder is the modules sorted by name — the order contributions are written in (novox/hq
// ADR 0203, ADR 0204), so the same set composes byte for byte whatever order they were assigned in.
func inModuleOrder(modules []Manifest) []Manifest {
out := append([]Manifest(nil), modules...)
sort.SliceStable(out, func(a, b int) bool { return out[a].Module < out[b].Module })
return out
}
// variablesSetOnce refuses a variable two modules on one node both set (novox/hq ADR 0203 §5),
// naming both. Neither is chosen: whichever was written last would win in one reader and not
// necessarily in the other, and the module that lost would not be told.
func variablesSetOnce(modules []Manifest) error {
setter := map[string]string{}
for _, m := range inModuleOrder(modules) {
e := environmentOf(m)
if e == nil {
continue
}
for _, n := range sortedKeys(e.Variables) {
if first, taken := setter[n]; taken {
return fmt.Errorf(
"%s and %s both set %s on this machine; the account has one environment, so one "+
"of them must stop setting it (novox/hq ADR 0203)", first, m.Module, n)
}
setter[n] = m.Module
}
}
return nil
}
// environmentOn gathers every module's environment on a node, with the machine's facts in place.
//
// A PATH entry two modules both add is written once, where the first puts it: two toolchains
// sharing ~/.local/bin is ordinary, and nothing about it is in conflict.
func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvironment, error) {
var env contributedEnvironment
if err := variablesSetOnce(modules); err != nil {
return env, err
}
placed := map[string]bool{}
for _, m := range inModuleOrder(modules) {
e := environmentOf(m)
if e == nil {
continue
}
if len(e.Variables) > 0 {
set := setBy{module: m.Module, values: map[string]string{}}
for _, n := range sortedKeys(e.Variables) {
v, err := factsIn(e.Variables[n], facts, m.Module, n)
if err != nil {
return env, err
}
set.names = append(set.names, n)
set.values[n] = v
}
env.variables = append(env.variables, set)
}
for _, p := range e.Path {
entry, err := factsIn(p.Entry, facts, m.Module, "a PATH entry")
if err != nil {
return env, err
}
if strings.Contains(entry, ":") {
return env, fmt.Errorf("%s puts %q on PATH on this machine, which holds a colon, PATH's own separator",
m.Module, entry)
}
if placed[entry] {
continue
}
placed[entry] = true
if p.At == PathAtEnd {
env.end = append(env.end, placedOn{m.Module, entry})
} else {
env.start = append(env.start, placedOn{m.Module, entry})
}
}
}
return env, nil
}
// environmentOf is a module's environment as the old form and the items of the environment collection say it: an
// item's template is the variable's value or the PATH entry, and its attributes the kind, the name and where on PATH
// (design 57 L3, issue 795). A moved contributor's items are rendered here into a collector not handed, as the
// provisioner's client renders them into one that is. An item whose text the mesh does not hold is not here; it is
// said as not placed (itemsLeftOutFor).
func environmentOf(m Manifest) *Environment {
var out *Environment
if m.Environment != nil {
out = &Environment{Variables: map[string]string{}, Path: append([]PathEntry(nil), m.Environment.Path...)}
for k, v := range m.Environment.Variables {
out.Variables[k] = v
}
}
for _, it := range itemsInOrder(m.Items) {
if it.Collection != EnvironmentCollection || it.Content == "" {
continue
}
if out == nil {
out = &Environment{Variables: map[string]string{}}
}
value := strings.TrimSuffix(it.Content, "\n")
switch it.Attributes["kind"] {
case "variable":
out.Variables[it.Attributes["name"]] = value
case "path":
out.Path = append(out.Path, PathEntry{Entry: value, At: it.Attributes["at"]})
}
}
return out
}
// factsIn resolves a contributed value's ${machine:…} facts with this machine's — first, before
// either format is written, so both say the same thing (novox/hq ADR 0203).
func factsIn(v string, facts map[string]string, module, what string) (string, error) {
for _, key := range machineUsed(v) {
value, has := facts[key]
if !has {
return "", fmt.Errorf("%s sets %s to a value that says ${machine:%s}, and this machine says %s",
module, what, key, orNothing(namesOfFacts(facts)))
}
v = strings.ReplaceAll(v, fmt.Sprintf("${machine:%s}", key), value)
}
// Judged again once filled: a fact is the mesh's, and still has to be a literal both readers
// take alike.
if why := literalProblem(v); why != "" {
return "", fmt.Errorf("%s sets %s to %q on this machine, which %s — %s", module, what, v, why, literalRule)
}
return v, nil
}
// posix is the environment as lines a POSIX shell sources (novox/hq ADR 0203 §3): every variable
// exported, every PATH entry added only when it is missing, so sourcing the file twice — a login
// shell that starts another — changes nothing. POSIX sh only, because sh, bash and zsh all read it.
//
// The start entries are written last-first: each is put in front of PATH, so the last written ends
// up first, and the result reads in module order, then the order each module declared.
func (e contributedEnvironment) posix() string {
var b strings.Builder
for _, set := range e.variables {
fmt.Fprintf(&b, "# %s\n", set.module)
for _, n := range set.names {
fmt.Fprintf(&b, "export %s='%s'\n", n, set.values[n])
}
}
named := ""
for i := len(e.start) - 1; i >= 0; i-- {
p := e.start[i]
if p.module != named {
fmt.Fprintf(&b, "# %s\n", p.module)
named = p.module
}
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH='%s'\"${PATH:+:${PATH}}\" ;; esac\n",
p.entry, p.entry)
}
named = ""
for _, p := range e.end {
if p.module != named {
fmt.Fprintf(&b, "# %s\n", p.module)
named = p.module
}
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH=\"${PATH:+${PATH}:}\"'%s' ;; esac\n",
p.entry, p.entry)
}
if len(e.start)+len(e.end) > 0 {
b.WriteString("export PATH\n")
}
return b.String()
}
// systemd is the same environment as the service manager's environment.d reads it (novox/hq ADR
// 0203 §3), for the account's user manager and so for everything a graphical session starts. Read
// once per manager start, so it needs no guard against running twice; the account's existing PATH
// sits between the start and the end entries.
func (e contributedEnvironment) systemd() string {
var b strings.Builder
for _, set := range e.variables {
fmt.Fprintf(&b, "# %s\n", set.module)
for _, n := range set.names {
fmt.Fprintf(&b, "%s=%s\n", n, set.values[n])
}
}
if len(e.start) > 0 {
fmt.Fprintf(&b, "# %s\nPATH=%s${PATH:+:$PATH}\n", modulesOf(e.start), entriesOf(e.start))
}
if len(e.end) > 0 {
fmt.Fprintf(&b, "# %s\nPATH=${PATH:+$PATH:}%s\n", modulesOf(e.end), entriesOf(e.end))
}
return b.String()
}
// modulesOf names who contributed a line holding several modules' entries, in the order they appear.
func modulesOf(entries []placedOn) string {
var names []string
seen := map[string]bool{}
for _, p := range entries {
if !seen[p.module] {
seen[p.module] = true
names = append(names, p.module)
}
}
return strings.Join(names, ", ")
}
func entriesOf(entries []placedOn) string {
out := make([]string, len(entries))
for i, p := range entries {
out[i] = p.entry
}
return strings.Join(out, ":")
}
// shellCode is every module's code for one shell and one slot (novox/hq ADR 0204 §3): in module
// order, each module's pieces in the order it declared them, each preceded by a line naming the
// module, and empty when nothing is contributed.
func shellCode(modules []Manifest, shell, slot string) string {
return shellCodeOn(modules, shell, slot, nil)
}
// shellCodeOn is shellCode on a machine with these facts: a moved contributor's item of the slot's collection is
// rendered in its module's place as its old form was (heldItemText), so a collector not handed keeps every line.
func shellCodeOn(modules []Manifest, shell, slot string, facts map[string]string) string {
return shellCodeFor(modules, shell, slot, facts, nil)
}
// oldFormOfCollected is a file's text with each `${collected:contribution:<seat>:<kind>:…}` of a kind received as data
// read as the old `${contribution:<seat>:<kind>:…}`: where the collector is not handed, the controller renders the
// kind's items as it rendered its contributions, so the two forms place the same text (design 57 L3, issue 781).
func oldFormOfCollected(content string) string {
return ofCollected.ReplaceAllStringFunc(content, func(placeholder string) string {
name := ofCollected.FindStringSubmatch(placeholder)[1]
base, _ := splitSelector(name)
if format, is := strings.CutPrefix(base, EnvironmentCollection+":"); is {
return "${environment:" + format + "}"
}
if !strings.HasPrefix(base, "contribution:") {
return placeholder
}
seat, kind, _ := strings.Cut(strings.TrimPrefix(base, "contribution:"), ":")
if _, r, ok := receivable(seat, kind); !ok || !collectableAsText(r) {
return placeholder
}
return "${" + name + "}"
})
}
// shellCodeFor is shellCodeOn on a machine with these capabilities, which an item's `if-capability` is judged by. The
// pieces are placed by order (an item's `order` attribute, the default for the old form), then module, as the
// provisioner's client places them.
func shellCodeFor(modules []Manifest, shell, slot string, facts map[string]string, caps map[string]bool) string {
var blocks []textBlock
for _, m := range inModuleOrder(modules) {
var old strings.Builder
for _, c := range m.Shell {
if c.For != shell || c.Slot != slot {
continue
}
if old.Len() == 0 {
fmt.Fprintf(&old, "# %s\n", m.Module)
}
old.WriteString(c.Code)
if !strings.HasSuffix(c.Code, "\n") {
old.WriteString("\n")
}
}
blocks = append(blocks, moduleBlocks(m, "#", old.String(), heldItems(m, "shell:"+shell+":"+slot, nil, facts, caps))...)
}
return joinBlocks(blocks)
}
// textBlock is a stretch of a collector's text and the order it is placed by.
type textBlock struct {
order int
text string
}
// moduleBlocks is one module's pieces of a placeholder: what its old form made (placed by the default order), and
// each of its held items under a line naming the module, as the provisioner's client writes one per item. An item
// of the default order that follows the old form's text shares its line, as a module's item always did.
func moduleBlocks(m Manifest, comment, old string, items []heldItem) []textBlock {
var blocks []textBlock
if old != "" {
blocks = append(blocks, textBlock{OrderDefault, old})
}
for i, h := range items {
text := h.text
if i == 0 && old != "" && h.item.order() == OrderDefault {
blocks[0].text += text
continue
}
blocks = append(blocks, textBlock{h.item.order(), fmt.Sprintf("%s %s\n", comment, m.Module) + text})
}
return blocks
}
// joinBlocks is the blocks by order, those of one order in the order given (module order).
func joinBlocks(blocks []textBlock) string {
sort.SliceStable(blocks, func(a, b int) bool { return blocks[a].order < blocks[b].order })
var b strings.Builder
for _, blk := range blocks {
b.WriteString(blk.text)
}
return b.String()
}
// contributionsInto fills a holder's file with the node's environment and its shell code.
//
// **Last, after every other placeholder pass, and in one pass each.** Shell code is contributed text
// in a shell's own syntax — `${XDG_CACHE_HOME:-$HOME/.cache}`, `${(%):-%n}` — and the rendered
// environment holds `${PATH:+…}`: a scanner for the mesh's own placeholders that ran after these
// were in place would read the shell's expansions as the mesh's and refuse them, or fill a
// `${machine:…}` some module wrote for its shell to see. So nothing runs after them, the environment
// is filled before the shell's code is, and each is replaced in a single pass over what the holder
// wrote, so a contributed piece is never scanned again.
func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string, with Rendering, caps map[string]bool, unplaced *[]string) error {
problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...)
if problems = append(problems, collectedPlaceholderProblems(m, resource)...); len(problems) > 0 {
return fmt.Errorf("%s", problems[0])
}
judged, err := consumersNotHanded(m, resource, with)
if err != nil {
return err
}
if judged {
resource["content"] = ofConsumers.ReplaceAllString(fmt.Sprint(resource["content"]), "")
}
content, ok := resource["content"].(string)
if !ok {
return nil
}
content = oldFormOfCollected(content)
// **An item is the provisioner client's** (design 57 L1): a collector filled here, the old way, holds the old
// forms alone, and each item of its collections is said as not placed. A collection of the new form is empty.
if unplaced != nil {
for _, f := range templatePlaceholder.FindAllStringSubmatch(content, -1) {
if collection, is := collectionOfPlaceholder(f[1], f[2]); is {
_, selector := splitSelector(f[2])
where, _ := selectorOf(selector)
*unplaced = append(*unplaced, itemsLeftOutFor(modules, m, collection, where, facts, caps)...)
}
}
}
content = ofCollected.ReplaceAllString(content, "")
if ofEnvironment.MatchString(content) {
env, err := environmentOn(modules, facts)
if err != nil {
return err
}
content = ofEnvironment.ReplaceAllStringFunc(content, func(placeholder string) string {
if ofEnvironment.FindStringSubmatch(placeholder)[1] == EnvironmentSystemd {
return env.systemd()
}
return env.posix()
})
}
// Shell code and seat contributions in one pass (novox/hq ADR 0212): both are contributed text
// the controller does not read, so neither may be scanned after the other is in place — a
// contributed line that happened to spell the other's placeholder would be filled.
if ofContributed.MatchString(content) {
var failed error
content = ofContributed.ReplaceAllStringFunc(content, func(placeholder string) string {
found := ofContributed.FindStringSubmatch(placeholder)
first, second, _ := strings.Cut(found[2], ":")
if found[1] == BackupNamespace {
placed, problem := backupPlacer{modules, with, facts, caps, unplaced}.placed(m, found[2], false)
if problem != "" && failed == nil {
failed = fmt.Errorf("%s names %s: %s", m.Module, placeholder, problem)
}
return placed
}
if found[1] == "contribution" {
placed, left, err := seatContributions(modules, m, found[2], with, facts, caps)
if err != nil && failed == nil {
failed = err
}
if unplaced != nil {
*unplaced = append(*unplaced, left...)
}
return placed
}
return shellCodeFor(modules, first, second, facts, caps)
})
if failed != nil {
return failed
}
}
resource["content"] = content
return nil
}
@@ -0,0 +1,199 @@
package catalogue
import (
"fmt"
"regexp"
"slices"
"strings"
)
// What a module may call an event, and what a consumer may ask for.
//
// A module names an event **locally**: `order.placed`, not a subject and not a routing key
// (design 29 §1). A consumer names the emitter and the event: `billing.order.placed`. The mesh
// derives the subject from those, so reorganising the subject space leaves every manifest correct.
//
// **Nothing checked this until every manifest in the catalogue was wrong the same way**
// (novox/hq 04-ISSUES/127). All thirty-seven kept the old bus's routing key —
// `module.<module>.<verb>` — which the derivation read as "a module called `module`", so every
// cross-module subscription in the mesh pointed at a namespace nobody publishes to. Nothing failed:
// the services started and none of them reacted. The documentation on these fields taught the old
// form too, which is why the drift was uniform rather than scattered.
// eventName is one name in a local event: lower-case, and no wildcard.
var eventName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// The wildcards a consumer may use, spelled the mesh's way and derived to whatever the transport
// spells them as.
//
// **A manifest holds no transport token**, which is the whole point of naming locally: the bus the
// mesh runs on today spells these `*` and `#`, and the one being built spells them `*` and `>`. A
// manifest that said either would be a manifest that stopped being true when the wire changed.
const (
// OneName stands for exactly one name.
OneName = "*"
// TheRest stands for one or more names, and may only come last.
TheRest = "**"
)
// EventProblems is what is wrong with a manifest's events.
//
// Refused at registration, because the alternative is a module that installs, starts, connects and
// reacts to nothing — and every log line says it is fine.
func EventProblems(m Manifest) []string {
var problems []string
for _, e := range m.Emits {
if was, stale := staleEventForm(e, m.Module); stale {
problems = append(problems, fmt.Sprintf(
"%s emits %q, which is the old bus's routing key. An event is named locally now, so "+
"write %q — the mesh derives the subject (novox/hq design 29 §1)",
m.Module, was, strings.TrimPrefix(was, "module."+m.Module+".")))
continue
}
if strings.HasPrefix(e, "module.") {
problems = append(problems, fmt.Sprintf(
"%s emits %q: `module.` is reserved, because it is how the old bus spelled a "+
"routing key and an event named that way derives into a namespace nobody owns",
m.Module, e))
continue
}
if err := localName(e); err != nil {
problems = append(problems, fmt.Sprintf("%s emits %q: %v", m.Module, e, err))
continue
}
// **Its own name, never another's.** The bus enforces that a namespace belongs to the module
// it is named for, so an event named for somebody else cannot be published at all. If the
// event is about a role rather than about this module, it belongs on the seat: a name that
// is stable across whoever fills it (04-ISSUES/127).
if first, _, split := strings.Cut(e, "."); split && isAModuleNameOtherThan(first, m.Module) {
problems = append(problems, fmt.Sprintf(
"%s emits %q, which reads as another module's event. A module publishes under its "+
"own name only. If this is about a role rather than about %s, declare it on that "+
"seat, where the name survives the holder changing",
m.Module, e, m.Module))
}
}
for _, c := range m.Consumes {
if strings.HasPrefix(c, "module.") {
problems = append(problems, fmt.Sprintf(
"%s consumes %q, which is the old bus's pattern. A consumed event names its emitter "+
"and the event: write %q", m.Module, c, strings.TrimPrefix(c, "module.")))
continue
}
if c == "#" {
problems = append(problems, fmt.Sprintf(
"%s consumes %q, which is the old bus's wildcard for everything. Write %q",
m.Module, c, TheRest))
continue
}
if err := consumePattern(c); err != nil {
problems = append(problems, fmt.Sprintf("%s consumes %q: %v", m.Module, c, err))
}
}
return problems
}
// staleEventForm says an emitted name is this module's own old routing key, and what it was.
func staleEventForm(event, module string) (string, bool) {
return event, module != "" && strings.HasPrefix(event, "module."+module+".")
}
// isAModuleNameOtherThan says a first token names some module of this mesh that is not this one.
//
// Only the mesh's own seats and the catalogue could answer this properly, and neither is reachable
// from a parser given one manifest. So this catches the case that actually happened — a name that
// is a *provision* the mesh defines, which is where "another module's event" comes from in practice
// — and the whole-catalogue check catches the rest.
func isAModuleNameOtherThan(first, module string) bool {
if first == module || first == "" {
return false
}
if _, isASeat := SeatNamed(first); isASeat {
return true
}
if _, isASeat := SeatDelivering(first); isASeat {
return true
}
return false
}
// localName checks one event name: dot-separated names, no wildcards, nothing else.
func localName(event string) error {
if event == "" {
return fmt.Errorf("an event needs a name")
}
for _, part := range strings.Split(event, ".") {
if part == OneName || part == TheRest {
return fmt.Errorf("an emitted event names one event, so it carries no wildcard")
}
if !eventName.MatchString(part) {
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
}
}
return nil
}
// consumePattern checks a consumed pattern: the emitter, then the event, with wildcards.
func consumePattern(pattern string) error {
if pattern == "" {
return fmt.Errorf("a consumed event needs an emitter and an event")
}
parts := strings.Split(pattern, ".")
for i, part := range parts {
switch {
case part == TheRest:
if i != len(parts)-1 {
return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", TheRest)
}
case part == OneName:
case !eventName.MatchString(part):
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
}
}
// `**` alone is every event from every module, which the audit logger wants and says plainly.
if len(parts) == 1 && parts[0] != TheRest {
return fmt.Errorf(
"%q names an emitter and no event. Write <emitter>.<event>, or %q for every event",
pattern, TheRest)
}
return nil
}
// The events a provider says about its consumers (novox/hq ADR 0224): a consumer it has failed
// without one success for minutes, and that consumer succeeding again or being withdrawn. The
// controller follows them from every module and `status` names a consumer failing until it recovers.
//
// **And what becomes of a consumer the mesh stopped asking for** (novox/hq ADR 0230): retired — its
// access disabled and its data kept — after the same answer in five passes, waiting for a person when
// more would go than the bound allows, re-enabled when asked for again, and deleted only by a person's
// `cleanup delete`. One event, its `change` saying which.
const (
ProvisionerFailing = "provisioner.failing"
ProvisionerRecovered = "provisioner.recovered"
ProvisionerRetirement = "provisioner.retirement"
)
// ProvisionerEvents are all three, in the order they are said.
var ProvisionerEvents = []string{ProvisionerFailing, ProvisionerRecovered, ProvisionerRetirement}
// EmitsAll is every event a module may publish: what it declares and, for a module that receives
// contributions — a provider, running a provisioner over them — the provider's standing events.
//
// **Derived, not declared**, because they are the mesh's rule about every provider rather than
// anything one module chose to say: a provider whose manifest forgot them would fail its consumers
// as silently as on 2026-10-05, with its announcement refused by the bus (novox/hq issue 179). Every
// grant of a module's publishing reads this, never the declared list alone.
func (m Manifest) EmitsAll() []string {
out := append([]string(nil), m.Emits...)
if len(m.Receives) == 0 {
return out
}
for _, e := range ProvisionerEvents {
if !slices.Contains(out, e) {
out = append(out, e)
}
}
return out
}
+147
View File
@@ -0,0 +1,147 @@
package catalogue
import (
"encoding/json"
"fmt"
"path"
"strings"
)
// A node's files, read and managed (novox/hq ADR 0394, issue 900; superseding ADR 0310; hq to-be 41 WP9).
//
// **One seat for every file on a node.** The holder runs as the tool runner's account, the node's operator
// account (ADR 0394 §2), and reaches further through `sudo -n` where that account's rights do not. It refuses by
// name the places a secret is kept (reads only, ADR 0394 §7) and the paths a module owns, unless a write names a reason (`override`).
// node-reads stays beside it until mesh-files is assigned on every node (ADR 0394 §9); it goes in the next
// delivery.
// FilesSeat is the role that reads and manages a node's files and accounts (novox/hq ADR 0394).
const FilesSeat = "node-files"
// FilesOwnedPaths is the word the holder of node-files is given beside the secret places: a JSON object from
// each absolute path a module owns on its node to "module (tool to use)" (novox/hq ADR 0394 §5).
const FilesOwnedPaths = "MESH_OWNED_PATHS"
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"
overrideWord = "a reason, to change a path a module owns anyway (optional)"
)
// 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() []Verb {
var verbs []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) Verb {
in["reason"] = "why the file is changed, for the audit trail"
in["override"] = overrideWord
return 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"),
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"),
)
}
// FilesHolderProblems is what a holder of node-files is held to (novox/hq ADR 0394 §2): it runs as the tool
// runner's account, the node's operator account, so it declares no runs-as.
func FilesHolderProblems(m Manifest) []string {
if !m.ClaimsSeat(FilesSeat) || m.RunsAs == "" {
return nil
}
return []string{fmt.Sprintf("%s claims %s and declares runs-as %q: its holder runs as the tool runner's account, "+
"the node's operator account (novox/hq ADR 0394 §2)", m.Module, FilesSeat, m.RunsAs)}
}
// ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the
// paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each
// module's manifest places (declaredPaths). A seat's path wins where both name one.
func (r Resolution) ownedPaths() map[string]string {
owned := map[string]string{}
for _, m := range r.Modules {
for _, p := range sortedKeys(declaredPaths(m)) {
owned[p] = m.Module + " (its own tools)"
}
}
for _, s := range Seats() {
for _, sp := range s.Paths {
for _, m := range r.Modules {
if m.ClaimsSeat(s.Name) && declaresPath(m, path.Clean(sp.Path)) {
owned[sp.Path] = m.Module + " (" + s.Name + ")"
}
}
}
}
return owned
}
// filesWords is what the holder of node-files is given beside its own words: the node's secret places and the
// paths modules own here.
func (r Resolution) filesWords(m Manifest, words map[string]string, with Rendering) (map[string]string, error) {
if !m.ClaimsSeat(FilesSeat) {
return words, nil
}
places, err := r.secretPlaces(with)
if err != nil {
return nil, err
}
places2, err := json.Marshal(places)
if err != nil {
return nil, err
}
owned, err := json.Marshal(r.ownedPaths())
if err != nil {
return nil, err
}
if words == nil {
words = map[string]string{}
}
for _, w := range []string{ReadsSecretPlaces, FilesOwnedPaths} {
if _, has := words[w]; has {
return nil, fmt.Errorf("%s gives its own %s; the node's secret places and owned paths are the mesh's to "+
"say (novox/hq ADR 0394 §5)", m.Module, w)
}
}
words[ReadsSecretPlaces] = string(places2)
words[FilesOwnedPaths] = string(owned)
return words, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,247 @@
package catalogue
import (
"fmt"
"strings"
)
// The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's
// role a node seat in the mesh's own set, so i3 and sway, xterm and foot, rofi and dmenu compete for
// a role rather than each inventing one — and a machine running two of one role is refused at
// assignment instead of found by two bars on one screen.
const (
LoginManagerSeat = "node-login-manager"
DisplayServerSeat = "node-display-server"
DisplaySessionSeat = "node-display-session"
TerminalEmulatorSeat = "node-terminal-emulator"
LauncherSeat = "node-launcher"
NotifierSeat = "node-notifier"
LockScreenSeat = "node-lock-screen"
ClipboardSeat = "node-clipboard"
BarSeat = "node-bar"
CompositorSeat = "node-compositor"
SecretServiceSeat = "node-secret-service"
)
// 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() []Seat {
const decided = "novox/hq ADR 0208"
return []Seat{
{Name: LoginManagerSeat, Scope: ScopeNode, Decision: decided, Serves: []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: DisplayServerSeat, Scope: ScopeNode, Decision: decided, Serves: []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: DisplaySessionSeat, Scope: ScopeNode, Decision: decided,
Receives: []Receivable{{Kind: "config", Comment: "#"}}, Serves: []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: TerminalEmulatorSeat, Scope: ScopeNode, Decision: decided, Serves: []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: LauncherSeat, Scope: ScopeNode, Decision: decided, Serves: []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: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []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: LockScreenSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "lock", Description: "Lock the operator's session now.",
Input: schema(map[string]string{}, nil)},
}},
{Name: ClipboardSeat, Scope: ScopeNode, Decision: decided, Serves: []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: BarSeat, Scope: ScopeNode, Decision: decided,
Receives: []Receivable{{Kind: BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}},
{Name: CompositorSeat, Scope: ScopeNode, Decision: decided},
{Name: SecretServiceSeat, Scope: ScopeNode, Decision: decided},
}
}
// 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
}
// BarKindBlock is the bar's one received kind: a block (novox/hq ADR 0255).
const BarKindBlock = "block"
// barShows is what a contributed block may show, and the options each takes — the bar-neutral
// vocabulary every holder of node-bar renders. A new entry is a decision, and every holder's template
// renders it (the shape's examples) before it is accepted.
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"}},
}
// 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) []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 []StateRead{{Read: s, Key: key}}
}
type barOption struct {
kind string // text, or seconds (a whole number from 1 to 86400)
required bool
}
// 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() *Shape {
return &Shape{
Text: true,
Places: []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
}
@@ -0,0 +1,802 @@
package catalogue
import (
"errors"
"fmt"
"maps"
"regexp"
"slices"
"sort"
"strings"
)
// A module handed to the provisioner (novox/hq ADR 0349 clauses 2, 4, 6 and 17, design 57 P6, issue 672).
//
// **Marked in its manifest, one module at a time.** A module whose manifest says `"moved": true` has moved: each
// file of it that names a template is filled by the provisioner's client on the node, from the templates archive
// its build packed, and the controller fills nothing in it. Every other resource of the module, and every file of
// a module not marked, is composed exactly as before (clause 17: each file the old way or the new way, never both).
//
// **The build keeps what the client needs.** Resolving a moved module's build keeps, beside the files' text the
// mesh holds, where its templates archive is and what it hashes to, and which template each file is filled from
// (Handed). The text stays, so a machine whose node-engine cannot take a handed file is still sent the module the
// old way, byte for byte.
//
// **Handed only where the machine can take it.** A node's declaration hands a moved module's files over only when
// its node-engine writes a filled file on the client's word alone (Rendering.Hands) and the node runs a provisioner
// client (Rendering.Provisioner): then each such file is declared with its path, mode, owner and who owns it at
// runtime and without its text, the declaration names it among the filled files, and the module's plan says the
// client where its templates are and the value of every placement placeholder they name. Anywhere else the module
// composes as if it were not marked.
// MovedField is the manifest's key marking a module moved.
const MovedField = "moved"
// Handed is what a moved module's build kept for the provisioner (novox/hq ADR 0349 §2): its templates archive, as
// recorded (store-relative, routed at composition like any archive), and its digest; and, for each file resource
// filled from a template, that template's path inside the archive. Written by Resolve, never by hand.
type Handed struct {
Source string `json:"source"`
Digest string `json:"digest"`
Templates map[string]string `json:"templates"`
// Items is, for each item the module contributes, by ItemKey (its collection, and `/<name>` for a named one),
// that item's template inside the archive (collections.go, design 57 L1).
Items map[string]string `json:"items,omitempty"`
}
// Plan is a moved module's plan on one node: what the node's provisioner client fills its files from. The shape
// of the assignments mirror's `provisioning` (mesh-controller internal/inventory Provisioning).
type Plan struct {
Templates PlanTemplates `json:"templates"`
Files []PlanFile `json:"files"`
Placement map[string]string `json:"placement,omitempty"`
// Secrets are the module's own secrets this declaration names (design 57 V4, issue 647): each its templates
// name, and each it keeps in a file of its own, with that file's path. By name, never a value: the client asks
// the vault for each, and the vault hands one only when the plan names it.
Secrets []PlanSecret `json:"secrets,omitempty"`
// Items are the module's items of collections (design 57 L1): each filled by the client like a file and recorded
// in mesh-provisioner_collect for its node. Collections are the collections its files name, as their collector,
// each with its comment. By template and name, never a contributor's text.
Items []PlanItem `json:"items,omitempty"`
Collections []PlanCollection `json:"collections,omitempty"`
// Provisions are what it requires here, each with the provider resolution chose to serve it (issue 760), so the
// vault sees a consumer bind to a provider and unbind from it (novox/hq ADR 0349 clause 8); and, for a
// credential the vault keeps for it alone, the name its files call it and the file holding it (issue 757). By
// name, never a value.
Provisions []PlanProvision `json:"provisions,omitempty"`
}
// PlanItem is one item the module contributes: to which collection, and its template inside the archive.
//
// A named item has its Name and Attributes (design 57 L3, issue 777): the client records them with the item and
// places it under a placeholder selecting by them. An item whose `if-capability` the machine lacks is not in the plan.
type PlanItem struct {
Collection string `json:"collection"`
Name string `json:"name,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
Template string `json:"template"`
}
// PlanCollection is one collection the module collects, and the comment introducing each contributor's item.
type PlanCollection struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty"`
}
// PlanProvision is one provision the module requires, under one local name, and the provider serving it: the module
// (ProviderModule) on the machine (Provider) that resolution chose for this consumer. Where several providers offer
// a mesh-scoped provision, it is the one this consumer is bound to (a pin, the seat's holder, or where its data is),
// never the others (issue 760). Local is the name the consumer keeps the credential under where it keeps several
// from one requirement (ADR 0094), empty for the ordinary one; a consumer has one provider per provision and local
// name.
//
// Name and Path are set only for a credential the vault keeps for this holder alone, which its machine's client
// fills (issue 757): the value the provider's software issued for this module on this machine. Name is what the
// module's files call it (`${secret:<name>}`) and Path the file holding it alone.
//
// CredentialForm is the form the provider's offer declares for its credential (Manifest.CredentialFormOf, issue
// 768): the vault makes a first value in that form for a provider it holds no row of. Empty where the provider's
// manifest was not in hand, and the vault then makes nothing.
type PlanProvision struct {
Name string `json:"name,omitempty"`
Provision string `json:"provision"`
Local string `json:"local,omitempty"`
Provider string `json:"provider"`
ProviderModule string `json:"provider-module"`
CredentialForm string `json:"credential-form,omitempty"`
// CredentialTool is, for an offer whose credential the provider's software issues (issue 928), the provider's tool
// the vault asks for each holder's value; empty for any other form.
CredentialTool string `json:"credential-tool,omitempty"`
Path string `json:"path,omitempty"`
// Contributes is what the consumer's manifest tells its provider for this provision (Manifest.Contributes: a
// database's name, its extensions), so the provider's helper learns it from the bus (issue 776). Nil where the
// manifest contributes nothing for it, and the entry is then as it was.
Contributes map[string]any `json:"contributes,omitempty"`
// At, From and As are what today's binding file tells the consumer of this provision (issue 788): where the
// provider is on the private network, the machine, and who the consumer is at its end (its slug first), so the
// provider's helper writes them into the consumer's entry and the client fills `${bound:<provision>:at|from|as}`
// from there. Empty where the resolution knew none.
At string `json:"at,omitempty"`
From string `json:"from,omitempty"`
As string `json:"as,omitempty"`
// Names are what the mesh calls the consumer through this provision (issue 792), keyed as its binding file names
// them (`name`, `internal-name`, `name-<local>`, `internal-name-<local>`): composed by the controller, which
// alone knows the node's domains, and carried unchanged by the provider's helper into the consumer's entry. Nil
// where the consumer contributes nothing to the provision, and the entry is then as it was.
Names map[string]string `json:"names,omitempty"`
}
// provisionsOf is, from a resolution's needs, each provision module requires and its provider, by provision then
// local name. A need answered by a record (a licence) or by no module names no provider on a machine and is left
// out: the vault has no provider's value to make for it.
func provisionsOf(needs []Needed, module, as string, contributes map[string]map[string]any,
named map[string]bool) []PlanProvision {
var out []PlanProvision
for _, n := range needs {
if n.For != module || n.ByRecord || n.Module == "" || n.From == "" {
continue
}
p := PlanProvision{Provision: n.Name, Local: n.Local, Provider: n.From, ProviderModule: n.Module,
CredentialForm: n.CredentialForm, CredentialTool: n.CredentialTool, Contributes: maps.Clone(contributes[n.Name]),
At: n.At, From: n.From, As: as}
// **A credential only a template names** (issue 788): named like a file's, so the client fills it from the
// vault's row for this holder.
if (named[n.Name] || named[SecretLocal(n.Name, n.Local)]) && n.IssuedToHolder {
p.Name = SecretLocal(n.Name, n.Local)
}
out = append(out, p)
}
return out
}
// sortProvisions orders a plan's provisions by provision, then local name.
func sortProvisions(ps []PlanProvision) {
sort.SliceStable(ps, func(i, j int) bool {
if ps[i].Provision != ps[j].Provision {
return ps[i].Provision < ps[j].Provision
}
return ps[i].Local < ps[j].Local
})
}
// PlanSecret is one of the module's own secrets, by its name in the manifest; Path is the file holding it alone,
// when the manifest's own-secrets places one.
type PlanSecret struct {
Name string `json:"name"`
Path string `json:"path,omitempty"`
}
// PlanTemplates is where the templates archive is fetched from on this node, and what it hashes to.
type PlanTemplates struct {
Source string `json:"source"`
Digest string `json:"digest"`
}
// PlanFile is one file the client fills.
type PlanFile struct {
ID string `json:"id"`
Path string `json:"path"`
Template string `json:"template"`
OwnerAtRuntime string `json:"owner-at-runtime"`
Takes string `json:"takes,omitempty"`
}
// The words a placed file says who owns it at runtime with (novox/hq ADR 0349 §5).
const (
RuntimeOwned = "owned"
RuntimeSeed = "seed"
RuntimeManaged = "managed"
)
// OwnerAtRuntimeField is the key a declared file says who owns it at runtime by (the node-engine's
// `owner-at-runtime`).
const OwnerAtRuntimeField = "owner-at-runtime"
// 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 RuntimeManaged
case r["create-once"] == true:
return RuntimeSeed
}
return RuntimeOwned
}
// takesOf is how the program reading a file takes a change, by what names the file among the module's resources:
// restart-on wins over reload-on; empty when nothing names it (the program reads it when it uses it).
func takesOf(m Manifest, id string) string {
takes := ""
for _, r := range m.Resources {
if slices.Contains(stringsIn(r["restart-on"]), id) {
return "restart-on"
}
if slices.Contains(stringsIn(r["reload-on"]), id) {
takes = "reload-on"
}
}
return takes
}
// movedProblems is what is wrong with a module's mark: a moved module must name a template (as written) or carry
// what its build kept (as held), and Handed is the build's word, never a repository's.
func movedProblems(m Manifest) []string {
switch {
case m.Handed != nil && !m.Moved:
return []string{fmt.Sprintf("%s carries what a moved module's build keeps (handed) and is not marked moved",
m.Module)}
case m.Handed != nil && m.Build != nil:
return []string{fmt.Sprintf("%s says handed in its repository: that is what its build keeps, never "+
"written by hand (novox/hq ADR 0349 §2)", m.Module)}
case m.Moved && m.Handed == nil && !m.Templated():
return []string{fmt.Sprintf("%s is marked moved and names no template: a moved module's files are filled "+
"from template files of its own, `\"template\": \"%s/<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 == IssuedOutside {
problems = append(problems, fmt.Sprintf("%s is marked moved and its secret %s is issued outside the mesh: "+
"a value that may not be given is not yet handed by the vault (design 57 V4)", m.Module, name))
}
}
return problems
}
// handedBy is what a moved module's build keeps, from its templates archive as built, or nil for a module not
// moved.
func handedBy(m Manifest, archive Built) *Handed {
if !m.Moved {
return nil
}
dir := m.TemplatesDir()
h := &Handed{Source: Recorded(archive.Reference), Digest: archive.Digest, Templates: map[string]string{}}
for _, r := range m.Resources {
name, has, wrong := templateOf(m.Module, dir, r)
if !has || wrong != "" {
continue
}
h.Templates[fmt.Sprint(r["id"])] = strings.TrimPrefix(name, dir+"/")
}
for _, it := range m.Items {
if it.Template == "" || itemTemplateProblem(m, it.Template) != "" {
continue
}
if h.Items == nil {
h.Items = map[string]string{}
}
h.Items[it.key()] = strings.TrimPrefix(it.Template, dir+"/")
}
return h
}
// handing is whether this composition hands the module's templated files to the provisioner.
func (with Rendering) handing(m Manifest) bool {
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
}
// Handing is whether a machine composed with this rendering hands the module to the provisioner, its files and
// its own secrets (design 57 P6, V4): a moved module there, assigned or only pulled in by another (issue 765), on a
// machine that can take it. The controller makes and seals none of such a module's secrets for that machine: its
// client asks the vault.
func (with Rendering) Handing(m Manifest) bool { return with.handing(m) }
// requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by
// <module>`.
const requiredByWords = "required by "
// RequiredBy is the module that requires module on this machine when nothing assigned it there, only pulled it in
// (issue 765): what its value in the assignments mirror names. Empty for a module assigned there, or not there.
func (r Resolution) RequiredBy(module string) string {
by, _ := strings.CutPrefix(r.Because[module], requiredByWords)
if by == r.Because[module] {
return ""
}
return by
}
// HandedProvisionField marks, between composing a handed module's credential file for something it requires and
// placing it, one the vault keeps for this holder alone (issue 757): its value is the PlanProvision without its path.
// Never on the wire.
const HandedProvisionField = "handed-provision"
// HandedSecretField marks, between composing a module's own secret files and placing them, the one a handing
// composition hands to the provisioner: its value is the secret's name. Never on the wire.
const HandedSecretField = "handed-secret"
// handedSecretFile is a handed module's own secret file as the declaration carries it: placed, the secret's
// alone (0600 unless the manifest says otherwise), owned at runtime, without a value; the client fills it from the
// vault.
func handedSecretFile(r map[string]any, m Manifest, dirs, accessPaths, facts map[string]string,
placed map[string]Placement) (map[string]any, PlanSecret, error) {
name := fmt.Sprint(r[HandedSecretField])
out := map[string]any{}
for k, v := range r {
out[k] = v
}
delete(out, HandedSecretField)
delete(out, "sealed")
if err := dirInto(out, dirs, m.Module); err != nil {
return nil, PlanSecret{}, err
}
if err := accessInto(out, accessPaths, m.Module); err != nil {
return nil, PlanSecret{}, err
}
ownerInto(out, placed)
if err := machineInto(out, facts, m.Module); err != nil {
return nil, PlanSecret{}, err
}
if out["mode"] == nil {
out["mode"] = "0600"
}
out[OwnerAtRuntimeField] = RuntimeOwned
return out, PlanSecret{Name: name, Path: fmt.Sprint(out["path"])}, nil
}
// templatePlaceholder is a placeholder as a template writes it: its namespace and its key.
var templatePlaceholder = regexp.MustCompile(`\$\{([a-z][a-z-]*):([^}\s$]+)\}`)
// configNamespaces are the namespaces the config domain answers: `config`, and `setting`, which the manifest's
// reading writes it as (toSettingsForm).
var configNamespaces = []string{"config", SettingField}
// SettingField is the settings form's word for a config placeholder's namespace.
const SettingField = "setting"
// mergesFile is whether a file resource says `merge` or `takes-settings`: the layers' settings are merged into it.
func mergesFile(r map[string]any) bool {
_, merges := r["merge"]
_, takes := r[TakesSettingsField]
return merges || takes
}
// mergingIn is whether the module's resource id is a merging file (mergesFile).
func mergingIn(m Manifest, id string) bool {
for _, r := range m.Resources {
if fmt.Sprint(r["id"]) == id {
return mergesFile(r)
}
}
return false
}
// handedFile is a moved module's templated file as a handing declaration carries it: placed (its path and owner
// filled by placement alone), saying who owns it at runtime, without its text. And its line of the plan.
func handedFile(r map[string]any, m Manifest, dirs, accessPaths, facts map[string]string, placed map[string]Placement,
with Rendering) (map[string]any, PlanFile, error) {
id := fmt.Sprint(r["id"])
out := map[string]any{}
for k, v := range r {
out[k] = v
}
// **No text, and nothing filled into it**: the client fills it from the template; the declaration says where
// it goes and how it is written.
delete(out, "content")
delete(out, TrustedField)
delete(out, NamesOnPurpose)
delete(out, SecretsInEnvironment)
if err := dirInto(out, dirs, m.Module); err != nil {
return nil, PlanFile{}, err
}
if err := accessInto(out, accessPaths, m.Module); err != nil {
return nil, PlanFile{}, err
}
ownerInto(out, placed)
if err := machineInto(out, facts, m.Module); err != nil {
return nil, PlanFile{}, err
}
out[OwnerAtRuntimeField] = ownershipOf(out)
path := fmt.Sprint(out["path"])
return out, PlanFile{ID: id, Path: path, Template: m.Handed.Templates[id],
OwnerAtRuntime: fmt.Sprint(out[OwnerAtRuntimeField]), Takes: takesOf(m, id)}, nil
}
// placementOf is the value of every placement placeholder the module's handed templates name, keyed as the
// placeholder names it (`machine:account`), each filled by the same pass that fills it in a declaration. A
// template naming a value only another domain answers — a secret, a binding, a contribution — is refused by
// name: the client fills config and placement, and the rest move with their own packages (design 57).
func placementOf(node string, m Manifest, texts, items map[string]string, dirs, accessPaths, facts map[string]string,
with Rendering, moved map[string]bool, backups backupPlacer) (map[string]string, []string, []string, []PlanCollection, error) {
out := map[string]string{}
var secrets, required []string
var problems []string
var collections []PlanCollection
all := map[string]string{}
templateOf := map[string]string{}
for id, text := range texts {
all[id], templateOf[id] = text, m.Handed.Templates[id]
}
for key, text := range items {
all["item:"+key], templateOf["item:"+key] = text, m.Handed.Items[key]
}
judgedConsumers := map[string]bool{}
for _, id := range sortedKeys(all) {
item := strings.HasPrefix(id, "item:")
for _, found := range templatePlaceholder.FindAllStringSubmatch(all[id], -1) {
kind, key := found[1], found[2]
if collection, is := collectionOfPlaceholder(kind, key); is {
// **A collection its file names, read by the client from the items there** (design 57 L1).
comment, err := collectionComment(m, collection)
_, selector := splitSelector(key)
_, selErr := selectorOf(selector)
switch {
case selErr != nil && !item:
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %v", m.Module,
templateOf[id], found[0], selErr))
case item:
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is not a "+
"collector", m.Module, templateOf[id], found[0]))
case err != nil:
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %v", m.Module,
templateOf[id], found[0], err))
case !slices.ContainsFunc(collections, func(c PlanCollection) bool { return c.Name == collection }):
collections = append(collections, PlanCollection{Name: collection, Comment: comment})
}
continue
}
switch {
case slices.Contains(configNamespaces, kind):
continue
case kind == ConsumersNamespace:
// **A provider's consumers' credentials** (ADR 0373): its machine's client fills them from the vault,
// for a provision the module provides, in a file only it reads (consumersPlaceholderProblems).
if item {
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is kept in a "+
"bucket, where no secret is (novox/hq ADR 0349 §7)", m.Module, templateOf[id], found[0]))
} else if !judgedConsumers[id] {
judgedConsumers[id] = true
problems = append(problems, consumersOfTemplate(m, id, all[id])...)
}
continue
case item && (kind == "secret" || kind == "bound"):
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is kept in a "+
"bucket, where no secret is (novox/hq ADR 0349 §7)", m.Module, templateOf[id], found[0]))
continue
case kind == "secret":
// **The module's own secret, by name** (design 57 V4): its machine's client asks the vault for it.
// A requirement's credential is a binding's, and moves with the providers (design 57 B1–B3).
if _, own := m.OwnSecrets.Plain()[key]; !own && moved[key] {
// **A credential it requires, from a provider that has moved** (issue 788): the client fills it
// from the vault's row for this holder, which the plan names.
if !slices.Contains(required, key) {
required = append(required, key)
}
} else if !own {
problems = append(problems, fmt.Sprintf("%s: its template %s names %s, which is no secret of its "+
"own; a credential it requires moves with its provider (design 57 B1–B3)", m.Module,
templateOf[id], found[0]))
} else if !slices.Contains(secrets, key) {
secrets = append(secrets, key)
}
continue
case kind == "bound" && moved[strings.SplitN(key, ":", 2)[0]]:
// **What its moved provider told it** (issue 788): the client fills it from the provider's entry.
continue
case !slices.Contains(PlacementKinds, kind):
if known(kind) {
problems = append(problems, fmt.Sprintf("%s: its template %s names %s, and the provisioner's "+
"client fills only config, secrets, collections and placement until that domain moves "+
"(design 57)", m.Module, templateOf[id], found[0]))
}
continue
}
name := kind + ":" + key
if _, done := out[name]; done {
continue
}
if kind == BackupNamespace {
// **What the machine's modules back up** (design 57 L3, issue 794): the text the old contribution
// placeholders render, from the same function, over the modules placed on this machine.
value, problem := backups.placed(m, key, item)
if problem != "" {
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %s", m.Module,
templateOf[id], found[0], problem))
continue
}
out[name] = value
continue
}
value, err := placed(node, found[0], m, dirs, accessPaths, facts, with)
if err != nil {
problems = append(problems, err.Error())
continue
}
out[name] = value
}
}
if len(problems) > 0 {
sort.Strings(problems)
return nil, nil, nil, nil, errors.New(strings.Join(problems, "; "))
}
sort.Slice(collections, func(a, b int) bool { return collections[a].Name < collections[b].Name })
return out, secrets, required, collections, nil
}
// PlacementKinds are the placeholder namespaces placement answers (ADR 0349 clause 2), as a plan's keys begin.
var PlacementKinds = []string{"dir", "access", "port", "seat", "machine", BackupNamespace}
// known is whether a namespace is one the mesh fills somewhere.
func known(kind string) bool {
for _, f := range fillers {
if f.namespace == kind {
return true
}
}
return false
}
// placed is one placement placeholder's value, filled by the passes that fill it in a file's content.
func placed(node, placeholder string, m Manifest, dirs, accessPaths, facts map[string]string, with Rendering) (string, error) {
r := map[string]any{"id": "placement", "type": "file", "path": "/placement", "content": placeholder}
if err := dirInto(r, dirs, m.Module); err != nil {
return "", err
}
if err := accessInto(r, accessPaths, m.Module); err != nil {
return "", err
}
if err := portInto(r, m.Module, m.Listens, with); err != nil {
return "", err
}
if err := seatInto(r, m.Module, with); err != nil {
return "", err
}
if err := machineInto(r, facts, m.Module); err != nil {
return "", err
}
value := fmt.Sprint(r["content"])
if value == placeholder {
return "", fmt.Errorf("%s: %s is not answered by placement on %s", m.Module, placeholder, node)
}
return value, nil
}
// routedTemplates is the templates archive as this node fetches it, through the artifact store it reaches.
func routedTemplates(m Manifest, with Rendering) (PlanTemplates, error) {
r := map[string]any{"id": "templates", "source": m.Handed.Source}
if err := artifactsInto(r, m.Module, with); err != nil {
return PlanTemplates{}, err
}
return PlanTemplates{Source: fmt.Sprint(r["source"]), Digest: m.Handed.Digest}, nil
}
// handedFiles gathers, over one composition, the files handed to the provisioner and each handed module's plan.
type handedFiles struct {
paths []string
plans map[string]Plan
// secrets is each handed module's own secrets its declaration names, by name: the path of the file holding one
// alone, or "" for one only its templates name.
secrets map[string]map[string]string
// provisions is each handed module's credentials of what it requires that the vault keeps for it (issue 757).
provisions map[string][]PlanProvision
// contributes is each handed module's manifest `contributes`, by the requirement it is about (issue 776).
contributes map[string]map[string]map[string]any
// identities is who each handed module is at its providers' ends (issue 788), and required the credentials of
// what it requires that its templates name.
identities map[string]string
required map[string]map[string]bool
// names is the names composed for each handed module through each requirement it contributes to (issue 792).
names map[string]map[string]map[string]string
}
// name records the names composed for a handed module through one requirement, as its binding file carries them.
func (h *handedFiles) name(module, provision string, own map[string]any) {
values := map[string]string{}
withOwnNames(values, own)
if len(values) == 0 {
return
}
if h.names == nil {
h.names = map[string]map[string]map[string]string{}
}
if h.names[module] == nil {
h.names[module] = map[string]map[string]string{}
}
h.names[module][provision] = values
}
// begin starts a module's plan where this composition hands it, and answers the ids of the files handed: none for
// a module that is not handed here. The plan's templates and placement values are composed now; each file is added
// as it is composed.
//
// **Assigned or only pulled in by another** (issue 765): the plan travels in the module's value of the assignments
// mirror, `<node>.<module>`, which the client reads (inventory.Provisioning); a module pulled in has a value there of
// its own, which says what pulled it in.
func (h *handedFiles) begin(node string, m Manifest, dirs, accessPaths, facts map[string]string,
with Rendering, caps map[string]bool, needs []Needed, backups backupPlacer) (map[string]bool, error) {
if !with.handing(m) {
return nil, nil
}
texts := map[string]string{}
ids := map[string]bool{}
for _, r := range m.Resources {
id := fmt.Sprint(r["id"])
if _, named := m.Handed.Templates[id]; !named {
continue
}
if mergesFile(r) {
// **A merging file is filled the old way** (issue 831, ADR 0349 clause 17): handing it needs the client
// to merge the layers, which nothing builds yet. Its text stays, the controller fills and merges it.
continue
}
if fmt.Sprint(r["type"]) != "file" {
return nil, fmt.Errorf("%s: %s is a %v and its build kept a template for it; only a file is filled "+
"from one", m.Module, id, r["type"])
}
text, _ := r["content"].(string)
texts[id] = text
ids[id] = true
}
for _, id := range sortedKeys(m.Handed.Templates) {
if !ids[id] && !mergingIn(m, id) {
return nil, fmt.Errorf("%s: its build kept a template for %s, which it does not declare", m.Module, id)
}
}
// **Its items, by template** (design 57 L1): the client fills each and records it for this node.
items := map[string]string{}
var planned []PlanItem
for _, it := range m.Items {
template, kept := m.Handed.Items[it.key()]
if !kept {
return nil, fmt.Errorf("%s: its build kept no template for its item of %s", m.Module, it.key())
}
// **A capability the machine lacks leaves the item out of its plan** (design 57 L3): the client judges none.
if it.IfCapability != "" && !caps[it.IfCapability] {
continue
}
items[it.key()] = it.Content
planned = append(planned, PlanItem{Collection: it.Collection, Name: it.Name,
Attributes: it.Attributes, Template: template})
}
if len(ids) == 0 && len(planned) == 0 {
return nil, nil
}
templates, err := routedTemplates(m, with)
if err != nil {
return nil, err
}
// What it requires from a provider that has moved (issue 788): its templates may name what that provider tells it.
moved := map[string]bool{}
for _, n := range needs {
if n.For == m.Module && n.ProviderMoved {
moved[n.Name] = true
// A credential kept per local name (issue 793) is named by its local in a template.
moved[SecretLocal(n.Name, n.Local)] = true
}
}
placement, secrets, required, collections, err := placementOf(node, m, texts, items, dirs, accessPaths, facts,
with, moved, backups)
if err != nil {
return nil, err
}
if h.identities == nil {
h.identities = map[string]string{}
h.required = map[string]map[string]bool{}
}
h.identities[m.Module] = ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
h.required[m.Module] = map[string]bool{}
for _, name := range required {
h.required[m.Module][name] = true
}
for _, name := range secrets {
h.secret(m.Module, PlanSecret{Name: name})
}
plan := Plan{Templates: templates, Items: planned, Collections: collections}
if len(placement) > 0 {
plan.Placement = placement
}
h.plans[m.Module] = plan
if len(m.Contributes) > 0 {
if h.contributes == nil {
h.contributes = map[string]map[string]map[string]any{}
}
h.contributes[m.Module] = m.Contributes
}
return ids, nil
}
// add is one handed file of a module, composed. A path handed twice is refused: the node-engine writes a filled
// file for exactly one resource, and the client's write names only the path (mesh-host internal/apply).
func (h *handedFiles) add(module string, f PlanFile) error {
if slices.Contains(h.paths, f.Path) {
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
"resource and one writer (novox/hq ADR 0349 §5)", module, f.Path)
}
plan := h.plans[module]
plan.Files = append(plan.Files, f)
h.plans[module] = plan
h.paths = append(h.paths, f.Path)
return nil
}
// secret is one of a handed module's own secrets its declaration names; a file holding it alone is a filled file.
func (h *handedFiles) secret(module string, sec PlanSecret) {
if h.secrets == nil {
h.secrets = map[string]map[string]string{}
}
if h.secrets[module] == nil {
h.secrets[module] = map[string]string{}
}
if sec.Path != "" || h.secrets[module][sec.Name] == "" {
h.secrets[module][sec.Name] = sec.Path
}
}
// addSecretFile is a handed module's own secret file, composed: a filled file, and its secret in the plan.
func (h *handedFiles) addSecretFile(module string, sec PlanSecret) error {
if slices.Contains(h.paths, sec.Path) {
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
"resource and one writer (novox/hq ADR 0349 §5)", module, sec.Path)
}
h.paths = append(h.paths, sec.Path)
h.secret(module, sec)
return nil
}
// addProvisionFile is a handed module's credential file for something it requires, which the vault keeps for it
// alone (issue 757): a filled file, and the credential in the plan.
func (h *handedFiles) addProvisionFile(module string, pr PlanProvision) error {
if slices.Contains(h.paths, pr.Path) {
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
"resource and one writer (novox/hq ADR 0349 §5)", module, pr.Path)
}
h.paths = append(h.paths, pr.Path)
if h.provisions == nil {
h.provisions = map[string][]PlanProvision{}
}
h.provisions[module] = append(h.provisions[module], pr)
return nil
}
// finished is every plan with its secrets, by name, and what its module requires, each with its provider.
func (h *handedFiles) finished(needs []Needed) map[string]Plan {
if len(h.plans) == 0 {
return nil
}
for module, plan := range h.plans {
// **Every provision it requires, with its provider** (issue 760); a credential the vault keeps for it alone
// is that provision's entry, with its name and file (issue 757).
provisions := provisionsOf(needs, module, h.identities[module], h.contributes[module], h.required[module])
for _, credential := range h.provisions[module] {
i := slices.IndexFunc(provisions, func(p PlanProvision) bool {
return p.Provision == credential.Provision && p.Local == credential.Local
})
if i < 0 {
credential.As = h.identities[module]
provisions = append(provisions, credential)
continue
}
provisions[i].Name, provisions[i].Path = credential.Name, credential.Path
}
for i := range provisions {
provisions[i].Names = maps.Clone(h.names[module][provisions[i].Provision])
}
sortProvisions(provisions)
plan.Provisions = provisions
h.plans[module] = plan
}
for module, named := range h.secrets {
plan, ok := h.plans[module]
if !ok {
continue
}
plan.Secrets = nil
for _, name := range sortedKeys(named) {
plan.Secrets = append(plan.Secrets, PlanSecret{Name: name, Path: named[name]})
}
h.plans[module] = plan
}
return h.plans
}
@@ -0,0 +1,495 @@
package catalogue
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// A module says how each long-running resource is ready (novox/hq ADR 0240 rule 2, to-be 48 §2 and §8,
// Phase B).
//
// **On the resource, beside its other fields**: one kind and its timing. The node-engine judges every
// long-running resource alive with no declaration (Phase A); this is how a module says what *ready* means
// for one — the image's own check adopted by name, an HTTP request to a declared endpoint, a TCP connect,
// a command in the container, the unit's own readiness, or one of the module's own tools.
//
// **An endpoint is named, never a port or an address**: the check follows the machine's port for that
// endpoint as the endpoint does, so a port this machine gave elsewhere moves the check with it. The
// controller composes the name into the port the machine published it on, and sends the field only to a
// node-engine that reads it (link.ReadinessContract): an older, strict engine refuses a field it does not
// know, whole.
//
// **The bounds are the record's**: an interval not under ten seconds, a timeout under the interval, at
// least two failing looks in a row, and a grace plus the failing looks within five minutes — so a
// resource broken from its start is said within the gate's ten. Refused here, near the author, and again
// by the node-engine, far away, in the same words.
// HealthField is the resource field.
const HealthField = "health"
// The kinds of check (to-be 48 §2).
const (
HealthRuntime = "runtime"
HealthHTTP = "http"
HealthTCP = "tcp"
HealthExec = "exec"
HealthUnit = "unit"
HealthTool = "tool"
)
// The bounds and the defaults (ADR 0240 rule 2).
const (
HealthIntervalDefault = 30 * time.Second
HealthIntervalFloor = 10 * time.Second
HealthTimeoutDefault = 5 * time.Second
HealthLooksDefault = 3
HealthLooksFloor = 2
HealthGraceDefault = 60 * time.Second
// HealthWithin is the most a grace and the failing looks may take together: a resource broken
// from its start is said within the gate's ten minutes with room for its judgings.
HealthWithin = 5 * time.Minute
)
// HealthRequiredFrom is when `module check` refuses a long-running resource without `health` (ADR 0240
// rule 8): six weeks after liveness was first judged live (2026-10-07), unless the catalogue's count of
// undeclared resources reached zero first — which its own counter enforces by never letting it rise.
var HealthRequiredFrom = time.Date(2026, 11, 18, 0, 0, 0, 0, time.UTC)
// Health is one resource's declaration, read.
type Health struct {
Kind string
// Endpoint is the `listens` name an http or tcp check looks at.
Endpoint string
// Path, Status, Body and Scheme are an http check's: the path asked, the status expected (zero: any
// status under 400), a text the answer must hold, and http or https.
Path string
Status int
Body string
Scheme string
// Command is an exec check's command, run by the container's shell.
Command string
// Tool is a tool check's tool, one of the module's own.
Tool string
// The timing, with the defaults applied.
Interval, Timeout, Grace time.Duration
Looks int
// Needs is the provision the check exercises (to-be 48 §6): while its provider for this consumer is
// unhealthy, what this check finds is held under the provider's condition.
Needs string
}
// healthKeys are the keys a `health` field may carry; anything else is refused by name.
var healthKeys = map[string]bool{"kind": true, "endpoint": true, "path": true, "status": true, "body": true,
"scheme": true, "command": true, "tool": true, "interval": true, "timeout": true, "looks": true,
"grace": true, "needs": true}
// healthAddressKeys are what a check may not be aimed by: a port or an address does not follow the
// machine's port for the endpoint, and a manifest is the same on every machine.
var healthAddressKeys = map[string]bool{"port": true, "address": true, "host": true, "url": true, "ip": true}
// LongRunning says whether a manifest resource stays up: a container that is no step and on no schedule,
// a service stated running, a process that is no step and on no schedule (ADR 0240 rule 1).
func LongRunning(r map[string]any) bool {
switch fmt.Sprint(r["type"]) {
case "container", "process":
if once, _ := r["run-once"].(bool); once {
return false
}
return r["schedule"] == nil
case "service":
return fmt.Sprint(r["state"]) == "running"
}
return false
}
// ReadHealth reads a resource's `health` field, defaults applied. False when it carries none.
func ReadHealth(r map[string]any) (Health, bool, []string) {
raw, present := r[HealthField]
if !present {
return Health{}, false, nil
}
id := fmt.Sprint(r["id"])
fields, ok := raw.(map[string]any)
if !ok {
return Health{}, true, []string{fmt.Sprintf("%s: health is %T; it is an object with a kind and its timing", id, raw)}
}
var problems []string
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
switch {
case healthAddressKeys[k]:
problems = append(problems, fmt.Sprintf("%s: its health names a %s; a check names an endpoint the module "+
"declares under listens, by its name, so it follows the port this machine gives it (ADR 0240 rule 2)", id, k))
case !healthKeys[k]:
problems = append(problems, fmt.Sprintf("%s: its health says %q, which a health check does not have", id, k))
}
}
text := func(key string) string {
v, present := fields[key]
if !present {
return ""
}
s, ok := v.(string)
if !ok {
problems = append(problems, fmt.Sprintf("%s: its health's %s is %T; it is text", id, key, v))
}
return strings.TrimSpace(s)
}
duration := func(key string, fallback time.Duration) time.Duration {
s := text(key)
if s == "" {
return fallback
}
d, err := time.ParseDuration(s)
if err != nil || d < 0 {
problems = append(problems, fmt.Sprintf("%s: its health's %s is %q; it is a duration such as \"30s\"", id, key, s))
return fallback
}
return d
}
number := func(key string, fallback int) int {
v, present := fields[key]
if !present {
return fallback
}
f, ok := v.(float64)
if !ok || f != float64(int(f)) {
problems = append(problems, fmt.Sprintf("%s: its health's %s is %v; it is a whole number", id, key, v))
return fallback
}
return int(f)
}
h := Health{Kind: text("kind"), Endpoint: text("endpoint"), Path: text("path"), Body: text("body"),
Scheme: text("scheme"), Command: text("command"), Tool: text("tool"), Needs: text("needs"),
Interval: duration("interval", HealthIntervalDefault), Timeout: duration("timeout", HealthTimeoutDefault),
Grace: duration("grace", HealthGraceDefault), Looks: number("looks", HealthLooksDefault),
Status: number("status", 0)}
return h, true, problems
}
// healthProblems is everything wrong with a manifest's `health` fields, in the manifest's words (to-be
// 48 §8): a field on something that does not stay up, a kind its resource cannot have, an endpoint the
// module does not declare, a port or an address, a timing outside its bounds, a tool the module does not
// serve, and a tool check with no check of another kind beside it on the module.
func healthProblems(m Manifest) []string {
var problems []string
endpoints := map[string]Listening{}
for _, l := range m.Listens {
if n := strings.TrimSpace(l.Name); n != "" {
endpoints[n] = l
}
}
tools := map[string]bool{}
for _, t := range m.Tools {
tools[t] = true
}
wants := map[string]bool{}
for _, w := range m.Wants() {
wants[w] = true
}
var toolChecks []string
otherKinds := 0
resources := m.Resources
// A module's own runtime is judged as the process the controller writes for it (novox/hq ADR 0366 §1),
// and only a module that runs as its own account has one.
own, hasOwn := OwnRuntimeHealth(m)
if hasOwn {
if m.RunsAs == "" {
problems = append(problems, fmt.Sprintf("%s declares runtime-health and runs as no account of its own "+
"(runs-as): only a module served by a runtime of its own has that runtime's health to declare "+
"(novox/hq ADR 0366 §1)", m.Module))
hasOwn = false
} else {
resources = append(append([]map[string]any(nil), m.Resources...), own)
}
}
for i, r := range resources {
h, has, read := ReadHealth(r)
if !has {
continue
}
ownRuntime := hasOwn && i == len(resources)-1
id, typ := fmt.Sprint(r["id"]), fmt.Sprint(r["type"])
where := m.Module + ": " + id
if ownRuntime {
where = m.Module + ": its runtime-health"
}
for _, p := range read {
problems = append(problems, m.Module+": "+p)
}
if !LongRunning(r) {
problems = append(problems, fmt.Sprintf("%s declares health and does not stay up: a step, anything on a "+
"schedule and a service not stated running are judged by their step and their schedule (ADR 0240 rule 1)", where))
continue
}
switch h.Kind {
case HealthRuntime, HealthExec:
if typ != "container" {
problems = append(problems, fmt.Sprintf("%s is a %s and its health is %q, which only a container has: "+
"the runtime runs it inside the container", where, typ, h.Kind))
}
case HealthUnit:
if typ == "container" {
problems = append(problems, fmt.Sprintf("%s is a container and its health is %q, which is a service's "+
"or a process's own readiness", where, h.Kind))
}
case HealthHTTP, HealthTCP, HealthTool:
case "":
problems = append(problems, fmt.Sprintf("%s declares health with no kind: %s", where, healthKindsWords()))
default:
problems = append(problems, fmt.Sprintf("%s declares health of kind %q: %s", where, h.Kind, healthKindsWords()))
}
switch h.Kind {
case HealthHTTP, HealthTCP:
l, declared := endpoints[h.Endpoint]
switch {
case h.Endpoint == "":
problems = append(problems, fmt.Sprintf("%s's %s check names no endpoint: it names one the module "+
"declares under listens, by its name", where, h.Kind))
case !declared:
problems = append(problems, fmt.Sprintf("%s's %s check names the endpoint %q, which %s does not declare "+
"under listens (%s)", where, h.Kind, h.Endpoint, m.Module, namedEndpointsWords(endpoints)))
case l.At() != "tcp":
problems = append(problems, fmt.Sprintf("%s's %s check names %q, which is %s: a check connects over tcp",
where, h.Kind, h.Endpoint, l.At()))
}
default:
if h.Endpoint != "" {
problems = append(problems, fmt.Sprintf("%s's %s check names an endpoint, which only an http or tcp check "+
"looks at", where, h.Kind))
}
}
if h.Kind != HealthHTTP && (h.Path != "" || h.Status != 0 || h.Body != "" || h.Scheme != "") {
problems = append(problems, fmt.Sprintf("%s's %s check says a path, a status, a body or a scheme, which "+
"only an http check has", where, h.Kind))
}
if h.Kind == HealthHTTP {
if h.Path != "" && !strings.HasPrefix(h.Path, "/") {
problems = append(problems, fmt.Sprintf("%s's http check asks %q; a path starts with /", where, h.Path))
}
if h.Status != 0 && (h.Status < 100 || h.Status > 599) {
problems = append(problems, fmt.Sprintf("%s's http check expects status %d, which is not one", where, h.Status))
}
if h.Scheme != "" && h.Scheme != "http" && h.Scheme != "https" {
problems = append(problems, fmt.Sprintf("%s's http check is over %q; it is http or https", where, h.Scheme))
}
}
if (h.Command != "") != (h.Kind == HealthExec) {
if h.Kind == HealthExec {
problems = append(problems, fmt.Sprintf("%s's exec check says no command", where))
} else {
problems = append(problems, fmt.Sprintf("%s's %s check says a command, which only an exec check runs",
where, h.Kind))
}
}
if h.Kind == HealthTool {
switch {
case h.Tool == "":
problems = append(problems, fmt.Sprintf("%s's tool check names no tool", where))
case !tools[h.Tool]:
problems = append(problems, fmt.Sprintf("%s's tool check asks %q, which %s does not serve (its tools: %s)",
where, h.Tool, m.Module, orNoneWords(m.Tools)))
}
// Its own runtime being alive, judged by the node-engine, is the check the module does not run
// itself that this tool check stands beside (novox/hq ADR 0366 §2).
if !ownRuntime {
toolChecks = append(toolChecks, id)
}
} else {
if h.Tool != "" {
problems = append(problems, fmt.Sprintf("%s's %s check names a tool, which only a tool check asks", where, h.Kind))
}
if h.Kind != "" {
otherKinds++
}
}
if h.Needs != "" && !wants[h.Needs] {
problems = append(problems, fmt.Sprintf("%s's check needs %q, which %s does not require: a check names "+
"the provision it exercises, among those the module requires", where, h.Needs, m.Module))
}
problems = append(problems, healthTimingProblems(where, h)...)
}
if len(toolChecks) > 0 && otherKinds == 0 {
problems = append(problems, fmt.Sprintf("%s judges itself only by its own tool (%s): a tool check is for "+
"function no endpoint shows, and only beside a check of another kind the module does not run itself "+
"(ADR 0227 rule 8, ADR 0240 rule 2)", m.Module, strings.Join(toolChecks, ", ")))
}
return problems
}
// healthTimingProblems holds a check's timing to its bounds.
func healthTimingProblems(where string, h Health) []string {
var problems []string
if h.Interval < HealthIntervalFloor {
problems = append(problems, fmt.Sprintf("%s looks every %s; a check looks no more often than every %s — "+
"the mesh is a guest on the machine (ADR 0240 rule 2)", where, h.Interval, HealthIntervalFloor))
}
if h.Timeout <= 0 || h.Timeout >= h.Interval {
problems = append(problems, fmt.Sprintf("%s gives a look %s, which must be more than nothing and under its "+
"interval of %s", where, h.Timeout, h.Interval))
}
if h.Looks < HealthLooksFloor {
problems = append(problems, fmt.Sprintf("%s is unhealthy after %d failing look(s); it is at least %d — one "+
"look can be wrong (issue 277)", where, h.Looks, HealthLooksFloor))
}
if h.Grace < 0 {
problems = append(problems, fmt.Sprintf("%s has a grace of %s", where, h.Grace))
}
if h.Looks >= HealthLooksFloor && h.Interval >= HealthIntervalFloor {
if took := h.Grace + time.Duration(h.Looks)*h.Interval; took > HealthWithin {
problems = append(problems, fmt.Sprintf("%s is said unhealthy at the earliest %s after it starts (a grace "+
"of %s and %d looks every %s); it is at most %s, so a resource broken from its start is said within "+
"the gate's bound", where, took, h.Grace, h.Looks, h.Interval, HealthWithin))
}
}
return problems
}
func healthKindsWords() string {
return "a check is runtime (the image's own, adopted by name), http, tcp, exec, unit or tool"
}
func namedEndpointsWords(endpoints map[string]Listening) string {
if len(endpoints) == 0 {
return "it names none"
}
names := make([]string, 0, len(endpoints))
for n := range endpoints {
names = append(names, n)
}
sort.Strings(names)
return "it names " + strings.Join(names, ", ")
}
func orNoneWords(names []string) string {
if len(names) == 0 {
return "none"
}
return strings.Join(names, ", ")
}
// Undeclared is every long-running resource of the manifest without `health`, by id (ADR 0240 rule 8):
// what the catalogue's count counts.
func Undeclared(m Manifest) []string {
var out []string
for _, r := range m.Resources {
if !LongRunning(r) {
continue
}
if _, has := r[HealthField]; !has {
out = append(out, fmt.Sprint(r["id"]))
}
}
return out
}
// HealthChecks is every tool a module's health asks, as `<module>.<tool>`: what a machine's node-engine
// is granted to ask of its own node tools (to-be 48 §3).
func HealthChecks(m Manifest) []string {
var out []string
resources := m.Resources
if own, has := OwnRuntimeHealth(m); has && m.RunsAs != "" {
resources = append(append([]map[string]any(nil), resources...), own)
}
for _, r := range resources {
h, has, _ := ReadHealth(r)
if has && h.Kind == HealthTool && h.Tool != "" && LongRunning(r) {
out = append(out, m.Module+"."+h.Tool)
}
}
sort.Strings(out)
return out
}
// OwnRuntimeHealth is a module's `runtime-health` as the resource it is put on (novox/hq ADR 0366 §1): the
// process `<module>.own-runtime`, carrying it as its `health`. False when the module declares none.
func OwnRuntimeHealth(m Manifest) (map[string]any, bool) {
if m.RuntimeHealth == nil {
return nil, false
}
return map[string]any{"id": m.Module + "." + OwnRuntimeID(), "type": "process", HealthField: m.RuntimeHealth}, true
}
// healthInto composes a resource's `health` into the node-engine's words, or takes it away (to-be 48 §2,
// §3): for an engine that reads it, the endpoint becomes the port this machine published it on and the
// defaults are written out; for one that does not — older and strict — the field is not sent, and the
// resource is judged by liveness alone, as before.
func healthInto(resource map[string]any, m Manifest, with Rendering) {
if _, has := resource[HealthField]; !has {
return
}
if !with.ReadsHealth {
delete(resource, HealthField)
return
}
h, _, _ := ReadHealth(resource)
out := map[string]any{"kind": h.Kind, "interval": h.Interval.String(), "timeout": h.Timeout.String(),
"looks": h.Looks, "grace": h.Grace.String()}
if h.Endpoint != "" {
out["endpoint"] = h.Endpoint
if port, ok := EndpointPort(m, h.Endpoint); ok {
out["port"] = with.machinePort(m.Module, port)
}
}
if h.Kind == HealthHTTP {
path := h.Path
if path == "" {
path = "/"
}
out["path"] = path
if h.Status != 0 {
out["status"] = h.Status
}
if h.Body != "" {
out["body"] = h.Body
}
if h.Scheme != "" {
out["scheme"] = h.Scheme
}
}
if h.Command != "" {
out["command"] = h.Command
}
if h.Tool != "" {
out["tool"] = h.Tool
}
if h.Needs != "" {
out["needs"] = h.Needs
}
resource[HealthField] = out
}
// HealthWords is a declared check in a line, for `module check` and `node show`.
func HealthWords(h Health) string {
var what string
switch h.Kind {
case HealthHTTP:
what = "http " + orSlash(h.Path) + " on " + h.Endpoint
if h.Status != 0 {
what += " expecting " + strconv.Itoa(h.Status)
}
case HealthTCP:
what = "tcp on " + h.Endpoint
case HealthTool:
what = "its tool " + h.Tool
case HealthRuntime:
what = "its image's own check"
default:
what = h.Kind
}
return fmt.Sprintf("%s every %s", what, h.Interval)
}
func orSlash(p string) string {
if p == "" {
return "/"
}
return p
}
@@ -0,0 +1,220 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// Who a consumer is, said once by the mesh (novox/hq 04-ISSUES/023).
//
// **The provisioner used to invent this and nothing else could derive it.** It made a role called
// `mesh_<node>_<module>`, which is a reasonable name and is knowable nowhere else: not by the
// control plane, not by the binding, and above all not by the consumer — which has to present it
// in order to authenticate. The one identifier needed to connect was the one thing no part of the
// mesh would say.
//
// So the mesh says it. It goes to the provider in the grant and to the consumer in its binding,
// from **one derivation**, which is what makes the two ends agree by construction rather than by
// two conventions that were the same on the day they were written.
//
// **It is still name-agnostic.** The mesh does not know what a role or an access key or a client
// is; it says who is asking, and each provisioner makes that true in whatever its own system
// calls an identity. What a provider does with it is the provider's business, as everything about
// a provision is.
// identityUnusable is every character that is not safe unquoted in the systems these names reach.
//
// Conservative on purpose: lower-case letters, digits and underscore reach a PostgreSQL role, a
// MinIO access key, an LDAP uid and a Keycloak client without quoting or escaping in any of them.
// A wider set would work in most and fail in one, discovered as a login that cannot be created.
var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`)
// IdentityPrefix marks what the mesh made, so a provisioner can find its own work and leave
// everything else alone. Withdrawal depends on it entirely.
const IdentityPrefix = "mesh_"
// IdentitySource is the name the mesh derives a consumer's identity from: the module's slug when it
// has declared one, otherwise its name (novox/hq ADR 0049). A module with a name short enough to fit
// the tightest backend needs no slug; one whose name would overflow declares a short legible one.
func IdentitySource(slug, name string) string {
if slug != "" {
return slug
}
return name
}
// ConsumerIdentity is what one module on one machine is called, wherever it authenticates. The
// `module` argument is the identity source — a slug or a name; see IdentitySource.
//
// A dot and a dash both become an underscore, so `home-server` and `home.server` would collide —
// which cannot happen, because a machine has one name and it is either.
func ConsumerIdentity(node, module string) string {
clean := func(s string) string {
return strings.Trim(identityUnusable.ReplaceAllString(strings.ToLower(s), "_"), "_")
}
return IdentityPrefix + clean(node) + "_" + clean(module)
}
// IdentityBound is the longest consumer identity one provision's backend keeps, and what keeps it
// (novox/hq ADR 0049, refined by ADR 0225). Max zero means no bound: the provision keeps no name
// derived from its consumer, or keeps one in something with no limit the mesh need respect.
type IdentityBound struct {
// Max is the longest identity that backend keeps, in characters; zero for none.
Max int `json:"max,omitempty"`
// In is what keeps it, in words a refusal can quote: "an S3 access key", "a PostgreSQL role".
In string `json:"in,omitempty"`
}
// Bounded is whether this bound refuses anything.
func (b IdentityBound) Bounded() bool { return b.Max > 0 }
// DefaultIdentityLimit is the bound on a provision whose provider receives its consumers and does
// not say how long a name it keeps: an S3 access key's 20 (novox/hq 04-ISSUES/010, 034), the
// tightest backend the mesh has met. It was the bound on every provision until ADR 0225; it stays
// the bound on any that has not said otherwise, because a provider that is told each consumer's
// identity may create a name from it in a backend nobody has measured.
const DefaultIdentityLimit = 20
// DefaultIdentityBound is DefaultIdentityLimit, said as a bound.
var DefaultIdentityBound = IdentityBound{Max: DefaultIdentityLimit,
In: "a backend that has not said its limit (the tightest known, an S3 access key's)"}
// identityLimit is the bound CheckIdentity applies, for a caller that does not know which provision
// the identity is for.
const identityLimit = DefaultIdentityLimit
// CheckIdentity refuses an identity that would not fit the tightest backend the mesh knows. A caller
// that knows the provision uses CheckIdentityWithin and that provision's own bound (ADR 0225).
//
// **Truncation is not an error in most of these systems** — a name past the limit is cut to fit and
// the statement succeeds, so two consumers agreeing for the first N bytes would become one login
// (04-ISSUES/022) — and S3 refuses outright. Refused here, at the mesh, because the mesh chose the
// name and is the only thing that can choose another. The remedy is a first-class one: give the
// module a short `slug` (ADR 0049), or shorten the machine's name.
func CheckIdentity(node, module string) error {
return CheckIdentityWithin(node, module, IdentityBound{Max: identityLimit, In: "a backend (an S3 access key)"})
}
// CheckIdentityWithin refuses an identity that would not fit one provision's bound, and accepts any
// identity for a provision with none (novox/hq ADR 0225).
func CheckIdentityWithin(node, module string, bound IdentityBound) error {
if !bound.Bounded() {
return nil
}
got := ConsumerIdentity(node, module)
if len(got) <= bound.Max {
return nil
}
return fmt.Errorf(
"%s on %s is identified as %q, %d characters where %s keeps %d — "+
"give the module a shorter `slug` or shorten the machine's name",
module, node, got, len(got), bound.In, bound.Max)
}
// Overflow is one consumer whose identity does not fit the provision it requires: left out of its
// provider's grants and reported, never a reason to refuse the provider's machine (ADR 0225).
type Overflow struct {
// Provision is what was required, Provider the machine answering it.
Provision string `json:"provision"`
Provider string `json:"provider"`
// Consumer is the machine, Module the module on it that required it.
Consumer string `json:"consumer"`
Module string `json:"module"`
// Identity is the name the mesh derived, and Bound what it overflows.
Identity string `json:"identity"`
Bound IdentityBound `json:"bound"`
}
func (o Overflow) String() string {
return fmt.Sprintf("%s on %s requires %s from %s and is identified as %q, %d characters where %s "+
"keeps %d — left out of %s's grants until the module's `slug` is shorter",
o.Module, o.Consumer, o.Provision, o.Provider, o.Identity, len(o.Identity), o.Bound.In,
o.Bound.Max, o.Provider)
}
// Overflowing is every requirement of this machine's modules whose identity overflows the bound of
// the provision answering it. The same judgement the provider's composition makes before it grants
// (grantsFor), made from the consumer's side so `status` can say it about every machine.
func (r Resolution) Overflowing() []Overflow {
slugs := map[string]string{}
for _, m := range r.Modules {
slugs[m.Module] = m.Slug
}
var out []Overflow
seen := map[string]bool{}
for _, n := range r.Needs {
if n.ByRecord || !n.Identity.Bounded() {
continue
}
source := IdentitySource(slugs[n.For], n.For)
if CheckIdentityWithin(r.Node, source, n.Identity) == nil {
continue
}
key := n.Name + "\x00" + n.From + "\x00" + n.For
if seen[key] {
continue // one line per requirement, however many local names it has
}
seen[key] = true
out = append(out, Overflow{Provision: n.Name, Provider: n.From, Consumer: r.Node, Module: n.For,
Identity: ConsumerIdentity(r.Node, source), Bound: n.Identity})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Module != out[j].Module {
return out[i].Module < out[j].Module
}
return out[i].Provision < out[j].Provision
})
return out
}
// DefaultLongestMachine is the machine name the catalogue check judges identities on when it is not
// told one: the longest name of the mesh this catalogue is written for, so a catalogue that passes
// passes on every machine that mesh has. `module check --longest-machine-name` says another mesh's;
// a mesh that names a longer machine raises this in the same change (ADR 0225).
const DefaultLongestMachine = 6
// IdentityProblems is every module whose identity would overflow a provision it wants, on a machine
// whose name is `longestMachine` characters — judged before merge, over the catalogue alone, so the
// pull request that introduces an overflow is the one refused (novox/hq ADR 0225, issue 263). A
// provision no module in the shelf offers is not judged: its bound is not known here.
func IdentityProblems(shelf Shelf, longestMachine int) []string {
offeredBy := map[string][]string{}
for _, name := range shelfOrder(shelf) {
for _, o := range shelf[name].Offers() {
offeredBy[o] = append(offeredBy[o], name)
}
}
machine := strings.Repeat("n", longestMachine)
var problems []string
for _, name := range shelfOrder(shelf) {
m := shelf[name]
source := IdentitySource(m.Slug, m.Module)
for _, want := range m.Wants() {
// The tightest bound among the modules offering it: whichever one answers on a given
// machine, the identity has to fit it.
tightest, by := IdentityBound{}, ""
for _, provider := range offeredBy[want] {
if provider == name {
continue // a module answering its own requirement is not its own consumer
}
b := shelf[provider].IdentityBoundOf(want)
if b.Bounded() && (!tightest.Bounded() || b.Max < tightest.Max) {
tightest, by = b, provider
}
}
if CheckIdentityWithin(machine, source, tightest) == nil {
continue
}
got := ConsumerIdentity(machine, source)
problems = append(problems, fmt.Sprintf(
"%s wants %s, and %s keeps its consumers' identities in %s of at most %d characters: "+
"on a machine with a %d-character name it is identified as %q, %d — give %s a "+
"`slug` of at most %d characters",
name, want, by, tightest.In, tightest.Max, longestMachine, got, len(got), name,
tightest.Max-len(IdentityPrefix)-longestMachine-1))
}
}
return problems
}
@@ -0,0 +1,236 @@
package catalogue
import (
"encoding/json"
"fmt"
"net"
"regexp"
"sort"
"strings"
)
// A definition names no installation (novox/hq ADR 0112, ADR 0155, issues 122 and 134).
//
// A module definition holds what is true of the module everywhere; what is particular to one mesh —
// a public name, a forge's address, a node's public address — is resolved at assignment. The rule
// stood for a month with nothing checking it, and a sweep found thirty of seventy-one definitions
// naming the installation they were written in. This is the check.
//
// **What is judged is what the mesh acts on, not what a person reads.** A domain in a `why` or a
// `description` is documentation the mesh never reads; reporting it beside `KC_HOSTNAME` would teach
// people to ignore the report. What is judged is every other string value: a name under a public
// top-level domain, or a public address. Two families of name are the world's and not this mesh's,
// and are allowed where they can only mean the world: the public registries an `image` may be pulled
// from, and the public resolvers a machine may forward to. The container runtime's own alias for
// its host is the runtime's, true on every machine that runs it.
//
// **A name that is right where it stands is declared, one by one, with its reason.** A federated
// server's config names the federation's public directory; an application built outside the mesh
// is pulled from the registry that built it, until the mesh builds it. The resource carries
// `names-on-purpose`, a map from each such name to why — the shape `secrets-in-environment` has,
// per name — so a reader sees which names a definition means to carry and why, a name the map does
// not cover is still reported, and the catalogue-wide test is the list that shrinks as names move.
// NamesOnPurpose is the catalogue-level word a resource carries for the names it means to name:
// each name mapped to its reason. The host never sees it.
const NamesOnPurpose = "names-on-purpose"
// prose is every key whose value the mesh never reads.
var prose = map[string]bool{"why": true, "description": true}
// Registries the world runs, which an image may name because an image reference must say where it
// is pulled from. Anything else in an image reference is a registry of some installation.
var worldsRegistries = map[string]bool{
"docker.io": true, "registry-1.docker.io": true, "index.docker.io": true, "ghcr.io": true,
"quay.io": true, "gcr.io": true, "registry.k8s.io": true, "k8s.gcr.io": true,
"mcr.microsoft.com": true, "lscr.io": true, "public.ecr.aws": true, "registry.gitlab.com": true,
"codeberg.org": true, "cgr.dev": true,
}
// Services the world runs that a definition may name as a policy default, the way it may name a
// public resolver: the public certificate authorities' ACME directories. Anything else a served
// fact or a file names is somebody's installation.
var worldsServices = map[string]bool{
"acme-v02.api.letsencrypt.org": true, "acme-staging-v02.api.letsencrypt.org": true,
"api.buypass.com": true, "api.test4.buypass.no": true, "dv.acme-v02.api.pki.goog": true,
"acme.zerossl.com": true,
}
// Resolvers the world runs, which a machine's resolver may forward to as a policy default.
var worldsResolvers = map[string]bool{
"1.1.1.1": true, "1.0.0.1": true, "8.8.8.8": true, "8.8.4.4": true, "9.9.9.9": true,
"149.112.112.112": true, "208.67.222.222": true, "208.67.220.220": true,
}
// hostname is a dotted name whose last label is a top-level domain a real installation would have.
// Not every dotted token: `module.json`, `index.html` and `docker.sock` are dotted and name nothing.
// Boundaries are checked by hand rather than in the pattern, because two names one character apart
// — `a.example.tld,b.example.tld` — would otherwise share the delimiter and the second would be lost.
var hostname = regexp.MustCompile(
`(?i)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+` +
`(?:be|nl|de|fr|uk|eu|com|net|org|io|dev|app|cloud|site|online|me|co|ch|at|lu|` +
`internal|example|tld|test|invalid)`)
// upstreamVersion is where an artifact pinned by URL says which release it is (Artifact.Version).
var upstreamVersion = regexp.MustCompile(`^build\.artifacts\[[0-9]+\]\.version$`)
// address is a dotted quad.
var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`)
// isName is whether a byte may be part of a name; a match bordered by one is a longer token.
func isName(b byte) bool {
return b == '.' || b == '-' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
// standalone are the matches of re in value that are whole tokens, not parts of a longer one.
func standalone(re *regexp.Regexp, value string) []string {
var out []string
for _, span := range re.FindAllStringIndex(value, -1) {
if span[0] > 0 && isName(value[span[0]-1]) {
continue
}
if span[1] < len(value) && isName(value[span[1]]) {
continue
}
out = append(out, value[span[0]:span[1]])
}
return out
}
// InstallationProblems is every value of a definition that names an installation, in the
// definition's own words: where it is, and what it names.
func InstallationProblems(m Manifest) []string {
raw, err := json.Marshal(m)
if err != nil {
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
}
var tree any
if err := json.Unmarshal(raw, &tree); err != nil {
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
}
var problems []string
// The module's own name is a value too: a module named after the domain it serves is a
// definition that can only be installed there (issue 134).
for _, name := range namesIn(m.Module) {
problems = append(problems, fmt.Sprintf(
"%s is named after %s, and a module is named for what it is, not for where it runs", m.Module, name))
}
walk(tree, "", nil, func(at string, value string, meant map[string]bool, isImage bool) {
for _, name := range namesIn(value) {
if (isImage && worldsRegistries[strings.ToLower(name)]) || meant[name] {
continue
}
problems = append(problems, fmt.Sprintf("%s names %s at %s", m.Module, name, at))
}
for _, ip := range addressesIn(value) {
// An upstream pin's version may have four dotted numbers (Owl 1.5.6.1), which is no address; a name
// in it is still judged above. Its URL is judged whole: the vendor's host goes through the artifact's
// names-on-purpose like any name a definition means (novox/hq ADR 0316).
if meant[ip] || upstreamVersion.MatchString(at) {
continue
}
problems = append(problems, fmt.Sprintf("%s names the public address %s at %s", m.Module, ip, at))
}
})
sort.Strings(problems)
return problems
}
// walk visits every string in the tree with its path, the names the enclosing resource means to
// name (with a reason), and whether it is an image reference.
func walk(node any, at string, meant map[string]bool, visit func(at, value string, meant map[string]bool, isImage bool)) {
switch v := node.(type) {
case map[string]any:
if declared, has := v[NamesOnPurpose].(map[string]any); has {
widened := map[string]bool{}
for name := range meant {
widened[name] = true
}
for name, reason := range declared {
if r, ok := reason.(string); ok && strings.TrimSpace(r) != "" {
widened[strings.ToLower(name)] = true
}
}
meant = widened
}
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
// Prose is a string a person reads. A key that is called `why` or `description` and holds
// anything else — a setting of that name, whose default the mesh writes — is walked like any
// other (novox/hq ADR 0262).
if _, isString := v[k].(string); (prose[k] && isString) || k == NamesOnPurpose || (at == "" && k == "module") {
continue
}
child := at + "." + k
if at == "" {
child = k
}
if s, isString := v[k].(string); isString {
visit(child, s, meant, k == "image")
continue
}
walk(v[k], child, meant, visit)
}
case []any:
for i, item := range v {
child := fmt.Sprintf("%s[%d]", at, i)
if s, isString := item.(string); isString {
visit(child, s, meant, false)
continue
}
walk(item, child, meant, visit)
}
}
}
// namesIn is every hostname in a value that could belong to an installation.
func namesIn(value string) []string {
var out []string
for _, found := range standalone(hostname, value) {
name := strings.ToLower(found)
switch {
case strings.HasSuffix(name, ".docker.internal"):
// The container runtime's alias for its own host: every machine running it has one.
case worldsServices[name]:
// A public authority named as a policy default, true of any mesh that wants it.
case name == "example.tld", strings.HasSuffix(name, ".example.tld"),
name == "example.com", name == "example.net", name == "example.org",
strings.HasSuffix(name, ".example.com"), strings.HasSuffix(name, ".example.net"),
strings.HasSuffix(name, ".example.org"), strings.HasSuffix(name, ".example"),
strings.HasSuffix(name, ".test"), strings.HasSuffix(name, ".invalid"):
// Documentation names, which is what a definition's own example should use.
default:
out = append(out, name)
}
}
return out
}
// addressesIn is every public address in a value: not a private range, loopback, link-local, the
// unspecified address, a documentation range, or a resolver the world runs.
func addressesIn(value string) []string {
var out []string
for _, found := range standalone(address, value) {
ip := net.ParseIP(found)
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsUnspecified() || ip.IsMulticast() || worldsResolvers[found] || documentation(ip) {
continue
}
out = append(out, found)
}
return out
}
func documentation(ip net.IP) bool {
for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "100.64.0.0/10"} {
_, block, _ := net.ParseCIDR(cidr)
if block.Contains(ip) {
return true
}
}
return false
}
@@ -0,0 +1,72 @@
package catalogue
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
)
// A node's fail2ban jails, composed from the modules it runs (novox/hq to-be 31).
//
// **The same shape as the firewall.** Every module's `listens` become the node's rule set; every
// module's `jails` become the node's fail2ban config. A module that runs an authenticating service
// declares what a break-in on it looks like and how to ban it, naming no node and no path (ADR
// 0112); the intrusion-prevention holder — the one module with `jailing` — gathers them and writes
// them where it owns. A node not running a module has none of its jails.
// jailsInto composes every jail declared by the modules on a node into the files the holder writes:
// one jail file (all stanzas, so the fail2ban service restarts on a single resource) and one filter
// file per jail (its failregex, which fail2ban references by the jail's name).
//
// Owned by the holder, because the directory is: two modules writing into one fail2ban is the
// collision the holder model exists to prevent. Empty when nothing declares a jail — then the file
// is written empty rather than absent, so removing the last jail is an ordinary change the service
// restarts on rather than a file that vanishes.
func jailsInto(modules []Manifest, j *Jailing) []map[string]any {
type declared struct {
module string
jail Jail
}
var jails []declared
for _, m := range modules {
for _, jail := range m.Jails {
jails = append(jails, declared{m.Module, jail})
}
}
// A stable order the host applies as given (ADR 0005), and so the same set composes byte for
// byte every time rather than differing by map iteration.
sort.Slice(jails, func(a, b int) bool { return jails[a].jail.Name < jails[b].jail.Name })
var composed strings.Builder
composed.WriteString("# The mesh's jails, composed from the modules this node runs. Do not edit —\n")
composed.WriteString("# replaced whenever the node's modules change (novox/hq to-be 31).\n")
out := make([]map[string]any, 0, len(jails)+1)
for _, d := range jails {
// **The filter's digest rides in the jail file.** fail2ban is restarted when this file
// changes, and the filter is a file of its own: a module that changed only what a failure
// looks like rewrote the filter on disk and left the running jail on the old pattern, with
// nothing said (novox/hq issue 191's rollout found it on gitea's sshd). Naming the filter's
// digest here makes a changed pattern a changed jail file, so the restart the service
// already takes on it covers the filter too.
sum := sha256.Sum256([]byte(d.jail.Failregex))
fmt.Fprintf(&composed, "\n# from %s, filter %s\n[%s]\nenabled = true\nfilter = %s\n%s\n",
d.module, hex.EncodeToString(sum[:])[:12], d.jail.Name, d.jail.Name,
strings.TrimRight(d.jail.Jail, "\n"))
// The filter is a file of its own, named as the jail's filter= references it.
out = append(out, map[string]any{
"id": "filter-" + d.jail.Name,
"type": "file", "path": strings.TrimRight(j.FilterInto, "/") + "/" + d.jail.Name + ".conf",
"mode": "0644",
"content": "# Generated by the mesh (from module " + d.module + "). Do not edit.\n" +
"[Definition]\nfailregex = " + d.jail.Failregex + "\n",
})
}
// The one jail file, first, with the fixed id the fail2ban service names in its restart-on.
return append([]map[string]any{{
"id": ComposedJailsID(), "type": "file", "path": j.Into, "mode": "0644",
"content": composed.String(),
}}, out...)
}
@@ -0,0 +1,154 @@
package catalogue
import (
"fmt"
"net/netip"
"slices"
"sort"
"strings"
)
// AttachedNetwork is one network a machine said it is directly attached to: the link it is on and the
// network as address and prefix (novox/hq ADR 0340 §7), as the node-engine reports it among its facts.
type AttachedNetwork struct {
Link string `json:"link"`
Network string `json:"network"`
}
// LocalNetwork is one attached network judged: whether it counts as this machine's local network for a
// port reached from `local-network`, and why — said for one that counts as well as for one that does
// not, so `node` can show which counted and why the others did not (ADR 0340 §8).
type LocalNetwork struct {
Link string
Network string
Counts bool
Why string
}
// privateRanges are the only ranges a local network may lie in (ADR 0340 §2): RFC 1918 for IPv4 and the
// unique local addresses for IPv6. A network counts only when it lies wholly inside one of them.
var privateRanges = []struct {
prefix netip.Prefix
name string
}{
{netip.MustParsePrefix("10.0.0.0/8"), "a private range (RFC 1918, 10.0.0.0/8)"},
{netip.MustParsePrefix("172.16.0.0/12"), "a private range (RFC 1918, 172.16.0.0/12)"},
{netip.MustParsePrefix("192.168.0.0/16"), "a private range (RFC 1918, 192.168.0.0/16)"},
{netip.MustParsePrefix("fc00::/7"), "the IPv6 unique local range (fc00::/7)"},
}
// JudgeLocalNetworks says, for each network a machine reported, whether it counts as its local network.
//
// **A public subnet never counts** (ADR 0340 §2): a network counts only when it lies wholly in a private
// range, so `local-network` can never open a port to the internet — a machine sitting on a public /24 has
// that /24 judged and refused, never rendered. A link-local network does not count either. And only a
// link the machine reported as facing outside it counts (§3): the private network's own link and a
// container bridge are this machine's own, not its local network, whatever their addresses.
//
// Sorted by link and then network, one entry per (link, network), so what `node` shows and what the
// filter is rendered from do not depend on the order a machine happened to say them in.
func JudgeLocalNetworks(attached []AttachedNetwork, outward []string, tunnel string) []LocalNetwork {
seen := map[[2]string]bool{}
var out []LocalNetwork
for _, a := range attached {
key := [2]string{a.Link, a.Network}
if seen[key] {
continue
}
seen[key] = true
out = append(out, judgeOne(a, outward, tunnel))
}
sort.Slice(out, func(i, j int) bool {
if out[i].Link != out[j].Link {
return out[i].Link < out[j].Link
}
return out[i].Network < out[j].Network
})
return out
}
func judgeOne(a AttachedNetwork, outward []string, tunnel string) LocalNetwork {
j := LocalNetwork{Link: a.Link, Network: a.Network}
prefix, err := netip.ParsePrefix(strings.TrimSpace(a.Network))
if err != nil {
j.Why = "not a network the mesh can read (" + err.Error() + ")"
return j
}
prefix = prefix.Masked()
j.Network = prefix.String()
switch {
case tunnel != "" && a.Link == tunnel:
j.Why = "the private network's own link, not the local network"
return j
case !slices.Contains(outward, a.Link):
j.Why = fmt.Sprintf("%s does not face outside this machine (a bridge or a link of its own)", a.Link)
return j
case prefix.Addr().IsLoopback():
j.Why = "loopback"
return j
case prefix.Addr().IsLinkLocalUnicast():
j.Why = "link-local, never counted"
return j
}
for _, r := range privateRanges {
if r.prefix.Contains(prefix.Addr()) && prefix.Bits() >= r.prefix.Bits() {
j.Counts = true
j.Why = "in " + r.name
return j
}
}
if prefix.Addr().IsPrivate() {
// Overlaps a private range without lying wholly inside it (a /7 around 10.0.0.0/8, say).
j.Why = "not wholly in a private range, so never counted"
return j
}
j.Why = "public, never counted: local-network may not open a port to the internet"
return j
}
// CountedLocalNetworks is the networks that count, as the filter renders them: sorted, without repeats.
func CountedLocalNetworks(judged []LocalNetwork) []string {
var out []string
for _, j := range judged {
if j.Counts && !slices.Contains(out, j.Network) {
out = append(out, j.Network)
}
}
sort.Strings(out)
return out
}
// LocalNetworkPort is one port the filter admits from `local-network`, and what that resolved to on this
// machine (ADR 0340 §8): what `plan` shows for it.
type LocalNetworkPort struct {
Port int
Protocol string
Because []string
// Networks is the local networks it is admitted from; empty when the machine reported none (§6).
Networks []string
}
// LocalNetworkPorts is every rule admitted from `local-network`, with the networks it resolved to.
func LocalNetworkPorts(rules []Rule, networks []string) []LocalNetworkPort {
var out []LocalNetworkPort
for _, rule := range rules {
if rule.From != FromLocalNetwork {
continue
}
out = append(out, LocalNetworkPort{Port: rule.Port, Protocol: rule.Protocol,
Because: rule.Because, Networks: networks})
}
return out
}
// Said is how `plan` says one such port: the networks, or that there are none and the port is admitted
// from the private network only.
func (p LocalNetworkPort) Said() string {
by := strings.Join(p.Because, ", ")
if len(p.Networks) == 0 {
return fmt.Sprintf("%s/%d (%s) from the local network: none — this machine reported no private "+
"network on a link facing outside, so it is admitted from the private network only", p.Protocol, p.Port, by)
}
return fmt.Sprintf("%s/%d (%s) from the local network: %s, and the private network", p.Protocol, p.Port, by,
strings.Join(p.Networks, ", "))
}
+105
View File
@@ -0,0 +1,105 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
)
// What a module may call its logs (novox/hq ADR 0297).
//
// A log is a module's record of operations: entries appended under a key, each kept as long as the
// log is, in the order they came. A module names each log it owns **locally** — `changes`, never a
// stream or a subject (ADR 0201 §4) — and the mesh derives the stream from the module and the local
// name, as it derives a bucket. So the rule for a log's name is a state name's: one plain token, and
// a module that keeps a log has a name that is one plain token too.
// The mesh's caps on a log, in MiB: what a module may ask, and what it gets when it asks nothing.
const (
LogLeastMiB = 1
LogMostMiB = 8192
LogDefaultMiB = 1024
)
// LogDeclaration is one log a module owns: its local name, and how large it may grow.
type LogDeclaration struct {
Name string `json:"name"`
// MaxMiB is the log's cap in MiB; zero is LogDefaultMiB. When full, the log refuses new entries
// and never drops old ones.
MaxMiB int `json:"max-mib,omitempty"`
}
// Cap is the log's cap in MiB, the default where none is said.
func (l LogDeclaration) Cap() int {
if l.MaxMiB == 0 {
return LogDefaultMiB
}
return l.MaxMiB
}
// UnmarshalJSON reads a log as its bare name, or as {name, max-mib}.
func (l *LogDeclaration) UnmarshalJSON(raw []byte) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) > 0 && trimmed[0] == '"' {
return json.Unmarshal(trimmed, &l.Name)
}
var full struct {
Name string `json:"name"`
MaxMiB *int `json:"max-mib"`
}
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&full); err != nil {
return fmt.Errorf("a log is either a name or {name, max-mib}: %w", typedUnknown(err))
}
l.Name = full.Name
l.MaxMiB = 0
if full.MaxMiB != nil {
// Said, and said as nothing: refused rather than read as the default, which it did not say.
if *full.MaxMiB == 0 {
return fmt.Errorf("log %q: max-mib is between %d and %d, not 0", full.Name, LogLeastMiB, LogMostMiB)
}
l.MaxMiB = *full.MaxMiB
}
return nil
}
// MarshalJSON writes back the short form when there is nothing else to say.
func (l LogDeclaration) MarshalJSON() ([]byte, error) {
if l.MaxMiB == 0 {
return json.Marshal(l.Name)
}
type plain LogDeclaration
return json.Marshal(plain(l))
}
// LogProblems is what is wrong with a manifest's logs.
//
// Refused at registration, for a bucket's reason: a stream name the bus cannot hold, or a cap the
// mesh would not grant, is a module that installs, starts, and is refused on its first append.
func LogProblems(m Manifest) []string {
var problems []string
if len(m.Logs) > 0 && !stateName.MatchString(m.Module) {
problems = append(problems, fmt.Sprintf(
"%s keeps a log, and a module's name is part of its logs' names, which take one plain "+
"name — no dot (novox/hq ADR 0297)", m.Module))
}
seen := map[string]bool{}
for _, l := range m.Logs {
switch {
case !stateName.MatchString(l.Name):
problems = append(problems, fmt.Sprintf(
"%s keeps log %q: a log is named locally — lower-case letters, digits and hyphens, "+
"no dot and no underscore; the mesh derives the stream (novox/hq ADR 0297)", m.Module, l.Name))
case seen[l.Name]:
problems = append(problems, fmt.Sprintf("%s keeps log %q twice", m.Module, l.Name))
}
seen[l.Name] = true
if l.MaxMiB != 0 && (l.MaxMiB < LogLeastMiB || l.MaxMiB > LogMostMiB) {
problems = append(problems, fmt.Sprintf(
"%s caps log %q at %d MiB; a log holds between %d and %d MiB (novox/hq ADR 0297)",
m.Module, l.Name, l.MaxMiB, LogLeastMiB, LogMostMiB))
}
}
return problems
}
@@ -0,0 +1,177 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// What a module may say about the machine it is running on.
//
// **A module cannot know where it will be assigned, and sometimes it must say so anyway.** Every
// other name in a declaration is either the module's own — which it wrote — or something it
// requires, which arrives as a binding. The machine underneath is neither: it is chosen when the
// module is assigned, long after the manifest was written, and until now nothing carried it into a
// file.
//
// The case that found this is a certificate authority inside the mesh (novox/hq ADR 0066). A proxy
// reaches it at the address the mesh handed over, `<machine>.internal` — so the authority's own
// certificate has to be issued for that name, or the first thing that happens is the proxy refusing
// to talk to it. The authority is the one thing that cannot be told its name by a binding: it
// provides, it does not require. Written as a literal it would be a manifest carrying one
// deployment's machine name, which is the shape [ADR 0066] exists to remove.
//
// Three facts, all the mesh's own vocabulary — the same `node` and `at` a contribution already
// carries, and the address behind `at`, for software that takes an address and not a name. The
// case that found the third is a resolver pointing the container runtime at itself: the runtime's
// list of resolvers is addresses, because a name there would have to be resolved by the resolver
// it names. Nothing about what a machine is *for*: that would be the mesh learning what a module
// means, which it does not do.
// ofMachine is where a module says a fact about the machine underneath it belongs:
// ${machine:<key>}.
var ofMachine = regexp.MustCompile(`\$\{machine:([a-z0-9][a-z0-9_-]*)\}`)
// machineUsed are the keys a file's content asks for, first appearance first.
func machineUsed(content string) []string {
var used []string
seen := map[string]bool{}
for _, m := range ofMachine.FindAllStringSubmatch(content, -1) {
if key := m[1]; !seen[key] {
seen[key] = true
used = append(used, key)
}
}
return used
}
// machineFacts is what a module may name about the machine it was assigned to.
//
// `at` is absent rather than empty when the machine is not on the private network. A module asking
// to be reached at an address that does not exist is a misconfiguration, and it is said here —
// where the module and the machine are both named — rather than discovered later as a certificate
// nobody can verify.
//
// `address` is what `at` resolves to, read from the names the control plane composed — the same map
// the hosts file and the resolver's wildcards are written from, so a file naming the machine's
// address and the file every other machine reaches it by cannot disagree. Absent, like `at`, when
// the machine is off the network or the mesh has not placed it.
func machineFacts(r Resolution, names map[string]string, meshRange string) map[string]string {
out := map[string]string{"name": r.Node}
if r.At != "" {
out["at"] = r.At
if address := names[r.At]; address != "" {
out["address"] = address
}
}
// The private network's whole range — a mesh-wide fact, not this machine's, but named here
// because a module cannot know it and sometimes must (an intrusion filter that must never ban a
// tunnel peer). Absent when the mesh has no range to give.
if meshRange != "" {
out["mesh-range"] = meshRange
}
// The operator's login on this machine and where its home is (novox/hq to-be 29), so a module
// that writes operator config names the account and its home rather than a value it cannot know.
// Absent when no operator account is known — a headless box a person never logs into.
if r.Account != "" {
out["account"] = r.Account
out["account-home"] = accountHomeOf(r.Account, r.AccountHome)
}
// The account agents run as here, and whether it must never become root (novox/hq ADR 0266). The agent
// account where the node names one; the operator account otherwise, so a module writing the agent's
// home names one fact on every machine. `agent-root` is "never" only for an account of the agents' own:
// the user resource naming it then asks the node-engine to judge it, and on a machine where agents run
// as the operator it is empty, asserting nothing — the operator's account may become root there.
if agent, home := r.agentAccount(); agent != "" {
out["agent-account"] = agent
out["agent-home"] = home
out["agent-root"] = ""
if r.AgentAccount != "" {
out["agent-root"] = RootNever
}
}
return out
}
// RootNever is what a user resource's `root` says of an account that must never become root without a
// person (novox/hq ADR 0266); the node-engine judges it.
const RootNever = "never"
// agentAccount is the account agents run as on this machine and its home: the agent account when the node
// names one (novox/hq ADR 0266), else the operator account; empty when neither is known.
func (r Resolution) agentAccount() (string, string) {
if r.AgentAccount != "" {
return r.AgentAccount, agentHomeOf(r.AgentAccount, r.AgentAccountHome)
}
if r.Account != "" {
return r.Account, accountHomeOf(r.Account, r.AccountHome)
}
return "", ""
}
// agentHomeOf is where the agent account's home is: what was stored, or /home/<account>. Never /root: the
// agent account is never root.
func agentHomeOf(account, home string) string {
if home != "" {
return home
}
return "/home/" + account
}
// accountHomeOf is where an account's home is: what was stored, or the derived default — /root for
// root, /home/<account> otherwise. The one place the default is written, so a fact and the store
// cannot disagree about it.
func accountHomeOf(account, home string) string {
if home != "" {
return home
}
if account == "root" {
return "/root"
}
return "/home/" + account
}
// machineInto replaces a file's ${machine:…} placeholders with what the mesh knows about the
// machine the module was assigned to.
//
// A key the mesh does not hold is refused, for the same reason a binding's is: left alone, the
// literal would be written into a configuration file and read as a value.
func machineInto(resource map[string]any, facts map[string]string, module string) error {
// Content, and now the path and owner too: a module that writes into a person's home names it
// with ${machine:account-home} and ${machine:account}, which it cannot know until assigned
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
// the shell module makes the operator's account its holder's login shell, and the desktop's
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person. And a
// user's `root`: the agent's module declares the account agents run as with ${machine:agent-root},
// "never" only where that account is the agents' own (novox/hq ADR 0266), and its `home`, so an account
// the operator named with a home of its own is made there (${machine:agent-home}); the node-engine reads a
// user's home only when it creates the account, so an existing one is never moved.
for _, field := range []string{"path", "owner", "content", "name", "user", "root", "home"} {
s, ok := resource[field].(string)
if !ok {
continue
}
for _, key := range machineUsed(s) {
value, has := facts[key]
if !has {
return fmt.Errorf(
"%s has a %s that says ${machine:%s}, and this machine says %s",
module, field, key, orNothing(namesOfFacts(facts)))
}
s = strings.ReplaceAll(s, fmt.Sprintf("${machine:%s}", key), value)
resource[field] = s
}
}
return nil
}
func namesOfFacts(facts map[string]string) []string {
out := make([]string, 0, len(facts))
for k := range facts {
out = append(out, k)
}
sort.Strings(out)
return out
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
package catalogue
// Provision names that say a role where the consumer is coupled to an implementation.
//
// **A consumer's code is written against PostgreSQL or against SQL Server, never against a
// database** (novox/hq ADR 0027). A requirement naming the role matches any engine, resolves as
// satisfied, deploys, and fails on the first query — with nothing connecting the error back to a
// match made elsewhere by something that thought it had done its job.
//
// Refused rather than documented, because the previous naming *was* the documented convention.
// A rule states how it is checked (novox/hq 00-META/how-we-build.md §5).
var engineGeneric = map[string]string{
"database": "postgres-database, mssql-database, mariadb-database",
"db": "postgres-database, mssql-database, mariadb-database",
"sql": "postgres-database, mssql-database, mariadb-database",
"sql-database": "postgres-database, mssql-database, mariadb-database",
}
@@ -0,0 +1,119 @@
package catalogue
import "path"
// The machine's own resolver (novox/hq ADR 0247).
//
// **Most machines have none.** Every machine lists the mesh's resolvers in /etc/resolv.conf and nothing
// else, and the module holding its uplink writes that file (ADR 0223). A machine with a VPN client that
// pushes its own resolvers for its own domains needs a third answer: those domains to the VPN's servers,
// over the VPN's link, and every other name to the mesh's resolvers as before. One file cannot list both
// sets of servers — musl takes the first reply, glibc the first server's "no such name" — so the domains
// are routed by a resolver on the machine itself: the `node-resolver` seat's holder.
//
// **Where it is held, it owns the resolver file.** The file then names the machine's own resolver, which
// routes; the uplink's holder steps back from writing it on that machine, by the rule below and not by
// two modules writing one path. There are two uplink holders (NetworkManager's and systemd-networkd's),
// and the resolver is its own module so it is written once, not once in each.
//
// **It knows nothing of any VPN.** Its verbs route a set of domains to a set of servers over one link,
// list what is routed and remove a route. A module wrapping a VPN client that writes /etc/resolv.conf
// itself carries its own adapter and calls those verbs; a VPN that tells systemd-resolved its link's DNS
// itself needs none.
// ResolverSeat is the machine's own resolver (novox/hq ADR 0247).
const ResolverSeat = "node-resolver"
// ResolverFile is the machine's resolver file: the uplink holder's, or the node-resolver holder's where
// one is held.
const ResolverFile = "/etc/resolv.conf"
// 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() []Verb {
return []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"}},
}
}
// holdsSeat says whether a module claims a seat, by its current name.
func holdsSeat(m Manifest, seat string) bool {
for _, c := range m.Claims {
if canonicalSeat(c.Name) == seat {
return true
}
}
return false
}
// stepsBack is the module as it composes among the modules of one machine (novox/hq ADR 0287,
// ADR 0247):
// **where it holds a seat that names a path stepping back for another seat, and that other seat's holder
// is on the machine and declares the path, its own rendering of the path is left out**, and nothing else
// of it changes. Only a file the mesh renders for it steps back, as before: a resource on the path is
// composed as declared (the declaration copies a module's resources whole), so an uplink holder declaring
// the resolver file as a resource beside the resolver's holder is two owners of one path, and refused. The stepping back is the seat's definition — node-uplink stepping back from the resolver
// file for node-resolver — so any other module declaring the path beside the owner's is still two owners
// of one path, and is refused as before.
func stepsBack(m Manifest, modules []Manifest) Manifest {
drop := map[string]bool{}
for _, c := range m.Claims {
seat, known := SeatNamed(c.Name)
if !known {
continue
}
for _, p := range seat.Paths {
if p.StepsBackFor == "" {
continue
}
for _, o := range modules {
if o.Module != m.Module && holdsSeat(o, p.StepsBackFor) && declaresPath(o, p.Path) {
drop[p.Path] = true
}
}
}
}
if len(drop) == 0 {
return m
}
facts := make(map[string]RosterFile, len(m.Facts))
for name, f := range m.Facts {
if !f.Home && drop[path.Clean(f.Path)] {
continue
}
facts[name] = f
}
m.Facts = facts
return m
}
// steppedBack is every module of one machine as it composes there (stepsBack, each).
func steppedBack(modules []Manifest) []Manifest {
out := make([]Manifest, len(modules))
for i, m := range modules {
out[i] = stepsBack(m, modules)
}
return out
}
@@ -0,0 +1,282 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"strings"
)
// A requirement a module can run without (novox/hq ADR 0330, issue 166).
//
// **Hard stays the default.** A requirement written as a bare name is hard, exactly as before: it
// resolves, or the assignment is refused, and a single catalogue candidate is installed for it. One
// written `{"name": "influxdb-api", "optional": true}` resolves the same way when a provider is
// already assigned in the mesh or reachable from the consumer (§2), and is otherwise **absent**: not
// refused, and nothing is installed for it, on this machine or elsewhere (§3).
//
// **An absent requirement is never rendered as an empty value** (§4–§6). Its binding, its pair
// credential and its grant are not composed, so a file that names one of them is delivered only while
// the requirement is bound (`only-when-bound`), and a `${bound:…}` placeholder of it outside such a
// file names the value it takes while absent: `${bound:<provision>:<key>|default:<value>}`. The
// module check refuses anything else, so the gap is found when the manifest is written, not on a
// machine where it reads as a blank.
// OnlyWhenBound is a resource's field naming the optional requirement it is delivered only while
// bound (ADR 0330 §5). Said in the catalogue and dropped before the declaration is sent: the
// node-engine parses strictly and knows no such field.
const OnlyWhenBound = "only-when-bound"
// requirementForm is one entry of `requires` in its long form.
type requirementForm struct {
Name string `json:"name"`
Optional bool `json:"optional,omitempty"`
}
// readRequires reads `requires` in both of its forms — a bare name, or {name, optional} — into the
// names and which of them are optional.
func readRequires(raw json.RawMessage) ([]string, map[string]bool, error) {
var entries []json.RawMessage
if err := json.Unmarshal(raw, &entries); err != nil {
return nil, nil, fmt.Errorf("requires: a list of names, or of {name, optional}: %w", err)
}
var names []string
optional := map[string]bool{}
for i, e := range entries {
var plain string
if err := json.Unmarshal(e, &plain); err == nil {
names = append(names, plain)
continue
}
var full requirementForm
dec := json.NewDecoder(bytes.NewReader(e))
dec.DisallowUnknownFields()
if err := dec.Decode(&full); err != nil {
return nil, nil, fmt.Errorf("requires[%d]: a name, or {name, optional} (novox/hq ADR 0330): %w", i, err)
}
if strings.TrimSpace(full.Name) == "" {
return nil, nil, fmt.Errorf("requires[%d] names no requirement: {name, optional}", i)
}
names = append(names, full.Name)
if full.Optional {
optional[full.Name] = true
}
}
if len(optional) == 0 {
optional = nil
}
return names, optional, nil
}
// OptionalRequiresKey is where a stored manifest keeps which of its requirements are optional: beside
// `requires`, which stays a plain list of names (mesh-controller #281's review). A controller older than
// optional requirements reads `requires` as names; the long form inside it failed that read, and with it
// the whole catalogue, so a rollback below this controller would have composed no machine. An older
// controller sets this unknown key aside instead and leaves only the module that carries it out. A
// manifest may be written either way; it is stored this way.
const OptionalRequiresKey = "optional-requires"
// readOptionalRequires reads the stored form's list of optional requirements.
func readOptionalRequires(raw json.RawMessage) (map[string]bool, error) {
var names []string
if err := json.Unmarshal(raw, &names); err != nil {
return nil, fmt.Errorf("%s: a list of the names in `requires` that are optional: %w", OptionalRequiresKey, err)
}
out := map[string]bool{}
for _, n := range names {
out[n] = true
}
return out, nil
}
// optionalRequiresList is the stored form's list: the optional requirements, sorted.
func optionalRequiresList(m Manifest) []string {
var out []string
for name, optional := range m.OptionalRequires {
if optional {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// IsOptional is whether this module requires the name optionally (ADR 0330 §1). A name it contributes
// to without requiring, or requires as a bare name, is hard.
func (m Manifest) IsOptional(requirement string) bool { return m.OptionalRequires[requirement] }
// wantsHard is whether this module wants the name as a hard requirement: required without
// `optional`, or contributed to.
func (m Manifest) wantsHard(name string) bool {
for _, w := range m.Wants() {
if w == name {
return !m.IsOptional(name)
}
}
return false
}
// Absent is one optional requirement of one consumer that nothing assigned answers (ADR 0330 §3):
// left unbound rather than refused, and listed by `plan` and `node` (§7).
type Absent struct {
// Consumer is the module that requires it optionally.
Consumer string `json:"consumer"`
// Requirement is what it requires.
Requirement string `json:"requirement"`
}
// AbsentFor is the optional requirements of one module this resolution leaves unbound.
func (r Resolution) AbsentFor(module string) map[string]bool {
out := map[string]bool{}
for _, a := range r.Absent {
if a.Consumer == module {
out[a.Requirement] = true
}
}
return out
}
// absentOf is, for each requirement left absent, every module of the set that requires it
// optionally, sorted by consumer then requirement.
func absentOf(names map[string]bool, order []string, catalogue map[string]Manifest) []Absent {
var out []Absent
for _, module := range order {
m := catalogue[module]
for _, r := range m.Requires {
if names[r] && m.IsOptional(r) {
out = append(out, Absent{Consumer: module, Requirement: r})
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Consumer != out[j].Consumer {
return out[i].Consumer < out[j].Consumer
}
return out[i].Requirement < out[j].Requirement
})
return out
}
// withoutAbsent drops the resources delivered only while a requirement is bound whose requirement is
// absent here (ADR 0330 §5), and the field itself from every resource that keeps it.
func withoutAbsent(resources []map[string]any, absent map[string]bool) []map[string]any {
out := make([]map[string]any, 0, len(resources))
for _, r := range resources {
when, has := r[OnlyWhenBound]
if !has {
out = append(out, r)
continue
}
if absent[fmt.Sprint(when)] {
continue
}
kept := make(map[string]any, len(r))
for k, v := range r {
if k != OnlyWhenBound {
kept[k] = v
}
}
out = append(out, kept)
}
return out
}
// secretNamesOf is every `${secret:…}` name that carries a credential of this requirement: its own
// name, and each local name it keeps several under (ADR 0094).
func secretNamesOf(m Manifest, requirement string) []string {
names := []string{requirement}
for local := range m.SecretsMany[requirement] {
names = append(names, local)
}
sort.Strings(names)
return names
}
// OptionalProblems is what the module check refuses of a manifest's optional requirements and its
// only-when-bound resources (ADR 0330 §4–§6).
func OptionalProblems(m Manifest) []string {
var problems []string
required := map[string]bool{}
for _, r := range m.Requires {
if required[r] && m.IsOptional(r) {
problems = append(problems, fmt.Sprintf("%s requires %q twice; say it once, optional or not", m.Module, r))
}
required[r] = true
}
for _, r := range optionalRequiresList(m) {
if !required[r] {
problems = append(problems, fmt.Sprintf(
"%s says %q is optional in %s and does not require it", m.Module, r, OptionalRequiresKey))
}
}
// A contribution is to whoever answers the requirement, so it goes with it: contributing to one
// that is optional is allowed, and composed only while it is bound.
for _, r := range m.Resources {
id := fmt.Sprint(r["id"])
when, marked := r[OnlyWhenBound]
on := ""
if marked {
name, ok := when.(string)
switch {
case !ok || name == "":
problems = append(problems, fmt.Sprintf(
"%s's resource %s says %s %v; it names one requirement the module requires optionally",
m.Module, id, OnlyWhenBound, when))
continue
case fmt.Sprint(r["type"]) != "file":
problems = append(problems, fmt.Sprintf(
"%s's resource %s is a %v and says %s: only a file is left out while a requirement is "+
"absent (novox/hq ADR 0330 §5)", m.Module, id, r["type"], OnlyWhenBound))
continue
case !required[name]:
problems = append(problems, fmt.Sprintf(
"%s's resource %s is delivered only when %q is bound, and %s does not require it",
m.Module, id, name, m.Module))
continue
case !m.IsOptional(name):
problems = append(problems, fmt.Sprintf(
"%s's resource %s is delivered only when %q is bound, which is a hard requirement and "+
"always bound: mark the requirement optional, or drop %s", m.Module, id, name, OnlyWhenBound))
continue
}
on = name
}
if fmt.Sprint(r["type"]) != "file" {
continue
}
content, _ := r["content"].(string)
for _, use := range bound.FindAllStringSubmatch(content, -1) {
provision, key, hasDefault := use[1], use[2], use[3] != ""
if !m.IsOptional(provision) {
if hasDefault {
problems = append(problems, fmt.Sprintf(
"%s's resource %s gives ${bound:%s:%s} a default, and %q is a hard requirement, always "+
"bound: a default it never renders only misleads a reader", m.Module, id, provision, key, provision))
}
continue
}
if !hasDefault && on != provision {
problems = append(problems, fmt.Sprintf(
"%s's resource %s says ${bound:%s:%s}, and %q is optional: while it is absent this would "+
"be empty. Name its value then — ${bound:%s:%s|default:<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, OnlyWhenBound, provision))
}
}
for _, provision := range m.Requires {
if !m.IsOptional(provision) || on == provision {
continue
}
for _, name := range secretNamesOf(m, provision) {
if strings.Contains(content, "${secret:"+name+"}") {
problems = append(problems, fmt.Sprintf(
"%s's resource %s says ${secret:%s}, the credential of %q, which is optional and not "+
"delivered while absent: deliver the file only while it is bound, with \"%s\": %q "+
"(novox/hq ADR 0330 §6)", m.Module, id, name, provision, OnlyWhenBound, provision))
}
}
}
}
return problems
}
@@ -0,0 +1,616 @@
package catalogue
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Where a module's data is on THIS machine is the assignment's (novox/hq ADR 0112, issue 153).
//
// A definition names no host path. It declares the directories it owns by id, and the operator's
// shared data it needs by id too (an `access`, ADR 0051). A node has a default layout for the
// former — <root>/<module>/<id> — and nothing at all for the latter, because shared data is
// wherever the operator keeps it. An adopted machine keeps its data where the predecessor put it:
// a 40 TB library on its own pool, a configuration on a second disk. Both halves are said on the
// assignment, validated the way `endpoints` is — an id the module does not declare is refused,
// because a setting that reaches nothing is a mistake — and resolved here, so the host receives
// concrete paths exactly as it always has and learns no field.
//
// {"places": {"config": "/services/sonarr/config",
// "data": {"path": "/mnt/plex/data", "owner": "1000:1000"}},
// "accesses": {"series": "/storage/media/series",
// "downloads": "/storage/downloads"}}
//
// A placed directory is still the mesh's: created, chowned to the owner the assignment says (or
// the manifest's), removed when empty and no longer declared. A placed access is still the
// operator's: mounted, never created, chowned or removed.
// PlacesSetting is the settings key that places a module's declared directories, by id.
const PlacesSetting = "places"
// AccessesSetting is the settings key that says where a module's accesses are on this node, by id.
const AccessesSetting = "accesses"
// **Placement is not config** (novox/hq ADR 0348 clause 11, issues 651 and 736). `places`, `accesses` and
// `networks` — where an assignment's things are — and `endpoints`, `expose`, `reach` and `ports` — where it
// is reached and from how far — are facts of an assignment the controller decides, held beside the
// assignment and apart from its settings: a Layer carries them in Placement, never in Values, and only
// their readers (Places, AccessPlaces, KeptNetworks, Endpoints, Exposure, Reaches, GivenPorts) read them.
// A person still says them as keys of a layer through `settings`; LayerOf splits such a layer, so what
// they type is routed to the placement and nothing they do changes.
// placementKeys are the keys of a layer that are placement, not config.
var placementKeys = map[string]bool{PlacesSetting: true, AccessesSetting: true, NetworksSetting: true,
EndpointsSetting: true, ExposeSetting: true, ReachSetting: true, PortsSetting: true}
// PlacementKeys are the keys of a layer that are placement, not config, sorted: what a settings row must
// no longer hold once the move of placement has run (issue 736).
func PlacementKeys() []string {
keys := make([]string, 0, len(placementKeys))
for k := range placementKeys {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// SplitPlacement divides a layer as a person says it into its config values and its placement. Each is
// nil when it holds nothing; the values themselves are not copied.
func SplitPlacement(values map[string]any) (config, placement map[string]any) {
for key, value := range values {
if placementKeys[key] {
if placement == nil {
placement = map[string]any{}
}
placement[key] = value
continue
}
if config == nil {
config = map[string]any{}
}
config[key] = value
}
return config, placement
}
// LayerOf is a layer as a person says it — config values and placement in one map — split into the two.
func LayerOf(from string, values map[string]any) Layer {
config, placement := SplitPlacement(values)
if config == nil {
config = map[string]any{}
}
return Layer{From: from, Values: config, Placement: placement}
}
// Whole is the layer as a person says it: its values and its placement in one map, a new map.
func (l Layer) Whole() map[string]any {
out := make(map[string]any, len(l.Values)+len(l.Placement))
for k, v := range l.Values {
out[k] = v
}
for k, v := range l.Placement {
out[k] = v
}
return out
}
// asSaid is the layers with placement folded back into the values, for what reads a layer whole as it
// did while placement was kept in it: today's mergeable file, which takes every key (ADR 0326 §3), and
// the judgement that no value holds a line end.
func asSaid(layers []Layer) []Layer {
out := make([]Layer, len(layers))
for i, l := range layers {
out[i] = l
if len(l.Placement) > 0 {
out[i] = Layer{From: l.From, Values: l.Whole(), Default: l.Default}
}
}
return out
}
// Placement is what an assignment says about one of a module's directories.
type Placement struct {
// Path is where the directory is on this machine. Absolute.
Path string
// Owner is "uid:gid" when the assignment overrides the manifest's — the predecessor's data is
// owned by whoever it ran as, and that is one machine's fact.
Owner string
}
var ownerShape = regexp.MustCompile(`^[0-9]+:[0-9]+$`)
// Where no placement and no access may be, from any route, the controller's terminal too (novox/hq issue 339).
//
// A placed directory is created and owned by the node-engine as root, with the owner the setting names, and
// whatever the module writes into it is written as root; an access is mounted into the module's container,
// which may run as root. A place at /etc owned by an account a caller names hands that account the machine,
// and an access at / mounts the machine's root into a container. So the machine's own trees are refused here,
// before anything is kept or composed, and the node-engine refuses them again where it applies.
//
// systemTrees are refused at and below: the machine's system, the kernel's, the boot loader's, root's home,
// what lives only while the machine runs, the spool (cron's tables are there), the container runtimes' data
// (every container's filesystem), /opt, and the node-engine's and the mesh's own state. Any home's .ssh is
// refused as well, wherever the home is. systemRoots are
// refused at, and wherever a path holds one (an ancestor of /var/lib holds it): each is the parent of every
// module's or every person's directories, and owning it is owning all of them.
var (
systemTrees = []string{"/etc", "/usr", "/boot", "/root", "/run", "/var/run", "/var/lock", "/proc", "/sys",
"/dev", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/var/lib/mesh", "/var/lib/mesh-host", "/var/spool",
"/var/lib/docker", "/var/lib/containers", "/var/lib/containerd", "/opt"}
systemRoots = []string{"/", "/var", "/var/lib", "/var/cache", "/var/log", "/var/tmp", "/home",
"/mnt", "/media", "/srv", "/tmp", "/storage", "/data", "/services"}
)
// systemPath says why a clean absolute path is the machine's own and never a placement's or an access's, or "".
func systemPath(path string) string {
// Any home's keys, wherever the home is: a .ssh directory is its account's, and the keys and the list of who
// may log in as it are in there.
for _, part := range strings.Split(path, "/") {
if part == ".ssh" {
return path + " is an account's .ssh, which holds its keys and who may log in as it"
}
}
for _, tree := range systemTrees {
if path == tree || strings.HasPrefix(path, tree+"/") {
return path + " is in " + tree + ", the machine's own or the mesh's state"
}
if strings.HasPrefix(tree, path+"/") || path == "/" {
return path + " holds " + tree + ", the machine's own or the mesh's state"
}
}
for _, root := range systemRoots {
if path == root {
return path + " is the parent of every module's or every person's directories"
}
}
return ""
}
// Where no directory or file a module's definition resolves to may be (novox/hq issue 496).
//
// mesh-catalog #205 gave docker's `state` directory `"place": "."`, which resolves to <root>/docker: with the
// default root, /var/lib/docker, every container's filesystem. systemPath judged only the `places` and `accesses`
// settings, so the default layout and a definition's own paths reached the merge gate unjudged; it composed every
// machine and passed, and only the node-engine refused the directory, at apply, failing the walk.
//
// **Judged where a definition is judged, never where a machine is composed.** resolvedPathProblems runs in
// ParseManifest, which every route a definition takes into the mesh passes: `module check`, registration of a
// build (the builder's and the registry verbs'), and the merge gate's reading of the changed repository. A
// refusal there stops one definition before it reaches any machine. Composition reads registered manifests
// without ParseManifest, and judges nothing of this: refusing there would fail the whole machine's declaration and
// freeze every module on it for one module's path, where the node-engine fails only that resource.
//
// **The node-engine's rules, no more** (mesh-host's internal/apply/placement_guard.go, with files judged as
// directories are after novox/hq issue 495, rule 6). A path is refused when it is one of protectedRoots or holds
// one, when it is at or below a tree in forbiddenBelow, or at or below one of engineTrees and its module is not
// the node-engine's. What the engine judges with what only the machine knows stays the engine's: where the
// runtimes really keep their data, links, the accounts' homes, a directory's owner below /etc. The lists are the
// engine's own words, and a test (engine_guard_test.go) holds them equal to mesh-host's beside this repository.
// systemPath stays the stricter rule for a setting: a setting is an operator's word about one machine, and the
// trees it lists (/etc, /usr, /run, the mesh's own) are where the mesh's own modules write by design.
var (
protectedRoots = []string{"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64",
"/media", "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/usr/bin",
"/usr/lib", "/usr/lib64", "/usr/local", "/usr/local/bin", "/usr/local/lib", "/usr/local/sbin", "/usr/sbin",
"/usr/share", "/var", "/var/cache", "/var/lib", "/var/lib/mesh", "/var/log", "/var/run", "/var/tmp",
"/var/spool"}
forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot", "/root", "/var/spool", "/opt", "/var/lib/docker",
"/var/lib/containers", "/var/lib/containerd"}
engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"}
)
// engineModule is the node-engine's own module, the one that places in engineTrees.
const engineModule = "mesh-host"
// enginePath says why the node-engine refuses a directory or file at path for module, or "".
func enginePath(path, module string) string {
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
return ""
}
atOrBelow := func(tree string) bool { return path == tree || strings.HasPrefix(path, tree+"/") }
for _, root := range protectedRoots {
if path == root || path == "/" || strings.HasPrefix(root, path+"/") {
return root + " is one of the machine's own directories, and owning it is owning everything in it"
}
}
for _, tree := range forbiddenBelow {
if atOrBelow(tree) {
return "nothing is placed in " + tree
}
}
if module != engineModule {
for _, tree := range engineTrees {
if atOrBelow(tree) {
return tree + " is the node-engine's own, placed in by its own module alone"
}
}
}
return ""
}
// resolvedPathProblems is every directory and file of the definition whose path, resolved as a node with the
// default root resolves it, the node-engine would refuse (novox/hq issue 496). A path still holding a placeholder
// only a machine fills (a setting, an access the definition gives no default) is the engine's to judge.
func (m Manifest) resolvedPathProblems() []string {
dirs := dirsFor(m, Rendering{})
accesses := map[string]string{}
for _, a := range m.Accesses {
if a.ID != "" && a.Path != "" {
accesses[a.ID] = a.Path
}
}
var problems []string
for _, r := range m.Resources {
kind := fmt.Sprint(r["type"])
if kind != "directory" && kind != "file" {
continue
}
id := fmt.Sprint(r["id"])
path, _ := r["path"].(string)
if kind == "directory" && path == "" {
path = dirs[id]
}
path, _ = dirFill(path, dirs, m.Module)
path, _ = accessFill(path, accesses, m.Module)
if path == "" || strings.Contains(path, "${") {
continue
}
if why := enginePath(path, m.Module); why != "" {
problems = append(problems, fmt.Sprintf("%s's %s %q resolves to %s, which the node-engine refuses: %s. "+
"A module's directories and files are judged when its definition is, so the merge gate refuses it "+
"before any machine does (novox/hq issue 496)", m.Module, kind, id, filepath.Clean(path), why))
}
}
return problems
}
// accessRef is how a module names one of its accesses: ${access:<id>}.
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
// Places reads where this node places the module's directories, by directory id.
//
// It refuses an id the module declares no directory for, a path that is not absolute, and an
// owner that is not uid:gid. A directory the assignment does not mention keeps the manifest's
// stated path or the node's default layout.
func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
declared := map[string]bool{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "directory" {
declared[fmt.Sprint(r["id"])] = true
}
}
out := map[string]Placement{}
for _, layer := range layers {
raw, ok := layer.Placement[PlacesSetting]
if !ok {
continue
}
blocks, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: %s is a { directory: path | { path, owner } } map, and %q set it to something else",
m.Module, PlacesSetting, layer.From)
}
for id, body := range blocks {
if !declared[id] {
return nil, fmt.Errorf(
"%s places the directory %q, which it does not declare — the setting reaches "+
"nothing. It declares %s", m.Module, id, orNothing(namesOfDirs(directoriesOf(m))))
}
p := out[id]
switch v := body.(type) {
case string:
p.Path = strings.TrimSpace(v)
case map[string]any:
if path, said := v["path"]; said {
text, _ := path.(string)
p.Path = strings.TrimSpace(text)
}
if owner, said := v["owner"]; said {
text, _ := owner.(string)
if !ownerShape.MatchString(strings.TrimSpace(text)) {
return nil, fmt.Errorf("%s places %q with owner %v; an owner is uid:gid, numeric",
m.Module, id, owner)
}
p.Owner = strings.TrimSpace(text)
}
default:
return nil, fmt.Errorf("%s places %q with %v; a placement is a path, or { path, owner }",
m.Module, id, body)
}
if p.Path == "" {
return nil, fmt.Errorf("%s places %q without a path", m.Module, id)
}
if !strings.HasPrefix(p.Path, "/") {
return nil, fmt.Errorf("%s places %q at %q, which is not an absolute path", m.Module, id, p.Path)
}
p.Path = filepath.Clean(p.Path)
if why := systemPath(p.Path); why != "" {
return nil, fmt.Errorf("%s places %q at %s: %s, and the node-engine creates and owns a placed "+
"directory as root, with the owner the setting names — no placement is ever there "+
"(novox/hq issue 339)", m.Module, id, p.Path, why)
}
out[id] = p
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// AccessPlaces reads where this node keeps the operator's data the module accesses, by access id.
//
// It refuses an id the module declares no access under, and a path that is not absolute. An
// access declared by path alone cannot be placed — it has no name to place it by.
func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
declared := map[string]bool{}
for _, a := range m.Accesses {
if a.ID != "" {
declared[a.ID] = true
}
}
out := map[string]string{}
for _, layer := range layers {
raw, ok := layer.Placement[AccessesSetting]
if !ok {
continue
}
blocks, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: %s is a { access: path } map, and %q set it to something else",
m.Module, AccessesSetting, layer.From)
}
for id, body := range blocks {
if !declared[id] {
return nil, fmt.Errorf(
"%s places the access %q, which it does not declare — the setting reaches "+
"nothing. It declares %s", m.Module, id, orNothing(namesOfAccesses(m)))
}
path, _ := body.(string)
path = strings.TrimSpace(path)
if !strings.HasPrefix(path, "/") {
return nil, fmt.Errorf("%s places the access %q at %v, which is not an absolute path",
m.Module, id, body)
}
path = filepath.Clean(path)
if why := systemPath(path); why != "" {
return nil, fmt.Errorf("%s places the access %q at %s: %s, and an access is mounted into the "+
"module's container — no access is ever there (novox/hq issue 339)", m.Module, id, path, why)
}
out[id] = path
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// placedAccess is one access with the path it resolves to on this node.
type placedAccess struct {
ID string
Path string
Mode string
}
// accessesFor is every access of a module with its path on this node: the assignment's where it
// placed one, the definition's where it carries a default, and refused where neither says — an
// access that resolves to nowhere would reach the machine as a mount of nothing.
func accessesFor(m Manifest, layers []Layer) ([]placedAccess, map[string]string, error) {
placed, err := AccessPlaces(m, layers)
if err != nil {
return nil, nil, err
}
var out []placedAccess
byID := map[string]string{}
for _, a := range m.Accesses {
path := a.Path
if a.ID != "" {
if at, said := placed[a.ID]; said {
path = at
}
}
if path == "" {
return nil, nil, fmt.Errorf(
"%s accesses %q, and nothing says where that is on this node — the definition "+
"carries no path (it must not, novox/hq ADR 0112) and the assignment places "+
"none. Set %s: {%q: \"/where/it/is\"}",
m.Module, a.ID, AccessesSetting, a.ID)
}
out = append(out, placedAccess{ID: a.ID, Path: path, Mode: a.At()})
if a.ID != "" {
byID[a.ID] = path
}
}
return out, byID, nil
}
// 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
}
// accessInto fills every ${access:…} a resource carries — in its path, its content, its mounts,
// its environment and its env-files — with the path this node resolved for it. The same walk as
// dirInto, for the same reason: a literal `${access:x}` reaching the machine would be mounted as
// a directory called that.
func accessInto(resource map[string]any, accesses map[string]string, module string) error {
if !mentionsAccess(resource) {
return nil
}
fill := func(s string) (string, error) { return accessFill(s, accesses, module) }
var err error
if path, ok := resource["path"].(string); ok {
if resource["path"], err = fill(path); err != nil {
return err
}
}
if content, ok := resource["content"].(string); ok {
if resource["content"], err = fill(content); err != nil {
return err
}
}
for _, field := range []string{"volumes", "env-file"} {
list, ok := resource[field].([]any)
if !ok {
continue
}
filled := make([]any, len(list))
for i, v := range list {
filled[i] = v
if s, ok := v.(string); ok {
if filled[i], err = fill(s); err != nil {
return err
}
}
}
resource[field] = filled
}
if env, ok := resource["env"].(map[string]any); ok {
filled := make(map[string]any, len(env))
for key, v := range env {
filled[key] = v
if s, ok := v.(string); ok {
if filled[key], err = fill(s); err != nil {
return err
}
}
}
resource["env"] = filled
}
return nil
}
func mentionsAccess(resource map[string]any) bool {
for _, field := range []string{"path", "content"} {
if s, ok := resource[field].(string); ok && accessRef.MatchString(s) {
return true
}
}
for _, field := range []string{"volumes", "env-file"} {
if list, ok := resource[field].([]any); ok {
for _, v := range list {
if s, ok := v.(string); ok && accessRef.MatchString(s) {
return true
}
}
}
}
if env, ok := resource["env"].(map[string]any); ok {
for _, v := range env {
if s, ok := v.(string); ok && accessRef.MatchString(s) {
return true
}
}
}
return false
}
// ownerInto gives a placed directory the owner the assignment said, where it said one. The
// manifest's owner is what the image expects on any machine; the assignment's is what this
// machine's data already is.
func ownerInto(resource map[string]any, placed map[string]Placement) {
if fmt.Sprint(resource["type"]) != "directory" {
return
}
if p, ok := placed[fmt.Sprint(resource["id"])]; ok && p.Owner != "" {
resource["owner"] = p.Owner
}
}
// unknownAccessRefs is every ${access:…} in the definition that names no access the definition
// declares by id — refused where the author is, as unknownDirRefs does for directories.
func (m Manifest) unknownAccessRefs() []string {
declared := map[string]bool{}
for _, a := range m.Accesses {
if a.ID != "" {
declared[a.ID] = true
}
}
seen := map[string]bool{}
var problems []string
refuse := func(s string, where any) {
for _, match := range accessRef.FindAllStringSubmatch(s, -1) {
id := match[1]
if declared[id] || seen[id] {
continue
}
seen[id] = true
problems = append(problems, fmt.Sprintf(
"%s says ${access:%s} in %v, and declares no access %q — a reference the mesh "+
"cannot place would reach the machine as a literal path",
m.Module, id, where, id))
}
}
for _, r := range m.Resources {
for _, field := range []string{"path", "content"} {
if s, ok := r[field].(string); ok {
refuse(s, r["id"])
}
}
for _, field := range []string{"volumes", "env-file"} {
if list, ok := r[field].([]any); ok {
for _, v := range list {
if s, ok := v.(string); ok {
refuse(s, r["id"])
}
}
}
}
if env, ok := r["env"].(map[string]any); ok {
for _, v := range env {
if s, ok := v.(string); ok {
refuse(s, r["id"])
}
}
}
}
sort.Strings(problems)
return problems
}
func directoriesOf(m Manifest) map[string]string {
dirs := map[string]string{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "directory" {
dirs[fmt.Sprint(r["id"])] = ""
}
}
return dirs
}
func namesOfAccesses(m Manifest) []string {
var names []string
for _, a := range m.Accesses {
if a.ID != "" {
names = append(names, fmt.Sprintf("%q", a.ID))
}
}
sort.Strings(names)
return names
}
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
}
@@ -0,0 +1,168 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// Telling a module which port it was given.
//
// **The mesh assigns the machine-side port and a module does not choose one**
// ([ADR 0038](../../02-DECISIONS/0038-the-mesh-assigns-the-port.md)); on a node given one for a
// module it is the operator's number rather than the mesh's
// ([ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md)). For a
// container's own listening socket that is invisible: the mesh rewrites `ports` into
// `assigned:wanted`, the software inside binds the number it has always bound, and the machine
// publishes a different one.
//
// **Two kinds of resource have no such layer.**
//
// - **A process** runs on the machine, there is nothing to rewrite, and it binds whatever its
// configuration says — so without this, every process binds the number written in its own
// config, two modules declaring the same one collide, and the mesh's whole reason for
// assigning ports is defeated by the resource kind that most needs it.
// - **A container that DIALS the machine** — a module's own sidecar reaching the service beside
// it over the machine's loopback — is told that address in its environment, and the mapping
// that saves the listener does nothing for the caller: what it must dial is the machine-side
// number, which is exactly the one the module cannot know (novox/hq 04-ISSUES/088).
//
// So a module asks. `${port:8080}` is "the machine-side port you gave me for the 8080 I said I
// listen on", and the module writes that where it would otherwise have written a literal — in a
// file's content, or in a value of a container's or a process's `env`.
//
// **The environment is filled by the control plane, exactly as a bound value is.** A port is not
// secret — the mesh holds it in the clear — so there is nothing for the host to be the only
// witness of, and the host learns no new field. That is what separates this from
// [ADR 0086](../../02-DECISIONS/0086-a-secret-reaches-a-process-as-a-file.md), which refuses a
// `${secret:…}` in an `env` outright: the objection there is to the value being in an environment
// at all, not to who fills it in.
//
// **It answers with the machine's number, wherever it is written.** A container reaching a sibling
// over the runtime's own network reaches it on the port inside that container and goes on writing
// that number literally — it is a number the module does control. This is for the machine side,
// which is the side nobody but the mesh can know.
// ofPort is where a module asks which port it was given: ${port:<the port its software uses>}.
var ofPort = regexp.MustCompile(`\$\{port:([0-9]+)\}`)
// portsUsed are the ports a written value asks about, first appearance first.
func portsUsed(content string) []int {
var used []int
seen := map[int]bool{}
for _, m := range ofPort.FindAllStringSubmatch(content, -1) {
n, err := strconv.Atoi(m[1])
if err != nil || seen[n] {
continue
}
seen[n] = true
used = append(used, n)
}
return used
}
// portInto replaces a resource's ${port:…} placeholders with what this machine assigned — in a
// file's content, and in a value of a container's or a process's environment.
//
// **A process's environment is a container's** (novox/hq to-be 38 WP4c). A module's code moving out
// of its container becomes a process on the machine and still has to be told what the container
// was told; filled for one kind and not the other, the literal reached the process and was read as
// a port, and the modules that moved first wrote their run-once steps a 0600 env file instead.
//
// A port the module did not say it listens on is refused, for the same reason a binding's unknown
// key is: the module is asking about something it never declared, and the answer would be a guess.
// Left alone, the literal would be written into a configuration file, or handed to a process as
// its environment, and read as a port number.
func portInto(resource map[string]any, module string, listens []Listening, with Rendering) error {
switch fmt.Sprint(resource["type"]) {
case "file":
content, ok := resource["content"].(string)
if !ok {
return nil
}
filled, err := portsFilledInto(content,
fmt.Sprintf("%s has a file that", module), module, listens, with)
if err != nil {
return err
}
resource["content"] = filled
case "container", "process":
env, ok := resource["env"].(map[string]any)
if !ok {
return nil
}
// In a stated order, so a container with two bad values always refuses on the same one.
named := make([]string, 0, len(env))
for key := range env {
named = append(named, key)
}
sort.Strings(named)
// **A fresh map, and only when something changes.** This map came out of the module's
// manifest and the resource around it is a shallow copy, so filling a value in place would
// change what the catalogue holds for every other machine running the module.
var filled map[string]any
for _, key := range named {
written, ok := env[key].(string)
if !ok || len(portsUsed(written)) == 0 {
continue
}
value, err := portsFilledInto(written,
fmt.Sprintf("%s's %s %s sets %s to something that",
module, resource["type"], resource["name"], key), module, listens, with)
if err != nil {
return err
}
if filled == nil {
filled = map[string]any{}
for k, v := range env {
filled[k] = v
}
}
filled[key] = value
}
if filled != nil {
resource["env"] = filled
}
}
return nil
}
// portsFilledInto answers every ${port:…} in one written value, or refuses. `where` names the
// place it was written, so a refusal is one edit from right whichever kind of resource it came
// out of.
func portsFilledInto(written, where, module string, listens []Listening, with Rendering) (
string, error) {
for _, wanted := range portsUsed(written) {
var declared bool
for _, l := range listens {
if l.Port == wanted {
declared = true
}
}
if !declared {
return "", fmt.Errorf(
"%s says ${port:%d}, and %s does not say it listens on %d. A module is told the "+
"port it was given for something it declared, and %s",
where, wanted, module, wanted, orNoListens(listens))
}
written = strings.ReplaceAll(written, fmt.Sprintf("${port:%d}", wanted),
strconv.Itoa(with.machinePort(module, wanted)))
}
return written, nil
}
// orNoListens says what would have worked, so a refusal is one edit from right.
func orNoListens(listens []Listening) string {
if len(listens) == 0 {
return "it declares no ports at all"
}
said := make([]string, 0, len(listens))
for _, l := range listens {
said = append(said, strconv.Itoa(l.Port))
}
return "it declares " + strings.Join(said, ", ")
}
@@ -0,0 +1,263 @@
package catalogue
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// The list of predefined keys (novox/hq ADR 0348 §7, design 57 package K2, issue 661).
//
// **A fixed, reviewable list, owned by the build.** Mesh config and node config hold values only for the keys
// in the catalogue's `predefined.json`, at the root of its repository: each key with its type, a description
// including its unit, its validation (enum, minimum and maximum, pattern) and whether it is trusted. A module's
// value may fall back to one of them, of the same type (ADR 0348 §2, §4).
//
// **Read where a manifest is judged, from the tree it is judged in.** A build reads the file at the root of the
// repository at the commit it builds, `module check` at the root of the repository above each manifest, the
// merge gate at the root of the tree under check; each refuses a file that is not exactly a list of keys, and
// judges every manifest it reads against it (ParseManifestWith). Everywhere else — registration, a stored build
// read again — a manifest is judged against the list the mesh published (PublishedPredefined): the newest trunk
// build's of the catalogue, which the controller keeps and loads with its store.
// PredefinedFile is the list's file at the root of the catalogue's repository.
const PredefinedFile = "predefined.json"
// CatalogueRepository is the name of the repository whose list the mesh publishes: the catalogue's, by the name
// a check finds it beside under (internal/builder's check, ADR 0121).
const CatalogueRepository = "mesh-catalog"
// PredefinedKey is one key of the list: its type, its description, its validation and whether it is trusted
// (ADR 0348 §7). It has no default: a module's value falling back to it has its own.
type PredefinedKey struct {
Type string `json:"type"`
Description string `json:"description"`
Enum []any `json:"enum,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Pattern string `json:"pattern,omitempty"`
Trusted bool `json:"trusted,omitempty"`
}
// PredefinedList is the list by key. Nil is no list at all, which is not the same as an empty one: a value
// naming a key is refused then saying the list is not present.
type PredefinedList map[string]PredefinedKey
// ParsePredefined reads predefined.json strictly: one JSON object of keys, each with exactly the fields of a
// PredefinedKey, and every key judged as a module's config value is (PredefinedProblems). Anything else is
// refused whole, naming each problem — a list half read would let a module fall back to a key nobody reviewed.
func ParsePredefined(raw []byte) (PredefinedList, error) {
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", PredefinedFile, err)
}
if keys == nil {
return nil, fmt.Errorf("%s is not a JSON object of keys: it says null", PredefinedFile)
}
list := PredefinedList{}
var problems []string
for _, key := range sortedKeys(keys) {
decoder := json.NewDecoder(bytes.NewReader(keys[key]))
decoder.DisallowUnknownFields()
var k PredefinedKey
if err := decoder.Decode(&k); err != nil {
problems = append(problems, fmt.Sprintf("%q is not a predefined key's declaration (type, description, "+
"enum, minimum, maximum, pattern, trusted): %v", key, err))
continue
}
list[key] = k
}
problems = append(problems, PredefinedProblems(list)...)
if len(problems) > 0 {
return nil, fmt.Errorf("%s is refused: %s", PredefinedFile, strings.Join(problems, "; "))
}
return list, nil
}
// PredefinedProblems is every way a key of the list is declared wrongly, by the rules a module's config value
// is held to (ADR 0348 §4, §7, §11): a usable name that is not placement, a type, a description, validation
// that fits the type, and an object or a list only when trusted.
func PredefinedProblems(list PredefinedList) []string {
var problems []string
for _, key := range sortedKeys(list) {
k := list[key]
say := func(format string, args ...any) {
problems = append(problems, fmt.Sprintf("the predefined key %q: ", 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
}
switch k.Type {
case ConfigNumber, ConfigString, ConfigBoolean:
case ConfigObject, ConfigList:
if !k.Trusted {
say("is %s and not trusted: an object or a list is trusted, \"trusted\": true (novox/hq ADR 0348 §4)", k.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)", k.Type)
}
if strings.TrimSpace(k.Description) == "" {
say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §7)")
}
for _, wrong := range validationProblems(SettingDeclaration{Type: k.Type, Enum: k.Enum, Minimum: k.Minimum,
Maximum: k.Maximum, Pattern: k.Pattern}) {
say("%s", wrong)
}
}
return problems
}
// ReadPredefined is the list in predefined.json at a repository's root, and nil with no error when the root
// holds none: a repository other than the catalogue, or the catalogue before the file.
func ReadPredefined(root string) (PredefinedList, error) {
path := filepath.Join(root, PredefinedFile)
info, err := os.Lstat(path)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%s is %s, not a plain file", path, kindOf(info.Mode()))
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParsePredefined(raw)
}
// RepositoryRoot is the nearest directory at or above dir holding `.git`: the root of the repository a manifest
// is in, where `module check` reads the list. Empty when none is: a manifest outside a repository is judged
// against no list.
func RepositoryRoot(dir string) string {
at, err := filepath.Abs(dir)
if err != nil {
return ""
}
for {
if _, err := os.Lstat(filepath.Join(at, ".git")); err == nil {
return at
}
up := filepath.Dir(at)
if up == at {
return ""
}
at = up
}
}
// PredefinedAbove is the list at the root of the repository above dir (RepositoryRoot), nil when there is no
// repository or it holds no list.
func PredefinedAbove(dir string) (PredefinedList, error) {
root := RepositoryRoot(dir)
if root == "" {
return nil, nil
}
return ReadPredefined(root)
}
// Canonical is the list as JSON with its keys sorted: what a build carries and the controller keeps, so one list
// is one text.
func (l PredefinedList) Canonical() ([]byte, error) {
if l == nil {
return nil, nil
}
return json.Marshal(map[string]PredefinedKey(l))
}
// Keys is the list's keys, sorted.
func (l PredefinedList) Keys() []string {
out := make([]string, 0, len(l))
for k := range l {
out = append(out, k)
}
sort.Strings(out)
return out
}
// NamesPredefined is whether any value of the manifest falls back to a predefined key.
func (m Manifest) NamesPredefined() bool {
for _, d := range m.Settings {
if d.Predefined != "" {
return true
}
}
return false
}
// publishedPredefined is the list the mesh published, which a manifest is judged against where no tree is read.
var publishedPredefined struct {
sync.RWMutex
list PredefinedList
}
// UsePredefined makes list the one a manifest is judged against where no tree is read: what the controller
// loads from its store, and what it keeps when a newer trunk build of the catalogue publishes one. Nil leaves
// the list it has: a store that holds none yet never empties one already loaded.
func UsePredefined(list PredefinedList) {
if list == nil {
return
}
publishedPredefined.Lock()
defer publishedPredefined.Unlock()
publishedPredefined.list = list
}
// PublishedPredefined is the list a manifest is judged against where no tree is read; nil when none is loaded.
func PublishedPredefined() PredefinedList {
publishedPredefined.RLock()
defer publishedPredefined.RUnlock()
return publishedPredefined.list
}
// ReplacePublished sets the published list, nil included, and answers the one it replaced: for a test that
// judges against a list of its own and puts the mesh's back.
func ReplacePublished(list PredefinedList) (was PredefinedList) {
publishedPredefined.Lock()
defer publishedPredefined.Unlock()
was, publishedPredefined.list = publishedPredefined.list, list
return was
}
// ReadTreeManifestWritten is the manifest in a module's directory of a repository tree as written, still naming its
// templates, judged against the list of predefined keys at the tree's root as the merge gate judges it
// (cmd/mesh-controller). What a build resolves, given the text it reads back from the archive it packs the templates
// into (Built.Texts, ReadTemplates).
func ReadTreeManifestWritten(root, dir string) (Manifest, error) {
list, err := ReadPredefined(root)
if err != nil {
return Manifest{}, err
}
raw, err := os.ReadFile(filepath.Join(dir, "module.json"))
if err != nil {
return Manifest{}, err
}
return ParseManifestWith(raw, list)
}
// ReadTreeManifest is ReadTreeManifestWritten with each template's text read from the module's directory as the
// merge gate reads it (WithTemplatesFrom). For a check that composes a tree's manifests, so what it judges is what
// the real check judges.
func ReadTreeManifest(root, dir string) (Manifest, error) {
m, err := ReadTreeManifestWritten(root, dir)
if err != nil {
return Manifest{}, err
}
return WithTemplatesFrom(m, dir)
}
@@ -0,0 +1,189 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
// ProcessBusField is the key a process resource declares its bus on. Controller-side only: never composed
// onto the node-engine's process resource (novox/hq ADR 0436 §1) — the wire declaration is mesh-host's own,
// vendored, with a strict decoder that refuses an unknown key.
const ProcessBusField = "bus"
// ProcessBus is what one process of a module does on the bus (novox/hq ADR 0436 §1, §2, §3, §5): its own bus
// account, granted exactly this and nothing of the module's other grants or the tool runner's — never a seat
// to hold or serve, never a tool call or seat verb answered, never a restricted stream heard, never a
// warrant published (§3, §5).
type ProcessBus struct {
// Emits are the events this process emits, named locally, as the module's own Emits names them
// (EmitsAll): a process emits only what its module may emit as a whole.
Emits []string `json:"emits,omitempty"`
// Queues are the module's own work-queue streams (ADR 0436 §6) this process consumes.
Queues []string `json:"queues,omitempty"`
// State is the module's own state, by local name, this process reads and writes.
State []string `json:"state,omitempty"`
// Asks says whether this process reads the router's records of this module's own asks.
Asks bool `json:"asks,omitempty"`
// Invokes are the tools and seat verbs this process calls, each <module>.<tool> or a role's
// seat:<seat>.<verb> (as Manifest.Invokes), or * for every tool on the mesh.
Invokes []string `json:"invokes,omitempty"`
}
// UnmarshalJSON reads a process's bus strictly: an unknown key — serves, a seat to hold or claim, or
// anything else that would answer a tool call or a seat's verb — is refused here, by name (novox/hq ADR
// 0436 §3).
func (b *ProcessBus) UnmarshalJSON(raw []byte) error {
type plain ProcessBus
var p plain
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("a process's bus is {emits, queues, state, asks, invokes}: %w", typedUnknown(err))
}
*b = ProcessBus(p)
return nil
}
// processBusOf is the bus a process resource declares, and whether it declared one at all.
func processBusOf(r map[string]any) (bus ProcessBus, present bool, err error) {
raw, present := r["bus"]
if !present {
return ProcessBus{}, false, nil
}
body, err := json.Marshal(raw)
if err != nil {
return ProcessBus{}, true, err
}
if err := json.Unmarshal(body, &bus); err != nil {
return ProcessBus{}, true, err
}
return bus, true, nil
}
// ProcessBuses is every process of this module that declares bus, by its id (the manifest's own resource
// id, novox/hq ADR 0436, settled by the lead) — what the grant composition reads to grant a process
// account exactly what it emits, consumes and calls (novox/hq ADR 0436 §2).
func (m Manifest) ProcessBuses() map[string]ProcessBus {
var out map[string]ProcessBus
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "process" {
continue
}
b, present, err := processBusOf(r)
if !present || err != nil {
continue
}
if out == nil {
out = map[string]ProcessBus{}
}
out[fmt.Sprint(r["id"])] = b
}
return out
}
// ProcessBusProblems is what is wrong with a manifest's processes' bus declarations (novox/hq ADR 0436 §1,
// §3): the account rule is modelled on RunsAsProblems (seats_declared.go:522-550), and an invoked tool or
// seat verb's shape on invokeProblems.
func ProcessBusProblems(m Manifest) []string {
var problems []string
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
}
// processAccountProblems is what is wrong with one process's bus account (novox/hq ADR 0436 §1, §2): an
// account the module makes — a `user` resource of that name — never root and never the operator account,
// which a manifest cannot say safely (a node with no account has nothing to resolve it to, novox/hq ADR
// 0436, settled by the lead).
func processAccountProblems(m Manifest, processID, user string) []string {
switch {
case user == "":
return []string{fmt.Sprintf(
"%s's process %s declares bus and names no account; a process account is a user resource the "+
"module makes, never root and never the operator account (novox/hq ADR 0436 §1, §2)",
m.Module, processID)}
case user == "root":
return []string{fmt.Sprintf(
"%s's process %s runs as root; a process account is the module's own, never root (novox/hq ADR "+
"0436 §1, §2)", m.Module, processID)}
case user == "${machine:account}":
return []string{fmt.Sprintf(
"%s's process %s runs as the operator account; a process account is the module's own, never the "+
"operator's (novox/hq ADR 0436 §1, §2)", m.Module, processID)}
}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == user {
return nil
}
}
return []string{fmt.Sprintf(
"%s's process %s runs as %s, which it does not make: a user resource named %s (novox/hq ADR 0436 §1, "+
"§2)", m.Module, processID, user, user)}
}
// invokeNameValid says a module or process's invoked entry names a tool: <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)
}
+220
View File
@@ -0,0 +1,220 @@
package catalogue
import (
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"
)
// A node's files and accounts, read (novox/hq ADR 0310, extending ADR 0268 §5; hq to-be 41 WP8).
//
// **A seat of its own on every node.** Reading a file, searching one, listing a directory, the owner and mode
// of each component of a path, and looking up an account or a group are the reads ADR 0268 §5 has narrow tools
// take over from the login shell's `execute`, so an approval is asked for an act and never for a read. Every
// node needs them, the control-node included, and another module could offer them instead of the tool
// runner's (ADR 0126 §9): a seat, so the mesh finds them on every node by one name.
//
// **Read as an account that holds no secret.** The holder runs as an account of its own (runs-as, ADR 0259
// §8), so the node's file modes bound what it sees — never the operator's account, which sees the
// operator's keys and the agent's credentials. On top, it refuses by name the places a secret is kept,
// which the controller names for it (secretPlaces), and every answer passes the content rule's secret class
// (mesh-sdk's outward, ADR 0310 §4).
// ReadsSeat is the role that reads a node's files and accounts (novox/hq ADR 0310).
const ReadsSeat = "node-reads"
// ReadsSecretPlaces is the word the holder of node-reads is given: the places on its node where a secret is
// kept, as a JSON list of absolute paths, each refused by name before any read (novox/hq ADR 0310 §5).
const ReadsSecretPlaces = "MESH_SECRET_PLACES"
// 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() []Verb {
return []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"}},
}
}
// ReadsHolderProblems is what a holder of node-reads is held to (novox/hq ADR 0310 §3): it reads as an
// account of its own, so the node's file modes bound what it sees. Carried by the machine's runtime it would
// read as the operator's account, which sees the operator's keys and the agent's credentials.
func ReadsHolderProblems(m Manifest) []string {
if !m.ClaimsSeat(ReadsSeat) {
return nil
}
if m.RunsAs == "" {
return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+
"bounded by that account's file modes, and the machine's runtime runs as the operator's account "+
"(novox/hq ADR 0310 §3)", m.Module, ReadsSeat)}
}
// In no group beside its own: a group is a way to a secret (docker's is root's), and the account's file modes
// are what bound its reads.
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "user" || fmt.Sprint(r["name"]) != m.RunsAs {
continue
}
if groups, said := r["groups"]; said {
if list, ok := groups.([]any); !ok || len(list) > 0 {
return []string{fmt.Sprintf("%s claims %s, and its account %s is put in groups %v: the account that "+
"reads is in no group beside its own (novox/hq ADR 0310 §3)", m.Module, ReadsSeat, m.RunsAs, groups)}
}
}
}
return nil
}
// secretPlaces is every place on this node a secret is kept, as the placed manifests say (novox/hq ADR 0310
// §5): the directory of every module's own secret, every credential file a module is given and every grant
// directory a provider is handed its consumers' credentials in, the place a module keeps the operator-sealed
// secrets, and every directory of the module that provides `secret` — the vault — wherever the node places it,
// a directory declared without a path included. Sorted, each once, and a place under another dropped, so the
// list says each refusal once.
//
// **Failure is loud.** A place still holding a placeholder, or not absolute, fails the composition with the
// module named: a list that silently shrank would be a refusal the decision promises and nothing does.
func (r Resolution) secretPlaces(with Rendering) ([]string, error) {
seen := map[string]bool{}
var problems []string
add := func(module, what, path string) {
path = strings.TrimSpace(path)
if path == "" {
return
}
if !strings.HasPrefix(path, "/") || strings.Contains(path, "${") {
problems = append(problems, fmt.Sprintf("%s's %s is %q, which is not a place on %s", module, what, path, r.Node))
return
}
path = filepath.Clean(path)
if path == "/" {
problems = append(problems, fmt.Sprintf("%s's %s is the root directory", module, what))
return
}
seen[path] = true
}
for _, m := range r.Modules {
for name, own := range m.OwnSecrets {
add(m.Module, "own secret "+name, own.Path)
if strings.HasPrefix(own.Path, "/") {
add(m.Module, "own secret "+name+"'s directory", filepath.Dir(own.Path))
}
}
for _, to := range m.SecretRequirements() {
for _, f := range m.SecretFiles(to) {
add(m.Module, "credential file for "+to, f.Path)
}
}
for to, where := range m.Grants {
add(m.Module, "grant directory for "+to, where)
}
add(m.Module, "place for the operator-sealed secrets (keeps)", m.Keeps)
if providesSecret(m) {
dirs := dirsFor(m, with)
for _, id := range sortedKeys(dirs) {
add(m.Module, "directory "+id, dirs[id])
}
}
}
if len(problems) > 0 {
sort.Strings(problems)
return nil, fmt.Errorf("the holder of %s on %s cannot be told where a secret is kept, so it would read "+
"one (novox/hq ADR 0310 §5): %s", ReadsSeat, r.Node, strings.Join(problems, "; "))
}
all := make([]string, 0, len(seen))
for p := range seen {
all = append(all, p)
}
sort.Strings(all)
var out []string
for _, p := range all {
if n := len(out); n > 0 && strings.HasPrefix(p, out[n-1]+"/") {
continue
}
out = append(out, p)
}
return out, nil
}
func providesSecret(m Manifest) bool {
for _, p := range m.Provides {
if p.Name == "secret" {
return true
}
}
return false
}
// readsWords is what the holder of node-reads is given beside its own words: the node's secret places.
func (r Resolution) readsWords(m Manifest, words map[string]string, with Rendering) (map[string]string, error) {
if !m.ClaimsSeat(ReadsSeat) {
return words, nil
}
places, err := r.secretPlaces(with)
if err != nil {
return nil, err
}
body, err := json.Marshal(places)
if err != nil {
return nil, err
}
if words == nil {
words = map[string]string{}
}
if _, has := words[ReadsSecretPlaces]; has {
return nil, fmt.Errorf("%s gives its own %s; the node's secret places are the mesh's to say (novox/hq ADR 0310 §5)",
m.Module, ReadsSecretPlaces)
}
words[ReadsSecretPlaces] = string(body)
return words, nil
}
@@ -0,0 +1,332 @@
package catalogue
import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
)
// What a move does to a module's containers (novox/hq ADR 0245).
//
// A container whose declaration changes is recreated, whatever changed in it: an image, an environment
// variable, a health check adopted. A module whose containers are recreated in one send is down while
// they start again — on 2026-10-07 a catalogue change that only adopted the images' own health checks
// recreated nine of mail's containers at once, and the operator's phone could not reach the mail. So a
// send says, per module, how many of its containers it recreates and whether any image changed, before
// it is sent and in the plan that sent it.
// Recreation is what moving a module from one build's manifest to another's does to its containers.
type Recreation struct {
// Containers is how many containers the new build declares.
Containers int
// Recreated are the ids of the containers whose declaration differs — changed, added or gone —
// sorted.
Recreated []string
// Images are the ids among them whose image changed (or that are added or gone).
Images []string
}
// Recreates compares two builds of a module, container by container, by resource id.
func Recreates(from, to Manifest) Recreation {
before := containersByID(from)
after := containersByID(to)
var r Recreation
r.Containers = len(after)
for id, now := range after {
was, held := before[id]
switch {
case !held:
r.Recreated = append(r.Recreated, id)
r.Images = append(r.Images, id)
case !sameDeclaration(was, now):
r.Recreated = append(r.Recreated, id)
if !sameDeclaration(was["image"], now["image"]) {
r.Images = append(r.Images, id)
}
}
}
for id := range before {
if _, kept := after[id]; !kept {
r.Recreated = append(r.Recreated, id)
r.Images = append(r.Images, id)
}
}
sort.Strings(r.Recreated)
sort.Strings(r.Images)
return r
}
// SpecOnly is whether the move recreates containers without any new image: a change to how they are
// declared only — a health check adopted, a variable — which a person may well not expect to interrupt.
func (r Recreation) SpecOnly() bool { return len(r.Recreated) > 0 && len(r.Images) == 0 }
// Say is the recreation in the mesh's words, for a module: empty when the move recreates nothing.
func (r Recreation) Say(module string) string {
if len(r.Recreated) == 0 {
return ""
}
what := "with a new image"
switch {
case r.SpecOnly():
what = "no new image, only their declaration"
case len(r.Images) < len(r.Recreated):
what = fmt.Sprintf("%d with a new image", len(r.Images))
}
whole := ""
if len(r.Recreated) > 1 && len(r.Recreated) >= r.Containers {
whole = ", every one at once: its service is interrupted until they are up again"
} else if len(r.Recreated) > 1 {
whole = ", at once: what they serve is interrupted until they are up again"
}
return fmt.Sprintf("recreates %d of %s's %d container(s) (%s: %s)%s", len(r.Recreated), module, r.Containers,
what, strings.Join(r.Recreated, ", "), whole)
}
func containersByID(m Manifest) map[string]map[string]any {
out := map[string]map[string]any{}
for _, r := range m.Resources {
if t, _ := r["type"].(string); t != "container" {
continue
}
id, _ := r["id"].(string)
out[id] = r
}
return out
}
// sameDeclaration compares two declarations as JSON, so a number read as an int and one read as a
// float are one value.
func sameDeclaration(a, b any) bool {
ra, errA := json.Marshal(a)
rb, errB := json.Marshal(b)
if errA != nil || errB != nil {
return reflect.DeepEqual(a, b)
}
var na, nb any
_ = json.Unmarshal(ra, &na)
_ = json.Unmarshal(rb, &nb)
return reflect.DeepEqual(na, nb)
}
// What a move under a module's own `record` may do without a person (novox/hq ADR 0377).
//
// A module records because its manifest says so or because it keeps irreplaceable data: mail, whose containers
// recreated together interrupt everyone's mail, and the database server, whose restart drops every consumer. That
// hold also held the moves that interrupt nothing — on 2026-10-15 the database server moved with only its tools
// bundle changed, its walk said it "recreates none of its containers", and the machine stayed behind until a
// person pushed. So such a move rolls out like any other, and only a move that recreates a container or touches
// data still waits.
// Holds is why a move from one build's manifest to another's still waits for a person under a module's own
// `record` (ADR 0377 §3), empty when it recreates none of its containers and moves none of its data: no container
// changed, added or gone; no file or directory changed, added or gone that a container mounts, or that holds or
// lies within a path of the module's declared data; no change to its `data` declaration, nor to what it provides,
// requires or contributes; and, for a module that declares data of its own, no change to a long-running process.
// Its tools bundles, a file nothing mounts that holds none of its data, and manifest fields that recreate
// nothing — its tool list — may differ.
func Holds(from, to Manifest) string {
module := to.Module
if module == "" {
module = from.Module
}
if said := Recreates(from, to).Say(module); said != "" {
return said
}
if !sameDeclaration(from.Data, to.Data) {
return "it changes " + module + "'s data declaration"
}
for _, f := range []struct {
what string
was, s any
}{
{"what it provides", from.Provides, to.Provides},
{"what it requires", from.Requires, to.Requires},
{"which of its requirements are optional", from.OptionalRequires, to.OptionalRequires},
{"what it contributes to a provider", from.Contributes, to.Contributes},
{"what it contributes to a provider", from.ContributesMany, to.ContributesMany},
// A setting is filled into the files the module writes: a changed default changes what a container may
// mount without the file's own declaration changing.
{"its settings, which the files it writes are filled from", from.Settings, to.Settings},
} {
if !sameDeclaration(emptyAsNil(f.was), emptyAsNil(f.s)) {
return "it changes " + f.what
}
}
mounted := append(mountSources(from), mountSources(to)...)
data := append(dataPaths(from), dataPaths(to)...)
keepsData := (from.Data != nil && len(from.Data.Own) > 0) || (to.Data != nil && len(to.Data.Own) > 0)
before, after := resourcesByID(from), resourcesByID(to)
var changed []string
for id, r := range after {
if was, held := before[id]; !held || !sameDeclaration(was, r) {
changed = append(changed, id)
}
}
for id := range before {
if _, kept := after[id]; !kept {
changed = append(changed, id)
}
}
sort.Strings(changed)
dirs := absoluteDirectories(from, to)
for _, key := range changed {
for _, r := range []map[string]any{before[key], after[key]} {
if r == nil {
continue
}
kind, id := fmt.Sprint(r["type"]), fmt.Sprint(r["id"])
switch kind {
case "file", "directory":
for _, p := range pathsOf(r, dirs) {
for _, m := range mounted {
if pathsMeet(p, expandDir(m.source, dirs)) {
return fmt.Sprintf("it changes the %s %s, which its container %s mounts", kind, id, m.container)
}
}
for _, d := range data {
if pathsMeet(p, expandDir(d.path, dirs)) {
return fmt.Sprintf("it changes the %s %s, which holds or lies within its data %s", kind, id, d.id)
}
}
}
case "process", "service":
if keepsData {
return fmt.Sprintf("it changes its long-running %s %s, and it keeps data of its own", kind, id)
}
default:
// Anything that is not a file or directory (judged above by whether a container mounts it
// or it holds data) always holds: an archive, a package, a user, a network, or any type
// added later (novox/hq ADR 0377 §3's allowlist names only tools bundles and manifest
// fields that recreate nothing).
return fmt.Sprintf("it changes the %s %s", kind, id)
}
}
}
return ""
}
type mountSource struct{ container, source string }
// mountSources is every path a container of the module reads from its machine: its volumes' sources and its
// environment files.
func mountSources(m Manifest) []mountSource {
var out []mountSource
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "container" {
continue
}
id := fmt.Sprint(r["id"])
vols, _ := r["volumes"].([]any)
for _, v := range vols {
s, _ := v.(string)
if mount := volumeMount.FindStringSubmatch(s); mount != nil {
out = append(out, mountSource{id, mount[1]})
}
}
switch files := r["env-file"].(type) {
case string:
out = append(out, mountSource{id, files})
case []any:
for _, f := range files {
if s, ok := f.(string); ok {
out = append(out, mountSource{id, s})
}
}
}
}
return out
}
type dataPath struct{ id, path string }
func dataPaths(m Manifest) []dataPath {
if m.Data == nil {
return nil
}
var out []dataPath
for _, it := range m.Data.Own {
out = append(out, dataPath{it.ID, it.Path})
}
return out
}
func resourcesByID(m Manifest) map[string]map[string]any {
out := map[string]map[string]any{}
for _, r := range m.Resources {
out[fmt.Sprint(r["type"])+":"+fmt.Sprint(r["id"])] = r
}
return out
}
// absoluteDirectories is, per directory id, the absolute path a directory of either build states.
func absoluteDirectories(ms ...Manifest) map[string]string {
out := map[string]string{}
for _, m := range ms {
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "directory" {
continue
}
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
out[fmt.Sprint(r["id"])] = strings.TrimRight(p, "/")
}
}
}
return out
}
// pathsOf is every way a file or directory resource is named: its path, and a directory's `${dir:<id>}`.
func pathsOf(r map[string]any, dirs map[string]string) []string {
var out []string
if p, ok := r["path"].(string); ok && p != "" {
out = append(out, expandDir(p, dirs))
}
if fmt.Sprint(r["type"]) == "directory" {
out = append(out, expandDir("${dir:"+fmt.Sprint(r["id"])+"}", dirs))
}
return out
}
// expandDir writes a leading `${dir:<id>}` as the directory's absolute path when one is stated, so a path named
// either way is one path.
func expandDir(p string, dirs map[string]string) string {
p = strings.TrimRight(p, "/")
if ref := dirPlain.FindStringSubmatch(p); ref != nil {
if abs, ok := dirs[ref[1]]; ok {
return abs
}
return p
}
if strings.HasPrefix(p, "${dir:") {
end := strings.Index(p, "}")
if abs, ok := dirs[p[len("${dir:"):end]]; ok {
return abs + p[end+1:]
}
}
return p
}
// pathsMeet is whether one path is the other or lies within it.
func pathsMeet(a, b string) bool {
if a == "" || b == "" {
return false
}
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
}
// emptyAsNil reads an empty list or map as nothing, so a field written empty is no change from one left out.
func emptyAsNil(v any) any {
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Slice, reflect.Map:
if rv.Len() == 0 {
return nil
}
case reflect.Ptr:
if rv.IsNil() {
return nil
}
}
return v
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
package catalogue
import (
"bytes"
"fmt"
"sort"
"strings"
"text/template"
)
// What only the mesh knows, written where a module asks for it — in the module's own format.
//
// **The graph is the control plane's; the format is the module's.** The mesh knows which machines
// exist, what they are called and where they are. Turning that into a hosts file, a resolver's
// zones, an ssh known_hosts is somebody's configuration language, and the mesh has no business
// knowing it. So the mesh hands the roster to a template the module wrote and renders it; it never
// learns what the file means.
//
// This used to be a closed list of fact names, each with its format written in Go here — a hosts
// file, a resolver's zones. Every new consumer meant a new formatter in the control plane, in the
// consumer's configuration language. Now the data is the mesh's and the format is a template the
// module ships: the two built-in cases (the network module's `/etc/hosts`, dnsmasq's zones) render
// the same way any module's would, and the control plane holds no format at all.
//
// It replaced three modules that existed only because computed output needed somewhere to live —
// they ran no software, could not be swapped for anything, and appeared in the graph as modules
// while being a data channel wearing a costume (novox/hq ADR 0040).
// A RosterFile is a file the mesh renders from the roster of machines, in the format the module
// gives as a Go text/template. The template sees a rosterView: `.Node` (this machine's bare name),
// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names` and
// `.Machines`. Both are the nodes of the mesh: routed names were once in `.Names` too, and are not
// since every route became a name under its node's internal domain (novox/hq ADR 0191) — the hq
// issue 111 distinction is kept as two fields so the templates that range either keep rendering.
type RosterFile struct {
// Path is where on the machine the rendered file goes. Absolute, or it is refused here rather
// than discovered as a daemon that reads nothing.
Path string `json:"path"`
// Template is the module's format, a Go text/template over the rosterView. It is the module's,
// not the mesh's: the mesh renders it and does not read it.
Template string `json:"template"`
// Shared is whether the file the fact goes to belongs to the machine rather than the mesh. When
// it does, the mesh owns only a marked region of it and keeps the rest byte for byte (novox/hq
// issue 128) — a hosts file is shared, since the distribution's `localhost`, the operator's own
// lines and other tools' blocks live there too; a resolver's zones file is not, the mesh owns it
// whole. A property of the fact, not of the path: the format determines whether the file is
// wholly the mesh's, not where a module happened to ask for it.
Shared bool `json:"shared,omitempty"`
// Home places the file under this node's operator-account home and chowns it to that account,
// rather than at an absolute system path (novox/hq to-be 29). Then Path is home-relative
// (`.ssh/config.d/mesh`), resolved against the account's home on the node it is composed for; a
// node with no operator account gets no such file. This is how the ssh-client config — every
// other node's Host block — is written into a person's home rather than into /etc.
Home bool `json:"home,omitempty"`
}
// rosterView is what a RosterFile's template sees. A closed shape — a template referencing a field
// the mesh does not compute fails to render here, not on a machine.
type rosterView struct {
Node string
Suffix string
Names []rosterEntry
Machines []rosterEntry
// Zones is every zone a module in the mesh answers itself, with where its answerer is (novox/hq
// ADR 0199) — what the mesh's resolver forwards. Ordered by zone.
Zones []rosterZone
// Holders is the machines holding each replicated mesh seat, by seat (novox/hq ADR 0223) — what
// a machine's resolver file lists for `mesh-dns-resolver`. **This machine first when it is one
// of them**, then the rest by name: the nearest holder is asked first, and two renderings of
// one mesh on one machine are one file. A template reads one seat's with
// `index .Holders "<seat>"`; a seat nobody holds ranges over nothing.
Holders map[string][]rosterEntry
}
// rosterZone is one zone as a template sees it: the zone, and the address and port answering it.
type rosterZone struct {
Zone string
Address string
Port int
}
// rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address,
// and the operator account to log into it as (novox/hq to-be 29) — empty when none is known, so an
// ssh Host block template can omit the User line for a machine nobody has an account on.
type rosterEntry struct {
Name string
FQDN string
Address string
Account string
}
// FactsInto renders the roster files a module asked for, as files it will be given.
//
// The module owns everything after the file exists: loading it, restarting on it, what a resolver
// or a client does with it. This only puts it there. `every` is every name the mesh serves;
// `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both
// are given and the template chooses.
func FactsInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string) ([]map[string]any, error) {
return FactsWithZonesInto(m, r, every, machines, accounts, suffix, nil)
}
// FactsWithZonesInto is FactsInto with the mesh's zones in the view, for a template that ranges them.
func FactsWithZonesInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string,
zones []ZoneAt) ([]map[string]any, error) {
return FactsFrom(m, r, Rendering{Names: every, Machines: machines, Accounts: accounts, Suffix: suffix,
Zones: zones})
}
// FactsFrom renders the roster files a module asked for from everything the mesh composed for this
// machine: its machines, zones and the holders of each replicated seat.
func FactsFrom(m Manifest, r Resolution, with Rendering) ([]map[string]any, error) {
if len(m.Facts) == 0 {
return nil, nil
}
names := sortedFacts(m.Facts)
view := rosterView{
Node: r.Node,
Suffix: strings.TrimPrefix(suffixOr(with.Suffix), "."),
Names: entriesFrom(with.Names, with.Accounts, with.Suffix),
Machines: entriesFrom(with.Machines, with.Accounts, with.Suffix),
Zones: zonesFrom(with.Zones),
Holders: holdersFrom(with.Holders, with.Accounts, with.Suffix, r.Node),
}
out := make([]map[string]any, 0, len(names))
for _, name := range names {
fact := m.Facts[name]
content, err := renderRoster(fact.Template, view)
if err != nil {
return nil, fmt.Errorf("%s cannot render %q: %w", m.Module, name, err)
}
// Where the file goes: under the operator's home and chowned to it (a home fact), or at the
// absolute system path it names. A home fact on a machine with no operator account cannot be
// placed, and is left out rather than written to nowhere (novox/hq to-be 29).
path := fact.Path
var owner string
if fact.Home {
if r.Account == "" {
continue
}
path = accountHomeOf(r.Account, r.AccountHome) + "/" + strings.TrimLeft(fact.Path, "/")
owner = r.Account
} else if !strings.HasPrefix(fact.Path, "/") {
return nil, fmt.Errorf(
"%s asks for %q at %q, which is not an absolute path", m.Module, name, fact.Path)
}
file := map[string]any{
"id": "fact-" + name, "type": "file", "path": path, "mode": "0644",
"content": content,
}
if owner != "" {
file["owner"] = owner
}
if fact.Shared {
// The host owns only the lines between `# BEGIN mesh <id>` and `# END mesh <id>` and
// keeps the rest of the file byte for byte; undeclared, the region goes and nothing else
// does (novox/hq issue 128). Every node on the private network receives this, so every
// node's host — the controller's own machine included — must be block-aware before a
// controller emitting it is rolled out: the order ADR 0102 set for `into: json`.
file["into"] = "block"
}
out = append(out, file)
}
return out, nil
}
// renderRoster runs a module's template over the roster. A template that will not parse, or reads
// a field the mesh does not have, is an error here — where the manifest is — rather than an empty
// file on a machine.
func renderRoster(tmpl string, view rosterView) (string, error) {
t, err := template.New("roster").Option("missingkey=error").Parse(tmpl)
if err != nil {
return "", err
}
var b bytes.Buffer
if err := t.Execute(&b, view); err != nil {
return "", err
}
return b.String(), nil
}
// entriesFrom is a name→address map as sorted roster entries.
//
// **A machine with no address is left out.** The mesh has a record for it — somebody added it —
// and does not yet know where it is, which is the ordinary state between adding a machine and it
// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to
// that hangs; leaving it out fails at once and says the name is unknown.
func entriesFrom(addresses, accounts map[string]string, suffix string) []rosterEntry {
out := make([]rosterEntry, 0, len(addresses))
for _, name := range sortedNames(addresses) {
if routed(name, suffix) {
// A routed name is already a full name under a public domain, and it has no mesh
// form: appending the suffix made `<name>.<suffix>`, which every machine's hosts file
// carried and nothing served (novox/hq issue 157). It is published as itself, once.
out = append(out, rosterEntry{Name: name, FQDN: name, Address: addresses[name], Account: accounts[name]})
continue
}
internal, bare := meshName(name, suffix)
// The account is looked up by whichever key the caller keys accounts on — the internal name
// or the bare one — so a template gets the right login however the maps were built.
account := accounts[name]
if account == "" {
account = accounts[bare]
}
out = append(out, rosterEntry{Name: bare, FQDN: internal, Address: addresses[name], Account: account})
}
return out
}
// meshName is a machine's internal name and its bare one, from either. The control plane keys
// the names it hands a resolution by the internal name (`homer.internal`), the same map a
// container gets as its hosts; a caller that keys by the bare name gets the same answer. The
// suffix is the one the control plane composed those names with, handed down rather than written
// here a second time — the alternative was `homer.internal.internal` on every machine.
func meshName(name, suffix string) (internal, bare string) {
dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".")
if strings.HasSuffix(name, dotted) {
return name, strings.TrimSuffix(name, dotted)
}
return name + dotted, name
}
// routed says whether a name the mesh serves is a routed public name rather than a machine's: it
// carries a domain of its own and not the mesh's suffix. A machine's name is bare (`homer`) or
// internal (`homer.internal`); anything else with a dot in it was composed under a public domain.
func routed(name, suffix string) bool {
dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".")
return strings.Contains(name, ".") && !strings.HasSuffix(name, dotted)
}
// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down.
// The one place the default is written, so a fact and a name cannot disagree about it.
func suffixOr(suffix string) string {
if suffix == "" {
return "internal"
}
return suffix
}
func sortedNames(addresses map[string]string) []string {
out := make([]string, 0, len(addresses))
for name, at := range addresses {
// A machine the mesh cannot place is left out rather than named at nothing.
if at == "" {
continue
}
out = append(out, name)
}
sort.Strings(out)
return out
}
// zonesFrom is the zones a template ranges, ordered by zone so two renderings of one mesh are one file.
func zonesFrom(zones []ZoneAt) []rosterZone {
out := make([]rosterZone, 0, len(zones))
for _, z := range zones {
out = append(out, rosterZone{Zone: z.Zone, Address: z.Address, Port: z.Port})
}
sort.Slice(out, func(i, j int) bool { return out[i].Zone < out[j].Zone })
return out
}
// holdersFrom is each replicated seat's holders as a template ranges them: this machine first when
// it holds the seat, then the others by name (novox/hq ADR 0223). A machine with no address is left
// out, as everywhere in the roster — a resolver named at nothing is a lookup that hangs.
func holdersFrom(holders map[string]map[string]string, accounts map[string]string, suffix, node string) map[string][]rosterEntry {
out := make(map[string][]rosterEntry, len(holders))
for seat, at := range holders {
entries := entriesFrom(at, accounts, suffix)
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].Name == node && entries[j].Name != node
})
out[seat] = entries
}
return out
}
// sortedFacts is a module's fact names in order, so what is said about them is said the same way twice.
func sortedFacts(facts map[string]RosterFile) []string {
out := make([]string, 0, len(facts))
for name := range facts {
out = append(out, name)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,479 @@
package catalogue
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// The node's tool runtime, as the catalogue knows it (novox/hq ADR 0175, to-be 38).
//
// **One module is the runtime.** Where it is assigned, one process per machine serves every assigned
// module's tools and every held seat's verbs, on the host side, from the bundles each module's build
// produced — and no module needs a container to reach the bus with its tools. The name is a constant
// rather than a manifest field because a rule turns on it: the composer places the runtime's process
// where this module is, and registration refuses the old pattern once this module exists.
// RuntimeModule is the module that is the node's tool runtime. Mirrored in the broker package,
// which composes a principal of its own for it; the agreement test there holds the two to one string.
const RuntimeModule = "node-tools"
// BundleRoot is where a machine keeps the tools bundles the mesh delivers to it: under the mesh's
// own directory, beside the daemons the host unpacks there, and never where a package manager also
// writes. One directory per module, one per bundle beneath it, at a path that does not move with
// the version — so the runtime's process names each entrypoint once and is restarted, not
// recomposed, when a bundle changes.
const BundleRoot = "/var/lib/mesh/bundles"
// BundleID names the archive resource that delivers one of a module's bundles; prefixed with the
// module like every resource of its own.
func BundleID(bundle string) string { return "bundle-" + bundle }
// BundlePath is where one module's bundle is unpacked on a machine.
func BundlePath(module, bundle string) string { return BundleRoot + "/" + module + "/" + bundle }
// runtimeHere says whether this node's set includes the runtime module, which is what decides
// whether anything about tools changes on the machine (to-be 38 WP2): until the runtime is assigned,
// a node is sent exactly what it was sent before, bundles included, because a bundle nothing loads
// is bytes nobody reads.
func (r Resolution) runtimeHere() bool {
for _, m := range r.Modules {
if m.Module == RuntimeModule {
return true
}
}
return false
}
// bundleArchives is one archive per tools bundle of a module — a bundle the runtime LOADS something
// from — as the host fetches and unpacks any artifact (novox/hq ADR 0175 §3: a module brings its
// tools as a bundle, delivered by the host like any artifact, never an image). A bundle it loads
// nothing from is run rather than loaded: a daemon, a step, the runtime itself — delivered by the
// process that runs it, and not again here.
//
// The source is the kept reference; the per-resource pass that follows routes it through the
// artifact store as this network reaches it now, as it does every image and archive the mesh built.
func bundleArchives(m Manifest) []map[string]any {
var out []map[string]any
for _, b := range m.Bundles {
if len(b.Loads) == 0 {
continue
}
out = append(out, map[string]any{
"id": BundleID(b.Name), "type": "archive",
"source": b.Source, "digest": b.Digest,
"path": BundlePath(m.Module, b.Name),
})
}
return out
}
// RuntimeProcessID names the one process the mesh composes for a machine's runtime; prefixed with
// the runtime module like a resource of its own, because that module is what the host sees it as.
func RuntimeProcessID() string { return "runtime" }
// RuntimeToolModules is the variable the runtime reads the modules it serves from: one
// `<module>=<entrypoint>` per file it loads, comma-separated — several entries may name one module.
// RuntimeBrokerFile is where it reads the node's credential; RuntimeOperatorAccount and
// RuntimeOperatorHome are the machine's operator account and home, handed to every tool's
// environment (to-be 38 WP1), and absent on a machine with no account.
const (
RuntimeToolModules = "MESH_TOOL_MODULES"
RuntimeBrokerFile = "MESH_BROKER_FILE"
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
RuntimeOperatorHome = "MESH_OPERATOR_HOME"
// RuntimeAgentAccount and RuntimeAgentHome are the account agents run as on the machine and its home
// (novox/hq ADR 0266): the agent account where the node names one, the operator account otherwise.
// The agent's module writes the agent's home from them; absent where neither account is known.
RuntimeAgentAccount = "MESH_AGENT_ACCOUNT"
RuntimeAgentHome = "MESH_AGENT_HOME"
// RuntimeToolEnv is every served module's composed environment, as JSON (novox/hq ADR 0192):
// {"<module>": {"<word>": "<value>"}}. The runtime takes it at start, removes it from its own
// environment and hands each module's words to that module's bundles alone. In the unit, so a
// change to any module's words changes the process and restarts it.
RuntimeToolEnv = "MESH_TOOL_ENV"
)
// interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the
// bundle's entrypoint after it. The one thing the composer takes from a language, and said here
// rather than in a manifest because the runtime's process is the mesh's to compose (to-be 38 WP3).
func interpreterFor(language string) (string, error) {
switch language {
case "typescript":
return "node", nil
}
return "", fmt.Errorf(
"%s is written in %q, and the mesh knows no interpreter to run a %q bundle with",
RuntimeModule, language, language)
}
// runtimeProcess is the one process a machine runs the node's tool runtime as (novox/hq ADR 0175,
// to-be 38 WP2.3): the runtime module's own bundle, run by its language's interpreter, told which
// modules it serves and from which files, where its credential is, and who the machine's operator
// is — and restarted when any bundle it loads or the credential it holds changes.
//
// Composed from the placed manifests, so the credential's path is where this node puts it. The
// runtime runs as the operator's account when the machine has one, which is what lets a tool that
// needs root escalate as the operator would (ADR 0175 §4); on a machine with no account it runs as
// root, and the two operator words are not set.
func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
var runtime *Manifest
for i := range r.Modules {
if r.Modules[i].Module == RuntimeModule {
runtime = &r.Modules[i]
}
}
if runtime == nil {
return nil, nil
}
if len(runtime.Bundles) != 1 {
return nil, fmt.Errorf(
"%s is assigned to %s and its build produced %d bundle(s); the runtime is one bundle "+
"the mesh runs, so the module declares exactly one (novox/hq to-be 38)",
RuntimeModule, r.Node, len(runtime.Bundles))
}
bundle := runtime.Bundles[0]
// What runs it (novox/hq ADR 0193): a runtime compiled to a binary runs itself, from its own
// unpacked bundle; an interpreted one is its language's interpreter and its one entrypoint.
var run []any
if bundle.Binary != "" {
run = []any{"./" + bundle.Binary}
} else {
if len(bundle.Entrypoints) != 1 {
return nil, fmt.Errorf(
"%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+
"declares exactly one (novox/hq to-be 38)", RuntimeModule, bundle.Name, len(bundle.Entrypoints))
}
interpreter, err := interpreterFor(bundle.Language)
if err != nil {
return nil, err
}
run = []any{interpreter, bundle.Entrypoints[0]}
}
credential, declared := runtime.OwnSecrets["broker"]
if !declared {
return nil, fmt.Errorf(
"%s declares no own secret named broker, and the node's credential is delivered there: "+
"a module that speaks on the bus declares \"own-secrets\": {\"broker\": <path>}",
RuntimeModule)
}
// What it serves, and from which files: every module on this machine that composes here, in
// name order, each bundle it loads from in the order the manifest gave. A module left out of
// the declaration — a filter on an adopted machine — is left out of this too, or the runtime
// would be told to load files that were never delivered.
var served []string
var restartOn []string
given := map[string]map[string]string{}
for _, m := range r.Modules {
if with.Adopted && m.Filtering != nil {
continue
}
if m.RunsAs != "" {
// Served by a runtime of its own, on its own account (ownRuntimes): never the machine's.
continue
}
words, err := bundleWords(m, with)
if err != nil {
return nil, err
}
// The holder of node-files is told the same, and the paths modules own here (novox/hq ADR 0394 §4).
if words, err = r.filesWords(m, words, with); err != nil {
return nil, err
}
if len(words) > 0 {
given[m.Module] = words
}
for _, b := range m.Bundles {
if len(b.Loads) == 0 {
continue
}
for _, load := range b.Loads {
// What the runtime starts: the launcher the build wrote beside the entrypoint, where
// it wrote one (ADR 0193); the entrypoint itself for a build from before them.
if launcher, has := b.Launchers[load]; has {
load = launcher
}
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
}
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
}
}
sort.Strings(served)
restartOn = append(restartOn, RuntimeModule+"."+NeedID("broker"))
sort.Strings(restartOn)
env := map[string]string{
RuntimeToolModules: strings.Join(served, ","),
RuntimeBrokerFile: credential.Path,
}
if len(given) > 0 {
// Marshalled from maps, whose keys encoding/json sorts: the same words, the same unit.
body, err := json.Marshal(given)
if err != nil {
return nil, err
}
env[RuntimeToolEnv] = string(body)
}
process := map[string]any{
"id": RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": RuntimeModule,
"source": bundle.Source, "digest": bundle.Digest,
"run": run,
"env": env,
"restart-on": toAny(restartOn),
}
if r.Account != "" {
env[RuntimeOperatorAccount] = r.Account
env[RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome)
process["user"] = r.Account
}
if agent, home := r.agentAccount(); agent != "" {
env[RuntimeAgentAccount] = agent
env[RuntimeAgentHome] = home
}
// Routed through the artifact store as this network reaches it now, like everything the mesh
// built; refused with the same words when there is no store to route through.
if err := artifactsInto(process, RuntimeModule, with); err != nil {
return nil, err
}
return process, nil
}
// OwnRuntimeID names the process a module of its own account is served by (novox/hq ADR 0259 §8).
func OwnRuntimeID() string { return "own-runtime" }
// ownRuntimes are the processes the modules of their own account are served by (novox/hq ADR 0259 §8): each
// the machine's runtime program — the same build, run from the same source — serving that one module alone,
// as the module's own account, on the module's own bus credential. Never the machine's runtime, which runs
// as the operator's account and carries every module on the machine.
func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
var own []Manifest
for _, m := range r.Modules {
if m.RunsAs != "" && !(with.Adopted && m.Filtering != nil) {
own = append(own, m)
}
}
if len(own) == 0 {
return nil, nil
}
var runtime *Manifest
for i := range r.Modules {
if r.Modules[i].Module == RuntimeModule {
runtime = &r.Modules[i]
}
}
if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" {
return nil, fmt.Errorf("%s runs as its own account in a runtime of its own, and %s is not here to run it "+
"from: assign %s to %s first (novox/hq ADR 0259)", own[0].Module, RuntimeModule, RuntimeModule, r.Node)
}
program := runtime.Bundles[0]
var out []map[string]any
for _, m := range own {
// Never the node's operator account, nor the account agents run as there (the review of 2026-10-09):
// either would hand what it holds back to the very accounts it is kept from.
switch {
case r.Account != "" && m.RunsAs == r.Account:
return nil, fmt.Errorf("%s runs as %s, the operator's account on %s: a module of its own account never "+
"runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node)
case r.AgentAccount != "" && m.RunsAs == r.AgentAccount:
return nil, fmt.Errorf("%s runs as %s, the account agents run as on %s: a module of its own account "+
"never runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node)
}
credential, declared := m.OwnSecrets["broker"]
if !declared {
return nil, fmt.Errorf("%s runs as its own account and declares no own secret broker", m.Module)
}
var served, restartOn []string
for _, b := range m.Bundles {
for _, load := range b.Loads {
if launcher, has := b.Launchers[load]; has {
load = launcher
}
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
}
if len(b.Loads) > 0 {
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
}
}
if len(served) == 0 {
return nil, fmt.Errorf("%s runs as its own account and its build produced no bundle to serve", m.Module)
}
sort.Strings(served)
restartOn = append(restartOn, m.Module+"."+NeedID("broker"))
sort.Strings(restartOn)
env := map[string]string{RuntimeToolModules: strings.Join(served, ","), RuntimeBrokerFile: credential.Path}
words, err := bundleWords(m, with)
if err != nil {
return nil, err
}
// The holder of node-reads is told where this node keeps its secrets (novox/hq ADR 0310 §5).
if words, err = r.readsWords(m, words, with); err != nil {
return nil, err
}
if len(words) > 0 {
body, err := json.Marshal(map[string]map[string]string{m.Module: words})
if err != nil {
return nil, err
}
env[RuntimeToolEnv] = string(body)
}
process := map[string]any{
"id": m.Module + "." + OwnRuntimeID(), "type": "process", "name": m.Module + "-runtime",
"source": program.Source, "digest": program.Digest,
"run": []any{"./" + program.Binary}, "env": env, "restart-on": toAny(restartOn),
"user": m.RunsAs,
}
// How it is ready, as the module declared it (novox/hq ADR 0366 §1), in the node-engine's words.
if _, has := OwnRuntimeHealth(m); has {
process[HealthField] = m.RuntimeHealth
healthInto(process, m, with)
}
if err := artifactsInto(process, RuntimeModule, with); err != nil {
return nil, err
}
out = append(out, process)
}
return out, nil
}
func toAny(in []string) []any {
out := make([]any, 0, len(in))
for _, s := range in {
out = append(out, s)
}
return out
}
// RuntimeImageModule and RuntimeImageArtifact name the image every per-module tool container was
// built on: the tool runtime's own runtime image. With the runtime a module of its own, that image
// stays the way a module's SERVICE may be built and stops being the way tools reach a node (ADR 0175).
const (
RuntimeImageModule = "mesh-tools"
RuntimeImageArtifact = "runtime"
)
// ToolContainerOnTheRuntime says why a manifest is the pattern ADR 0175 retires — a module whose tools
// are served from a container built on the tool runtime's image — or nothing when it is not. Judged
// from the manifest's own `build.on` when it is a repository manifest, and from what its build stood
// on when it is a built one, because a resolved manifest carries no build. The gate itself is
// registration's (to-be 38 WP2.4): once the runtime module is in the catalogue, this is refused.
//
// Three things must hold, and each alone is fine: declaring tools (a bundle does that); a container
// (a module's service may well be one); building on the runtime's image (a service written against
// the SDK may). All three is a container whose purpose is tools, which the runtime now serves.
func ToolContainerOnTheRuntime(m Manifest, against []string) string {
if len(m.Tools) == 0 {
return ""
}
container := false
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "container" {
container = true
}
}
if !container {
return ""
}
onTheRuntime := false
if m.Build != nil {
for _, on := range m.Build.On {
if on.Module == RuntimeImageModule && on.Artifact == RuntimeImageArtifact {
onTheRuntime = true
}
}
}
for _, ref := range against {
path, kept := InArtifactStore(Recorded(ref))
if kept && strings.HasPrefix(path, RuntimeImageModule+"/"+RuntimeImageArtifact+"@") {
onTheRuntime = true
}
}
if !onTheRuntime {
return ""
}
return fmt.Sprintf(
"%s declares tools and a container built on %s's %s image — a container whose purpose is "+
"serving tools. The node's tool runtime (%s) serves every module's tools from its bundle "+
"now (novox/hq ADR 0175, to-be 38); declare the tools as a bundle and drop the container",
m.Module, RuntimeImageModule, RuntimeImageArtifact, RuntimeModule)
}
// bundleWords is what one module's tools bundles are given on this machine (novox/hq ADR 0192):
// each loaded bundle's env, its ${dir:…} resolved to where this machine places the module's
// directories and its ${port:…} to the port this machine gave it — the same resolution a
// container's environment gets. Two bundles of one module naming one word differently is refused:
// the runtime hands a module's words to all its bundles.
func bundleWords(m Manifest, with Rendering) (map[string]string, error) {
var out map[string]string
dirs := dirsFor(m, with)
for _, b := range m.Bundles {
if len(b.Loads) == 0 || len(b.Env) == 0 {
continue
}
for _, word := range sortedKeys(b.Env) {
value, err := dirFill(b.Env[word], dirs, m.Module)
if err != nil {
return nil, err
}
if value, err = portsFilledInto(value, m.Module+"'s bundle "+b.Name+" ("+word+")", m.Module, m.Listens, with); err != nil {
return nil, err
}
if out == nil {
out = map[string]string{}
}
if was, had := out[word]; had && was != value {
return nil, fmt.Errorf("%s's bundles give %s two values (%q, %q); a module's words are "+
"handed to all its bundles, so they agree (novox/hq ADR 0192)", m.Module, word, was, value)
}
out[word] = value
}
}
return out, nil
}
// givenTo makes what a bundle's words name readable by the account the runtime runs as (novox/hq
// ADR 0192): every file and directory of the module whose path a word names, or that holds one,
// is owned by the account — a tool reads its configuration and its secret as the account, and a
// root-owned 0600 file or a 0700 directory is one it cannot. Only where it says no owner already:
// a module that named one knew why. Nothing on a machine with no account, where the runtime is root.
//
// It answers the files a word names exactly: what a tool reads, whose change the runtime must be
// restarted for, as the container the tools came from was restarted when its configuration changed.
func givenTo(out []map[string]any, owner map[string]string, words map[string]map[string]string, account string) []string {
var named []string
if len(words) == 0 {
return nil
}
for _, resource := range out {
module := owner[fmt.Sprint(resource["id"])]
mine := words[module]
if len(mine) == 0 {
continue
}
kind := fmt.Sprint(resource["type"])
if kind != "file" && kind != "directory" {
continue
}
path, _ := resource["path"].(string)
if path == "" {
continue
}
for _, value := range mine {
if kind == "file" && value == path {
named = append(named, fmt.Sprint(resource["id"]))
}
}
if _, said := resource["owner"]; said || account == "" {
continue
}
for _, value := range mine {
if value == path || strings.HasPrefix(value, strings.TrimRight(path, "/")+"/") {
resource["owner"] = account
break
}
}
}
sort.Strings(named)
return named
}
@@ -0,0 +1,291 @@
package catalogue
import (
"fmt"
"regexp"
"strings"
)
// A module contributes to a seat it does not hold (novox/hq ADR 0212).
//
// ADR 0210 made a tool's configuration its seat holder's, and every other module's way in a
// contribution to the seat. The environment, the shell's slots and the power moments each became a
// field of their own; this is the general form, so a new seat that takes contributions is a row in
// the seat table rather than a change to the manifest: a contribution names a seat, a kind that
// seat receives, and text in the tool's own grammar, which the controller never reads.
// HotkeysSeat is the machine's hotkey daemon (novox/hq ADR 0212 §5).
const HotkeysSeat = "node-hotkeys"
// MessageBusSeat is the machine's D-Bus (novox/hq ADR 0215).
const MessageBusSeat = "node-message-bus"
// BackupSeat is the machine's backups (novox/hq ADR 0214, to-be 43).
const BackupSeat = "node-backup"
// SeatContribution is one piece of configuration a module gives a seat's holder to place.
type SeatContribution struct {
// Seat is the seat whose holder places it.
Seat string `json:"seat"`
// Kind is which of the seat's receivable kinds it is.
Kind string `json:"kind"`
// Content is the text, in the tool's own grammar. Never interpreted.
Content string `json:"content,omitempty"`
// Data is the piece in the seat's own shape, for a kind the seat receives as data (novox/hq ADR
// 0255): the holder renders it with its template, and Content is then empty.
Data map[string]any `json:"data,omitempty"`
// IfCapability names a capability the machine must report for this contribution to be placed
// there (novox/hq ADR 0255): a battery's block only where the node-engine found a battery. The
// composition decides, from the machine's facts, not the tool at run time.
IfCapability string `json:"if-capability,omitempty"`
}
// ofContribution is where a holder places a kind: ${contribution:<seat>:<kind>}. Loose inside the
// braces, so a misspelt seat or kind is found and refused rather than written out as text.
var ofContribution = regexp.MustCompile(`\$\{contribution:([^}]*)\}`)
// 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) (Seat, Receivable, bool) {
s, known := SeatNamed(seat)
if !known {
return Seat{}, Receivable{}, false
}
for _, r := range s.Receives {
if r.Kind == kind {
return s, r, true
}
}
return s, Receivable{}, false
}
// kindsOf names a seat's receivable kinds for a refusal.
func kindsOf(s 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, ", ")
}
// seatContributionProblems is what is wrong with this module's contributions, from the manifest
// alone (novox/hq ADR 0212 §2).
func (m Manifest) seatContributionProblems() []string {
var problems []string
for i, c := range m.Contributions {
s, r, ok := receivable(c.Seat, c.Kind)
// A directory a contribution names must be one of this module's own, here rather than on the
// machine — where a `${dir:x}` nobody declared would reach the holder as the literal text.
if ok && r.Dirs {
declared := map[string]string{}
for _, res := range m.Resources {
if fmt.Sprint(res["type"]) == "directory" {
declared[fmt.Sprint(res["id"])] = ""
}
}
if _, err := dirFill(c.Content, declared, m.Module); err != nil {
problems = append(problems, fmt.Sprintf("%s's contribution %d: %v", m.Module, i+1, err))
}
}
switch {
case s.Name == "":
problems = append(problems, fmt.Sprintf(
"%s's contribution %d is to the seat %q, which the mesh does not define", m.Module, i+1, c.Seat))
case !ok:
problems = append(problems, fmt.Sprintf(
"%s's contribution %d to %s is of the kind %q; %s (novox/hq ADR 0212)",
m.Module, i+1, s.Name, c.Kind, kindsOf(s)))
}
// Only on a kind the seat offers (novox/hq ADR 0255): a piece shown where the hardware is. On a
// kind a holder depends on — what a backup keeps, a key's trigger — a machine missing the
// capability would lose the piece without a word.
if c.IfCapability != "" {
switch {
case !capabilityName.MatchString(c.IfCapability):
problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which is not a "+
"capability's name", m.Module, i+1, c.IfCapability))
case !oneOf(KnownCapabilities, c.IfCapability):
problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which no machine "+
"reports; the node-engine detects %s", m.Module, i+1, c.IfCapability, strings.Join(KnownCapabilities, ", ")))
case ok && !r.Offered:
problems = append(problems, fmt.Sprintf("%s's contribution %d to %s (%s) is if-capability %s; only a "+
"kind the seat offers may be left out where a machine lacks something (novox/hq ADR 0255)",
m.Module, i+1, s.Name, c.Kind, c.IfCapability))
}
}
switch {
case ok && r.Shape != nil:
problems = append(problems, shapedProblems(m.Module, i, r, c)...)
problems = append(problems, ownStateProblems(m, i, r, c)...)
case len(c.Data) > 0:
problems = append(problems, fmt.Sprintf("%s's contribution %d has data; %s receives %s as text, "+
"given as `content`", m.Module, i+1, s.Name, c.Kind))
case strings.TrimSpace(c.Content) == "":
problems = append(problems, fmt.Sprintf("%s's contribution %d has no content", m.Module, i+1))
}
}
return problems
}
// seatPlaceholderProblems is what is wrong with one resource's ${contribution:…}: placed only in a
// file's content, naming a seat and a kind it receives, and only by a module that claims that seat
// — another would be a second writer of a file there is one of (novox/hq ADR 0212 §3).
func seatPlaceholderProblems(m 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
}
// seatContributions is every module's contribution of one kind to one seat (novox/hq ADR 0212 §3):
// in module order, each module's pieces in the order it declared them, each module's preceded by a
// comment line naming it in the tool's grammar, and empty when nothing is contributed. A kind that
// takes directories has each contributor's `${dir:<id>}` filled with where that module's directory
// is on this machine (novox/hq to-be 43).
//
// A directory placed in a person's home is named `${machine:account-home}/…` (novox/hq to-be 29); in a
// kind that takes directories that is filled from the machine's facts as well, so the holder reads a
// path, never a placeholder (novox/hq ADR 0233: an item in the operator's home is backed up too).
//
// A kind received as data is rendered through the holder's template instead, narrowed by where
// (novox/hq ADR 0255); and a contribution naming a capability this machine did not report is left
// out, of either form.
//
// What it could not render is answered as unplaced, each naming its module and why, and left out: one
// piece the holder's template does not know is that piece's fault, not the machine's.
func seatContributions(modules []Manifest, holder Manifest, placeholder string, with Rendering, facts map[string]string, caps map[string]bool) (string, []string, error) {
seat, rest, _ := strings.Cut(placeholder, ":")
kind, whereText, _ := strings.Cut(rest, ":")
s, r, ok := receivable(seat, kind)
if !ok {
return "", nil, nil
}
if r.Shape != nil {
where, err := placeholderWhere(r, whereText)
if err != nil {
return "", nil, err
}
return shapedContributions(modules, holder, facts["name"], facts, s, r, where, caps)
}
var failed error
var unplacedLines []string
var blocks []textBlock
for _, m := range inModuleOrder(modules) {
var old strings.Builder
// A left-out module's lines are best effort (novox/hq ADR 0262): what cannot be placed is said,
// and the machine is declared without it. A placement setting that does not read is not
// replaced by the definition's own path, which may not be where the data is.
if m.bestEffort && r.Dirs {
if _, err := Places(m, with.Settings[m.Module]); err != nil {
unplacedLines = append(unplacedLines, fmt.Sprintf("%s's %s for %s, kept while it is left out, "+
"is not placed: its placement setting does not read (%v)", m.Module, kind, s.Name, err))
continue
}
}
for _, c := range m.allContributions() {
if c.Kind != kind || !capable(c, caps) {
continue
}
if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name {
continue
}
content := c.Content
var lineFailed error
if r.Dirs {
filled, err := dirFill(content, dirsFor(m, with), m.Module)
if err != nil && lineFailed == nil {
lineFailed = err
}
// An operator's path the module was given, as an item of data on it (novox/hq ADR 0233).
if accessRef.MatchString(filled) {
_, byID, err := accessesFor(m, with.Settings[m.Module])
if err == nil {
filled, err = accessFill(filled, byID, m.Module)
}
if err != nil && lineFailed == nil {
lineFailed = err
}
}
for _, key := range machineUsed(filled) {
value, has := facts[key]
if !has {
if lineFailed == nil {
lineFailed = fmt.Errorf("%s's %s for %s says ${machine:%s}, and this machine says %s",
m.Module, kind, s.Name, key, orNothing(namesOfFacts(facts)))
}
continue
}
filled = strings.ReplaceAll(filled, "${machine:"+key+"}", value)
}
content = filled
}
if lineFailed != nil {
if m.bestEffort {
unplacedLines = append(unplacedLines, fmt.Sprintf("%s's %s for %s, kept while it is left "+
"out, is not placed: %v", m.Module, kind, s.Name, lineFailed))
continue
}
if failed == nil {
failed = lineFailed
}
}
if old.Len() == 0 {
fmt.Fprintf(&old, "%s %s\n", r.Comment, m.Module)
}
old.WriteString(content)
if !strings.HasSuffix(content, "\n") {
old.WriteString("\n")
}
}
var held []heldItem
if !r.Dirs {
held = heldItems(m, "contribution:"+s.Name+":"+kind, nil, facts, caps)
}
blocks = append(blocks, moduleBlocks(m, r.Comment, old.String(), held)...)
}
return joinBlocks(blocks), unplacedLines, failed
}
@@ -0,0 +1,518 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
"math"
"regexp"
"sort"
"strings"
"text/template"
)
// A kind a seat receives as data, rendered by its holder (novox/hq ADR 0255).
//
// ADR 0212 made a contribution text in the tool's own grammar, which the controller places and never
// reads. That is right where the tool is the seat's for good — a hotkey daemon's trigger lines — and
// wrong where the seat's whole point is that its tool can be replaced: a module adding a battery to the
// bar would write i3status-rust's TOML, and a second bar would have to read it. So a seat may define a
// kind as data instead. The seat says the data's shape; a contributor gives data in that shape; the
// holder gives a template that renders one piece of it into its tool's grammar, as a module's facts
// template renders the roster (`facts`): the data is the mesh's, the format is the holder's.
//
// What the holder places is narrowed by the shape's placing fields — the bar's `bar` and `place` — in
// the placeholder, `${contribution:<seat>:<kind>:<field>=<value>,…}`, and every combination of them is
// placed by the holder exactly once, so nothing a module contributes is dropped by a holder that forgot
// a corner of the shape.
// Shape is what a kind received as data looks like.
type Shape struct {
// Places are the fields a holder narrows its placeholders by, each with every value it takes.
Places []PlaceField
// Order is the whole-number field that orders the pieces within one placeholder (lowest first,
// then module order); absent in a piece means OrderDefault.
Order string
// Check is what else is wrong with one piece's data, beyond its placing fields and its order.
Check func(data map[string]any) []string `json:"-"`
// Examples are pieces every holder's template must render, one for each variant the shape has,
// so a holder that cannot render one is refused at registration and not found on a machine.
Examples []map[string]any
// Reads is the state on the bus a piece shows, each a state and the one key of it, or nothing
// (novox/hq ADR 0260): a contributor offers only state it keeps itself, and the holder on the same
// machine is granted to read that key, so the holder's manifest names no contributor.
Reads func(data map[string]any) []StateRead `json:"-"`
// Text says every piece of this kind is, rendered, text that a collector may place as a collection of items
// (design 57 L3): a collector then names the kind as `${collected:contribution:<seat>:<kind>:<field>=<value>,…}`.
Text bool
}
// collectableAsText is whether a collector may name a kind received as data as a collection.
func collectableAsText(r Receivable) bool { return r.Shape != nil && r.Shape.Text }
// PlaceField is one field a holder places by, and the values it takes.
type PlaceField struct {
Field string
Values []string
}
// OrderDefault is a piece's order when it gives none: the middle of 0–99.
const OrderDefault = 50
// capabilityName is the shape of a capability's name, as a node's profile reports it.
var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// KnownCapabilities is every capability the node-engine detects (mesh-host internal/profile), the
// names an `if-capability` may give (novox/hq ADR 0255). A name nothing detects would leave its
// contribution out on every machine, silently, so it is refused. A detector added there is added here.
var KnownCapabilities = []string{
"battery", "container-runtime", "firewall", "graphical-session", "overlay", "package-manager",
"power-meter", "privileged", "seat", "service-manager", "uplink-dhcpcd", "uplink-networkmanager",
"uplink-systemd-networkd", "user-namespaces", "virtualisation",
}
// renderFuncs are the functions a holder's template may call. `quote` writes a value as a JSON
// string with DEL escaped as well — JSON leaves it bare, and TOML refuses it — which makes it a valid
// basic string in TOML and in most tools' grammars, so a contributed command with quotes or control
// characters in it cannot break out of the holder's line.
var renderFuncs = template.FuncMap{"quote": quote}
func quote(v any) (string, error) {
b, err := json.Marshal(fmt.Sprint(v))
return strings.ReplaceAll(string(b), "\x7f", `\u007f`), err
}
// shapedProblems is what is wrong with one contribution of a kind received as data.
func shapedProblems(module string, i int, r Receivable, c SeatContribution) []string {
var problems []string
at := fmt.Sprintf("%s's contribution %d to %s (%s)", module, i+1, c.Seat, c.Kind)
if strings.TrimSpace(c.Content) != "" {
problems = append(problems, at+" has content; this kind is data, given as `data` (novox/hq ADR 0255)")
}
if len(c.Data) == 0 {
return append(problems, at+" has no data")
}
for _, p := range r.Shape.Places {
v, _ := c.Data[p.Field].(string)
if !oneOf(p.Values, v) {
problems = append(problems, fmt.Sprintf("%s says %s %q; it is one of %s",
at, p.Field, fmt.Sprint(c.Data[p.Field]), strings.Join(p.Values, ", ")))
}
}
if r.Shape.Order != "" {
if v, has := c.Data[r.Shape.Order]; has {
if _, ok := wholeIn(v, 0, 99); !ok {
problems = append(problems, fmt.Sprintf("%s says %s %v; it is a whole number from 0 to 99",
at, r.Shape.Order, v))
}
}
}
if r.Shape.Check != nil {
for _, p := range r.Shape.Check(c.Data) {
problems = append(problems, at+": "+p)
}
}
return problems
}
// ownStateProblems is a data contribution showing state its module does not keep (novox/hq ADR 0255):
// a module offers the holder its own state and nobody else's, so what it publishes stays its own.
func ownStateProblems(m Manifest, i int, r Receivable, c SeatContribution) []string {
if r.Shape == nil || r.Shape.Reads == nil {
return nil
}
keeps := map[string]bool{}
for _, s := range m.State {
keeps[s.Name] = true
}
var problems []string
for _, sr := range r.Shape.Reads(c.Data) {
module, local, err := ReadState(sr.Read)
switch {
case err != nil:
problems = append(problems, fmt.Sprintf("%s's contribution %d shows state %v", m.Module, i+1, err))
case module != m.Module || !keeps[local]:
problems = append(problems, fmt.Sprintf("%s's contribution %d shows the state %s, which %s does not keep; a "+
"module offers its own state only (novox/hq ADR 0260)", m.Module, i+1, sr.Read, m.Module))
}
if sr.Key != "" && !stateKeyName.MatchString(sr.Key) {
problems = append(problems, fmt.Sprintf("%s's contribution %d shows the key %q; a key is lower-case letters, "+
"digits and hyphens", m.Module, i+1, sr.Key))
}
}
return problems
}
// StateRead is one key of another module's state that a holder reads: Read is `<module>.<name>`,
// and Key the one key, empty for the holder's own machine until ReadsGranted names it.
type StateRead struct {
Read, Key string
}
// stateKeyName is a key a piece may name: a machine's name is one.
var stateKeyName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// ReadsGranted is the state a holder reads because modules on its machine offer it in pieces of a
// kind its seat receives as data (novox/hq ADR 0260), each with the one key the piece shows — the
// machine's own when it names none — sorted and once each. The bus grants the holder these keys and
// no others: the holder names no contributor, and reads only what is offered to it where it runs.
func ReadsGranted(holder Manifest, onMachine []Manifest, machine string) []StateRead {
seen := map[StateRead]bool{}
for _, claim := range holder.Claims {
s, known := SeatNamed(claim.Name)
if !known {
continue
}
for _, r := range s.Receives {
if r.Shape == nil || r.Shape.Reads == nil {
continue
}
for _, m := range onMachine {
for _, c := range m.allContributions() {
if cs, ok := SeatNamed(c.Seat); !ok || cs.Name != s.Name || c.Kind != r.Kind {
continue
}
for _, sr := range r.Shape.Reads(c.Data) {
if sr.Key == "" {
sr.Key = machine
}
if module, _, err := ReadState(sr.Read); err == nil && module == m.Module && module != holder.Module &&
stateKeyName.MatchString(sr.Key) {
seen[sr] = true
}
}
}
}
}
}
out := make([]StateRead, 0, len(seen))
for sr := range seen {
out = append(out, sr)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Read != out[j].Read {
return out[i].Read < out[j].Read
}
return out[i].Key < out[j].Key
})
return out
}
// 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
}
// placeholderWhere parses the narrowing part of a placeholder, `bar=bottom,place=status`, against the
// kind's shape. Empty narrows nothing.
func placeholderWhere(r 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 *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
}
func placeFields(r Receivable) string {
var names []string
for _, p := range r.Shape.Places {
names = append(names, p.Field)
}
return strings.Join(names, ", ")
}
// holderTemplate is the holder's template for a kind, parsed.
func holderTemplate(holder Manifest, seat Seat, kind string) (*template.Template, error) {
for _, c := range holder.Claims {
if s, known := 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
}
// placedPiece is one contributed piece on its way into a holder's file.
type placedPiece struct {
module string
data map[string]any
order int
// text is a moved contributor's item, rendered (collections.go heldItems): placed as it is, not through the
// holder's template.
text string
item bool
}
// shapedContributions is every module's data of one kind to one seat, narrowed by where and by the
// machine's capabilities, ordered, and rendered through the holder's template — each piece under a
// comment line naming its module.
//
// A piece the template renders to nothing — a `shows` the holder does not know yet — or fails on is
// left out and answered as unplaced, naming its module: the other pieces and the rest of the machine's
// declaration go on (novox/hq ADR 0255).
func shapedContributions(modules []Manifest, holder Manifest, machine string, facts map[string]string, s Seat, r Receivable, where map[string]string, caps map[string]bool) (string, []string, error) {
var pieces []placedPiece
for _, m := range inModuleOrder(modules) {
for _, c := range m.allContributions() {
if c.Kind != r.Kind || !capable(c, caps) {
continue
}
if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name {
continue
}
match := true
for field, value := range where {
if v, _ := c.Data[field].(string); v != value {
match = false
}
}
if !match {
continue
}
order := OrderDefault
if v, ok := wholeIn(c.Data[r.Shape.Order], 0, 99); ok {
order = v
}
pieces = append(pieces, placedPiece{module: m.Module, data: c.Data, order: order})
}
// A moved contributor's items of the kind, by their attributes (design 57 L3, issue 777): each in its
// module's place, by its order, then its name.
if !r.Dirs {
for _, h := range heldItems(m, "contribution:"+s.Name+":"+r.Kind, where, facts, caps) {
pieces = append(pieces, placedPiece{module: m.Module, order: h.item.order(), text: h.text, item: true})
}
}
}
if len(pieces) == 0 {
return "", nil, nil
}
var t *template.Template
for _, p := range pieces {
if p.item {
continue
}
var err error
if t, err = holderTemplate(holder, s, r.Kind); err != nil {
return "", nil, err
}
break
}
sort.SliceStable(pieces, func(a, b int) bool { return pieces[a].order < pieces[b].order })
var b strings.Builder
var unplaced []string
for _, p := range pieces {
if p.item {
fmt.Fprintf(&b, "%s %s\n", r.Comment, p.module)
b.WriteString(p.text)
continue
}
out, err := renderPiece(t, p.module, machine, p.data)
if err == nil && strings.TrimSpace(out) == "" {
err = fmt.Errorf("%s's template renders nothing for it", holder.Module)
}
if err != nil {
unplaced = append(unplaced, fmt.Sprintf("%s's %s:%s %s is not placed by %s: %v",
p.module, s.Name, r.Kind, describePiece(p.data), holder.Module, err))
continue
}
fmt.Fprintf(&b, "%s %s\n", r.Comment, p.module)
b.WriteString(out)
if !strings.HasSuffix(out, "\n") {
b.WriteString("\n")
}
}
return b.String(), unplaced, nil
}
// describePiece is a piece in one line, its fields in order, for a person reading why it was left out.
func describePiece(data map[string]any) string {
var parts []string
for _, k := range sortedKeys(data) {
if _, nested := data[k].(map[string]any); nested {
continue
}
parts = append(parts, fmt.Sprintf("%s=%v", k, data[k]))
}
return "(" + strings.Join(parts, " ") + ")"
}
// capable is whether a contribution applies on a machine with these capabilities: always, unless it
// names one with `if-capability` the machine did not report (novox/hq ADR 0255). A machine whose
// capabilities are not known has none, so a conditional piece is left out rather than guessed in.
func capable(c SeatContribution, caps map[string]bool) bool {
return c.IfCapability == "" || caps[c.IfCapability]
}
// placementProblems is what is wrong with how a holder places the kinds its seats receive as data:
// once it places one at all, it needs a template that renders every example of the shape, and every combination of the placing
// fields placed exactly once across its files — or something contributed would be dropped, or written
// twice (novox/hq ADR 0255).
func (m Manifest) placementProblems() []string {
var problems []string
for _, c := range m.Claims {
s, known := SeatNamed(c.Name)
if !known {
continue
}
for _, r := range s.Receives {
// A holder that places none of the kind is older than it (novox/hq ADR 0255): refusing it
// would refuse every bar registered before the kind existed, and the controller that
// defines the kind ships first. Its contributions wait, unplaced, until it places them.
if r.Shape == nil || !placesKind(m, s, r.Kind) {
continue
}
t, err := holderTemplate(m, s, r.Kind)
if err != nil {
problems = append(problems, err.Error())
continue
}
for _, ex := range r.Shape.Examples {
if out, err := renderPiece(t, "example", "machine", ex); err != nil || strings.TrimSpace(out) == "" {
problems = append(problems, fmt.Sprintf("%s's template for %s:%s renders nothing for %v: %v",
m.Module, s.Name, r.Kind, ex, err))
}
}
placed := map[string]int{}
for _, res := range m.Resources {
content, _ := res["content"].(string)
for _, f := range ofContribution.FindAllStringSubmatch(content, -1) {
seat, rest, _ := strings.Cut(f[1], ":")
kind, whereText, _ := strings.Cut(rest, ":")
if cs, ok := SeatNamed(seat); !ok || cs.Name != s.Name || kind != r.Kind {
continue
}
where, err := placeholderWhere(r, whereText)
if err != nil {
continue // said by seatPlaceholderProblems
}
for _, combo := range combinations(r.Shape.Places) {
if covers(where, combo) {
placed[comboKey(combo)]++
}
}
}
}
for _, combo := range combinations(r.Shape.Places) {
switch n := placed[comboKey(combo)]; {
case n == 0:
problems = append(problems, fmt.Sprintf("%s never places %s:%s for %s; every combination of %s is "+
"placed once, or a contribution to it is lost (novox/hq ADR 0255)",
m.Module, s.Name, r.Kind, comboKey(combo), placeFields(r)))
case n > 1:
problems = append(problems, fmt.Sprintf("%s places %s:%s for %s %d times; once, or a contribution "+
"is written twice (novox/hq ADR 0255)", m.Module, s.Name, r.Kind, comboKey(combo), n))
}
}
}
}
return problems
}
// placesKind is whether any of a holder's files names the kind's placeholder.
func placesKind(m Manifest, s Seat, kind string) bool {
for _, res := range m.Resources {
content, _ := res["content"].(string)
for _, f := range ofContribution.FindAllStringSubmatch(content, -1) {
seat, rest, _ := strings.Cut(f[1], ":")
k, _, _ := strings.Cut(rest, ":")
if cs, ok := SeatNamed(seat); ok && cs.Name == s.Name && k == kind {
return true
}
}
}
return false
}
// combinations is every assignment of a value to each placing field.
func combinations(places []PlaceField) []map[string]string {
out := []map[string]string{{}}
for _, p := range places {
var next []map[string]string
for _, partial := range out {
for _, v := range p.Values {
c := map[string]string{p.Field: v}
for k, w := range partial {
c[k] = w
}
next = append(next, c)
}
}
out = next
}
return out
}
func covers(where, combo map[string]string) bool {
for field, value := range where {
if combo[field] != value {
return false
}
}
return true
}
func comboKey(combo map[string]string) string {
var parts []string
for _, k := range sortedKeys(combo) {
parts = append(parts, k+"="+combo[k])
}
return strings.Join(parts, ",")
}
@@ -0,0 +1,392 @@
package catalogue
import (
"fmt"
"sort"
"strings"
)
// A module depends on the node seats that apply its resources (novox/hq ADR 0207), on the
// seats it contributes to (novox/hq ADR 0210), and on the seats a seat it holds needs beside it
// (novox/hq ADR 0220). The third source once served only compiled seats and had one user,
// node-resolver-config needing node-uplink, which went with that seat when the resolver file became
// the uplink's own (novox/hq ADR 0223) — and now serves module-declared seats instead: mail-notifier
// needing node-notifier, and the like (novox/hq ADR 0378 §8).
//
// Some of what a module declares is applied through software on the machine that is itself a
// module: a service through the service manager, a package through the package manager, a container
// through the container runtime. A *capability* only says that software is installed; it does not
// say that a module of the mesh holds the role and answers for it. So the dependency is derived from
// the resources — never stated in a manifest, because a module that adds a service and forgets a
// field would pass — and is met when some module assigned to the same node holds the seat.
// The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation,
// the seed and the messages all turn on these strings.
const (
ServiceManagerSeat = "node-service-manager"
PackageManagerSeat = "node-package-manager"
ContainerRuntimeSeat = "node-container-runtime"
)
// appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207
// names them and no more. A process is supervised by the host itself, a file, a directory, an
// archive, a user or an action is the host's own act, and a module's other kinds reach the machine
// without a role in between — adding one here is a decision, not a refinement.
var appliedThrough = map[string]string{
"service": ServiceManagerSeat,
"package": PackageManagerSeat,
"container": ContainerRuntimeSeat,
}
// enforceSeatDependencies is the one-line switch ADR 0207 §4 names. Off, an unmet dependency at
// composition is *reported* — in the resolution, in `status`, once in the log — and the node still
// resolves; on, it is refused like any unresolved requirement. Off until `status` reports none,
// which is when the three holders are assigned to every node: switching it before then would stop
// every machine lacking one from being sent anything at all.
//
// Switched on 2026-10-04, when `status` first reported no unmet dependency on any node: systemd,
// pacman and docker were assigned to all four machines that afternoon (novox/hq to-be 42).
//
// A variable rather than a constant only so a test can hold both behaviours; nothing else sets it.
var enforceSeatDependencies = true
// EnforcingSeatDependencies sets the switch and returns what puts it back. For tests in other
// packages that hold the behaviour from before the switch; nothing else calls it.
func EnforcingSeatDependencies(on bool) (restore func()) {
was := enforceSeatDependencies
enforceSeatDependencies = on
return func() { enforceSeatDependencies = was }
}
// foundationModules are the pieces genesis lays before any module exists (novox/hq ADR 0207 §5):
// the host and the private network. Registered as modules so they can be assigned, but what they
// declare is the installation's, not a module's, so it is never judged. The third piece, the
// bootstrap container runtime, is not a module at all: its package and service are in the genesis
// bundle the host applies itself, and never pass through a resolution here.
//
// The private network's module is overlay.Name, written out because the overlay package composes
// on top of this one; its resources are computed, which exempts it by the rule below as well.
var foundationModules = map[string]bool{
"mesh-host": true,
"mesh-wireguard": true,
}
// isFoundation is whether a module's declarations are the foundation's rather than its own. A
// module whose resources are computed is the mesh's by construction — the private network's peer
// list and its tools are the controller's, written per node — so it counts whatever its name.
func isFoundation(m Manifest) bool {
return foundationModules[m.Module] || m.Computed != ""
}
// DependsOn is every seat a module needs held on its node: derived from the resource types it
// declares itself (novox/hq ADR 0207 §2), from what it contributes to (ADR 0210), and from what a
// node seat it claims needs beside it — read from whichever manifest in `catalogue` declares that
// seat (novox/hq ADR 0220, ADR 0378 §8). Sorted.
//
// **The module's own `resources` only**, for the first source. What the controller composes around
// a module — its filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put
// there because the module is assigned, and depending on it would make the module answer for the
// mesh's choices.
func DependsOn(catalogue map[string]Manifest, m Manifest) []string {
if isFoundation(m) {
return nil
}
seen := map[string]bool{}
for _, r := range m.Resources {
if seat, applied := appliedThrough[fmt.Sprint(r["type"])]; applied {
seen[seat] = true
}
}
for _, seat := range contributedTo(m) {
seen[seat] = true
}
for _, seat := range neededBesideClaims(catalogue, m) {
seen[seat] = true
}
out := make([]string, 0, len(seen))
for s := range seen {
out = append(out, s)
}
sort.Strings(out)
return out
}
// contributedTo is every seat a module contributes to (novox/hq ADR 0210 §3): a contribution is
// configuration only the seat's holder applies, so it is a dependency on that seat exactly as a
// resource is on the seat that applies it. The environment goes to node-environment's holder
// (ADR 0203); shell code to the holder that places it for its target — the login shell's for a
// shell, the display server's for the session's start and resources (ADR 0204, ADR 0208 §4).
func contributedTo(m Manifest) []string {
var out []string
if e := m.Environment; e != nil && (len(e.Variables) > 0 || len(e.Path) > 0) {
out = append(out, EnvironmentSeat)
}
for _, c := range m.Shell {
out = append(out, placerOf(c.For))
}
// Any other seat's contribution (novox/hq ADR 0212 §4), by the seat's canonical name; one the
// mesh does not define is refused at registration and depends on nothing here.
// A kind the seat only offers to place makes no dependency (novox/hq ADR 0255).
for _, c := range m.Contributions {
if s, r, ok := receivable(c.Seat, c.Kind); ok && r.Offered {
continue
} else if s.Name != "" {
out = append(out, s.Name)
}
}
// And the backup holder for what is irreplaceable (novox/hq ADR 0233): it backs it up or watches the
// array it is on. What is valuable is backed up where a holder is — the standard plan — and makes
// no machine refuse it for want of one.
if m.NeedsBackupHolder() {
out = append(out, BackupSeat)
}
return out
}
// neededBesideClaims is every seat a node seat the module claims needs held on the same node
// (novox/hq ADR 0220, ADR 0378 §8): a module-declared seat's needs are read from whichever manifest
// in the catalogue declares that seat, never written again in the claimant's own manifest. Derived
// from the claim, as a resource's seat is derived from its type, so a module that claims the seat
// cannot leave the dependency out. A compiled seat carries no needs (novox/hq ADR 0378 §8).
func neededBesideClaims(catalogue map[string]Manifest, m Manifest) []string {
var out []string
for _, seat := range nodeSeatsClaimed(m) {
for _, candidate := range catalogue {
for _, s := range candidate.DefinesSeats {
if s.Name == seat {
out = append(out, s.Needs...)
}
}
}
}
return out
}
// claimsSeat is whether a module claims a node seat, by its current name or one it used to have
// (ADR 0122), so a rename leaves the dependency met.
func claimsSeat(m Manifest, seat string) bool {
for _, c := range m.Claims {
if c.At() != ScopeNode {
continue
}
name := c.Name
if s, known := SeatNamed(name); known {
name = s.Name
}
if name == seat {
return true
}
}
return false
}
// PossibleHolders is every module in the catalogue that claims a seat at node scope — what a
// refusal names as the remedy.
func PossibleHolders(catalogue map[string]Manifest, seat string) []string {
var out []string
for name, m := range catalogue {
if claimsSeat(m, seat) {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// Unheld is one dependency of one module on a node that nothing on that node holds.
type Unheld struct {
Node string `json:"node"`
Module string `json:"module"`
Seat string `json:"seat"`
// Holders are the modules in the catalogue that could hold the seat: assigning one meets it.
Holders []string `json:"holders"`
}
// String is the line a refusal and a report both say, so the two never drift.
func (u Unheld) String() string {
remedy := "and no module in the catalogue claims it yet"
if len(u.Holders) > 0 {
remedy = "— assign one that holds it: " + strings.Join(u.Holders, ", ")
}
return fmt.Sprintf("%s on %s depends on %s, which nothing on %s holds (novox/hq ADR 0207) %s",
u.Module, u.Node, u.Seat, u.Node, remedy)
}
// UnheldDependencies is every dependency of the modules in `judged` that the node's whole set
// leaves unmet (novox/hq ADR 0207 §3).
//
// **Judged over the whole set, never one module at a time.** The holders depend on each other:
// the service manager's own package needs the package manager, and the package manager's timer
// needs the service manager. Asked one by one, neither could ever be first; asked of the set, the
// two assigned together meet each other. A module holding a seat it depends on meets its own
// dependency. `judged` nil judges every module of the set.
func UnheldDependencies(catalogue map[string]Manifest, node string, set []Manifest, judged map[string]bool) []Unheld {
held := map[string]bool{}
for _, m := range set {
for _, seat := range nodeSeatsClaimed(m) {
held[seat] = true
}
}
var out []Unheld
for _, m := range set {
if judged != nil && !judged[m.Module] {
continue
}
for _, seat := range DependsOn(catalogue, m) {
if held[seat] {
continue
}
out = append(out, Unheld{Node: node, Module: m.Module, Seat: seat,
Holders: PossibleHolders(catalogue, seat)})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Module != out[j].Module {
return out[i].Module < out[j].Module
}
return out[i].Seat < out[j].Seat
})
return out
}
// nodeSeatsClaimed is every node seat a module claims, each by its current name (ADR 0122), so
// a dependency on any of them — a resource's or a contribution's — is met by the claim.
func nodeSeatsClaimed(m Manifest) []string {
var out []string
for _, c := range m.Claims {
if c.At() != ScopeNode {
continue
}
name := c.Name
if s, known := SeatNamed(name); known {
name = s.Name
}
out = append(out, name)
}
return out
}
// manifestsOf is the catalogue's definitions of the named modules; a name the catalogue does not
// know contributes nothing, as it does to a resolution.
func manifestsOf(catalogue map[string]Manifest, names []string) []Manifest {
var out []Manifest
seen := map[string]bool{}
for _, n := range names {
if m, known := catalogue[n]; known && !seen[n] {
seen[n] = true
out = append(out, m)
}
}
return out
}
// AssignRefusal is why assigning `adding` to a node already running `assigned` is refused, or
// nothing (novox/hq ADR 0207 §4): each new module's dependency the node's assignments, the new ones
// included, leave unmet.
//
// **Only the new modules are judged.** A node already short of a holder is reported by `status`;
// refusing an unrelated assignment for it would make the remedy — assigning the holder — refused too.
//
// **A dependency nothing in the catalogue can meet is said, not refused.** A refusal names the
// module that would meet it; with none registered there is no remedy to name, and refusing would
// stop every assignment of that kind until a module that does not exist yet is written. The answer
// still says it, and `status` reports it — before the switch and after it alike: there is never a
// remedy to name for it. The first return is those lines.
func AssignRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) ([]string, error) {
set := manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))
judged := map[string]bool{}
for _, a := range adding {
judged[a] = true
}
var refused, said []string
for _, u := range UnheldDependencies(catalogue, node, set, judged) {
if len(u.Holders) == 0 {
said = append(said, u.String())
continue
}
refused = append(refused, u.String())
}
if len(refused) > 0 {
return said, &Refusal{Problems: append(refused,
fmt.Sprintf("holders that depend on each other are assigned together: `assign %s <module> <module>…`", node))}
}
return said, nil
}
// UnassignRefusal is why taking `removing` off a node running `assigned` is refused, or nothing
// (novox/hq ADR 0207): a seat the removed modules hold that nothing left on the node holds, while
// a module left there depends on it. Names the dependents, because they are what must go first —
// or the holder's replacement come.
func UnassignRefusal(catalogue map[string]Manifest, node string, assigned, removing []string) error {
gone := map[string]bool{}
for _, r := range removing {
gone[r] = true
}
var left []string
for _, a := range assigned {
if !gone[a] {
left = append(left, a)
}
}
before := map[string]bool{}
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, assigned), nil) {
before[u.Module+"\x00"+u.Seat] = true
}
dependents := map[string][]string{}
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, left), nil) {
if before[u.Module+"\x00"+u.Seat] {
continue // unmet already; not this removal's doing
}
dependents[u.Seat] = append(dependents[u.Seat], u.Module)
}
if len(dependents) == 0 {
return nil
}
seats := make([]string, 0, len(dependents))
for s := range dependents {
seats = append(seats, s)
}
sort.Strings(seats)
var problems []string
for _, s := range seats {
problems = append(problems, fmt.Sprintf(
"%s holds %s on %s, and %s depend on it (novox/hq ADR 0207) — unassign them with it, "+
"or assign another holder first", strings.Join(removing, ", "), s, node,
strings.Join(dependents[s], ", ")))
}
return &Refusal{Problems: problems}
}
// CollisionRefusal is why assigning `adding` beside `assigned` is refused for what two modules
// would both declare, or nothing (novox/hq ADR 0210 §1, 04-ISSUES/235): one path, unit or name, or a package one
// declares present and another absent. A package several modules declare present is no collision (ADR 0303).
//
// **Refused, not kept like an unresolved provision.** An assignment is otherwise kept when the
// node does not resolve, because assignment is not an ordering: a consumer's provider can follow.
// A collision is not an order anything can complete — no further assignment makes two owners of one
// file one owner — and kept, it leaves the node unresolvable, so the next push of anything drops
// it from the mesh. Only collisions involving a module being added are refused; one already on the
// node is `status`'s, and refusing an unrelated assignment for it would block its own remedy.
func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) error {
before := map[string]bool{}
for _, p := range checkResources(manifestsOf(catalogue, assigned)) {
before[p] = true
}
var problems []string
for _, p := range checkResources(manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))) {
if before[p] {
continue // on the node already; not this assignment's doing
}
if strings.Contains(p, "ADR 0303") {
problems = append(problems, p) // a package declared present and absent says its own rule
continue
}
problems = append(problems, p+" (novox/hq ADR 0210: one owner per node; the other module "+
"depends on the owner's seat instead)")
}
if len(problems) == 0 {
return nil
}
sort.Strings(problems)
return &Refusal{Problems: append(problems, fmt.Sprintf("nothing was assigned to %s", node))}
}
@@ -0,0 +1,222 @@
package catalogue
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// Telling a module where this machine put the holder of a seat.
//
// **The foundation's ports are the node's** (novox/hq ADR 0100): the port a foundation server was
// given at genesis becomes that node's setting for the module that serves it, and every reader
// follows the setting. Every consumer's binding did. The control plane's own connections did not
// (04-ISSUES/102): they are written at genesis, before any module exists to bind to — full
// connection strings, sealed, with the port inside — so when the node moved the store, the
// control plane went on dialling where genesis had written and the mesh was headless.
//
// The control plane cannot open its own sealed connection to move the port, and it cannot bind
// the store as a consumer would: a binding mints a credential, and what the control plane holds
// is the foundation's superuser, made before the mesh. What it can do is read the node's settings
// when it composes its own declaration — it is the thing that composes every other module's — and
// say in its own environment which port this machine put the store at.
//
// So a module may ask about a **seat** (ADR 0079: a foundation seat is named after the server it
// guards — `mesh-store`, `mesh-broker`). `${seat:mesh-store:5432}` is "the port this machine put
// the holder of the mesh-store seat's 5432 at". Not a provision: nothing is required, nothing is
// granted, no credential is minted. A seat is the mesh's own vocabulary for the store and the
// broker, which is what makes this the control plane's way of naming them and not a way for a
// module to reach a server it was not granted — the answer is a port number the mesh holds in the
// clear, and the credential to use it is still the module's own to have.
//
// **The answer may be empty, and that is the one place a placeholder answers with nothing.** The
// store and the broker are raised at genesis, before the mesh knows them as modules; a mesh raised
// on the catalogue's own ports never gives them a setting at all. In both, the port genesis wrote
// into the connection string is the right one, and the mesh has nothing to add. An empty answer
// says exactly that, and what reads it — the control plane's `_PORT` twin — treats an empty value
// as no value. Answering with the software's own port instead would override what genesis wrote
// with a number the mesh never checked, on the one machine where that is a headless mesh.
// ofSeat is where a module asks about a seat: ${seat:<seat>:<the port its holder's software uses>}.
var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`)
// **Where this machine reaches a mesh seat's holder** (novox/hq ADR 0222, issue 190).
//
// `${seat:<mesh-seat>:reach}` is host:port — the address this machine dials to reach whatever holds a
// seat the mesh holds once. The same reasoning as the port above, one step further: nothing is
// required, nothing is granted, no credential is minted, and the answer is an address the mesh
// already holds in the clear and composes into every reference it built. What it is for is a module
// that must *state* where a mesh service is to software it owns — the container runtime trusting
// the mesh's registry is the case — without becoming that service's consumer.
//
// Answered for the seats in reachedSeats only. Another seat is refused by name rather than answered
// with nothing: a module asking where something is that the mesh does not say would otherwise be
// given an empty value and never know the question was not understood.
//
// The answer may be empty: no machine on the private network holds the seat yet (genesis raises
// the store before the network). In a file written into as JSON, an empty member is dropped from
// its list, and a list left with none is dropped, so the runtime is never told to trust "".
var ofSeatReach = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):reach\}`)
// reachedSeats is every seat ${seat:…:reach} answers for.
var reachedSeats = map[string]bool{"mesh-artifact-store": true}
// seatInto replaces a resource's ${seat:…} placeholders with where this machine put each seat's
// holder — in a file's content, and in a value of a container's or a process's environment. The
// same places portInto fills, for the same reason: they are where a program reads a number from.
func seatInto(resource map[string]any, module string, with Rendering) error {
switch fmt.Sprint(resource["type"]) {
case "file":
content, ok := resource["content"].(string)
if !ok || (!ofSeat.MatchString(content) && !ofSeatReach.MatchString(content)) {
return nil
}
where := fmt.Sprintf("%s has a file that", module)
filled, err := seatsFilledInto(content, where, with)
if err != nil {
return err
}
if ofSeatReach.MatchString(filled) {
if filled, err = reachFilledInto(filled, where, with); err != nil {
return err
}
if fmt.Sprint(resource["into"]) == "json" {
if filled, err = withoutEmptyMembers(filled, where); err != nil {
return err
}
}
}
resource["content"] = filled
case "container", "process":
env, ok := resource["env"].(map[string]any)
if !ok {
return nil
}
named := make([]string, 0, len(env))
for key := range env {
named = append(named, key)
}
sort.Strings(named)
// A fresh map, and only when something changes — this map is the catalogue's, shared by
// every node running the module (see portInto).
var filled map[string]any
for _, key := range named {
written, ok := env[key].(string)
if !ok || (!ofSeat.MatchString(written) && !ofSeatReach.MatchString(written)) {
continue
}
where := fmt.Sprintf("%s's %s %s sets %s to something that",
module, resource["type"], resource["name"], key)
value, err := seatsFilledInto(written, where, with)
if err != nil {
return err
}
if value, err = reachFilledInto(value, where, with); err != nil {
return err
}
if filled == nil {
filled = map[string]any{}
for k, v := range env {
filled[k] = v
}
}
filled[key] = value
}
if filled != nil {
resource["env"] = filled
}
}
return nil
}
// seatsFilledInto answers every ${seat:…} in one written value.
//
// A port the seat's holder does not publish on this machine — or a seat nothing on it holds —
// answers with nothing, for the reason the package comment gives. A port that is not one is
// refused: it was written by a person and it is wrong.
func seatsFilledInto(written, where string, with Rendering) (string, error) {
for _, m := range ofSeat.FindAllStringSubmatch(written, -1) {
seat, port := m[1], m[2]
wanted, err := strconv.Atoi(port)
if err != nil || wanted < 1 || wanted > 65535 {
return "", fmt.Errorf("%s says ${seat:%s:%s}, and %s is not a port", where, seat, port, port)
}
answer := ""
if at, known := with.Seats[seat][wanted]; known {
answer = strconv.Itoa(at)
}
written = strings.ReplaceAll(written, m[0], answer)
}
return written, nil
}
// reachFilledInto answers every ${seat:…:reach} in one written value with where this machine
// reaches the seat's holder, or with nothing when no holder is reachable yet. A seat the mesh does
// not answer this for is refused by name.
func reachFilledInto(written, where string, with Rendering) (string, error) {
for _, m := range ofSeatReach.FindAllStringSubmatch(written, -1) {
seat := m[1]
if !reachedSeats[seat] {
known := make([]string, 0, len(reachedSeats))
for s := range reachedSeats {
known = append(known, s)
}
sort.Strings(known)
return "", fmt.Errorf("%s says ${seat:%s:reach}, and the mesh says where a seat's holder is "+
"reached only for %s (novox/hq ADR 0222)", where, seat, strings.Join(known, ", "))
}
written = strings.ReplaceAll(written, m[0], with.SeatReach[seat])
}
return written, nil
}
// withoutEmptyMembers drops every empty string from the lists at the top of a JSON object written
// into a machine's file, and a list left with no members, so an unanswered ${seat:…:reach} adds
// nothing to the machine's list rather than adding "".
func withoutEmptyMembers(content, where string) (string, error) {
var object map[string]json.RawMessage
if err := json.Unmarshal([]byte(content), &object); err != nil {
return "", fmt.Errorf("%s is written into as JSON and is not a JSON object: %w", where, err)
}
changed := false
for key, raw := range object {
var members []json.RawMessage
if err := json.Unmarshal(raw, &members); err != nil {
continue // not a list
}
kept := make([]json.RawMessage, 0, len(members))
for _, member := range members {
var s string
if json.Unmarshal(member, &s) == nil && s == "" {
continue
}
kept = append(kept, member)
}
if len(kept) == len(members) {
continue
}
changed = true
if len(kept) == 0 {
delete(object, key)
continue
}
list, err := json.Marshal(kept)
if err != nil {
return "", err
}
object[key] = list
}
if !changed {
return content, nil
}
out, err := json.Marshal(object)
if err != nil {
return "", err
}
return string(out) + "\n", nil
}
@@ -0,0 +1,85 @@
package catalogue
import (
"fmt"
"sort"
)
// An assignment as the passing of a seat reads it: a module on a machine, with its manifest.
type Assignment struct {
Node string
Manifest Manifest
}
// SeatPass is one seat whose holder on record is assigned nowhere, and the only assignment that can take it.
type SeatPass struct {
Seat Seat
From Held
To Held
}
// SeatsToPassOn reads the mesh for seats held once whose holder on record has left (novox/hq ADR 0449 §4).
//
// **A holder on record that is assigned on no machine holds nothing, and a seat that answers nothing is
// the day a delivery removed its module.** Of such a seat, exactly one assignment in the mesh able to hold
// it (it claims the seat at its scope and CanHold accepts it) takes it over: that is a pass, and the caller
// records it and writes the hand act. With no such assignment the record stays; with two or more nothing is
// recorded and the answer says so, naming both and the `seat --to` that settles it. A holder still assigned
// anywhere changes nothing: the other claimants stand beside it, eligible and silent (ADR 0131). Only mesh
// seats held once are read: a replicated seat's holders are added by an act (ADR 0223), a kinded bench is held
// by its claims, and a seat with several holders on record is a store that disagrees with the mesh (refused
// by the resolver).
func SeatsToPassOn(holdings []Held, assignments []Assignment) ([]SeatPass, []string) {
assigned := map[string]bool{}
for _, a := range assignments {
assigned[a.Manifest.Module] = true
}
bySeat := map[string][]Held{}
var order []string
for _, h := range holdings {
s, ok := SeatNamed(h.Claim)
if !ok || s.Replicated || s.Scope != ScopeMesh || h.Scope != ScopeMesh || KindedBenches[s.Name] {
continue
}
if _, seen := bySeat[s.Name]; !seen {
order = append(order, s.Name)
}
bySeat[s.Name] = append(bySeat[s.Name], h)
}
sort.Strings(order)
var passes []SeatPass
var said []string
for _, name := range order {
recs := bySeat[name]
if len(recs) != 1 || assigned[recs[0].Module] {
continue
}
seat, _ := SeatNamed(name)
var claimants []Held
for _, a := range assignments {
if CanHold(a.Manifest, seat) != nil {
continue
}
claimants = append(claimants, Held{Claim: seat.Name, Scope: seat.Scope, Node: a.Node, Module: a.Manifest.Module})
}
sort.Slice(claimants, func(i, j int) bool {
if claimants[i].Node != claimants[j].Node {
return claimants[i].Node < claimants[j].Node
}
return claimants[i].Module < claimants[j].Module
})
switch len(claimants) {
case 0:
case 1:
passes = append(passes, SeatPass{Seat: seat, From: recs[0], To: claimants[0]})
default:
said = append(said, fmt.Sprintf(
"%s on %s is on record as the holder of %q and is assigned nowhere, and %s on %s and %s on %s both claim it "+
"— nothing is recorded; `seat %s --to <node>/<module>` records the one that holds it",
recs[0].Module, recs[0].Node, seat.Name, claimants[0].Module, claimants[0].Node,
claimants[1].Module, claimants[1].Node, seat.Name))
}
}
return passes, said
}
@@ -0,0 +1,315 @@
package catalogue
import (
"fmt"
"path"
"strings"
)
// The paths a seat's holder owns (novox/hq ADR 0287).
//
// **Who owns a machine-wide file is the role's question, answered once.** Until this, which module owned
// /etc/hosts or /etc/resolv.conf was decided record by record (ADRs 0199, 0222, 0223, 0241) and enforced
// only after the fact, by checkResources finding two modules on one machine declaring the same path: a
// module that did not hold node-hostname could declare /etc/hosts on a machine where nobody held it, and
// nothing refused it. Now a seat's definition names the paths the role owns on any machine, and three
// refusals follow from it, each where the mesh can still say no:
//
// - a claim whose module does not declare every path its seat names, by CanHold, at registration and at
// handover;
// - a module declaring a path a seat names while claiming none of the seats that name it, at
// registration (CatalogueProblems), and what the controller generates for a machine, at composition;
// - a module's own seat naming a path another seat already names, at registration — the second loses.
//
// Reading a seat's path is access, never a resource, so it stays allowed.
// SeatPath is one path a seat's holder owns: absolute, literal, on the machine where the holder runs.
type SeatPath struct {
Path string `json:"path"`
// StepsBackFor is a node seat whose holder owns the path instead, on a machine where that seat is held
// (novox/hq ADR 0287, ADR 0247): this seat's holder still declares the path, and its declaration is not
// composed there. Only the mesh's own seats step back, each by a record.
StepsBackFor string `json:"steps-back-for,omitempty"`
}
// seatPathProblem is what is wrong with one path a seat names, judged alone: absolute, written literally
// with no placeholder, and clean, so it compares with what a module declares byte for byte.
func seatPathProblem(seat, scope, p string) string {
switch {
case scope != ScopeNode:
return fmt.Sprintf("the seat %s names the path %q and is a %s seat; only a node seat names paths, "+
"because a path is a machine's (novox/hq ADR 0287)", seat, p, scope)
case strings.Contains(p, "${"):
return fmt.Sprintf("the seat %s names the path %q, which has a placeholder; a seat's path is written "+
"literally (novox/hq ADR 0287)", seat, p)
case !strings.HasPrefix(p, "/") || path.Clean(p) != p:
return fmt.Sprintf("the seat %s names the path %q, which is not a clean absolute path (novox/hq ADR 0287)",
seat, p)
case path.Dir(p) == "/":
// The root or a top-level directory is every role's, never one role's own (ADR 0287).
return fmt.Sprintf("the seat %s names the path %q, the root or a top-level directory, which no one "+
"role owns (novox/hq ADR 0287)", seat, p)
}
return ""
}
// SeatPathProblems is what is wrong with the paths a set of the mesh's own seats names (novox/hq ADR
// 0287): only a node seat names one, each absolute and literal; every path has exactly one seat naming it
// without stepping back; and a seat steps back only for another node seat naming the same path without
// stepping back, so the stepping back cannot cycle.
func SeatPathProblems(set []Seat) []string {
var problems []string
owners := map[string][]string{} // path → the seats naming it without stepping back
stepping := map[string][]SeatPath{} // seat → the paths it names stepping back
byName := map[string]Seat{}
for _, s := range set {
byName[s.Name] = s
for _, p := range s.Paths {
if why := seatPathProblem(s.Name, s.Scope, p.Path); why != "" {
problems = append(problems, why)
}
if p.StepsBackFor == "" {
owners[p.Path] = append(owners[p.Path], s.Name)
} else {
stepping[s.Name] = append(stepping[s.Name], p)
}
}
}
for _, s := range set {
for _, p := range stepping[s.Name] {
other, known := byName[p.StepsBackFor]
switch {
case !known || other.Scope != ScopeNode:
problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %q, which is not a "+
"node seat of the mesh's (novox/hq ADR 0287)", s.Name, p.Path, p.StepsBackFor))
case !namesWithoutSteppingBack(other, p.Path):
problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %s, which does not "+
"own that path itself; a seat steps back only for the one that owns it (novox/hq ADR 0287)",
s.Name, p.Path, other.Name))
}
}
}
for _, p := range sortedKeys(owners) {
if len(owners[p]) > 1 {
problems = append(problems, fmt.Sprintf("the path %q is named by %s, each without stepping back; "+
"no two seats name one path (novox/hq ADR 0287)", p, strings.Join(owners[p], " and ")))
}
}
for _, s := range set {
for _, p := range stepping[s.Name] {
if len(owners[p.Path]) == 0 {
problems = append(problems, fmt.Sprintf("the path %q has no seat naming it without stepping back "+
"(novox/hq ADR 0287)", p.Path))
}
}
}
return problems
}
func namesWithoutSteppingBack(s Seat, p string) bool {
for _, sp := range s.Paths {
if sp.Path == p && sp.StepsBackFor == "" {
return true
}
}
return false
}
// declaredPaths is every machine path a module declares it owns: a resource's literal path, and a file
// the mesh renders for it outside the operator's home. An access is not here — reading is not owning.
//
// **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 Manifest) map[string]bool {
out := map[string]bool{}
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
}
// declaresPath says whether a module declares one path, as a resource or as a file the mesh renders.
func declaresPath(m Manifest, p string) bool { return declaredPaths(m)[p] }
// missingPaths is each path a seat names that a module does not declare, in the seat's order.
func missingPaths(m Manifest, paths []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
}
// 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 Shelf, declaredBy map[string]string) (map[string][]string, []string) {
named := map[string][]string{}
for _, s := range 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
}
// 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 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
}
// declaredSeatPathProblems is what one module's own seat declaration can be judged on alone: a path
// only on a node seat, absolute, literal and clean.
func declaredSeatPathProblems(m Manifest, s SeatDeclaration) []string {
var problems []string
for _, p := range s.Paths {
if why := seatPathProblem(s.Name, s.At(), p); why != "" {
problems = append(problems, m.Module+": "+why)
}
}
return problems
}
// asSeatPaths is a module's own seat's paths in the shape the mesh's seats name theirs. A module's seat
// never steps back: only the mesh's own seats do, each by a record.
func asSeatPaths(paths []string) []SeatPath {
out := make([]SeatPath, 0, len(paths))
for _, p := range paths {
out = append(out, SeatPath{Path: p})
}
return out
}
// PathOwner is the module that owns one seat's path on one machine (novox/hq ADR 0287): the seat naming
// the path, and that seat's holder there, with the stepping back applied.
type PathOwner struct {
Path string `json:"path"`
Node string `json:"node"`
Seat string `json:"seat"`
Module string `json:"module"`
}
// PathOwners is, for every path a seat in the set names and every machine where a seat naming it is held,
// who owns the path there. A seat that steps back for another held on the same machine is passed over.
func PathOwners(set []Seat, held []Held) []PathOwner {
// node → seat → holding module
holders := map[string]map[string]string{}
for _, h := range held {
s, ok := SeatNamed(h.Claim)
if !ok || h.Scope != ScopeNode || s.Scope != ScopeNode {
continue
}
if holders[h.Node] == nil {
holders[h.Node] = map[string]string{}
}
if _, taken := holders[h.Node][s.Name]; !taken {
holders[h.Node][s.Name] = h.Module
}
}
naming := map[string][]Seat{} // path → the seats naming it
steps := map[string]map[string]string{} // path → seat → the seat it steps back for
for _, s := range set {
for _, p := range s.Paths {
naming[p.Path] = append(naming[p.Path], s)
if p.StepsBackFor != "" {
if steps[p.Path] == nil {
steps[p.Path] = map[string]string{}
}
steps[p.Path][s.Name] = p.StepsBackFor
}
}
}
var out []PathOwner
for _, p := range sortedKeys(naming) {
for _, node := range sortedKeys(holders) {
for _, s := range naming[p] {
module, holds := holders[node][s.Name]
if !holds {
continue
}
if forSeat := steps[p][s.Name]; forSeat != "" {
if _, there := holders[node][forSeat]; there {
continue
}
}
out = append(out, PathOwner{Path: p, Node: node, Seat: s.Name, Module: module})
break
}
}
}
return out
}
// seatPathsOnMachine is each path the modules of one machine declare that a seat names, for a module
// claiming none of the seats naming it (novox/hq ADR 0287). Registration refuses the same of a manifest;
// this holds what the controller generates for a machine to it too, because a computed module's resources
// exist only once composed (checkResources is asked them there). The seats are the mesh's own and those the
// machine's modules declare.
func seatPathsOnMachine(modules []Manifest) []string {
shelf := Shelf{}
var order []string
for _, m := range modules {
shelf[m.Module] = m
order = append(order, m.Module)
}
named, _ := pathSeats(order, shelf, nil)
var problems []string
for _, m := range modules {
problems = append(problems, unclaimedSeatPaths(m, named)...)
}
return problems
}
+951
View File
@@ -0,0 +1,951 @@
package catalogue
import (
"fmt"
"sort"
"strings"
)
// The seats a mesh can have (novox/hq ADR 0110).
//
// **A closed set, defined here rather than by whoever claims one.** Until this, a well-formed name
// became a seat by being claimed, so nothing could say which seats a mesh has or who fills them:
// `the-showcase` and `the-build-machine` were each invented by the module claiming it. The set is
// what a person reads to learn what a mesh can have, so an entry nobody argued for is an entry
// nobody can explain — the same reason every shape in the host's vocabulary names its decision.
//
// A seat is held by a module assignment. What the mesh knows about a holder is what it knows about
// that assignment; nothing about holders is kept here or anywhere else.
// Seat is one role the mesh defines.
type Seat struct {
// Name is what a manifest claims.
Name string
// Scope is where there may be only one holder — unless the seat is Replicated.
Scope string
// Replicated says a mesh seat may be held on several machines at once, each holder answering the
// same thing (novox/hq ADR 0223): the mesh's resolver, held on the anchor and the home server so a
// machine's resolver file lists two that give one answer. Each holder is on record, added by an
// act (`seat <name> --add <node>/<module>`), never by being assigned: two claimants with nothing on
// record are refused exactly as for any mesh seat. One per machine still — two modules on one
// node claiming it are refused. Compiled, never stored, like Receives: it is the mesh's definition of
// the role, and the store's rows carry no column for it.
Replicated bool
// Delivers is the provision the seat's holder answers for, or empty. A seat that delivers a
// provision may only be held by a module providing it at the seat's scope, and its holder is
// what a requirement for that provision resolves to when several modules provide it.
Delivers string
// Accepts, Emits and Serves are the protocol of the role, as local verbs — the same three a
// module declares for a seat of its own (novox/hq ADR 0118), and empty for most of these: a seat
// is usually about who does a job and not about what may be said to them.
//
// **Named here so the mesh has no role it cannot describe** (ADR 0121). Without them a build
// machine had three audiences for one outcome and nothing derived a grant for any of them, and an
// event about a role had nowhere to live but the namespace of whichever module held that role
// today — which the bus refuses, because a namespace belongs to who it is named for.
Accepts []string
Emits []string
// Serves carries each verb in full — name, description, schema — because a role's tools are the
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
Serves []Verb
// Receives is what other modules may contribute to the seat's holder, by kind (novox/hq ADR
// 0212): each kind is text in the tool's own grammar, placed by the holder with
// ${contribution:<seat>:<kind>}. Compiled, never stored: like the protocol, it is the mesh's
// definition of the role, and the store's rows carry no column for it.
Receives []Receivable
// Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287): a holder declares
// every one, and a module claiming no seat that names a path may not declare it. Only a node seat
// names paths. Compiled, never stored, like Receives: adding or taking away a path is a decision,
// recorded, and the store's rows carry no column for it.
Paths []SeatPath
// Decision is the record that made it a seat.
Decision string
}
// Receivable is one kind of contribution a seat receives (novox/hq ADR 0212 §2): its name, and the
// comment prefix of the tool's grammar, with which the controller names each contributing module.
type Receivable struct {
Kind string
Comment string
// Dirs says a contribution of this kind may name its contributor's own directories as
// `${dir:<id>}`, filled with where they are on the machine before the holder places it (novox/hq
// to-be 43): a module saying which of its data to back up names a directory the mesh placed, and
// only the mesh knows where. Off for every kind written in a tool's grammar that has its own
// `${…}` — a shell's — where the mesh filling one would change what the tool reads.
Dirs bool
// Shape, when set, makes the kind data rather than text (novox/hq ADR 0255): a contributor gives
// `data` in this shape, and the holder renders each piece with the template its claim gives.
Shape *Shape
// Offered says a contribution of this kind does not depend on the seat (novox/hq ADR 0255): where
// nobody holds it nothing is placed, and the contributor runs there all the same. A battery's bar
// block is offered — the power module is on every machine, and a server has no bar.
Offered bool
}
// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the
// fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is
// written; the store's table is seeded from it and thereafter is the live, editable copy.
//
// In the order a person reads it: the mesh's own, then a node's.
var defaultSeats = append([]Seat{
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
// so no work queue is raised for it — only what its holder may say.
// And it serves the mesh's own verbs as the seat's tools (novox/hq ADR 0154): `status`, `push`,
// `assign` and the rest are a role's interface, not a container's, and stay addressable while
// the control plane is replaced.
{Name: ControllerSeatName, Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
// And what is wrong, as it changes, and the self-check's heartbeat (novox/hq to-be 45 §2, §4).
Emits: []string{"applied", "refused", "built-before",
"condition-raised", "condition-changed", "condition-cleared", "doctor-heartbeat",
// A value given by hand, replaced after its module's first good start (novox/hq ADR 0228).
"secret-replaced",
// Every act a healer takes (novox/hq to-be 45 §7).
"healer-acted",
// A build put back after its gate failed (novox/hq ADR 0236, to-be 45 §8).
"rolled-back",
// A pull request's merge check, judged (novox/hq to-be 45 §9).
"checked",
// A walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
"plan-moved",
// A tier sent to a machine, once the send is kept (novox/hq ADR 0406 §5, issue 974).
"sent",
// A key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3).
"assignment-changed", "declared-config-changed", "predefined-changed"},
Serves: ControllerVerbs},
// 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: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079",
Serves: []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: BrokerSeat, Scope: 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: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161",
Serves: []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 alone: 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)},
{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"}}}},
}},
// 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: ScopeMesh, Decision: "novox/hq ADR 0348",
Serves: []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. A trusted value only " +
"from the controller's terminal.",
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: ScopeMesh, Decision: "novox/hq ADR 0349",
Serves: []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: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
{Name: "mesh-catalog", Scope: 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: DeliverySeat, Scope: ScopeMesh, Serves: deliveryVerbs(), Decision: "novox/hq ADR 0239"},
// 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: ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"},
{Name: "git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"},
// A build is work submitted to this role and its outcome is the role's own event (ADR 0129).
// One publish reaches whoever asked, the controller that records it, and the catalogue that
// places it in the graph — what the old bus's shared exchange did for free.
// A build says what it does as it does it (novox/hq ADR 0157): `started` when work is taken,
// `log.<build id>` for every line, `built` for the outcome. The log's tail token is the build's
// id, so a reader follows one build by subject alone.
// **Node-scoped, and every holder takes from one queue** (novox/hq ADR 0190): a build is asked of
// the role, and whichever machine holding the seat is idle pulls it. One holder per machine is
// what the scope says; sharing the work is what a seat's queue has always done.
//
// **And its holder answers for the build it is running** (novox/hq ADR 0219): what it is
// building, kill it, take nothing new, take again. The queue as a whole is the controller's to
// show and change (`queue`, `cancel`, `clear`); what one machine does with an ask it already
// took only that machine can do. `paused.<node>` is each holder saying whether it takes work, so
// the controller can tell a plan waiting on a paused seat from one that is late.
{Name: "node-build-agent", Scope: ScopeNode,
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*", "paused.*"},
Serves: buildAgentVerbs(),
Decision: "novox/hq ADR 0190, ADR 0219"},
// **Retired by ADR 0190, kept while a manifest still claims it.** The one build machine's seat.
// A claim to a seat the mesh no longer defines is refused, and the module holding this one is
// assigned on a live machine until build-agent replaces it — removing the row first would make
// that machine unresolvable in the meantime. Deleted once no registered manifest claims it.
{Name: "mesh-build-machine", Scope: ScopeMesh,
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0190"},
// **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: 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: 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: []SeatPath{{Path: "/etc/hosts"}, {Path: "/etc/hostname"}},
Serves: []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: ScopeNode, Decision: "novox/hq ADR 0121",
Serves: []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: ScopeNode, Decision: "novox/hq ADR 0121",
Serves: []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: ServiceManagerSeat, Scope: 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: PackageManagerSeat, Scope: 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: ContainerRuntimeSeat, Scope: 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: EnvironmentSeat, Scope: 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: 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: LoginShellSeat, Scope: 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: PowerSeat, Scope: 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: HotkeysSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0212",
Receives: []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: MessageBusSeat, Scope: 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: BackupSeat, Scope: 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: []Receivable{{Kind: BackupKindBackup, Comment: "#", Dirs: true}, {Kind: 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: 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: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs(),
Paths: []SeatPath{{Path: ResolverFile, StepsBackFor: 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: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs(),
Paths: []SeatPath{{Path: 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: NFSServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()},
{Name: MountsSeat, Scope: 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: ReadsSeat, Scope: 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: FilesSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()},
},
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
graphicalSessionSeats()...)
// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for
// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused;
// any other name is a module's own to define and claim. Some of the mesh's own seats predate this
// convention and are not yet renamed (git, npm-package-registry, the-private-network) — those are
// in the set, so they resolve by name, not by prefix. the-artifact-store was renamed on 2026-09-30
// (novox/hq ADR 0156) and resolves through the alias table on a mesh that knew it.
func isSystemSeatName(name string) bool {
return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-")
}
// seats is the working set the lookups read. It starts as the compiled defaults and is replaced by
// what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a
// change to data, not to this code.
var seats = defaultSeats
// 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
}
// DefaultSeats is the set the mesh ships with, for seeding the store's seat table.
func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) }
// UseSeats replaces the working set with the one the control plane read from its store.
//
// **Empty is ignored on purpose.** A store that has not been seeded yet — or one that could not be
// read — must leave the compiled defaults in force rather than emptying the set: an empty set would
// refuse every claim and could stop the control plane composing at all, which is a far worse failure
// than running on the set the binary shipped with. So the store can only ever *replace* the set with
// a non-empty one, never erase it.
func UseSeats(s []Seat) {
if len(s) == 0 {
return
}
// **The store's rows carry no protocol yet, and the protocol is what the bus is derived
// from.** ADR 0129 gives a seat what it accepts, emits and serves; ADR 0122 moved the set into
// a table that has name, scope, delivers and decision and nothing else, and the columns for
// the rest are not there yet. So a row replacing a compiled entry would silently drop the
// protocol, and the roles' work queues would never be raised — found live as "no response
// from stream" the first time a build was submitted over the new bus (2026-09-28). Until the
// table gains the columns, a row without a protocol keeps the compiled one of the same name.
byName := map[string]Seat{}
for _, d := range defaultSeats {
byName[d.Name] = d
}
merged := make([]Seat, 0, len(s))
for _, row := range s {
if len(row.Accepts)+len(row.Emits)+len(row.Serves) == 0 {
if d, known := byName[row.Name]; known {
row.Accepts, row.Emits, row.Serves = d.Accepts, d.Emits, d.Serves
}
}
// What a seat receives, whether it is replicated and the paths it names are never stored
// (novox/hq ADR 0212, ADR 0223, ADR 0287), so they are always the compiled ones.
if d, known := byName[row.Name]; known {
row.Receives = d.Receives
row.Paths = d.Paths
row.Replicated = d.Replicated
row.Serves = optionalAsCompiled(row.Serves, d.Serves)
}
merged = append(merged, row)
}
seats = merged
}
// optionalAsCompiled is a row's verbs with each one the compiled seat marks optional marked so. The mark
// is never stored (Verb.Optional), and a row is what the working set holds once the store is read: a verb
// the seeding added to the row — `failed`, `checks` — would otherwise come back required, and every holder
// not serving it yet would be refused at registration and at handover, which the mark exists to prevent.
//
// **And what a verb replaces is the compiled one** (novox/hq ADR 0245): a row seeded before the field
// existed carries none, and re-seeding widens a verb's arguments only, so read from the row alone the
// `tools` answer — what the mesh MCP server's search and the agent's instructions are built from — would
// say no verb replaces anything on a mesh whose rows exist. The mesh's statement of what a role replaces
// is this binary's, like the optional mark.
func optionalAsCompiled(row, compiled []Verb) []Verb {
optional := map[string]bool{}
replaces := map[string][]string{}
for _, v := range compiled {
if v.Optional {
optional[v.Name] = true
}
if len(v.Replaces) > 0 {
replaces[v.Name] = v.Replaces
}
}
if len(optional) == 0 && len(replaces) == 0 {
return row
}
out := make([]Verb, len(row))
for i, v := range row {
v.Optional = optional[v.Name]
if r, ok := replaces[v.Name]; ok {
v.Replaces = r
}
out[i] = v
}
return out
}
// aliases maps a seat's former names to its current canonical name (novox/hq ADR 0122). Loaded from
// the store alongside the set, so a reference to a name a seat used to have — a manifest's claim, a
// held record — still resolves to it after a rename, and nothing downstream has to change.
var aliases = map[string]string{}
// UseAliases replaces the former-name map with the one the control plane read from its store. Empty
// is fine and ordinary: a mesh whose seats have never been renamed has no aliases.
func UseAliases(m map[string]string) { aliases = m }
// Seats is every seat the mesh defines, in reading order.
func Seats() []Seat {
return append([]Seat(nil), seats...)
}
// SeatNamed is the seat a name refers to, whether that is its current name or one it used to have
// (novox/hq ADR 0122). A former name resolves to the seat's canonical row, so a rename breaks no
// reference to the old name.
func SeatNamed(name string) (Seat, bool) {
for _, s := range seats {
if s.Name == name {
return s, true
}
}
if canonical, aliased := aliases[name]; aliased {
for _, s := range seats {
if s.Name == canonical {
return s, true
}
}
}
return Seat{}, false
}
// SeatDelivering is the seat whose holder answers for a provision, if there is one.
func SeatDelivering(provision string) (Seat, bool) {
if provision == "" {
return Seat{}, false
}
for _, s := range seats {
if s.Delivers == provision {
return s, true
}
}
return Seat{}, false
}
// claimProblems is what is wrong with a manifest's claims and the seats it defines.
//
// A claim is one of three things (novox/hq ADR 0121): a **system seat** the control plane defines —
// checked for scope and, if it delivers a provision, that the claimant provides it; a **system name
// the mesh does not define** (`mesh-*`/`node-*`) — refused, because that namespace is the control
// plane's; or a **module-defined seat** — valid only when this manifest also declares it, since a
// module may coordinate its own instances through a seat of its own but may not invent one by
// claiming it. A module's own seat declaration may not sit in the system namespace or shadow a
// system seat.
func claimProblems(m Manifest) []string {
var problems []string
defined := map[string]SeatDeclaration{}
for _, d := range m.DefinesSeats {
if _, isSystem := SeatNamed(d.Name); isSystem || isSystemSeatName(d.Name) {
problems = append(problems, fmt.Sprintf(
"%s defines a seat %q in the mesh's own namespace; a module's seat is named outside "+
"mesh-*/node-* (novox/hq ADR 0121)", m.Module, d.Name))
continue
}
defined[d.Name] = d
}
for _, c := range m.Claims {
if _, known := SeatNamed(c.Name); known {
// **A seat's scope and what it delivers are not judged here** (novox/hq ADR 0122).
// This function runs wherever a manifest is parsed, and one of those places is the
// build machine, which has no store: there, `SeatNamed` answers from the set the
// binary shipped with, so a build would be refused for disagreeing with a compiled
// copy of data the control plane owns. Exactly that happened — a holder of the bus
// seat was refused for not providing what a stale compiled row said the seat
// delivered, while the store's own row said otherwise.
//
// Both checks moved to CatalogueProblems, which only ever runs in the control plane,
// after UseSeats has replaced the set with the store's.
continue
}
if isSystemSeatName(c.Name) {
problems = append(problems, fmt.Sprintf(
"%s claims %q, which is a seat in the mesh's own namespace (mesh-*/node-*) that it "+
"does not define (novox/hq ADR 0121) — the seats are: %s", m.Module, c.Name, seatNames()))
continue
}
d, ours := defined[c.Name]
if !ours {
// **A claim on a seat this manifest does not declare is not the parser's to judge.**
// A module may hold a seat another module declared — that is why ADR 0126 has callers
// name the seat and not its provider, so an implementation can be replaced without
// touching a caller. Whether the seat exists is a fact about the whole catalogue, so
// the refusal is at registration, where every declaration is in view
// (`CatalogueProblems`: "which no module declares and the mesh does not define").
continue
}
if c.At() != d.At() {
problems = append(problems, fmt.Sprintf(
"%s claims its own seat %s at scope %q, having declared it at %q",
m.Module, c.Name, c.At(), d.At()))
}
}
return problems
}
// CanHold is why a module could not hold a seat, or nothing: its definition must claim the seat at
// the seat's scope, and provide what the seat delivers, if it delivers anything. The seat is the
// store's row, so this is judged only where the store's set is loaded — at registration and in the
// handover command (novox/hq ADR 0131), never in the parser.
func CanHold(m Manifest, seat Seat) error {
var claimed *Claim
for i := range m.Claims {
if hs, ok := SeatNamed(m.Claims[i].Name); ok && hs.Name == seat.Name {
claimed = &m.Claims[i]
}
}
if claimed == nil {
return fmt.Errorf("%s does not claim %s", m.Module, seat.Name)
}
if claimed.At() != seat.Scope {
return fmt.Errorf("%s claims %s at scope %q, and %s is a %s seat",
m.Module, seat.Name, claimed.At(), seat.Name, seat.Scope)
}
if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) {
return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)
}
// **A holder owns what the role owns** (novox/hq ADR 0287): every path the seat names, declared as
// a resource or as a file the mesh renders for it, so which module owns /etc/hosts on a machine is
// answered by the seat's definition and not by reading every manifest.
if missing := missingPaths(m, seat.Paths); len(missing) > 0 {
return fmt.Errorf("%s claims %s but does not declare %s, which that seat's holder owns (novox/hq "+
"ADR 0287) — a holder declares every path its seat names, as a resource or as a file the mesh "+
"renders for it", m.Module, seat.Name, strings.Join(missing, ", "))
}
// **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that
// does not answer what the role promises is every caller's timeout, found at registration and
// at handover instead, naming the verbs rather than the fact that something is missing.
if missing := unservedVerbs(claimed.ServesFor(m), seat.Serves); len(missing) > 0 {
return fmt.Errorf("%s claims %s but does not serve %s, which that seat's protocol promises "+
"(novox/hq ADR 0132) — a holder names every verb its seat declares, under the claim's "+
"serves or among its own tools",
m.Module, seat.Name, strings.Join(missing, ", "))
}
// And nothing the seat does not promise: a verb named here that the protocol lacks is served
// to nobody, which is a typo the holder would otherwise discover as a caller's timeout.
if extra := unpromised(claimed.Serves, seat.Serves); len(extra) > 0 {
return fmt.Errorf("%s claims %s and says it serves %s, which that seat's protocol does not "+
"promise — a claim's serves names the seat's verbs and nothing else",
m.Module, seat.Name, strings.Join(extra, ", "))
}
return nil
}
func providesAt(m Manifest, provision, scope string) bool {
for _, o := range m.Provides {
if o.Name == provision && o.At() == scope {
return true
}
}
return false
}
func seatNames() string {
names := make([]string, 0, len(seats))
for _, s := range seats {
names = append(names, s.Name)
}
sort.Strings(names)
return strings.Join(names, ", ")
}
// HolderAmong is which of several providers of a provision holds the seat that delivers it.
//
// Found by the (node, module) pair, because a provider is identified by both (novox/hq to-be 23):
// two modules on one node could both provide a provision, and only the one holding the seat
// answers for it. Nothing when no seat delivers the provision, when nobody holds
// it, or when the holder is not among the providers offered.
//
// **The first holder in the providers' own order** (novox/hq ADR 0223). A replicated seat has
// several, and the answer must not depend on the order the mesh happened to resolve its machines
// in: the providers come sorted by machine, so every consumer is bound to the same one. A seat with
// one holder gets the same answer as before.
func HolderAmong(provision string, providers []Provider, held []Held) (Provider, bool) {
seat, delivered := SeatDelivering(provision)
if !delivered {
return Provider{}, false
}
for _, p := range providers {
for _, h := range held {
// Resolve the held claim to a seat rather than comparing names, so a record naming a
// seat's former name still matches it after a rename (novox/hq ADR 0122).
hs, ok := SeatNamed(h.Claim)
if !ok || hs.Name != seat.Name || h.Scope != seat.Scope {
continue
}
if p.Node == h.Node && p.Module == h.Module {
return p, true
}
}
}
return Provider{}, false
}
// SeatsWithAProtocol are the mesh's own seats that say something about what may be said to them or by
// them, which is the set the bus derives streams, consumers and permissions from.
//
// Most of the set is not here, and that is the ordinary case: a seat saying only who does a job grants
// nothing on the bus and needs no queue.
func SeatsWithAProtocol() []Seat {
var out []Seat
for _, s := range seats {
if len(s.Accepts) > 0 || len(s.Emits) > 0 || len(s.Serves) > 0 {
out = append(out, s)
}
}
return out
}
// 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() []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 []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>"}},
}
}
// 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() []Verb {
return []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},
}
}
// 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() []Verb {
return []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"}},
}
}
// 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() []Verb {
return []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"})},
}
}
// buildAgentVerbs are what a holder of node-build-agent answers on its own machine (novox/hq ADR
// 0219). One build at a time per holder (ADR 0190), so "the build running here" is one or none.
func buildAgentVerbs() []Verb {
return []Verb{
{Name: "current", Description: "The build this machine is running — its id, repository, path, " +
"the step it is at, when it started and for how long — or none; and whether this machine is " +
"paused, taking no new build.",
Input: schema(map[string]string{}, nil)},
{Name: "kill", Description: "End the build with this id, running here: its commands and the " +
"containers it started are stopped, and its outcome is announced as failed, killed by hand — " +
"settled, so it is not handed to another machine.",
Input: schema(map[string]string{"id": "the build's id, as `queue` or `current` says it"}, []string{"id"})},
{Name: "pause", Description: "Take no new build on this machine until resumed; a build running " +
"here finishes. Kept across a restart of the holder.",
Input: schema(map[string]string{}, nil)},
{Name: "resume", Description: "Take builds again on this machine.",
Input: schema(map[string]string{}, nil)},
}
}
// BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may
// snapshot the bus's streams and do nothing else (novox/hq ADR 0235).
const BrokerSeat = "mesh-broker"
// 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
}
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
}
@@ -0,0 +1,587 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// Seats a module declares of its own (novox/hq ADR 0118).
//
// The set of seats a mesh has is **derived**: the mesh's own, in seats.go, plus those declared by
// every module it has registered. Still closed — a seat named nowhere is refused — but computed
// from the catalogue rather than written in the controller, which is what ADR 0110 actually
// needed and a hand-maintained table could not keep. Its own evidence: the enumeration done by
// hand while that record was written reported eleven claims where there were thirteen.
//
// **What can be checked from one manifest and what cannot.** A declaration's shape, its scope,
// and the reserved prefix are facts about the manifest in front of you. Whether a seat anybody
// names actually exists, whether two modules declared the same one, and whether a holder
// satisfies the protocol are facts about the *catalogue* — so they are checked at registration,
// by CatalogueProblems, which is the last moment the mesh can still say no.
// meshSeatPrefix is reserved to the mesh. The prefix *is* the reservation rule: no list of
// reserved names to maintain, no way for the mesh's own namespace to be colonised by a manifest,
// and nothing to keep in step when a mesh seat is added.
const meshSeatPrefix = "mesh-"
// retiredLoginShell is the one name outside the prefix a module may not declare: the login shell's,
// from when a module declared it (novox/hq ADR 0176), before it became the mesh's (ADR 0204).
const retiredLoginShell = "login-shell"
// A SeatDeclaration is a role a module offers on the bus: what may be sent to it, what it says,
// and what it answers. A caller declares that it uses the *seat*, never the module, so the
// implementation can be replaced under it.
type SeatDeclaration struct {
Name string `json:"name"`
Scope string `json:"scope,omitempty"`
// Accepts are the verbs others may submit work on. Each becomes a work-queue subject, and
// the holder is the only consumer — so exactly one worker does the job, by construction
// rather than by how carefully somebody wrote a subscribe call.
Accepts []string `json:"accepts,omitempty"`
// Emits are the verbs the holder publishes: 1:many, nobody obliged to act.
Emits []string `json:"emits,omitempty"`
// Serves are the verbs the holder answers: request and reply, awaited. A bare name, or the
// verb in full with its schema (novox/hq ADR 0132).
Serves []Verb `json:"serves,omitempty"`
// RetainSeconds is how long the inbound backlog survives with no holder, zero for the
// mesh's default. Retention belongs to whoever owns the namespace (design 29 §3) — a seat
// owns its own, which is why a seat is also the answer for a module that needs retention
// its events cannot have.
RetainSeconds int `json:"retain-seconds,omitempty"`
// Kinded makes the seat a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): its holders are different
// modules, each claiming one kind, and each verb's subject carries the kind. Only the benches in
// KindedBenches may be kinded; making another is a decision, recorded.
Kinded bool `json:"kinded,omitempty"`
// ByCaller are accepts and emits whose subject's last token names the calling module (ADR 0259 §3): a
// user submits such an accept, and hears such an event, under its own name and no other.
ByCaller []string `json:"by-caller,omitempty"`
// Proofs are verbs carried as core request and reply, never on a stream: what travels on them (a code
// the operator typed) is never kept (ADR 0259 §3). On a kinded bench a holder asks with its own kind and
// the modules watching the seat answer.
Proofs []string `json:"proofs,omitempty"`
// Records are state buckets of the declaring module that each user reads under its own name — the
// keys `<user>.…` and no other (ADR 0259 §3).
Records []string `json:"records,omitempty"`
// Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287), under the rules the
// mesh's own seats name theirs by: a node seat only, each absolute and literal, and none another seat
// names. A module's seat never steps back.
Paths []string `json:"paths,omitempty"`
// Needs is every node seat this seat's holder needs held on its own node (novox/hq ADR 0220, ADR
// 0378 §8): a role whose holder is only right while another role is filled beside it. A module
// claiming this seat depends on each, exactly as a module declaring a service depends on the
// service manager (ADR 0207) — derived from the claim, never written again in the claimant's own
// manifest. Only a node seat may carry needs, and a compiled seat carries none.
Needs []string `json:"needs,omitempty"`
}
// KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator,
// `intake` takes what the operator answers. Another is a decision, recorded, as ADR 0223 asks of a bench.
var KindedBenches = map[string]bool{"channel": true, "intake": true}
// NamedByCaller says whether one of the seat's verbs is named by its caller.
func (s SeatDeclaration) NamedByCaller(verb string) bool {
for _, v := range s.ByCaller {
if v == verb {
return true
}
}
return false
}
// At is this declaration's scope, with the default applied. Mesh by default, because a seat
// declared by a module is nearly always "there is one of these in the mesh" — a per-node worker
// is the deliberate case, and says so.
func (s SeatDeclaration) At() string {
if s.Scope == "" {
return ScopeMesh
}
return s.Scope
}
// verbs is everything the protocol names, for the checks that do not care which half.
func (s SeatDeclaration) verbs() []string {
out := append([]string{}, s.Accepts...)
out = append(out, s.Emits...)
return append(out, VerbNames(s.Serves)...)
}
// declaredSeatProblems is what one manifest can be judged on alone.
func declaredSeatProblems(m Manifest) []string {
var problems []string
seen := map[string]bool{}
for _, s := range m.DefinesSeats {
switch {
case s.Name == "":
problems = append(problems, fmt.Sprintf("%s declares a seat with no name", m.Module))
continue
case !name.MatchString(s.Name):
problems = append(problems, fmt.Sprintf(
"%s declares a seat named %q, which is not a usable name", m.Module, s.Name))
continue
case strings.HasPrefix(s.Name, meshSeatPrefix):
// The mesh's own code dereferences its seats by name — the resolver *is* the thing
// that finds the store — so the prefix is not a convention, it is a namespace.
problems = append(problems, fmt.Sprintf(
"%s declares a seat named %q; %q is reserved to the mesh, which defines its own "+
"seats (novox/hq ADR 0118)", m.Module, s.Name, meshSeatPrefix+"*"))
continue
}
if s.Name == retiredLoginShell {
// The name ADR 0176 gave the login shell when the zsh module declared it. The seat is
// the mesh's now, so a module declaring the old name would be a second login shell
// beside it, with a protocol of its own (novox/hq ADR 0204).
problems = append(problems, fmt.Sprintf(
"%s declares a seat named %q; the login shell is the mesh's own seat %s, which a shell "+
"module claims and none declares (novox/hq ADR 0204)", m.Module, s.Name, LoginShellSeat))
continue
}
if seen[s.Name] {
problems = append(problems, fmt.Sprintf(
"%s declares the seat %q twice", m.Module, s.Name))
continue
}
seen[s.Name] = true
if _, isMesh := SeatNamed(s.Name); isMesh {
problems = append(problems, fmt.Sprintf(
"%s declares %q, which is a seat the mesh already defines", m.Module, s.Name))
}
switch s.At() {
case ScopeNode, ScopeSite, ScopeMesh:
default:
problems = append(problems, fmt.Sprintf(
"%s declares seat %s at scope %q; a seat is held per node, per site or per mesh",
m.Module, s.Name, s.Scope))
}
// A seat with an empty protocol is allowed, and is the mesh saying what a machine is:
// which module is this node's packet filter, or its showcase. Design 26 calls it a seat
// that delivers nothing, and that is most of the node-scoped ones. ADR 0126's "a declared
// seat carries a protocol" governs what a holder must satisfy, not that every seat offers
// something — a marker seat's protocol is satisfied by holding it. Nothing can reach this
// state by accident: a mistyped field name is refused by the parser above, so an empty
// protocol was written as one.
for _, v := range s.verbs() {
if !name.MatchString(v) {
problems = append(problems, fmt.Sprintf(
"%s declares %s.%s, which is not a usable verb", m.Module, s.Name, v))
}
}
problems = append(problems, trafficProblems(m, s)...)
problems = append(problems, declaredSeatPathProblems(m, s)...)
problems = append(problems, declaredSeatNeedsProblems(m, s)...)
}
for _, u := range m.Uses {
if !name.MatchString(u) {
problems = append(problems, fmt.Sprintf("%s uses %q, which is not a usable seat name", m.Module, u))
}
}
return problems
}
// trafficProblems is what one declaration of the rules of ADR 0259 §3 can be judged on alone.
func trafficProblems(m Manifest, s SeatDeclaration) []string {
var problems []string
if s.Kinded && !KindedBenches[s.Name] {
problems = append(problems, fmt.Sprintf(
"%s declares %s as a kinded bench; only channel and intake are kinded, and another is a "+
"decision, recorded (novox/hq ADR 0234)", m.Module, s.Name))
}
if s.Kinded && len(s.ByCaller) > 0 {
problems = append(problems, fmt.Sprintf(
"%s declares %s kinded and names verbs by their caller; a kinded bench's subjects carry the kind",
m.Module, s.Name))
}
for _, v := range s.ByCaller {
inAccepts, inEmits := false, false
for _, a := range s.Accepts {
inAccepts = inAccepts || a == v
}
for _, e := range s.Emits {
inEmits = inEmits || e == v
}
if !inAccepts && !inEmits {
problems = append(problems, fmt.Sprintf(
"%s names %s.%s by its caller, which the seat neither accepts nor emits", m.Module, s.Name, v))
}
}
for _, v := range s.Proofs {
if !name.MatchString(v) || strings.Contains(v, ".") {
problems = append(problems, fmt.Sprintf("%s declares the proof %s.%s, which is not a usable verb",
m.Module, s.Name, v))
}
}
if len(s.Proofs) > 0 && !s.Kinded {
problems = append(problems, fmt.Sprintf(
"%s declares proofs on %s, which is not kinded; a proof is asked by a holder of a kind",
m.Module, s.Name))
}
for _, r := range s.Records {
kept := false
for _, st := range m.State {
kept = kept || st.Name == r
}
if !kept {
problems = append(problems, fmt.Sprintf(
"%s says %s's users read its records %q, which it keeps no state of", m.Module, s.Name, r))
}
}
return problems
}
// declaredSeatNeedsProblems is what one declaration's needs can be judged on alone (novox/hq ADR
// 0378 §8): only a node seat is ever held beside another on the same node, so only a node seat may
// carry needs; and a seat needing itself names nothing that was not already true of holding it.
// Whether each named need itself exists as a node seat somewhere in the catalogue is CatalogueProblems'
// to say, once every manifest is known.
func declaredSeatNeedsProblems(m Manifest, s SeatDeclaration) []string {
var problems []string
if len(s.Needs) == 0 {
return problems
}
if s.At() != ScopeNode {
problems = append(problems, fmt.Sprintf(
"%s declares %s needing %s, and only a node seat needs another held beside it "+
"(novox/hq ADR 0378 §8)", m.Module, s.Name, strings.Join(s.Needs, ", ")))
}
for _, need := range s.Needs {
if need == s.Name {
problems = append(problems, fmt.Sprintf(
"%s declares %s needing itself (novox/hq ADR 0378 §8)", m.Module, s.Name))
}
}
return problems
}
// A Shelf is every manifest the mesh has registered, by module name.
type Shelf map[string]Manifest
// CatalogueProblems are the rules no single manifest can be judged against.
//
// Run at registration, which is the last moment the mesh can still refuse: after it, a caller is
// bound to a seat and a refusal is an outage rather than a conversation.
// UndeclaredSeat ends the problem of a `uses` or a claim naming a seat no manifest given declares: over the whole
// catalogue (registration, the catalogue's own check) a refusal, and over some manifests alone a seat whose
// declaring module was not given (novox/hq issue 364).
const UndeclaredSeat = "which no module declares and the mesh does not define"
// IsUndeclaredSeat says whether a problem CatalogueProblems gave is a seat no manifest given declares.
func IsUndeclaredSeat(problem string) bool { return strings.HasSuffix(problem, UndeclaredSeat) }
func CatalogueProblems(shelf Shelf) []string {
var problems []string
// A stream name has one owner (novox/hq ADR 0363).
problems = append(problems, StreamNameProblems(shelf)...)
// A module that hears a restricted stream runs as an account of its own (novox/hq ADR 0376 §2).
problems = append(problems, RestrictedHearingProblems(shelf)...)
// A work-queue stream is never asked for by another module's consumes (novox/hq ADR 0436 §6).
problems = append(problems, WorkQueueConsumedElsewhereProblems(shelf)...)
// Who declares what, and who declared it first.
declaredBy := map[string]string{}
declared := map[string]SeatDeclaration{}
for _, module := range shelfOrder(shelf) {
for _, s := range shelf[module].DefinesSeats {
if s.Name == "" {
continue
}
if first, taken := declaredBy[s.Name]; taken {
// The second loses. A seat name meaning two different protocols is the failure
// nobody could diagnose afterwards — a caller would bind to whichever happened
// to register first, and the symptom would appear in the other module.
problems = append(problems, fmt.Sprintf(
"%s declares the seat %q, which %s already declares; a seat name means one "+
"protocol", module, s.Name, first))
continue
}
declaredBy[s.Name] = module
declared[s.Name] = s
}
}
exists := func(seat string) bool {
if _, isMesh := SeatNamed(seat); isMesh {
return true
}
_, ok := declaredBy[seat]
return ok
}
// atNode says whether a seat, compiled or declared, is a node seat — judged here because only
// the whole catalogue knows a declared one's scope (novox/hq ADR 0378 §8).
atNode := func(seat string) bool {
if s, isMesh := SeatNamed(seat); isMesh {
return s.Scope == ScopeNode
}
if s, ok := declared[seat]; ok {
return s.At() == ScopeNode
}
return false
}
// A module-declared seat's needs name node seats that hold beside it (novox/hq ADR 0378 §8):
// whether one exists at all, compiled or declared by any module, only the whole catalogue can
// say — a single manifest's own problems (declaredSeatProblems) judge its needs no further.
for _, module := range shelfOrder(shelf) {
for _, s := range shelf[module].DefinesSeats {
for _, need := range s.Needs {
if !exists(need) {
problems = append(problems, fmt.Sprintf(
"%s declares %s needing %s, "+UndeclaredSeat, module, s.Name, need))
} else if !atNode(need) {
problems = append(problems, fmt.Sprintf(
"%s declares %s needing %s, which is not a node seat (novox/hq ADR 0378 §8)",
module, s.Name, need))
}
}
}
}
// 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 !exists(u) {
problems = append(problems, fmt.Sprintf(
"%s uses the seat %q, "+UndeclaredSeat,
module, u))
}
}
for _, c := range m.Claims {
if !exists(c.Name) {
problems = append(problems, fmt.Sprintf(
"%s claims the seat %q, "+UndeclaredSeat,
module, c.Name))
continue
}
s, isModuleSeat := declared[c.Name]
if !isModuleSeat {
// **A mesh seat is judged here and nowhere else** (novox/hq ADR 0122): the set is
// the store's, and this is the only place that runs with the store's set loaded.
// The parser cannot do it — it also runs on the build machine, against whatever
// set that binary was compiled with.
seat, _ := SeatNamed(c.Name)
if err := CanHold(m, seat); err != nil {
problems = append(problems, err.Error())
}
continue
}
problems = append(problems, kindProblems(module, c, s, kindsTaken)...)
if c.At() != s.At() {
problems = append(problems, fmt.Sprintf(
"%s claims %s at scope %q, and %s declares it at %s",
module, c.Name, c.At(), declaredBy[c.Name], s.At()))
}
// A holder that does not answer what the seat promises is a caller's timeout, found
// at assignment instead.
if missing := unserved(m, c, s); len(missing) > 0 {
problems = append(problems, fmt.Sprintf(
"%s claims %s but does not serve %s, which that seat's protocol promises",
module, c.Name, strings.Join(missing, ", ")))
}
// And owns what the seat's holder owns, as a holder of the mesh's own seats does (ADR 0287).
if missing := missingPaths(m, asSeatPaths(s.Paths)); len(missing) > 0 {
problems = append(problems, fmt.Sprintf(
"%s claims %s but does not declare %s, which that seat's holder owns (novox/hq ADR 0287)",
module, c.Name, strings.Join(missing, ", ")))
}
}
// Only a claimant declares a seat's path (novox/hq ADR 0287).
problems = append(problems, unclaimedSeatPaths(m, named)...)
}
// **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or
// speaking for a kind that proves its sender, is never carried by a machine's runtime.
for _, module := range shelfOrder(shelf) {
m := shelf[module]
if why := TrustedHolding(m, declared); why != "" && m.RunsAs == "" {
problems = append(problems, fmt.Sprintf(
"%s must run as an account of its own (runs-as): %s, and the machine's runtime runs as the "+
"operator's account, which every agent runs as (novox/hq ADR 0259)", module, why))
}
}
// A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the
// owner is on the shelf, as a consumer may be installed before its emitter.
var manifests []Manifest
for _, module := range shelfOrder(shelf) {
manifests = append(manifests, shelf[module])
}
problems = append(problems, StateReadsNothingDeclares(manifests)...)
sort.Strings(problems)
return problems
}
// ChannelCapabilities is the fixed vocabulary `channel-capabilities/1` (novox/hq ADR 0234 §2): a word
// outside it is refused. `max-length:<N>` takes a number.
var ChannelCapabilities = map[string]bool{
"deliver": true, "reaches-away": true, "loud": true, "silent": true, "edit": true,
"reaches-when-mesh-down": true, "private": true,
"choice": true, "reply": true, "threads": true, "operator-first": true,
"verified-sender": true, "exact-render": true, "code-factor": true, "key-factor": true,
}
var maxLength = regexp.MustCompile(`^max-length:[1-9][0-9]{0,6}$`)
// capabilityProblems are the words of a claim outside the vocabulary, and capabilities on a claim of a
// seat that is not kinded.
func capabilityProblems(module string, c Claim, kinded bool) []string {
if len(c.Capabilities) == 0 {
return nil
}
if !kinded {
return []string{fmt.Sprintf("%s claims %s with capabilities, and only a kinded bench's claim carries them",
module, c.Name)}
}
var problems []string
for _, w := range c.Capabilities {
if !ChannelCapabilities[w] && !maxLength.MatchString(w) {
problems = append(problems, fmt.Sprintf(
"%s claims %s with the capability %q, which channel-capabilities/1 does not have", module, c.Name, w))
}
}
return problems
}
// kindProblems is a claim judged against a declared seat's kind: a kinded bench takes one claim per kind,
// a usable name; any other seat takes none.
func kindProblems(module string, c Claim, s SeatDeclaration, taken map[string]string) []string {
if problems := capabilityProblems(module, c, s.Kinded); len(problems) > 0 {
return problems
}
switch {
case !s.Kinded && c.Kind != "":
return []string{fmt.Sprintf("%s claims %s of kind %q, and only a kinded bench takes a kind",
module, c.Name, c.Kind)}
case !s.Kinded:
return nil
case c.Kind == "":
return []string{fmt.Sprintf("%s claims the kinded bench %s and names no kind", module, c.Name)}
case !name.MatchString(c.Kind) || strings.Contains(c.Kind, "."):
return []string{fmt.Sprintf("%s claims %s of kind %q, which is not a usable name", module, c.Name, c.Kind)}
}
key := c.Name + "/" + c.Kind
if first, ok := taken[key]; ok && first != module {
return []string{fmt.Sprintf("%s claims %s of kind %q, which %s already claims; a kind has one holder",
module, c.Name, c.Kind, first)}
}
taken[key] = module
return nil
}
// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools
// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool
// is code the module either has or has not written — under the claim's serves, or among its own.
func unserved(m Manifest, c Claim, s SeatDeclaration) []string {
has := map[string]bool{}
for _, t := range c.ServesFor(m) {
has[t] = true
}
var missing []string
for _, t := range s.Serves {
if !has[t.Name] {
missing = append(missing, t.Name)
}
}
return missing
}
// shelfOrder is the catalogue in a stable order, so two runs report the same problems in the same
// sequence — a refusal that reorders itself is a refusal nobody can diff.
func shelfOrder(shelf Shelf) []string {
out := make([]string, 0, len(shelf))
for k := range shelf {
out = append(out, k)
}
sort.Strings(out)
return out
}
var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}$`)
// RunsAsProblems is what one manifest's `runs-as` is held to (novox/hq ADR 0259 §8): an account of the
// module's own making — a `user` resource of that name — that owns its secrets, with a bus account of its own,
// and that is neither root nor the operator's.
func RunsAsProblems(m Manifest) []string {
if m.RunsAs == "" {
return nil
}
var problems []string
say := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
switch {
case !accountName.MatchString(m.RunsAs):
say("%s runs as %q, which is not an account name of the module's own", m.Module, m.RunsAs)
return problems
case m.RunsAs == "root":
say("%s runs as root; a module of its own account runs as an account it makes, never root", m.Module)
}
made := false
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == m.RunsAs {
made = true
}
}
if !made {
say("%s runs as %s, which it does not make: a user resource named %s", m.Module, m.RunsAs, m.RunsAs)
}
if _, has := m.OwnSecrets["broker"]; !has {
say("%s runs as its own account and declares no own secret broker: its runtime reaches the bus on an "+
"account of its own", m.Module)
}
if m.SecretsOwner != m.RunsAs {
say("%s runs as %s, and its secrets belong to %q: they are the account's own", m.Module, m.RunsAs, m.SecretsOwner)
}
return problems
}
// TrustedHolding is why a module must run as its own account (novox/hq ADR 0259 §8), or "": it holds a seat
// whose events it says to one caller each (a warrant), or speaks for a kind of a kinded bench that proves
// its sender, or is private (a link's code is shown there). None may be carried by the machine's runtime, which
// runs as the operator's account.
func TrustedHolding(m Manifest, declared map[string]SeatDeclaration) string {
for _, c := range m.Claims {
s, ok := declared[c.Name]
if !ok {
continue
}
for _, e := range s.Emits {
if s.NamedByCaller(e) {
return fmt.Sprintf("it holds %s, whose %s it says to one caller each", c.Name, e)
}
}
if s.Kinded {
for _, capability := range c.Capabilities {
switch capability {
case "verified-sender":
return fmt.Sprintf("it holds %s of kind %s, which proves its sender", c.Name, c.Kind)
case "private":
// A private kind is shown a link's code, which makes an account the operator's.
return fmt.Sprintf("it holds %s of kind %s, which is private: a link's code is shown there", c.Name, c.Kind)
}
}
}
}
return ""
}
@@ -0,0 +1,238 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// A credential and a configuration file meeting.
//
// **The gap this closes.** A granted credential arrives as a file whose entire content is the
// password. That is what a program reading a password file wants — and most programs do not read
// one. They read `KEY=value`, or a JSON document with the password at some path inside it, or a
// YAML file in a home directory. Before this, a module in that position could be handed the bare
// value or nothing, and both are useless.
//
// The mesh cannot compose the document, because it discarded the value (novox/hq ADR 0024). So
// the module supplies the document with a hole in it, the mesh delivers the value sealed beside
// it, and the host — the only thing that ever sees both — puts one into the other on the machine.
//
// The host has always been able to do this. Nothing filled the values in, so the hole could be
// written and never closed, and the host refused the file. That refusal was correct and the
// feature was unreachable.
// placeholder is what a module's file content says where a sealed value belongs: ${secret:name}.
//
// The same expression the host matches, written out again rather than shared: they are separate
// repositories and this is a wire format, like the shape of the declaration itself.
//
// **Nothing here can check that they agree, and the claim that something did was wrong.** A unit
// test in this repository can only assert what this repository already believes. What proves it is
// the lab, where a real host receives a real declaration and the file arrives filled — and where
// the two expressions disagreeing shows up as a placeholder written through to a machine.
var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`)
// secretsUsed are the names a file's content asks for, in the order they first appear.
func secretsUsed(content string) []string {
var used []string
seen := map[string]bool{}
for _, m := range placeholder.FindAllStringSubmatch(content, -1) {
if !seen[m[1]] {
seen[m[1]] = true
used = append(used, m[1])
}
}
return used
}
// sealedFor is every credential a module may name from inside one of its own files.
//
// **Exactly what it already declared, and nothing else.** A module reaches its own secrets and the
// credentials it was granted for what it requires — both written down in its own manifest. It
// cannot name another module's, which is not an oversight: two modules on one machine are as
// separate as two on different machines, and letting one read the other's credential by guessing a
// name would end that, to save writing a file.
func sealedFor(m Manifest, needs []Needed, with Rendering) (map[string]string, error) {
sealed := map[string]string{}
for name := range m.OwnSecrets.Plain() {
if value := with.Needed[m.Module][name]; value != "" {
sealed[name] = value
}
}
for _, to := range m.SecretRequirements() {
for _, file := range m.SecretFiles(to) {
key := SecretLocal(to, file.Local)
if _, taken := sealed[key]; taken {
// A module whose own secret and whose requirement share a name. Refused rather than
// settled by precedence: whichever won, the manifest would read as though the other
// had, and the file would hold the credential for the wrong thing while every check
// passed.
return nil, fmt.Errorf(
"%s has a secret of its own called %q and also requires %q, so a file saying "+
"${secret:%s} could mean either — rename one of them", m.Module, key, key, key)
}
for i := range needs {
// `For == m.Module`, not name alone: on a node with two modules requiring the same
// provision, both appear in `needs`, and matching by name would fill ${secret:X} with
// whichever came last — the other module's credential (novox/hq 04-ISSUES/022). And
// the local name, where the module keeps several (ADR 0094).
if needs[i].Name == to && needs[i].For == m.Module && needs[i].Local == file.Local && needs[i].Sealed != "" {
sealed[key] = needs[i].Sealed
}
}
}
}
return sealed, nil
}
// SecretsInEnvironment is the key a container carries to say, out loud, that a secret reaches
// its process through the environment — and why.
//
// **A secret reaches a process as a file** (novox/hq ADR 0086). The mesh seals a value to the
// machine and the host writes it at 0600; a container that then reads it from an env-file hands
// it to the runtime, which prints it in `docker inspect` and keeps it in the process's /proc entry
// for anything on the machine that can talk to the runtime (novox/hq 04-ISSUES/041). Some software
// reads its configuration from the environment and nothing else, and for that this key exists: a
// reason, on the container, so a reader can tell from the manifest which secrets are exposed that
// way and which are not. Without it, a container whose env-file carries a secret is refused.
//
// Catalogue-level: the host never sees this key.
const SecretsInEnvironment = "secrets-in-environment"
// refuseSecretsInEnvironment is the check. `secretFiles` is every file of this module whose
// content names a secret.
func refuseSecretsInEnvironment(resource map[string]any, secretFiles map[string]bool, module string) error {
if fmt.Sprint(resource["type"]) != "container" {
return nil
}
name := fmt.Sprint(resource["name"])
if env, ok := resource["env"].(map[string]any); ok {
for key, value := range env {
if strings.Contains(fmt.Sprint(value), "${secret:") {
return fmt.Errorf(
"%s's container %s puts ${secret:…} in its env (%s). A secret is never filled into an "+
"environment variable: put it in a file the module declares and mount that, or name the "+
"file in env-file and say %q why", module, name, key, SecretsInEnvironment)
}
}
}
reason, _ := resource[SecretsInEnvironment].(string)
if strings.TrimSpace(reason) != "" {
return nil
}
if files, ok := resource["env-file"].([]any); ok {
for _, f := range files {
if secretFiles[fmt.Sprint(f)] {
return fmt.Errorf(
"%s's container %s reads %s as an env-file, and that file carries a secret, so the secret "+
"reaches the process environment — readable in `docker inspect` and /proc (novox/hq "+
"04-ISSUES/041). Mount the secret's file and point the program at it, or, if the program "+
"reads only its environment, say so on the container: %q: \"<why>\"",
module, name, f, SecretsInEnvironment)
}
}
}
return nil
}
// secretFilesOf is the paths of a module's file resources whose content names a secret.
func secretFilesOf(resources []map[string]any) map[string]bool {
out := map[string]bool{}
for _, r := range resources {
if fmt.Sprint(r["type"]) != "file" {
continue
}
if content, ok := r["content"].(string); ok && len(secretsUsed(content)) > 0 {
out[fmt.Sprint(r["path"])] = true
}
}
return out
}
// intoFile gives a file the sealed values its content asks for.
//
// A name the module never declared is refused here rather than on the machine. The host would
// refuse it too — but it would do so having already been handed a declaration, which reads as the
// mesh sending something broken, and the name it could not find is a manifest's typo.
//
// **An own secret issued outside the mesh that nobody gave is filled with nothing** (novox/hq issue 401): the mesh
// never makes one (ADR 0228), and a file is where the module reads it, so the place says empty — the module sees it
// was not given, where a value the mesh made looked given and wrong. notGiven names those secrets.
func intoFile(resource map[string]any, sealed map[string]string, notGiven map[string]bool, module string) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
content, ok := resource["content"].(string)
if !ok {
return nil
}
used := secretsUsed(content)
if len(used) == 0 {
return nil
}
into := map[string]any{}
for _, name := range used {
value := sealed[name]
if value == "" && notGiven[name] {
content = strings.ReplaceAll(content, "${secret:"+name+"}", "")
continue
}
if value == "" {
return fmt.Errorf(
"%s has a file that says ${secret:%s}, and %s has no secret of its own by that "+
"name and requires nothing called that either. A file may name %s",
module, name, module, namesOr(sealed))
}
into[name] = value
}
resource["content"] = content
if len(into) > 0 {
resource["secrets"] = into
}
return nil
}
// outsideNotGiven is each own secret of a module issued outside the mesh that this composition holds no value for
// (novox/hq issue 401): what intoFile fills with nothing, and what nothing restarts on.
func outsideNotGiven(m Manifest, given map[string]string) map[string]bool {
out := map[string]bool{}
for name, own := range m.OwnSecrets.Plain() {
if own.IssuedBy == IssuedOutside && given[name] == "" {
out[name] = true
}
}
return out
}
// namesOr says what a module could have written, because the answer to "that name is wrong" is
// almost always one of two or three right ones.
func namesOr(sealed map[string]string) string {
if len(sealed) == 0 {
return "nothing — it has no secrets of its own and requires nothing that grants one"
}
var names []string
for name := range sealed {
names = append(names, fmt.Sprintf("%q", name))
}
sort.Strings(names)
return join(names)
}
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]
}
@@ -0,0 +1,202 @@
package catalogue
import (
"fmt"
"sort"
"strings"
)
// A provider may tell its consumers its own composed names (novox/hq ADR 0346, issue 624).
//
// **The name is already the mesh's.** A module contributes a label to a requirement — a route — and
// the mesh joins it with the providing machine's domains; the module reads the result in its own files
// as `${bound:<requirement>:name}` (novox/hq 04-ISSUES/122). Its consumers could not be told it: a
// served value named only the consumer (ADR 0202) and settings. The forge's package registry writes
// every tarball at its public name, and a consumer that must trust that name had no source for it but a
// second copy kept by hand. So a served value may say `${bound:<requirement>:<name>}` for a requirement
// the provider contributes to, and the mesh fills it from the same composition the provider's own files
// and its proxy receive.
//
// **A name the providing machine does not compose leaves the key out** (ADR 0346 §3): a machine with
// no public domain has no public name to give, and an empty value or a refusal would be worse than a
// consumer told nothing — a refused provider machine takes every provision it offers down with it. The
// plan says which key was left out, and why, on the provider's machine and under each consumer's need.
// ownNameFacts are what a served value may name of the provider's own composition: the names, as its
// own files read them (withOwnNames).
var ownNameFacts = []string{"name", "internal-name"}
// requirementsContributedTo is every requirement m contributes to, sorted.
func requirementsContributedTo(m Manifest) []string {
seen := map[string]bool{}
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
}
// 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 Manifest, to string) []string {
keys := append([]string{}, ownNameFacts...)
for _, local := range sortedKeys(m.ContributesMany[to]) {
for _, fact := range ownNameFacts {
keys = append(keys, fact+"-"+local)
}
}
return keys
}
// ServedOwnNames fills the provider's own composed names into what it serves one provision's
// consumers, from this resolution's composition of the provider's machine (ADR 0346).
//
// A value naming a name this machine does not compose is left out, with its key, and said. The map is
// copied only when something changes, as ServedTo does: the caller's may be the manifest's.
func (r Resolution) ServedOwnNames(m Manifest, provision string, serves map[string]any, layers []Layer) (
map[string]any, []string, error) {
var asking []string
for _, key := range sortedAnyKeys(serves) {
if text, ok := serves[key].(string); ok && bound.MatchString(text) {
asking = append(asking, key)
}
}
if len(asking) == 0 {
return serves, nil, nil
}
own := map[string]map[string]string{}
for _, to := range requirementsContributedTo(m) {
names, err := r.ownNames(m, to, layers)
if err != nil {
return nil, nil, err
}
values := map[string]string{}
withOwnNames(values, names)
own[to] = values
}
out := make(map[string]any, len(serves))
for k, v := range serves {
out[k] = v
}
var said []string
for _, key := range asking {
text := serves[key].(string)
var missing string
filled := bound.ReplaceAllStringFunc(text, func(token string) string {
parts := bound.FindStringSubmatch(token)
if value := own[parts[1]][parts[2]]; value != "" && parts[3] == "" {
return value
}
if missing == "" {
missing = token
}
return token
})
if missing != "" {
delete(out, key)
said = append(said, fmt.Sprintf(
"%s serves %s without %q: it names %s, and %s composes no such name for %s (it has no "+
"public domain, or the endpoint is not reached publicly) (novox/hq ADR 0346)",
m.Module, provision, key, missing, orHere(r.Node), m.Module))
continue
}
out[key] = filled
}
return out, said, nil
}
func orHere(node string) string {
if node == "" {
return "this machine"
}
return node
}
// ServedLeftOut is what the providers in this node's own set leave out of what they serve, because
// this machine composes no name a served value asks for (ADR 0346 §3): said on the provider's machine,
// where the remedy is.
func (r Resolution) ServedLeftOut(settings SettingsBy) []string {
var said []string
for _, m := range r.Modules {
for _, provision := range sortedServes(m.Serves) {
_, lines, err := r.ServedOwnNames(m, provision, m.Serves[provision], WithDefaults(m, settings[m.Module]))
if err != nil {
said = append(said, fmt.Sprintf("%s serves %s, and its own names could not be composed: %v",
m.Module, provision, err))
continue
}
said = append(said, lines...)
}
}
return said
}
// checkServedOwnNames refuses a served `${bound:…}` that names a requirement the module does not
// contribute to, a fact other than its own names, or a local name it does not contribute under; and
// one with a default, since a served value is left out rather than given one (ADR 0346 §3, §4).
func checkServedOwnNames(m Manifest, provision, key, text string) []string {
var problems []string
contributes := requirementsContributedTo(m)
for _, parts := range bound.FindAllStringSubmatch(text, -1) {
to, fact, fallback := parts[1], parts[2], parts[3]
says := fmt.Sprintf("%s serves %s, and the value it serves as %q says %s", m.Module, provision, key, parts[0])
switch {
case fallback != "":
problems = append(problems, says+": a served name takes no default; one the machine does not "+
"compose leaves the key out (novox/hq ADR 0346)")
case !oneOf(contributes, to):
problems = append(problems, fmt.Sprintf(
"%s, and %s contributes to %s: a served value names only the provider's own names "+
"(novox/hq ADR 0346)", says, m.Module, orNothing(quoted(contributes))))
case !oneOf(ownNameKeys(m, to), fact):
problems = append(problems, fmt.Sprintf("%s, and what %s is called through %s is %s (novox/hq ADR 0346)",
says, m.Module, to, strings.Join(ownNameKeys(m, to), ", ")))
}
}
return problems
}
func quoted(values []string) []string {
out := make([]string, len(values))
for i, v := range values {
out[i] = fmt.Sprintf("%q", v)
}
return out
}
// nestedOwnNames refuses a `${bound:…}` inside a served list or object: only a string value of its own is
// filled (ServedOwnNames), so a nested one would reach the consumer as the placeholder (ADR 0346 §5).
func nestedOwnNames(m Manifest, provision, key string, value any) []string {
var found []string
var walk func(v any)
walk = func(v any) {
switch x := v.(type) {
case string:
found = append(found, bound.FindAllString(x, -1)...)
case []any:
for _, e := range x {
walk(e)
}
case map[string]any:
for _, k := range sortedAnyKeys(x) {
walk(x[k])
}
}
}
walk(value)
if len(found) == 0 {
return nil
}
return []string{fmt.Sprintf(
"%s serves %s, and the value it serves as %q holds %s inside a list or an object: a provider's own "+
"name is filled only in a string value of its own (novox/hq ADR 0346)",
m.Module, provision, key, strings.Join(found, ", "))}
}
@@ -0,0 +1,261 @@
package catalogue
import (
"fmt"
"sort"
"strings"
)
// A preference has a default; the operator's own value has none (novox/hq ADR 0262, extending ADR
// 0112 and taking the default half of ADR 0164).
//
// `${setting:<key>}` was refused whenever no layer set the key, and `settings set` refused a key no
// file asked for yet. Together they meant a running module could never gain a setting: the file that
// asks for it fails to compose until somebody sets it, and nobody can set it until the file asks.
// A definition may now give a key a default, with why, when the value is a **preference** — a font
// size, a width, a number of workers: something true of the software that a machine may tune. A
// value that is inherently the operator's — a domain, a public name, an identity, a secret — gets no
// default and stays refused by name until a layer sets it, which is what ADR 0112 and ADR 0155 were
// written for.
//
// The default is the lowest layer. The mesh's layer, then the node's, override it. It fills only
// `${setting:<key>}`: a mergeable file's content already is its defaults, and laying a default over
// it would reach every mergeable file of the module (novox/hq issue 168).
// SettingDeclaration is one key's default, as the definition gives it.
type SettingDeclaration struct {
// Kind says what sort of value this is. `preference` is the only kind with a default; it is
// stated rather than assumed so a reviewer sees the claim being made.
Kind string `json:"kind"`
// Default is the value when no layer sets the key: a string, a number or a boolean.
Default any `json:"default"`
// Why this default: one sentence for whoever wonders whether to change it.
Why string `json:"why"`
// The config form's declaration (novox/hq ADR 0348 §4, config.go): none of these is said under
// `settings`, and neither Kind nor Why under `config`.
// Type is number, string or boolean; object or list only when Trusted.
Type string `json:"type,omitempty"`
// Description says what the value is, including its unit.
Description string `json:"description,omitempty"`
// Enum, Minimum, Maximum and Pattern are its validation: the values it may take, the bounds of a
// number, and the whole of a string matching the pattern.
Enum []any `json:"enum,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Pattern string `json:"pattern,omitempty"`
// Trusted is a value that decides what runs or who can reach what (ADR 0348 §8).
Trusted bool `json:"trusted,omitempty"`
// Predefined is the predefined key the value falls back to, of the same type (ADR 0348 §2, §7).
Predefined string `json:"predefined,omitempty"`
// Required is the operator's own value: it has no default (ADR 0348 §4).
Required bool `json:"required,omitempty"`
// Runtime is a value the module's code reads at runtime rather than one placed in a file (ADR 0348 §5).
Runtime bool `json:"runtime,omitempty"`
}
// KindPreference is the kind of a setting that may have a default.
const KindPreference = "preference"
// DefaultLayer is what the layer of a module's own defaults is called where a value's source is said.
// Only said: the layer is recognised by Layer.Default, never by this name, which a node may also have.
const DefaultLayer = "default"
// meshWords are the settings keys the mesh reads itself; a module declares none of them.
var meshWords = map[string]bool{
PortsSetting: true, ExposeSetting: true, ReachSetting: true, EndpointsSetting: true,
PlacesSetting: true, AccessesSetting: true, NetworksSetting: true,
}
// operatorsOwn are the words that, as what a key's name is about, say its value is the operator's and
// never a preference: a default for one would be the literal ADR 0112 removed from definitions.
var operatorsOwn = map[string]bool{
"domain": true, "host": true, "hostname": true, "servername": true, "fqdn": true, "zone": true,
"realm": true, "tenant": true, "site": true, "timezone": true,
"issuer": true, "url": true, "uri": true, "webhook": true, "origin": true, "dsn": true,
"ip": true, "ipv4": true, "ipv6": true,
"email": true, "mail": true, "phone": true, "address": true,
"identity": true, "login": true, "user": true, "username": true, "account": true, "owner": true,
"uid": true, "gid": true, "puid": true, "pgid": true,
"password": true, "pass": true, "passwd": true, "passphrase": true, "secret": true, "token": true,
"key": true, "apikey": true, "bearer": true, "cert": true, "certificate": true, "credential": true,
"nameserver": true, "gateway": true, "subnet": true, "sender": true, "recipient": true, "contact": true,
"trusted": true, "whitelist": true, "peer": true, "bind": true, "listen": true, "upstream": true,
"proxy": true, "admin": true, "mac": true,
}
// operatorsCompounds are names of two words that are the operator's though neither word alone says so
// at the end of a key: a client's identifier, and a name the world knows a site or server by.
var operatorsCompounds = map[string]bool{
"client-id": true, "site-name": true, "server-name": true, "public-name": true, "smtp-relay": true,
"host-name": true, "user-name": true, "domain-name": true, "dns-server": true,
// Whom a rule lets in or keeps out: a list of addresses or networks.
"allow-from": true, "deny-from": true, "allow-list": true,
}
// aboutAnAmount are first words that make a key about how many or whether, never about whom:
// `max-tokens` is a number, `show-hostname` a switch. Not `allow` or `use`: `allow-from` and
// `use-host` name whom.
var aboutAnAmount = map[string]bool{
"max": true, "min": true, "num": true, "count": true, "show": true, "hide": true, "enable": true,
"disable": true,
}
// operatorsWord is what in a key's name says its value is the operator's, or "". A key is about its
// last word — `url-timeout` is a timeout, `user-agent` an agent, `mail-domain` a domain — or its last two
// as one of operatorsCompounds. A plural is read as its singular.
func operatorsWord(key string) string {
words := strings.FieldsFunc(key, func(r rune) bool { return r == '-' || r == '_' || r == '.' })
if len(words) == 0 || (len(words) > 1 && aboutAnAmount[words[0]]) {
return ""
}
for i, w := range words {
switch {
case operatorsOwn[w]:
case strings.HasSuffix(w, "ies") && operatorsOwn[strings.TrimSuffix(w, "ies")+"y"]:
words[i] = strings.TrimSuffix(w, "ies") + "y"
case strings.HasSuffix(w, "s") && operatorsOwn[strings.TrimSuffix(w, "s")]:
words[i] = strings.TrimSuffix(w, "s")
}
}
if n := len(words); n > 1 {
// Where a secret or an identity is kept is the operator's too: `password-file`, `token-path`.
if (words[n-1] == "file" || words[n-1] == "path") && operatorsOwn[words[n-2]] {
return words[n-2] + "-" + words[n-1]
}
for _, last := range []string{words[n-1], strings.TrimSuffix(words[n-1], "s")} {
if pair := words[n-2] + "-" + last; operatorsCompounds[pair] {
return pair
}
}
}
if last := words[len(words)-1]; operatorsOwn[last] {
return last
}
return ""
}
// SettingProblems is every way a definition's setting defaults are wrong, in its own words.
func SettingProblems(m Manifest) []string {
if len(m.Settings) == 0 {
return nil
}
used := settingKeysUsedBy(m)
var problems []string
for _, key := range sortedSettingKeys(m.Settings) {
d := m.Settings[key]
say := func(format string, args ...any) {
problems = append(problems, fmt.Sprintf("%s's setting %q: ", m.Module, key)+fmt.Sprintf(format, args...))
}
if !settingRef.MatchString("${setting:" + key + "}") {
say("not a usable key: lower-case letters, digits, dots, dashes and underscores")
continue
}
if meshWords[key] {
say("the mesh reads %q itself, and a module gives it no default", key)
continue
}
if d.Kind != KindPreference {
say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", d.Kind, KindPreference)
}
if word := operatorsWord(key); word != "" {
say("a key naming %q is the operator's value, and has no default — it is the "+
"assignment's, never the definition's (novox/hq ADR 0112, ADR 0262)", word)
}
switch v := d.Default.(type) {
case string:
if strings.TrimSpace(v) == "" {
say("an empty default is no default; give the value, or declare nothing")
}
case float64, bool:
case nil:
say("no default: a key without one is the operator's, and is not declared here")
default:
say("a default is a string, a number or a boolean, and this is %T", d.Default)
}
if strings.TrimSpace(d.Why) == "" {
say("no why: say in one sentence why this default")
}
if !used[key] {
say("nothing asks for ${setting:%s}, so the default reaches nothing", key)
}
}
return problems
}
// Defaults is the layer a module's own defaults make, or nothing when it gives none.
func Defaults(m Manifest) (Layer, bool) {
if len(m.Settings) == 0 {
return Layer{}, false
}
values := map[string]any{}
for key, d := range m.Settings {
if d.Default != nil {
values[key] = d.Default
}
}
return Layer{From: DefaultLayer, Values: values, Default: true}, len(values) > 0
}
// WithDefaults is a module's layers with its defaults under them, for filling `${setting:<key>}`.
func WithDefaults(m Manifest, layers []Layer) []Layer {
d, has := Defaults(m)
if !has {
return layers
}
return append([]Layer{d}, layers...)
}
// SettingSource is one key's effective value and the layer it came from.
type SettingSource struct {
Key string
Value any
// From is DefaultLayer, MeshWideLayer or the node's name.
From string
// FromDefault is whether the value is the module's default, whatever From reads.
FromDefault bool
// Default is the module's default, when it gives one.
Default any
HasDefault bool
}
// Effective is every key a module gives a default or a layer sets, with its value and where it came
// from: the default, then the mesh's layer, then the node's — later wins.
func Effective(m Manifest, layers []Layer) []SettingSource {
byKey := map[string]*SettingSource{}
for key, d := range m.Settings {
if d.Default == nil {
// A required config value has no default (ADR 0348 §4); a settings declaration always has one.
continue
}
byKey[key] = &SettingSource{Key: key, Value: d.Default, From: DefaultLayer, FromDefault: true,
Default: d.Default, HasDefault: true}
}
for _, layer := range layers {
// As the layer is said, placement too: `settings` answers it as a person set it.
for key, v := range layer.Whole() {
s, ok := byKey[key]
if !ok {
s = &SettingSource{Key: key}
byKey[key] = s
}
s.Value, s.From, s.FromDefault = v, layer.From, layer.Default
}
}
out := make([]SettingSource, 0, len(byKey))
for _, s := range byKey {
out = append(out, *s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
func sortedSettingKeys(in map[string]SettingDeclaration) []string {
keys := make([]string, 0, len(in))
for k := range in {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
@@ -0,0 +1,367 @@
package catalogue
import (
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// An operator's value, where a definition needs one (novox/hq ADR 0112, ADR 0155, design 27).
//
// A mail server's domain, a site's name, the address a proxy forwards from: values that are true of
// one installation and of no other, and that a module's software must be told. They had nowhere to
// live but the definition, which is how a catalogue meant for any mesh came to name this one
// (novox/hq issues 122, 134). ADR 0112 names the operator as one of the four providers; this is the
// operator answering.
//
// `${setting:<key>}` in a file's content, in a service's unit name and in a directory's owner (a uid or
// gid only, issue 279) is filled from the module's settings layers — the mesh's,
// then this node's — the same layers a mergeable JSON file and a contribution already take, so
// `settings set <module>` is the one place a person's values go. **Refused when no layer sets it**,
// naming the key and the remedy: a definition that carried a default for a mail domain would be
// carrying the very literal this removes, and a blank written silently would be a service that
// comes up wrong somewhere that names neither the module nor the key.
// settingRef is how a definition asks for an operator's value: ${setting:<key>}.
var settingRef = regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`)
// 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
}
// UnsetSettingError is a module whose definition says ${setting:<key>} where nothing sets that key: typed, so
// that whoever reads why a module was left out of a machine can tell a setting nobody gave — the operator's
// to give, named with the command that gives it — from any other reason (novox/hq issue 380). Its words are
// the refusal's, unchanged.
type UnsetSettingError struct {
Module, Setting string
said string
// where says what asks for it — "a file", "a service whose unit", "a directory <id> whose owner" — and
// setToday which of the module's keys a layer sets, for saying it again in the config form's words.
where, setToday string
}
func (e *UnsetSettingError) Error() string { return e.said }
// unsetInConfigWords is the refusal of a required config value nothing sets, in the words of a module written in the
// config form (novox/hq ADR 0348 §1, §4): `${config:<key>}` and the config verb, never `settings set`. Any other
// error, and any error of a module in the settings form, is returned as it was.
func unsetInConfigWords(err error, m Manifest) error {
var unset *UnsetSettingError
if err == nil || !m.DeclaresConfig() || !errors.As(err, &unset) {
return err
}
return &UnsetSettingError{Module: unset.Module, Setting: unset.Setting, where: unset.where,
setToday: unset.setToday, said: fmt.Sprintf(
"%s has %s that says ${config:%s}, and nothing sets the required config value %q for it — a "+
"required value is the operator's own and has no default (novox/hq ADR 0348 §4): set it with "+
"the config verb, for %s on this node, key %q%s",
unset.Module, unset.where, unset.Setting, unset.Setting, unset.Module, unset.Setting,
strings.ReplaceAll(unset.setToday, "no setting is set", "no config value is set"))}
}
// settingInto fills a file's ${setting:…} placeholders from the layers over a module.
//
// The last layer setting a key wins, which is the node's over the mesh's over the module's own
// default (novox/hq ADR 0262) — the same order settle applies to a mergeable file. The caller lays
// the defaults under the layers with WithDefaults. A value that is not a string is written the way a program would read
// it (a number without a trailing .000000, a boolean as true/false).
func settingInto(resource map[string]any, layers []Layer, module string) error {
if fmt.Sprint(resource["type"]) == "service" {
return settingIntoUnit(resource, layers, module)
}
if fmt.Sprint(resource["type"]) == "directory" {
return settingIntoOwner(resource, layers, module)
}
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
content, ok := resource["content"].(string)
if !ok {
return nil
}
for _, key := range settingsUsed(content) {
value, set := settingValue(layers, key)
if !set {
return &UnsetSettingError{Module: module, Setting: key, where: "a file",
setToday: orNoSettings(layers), said: fmt.Sprintf(
"%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+
"value is the assignment's, never the definition's (novox/hq ADR 0112), and only a "+
"preference has a default in the definition (ADR 0262): "+
"`settings set %s <file>` with {%q: …}%s",
module, key, key, module, key, orNoSettings(layers))}
}
content = strings.ReplaceAll(content, "${setting:"+key+"}", plainly(value))
}
resource["content"] = content
return nil
}
// unitPart is what a setting may put into a unit's name: the characters systemd allows in a unit name,
// less the instance's `@` and the escape's `\`, and at least one of them. Anything else — a space, a slash,
// a newline that would begin a directive in the unit file the name ends up in — is refused, never written.
var unitPart = regexp.MustCompile(`^[A-Za-z0-9:_.][A-Za-z0-9:_.-]{0,63}$`)
// unitInstance is the one place a setting may stand in a unit's name: the whole instance of a template,
// after its `@` and before its suffix — `zfs-scrub-weekly@${setting:scrub-pool}.timer` — so a value can
// make the unit another instance of the same template and nothing else (third review of mesh-catalog #147).
var unitInstance = regexp.MustCompile(`^[A-Za-z0-9:_.-]+@\$\{setting:[a-z0-9][a-z0-9_.-]*\}\.[a-z]+$`)
// UnitSettingProblems refuses, where a manifest is read, a service whose unit name carries a setting
// anywhere but as a template's whole instance.
func UnitSettingProblems(m Manifest) []string {
var problems []string
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "service" {
continue
}
unit, _ := r["unit"].(string)
if len(settingsUsed(unit)) == 0 && !strings.Contains(unit, "${setting:") {
continue
}
if !unitInstance.MatchString(unit) {
problems = append(problems, fmt.Sprintf("%s: the service %v's unit %q carries a setting outside a template's "+
"instance; a setting may stand only as the whole instance, after the @ and before the suffix "+
"(name@${setting:key}.timer)", m.Module, r["id"], unit))
}
}
return problems
}
// settingIntoUnit fills ${setting:…} in a service resource's unit name: a module that holds the
// distribution's timer for the pool the operator names cannot write the pool into its definition
// (novox/hq ADR 0112). Refused, with the key and why, when nothing sets it or the value would not make a
// unit's name; the resource is left as it was.
func settingIntoUnit(resource map[string]any, layers []Layer, module string) error {
unit, ok := resource["unit"].(string)
if !ok {
return nil
}
for _, key := range settingsUsed(unit) {
value, set := settingValue(layers, key)
if !set {
return &UnsetSettingError{Module: module, Setting: key, where: "a service whose unit",
setToday: orNoSettings(layers), said: fmt.Sprintf(
"%s has a service whose unit says ${setting:%s}, and nothing sets %q for it — an operator's "+
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
"`settings set %s <file>` with {%q: …}%s",
module, key, key, module, key, orNoSettings(layers))}
}
v := plainly(value)
if !unitPart.MatchString(v) {
return fmt.Errorf("%s: the setting %q is %q, which cannot be the instance of the unit %s: an instance "+
"takes letters, digits, ':', '_', '.' and '-', does not begin with '-' (a systemctl option), and is "+
"at most 64 characters", module, key, v, unit)
}
unit = strings.ReplaceAll(unit, "${setting:"+key+"}", v)
}
resource["unit"] = unit
return nil
}
// A declared directory may be owned by the account its module runs as (novox/hq issue 279).
//
// A containerised module runs as the identity its assignment sets per node — its puid and pgid
// settings, written into the environment file its image reads (ADR 0112, issue 153) — and a directory
// its app must write, beyond the one its image re-owns at start, was owned by whatever the manifest
// spelt: right on a machine whose identity matched the image's default, refused by the app on one whose
// did not. So a directory's `owner` may name the settings that identity is: `${setting:<key>}` for the
// user alone, `${setting:<user>}:${setting:<group>}` for both, filled from the same layers at
// composition.
//
// **Whole numbers only.** An owner a setting fills is a uid or a gid — never an account's name, never
// text — so no value can put anything into the ownership the node-engine applies but a number; one that
// is not is refused at composition, naming the key, and the directory is left as it was. The shape is
// judged where the manifest is read (OwnerSettingProblems): each side of the colon a whole placeholder
// or a whole number, and every key one the module itself asks for elsewhere — in a file, a unit, a
// contribution or a served fact — or declares a default for, so the owner follows the identity its
// process is told it runs as, never a key that reaches nothing else.
// ownerSide is one side of an owner a setting may fill: a whole placeholder or a whole number.
var ownerSide = `(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)`
// ownerWithSetting is the only shape an owner holding a setting may have: user, or user:group.
var ownerWithSetting = regexp.MustCompile(`^` + ownerSide + `(?::` + ownerSide + `)?$`)
// wholeID is a value as a uid or a gid, and whether it is one: a whole number below the kernel's "no id"
// (2^32-1). Uid 0 is one: a key an owner asks for is the controller's terminal's alone (TerminalKeys).
func wholeID(v string) (uint64, bool) {
if v == "" || strings.TrimLeft(v, "0123456789") != "" {
return 0, false
}
n, err := strconv.ParseUint(v, 10, 32)
return n, err == nil && n < 1<<32-1
}
// OwnerSettingProblems refuses, where a manifest is read, a directory whose owner carries a setting
// in any shape but a whole placeholder or number on each side of the colon, or names a key the module
// asks for nowhere else and declares no default for.
func OwnerSettingProblems(m Manifest) []string {
var problems []string
asked := settingKeysAskedOutsideOwners(m)
for k := range m.Settings {
asked[k] = true
}
for _, r := range m.Resources {
owner, _ := r["owner"].(string)
if !strings.Contains(owner, "${setting:") {
continue
}
if fmt.Sprint(r["type"]) != "directory" {
// Said by the placeholder sweep: only a directory's owner takes a setting.
continue
}
if !ownerWithSetting.MatchString(owner) {
problems = append(problems, fmt.Sprintf("%s: the directory %v's owner %q carries a setting in a shape "+
"the mesh does not fill; an owner names the account by number only, each side of the colon a whole "+
"${setting:key} or a whole number (${setting:puid}:${setting:pgid}) (novox/hq issue 279)",
m.Module, r["id"], owner))
continue
}
// A template's text is not here yet, so what the module asks for elsewhere cannot be known: WithTemplatesFrom
// parses its result again with every template filled, and this half is judged then (as config.go skips its
// "placed nowhere" rule while m.Templated()).
if m.Templated() {
continue
}
for _, key := range settingsUsed(owner) {
if !asked[key] {
problems = append(problems, fmt.Sprintf("%s: the directory %v's owner says ${setting:%s}, and the "+
"module asks for %q nowhere else and declares no default for it: an owner follows the identity "+
"the module's process is told it runs as (its identity file's ${setting:%s}), never a key of its "+
"own (novox/hq issue 279)", m.Module, r["id"], key, key, key))
}
}
}
return problems
}
// settingIntoOwner fills ${setting:…} in a directory's owner, refusing — with the key and why, the
// resource left as it was — a key nothing sets and a value that is not a whole number.
func settingIntoOwner(resource map[string]any, layers []Layer, module string) error {
owner, ok := resource["owner"].(string)
if !ok {
return nil
}
for _, key := range settingsUsed(owner) {
value, set := settingValue(layers, key)
if !set {
return &UnsetSettingError{Module: module, Setting: key,
where: fmt.Sprintf("a directory %v whose owner", resource["id"]), setToday: orNoSettings(layers),
said: fmt.Sprintf(
"%s has a directory %v whose owner says ${setting:%s}, and nothing sets %q for it — an operator's "+
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
"`settings set %s <file>` with {%q: …}%s",
module, resource["id"], key, key, module, key, orNoSettings(layers))}
}
v := plainly(value)
id, whole := wholeID(v)
if !whole {
return fmt.Errorf("%s: the setting %q is %q, which cannot own the directory %v: an owner a setting "+
"names is a uid or a gid, a whole number, never an account's name or other text (novox/hq issue 279)",
module, key, v, resource["id"])
}
// Written as the number parsed, so "0001" reaches the node-engine as 1, never as text.
owner = strings.ReplaceAll(owner, "${setting:"+key+"}", strconv.FormatUint(id, 10))
}
resource["owner"] = owner
return nil
}
func settingValue(layers []Layer, key string) (any, bool) {
var value any
set := false
for _, layer := range layers {
if v, has := layer.Values[key]; has {
value, set = v, true
}
}
return value, set
}
func orNoSettings(layers []Layer) string {
var keys []string
for _, l := range layers {
if l.Default {
continue
}
for k := range l.Values {
keys = append(keys, k)
}
}
if len(keys) == 0 {
return "; no setting is set for this module"
}
sort.Strings(keys)
return "; set today: " + strings.Join(keys, ", ")
}
// 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 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
}
// settingKeysAskedOutsideOwners is every key a module's files, units, contributions and served facts
// ask for: what a directory's owner may follow (OwnerSettingProblems).
func settingKeysAskedOutsideOwners(m 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)
}
}
}
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
}
@@ -0,0 +1,337 @@
package catalogue
import (
"fmt"
"strings"
"unicode"
)
// A mergeable file names the setting keys it takes (novox/hq ADR 0326, issue 168).
//
// Before this, every key of a module's layers merged into every one of its mergeable files: the
// mesh's own words, a key meant for a `${setting:<key>}` in another file, a key for a second
// configuration file. So a module could not own two configurable files, and every receiver had to
// ignore what it did not know. ADR 0164 §11 decided that a setting names the files that read it; this
// is that relation, stated from the file's side, so the operator's own values stay declared nowhere
// (ADR 0262 §2).
// TakesSettingsField is the key a mergeable file carries to name the top-level setting keys it takes.
const TakesSettingsField = "takes-settings"
// **A taken value may be laid under the file's own name** (issue 751). An entry of takes-config is a key, laid
// under that key, or {"key": <the config key>, "as": <the file's name for it>}: the key stays a config key,
// lower-case, and the file is written in its software's spelling, `atlassian-url` as ATLASSIAN_URL. Like every
// taken value it is laid only when a layer sets it, never from a default, so an unset one leaves the file's bytes
// and the code's own fallback (its environment) as they were; for that reason it is never `required`
// (ConfigProblems). The settings form has no such entry: it is the config form's alone.
// Fields of a renamed takes-config entry.
const (
takenKeyField = "key"
takenAsField = "as"
)
// takenEntry is one value a mergeable file takes: its key and the name the file has for it.
type takenEntry struct {
key, as string
renamed bool
}
// 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
}
// 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
}
// 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[TakesSettingsField]) {
keys = append(keys, e.key)
}
return keys
}
// takenKeys is the keys a resource names in takes-settings, each with the name the file has for it, and whether
// it names any list at all. An empty list is a list: that file takes no setting.
func takenKeys(resource map[string]any) (map[string]string, bool) {
raw, said := resource[TakesSettingsField]
if !said {
return nil, false
}
keys := map[string]string{}
for _, e := range takenEntries(raw) {
keys[e.key] = e.as
}
return keys, true
}
// onlyTaken is the layers with every key the file does not name left out, and each key it takes under the
// file's name for it. The file's own defaults and what the mesh reads itself are not touched here: settle
// already keeps both out of a file.
//
// **A layer said before its module moved still reaches the file** (issue 751): such a layer names a renamed value
// by the file's own spelling (nodered's timeZone on one machine), and until K5 moves the layer and renames the key
// that spelling is taken as the value's, so moving the module changes no machine's file. Where one layer says
// both, the config key wins.
func onlyTaken(layers []Layer, taken map[string]string) []Layer {
oldSpelling := map[string]string{}
for key, as := range taken {
if as != key {
oldSpelling[as] = key
}
}
kept := make([]Layer, 0, len(layers))
for _, layer := range layers {
values := map[string]any{}
for key, value := range layer.Values {
if as, takes := taken[key]; takes {
values[as] = value
continue
}
if newKey, old := oldSpelling[key]; old {
if _, said := layer.Values[newKey]; !said {
values[key] = value
}
}
}
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
}
return kept
}
// takenNames is every key a layer may say for the values a resource takes: each config key and, for a renamed
// one, the file's own spelling a layer said before its module moved (onlyTaken).
func takenNames(resource map[string]any) []string {
var names []string
for _, e := range takenEntries(resource[TakesSettingsField]) {
names = append(names, e.key)
if e.renamed {
names = append(names, e.as)
}
}
return names
}
// namesItsKeys says whether a module's mergeable files name the keys they take (ADR 0326 §2): true
// only when it has one and every one carries takes-settings. A module with a silent mergeable file
// keeps today's form (§3), where every key reaches every mergeable file.
func namesItsKeys(m Manifest) bool {
merging := 0
for _, r := range m.Resources {
if how, _ := r["merge"].(string); how == "" {
continue
}
merging++
if _, said := r[TakesSettingsField]; !said {
return false
}
}
return merging > 0
}
// TakenSettingsProblems is every way a definition's takes-settings is wrong (ADR 0326 §2 and §4), in
// its own words: on a file that does not merge, not a list of keys, a key named twice, one the mesh
// reads itself, one the file keeps for itself, or one mergeable file naming its keys beside another
// that names none.
func TakenSettingsProblems(m Manifest) []string {
return takenSettingsProblems(m)
}
// WrittenSettingsProblems is ADR 0326 §3, judged only where a definition is written — the module
// check, `module add`, a build taken in, the builder and the merge gate's registration — and never
// where a stored one is read again (ParseManifest, a build's manifest at a commit, a module put back):
// a module that declares a setting names the keys of every mergeable file it owns (novox/hq issue
// 574, ADR 0164 §11 and §15). A build stored before the rule, such as betterbird's before its accounts
// file named its keys, must still compose and still be put back; only its next change is held to it.
//
// Said only when none of the module's mergeable files names its keys: when one does, §2 already
// names every file that does not, and a file is not reported twice.
func WrittenSettingsProblems(m Manifest) []string {
if len(m.Settings) == 0 || m.DeclaresConfig() {
// A module in the config form is held to this where it is read, in its own words (ConfigProblems).
return nil
}
var silent []string
for _, r := range m.Resources {
how, _ := r["merge"].(string)
if how == "" {
continue
}
if _, said := r[TakesSettingsField]; said {
return nil
}
silent = append(silent, fmt.Sprint(r["id"]))
}
var problems []string
for _, id := range silent {
problems = append(problems, fmt.Sprintf("%s declares settings, and its mergeable %s names no %s: "+
"a module that declares a setting names the keys each mergeable file takes, an empty list "+
"for none (novox/hq ADR 0326 §3, ADR 0164 §11)", m.Module, id, TakesSettingsField))
}
return problems
}
// takenSettingsProblems is every rule of ADR 0326 §2 and §4, judged where a definition is checked and
// where a stored one is composed.
func takenSettingsProblems(m Manifest) []string {
var problems []string
var naming, silent []string
for _, r := range m.Resources {
id := fmt.Sprint(r["id"])
how, _ := r["merge"].(string)
raw, said := r[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, 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, 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, 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, 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, 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, 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, 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, TakesSettingsField, strings.Join(naming, ", ")))
}
}
return problems
}
// 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, 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, 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, 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, TakesSettingsField))
}
seen[e.key], landed[e.as] = true, true
return problems
}
// 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
}
// takenSaid is what a stray key's message adds for a module whose files name their keys: which keys
// each file takes, so the person sees where the key would have had to be named.
func takenSaid(m Manifest) string {
if !namesItsKeys(m) {
return ""
}
var files []string
for _, r := range m.Resources {
if how, _ := r["merge"].(string); how == "" {
continue
}
keys := takenKeyList(r)
if len(keys) == 0 {
files = append(files, fmt.Sprintf("%v takes none", r["id"]))
continue
}
files = append(files, fmt.Sprintf("%v takes %s", r["id"], strings.Join(keys, ", ")))
}
return " (" + strings.Join(files, "; ") + ")"
}
@@ -0,0 +1,655 @@
package catalogue
import (
"bytes"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// Settings are how somebody changes a config file a module owns, without editing it.
//
// Managed files are generated and never edited (novox/hq ADR 0011), so a person's intention has
// to be expressed somewhere the generator can see it. Then the file is produced from the module's
// defaults and that intention together, and upstream can rewrite its half freely.
//
// **An override beats a default, so there is nothing to resolve.** When the module changes a key
// somebody has set, the setting wins — it was a statement about that key, made deliberately, and
// the default was only ever what to do in the absence of one. This is the same shape as the rest
// of the mesh refusing to guess: there is no guess to make.
// Merge is how a file's content is combined with settings. A module names one per resource.
const (
// MergeJSON treats the content as a JSON document and merges settings into it by key.
MergeJSON = "json"
)
// Layer is one source of settings, in the order they are applied.
type Layer struct {
// Where these came from, for saying which layer set a value.
From string
Values map[string]any
// Default marks the layer a module's own defaults make (novox/hq ADR 0262). Marked rather than
// recognised by From, which is a node's name for a node's layer, and a node may be called anything.
Default bool
// Placement is the assignment's placement at this layer's scope — `places`, `accesses`, `networks`,
// `endpoints`, `expose`, `reach`, `ports` — which the controller holds apart from settings (novox/hq
// ADR 0348 clause 11, issues 651 and 736): read by their readers only, and never from Values. LayerOf
// splits a layer as a person says it into the two.
Placement map[string]any
}
// ApplySettings produces a resource's final content from the module's own and the layers over it.
//
// Layers are applied in order, so a later one beats an earlier one — the mesh's settings for a
// module first, then this node's. A node that differs from the rest is expressed by differing,
// rather than by repeating everything the rest already say.
func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, error) {
// Today's form reaches a mergeable file with every key of the layer as it was said, placement too
// (novox/hq ADR 0326 §3, ADR 0348 clause 11: a module not yet moved keeps it), so a file composes
// byte for byte as it did while placement was kept in the layer.
layers = asSaid(layers)
how, _ := resource["merge"].(string)
taken, names := takenKeys(resource)
if how == "" {
if names {
// Refused by the module check too (TakenSettingsProblems); said here as well, for a
// definition stored by an older binary, rather than handed to the host, which knows no
// such field.
return nil, fmt.Errorf("%v says %s and does not merge: only a mergeable file takes "+
"settings by key (novox/hq ADR 0326)", resource["id"], TakesSettingsField)
}
// Not a mergeable file. Settings for it are ignored rather than silently doing nothing
// somewhere else — see UnusedSettings, which is what says so.
return resource, nil
}
if names {
// **The file takes the keys it names, and no other** (novox/hq ADR 0326, issue 168): a key
// for another file, for a `${setting:<key>}` or for the mesh itself does not land here.
layers = onlyTaken(layers, taken)
}
if how != MergeJSON {
return nil, fmt.Errorf(
"%v says it merges as %q, and this control plane knows how to merge %q",
resource["id"], how, MergeJSON)
}
content, _ := resource["content"].(string)
var base map[string]any
if strings.TrimSpace(content) == "" {
base = map[string]any{}
} else if err := json.Unmarshal([]byte(content), &base); err != nil {
return nil, fmt.Errorf("%v says it merges as JSON and its content is not JSON: %w",
resource["id"], err)
}
protected := map[string]bool{}
for _, k := range stringsOf(resource["protected"]) {
protected[k] = true
}
merged, err := settle(base, layers, protected, fmt.Sprint(resource["id"]))
if err != nil {
return nil, err
}
out := map[string]any{}
for k, v := range resource {
out[k] = v
}
// Keys sorted by the encoder, so the same settings always produce the same bytes. A file
// whose lines move for no reason makes every reconcile look like a change, and anything
// reflecting it would restart for ever.
rendered, err := json.MarshalIndent(merged, "", " ")
if err != nil {
return nil, err
}
out["content"] = string(rendered) + "\n"
delete(out, "merge")
delete(out, "protected")
delete(out, TrustedField)
delete(out, TakesSettingsField)
return out, nil
}
// Settle fills what a provider serves from its settings. Exported because a served fact is settled
// where the mesh is walked rather than where a node is declared.
//
// **What a provider serves is its definition's; a layer replaces none of it** (novox/hq ADR 0348
// clause 11, issue 696). A served value is filled from the layers only where the definition asks,
// with `${setting:<key>}` (ADR 0155) — a mail provider's domain, an identity provider's issuer, a
// time-series store's org and bucket — and a layer that sets a key the provider serves as a literal
// changes nothing a consumer is told. Nor does a setting add a key (novox/hq 04-ISSUES/173): a
// setting made for one of the provider's files — a site name, a public address — is not part of the
// contract. Until issue 696, a layer replaced any served key it named; influxdb on ace was the one
// layer relying on it, and serves `${setting:org}` and `${setting:bucket}` since.
func Settle(base map[string]any, layers []Layer) (map[string]any, error) {
return filledFromSettings(deepCopy(base), layers, "what is served")
}
// overridden lays settings over a contribution: a map whose keys are its contract. Only the keys the
// map already declares are touched; the rest of a layer is somebody else's business (a file's,
// another destination's) and is left to reach it there.
//
// A declared value may itself be the operator's, `${setting:<key>}` (ADR 0155), filled after the
// overrides (filledFromSettings).
func overridden(base map[string]any, layers []Layer, what string) (map[string]any, error) {
kept := make([]Layer, 0, len(layers))
for _, layer := range layers {
values := map[string]any{}
for key, value := range layer.Values {
if _, declared := base[key]; declared {
values[key] = value
}
}
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
}
merged, err := settle(base, kept, nil, what)
if err != nil {
return nil, err
}
return filledFromSettings(merged, layers, what)
}
// filledFromSettings fills each `${setting:<key>}` in a map's top-level strings from the layers,
// later beating earlier and a module's defaults lowest, and refuses one nothing sets by name — a
// literal placeholder handed to a consumer is a service configured against a string nobody meant.
// It fills merged in place and returns it.
func filledFromSettings(merged map[string]any, layers []Layer, what string) (map[string]any, error) {
for key, value := range merged {
s, ok := value.(string)
if !ok {
continue
}
for _, asked := range settingsUsed(s) {
v, set := settingValue(layers, asked)
if !set {
return nil, fmt.Errorf(
"%s says ${setting:%s} for %q, and nothing sets %q — an operator's value is the "+
"assignment's, never the definition's (novox/hq ADR 0112)%s",
what, asked, key, asked, orNoSettings(layers))
}
s = strings.ReplaceAll(s, "${setting:"+asked+"}", plainly(v))
}
merged[key] = s
}
return merged, nil
}
// settle lays the layers over a module's own values, in order.
//
// Shared by a file's content and a module's contributions, because they are the same act: the
// module says what it means by default, and somebody says what it means here. A contribution that
// could not be settled would have to be edited to be reused anywhere else. The two differ in one
// respect, and the caller decides it: a file takes keys it did not declare (a setting may add to a
// configuration), a contribution does not (overridden). A served fact takes no layer at all; only
// its ${setting:<key>} is filled (Settle).
func settle(base map[string]any, layers []Layer, protected map[string]bool, what string) (
map[string]any, error) {
merged := deepCopy(base)
for _, layer := range layers {
if layer.Default {
// A module's own defaults fill ${setting:<key>} only (novox/hq ADR 0262). A mergeable
// file's content already is its defaults, and a contribution or served fact declares its
// own; laid on here, a default would reach every mergeable file of its module (issue 168).
continue
}
for key, value := range layer.Values {
if key == PortsSetting {
// Where the machine puts a port is the mesh's to apply, not a value for a file or
// for what a consumer is told (novox/hq ADR 0100); it reaches both as the port.
continue
}
if protected[key] {
// The module said it must own this one. Refused rather than ignored: a setting
// that is quietly dropped is somebody believing they changed something.
return nil, fmt.Errorf(
"%s sets %q on %v, and that module keeps %q for itself — it is not settable",
layer.From, key, what, key)
}
// **No layer replaces a secret the vault fills, at any depth** (novox/hq issue 610): a
// mergeable file's `${secret:<name>}` is the vault's value, and a layer that reached it —
// `server.secret_key` under a `server` the file takes — would put a value the operator or an
// agent chose where the vault's belongs. Judged against the file's own content, so it holds
// whatever a module's `protected` list says.
if at := secretReplacedBy(base[key], value, key); at != "" {
return nil, fmt.Errorf(
"%s sets %q on %v, which the vault fills (${secret:…}); a secret comes from the vault, "+
"never from a setting (novox/hq issues 342, 610)", layer.From, at, what)
}
merged[key] = mergeValue(merged[key], value)
}
}
return merged, nil
}
// mergeValue combines one value with the one over it.
//
// Two objects merge key by key, so setting one field of a nested block does not delete its
// siblings. Anything else is replaced whole: a list that merged element-wise could neither be
// shortened nor reordered, and there is no correct guess about which element is "the same one".
func mergeValue(under, over any) any {
underMap, isUnderMap := under.(map[string]any)
overMap, isOverMap := over.(map[string]any)
if !isUnderMap || !isOverMap {
return over
}
merged := deepCopy(underMap)
for k, v := range overMap {
merged[k] = mergeValue(merged[k], v)
}
return merged
}
func deepCopy(in map[string]any) map[string]any {
out := map[string]any{}
for k, v := range in {
if nested, ok := v.(map[string]any); ok {
out[k] = deepCopy(nested)
continue
}
out[k] = v
}
return out
}
// settingsHoldOneLine refuses a line break, a carriage return, any other line end (lineEnds) or a NUL in any
// string of any setting, for every
// module, at any depth, keys as well as values, in an object or a list (novox/hq issue 339). A value is
// substituted into env and configuration files the node-engine writes as root — an app's env file, a logind
// drop-in — and a line break there is a line of the caller's own: a directive, an assignment, a section. A NUL
// ends a string early wherever C reads it. Judged where a layer is kept and again where it is composed, so a
// layer that holds one, however it got into the store, is said with its key. What must hold lines is a file
// of the module's own, never a setting — with one exception, the certificate authority's root as certificates
// (certificates), because that is how step-ca serves it.
func settingsHoldOneLine(module string, layers []Layer) error {
for _, layer := range layers {
for _, key := range sortedKeysAny(layer.Values) {
if module == rootModule && key == rootSetting {
if text, ok := layer.Values[key].(string); ok && certificates(text) {
continue
}
}
if at := lineBreakIn(layer.Values[key], key); at != "" {
return fmt.Errorf("%s: the setting %s in %q holds a line break, a carriage return, another line end or a NUL, which a "+
"file it is written into would read as a line of its own; a setting is one line (novox/hq "+
"issue 339)", module, at, layer.From)
}
}
}
return nil
}
// lineEnds are every character a reader may take as the end of a line: \n and \r, vertical tab and form feed,
// NEL, and the Unicode line and paragraph separators; and NUL, which ends a string early wherever C reads it.
const lineEnds = "\n\r\v\f\x00\u0085\u2028\u2029"
// rootSetting is the one setting that may hold lines: the certificate authority's root, which step-ca serves to
// its consumers as the setting `root` and which they write into a bundle file (the co-located path, issue 038).
const (
rootModule = "step-ca"
rootSetting = "root"
)
// certificates says whether a value is one or more PEM CERTIFICATE blocks and nothing else: each decodes with
// encoding/pem, carries no headers, and parses with x509.ParseCertificate; nothing but a final line break
// surrounds them, and every line ends with \n alone.
func certificates(v string) bool {
if strings.ContainsAny(v, "\r\v\f\x00\u0085\u2028\u2029") {
return false
}
rest := []byte(v)
found := 0
for len(rest) > 0 {
if !bytes.HasPrefix(rest, []byte("-----BEGIN ")) {
return false
}
block, after := pem.Decode(rest)
if block == nil || block.Type != "CERTIFICATE" || len(block.Headers) > 0 {
return false
}
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
return false
}
found++
rest = after
}
return found > 0
}
// lineBreakIn is where the first string under v holding \n, \r or NUL is, or "".
func lineBreakIn(v any, at string) string {
if strings.ContainsAny(at, lineEnds) {
return strconv.Quote(at)
}
switch t := v.(type) {
case string:
if strings.ContainsAny(t, lineEnds) {
return at
}
case map[string]any:
for _, k := range sortedKeysAny(t) {
if found := lineBreakIn(t[k], at+"."+k); found != "" {
return found
}
}
case []any:
for i, e := range t {
if found := lineBreakIn(e, fmt.Sprintf("%s[%d]", at, i)); found != "" {
return found
}
}
}
return ""
}
func sortedKeysAny(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// UnusedSettings names settings that reach nothing.
//
// Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed
// nothing — and would find out by the machine not behaving differently, which is the slowest
// way there is. This is what makes that visible at the moment they set it.
//
// Where a key can land: any mergeable file takes any key; a file asking for `${setting:<key>}`
// takes that key (ADR 0155); a contribution takes a key it declares, and no other (novox/hq
// 04-ISSUES/173); a served fact takes only the keys it asks for as `${setting:<key>}`, since a layer
// replaces no served key (novox/hq issue 696); and the mesh's own words — `expose`, `ports`, `reach`, `endpoints` —
// are read by the mesh. A key none of those takes is stray, and is said so rather than dropped.
func UnusedSettings(m Manifest, layers []Layer) []string {
named := namesItsKeys(m)
for _, r := range m.Resources {
if how, _ := r["merge"].(string); how != "" && !named {
// Today's form (novox/hq ADR 0326 §3, ADR 0164 §15): a mergeable file that names no
// keys takes every key, so nothing is stray.
return nil
}
}
// A computed module has no resources here to look at — they are worked out per node, and
// whether a setting lands is not knowable until then. Silence rather than a wrong answer:
// claiming every setting on the private network is stray would be worse than saying nothing.
if m.Computed != "" {
return nil
}
lands := settingKeysUsedBy(m)
// A mergeable file takes the keys it names (novox/hq ADR 0326 §5).
for _, r := range m.Resources {
for _, key := range takenNames(r) {
lands[key] = true
}
}
// A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it
// overrides the default, which is how a running module gains a setting without a gap between.
for key := range m.Settings {
lands[key] = true
}
for _, values := range m.Contributes {
for key := range values {
lands[key] = true
}
}
for _, locals := range m.ContributesMany {
for _, values := range locals {
for key := range values {
lands[key] = true
}
}
}
var unused []string
for _, layer := range layers {
for key := range layer.Whole() {
if lands[key] {
continue
}
// `expose` is a real destination for a module that listens: it overrides a port's
// source (novox/hq ADR 0046), validated in Exposure, so it is not stray here.
if key == ExposeSetting && len(m.Listens) > 0 {
continue
}
// `ports` gives a module's port a machine port on one node (novox/hq ADR 0100),
// validated in GivenPorts, so it is not stray here either.
if key == PortsSetting {
continue
}
// `reach` says how far one of this module's endpoints reaches (novox/hq ADR 0138) — the
// filter's source, which names are composed, and therefore which authority certifies
// them. Validated in Reaches, so not stray.
if key == ReachSetting && len(m.Listens) > 0 {
continue
}
// `endpoints` configures a module's endpoints by name — the machine port, the subdomain and
// the reach as one block each (novox/hq ADR 0138). Validated in Endpoints, so not stray.
if key == EndpointsSetting && len(m.Listens) > 0 {
continue
}
// `places` puts a declared directory where this machine keeps it, `accesses` says where
// the operator's data is (novox/hq issue 153). Validated in Places and AccessPlaces.
if key == PlacesSetting && len(directoriesOf(m)) > 0 {
continue
}
if key == AccessesSetting && len(m.Accesses) > 0 {
continue
}
// `networks` keeps a found network for a taken container on one adopted machine
// (novox/hq ADR 0163). Validated in KeptNetworks, so not stray.
if key == NetworksSetting {
continue
}
unused = append(unused, fmt.Sprintf(
"%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+
"declares no %q in what it contributes%s",
layer.From, key, m.Module, key, key, takenSaid(m)))
}
}
sort.Strings(unused)
return unused
}
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
}
// NetworksSetting is the settings key that keeps a found network for a taken container, on one
// adopted machine (novox/hq ADR 0163, rule 4):
//
// {"networks": {"server": ["predecessor_default"]}}
//
// has the module's container `server` also join `predecessor_default` once taken, so a neighbour
// that resolves it by name on that network keeps resolving it. Migration scaffolding in the sense
// of ADR 0104: assigned only on an adopted machine, reported while it stands, removed when the
// neighbours are taken. Keyed by the container's resource id; the value is the networks it keeps.
const NetworksSetting = "networks"
// KeptNetworks reads which found networks each of a module's containers keeps, by container id.
//
// Refused from a mesh-wide layer — a found network is a fact about one machine — for an id the
// module declares no container under, for a name that is not a network's, and on a machine that
// is not adopted: the setting exists so neighbours the mesh has not taken yet keep reaching the
// container, and a converged machine has no such neighbours.
func KeptNetworks(m Manifest, layers []Layer, adopted bool) (map[string][]string, error) {
containers := map[string]bool{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "container" {
containers[fmt.Sprint(r["id"])] = true
}
}
out := map[string][]string{}
for _, layer := range layers {
raw, ok := layer.Placement[NetworksSetting]
if !ok {
continue
}
if layer.From == MeshWideLayer {
return nil, fmt.Errorf("%s: %s is given per node — a found network is a fact about one "+
"machine; set it with --node", m.Module, NetworksSetting)
}
if !adopted {
return nil, fmt.Errorf("%s: %s keeps a found network for neighbours the mesh has not taken "+
"yet, and %s is converged — nothing on it is found; clear the setting", m.Module,
NetworksSetting, layer.From)
}
blocks, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: %s is a { container: [network, …] } map, and %q set it to "+
"something else", m.Module, NetworksSetting, layer.From)
}
for id, body := range blocks {
if !containers[id] {
return nil, fmt.Errorf("%s: %s names the container %q, which it does not declare — "+
"the setting reaches nothing; it declares %s", m.Module, NetworksSetting, id,
orNothing(sortedKeys(containers)))
}
names := stringsOf(body)
if len(names) == 0 {
return nil, fmt.Errorf("%s: %s for %q is a list of network names, and %q set it to %v",
m.Module, NetworksSetting, id, layer.From, body)
}
for _, n := range names {
if !networkName.MatchString(n) {
return nil, fmt.Errorf("%s: %s for %q names %q, which is not a network name",
m.Module, NetworksSetting, id, n)
}
}
sort.Strings(names)
out[id] = names
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// networkName is what a container runtime accepts as a network's name.
var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
// JudgeSettings composes a module's settings against its definition and refuses the first thing
// that cannot work, naming the module, the layer and the key (novox/hq ADR 0163, rule 6).
//
// **The same judgement where a setting is stored and where a machine is declared.** Stored, a
// setting that cannot compose is refused before it is kept; composed later, a definition that has
// moved under a stored setting leaves that module out of the machine's declaration rather than
// the machine without one. Every reader of settings runs here: a port given, an exposure, a reach,
// an endpoint, a placement, an access, a kept network, a mergeable file's keys and a file's
// `${setting:…}`. A key that reaches nothing is not here: it cannot break a composition, so it is
// refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read,
// and never costs a module its place.
func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
// Placement is judged for line ends as it always was: a path is written into files as well.
if err := settingsHoldOneLine(m.Module, asSaid(layers)); err != nil {
return err
}
// Which keys each mergeable file takes is the definition's to say right (novox/hq ADR 0326);
// judged here too for a definition stored by an older binary.
if problems := takenSettingsProblems(m); len(problems) > 0 {
return fmt.Errorf("%s", problems[0])
}
// With no layers too: a definition may ask for a setting nobody made — an access placed by
// nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing.
if _, err := GivenPorts(m, layers); err != nil {
return err
}
if _, err := Reaches(m, layers); err != nil {
return err
}
if _, err := Endpoints(m, layers); err != nil {
return err
}
if _, err := Places(m, layers); err != nil {
return err
}
if _, _, err := accessesFor(m, layers); err != nil {
return err
}
if _, err := KeptNetworks(m, layers, adopted); err != nil {
return err
}
for _, r := range m.Resources {
settled, err := ApplySettings(r, layers)
if err != nil {
return err
}
copied := map[string]any{}
for k, v := range settled {
copied[k] = v
}
if err := unsetInConfigWords(settingInto(copied, WithDefaults(m, layers), m.Module), m); err != nil {
return err
}
}
return nil
}
// secretReplacedBy is the path in a mergeable file's own content that a layer's value would replace while the
// vault fills it — a string holding `${secret:` — or "" when the value leaves every such string as it is
// (novox/hq issue 610). Maps merge key by key (mergeValue), so only the keys the value names are judged; any
// other value replaces the whole of what it lands on.
func secretReplacedBy(base, value any, at string) string {
if baseMap, isMap := base.(map[string]any); isMap {
if valueMap, also := value.(map[string]any); also {
keys := make([]string, 0, len(valueMap))
for k := range valueMap {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if found := secretReplacedBy(baseMap[k], valueMap[k], at+"."+k); found != "" {
return found
}
}
return ""
}
}
return secretIn(base, at)
}
// secretIn is the first path under v holding a vault placeholder, or "".
func secretIn(v any, at string) string {
switch t := v.(type) {
case string:
if strings.Contains(t, "${secret:") {
return at
}
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if found := secretIn(t[k], at+"."+k); found != "" {
return found
}
}
case []any:
for i, item := range t {
if found := secretIn(item, fmt.Sprintf("%s[%d]", at, i)); found != "" {
return found
}
}
}
return ""
}
@@ -0,0 +1,99 @@
package catalogue
// A machine's shares, and the shares a machine mounts (novox/hq ADR 0263).
//
// **Two roles, one per side of the wire.** A machine that shares a directory with the mesh does it
// through the holder of `node-nfs-server`: it writes the machine's export file, runs the NFS service,
// opens its port to the private network only, and provides each share as the provision `nfs-share`. A
// machine that wants the files gets them through the holder of `node-mounts`: it writes a mount unit and
// an automount unit per share, so nothing mounts at boot and nothing can fail a boot, and it says a
// device that comes and goes is absent rather than failed.
//
// **One holder per machine on each side.** Two modules writing one machine's export file, or two writing
// mount units for one mount point, is the conflict a seat exists to refuse. Both seats deliver nothing:
// `nfs-share` is provided at the mesh's scope, by the module holding `node-nfs-server` on the machine that
// shares, and a seat at a node's scope cannot be the answer for a provision at the mesh's.
//
// **The data is the operator's** (ADR 0051). Neither holder creates, chowns or removes anything under a
// shared path; the export maps every client to the path's owner, so no client acts as another account on
// the server. Their verbs read, and the ones that act take over what a person wrote by hand only on a
// person's word: a dataset's export property, an fstab line.
// NFSServerSeat is the role of the machine that shares directories over NFS (novox/hq ADR 0263).
const NFSServerSeat = "node-nfs-server"
// MountsSeat is the role that mounts a machine's shares and occasional sources (novox/hq ADR 0263).
const MountsSeat = "node-mounts"
// nfsServerVerbs is the contract every holder of node-nfs-server serves (novox/hq ADR 0263).
func nfsServerVerbs() []Verb {
return []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() []Verb {
return []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"}},
}
}
+169
View File
@@ -0,0 +1,169 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"strings"
)
// What a module may call its state, and whose state it may ask to read (novox/hq ADR 0201).
//
// A module names its state **locally** — `servers`, never a bucket or a subject — and another
// module's as `<module>.<name>`, the way a consumed event names its emitter (design 32 §1). The
// mesh derives the bucket from the two names, so the module and the local name must each be one
// token: the bucket joins them with an underscore, which neither may contain, so two modules can
// never derive one bucket.
// stateName is one local name of a module's state: lower-case, no dot, no underscore.
var stateName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// The mesh's caps on what a module may ask of a bucket's history.
const (
// StateMostHistory is the most past values a key may keep. The server's own limit.
StateMostHistory = 64
)
// StateDeclaration is one bucket a module owns: its local name, and the options that are the
// owner's to choose, as a seat chooses how long its backlog survives (design 32 §3).
type StateDeclaration struct {
Name string `json:"name"`
// History is how many values a key keeps, the current one included; zero is one.
History int `json:"history,omitempty"`
// TTLSeconds is how long a value lives once written; zero is until it is replaced or deleted.
TTLSeconds int `json:"ttl-seconds,omitempty"`
// PerMachine says the state is keyed by machine: each machine's instance writes the key named for
// its machine and no other, and the bus grants it that key alone (novox/hq ADR 0260).
PerMachine bool `json:"per-machine,omitempty"`
// KeyTTL says each key of the bucket lives the time its writer gives it, and the server writes a delete marker
// when it ends (novox/hq ADR 0433 §1): the bucket is made with the server's time to live per message.
KeyTTL bool `json:"key-ttl,omitempty"`
}
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}.
func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) > 0 && trimmed[0] == '"' {
return json.Unmarshal(trimmed, &s.Name)
}
type plain StateDeclaration
var full plain
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&full); err != nil {
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: %w", typedUnknown(err))
}
*s = StateDeclaration(full)
return nil
}
// MarshalJSON writes back the short form when there is nothing else to say.
func (s StateDeclaration) MarshalJSON() ([]byte, error) {
if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL {
return json.Marshal(s.Name)
}
type plain StateDeclaration
return json.Marshal(plain(s))
}
// ReadState splits a read into the owning module and the local name, or says why it is not one.
func ReadState(read string) (module, local string, err error) {
at := strings.LastIndex(read, ".")
if at <= 0 || at == len(read)-1 {
return "", "", fmt.Errorf("%q does not name a module and its state: a read is <module>.<name>", read)
}
module, local = read[:at], read[at+1:]
if !stateName.MatchString(module) {
return "", "", fmt.Errorf("%q cannot own state: a module whose state is read is one plain name", module)
}
if !stateName.MatchString(local) {
return "", "", fmt.Errorf("%q is not a state name: lower-case letters, digits and hyphens", local)
}
return module, local, nil
}
// StateProblems is what is wrong with a manifest's state and reads.
//
// Refused at registration, because a bucket name the bus cannot hold is a module that installs,
// starts, and is refused on its first write with a reason about a bucket nobody named.
func StateProblems(m Manifest) []string {
var problems []string
if len(m.State) > 0 && !stateName.MatchString(m.Module) {
problems = append(problems, fmt.Sprintf(
"%s keeps state, and a module's name is part of its buckets' names, which take one plain "+
"name — no dot (novox/hq ADR 0201)", m.Module))
}
seen := map[string]bool{}
for _, s := range m.State {
switch {
case !stateName.MatchString(s.Name):
problems = append(problems, fmt.Sprintf(
"%s keeps state %q: a state is named locally — lower-case letters, digits and hyphens, "+
"no dot and no underscore; the mesh derives the bucket (novox/hq ADR 0201)", m.Module, s.Name))
case seen[s.Name]:
problems = append(problems, fmt.Sprintf("%s keeps state %q twice", m.Module, s.Name))
}
seen[s.Name] = true
if s.History < 0 || s.History > StateMostHistory {
problems = append(problems, fmt.Sprintf(
"%s keeps %d values of %q; a key keeps between 1 and %d", m.Module, s.History, s.Name, StateMostHistory))
}
if s.TTLSeconds < 0 {
problems = append(problems, fmt.Sprintf("%s gives %q a negative lifetime", m.Module, s.Name))
}
}
for _, r := range m.Reads {
module, _, err := ReadState(r)
if err != nil {
problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err))
continue
}
if module == m.Module {
problems = append(problems, fmt.Sprintf(
"%s reads %q, which is its own state: a module reads and writes what it keeps already", m.Module, r))
}
}
return problems
}
// ControllerMirrors are the controller's record mirrors a module may read, each `mesh-controller.<name>`
// (novox/hq ADR 0289, design 54 §5, design 57 F3). **The controller keeps them, not its manifest's `state`**:
// they are asserted with the controller's own buckets, with options of their own, and written by the
// controller's account alone (the writers table); declared as a module's state, they would be asserted a
// second way and granted to the module's own principal as well. Named here so a read of one is a read of
// kept state. The bus's list is broker.ControllerMirrors; a test keeps the two agreeing.
var ControllerMirrors = []string{"assignments", "declared-config", "predefined", "builds", "rollouts", "checks-running"}
// StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no
// such state. An absent owner says nothing — a module may be installed long before the one whose
// state it reads, as a consumer may before its emitter (design 32 §1).
func StateReadsNothingDeclares(manifests []Manifest) []string {
declared := map[string]map[string]bool{}
for _, m := range manifests {
own := map[string]bool{}
for _, s := range m.State {
own[s.Name] = true
}
declared[m.Module] = own
}
if own, present := declared[ControllerSeatName]; present {
for _, name := range ControllerMirrors {
own[name] = true
}
}
var problems []string
for _, m := range manifests {
for _, r := range m.Reads {
module, local, err := ReadState(r)
if err != nil {
continue
}
if own, present := declared[module]; present && !own[local] {
problems = append(problems, fmt.Sprintf(
"%s reads %q, and %s keeps no state called %q", m.Module, r, module, local))
}
}
}
return problems
}
@@ -0,0 +1,632 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
"slices"
"strings"
)
// A module's own stream (novox/hq ADR 0363).
//
// A module declares a stream as it declares a log (ADR 0297) or a bucket (ADR 0201): named locally, the
// events it carries named locally as `emits` names them, its age and its size within the mesh's bounds.
// The controller creates it and stays the only writer of stream definitions. A stream may be restricted
// by a trusted config value of its own module: then a consumer hears a part of it only where its own
// `consumes` asks and that value names it.
// The mesh's bounds on a module's stream: its age in days and its size in MiB, and what it gets when it
// says nothing.
const (
StreamLeastDays = 1
StreamMostDays = 30
StreamDefaultDays = 7
StreamLeastMiB = 1
StreamMostMiB = 8192
StreamDefaultMiB = 1024
)
// GrantKey is the name in a grant's pattern that each key of the config value fills.
const GrantKey = "{key}"
// StreamRetentionWorkQueue is a stream's declared retention (novox/hq ADR 0436 §6): a message is removed
// once a consumer acknowledges it, and the bus refuses a new one rather than dropping what it holds. Empty
// is the mesh's own limits retention — kept until it ages or the stream fills, dropping its oldest.
const StreamRetentionWorkQueue = "work-queue"
// StreamLeastDeliveries and StreamMostDeliveries bound a work-queue stream's max-deliveries (novox/hq ADR
// 0436 §6): how many times a message is handed over before it is given up as a dead letter.
const (
StreamLeastDeliveries = 1
StreamMostDeliveries = 10
)
// ReservedStreamPrefixes are the starts of the stream names the mesh derives, which no module's stream
// may take: a seat's work queue, a log, a bucket, an object store, and the dead letters.
var ReservedStreamPrefixes = []string{"SEAT_", "LOG_", "KV_", "OBJ_", "DEAD_"}
// MeshStreamNames are the mesh's own streams by name. Written here as well as in the broker, which this
// package cannot import; a test keeps them agreeing.
var MeshStreamNames = []string{"CONTROL", "NODES", "ASSIGNMENTS", "EVENTS", "DEAD_LETTER_NOTICES", "DEAD_LETTERS", "PROV_APPLIED", "CHECKS_READY", "CHECKS_OTHER"}
// StreamDeclaration is one stream a module owns.
type StreamDeclaration struct {
Name string `json:"name"`
// Events are the events it carries, named locally; `*` is one name and `**`, last only, the rest.
Events []string `json:"events"`
// MaxAgeDays and MaxMiB bound it; zero is the default.
MaxAgeDays int `json:"max-age-days,omitempty"`
MaxMiB int `json:"max-mib,omitempty"`
// Grant restricts it, when said.
Grant *StreamGrant `json:"grant,omitempty"`
// Retention is how the stream decides what to keep when it fills (novox/hq ADR 0436 §6): empty for the
// mesh's own limits retention (dropping its oldest), or StreamRetentionWorkQueue.
Retention string `json:"retention,omitempty"`
// MaxDeliveries bounds how many times a work-queue message is handed over, 1 to 10; refused without
// retention work-queue (novox/hq ADR 0436 §6).
MaxDeliveries int `json:"max-deliveries,omitempty"`
// Duplicates is a work-queue stream's duplicate window in hours, at most its maximum age; refused
// without retention work-queue (novox/hq ADR 0436 §6).
Duplicates int `json:"duplicates,omitempty"`
}
// StreamGrant says which trusted config value of the module grants a consumer a part of the stream, and
// which part each of its keys names.
type StreamGrant struct {
// Config is the trusted config value of the module that grants it (ADR 0348 §8): set only at the
// controller's terminal or on the operator's Approve.
Config string `json:"config"`
// Events is a pattern holding GrantKey exactly once, which each key of the config value fills.
Events string `json:"events"`
}
// UnmarshalJSON reads a stream strictly: an unknown key is refused here, not passed over.
func (s *StreamDeclaration) UnmarshalJSON(raw []byte) error {
type plain StreamDeclaration
var p plain
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("a stream is {name, events, max-age-days, max-mib, grant, retention, max-deliveries, "+
"duplicates}: %w", typedUnknown(err))
}
*s = StreamDeclaration(p)
return nil
}
// UnmarshalJSON reads a grant strictly.
func (g *StreamGrant) UnmarshalJSON(raw []byte) error {
type plain StreamGrant
var p plain
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("a stream's grant is {config, events}: %w", typedUnknown(err))
}
*g = StreamGrant(p)
return nil
}
// Days is the stream's age in days, the default where none is said.
func (s StreamDeclaration) Days() int {
if s.MaxAgeDays == 0 {
return StreamDefaultDays
}
return s.MaxAgeDays
}
// Cap is the stream's size in MiB, the default where none is said.
func (s StreamDeclaration) Cap() int {
if s.MaxMiB == 0 {
return StreamDefaultMiB
}
return s.MaxMiB
}
// BusName is the stream's name on the bus: its local name in capitals, a hyphen an underscore.
func (s StreamDeclaration) BusName() string { return StreamBusName(s.Name) }
// StreamBusName is a local stream name as the bus holds it.
func StreamBusName(local string) string {
return strings.ToUpper(strings.ReplaceAll(local, "-", "_"))
}
// Carries says whether an event, or every event a pattern names, is one this stream carries: within one
// of its patterns.
func (s StreamDeclaration) Carries(pattern string) bool {
for _, p := range s.Events {
if within(pattern, p) {
return true
}
}
return false
}
// StreamProblems is what is wrong with a manifest's streams, judged on the one manifest.
func StreamProblems(m Manifest) []string {
if len(m.Streams) == 0 {
return nil
}
var problems []string
if !stateName.MatchString(m.Module) {
problems = append(problems, fmt.Sprintf(
"%s declares a stream, and a module's name is part of its stream's subjects, which take one plain "+
"name — no dot (novox/hq ADR 0363)", m.Module))
}
seen := map[string]bool{}
terminal := TerminalKeys(m)
for i, s := range m.Streams {
switch {
case !stateName.MatchString(s.Name):
problems = append(problems, fmt.Sprintf(
"%s declares stream %q: a stream is named locally — lower-case letters, digits and hyphens "+
"(novox/hq ADR 0363)", m.Module, s.Name))
case seen[s.BusName()]:
problems = append(problems, fmt.Sprintf("%s declares stream %q twice", m.Module, s.Name))
case slices.Contains(MeshStreamNames, s.BusName()):
problems = append(problems, fmt.Sprintf(
"%s declares stream %q, which is the mesh's own stream %s (novox/hq ADR 0363)", m.Module, s.Name, s.BusName()))
default:
for _, prefix := range ReservedStreamPrefixes {
if strings.HasPrefix(s.BusName(), prefix) {
problems = append(problems, fmt.Sprintf(
"%s declares stream %q, whose name on the bus starts with %s, which the mesh derives names "+
"with (novox/hq ADR 0363)", m.Module, s.Name, prefix))
}
}
}
seen[s.BusName()] = true
if s.MaxAgeDays != 0 && (s.MaxAgeDays < StreamLeastDays || s.MaxAgeDays > StreamMostDays) {
problems = append(problems, fmt.Sprintf("%s keeps stream %q %d days; a stream keeps between %d and %d "+
"(novox/hq ADR 0363)", m.Module, s.Name, s.MaxAgeDays, StreamLeastDays, StreamMostDays))
}
if s.MaxMiB != 0 && (s.MaxMiB < StreamLeastMiB || s.MaxMiB > StreamMostMiB) {
problems = append(problems, fmt.Sprintf("%s caps stream %q at %d MiB; a stream holds between %d and %d "+
"(novox/hq ADR 0363)", m.Module, s.Name, s.MaxMiB, StreamLeastMiB, StreamMostMiB))
}
// A stream may declare retention: work-queue (novox/hq ADR 0436 §6): a message removed once a
// consumer acknowledges it, the bus refusing a new one when full rather than dropping its oldest, with
// max-deliveries (1 to 10) and a duplicate window (duplicates, hours, at most its maximum age) -- both
// meaningless, and refused, without it. A work-queue stream is read by its one consumer, never granted.
switch s.Retention {
case "", StreamRetentionWorkQueue:
default:
problems = append(problems, fmt.Sprintf(
"%s's stream %q declares retention %q; a stream's retention is %q or nothing (novox/hq ADR 0436 §6)",
m.Module, s.Name, s.Retention, StreamRetentionWorkQueue))
}
switch {
case s.Retention == StreamRetentionWorkQueue &&
(s.MaxDeliveries < StreamLeastDeliveries || s.MaxDeliveries > StreamMostDeliveries):
problems = append(problems, fmt.Sprintf(
"%s's stream %q declares max-deliveries %d; a work-queue stream's max-deliveries is between %d "+
"and %d (novox/hq ADR 0436 §6)", m.Module, s.Name, s.MaxDeliveries, StreamLeastDeliveries,
StreamMostDeliveries))
case s.Retention != StreamRetentionWorkQueue && s.MaxDeliveries != 0:
problems = append(problems, fmt.Sprintf(
"%s's stream %q declares max-deliveries %d without retention %q (novox/hq ADR 0436 §6)",
m.Module, s.Name, s.MaxDeliveries, StreamRetentionWorkQueue))
}
switch {
case s.Duplicates != 0 && s.Retention != StreamRetentionWorkQueue:
problems = append(problems, fmt.Sprintf(
"%s's stream %q declares duplicates %d hours without retention %q (novox/hq ADR 0436 §6)",
m.Module, s.Name, s.Duplicates, StreamRetentionWorkQueue))
case s.Duplicates != 0 && s.Duplicates > s.Days()*24:
problems = append(problems, fmt.Sprintf(
"%s's stream %q keeps duplicates %d hours, longer than its maximum age of %d days (novox/hq "+
"ADR 0436 §6)", m.Module, s.Name, s.Duplicates, s.Days()))
}
if s.Retention == StreamRetentionWorkQueue && s.Grant != nil {
problems = append(problems, fmt.Sprintf(
"%s's stream %q declares retention %q carrying a grant; a work-queue stream is read by its "+
"module's one consumer, never granted (novox/hq ADR 0436 §6)", m.Module, s.Name, s.Retention))
}
if len(s.Events) == 0 {
problems = append(problems, fmt.Sprintf("%s declares stream %q with no events", m.Module, s.Name))
}
for _, e := range s.Events {
if err := eventPattern(e, false); err != nil {
problems = append(problems, fmt.Sprintf("%s's stream %q carries %q: %v", m.Module, s.Name, e, err))
}
}
// **An event goes to one place** (ADR 0363 §2): an emitted event a stream carries, or two streams
// carrying one event, would leave the module's runtime to choose.
for _, e := range m.EmitsAll() {
if s.Carries(e) {
problems = append(problems, fmt.Sprintf(
"%s emits %q, which its stream %q carries: an event goes to EVENTS or to the module's stream, "+
"so name it in one place (novox/hq ADR 0363)", m.Module, e, s.Name))
}
}
for _, other := range m.Streams[i+1:] {
for _, a := range s.Events {
for _, b := range other.Events {
if patternsOverlap(a, b) {
problems = append(problems, fmt.Sprintf(
"%s's streams %q and %q both carry %q and %q: an event goes to one stream (novox/hq "+
"ADR 0363)", m.Module, s.Name, other.Name, a, b))
}
}
}
}
if g := s.Grant; g != nil {
if !slices.Contains(terminal, g.Config) {
problems = append(problems, fmt.Sprintf(
"%s's stream %q is granted by %q, which is not a trusted config value of the module, set only at "+
"the controller's terminal or on the operator's Approve: a grant of what a module hears is the "+
"operator's (novox/hq ADR 0348 §8, ADR 0363)", m.Module, s.Name, g.Config))
}
if strings.Count(g.Events, GrantKey) != 1 {
problems = append(problems, fmt.Sprintf(
"%s's stream %q grants %q, which must hold %s exactly once, as a name of its own (novox/hq ADR 0363)",
m.Module, s.Name, g.Events, GrantKey))
} else if err := eventPattern(g.Events, true); err != nil {
problems = append(problems, fmt.Sprintf("%s's stream %q grants %q: %v", m.Module, s.Name, g.Events, err))
}
}
}
return problems
}
// eventPattern checks one local event pattern: names, `*` for one, `**` last for the rest; with key,
// GrantKey is a name too.
func eventPattern(pattern string, key bool) error {
if pattern == "" {
return fmt.Errorf("a pattern needs a name")
}
parts := strings.Split(pattern, ".")
for i, part := range parts {
switch {
case part == TheRest:
if i != len(parts)-1 {
return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", TheRest)
}
case part == OneName:
case key && part == GrantKey:
case !eventName.MatchString(part):
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
}
}
return nil
}
// StreamsHearing is every stream of a module that carries some of a consumed pattern's events, and
// whether one of them carries all of them.
func (m Manifest) StreamsHearing(pattern string) (hearing []StreamDeclaration, whole bool) {
for _, s := range m.Streams {
for _, p := range s.Events {
if patternsOverlap(pattern, p) {
hearing = append(hearing, s)
break
}
}
if s.Carries(pattern) {
whole = true
}
}
return hearing, whole
}
// within says every event pattern a names is one b names too.
func within(a, b string) bool {
x, y := strings.Split(a, "."), strings.Split(b, ".")
for i := 0; ; i++ {
switch {
case i == len(y):
return i == len(x)
case y[i] == TheRest:
return i < len(x)
case i == len(x):
return false
case x[i] == TheRest:
return false
case y[i] == OneName:
continue
case x[i] == OneName:
return false
case x[i] != y[i]:
return false
}
}
}
// 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] == TheRest || y[i] == TheRest:
return true
case x[i] == OneName || y[i] == OneName || x[i] == y[i]:
continue
default:
return false
}
}
}
// WorkQueueStreamProblems refuses a work-queue stream (novox/hq ADR 0436 §6) that is not read by exactly
// one process of its own module: named by none, it queues forever unconsumed; named by two, each would take
// half of what the other needed, silently.
func WorkQueueStreamProblems(m Manifest) []string {
var problems []string
named := map[string]int{}
for _, b := range m.ProcessBuses() {
for _, q := range b.Queues {
named[q]++
}
}
for _, s := range m.Streams {
if s.Retention != StreamRetentionWorkQueue {
continue
}
switch named[s.Name] {
case 0:
problems = append(problems, fmt.Sprintf(
"%s's work-queue stream %q: no process names it in bus.queues; a work-queue stream is read by "+
"exactly one process (novox/hq ADR 0436 §6)", m.Module, s.Name))
case 1:
default:
problems = append(problems, fmt.Sprintf(
"%s's work-queue stream %q: %d processes name it in bus.queues; a work-queue stream is read by "+
"exactly one (novox/hq ADR 0436 §6)", m.Module, s.Name, named[s.Name]))
}
}
return problems
}
// WorkQueueConsumedElsewhereProblems refuses another module's `consumes` naming a work-queue stream's events
// (novox/hq ADR 0436 §6): read by its module's one process alone, never broadcast to a consumer that only
// subscribes. Modelled on RestrictedHearingProblems.
func WorkQueueConsumedElsewhereProblems(shelf Shelf) []string {
var problems []string
for _, module := range shelfOrder(shelf) {
m := shelf[module]
for _, c := range m.Consumes {
emitter, rest, named := strings.Cut(c, ".")
provider, known := shelf[emitter]
if !named || emitter == OneName || emitter == TheRest || !known || emitter == module {
continue
}
hearing, _ := provider.StreamsHearing(rest)
for _, s := range hearing {
if s.Retention != StreamRetentionWorkQueue {
continue
}
problems = append(problems, fmt.Sprintf(
"%s consumes %s, which %s's work-queue stream %s carries; a work-queue stream is read by "+
"its module's one process alone, never by another module's consumes (novox/hq ADR 0436 "+
"§6)", module, c, emitter, s.BusName()))
}
}
}
return problems
}
// StreamNameProblems are the rules about stream names no single manifest can be judged against: two
// modules declaring one name.
func StreamNameProblems(shelf Shelf) []string {
var problems []string
declaredBy := map[string]string{}
for _, module := range shelfOrder(shelf) {
for _, s := range shelf[module].Streams {
name := s.BusName()
if first, taken := declaredBy[name]; taken && first != module {
problems = append(problems, fmt.Sprintf(
"%s declares the stream %s, which %s already declares; a stream has one owner (novox/hq ADR 0363)",
module, name, first))
continue
}
declaredBy[name] = module
}
}
return problems
}
// RestrictedHearingProblems refuses a module that would hear a restricted stream without an account of its own
// (novox/hq ADR 0376 §2): a `consumes` naming a module literally and asking for events a restricted stream of
// that module carries, by a manifest that declares no `runs-as`. A module the machine's tool runner carries
// reaches the bus as the tool runner's account, which every agent session there shares, so the controller would
// compose nothing for it. Judged only where the provider is on the shelf, as a consumer may be installed before
// its emitter; a wildcard emitter never reaches a module's stream (ADR 0363 §5) and is not judged.
func RestrictedHearingProblems(shelf Shelf) []string {
var problems []string
for _, module := range shelfOrder(shelf) {
m := shelf[module]
if m.RunsAs != "" {
continue
}
for _, c := range m.Consumes {
emitter, rest, named := strings.Cut(c, ".")
provider, known := shelf[emitter]
if !named || emitter == OneName || emitter == TheRest || !known {
continue
}
hearing, _ := provider.StreamsHearing(rest)
for _, s := range hearing {
if s.Grant == nil {
continue
}
problems = append(problems, fmt.Sprintf(
"%s consumes %s, which %s's restricted stream %s carries, so %s must run as an account of its "+
"own (runs-as): the machine's runtime runs as the operator's account, which every agent "+
"runs as, and never hears a restricted stream (novox/hq ADR 0376)",
module, c, emitter, s.BusName(), module))
}
}
}
return problems
}
// GrantedKeys reads a restricting config value (novox/hq ADR 0363 §4): each key whose list names the
// module wherever it runs, or the module on this machine as `<machine>.<module>`. A value of another shape
// grants nothing; a key that is not one usable name grants nothing.
func GrantedKeys(value any, module, machine string) []string {
if text, isText := value.(string); isText {
// A layer may hold the object as its JSON text.
var parsed any
if json.Unmarshal([]byte(text), &parsed) != nil {
return nil
}
value = parsed
}
entries, ok := value.(map[string]any)
if !ok {
return nil
}
var out []string
for key, listed := range entries {
if !eventName.MatchString(key) {
continue
}
names, ok := listed.([]any)
if !ok {
continue
}
for _, n := range names {
s, _ := n.(string)
if s == module || (machine != "" && s == machine+"."+module) {
out = append(out, key)
break
}
}
}
slices.Sort(out)
return out
}
// Granted is a grant's pattern with its key filled.
func (g StreamGrant) Granted(key string) string { return strings.Replace(g.Events, GrantKey, key, 1) }
// narrow is the events both patterns name, as one pattern; false when they name none in common.
func narrow(a, b string) (string, bool) {
x, y := strings.Split(a, "."), strings.Split(b, ".")
var out []string
for i := 0; ; i++ {
switch {
case i == len(x) && i == len(y):
return strings.Join(out, "."), true
case i == len(x) || i == len(y):
return "", false
case x[i] == TheRest:
return strings.Join(append(out, y[i:]...), "."), true
case y[i] == TheRest:
return strings.Join(append(out, x[i:]...), "."), true
case x[i] == OneName:
out = append(out, y[i])
case y[i] == OneName || x[i] == y[i]:
out = append(out, x[i])
default:
return "", false
}
}
}
// HeardPart is what a consumer hears of one module's stream: the patterns, local, it is granted.
type HeardPart struct {
Emitter string
Stream StreamDeclaration
Patterns []string
}
// Hearing is what a consumer's `consumes` hears of the module streams the shelf declares, on one machine
// (novox/hq ADR 0363 §5): for each stream, its `consumes` narrowed to the stream's events and, for a
// restricted stream, to the parts the provider's trusted config value grants it. values answers a restricting config
// value on each machine running the provider. Answered too: whether any `consumes` names a module owning a
// stream, granted or not, and the `consumes` left for EVENTS — those naming something the provider emits
// there.
//
// Only an entry naming its emitter literally reaches a module's stream: a wildcard emitter, `**` among them,
// is EVENTS' alone, so nothing that hears every event hears a restricted stream by saying so.
func Hearing(consumer Manifest, shelf map[string]Manifest, machine string,
values func(provider, setting string) []any) (parts []HeardPart, touches bool, onEvents []string) {
byStream := map[string]*HeardPart{}
var order []string
for _, c := range consumer.Consumes {
emitter, rest, named := strings.Cut(c, ".")
provider, known := shelf[emitter]
if !named || emitter == OneName || emitter == TheRest || !known || len(provider.Streams) == 0 {
onEvents = append(onEvents, c)
continue
}
hearing, whole := provider.StreamsHearing(rest)
if len(hearing) > 0 {
touches = true
}
// Kept for EVENTS while the provider says something there it names: a stream carrying it whole, or
// a provider that says nothing of it on EVENTS, leaves it nothing to hear there.
if !whole && slices.ContainsFunc(provider.EmitsAll(), func(e string) bool { return patternsOverlap(rest, e) }) {
onEvents = append(onEvents, c)
}
for _, s := range hearing {
var granted []string
if s.Grant != nil {
for _, v := range values(emitter, s.Grant.Config) {
for _, key := range GrantedKeys(v, consumer.Module, machine) {
granted = append(granted, s.Grant.Granted(key))
}
}
}
var got []string
for _, p := range s.Events {
x, ok := narrow(rest, p)
if !ok {
continue
}
if s.Grant == nil {
got = append(got, x)
continue
}
for _, g := range granted {
if y, ok := narrow(x, g); ok {
got = append(got, y)
}
}
}
if len(got) == 0 {
continue
}
id := emitter + "/" + s.BusName()
if byStream[id] == nil {
byStream[id] = &HeardPart{Emitter: emitter, Stream: s}
order = append(order, id)
}
byStream[id].Patterns = append(byStream[id].Patterns, got...)
}
}
for _, id := range order {
h := byStream[id]
h.Patterns = uncovered(h.Patterns)
parts = append(parts, *h)
}
return parts, touches, onEvents
}
// uncovered is a list of patterns without those another names whole, and without repeats: the bus refuses
// a consumer whose filters overlap.
func uncovered(patterns []string) []string {
var out []string
for i, p := range patterns {
covered := false
for j, q := range patterns {
if i == j {
continue
}
if within(p, q) && (!within(q, p) || j < i) {
covered = true
break
}
}
if !covered {
out = append(out, p)
}
}
slices.Sort(out)
return out
}
@@ -0,0 +1,454 @@
package catalogue
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
)
// A file's text kept as a template file in its module's repository (novox/hq ADR 0349 §2, design 57 package T1,
// issue 654).
//
// **Two forms, one composition.** A file resource says its text inline (`content`) or names a template file
// beside its manifest (`"template": "files/<name>"`), never both. The module's build packs `files/` into an
// archive artifact of its own — an `archive` artifact built `from: "files"` — and resolving the build reads each
// template's text back from that packed archive into `content` and drops `template`. So the manifest the mesh
// holds, composition and the node-engine are exactly as they were: a module with its text in `files/` composes
// byte-identically to the same module with it inline. Where there is no build — `module check`, the merge gate,
// `module add` — the text is read from the repository tree beside the manifest (WithTemplatesFrom).
//
// **files/ unless the manifest names another directory** (issue 731). A module whose files/ is already an archive
// unpacked onto the machine names its own (`"templates": "templates"`), packed into an archive no resource names, so
// its templates neither land beside what it unpacks nor change that archive's digest.
//
// **Read back by the builder, carried in the resolved manifest.** The builder reads each template from the very
// bytes it hashed into the archive's digest, so the text the mesh holds is the archive's. The controller
// registers the resolved manifest as the build result carries it and fetches nothing from the artifact store; a
// manifest that still names a template when it would be held — built by a builder that predates templates — is
// refused at registration (HeldTemplateProblem).
// TemplateField is the key a file resource names its template file by.
const TemplateField = "template"
// DefaultTemplatesDir is the directory a module's template files are kept in, and its templates archive is built
// from, when its manifest names none (Manifest.Templates, issue 731).
const DefaultTemplatesDir = "files"
// TemplatesDir is the module's directory its template files are kept in, and the directory its templates archive is
// built from: the one its manifest names, else files/.
func (m Manifest) TemplatesDir() string {
if m.Templates != "" {
return m.Templates
}
return DefaultTemplatesDir
}
// templatesDirProblem is what is wrong with the templates directory a manifest names, or "": one directory of the
// module's own, a plain relative path inside it — never absolute, never leaving it, never the module's directory
// itself — and named only by a module that names a template, since a word read by nothing is refused.
func templatesDirProblem(m Manifest, templated bool) string {
d := m.Templates
if d == "" {
return ""
}
if strings.HasPrefix(d, "/") || strings.Contains(d, "\\") || path.Clean(d) != d || d == "." || d == ".." ||
strings.HasPrefix(d, "../") {
return fmt.Sprintf("%s names the templates directory %q, which is not a directory of its own: a plain "+
"relative path inside the module's directory, such as \"templates\" (issue 731)", m.Module, d)
}
if !templated {
return fmt.Sprintf("%s names the templates directory %q and names no template: a file resource names its "+
"template as %q, or the manifest says no templates directory", m.Module, d, d+"/<name>")
}
return ""
}
// Templated is whether any of the manifest's resources, or any item it contributes (collections.go), names a
// template.
func (m Manifest) Templated() bool {
for _, r := range m.Resources {
if _, has := r[TemplateField]; has {
return true
}
}
for _, it := range m.Items {
if it.Template != "" {
return true
}
}
return false
}
// TemplatesArtifact is the name of the archive artifact the module packs its templates into — the one built from its
// templates directory (TemplatesDir) — or "" when it declares none, or more than one (both refused by templateProblems
// for a module naming a template).
func TemplatesArtifact(m Manifest) string {
found := templatesArtifacts(m)
if len(found) != 1 {
return ""
}
return found[0]
}
func templatesArtifacts(m Manifest) []string {
if m.Build == nil {
return nil
}
var found []string
for _, a := range m.Build.Artifacts {
if a.Kind == ArtifactArchive && a.URL == "" && cleanFrom(a.From) == m.TemplatesDir() {
found = append(found, a.Name)
}
}
return found
}
// 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, "./")
}
// 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[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, ""
}
// templateProblems is what is wrong with the manifest's templates, as written: a template on something not a file,
// beside content, or outside the module's templates directory; a templates directory that is not one, or that no
// template is in; or a module naming templates whose build packs no archive from that directory. Whether each
// template exists is judged where a tree is (ReadTemplates) or the archive's text is (Resolve).
func templateProblems(m Manifest) []string {
var problems []string
templated := m.Templated()
if wrong := templatesDirProblem(m, templated); wrong != "" {
return append(problems, wrong)
}
dir := m.TemplatesDir()
for _, r := range m.Resources {
if _, _, wrong := templateOf(m.Module, dir, r); wrong != "" {
problems = append(problems, wrong)
}
}
if !templated {
return problems
}
switch found := templatesArtifacts(m); len(found) {
case 0:
problems = append(problems, fmt.Sprintf("%s names templates and its build packs no archive from %s/: "+
"declare an artifact {\"name\": %q, \"kind\": \"archive\", \"from\": %q} (novox/hq ADR 0349 §2)",
m.Module, dir, path.Base(dir), dir))
case 1:
default:
sort.Strings(found)
problems = append(problems, fmt.Sprintf("%s packs %s/ into %s; its templates are read from one archive",
m.Module, dir, strings.Join(found, " and ")))
}
return problems
}
// fillTemplates is resources with each template's text as its content and the template key dropped: text says the text
// of a template by its path ("<templates directory>/<name>"), and whether it has one. A template it has no text for, or
// text that is not UTF-8, is refused by name. Every resource is copied; the manifest's own are left as they were.
func fillTemplates(module, dir string, resources []map[string]any, text func(string) (string, bool),
where string) ([]map[string]any, error) {
out := make([]map[string]any, 0, len(resources))
var problems []string
for _, r := range resources {
name, has, wrong := templateOf(module, dir, r)
if !has {
out = append(out, r)
continue
}
if wrong != "" {
problems = append(problems, wrong)
continue
}
body, ok := text(name)
if !ok {
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, and %s holds no such file",
module, r["id"], name, where))
continue
}
if !utf8.ValidString(body) {
// A manifest is JSON: text that is not UTF-8 would be changed on the way, silently.
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which is not UTF-8 text; a "+
"file's text is carried as JSON and would not reach the machine as written", module, r["id"], name))
continue
}
filled := make(map[string]any, len(r))
for k, v := range r {
filled[k] = v
}
delete(filled, TemplateField)
filled["content"] = body
out = append(out, filled)
}
if len(problems) > 0 {
return nil, errors.New(strings.Join(problems, "; "))
}
return out, nil
}
// fillItemTemplates is items with each template's text as its content and the template dropped, as fillTemplates
// does for files (collections.go).
func fillItemTemplates(m Manifest, items []Item, text func(string) (string, bool), where string) ([]Item, error) {
if len(items) == 0 {
return items, nil
}
out := make([]Item, 0, len(items))
var problems []string
for _, it := range items {
if it.Template == "" {
out = append(out, it)
continue
}
if wrong := itemTemplateProblem(m, it.Template); wrong != "" {
problems = append(problems, fmt.Sprintf("%s's item of %s: %s", m.Module, it.Collection, wrong))
continue
}
body, ok := text(it.Template)
switch {
case !ok:
problems = append(problems, fmt.Sprintf("%s: its item of %s names the template %q, and %s holds no "+
"such file", m.Module, it.Collection, it.Template, where))
continue
case !utf8.ValidString(body):
problems = append(problems, fmt.Sprintf("%s: its item of %s names the template %q, which is not UTF-8 "+
"text", m.Module, it.Collection, it.Template))
continue
}
it.Template, it.Content = "", body
out = append(out, it)
}
if len(problems) > 0 {
return nil, errors.New(strings.Join(problems, "; "))
}
return out, nil
}
// ReadTemplates is the text of each template the manifest names, read from the module's directory in a repository tree,
// by template path. Refused by name: a template the directory lacks, and anything under its templates directory
// (TemplatesDir) the build's packing refuses — that directory itself, or any directory or file under it, that is a
// symlink or not a plain file — so nothing judged here can be read from outside the tree, and no tree passes here that
// the build would refuse. Each template is read through an os.Root of the module's directory besides, which no path
// element can leave.
func ReadTemplates(m Manifest, dir string) (map[string]string, error) {
problems := templatesTreeProblems(m.Module, dir, m.TemplatesDir())
root, err := os.OpenRoot(dir)
if err != nil {
return nil, fmt.Errorf("%s: the module's directory cannot be opened to read its templates: %w", m.Module, err)
}
defer root.Close()
texts := map[string]string{}
type named struct {
id any
template string
wrong string
}
var templates []named
for _, r := range m.Resources {
name, has, wrong := templateOf(m.Module, m.TemplatesDir(), r)
if has {
templates = append(templates, named{r["id"], name, wrong})
}
}
for _, it := range m.Items {
if it.Template != "" {
templates = append(templates, named{"its item of " + it.key(), it.Template,
itemTemplateProblem(m, it.Template)})
}
}
for _, t := range templates {
r := map[string]any{"id": t.id}
name, wrong := t.template, t.wrong
if wrong != "" {
problems = append(problems, wrong)
continue
}
if _, read := texts[name]; read {
continue
}
info, err := root.Lstat(name)
switch {
case errors.Is(err, fs.ErrNotExist):
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, and the module's directory "+
"has no such file", m.Module, r["id"], name))
continue
case err != nil:
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which cannot be read: %v",
m.Module, r["id"], name, err))
continue
case !info.Mode().IsRegular():
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which is not a plain file; "+
"the build packs only files", m.Module, r["id"], name))
continue
}
body, err := root.ReadFile(name)
if err != nil {
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which cannot be read: %v",
m.Module, r["id"], name, err))
continue
}
texts[name] = string(body)
}
if len(problems) > 0 {
return nil, errors.New(strings.Join(problems, "; "))
}
return texts, nil
}
// templatesTreeProblems is everything under the module's templates directory (templates, inside dir) that the
// build's packing refuses: that directory itself not a plain directory, and any entry under it that is neither a
// directory nor a plain file — a symlink, to a file or a directory, a device, a pipe. Each said by its path. A
// templates directory that does not exist is no problem here: each template it would have held is refused as missing.
func templatesTreeProblems(module, dir, templates string) []string {
top := filepath.Join(dir, filepath.FromSlash(templates))
info, err := os.Lstat(top)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return []string{fmt.Sprintf("%s: %s/ cannot be read: %v", module, templates, err)}
}
if !info.IsDir() {
return []string{fmt.Sprintf("%s: %s/ is %s, not a directory of the module's own; its templates are read "+
"only from inside the repository, and the build packs only directories and files", module, templates,
kindOf(info.Mode()))}
}
var problems []string
err = filepath.WalkDir(top, func(at string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || d.Type().IsRegular() {
return nil
}
rel, _ := filepath.Rel(dir, at)
problems = append(problems, fmt.Sprintf("%s: %s is %s; the build packs only directories and files, so "+
"%s/ holds nothing else", module, filepath.ToSlash(rel), kindOf(d.Type()), templates))
return nil
})
if err != nil {
problems = append(problems, fmt.Sprintf("%s: %s/ cannot be read: %v", module, templates, err))
}
return problems
}
// kindOf says what a file that is not a plain file is.
func kindOf(mode fs.FileMode) string {
switch {
case mode&fs.ModeSymlink != 0:
return "a symlink"
case mode&fs.ModeDevice != 0:
return "a device"
case mode&fs.ModeNamedPipe != 0:
return "a named pipe"
case mode&fs.ModeSocket != 0:
return "a socket"
case mode.IsDir():
return "a directory"
}
return "not a plain file"
}
// WithTemplatesFrom is the manifest with each template's text read from the module's directory in a repository
// tree as its content: what `module check`, the merge gate and `module add` judge, where nothing was built. Read
// again strictly afterwards, so every rule about a file's text judges the template's text exactly as it judges
// inline content. A manifest naming no template is returned as it is.
func WithTemplatesFrom(m Manifest, dir string) (Manifest, error) {
if !m.Templated() {
return m, nil
}
texts, err := ReadTemplates(m, dir)
if err != nil {
return Manifest{}, err
}
filled, err := fillTemplates(m.Module, m.TemplatesDir(), m.Resources, func(name string) (string, bool) {
t, ok := texts[name]
return t, ok
}, "the module's directory")
if err != nil {
return Manifest{}, err
}
items, err := fillItemTemplates(m, m.Items, func(name string) (string, bool) {
t, ok := texts[name]
return t, ok
}, "the module's directory")
if err != nil {
return Manifest{}, err
}
out := m
out.Resources = filled
out.Items = items
// Nothing names a template now, so nothing names its directory.
out.Templates = ""
// A moved module's mark was judged above, while it still named its templates; read again with its text inline,
// it is judged as it composes without its build's templates archive — as before, nothing handed (handed.go).
out.Moved = false
raw, err := json.Marshal(out)
if err != nil {
return Manifest{}, err
}
again, err := parseManifest(raw, m.predefined)
if err != nil {
return Manifest{}, err
}
again.Moved = m.Moved
return again, nil
}
// HeldTemplateProblem is why a manifest cannot be held by the mesh because it still names a template, or "": the
// manifest the mesh holds carries every file's text, which a build reads back from its templates archive. One
// naming a template was resolved by a builder that does not read templates, and would compose files with no text.
func HeldTemplateProblem(m Manifest) string {
var named []string
for _, r := range m.Resources {
if t, has := r[TemplateField]; has {
named = append(named, fmt.Sprintf("%v (%v)", r["id"], t))
}
}
for _, it := range m.Items {
if it.Template != "" {
named = append(named, fmt.Sprintf("its item of %s (%s)", it.Collection, it.Template))
}
}
if len(named) == 0 {
return ""
}
return fmt.Sprintf("%s still names the template(s) of %s: the manifest the mesh holds carries each file's "+
"text, read back from the build's templates archive — this one was resolved by a builder that does not "+
"read templates, or not built at all (novox/hq ADR 0349 §2)", m.Module, strings.Join(named, ", "))
}
@@ -0,0 +1,188 @@
package catalogue
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// Which settings are the controller's terminal's alone (novox/hq issue 339).
//
// A setting is the operator's word on how a module is configured, and the `settings` verb writes it for any
// caller allowed to call verbs — agents among them. Most settings change only the module itself. Some change
// what root or another module trusts, and those are said at the terminal alone, never through a verb:
//
// 1. `places` and `accesses`: where the node-engine creates and, as root, owns a module's directories, and
// which of the machine's paths are mounted into its container.
// 2. **Every key a provider serves**, and every setting a served value asks for. A setting overrides a served
// key (Settle), and what is served is what every consumer of the provision connects to and believes: a
// database's port, an object store's scheme, a registry's port, an identity provider's issuer and token
// path. Through a verb, any caller could point every consumer at a listener of its own and collect the
// credentials they present.
// 3. **Every setting a file asks for, unless the file says `"trusted": false`.** A file root or a consumer trusts —
// a logind drop-in, an env file that says which uid a container runs as, a script run as root — must not
// change through a verb, and the safe reading of a file that says nothing is that it is one of them (fail
// closed). `"trusted": false` is the opt-out, for a file nothing trusts: a person's own notifier settings.
// `module check` lists the files that say nothing, so an author can opt one out where that is true.
// **A mergeable file asks for every key its own content names** (novox/hq issue 340): a setting of that key
// lands in the file as a `${setting:…}` would, without the file ever spelling one. The agent's module keeps the
// managed settings and tool servers every Claude Code session on a node obeys in one, and through a verb an
// agent could have given every person's session a hook of its own.
//
// 4. **A module's whole layer, when it has a mergeable file not marked `"trusted": false`** (novox/hq issue 340,
// TrustedMergeable). A mergeable file takes any key a layer sets, not only those its content names, so no list
// of keys covers it: an empty runtime configuration a provider reads would take a `url` of the caller's.
// **Unless the module's files name the keys they take** (novox/hq ADR 0326, ADR 0329 §5): then a mergeable
// file takes exactly its `takes-settings` and no other key, so those keys of a trusted file are terminal keys
// and the rest of the layer is not. A key no file names is stray and refused where it is set. So betterbird's
// `tags`, in a file nothing trusts, is set through the verb, while its `accounts` and `identities` are not.
// 5. **Every setting a directory's owner asks for** (novox/hq issue 279): root chowns the directory to the number
// at the next push, so through a verb any caller could hand itself — or uid 0 — a module's folder. Whatever
// else asks for the key, a file marked `"trusted": false` included, the owner makes it the terminal's.
//
// Derived from the manifest, never listed by hand, so a provider or a trusted file added tomorrow is covered.
// TrustedField is the key a file resource carries to say whether the settings it asks for are trusted: true, or
// absent, makes each a terminal key; false says, out loud, that none changes what root or a consumer trusts. Said in the
// catalogue, never on the machine: the composer takes it out before the node-engine, which parses strictly.
const TrustedField = "trusted"
// TerminalKeys are the settings keys of a module that are set at the controller's terminal alone: places and
// accesses, every key its provisions serve and every setting a served value asks for, every setting a file
// asks for unless it says `"trusted": false`, and every setting a directory's owner asks for. Places and accesses first, then the rest sorted.
func TerminalKeys(m Manifest) []string {
keys := map[string]bool{}
for _, served := range m.Serves {
for key, value := range served {
keys[key] = true
if s, ok := value.(string); ok {
for _, asked := range settingsUsed(s) {
keys[asked] = true
}
}
}
}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "directory" {
owner, _ := r["owner"].(string)
for _, asked := range settingsUsed(owner) {
keys[asked] = true
}
continue
}
if fmt.Sprint(r["type"]) != "file" {
continue
}
if trusted, said := r[TrustedField].(bool); said && !trusted {
continue
}
for _, asked := range asksFor(r) {
keys[asked] = true
}
}
delete(keys, PlacesSetting)
delete(keys, AccessesSetting)
rest := make([]string, 0, len(keys))
for k := range keys {
rest = append(rest, k)
}
sort.Strings(rest)
return append([]string{PlacesSetting, AccessesSetting}, rest...)
}
// UnsaidTrust is every file of a module that asks for a setting and does not say whether it is trusted, by id:
// each counts as trusted, and is listed so an author can opt out a file nothing trusts.
func UnsaidTrust(m Manifest) []string {
var out []string
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "file" {
continue
}
if len(asksFor(r)) == 0 {
continue
}
if _, said := r[TrustedField]; !said {
out = append(out, fmt.Sprint(r["id"]))
}
}
sort.Strings(out)
return out
}
// TrustedMergeable is every mergeable file of a module not marked `"trusted": false`, by id (novox/hq issue 340).
// A mergeable file takes any key a layer sets, not only those its content names: an empty runtime configuration a
// provider reads takes a `url` of a caller's as surely as a declared one. So a module holding one has its whole
// layer set at the controller's terminal; a verb may read it and change nothing. A module whose mergeable files
// name the keys they take (ADR 0326) has none here: each such file takes only its named keys, which TerminalKeys
// holds for a trusted one (ADR 0329 §5).
func TrustedMergeable(m Manifest) []string {
if namesItsKeys(m) {
return nil
}
var out []string
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "file" {
continue
}
if how, _ := r["merge"].(string); how == "" {
continue
}
if trusted, said := r[TrustedField].(bool); said && !trusted {
continue
}
out = append(out, fmt.Sprint(r["id"]))
}
sort.Strings(out)
return out
}
// asksFor is every setting a file resource asks for: each `${setting:…}` in its content and, for a mergeable file,
// every key at the top of the content it merges into (novox/hq issue 340). Those are the keys the file declares it
// takes: a layer's value for one of them lands in it as surely as a placeholder would be filled. A key the content
// does not name may land too, but nothing reading the file was written to read it.
func asksFor(r map[string]any) []string {
content, _ := r["content"].(string)
asked := settingsUsed(content)
if how, _ := r["merge"].(string); how != "" {
if _, names := takenKeys(r); names {
// The file takes exactly the keys it names (ADR 0326), whatever its content holds, and a renamed
// one under the file's own spelling too, which a layer said before its module moved (issue 751).
asked = append(asked, takenNames(r)...)
sort.Strings(asked)
return asked
}
}
if how, _ := r["merge"].(string); how != "" && strings.TrimSpace(content) != "" {
var base map[string]any
if json.Unmarshal([]byte(content), &base) == nil {
for key := range base {
asked = append(asked, key)
}
}
}
sort.Strings(asked)
return asked
}
// TrustProblems are the ways a manifest states `trusted` wrongly: anything but true or false, or on anything but
// a file. Refused at registration and by `module check`.
func TrustProblems(m Manifest) []string {
var out []string
for _, r := range m.Resources {
v, said := r[TrustedField]
if !said {
continue
}
if fmt.Sprint(r["type"]) != "file" {
out = append(out, fmt.Sprintf("%s: %v is a %v and says %q; only a file says whether the settings it "+
"asks for are trusted (novox/hq issue 339)", m.Module, r["id"], r["type"], TrustedField))
continue
}
if _, ok := v.(bool); !ok {
out = append(out, fmt.Sprintf("%s: %v says %q as %v; it is true or false (novox/hq issue 339)",
m.Module, r["id"], TrustedField, v))
}
}
return out
}
@@ -0,0 +1,178 @@
package catalogue
import (
"fmt"
"regexp"
"strings"
)
// A placeholder no pass consumes is refused, never written out as text (novox/hq issue 231).
//
// Each namespace is filled by its own pass with its own pattern, in the fields that pass reads. A
// word in that shape that no pattern matched — `${machnie:address}`, `${shel:zsh:first}`, a setting
// key no definition could declare such as `${setting:Undeclared}` — was left in the file as it was
// written and reached a machine as a value: the predecessor's failure the namespaced placeholders
// were meant to end (novox/hq ADR 0164). So the definition is swept, field by field, against the
// same table of what each pass fills where.
//
// **The definition, never the values.** Swept as the manifest wrote it, at the catalogue check and
// again at composition before any pass has run: what an operator's setting, a binding or a merged
// JSON setting puts into a file is its own software's text — `${env:HOME}` for Log4j, `${timeout:30}`
// for Spring — and refusing it there would refuse something its author never wrote, on a machine the
// check had passed (novox/hq issue 231, review of mesh-controller #211).
//
// What is refused:
// - a placeholder of a namespace the mesh knows, in a field that namespace's pass does not read: it
// would reach the machine as the same text;
// - any other `${<known namespace>:`, case-insensitively, unless a shell's parameter operator follows
// its colon — `${Machine:address}`, `${machine:.address}`, `${machine: address}`, an unclosed
// `${machine:address` — because no pass's pattern takes it;
// - a namespace-shaped placeholder of a word the mesh does not know: a lower-case word, a colon, and
// a key that begins with a letter or a digit and holds no space, brace or `$`.
//
// **The shell's own syntax is not that shape, and passes.** `${NAME:-…}` has an upper-case word,
// `${(%):-…}` and `${1:-.}` begin with no letter, and a lower-case variable with an operator after its
// colon — `${count:-}`, `${trial:+…}`, `${state:=…}` — has no key that begins with a letter or a digit.
// And an operator — `:-`, `:=`, `:+`, `:?` — after a word the mesh also uses is the shell's too:
// `${PORT:-8080}`, `${SHELL:-/bin/sh}`, `${SECRET:?unset}` and `${dir:-/tmp}` are among the commonest
// lines of a script or an env file, and pass whatever the word's case.
// What the shape does catch is a zsh modifier (`${path:t}`) or a substring (`${where:0:12}`) in a
// resource's own text: shell code of that kind belongs in the module's contributed shell code, which
// is not a resource and is never swept (novox/hq ADR 0204).
// filler is one namespace's pass: the patterns it fills with, and the fields it reads them in, by
// resource type ("*" for any type).
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
}
// fillers is the table of what each pass fills where — read from the passes themselves: settingInto,
// dirInto, accessInto, intoFile (whose ${secret:…} the node-engine fills), boundInto, portInto,
// seatInto, machineInto and contributionsInto. A pass that comes to read another field adds it here,
// or the sweep refuses the placeholder it would have filled.
var fillers = []filler{
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}}},
{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: 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: CollectedNamespace, patterns: []*regexp.Regexp{ofCollected}, fields: map[string][]string{"*": {"content"}}},
// A provider's consumers' credentials (consumers.go): filled by the provisioner's client.
{namespace: 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 (f filler) reads(kind, field string) bool {
return oneOf(f.fields[kind], field) || oneOf(f.fields["*"], field)
}
// ofKnownNamespace is any `${<known namespace>:` and what follows it up to its brace or the end of
// its line, whatever its case, unless what follows the colon is a shell's parameter operator (`-`,
// `=`, `+`, `?`): what remains of one once every pass's pattern is set aside is a misspelling.
var ofKnownNamespace = regexp.MustCompile(`(?im)\$\{(?:` + strings.Join(namespacesOf(fillers), "|") +
`):(?:[^-=+?}\n][^}\n]*\}?|\}|$)`)
// namespaceShaped is a placeholder in the mesh's shape: a lower-case word, a colon, and a key that
// begins with a letter or a digit and holds no space, brace or `$`.
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
}
// 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 !f.reads(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
}
// 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, "; ")
}
@@ -0,0 +1,55 @@
package catalogue
import (
"fmt"
"strings"
)
// How loud a module's unhealthy condition is (novox/hq ADR 0355, extending ADR 0240).
//
// **A module may declare its unhealthy urgent.** A module whose failure costs people something they rely on
// (mail refused or stuck for everyone with a mailbox) says so with `unhealthy`, and its condition
// `module.<module>.<machine>.unhealthy` is raised urgent instead of a warning. **Urgent needs a why**, as an
// `upgrade` policy other than roll does: a person reading the catalogue sees what is lost while it is broken.
// The severities a module may declare for its unhealthy condition.
const (
// UnhealthyUrgent raises the module's unhealthy condition urgent, on every machine it runs on.
UnhealthyUrgent = "urgent"
// UnhealthyWarning is what a module's unhealthy condition is without the field, said out loud.
UnhealthyWarning = "warning"
)
// UnhealthyPolicy is what a module says about how loud its unhealthy condition is: `unhealthy` in its manifest
// (novox/hq ADR 0355).
type UnhealthyPolicy struct {
Severity string `json:"severity"`
// Why names what people lose while the module is broken. It is required: a module that says nothing of
// why it is loud is refused.
Why string `json:"why,omitempty"`
}
// unhealthyProblems refuses an `unhealthy` without a why, and any severity but urgent or warning (novox/hq ADR
// 0355 §2).
func (m Manifest) unhealthyProblems() []string {
if m.Unhealthy == nil {
return nil
}
var problems []string
switch m.Unhealthy.Severity {
case UnhealthyUrgent, UnhealthyWarning:
default:
problems = append(problems, fmt.Sprintf("unhealthy is %q or %q, not %q", UnhealthyUrgent, UnhealthyWarning,
m.Unhealthy.Severity))
}
if strings.TrimSpace(m.Unhealthy.Why) == "" {
problems = append(problems, "unhealthy says why: a module that declares how loud its failure is names "+
"what people lose while it is broken")
}
return problems
}
// UnhealthyIsUrgent is whether the module declares its unhealthy condition urgent (novox/hq ADR 0355 §1).
func (m Manifest) UnhealthyIsUrgent() bool {
return m.Unhealthy != nil && m.Unhealthy.Severity == UnhealthyUrgent
}
@@ -0,0 +1,206 @@
package catalogue
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"regexp"
)
// A key this controller does not know, in a manifest (novox/hq ADR 0262).
//
// Registration refuses it, as it always has. A manifest the store already holds was registered by a newer
// controller, and is read by this one after a rollback: refusing it there failed the whole catalogue, and
// with it every plan and every send. Dropping the key silently would be worse — a module running without
// something its manifest says. So the manifest is read, the module is left out of every machine's
// declaration by name, and the controller raises a condition until it is updated.
// UnknownFieldError is a key a manifest has that this controller does not know, wherever it is: at the
// top of the manifest or inside a block (a state, an offer, a data item's backup, an own secret, a verb).
// Typed, because the blocks' decoders wrap the decoder's words in their own.
type UnknownFieldError struct {
Field string
err error
}
func (e *UnknownFieldError) Error() string { return e.err.Error() }
func (e *UnknownFieldError) Unwrap() error { return e.err }
// unknownFieldText is how the JSON decoder words a key its target does not have.
var unknownFieldText = regexp.MustCompile(`^json: unknown field "([^"]*)"$`)
// typedUnknown is err as an UnknownFieldError when it is the decoder refusing an unknown key, else err.
func typedUnknown(err error) error {
if err == nil {
return nil
}
if m := unknownFieldText.FindStringSubmatch(err.Error()); m != nil {
return &UnknownFieldError{Field: m[1], err: err}
}
return err
}
// asUnknownField is the unknown key err is about, at any depth, or nil.
func asUnknownField(err error) *UnknownFieldError {
var u *UnknownFieldError
if errors.As(typedUnknown(err), &u) {
return u
}
return nil
}
// UnknownFieldReason is why a module whose stored manifest has a key this controller does not know is
// left out of a machine's declaration, or "" when it has none.
func UnknownFieldReason(m Manifest) string {
if m.unknown == "" {
return ""
}
return m.Module + " uses a field this controller does not know (" + m.unknown + "); it is left out " +
"until the controller is updated: nothing of it is changed on its machines and its contributions to " +
"other modules and its open ports stop. Its data is still backed up as this controller reads it, " +
"which may not be what its newer manifest asks, and it still provides what it provides " +
"(novox/hq ADR 0262)"
}
// backupView is what of a left-out module still reaches its machine: its data, so the backup holder
// keeps copying it, and the directories and accesses its data items name. No contribution, shell code
// or environment of its own: those are what leaving it out stops.
func backupView(m Manifest) Manifest {
view := Manifest{Module: m.Module, Data: m.Data, Accesses: m.Accesses, bestEffort: true}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "directory" {
view.Resources = append(view.Resources, r)
}
}
return view
}
// prunedFields is a stored manifest's fields with every key this controller does not know taken out,
// and the keys taken out (novox/hq ADR 0262). A second pass, after the strict one refused: each
// top-level field is decoded alone, and where an entry inside it has an unknown key, the one
// occurrence whose removal moves the decoder past it is removed — never a key of the same name that
// the entry around it knows. So a left-out module still provides what it provides, and its data and
// directories are still read, which its backup lines are made from.
func prunedFields(keys map[string]json.RawMessage) (manifestFields, []string, error) {
var removed []string
tree := map[string]any{}
for k, raw := range keys {
var v any
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&v); err != nil {
return manifestFields{}, nil, err
}
tree[k] = v
}
for _, k := range sortedAnyKeys(tree) {
for tries := 0; ; tries++ {
err := decodesAlone(k, tree[k])
if err == nil {
break
}
unknown := asUnknownField(err)
if unknown == nil || tries > 64 {
return manifestFields{}, nil, err
}
if unknown.Field == k {
delete(tree, k)
removed = append(removed, k)
break
}
path, ok := removalThatHelps(k, tree[k], unknown.Field, err.Error())
if !ok {
// The same key unknown in two entries alike: no one removal changes the words.
// Every occurrence goes, and the field is judged again.
if removeEvery(tree[k], unknown.Field) == 0 {
return manifestFields{}, nil, err
}
path = "…." + unknown.Field
}
removed = append(removed, k+path)
}
}
raw, err := json.Marshal(tree)
if err != nil {
return manifestFields{}, nil, err
}
var fields manifestFields
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&fields); err != nil {
return manifestFields{}, nil, err
}
return fields, removed, nil
}
// decodesAlone is whether one top-level field decodes strictly on its own.
func decodesAlone(k string, v any) error {
raw, err := json.Marshal(map[string]any{k: v})
if err != nil {
return err
}
var fields manifestFields
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
return dec.Decode(&fields)
}
// removalThatHelps removes, from v, the one occurrence of key whose removal changes what the strict
// decoder says of field k, and says where it was. Every other occurrence is left as it was.
func removalThatHelps(k string, v any, key, said string) (string, bool) {
var found bool
var where string
var walk func(node any, at string) bool
walk = func(node any, at string) bool {
switch n := node.(type) {
case map[string]any:
if value, has := n[key]; has {
delete(n, key)
// Accepted only when the decoder is past it: nothing left, or an unknown key said
// elsewhere. A different kind of error means the removal broke the entry; the same
// words mean this was not the occurrence it refused.
err := decodesAlone(k, v)
if err == nil || (asUnknownField(err) != nil && err.Error() != said) {
found, where = true, at+"."+key
return true
}
n[key] = value
}
for _, sub := range sortedAnyKeys(n) {
if walk(n[sub], at+"."+sub) {
return true
}
}
case []any:
for i, item := range n {
if walk(item, fmt.Sprintf("%s[%d]", at, i)) {
return true
}
}
}
return false
}
walk(v, "")
return where, found
}
// removeEvery removes key from every object in v, and says how many it removed.
func removeEvery(v any, key string) int {
n := 0
switch node := v.(type) {
case map[string]any:
if _, has := node[key]; has {
delete(node, key)
n++
}
for _, sub := range node {
n += removeEvery(sub, key)
}
case []any:
for _, item := range node {
n += removeEvery(item, key)
}
}
return n
}
@@ -0,0 +1,120 @@
package catalogue
import (
"fmt"
"strings"
)
// What the mesh does when a module's build moves (novox/hq ADR 0236, extending ADR 0162 §3 and ADR
// 0218 §2).
//
// **Rolled out by default, one machine first and gated.** With the gate on the first machine and the
// rollback after it (to-be 45 §8), a build that moves is sent to one machine, judged there by its own
// health, and only then to the rest — or put back there, said, and sent nowhere else. Recording an
// upgrade and waiting for a person to push it is kept where a module says why, and where the mesh knows
// a rollback cannot undo what a new build does.
// The policies a module may declare.
const (
// PolicyRoll sends one machine first, judges it at the gate, then the rest.
PolicyRoll = "roll"
// PolicyTogether sends every machine running the module at once — for a module that must change
// everywhere in the same minute. Still judged, on every machine, after.
PolicyTogether = "together"
// PolicyRecord builds and sends nothing: the machines running it are behind until a person pushes.
PolicyRecord = "record"
)
// UpgradePolicy is what a module says about how its new builds reach its machines: `upgrade` in its
// manifest.
type UpgradePolicy struct {
Policy string `json:"policy"`
// Why is required for anything but roll: a person reading the catalogue sees why this module waits
// for them, or why it changes everywhere at once.
Why string `json:"why,omitempty"`
}
func (m Manifest) upgradeProblems() []string {
if m.Upgrade == nil {
return nil
}
switch m.Upgrade.Policy {
case PolicyRoll:
case PolicyTogether, PolicyRecord:
if strings.TrimSpace(m.Upgrade.Why) == "" {
return []string{fmt.Sprintf("upgrade %q says why: a module that does not roll out one machine first "+
"names the reason a person reads", m.Upgrade.Policy)}
}
default:
return []string{fmt.Sprintf("upgrade is %q, %q or %q, not %q", PolicyRoll, PolicyTogether, PolicyRecord,
m.Upgrade.Policy)}
}
return nil
}
// Where a policy came from, as `upgrade` says it.
const (
FromPerson = "person"
FromModule = "module"
FromBus = "the bus"
FromData = "irreplaceable data"
FromDefault = "default"
)
// DerivedUpgrade is the policy a module's manifest gives it when no person has chosen one, with where
// it came from and why (ADR 0236):
//
// - **the bus is never rolled**: a module that provides the mesh's bus records, whatever it says — its
// upgrade is a planned step a person starts (to-be 45 §8);
// - a module that says its policy has it;
// - a module that keeps irreplaceable data records — sending the previous build cannot undo what a new
// one did to data that cannot be had again, so a person takes it, after a backup;
// - everything else rolls out, one machine first.
func DerivedUpgrade(m Manifest) (policy, from, why string) {
if ProvidesBus(m) {
return PolicyRecord, FromBus, "the bus is replaced only as a planned step a person starts (`bus upgrade`): " +
"its streams are snapshotted first and checked after"
}
if m.Upgrade != nil && m.Upgrade.Policy != "" {
return m.Upgrade.Policy, FromModule, m.Upgrade.Why
}
if item := m.irreplaceable(); item != "" {
return PolicyRecord, FromData, "it keeps irreplaceable data (" + item + "): a rollback cannot undo what a new " +
"build does to it, so a person takes each build, after a backup"
}
return PolicyRoll, FromDefault, ""
}
// ProvidesBus is whether a manifest provides the mesh's bus.
func ProvidesBus(m Manifest) bool {
for _, o := range m.Provides {
if o.Name == "mesh-bus" {
return true
}
}
return false
}
// irreplaceable names the first irreplaceable data the module keeps, its own or its consumers', or
// nothing.
func (m Manifest) irreplaceable() string {
if m.Data == nil {
return ""
}
for _, it := range m.Data.Own {
if it.Class == ClassIrreplaceable {
return it.ID
}
}
for provision, c := range m.Data.Consumers {
if c.Class == ClassIrreplaceable {
return "its consumers' " + provision
}
}
for provision, k := range m.Data.KeptBy {
if k.Class == ClassIrreplaceable {
return "what it keeps with " + provision
}
}
return ""
}
+764
View File
@@ -0,0 +1,764 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
)
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
// its answer (novox/hq ADR 0132, design 33 §2).
//
// **A name alone is not callable by something that has never seen the mesh before**, which is the
// whole population a tool surface exists for. So a seat's protocol carries the definition, in the
// form an agent protocol already uses — a JSON schema for the input — so nothing translates between
// a seat's idea of an argument and the caller's.
//
// A manifest may still write a bare verb name (`"serves": ["price"]`); that is a Verb with only a
// name, and the module's runtime answers `tools` with the rest. The two forms read into one type so
// nothing downstream cares which was written.
type Verb struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Input map[string]any `json:"input,omitempty"`
Output map[string]any `json:"output,omitempty"`
// Optional marks a verb added to a mesh seat whose holder lives in another repository (design 33 §7,
// additive within a version): a holder that serves it is accepted, and one that does not yet still
// holds the seat. Without it the addition would be a deadlock — this controller refusing the holder that
// does not serve the verb, the controller before it refusing the holder that does — and every check of
// the catalogue between the two would fail on a module nobody touched. Once every holder serves it,
// the mark is removed and the verb is a condition of holding like the rest. Never stored or said: the
// seat set's protocol is the compiled one.
Optional bool `json:"-"`
// Replaces are the shell commands this verb is the mesh's way to do, each as it is typed —
// `journalctl`, `systemctl restart` — so the agent finds the verb by the command it would have run
// and is told to call it instead (novox/hq ADR 0245): the mesh MCP server's search matches them, the agent's
// instructions list them, and the guard on its shell names the verb when it refuses a work-around.
// A command line is matched by its words in order, the first being the command's name; `edit
// /etc/hosts` and `HOSTALIASES` name the guard's two local work-arounds for a mesh name.
Replaces []string `json:"replaces,omitempty"`
}
func (v *Verb) UnmarshalJSON(raw []byte) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) > 0 && trimmed[0] == '"' {
var name string
if err := json.Unmarshal(trimmed, &name); err != nil {
return err
}
*v = Verb{Name: name}
return nil
}
// Strictly, like the manifest around it: a misspelt key in a tool's definition would otherwise
// describe a tool nobody can call and refuse nothing.
type plain Verb
var p plain
decoder := json.NewDecoder(bytes.NewReader(trimmed))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&p); err != nil {
return fmt.Errorf("a served verb is a name or {name, description, input, output}: %w", typedUnknown(err))
}
if p.Name == "" {
return fmt.Errorf("a served verb has no name: %s", trimmed)
}
*v = Verb(p)
return nil
}
// VerbNames are the names alone, for the grants and the checks that care about nothing else.
func VerbNames(verbs []Verb) []string {
out := make([]string, 0, len(verbs))
for _, v := range verbs {
out = append(out, v.Name)
}
return out
}
// ControllerSeatName is the seat the control plane holds, whose tools are the mesh's own verbs.
const ControllerSeatName = "mesh-controller"
// ControllerVerbs are the mesh's own verbs, as the `mesh-controller` seat's tools (novox/hq ADR 0154).
//
// **The same function the command line calls, and nothing the tool adds** (ADR 0035): each of these
// is a command the controller's binary already answers, run by the holder of the seat with the
// arguments below and answered with what the command printed. A verb here is a contract every future
// holder must implement, which is why the list is short and made of what an operator asks weekly.
// Additive within a version (design 33 §7); a verb that would break a caller takes a new version.
var ControllerVerbs = []Verb{
{Name: "tools", Description: "Every seat's tools, from the mesh's own records: what each role " +
"answers, whether or not its holder is up. The mesh's own verbs are the mesh-controller seat's.",
Input: schema(nil, nil)},
{Name: "calls", Description: "The calls this controller answered lately and what came of each — " +
"one still running, one that finished after its caller was told it was running, one whose answer " +
"the bus refused — and, given a call's id, its whole answer (novox/hq issue 265). A call that has " +
"not finished within ten seconds answers that it is running, with its id; this is where it ends.",
Input: schema(map[string]string{
"call": "a call's id, as a running answer or `calls` gives it: that call and its whole answer",
}, nil)},
{Name: "status", Description: "What is wrong, what is quiet, what is out of date, and which " +
"machines are behind what the mesh would send them.",
Input: schema(nil, nil)},
{Name: "nodes", Description: "Every machine the mesh knows, with whether it is converged or adopted.",
Input: schema(nil, nil)},
{Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.",
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"}),
Replaces: []string{"hostnamectl", "uptime"}},
{Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " +
"and which machines run it.",
Input: schema(nil, nil)},
{Name: "seats", Description: "Every seat the mesh defines, what it delivers, and who holds it.",
Input: schema(nil, nil)},
{Name: "builds", Description: "What has been built lately and what came of it, for every module or for one; " +
"or, given a build's id, everything the build machine said while building it, line by line, from the bus.",
Input: schema(map[string]string{
"module": "one module's name; every module when absent",
"log": "a build's id (as `builds` lists it): print what the build machine said, line by line",
"limit": "how many builds to list (default 20); not with log",
}, nil)},
{Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " +
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id; " +
"or, given wait, one plan followed until it moves.",
Input: schema(map[string]string{
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
"wait": "a plan's id: wait until it moves, then answer it as data — the plan, and what ended the wait " +
"(moved, tier, done, or bound); ask again to follow it further (novox/hq issue 229)",
"until": "with wait: moved (any change, the default), tier (it reaches another tier) or done (it ends)",
"bound": "with wait: how long to wait at most, as 15s or seconds — 20s, the default, is the longest, " +
"inside what a call is answered within",
"stop": "a plan's id: stop it — what was asked still builds, nothing further is asked",
"close": "a plan's id: close a plan that will not move again, as failed by hand (novox/hq issue 254)",
"retry": "a failed plan's id: ask its failed builds again under new ids, and carry the plan on from that tier (novox/hq ADR 0219)",
"go": "a walk's id that waits for its delivery's word: start it by hand, in place of mesh-delivery — with why, " +
"recorded in the hand-act log (novox/hq ADR 0239)",
"repository": "owner/repository: the plan a merge there would produce, saving nothing (what-if); with paths or modules",
"paths": "with repository: the files the merge would change, comma-separated, from the repository's root",
"modules": "with repository: or the modules it would change, comma-separated",
"module-dirs": "with repository and paths: the directories holding a module.json at the commit, comma-separated, " +
"as the forge's announcer says them (novox/hq issue 278); without it, a directory is a module only when the paths hold its manifest",
"data": "with repository and paths: true answers the change plan as data, the document delivery-plan answers " +
"(moved, dependents, new, tiers, machines with what each receives and waits for, steps, summary, class, head, base); " +
"without it the answer is text (novox/hq ADR 0450 §2)",
"head": "with data: the commit at hand",
"base": "with data: the branch it merges into (default main)",
"removed": "with data: the files among paths the change deletes, comma-separated",
"limit": "how many plans to list (default 10); only when listing",
"why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)",
"cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)",
}, nil, "data")},
// The verbs the delivery's owner asks with (novox/hq ADR 0239): the planner's answer, a group's order and
// composed check, and the start and end of a walk. Callable by a person too; mesh-delivery is who needs them.
{Name: "delivery-plan", Description: "The delivery plan of a diffset, as the planner computes it and keeping " +
"nothing (novox/hq ADR 0239): what it moves and builds after them in tiers, what each machine receives and " +
"what waits there, the steps that are not an ordinary send, and whether the gate would run.",
Input: schema(map[string]string{"repository": "owner/repository", "head": "the commit at hand",
"base": "the branch it merges into (default main)",
"paths": "the files it changes, comma-separated, from the repository's root",
"module-dirs": "the directories holding a module.json at the head, comma-separated, as the forge says them",
"removed": "the files among paths it deletes, comma-separated"}, []string{"repository", "paths"})},
{Name: "delivery-order", Description: "A delivery group's order (novox/hq ADR 0419): its members in the order " +
"they are delivered, every pair and why — built by, verb before caller, version skew, engine before " +
"controller — read from the module graph and the manifests' change alone; no `after:` line is read. The " +
"cycle, with every module and edge of it, is the one refusal.",
Input: schema(map[string]string{"members": "the members, as JSON: [{id, repository, base, head, number, paths, " +
"module_dirs, module_dirs_said, removed}]"}, []string{"members"})},
{Name: "delivery-check", Description: "Ask the build seat for a delivery group's composed check (novox/hq ADR " +
"0239): every member's head laid over the mesh in turn and judged as one future state. Answers the ask's id; " +
"the verdict is said as `checked` with the group's id. With one member and no group, that head is checked " +
"again as its pull request is.",
Input: schema(map[string]string{"group": "the group's id (none for one head checked again)",
"members": "the members, as JSON, as delivery-order takes them",
"ready": "\"true\" when the pull request is marked ready: its check is queued before every other check (novox/hq ADR 0406 §3)"},
[]string{"members"})},
{Name: "deliver", Description: "The delivery's word that a walk waiting for it may start (novox/hq ADR 0239): its " +
"first tier is asked at the controller's next pass. Refused for a walk that waits for nobody.",
Input: schema(map[string]string{"plan": "the walk's id", "why": "what lets it go: its turn, a person's release"},
[]string{"plan"})},
{Name: "delivery-stop", Description: "End a walk on its delivery's word (novox/hq ADR 0239): failed, said as stopped " +
"by whom and why; what it asked still builds and registers, nothing further is asked or sent. Given merges to " +
"leave out, every other merge the walk answered is walked again — in the open batch of its kind, or a new one — " +
"without them, without each later merge of their repository, which contains one, and without each merge moving " +
"a module one moves, each left out too and named (novox/hq ADR 0299); a batch not yet cut drops the merges. The " +
"answer names the batch, the merges walked again, the merges left out, the modules the stopped walk sent that " +
"stay on their machines, and the merges elsewhere that will still deliver a stopped change.",
Input: schema(map[string]string{"plan": "the walk's id", "why": "why", "by": "who stopped the delivery",
"without": "merges to leave out, <repository>@<commit>, comma-separated"},
[]string{"plan", "why"})},
{Name: "delivery-backups-again", Description: "Ask again the backups a walk was held for (novox/hq ADR 0404 " +
"decision 4): a walk whose backup before its send failed or did not answer is held, nothing asked or sent; this " +
"clears those failed backups and the next look at the plans asks them again, and only them. Refused for a walk " +
"with no failed backup. The delivery's release calls it. The answer names the walk and the backups asked again.",
Input: schema(map[string]string{"plan": "the held walk's id", "why": "a person's reason, as release passes it",
"by": "who released the delivery"}, []string{"plan", "why"})},
{Name: "delivery-walks", Description: "The walks the controller keeps (novox/hq ADR 0239): every open one and the " +
"last ended ones, each whole — its tiers, each module's state, first machines and first-node gate with its readings, and its " +
"phases from its merge to every machine running it (novox/hq ADR 0282) — and whether the delivery seat has a " +
"holder on record. Given a plan, that one.",
Input: schema(map[string]string{"plan": "one walk's id", "limit": "how many ended walks beside the open ones (default 50)"},
nil)},
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it — " +
"or, with files, the files it would be given; or, with diff, what a push would change there against what " +
"it was last sent: resources added, removed and changed, a container's changed fields (it is recreated), " +
"and any mount whose directory on the machine would move (novox/hq ADR 0217).",
Input: schema(map[string]string{
"node": "the machine's name",
"files": "\"true\": the files this machine would be given, instead of the declaration as JSON",
"diff": "\"true\": what a push would change on this machine, against what it was last sent; not with files",
}, []string{"node"}, "files", "diff")},
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there, " +
"or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207). " +
"A module not registered yet is looked for (novox/hq issue 325, ADR 0261): while its build runs — a merge " +
"that adds a module asks for one — the assignment is kept as pending and the controller makes it when the " +
"build registers it; `status` lists it, and if the build fails it expires with why and raises a condition. " +
"A module known and not built is said, and build asks for its build; a name with no module and no build " +
"request kept is refused with the closest names.",
Input: schema(map[string]string{"node": "the machine's name",
"module": "the module's name; several comma-separated are judged together",
"build": "\"true\": for a module known and not built (its last build failed, or was never asked), ask " +
"for its build and keep this assignment pending until the build registers it"},
[]string{"node", "module"}, "build")},
{Name: "unassign", Description: "Take a module off a machine. Refused when it holds a seat a module left there depends on.",
Input: schema(map[string]string{"node": "the machine's name",
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
"it runs on, both. Asked for when more than one could answer; the refusal lists them.",
Input: schema(map[string]string{
"node": "the machine's name",
"provision": "the provision, as the consumer requires it",
"from": "the node the chosen provider runs on",
"module": "the module providing it there",
}, []string{"node", "provision", "from", "module"})},
{Name: "unpin", Description: "Take that choice back, putting the question to the mesh again.",
Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})},
{Name: "push", Description: "Send one machine everything it should be. With no machine named it is a push of the " +
"WHOLE mesh — every machine that is behind — and the answer says so first; behind says that outright. " +
"Answers at once that it is running, with a call id: `calls` with that id says what it sent and, for a " +
"machine named, how that machine applied it — applied, refused with its reason, or each failed resource — " +
"once it reports within wait (a push can reload the bus, which then refuses any answer still to come). A push by hand is a repair, " +
"and says why: recorded in the hand-act log (novox/hq to-be 45 §7). A machine whose push would give a " +
"running module's data another directory is held and named, the others sent (novox/hq ADR 0217): read " +
"`plan` with diff, and name the machine with move to send it.",
Input: schema(map[string]string{
"node": "the machine's name; without it, every machine that is behind",
"move": "with node: the modules whose data may move with this push, comma-separated (optional)",
"wait": "with node: how long the call waits for the machine to report how it applied what it was sent, as \"90s\" or \"2m\"; \"0\" waits for nothing; without it, the controller's setting push-wait (optional)",
"behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node",
"why": "why this is pushed by hand: recorded in the hand-act log",
"cause": "the cause in a word, or a condition's kind — the word a second push for the same reason uses (optional)",
}, []string{"why"}, "behind")},
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine and module, " +
"else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " +
"name: made anew and the machine sent, so the module starts again on it — for a secret its definition " +
"says it reads at start, whether the mesh made the value or a person gave it (novox/hq ADR 0228); one " +
"an outside party issued, or one the module applies to a backend, is refused with the reason.",
Input: schema(map[string]string{
"provision": "a pair credential: the provision whose credential to replace",
"consumer": "with provision: only the holder on this machine (optional)",
"node": "an own secret: the machine",
"module": "an own secret: the module; with provision: only this consuming module's credential (optional)",
"secret": "an own secret: its name in the module's definition",
"why": "an own secret: why it is rotated — recorded in the hand-act log (optional)",
"cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)",
}, nil)},
{Name: "give", Description: "Take a module's own secret from the operator at their desk (novox/hq ADR 0259 " +
"§10): the same as secret-ask with the desk named. A prompt that does not show what is typed opens on " +
"the machine named by at, its answer comes back sealed to this call alone, and is sealed to the " +
"module's machine as `secret accept` seals it. The value is never an argument and never in the answer: " +
"the answer says it was taken, or why not. Recorded in the hand-act log as a value given at the desk. " +
"The prompt waits 25 seconds; dismissed or unanswered, nothing changes. Then push the machine.",
Input: schema(map[string]string{
"node": "the machine the module runs on, which the secret is sealed to",
"module": "the module's name",
"secret": "the own secret's name in the module's definition",
"at": "the machine the operator sits at, where the prompt opens",
}, []string{"node", "module", "secret", "at"})},
{Name: "issued", Description: "Take the credential a provider's software issued for a provision — a media " +
"server's sign-in token — and keep it as that provision's one credential for every consumer (novox/hq ADR " +
"0305): the controller asks the provider's own tool on its machine, in the controller's own name, for the " +
"value sealed to a key made for this call alone, seals it to every holder and forgets it, and sends their " +
"machines when it changed. Carries no value and answers none; a value every holder already holds is not " +
"sealed again, so asking again restarts nothing. Refused for a provision no offer says its software issues.",
Input: schema(map[string]string{
"provision": "the provision whose provider's software issues its credential, e.g. plex-api",
}, []string{"provision"})},
{Name: "secret-move", Description: "Move every secret the controller holds into the vault, as it is (novox/hq " +
"ADR 0349 clause 7, design 57's V3): each machine's node-engine opens what was sealed to it and hands it to " +
"the vault, which takes it from that machine alone, never over a value it holds, and reads it back; the " +
"record of asks at a desk is copied once. No value is rotated, in the arguments or in the answer, and " +
"nothing points at the vault because of it. Answers, per machine, what moved, what was there already and " +
"what did not move and why; a machine not answering is moved when it is next heard from. Run again, it " +
"moves what is left.",
Input: schema(map[string]string{
"node": "only this machine's secrets (optional; every machine's without it)",
}, nil)},
{Name: "secret-ask", Description: "Ask the operator for a module's own secret (novox/hq ADR 0277): a prompt " +
"that shows nothing of what is typed opens at the desk — the module's machine, or the one at names — " +
"naming the module, the secret and who asked; the operator types the value there, never on a channel " +
"and never in a verb's argument; the answer comes back sealed to this call alone and is sealed to the " +
"module's machine as `secret accept` seals it. The answer says the value was taken, or why not. " +
"Refused for a secret the mesh issues itself (the bus account, one the mesh may make) and for a module " +
"that runs as an account of its own, whose value is typed at the controller's terminal. Bounded: one " +
"open prompt per secret, three asks an hour. Recorded in the hand-act log, with who asked, and " +
"announced on every channel. Then push the machine.",
Input: schema(map[string]string{
"node": "the machine the module runs on, which the secret is sealed to",
"module": "the module's name",
"secret": "the own secret's name in the module's definition",
"at": "the machine the operator sits at, where the prompt opens; the module's machine when absent",
}, []string{"node", "module", "secret"})},
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
Input: schema(map[string]string{
"node": "the machine that runs the module",
"module": "the module's name",
}, []string{"node", "module"})},
{Name: "token", Description: "Refused through a verb since novox/hq ADR 0266: the one-time token a machine " +
"joins with is answered to its caller, and whoever may call a verb includes agents. Issue it at the " +
"controller's terminal: `mesh-controller token issue --new <name> --overlay-key <public half>`.",
Input: schema(map[string]string{
"node": "a machine the mesh already has a record for",
"new": "or the name of a machine to create the record for",
"overlay_key": "the public half of the machine's tunnel key",
"for": "how long it may be used, as a duration (default 1h)",
"adopted": "\"true\" when the machine is in use and joins adopted",
}, nil, "adopted"),
Replaces: []string{"mesh-controller token issue", "wg set"}},
{Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings for the whole " +
"mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it — " +
"then every value the module gives a default or a layer sets, with where it comes from: the module's default, " +
"the mesh, or the machine (novox/hq ADR 0262); with list \"preferences\", or with no module, every " +
"module's preferences and each machine's value; " +
"with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " +
"changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " +
"(novox/hq ADR 0217). Takes effect at the next push. With clear, removes the layer and the module is back to " +
"what its definition says; a cleared or replaced layer is kept in the history. A trusted setting — " +
"places, accesses, what a provider serves, what a trusted file asks for, the whole layer of a module " +
"with a trusted mergeable file — is refused through this verb (novox/hq issue 339, 340) and PROPOSED " +
"instead (novox/hq ADR 0277): with propose, the values (or clear) are put to the operator on a channel " +
"that proves who answers, with every key and its exact new value, and the layer is set only on their " +
"Approve; the answer names the proposal, its fingerprint and when it expires, and nothing changes until " +
"then. With proposals, every proposal and where each stands; with proposal, one whole. Once move-config has " +
"run, a module in the config form forwards to mesh-config (issue 664): show lists what config lists, set and " +
"clear set and clear each value as the module's own on the machine (none for the whole mesh), placement " +
"staying beside the assignment, and propose proposes each value through mesh-config.",
Input: schema(map[string]string{
"module": "the module's name; with list, only that module's",
"values": "the settings as a JSON object, for set or propose",
"node": "one machine; the whole mesh when absent",
"clear": "\"true\" to remove the layer instead of setting it, or to propose its removal; not with values",
"replace": "\"true\": with values, the set (or the proposal) is meant to remove the keys the layer had and it does not name",
"history": "\"true\": without values or clear, the layers this one replaced, the latest first",
"list": "\"preferences\": every module's preferences — key, default and why — and the value on each " +
"machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)",
"propose": "\"true\": propose the values (or clear) to the operator instead of setting them (novox/hq ADR 0277)",
"proposals": "\"true\": every settings proposal, newest first, and where each stands",
"proposal": "a proposal's id: that proposal whole, its values and the layer it was shown against",
}, nil, "clear", "replace", "history", "propose", "proposals")},
{Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " +
"its shell — `node show ace`, `module list`, `plans`, `conditions show <key>` — and answer what it " +
"printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " +
"status, version, help, seats, healers, hand-acts, durations, collection, images, artifacts, data, builds, " +
"queue, plan, plans (not stop/close/go), doctor (not run), conditions list/show/history, node list/show, " +
"module list, settings show/preferences, retire list, cleanup list, delivery plan/walks, bus, mirrors (not " +
"--record/--confirm). What writes has its named verb; what sets a key, issues a credential or a token, or " +
"accepts, recovers or exports a secret is the controller's terminal's alone.",
Input: schema(map[string]string{
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
}, []string{"command"})},
// The build queue, controlled by hand (novox/hq ADR 0219). Every verb that drops an ask leaves a
// failed outcome for it, so a plan waiting on it fails visibly instead of hanging.
{Name: "queue", Description: "Every ask in the build queue: waiting, in flight (on which machine, for how long), " +
"and dead (handed out as often as allowed and never settled) — each with its id, repository, path, ref and when it was asked. " +
"Answers the table as text and, as data, each ask's state, kind (check or build), repository, commit, machine, since " +
"and position among the waiting (novox/hq issue 455).",
Input: schema(nil, nil)},
{Name: "cancel", Description: "Drop one waiting or dead ask from the build queue; its outcome is recorded failed, " +
"cancelled by hand, and a plan that asked for it fails. One in flight is refused: `kill` ends it where it runs.",
Input: schema(map[string]string{"id": "the ask's build id, as `queue` lists it"}, []string{"id"})},
{Name: "clear", Description: "Cancel every waiting ask in the build queue — and with dead, every dead one too — each " +
"recorded failed, cancelled by hand. Never touches one in flight.",
Input: schema(map[string]string{"dead": "\"true\" to cancel the dead asks as well"}, nil, "dead")},
{Name: "rebuild", Description: "Ask a module's current source again under a new id — the branch it follows — or, given a " +
"build's id, that build's repository, path and ref. A module a plan holds unbuilt or failed joins that plan. Answers the new id.",
Input: schema(map[string]string{"what": "a module's name, or a build's id"}, []string{"what"})},
{Name: "replay", Description: "Ask a recorded build's repository and path again at the commit it built, under a new id. " +
"A dry run unless register: nothing recorded or registered. Registering is refused when a newer build of the module " +
"is registered — it would roll the older commit out (novox/hq issue 207) — unless older says so.",
Input: schema(map[string]string{
"id": "the build's id",
"register": "\"true\" to register what it builds",
"older": "\"true\", with register: even though a newer build of the module is registered",
}, []string{"id"}, "register", "older")},
// novox/hq issue 686: a builder ran a branch's store tests for the first time in its pull request's merge check.
{Name: "try", Description: "Try a pushed branch or commit before its pull request (novox/hq issue 686): the build " +
"seat merges it into the repository's trunk and runs the repository's own merge-check.sh on the merge, in the " +
"toolchain containers and against the throwaway store and bus a pull request's merge check gets. Answers at " +
"once with an id; asked again with that id it waits up to 20 s and answers pass, fail or error, how long it " +
"took and the lines naming what failed — or that it is still waiting or running, to ask again. Sets no " +
"status on the forge, registers and builds nothing, runs no merge gate, counts as a check in the build queue " +
"(after deliveries and after every pull request's check, however long it waits) and is bounded as one. One " +
"try of a commit at a time — a second ask answers the first's id — and at most four waiting or running in " +
"all: a fifth is refused. Only for a repository of the mesh; the ref must be on the forge.",
Input: schema(map[string]string{
"repository": "the repository on the forge, owner/name; with ref",
"ref": "the branch or commit to try, as pushed to the forge",
"base": "the branch it is merged into (the repository's default branch when none is named). The " +
"merge-check.sh that runs is that branch's, never the ref's own: the same trust as a pull request's base",
"tests": "only the tests this `go test -run` pattern names, where the repository's merge-check.sh reads " +
"MESH_CHECK_TESTS; a script that does not runs whole",
"store-only": "\"true\": only the repository's store tests, where its merge-check.sh reads " +
"MESH_CHECK_STORE_ONLY",
"id": "instead of a repository: the id a try answered, to read what came of it",
}, nil, "store-only"),
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
// novox/hq ADR 0350 §5: asked by the delivery's owner when the forge says a pull request closed unmerged.
{Name: "forget-checks", Description: "Let go of every build a pull request's merge checks made for its merge in " +
"the artifact store's scratch namespace (novox/hq ADR 0350 §5): asked when it closed unmerged; a merged one's go " +
"when the walk answering its merge ends, and any check build goes after a week.",
Input: schema(map[string]string{"owner": "the repository's owner", "repo": "the repository",
"number": "the pull request's number"}, []string{"owner", "repo", "number"})},
{Name: "kill", Description: "End a build where it runs: the machine that took it stops its commands and containers and " +
"announces it failed, killed by hand — settled, never handed to another machine.",
Input: schema(map[string]string{"id": "the build's id"}, []string{"id"})},
{Name: "pause", Description: "The build seat's holder on one machine — or every holder — takes no new build until resumed; " +
"a build running finishes. Kept across a restart of the holder. A plan waiting on a paused seat says so and is not late.",
Input: schema(map[string]string{"node": "one machine; every holder when absent"}, nil)},
{Name: "resume", Description: "The build seat's holder on one machine — or every holder — takes builds again.",
Input: schema(map[string]string{"node": "one machine; every holder when absent"}, nil)},
// Acts done by hand, and what the bounds are set from (novox/hq to-be 45 §7, Phase 0).
{Name: "hand-act", Description: "Record an act done by hand outside the mesh — a container restarted, a file " +
"edited, a service started on a machine — with why and its cause, in the hand-act log beside the pushes and " +
"plans ended by hand (novox/hq to-be 45 §7). A cause recorded twice in a fortnight is a healer wanted.",
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 — the word a second act for the same reason uses",
"condition": "the key of the condition it addressed, if any (optional)",
}, []string{"what", "why", "cause"})},
{Name: "drill", Description: "Record a drill — something broken on purpose to see the mesh raise and clear it: " +
"a module stopped, a process killed — with what it tests, in the hand-act log. A drill is a person's " +
"deliberate test, never a repair: no healer is wanted for it however often it is run (S15).",
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"})},
{Name: "warranted", Description: "Record in the hand-act log what a module did on the operator's warrant (novox/hq " +
"ADR 0274, ADR 0259): who chose, through which channel, with which proofs and which answer are read from the " +
"router's own record of that module's ask, never from the caller; recorded once per ask however often it is " +
"asked; the caller names the ask and says nothing else. 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, e.g. claude-code",
"ask": "its ask's id",
}, []string{"asker", "ask"})},
{Name: "hand-acts", Description: "What was done by hand lately — pushes, plans ended, consumers re-made, acts " +
"recorded — who, why and the cause of each, and which causes repeat: each repeat is a healer the mesh lacks.",
Input: schema(map[string]string{"days": "how many days back (default 14)"}, nil)},
{Name: "durations", Description: "How long things take, as the controller measured them: a send to its machine's " +
"report (apply), a plan's tier, a build, and each phase of an " +
"ended walk per class (walk-phase, novox/hq ADR 0282) — per machine, repository, module or class/phase, with " +
"median, p90 and max. What the core's bounds are set from (novox/hq to-be 45 Phase 0).",
Input: schema(map[string]string{
"kind": "one kind: apply, plan-tier, build or walk-phase; every kind when absent",
"days": "how many days back (default 14)",
}, nil)},
// What is wrong, and the self-check (novox/hq to-be 45 §2, §4).
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, " +
"then oldest — raised by the watchdogs of the signals table, the self-check's probes and the providers' " +
"own words, and cleared when observation says it is resolved, never by hand. Given a key, that one " +
"whole with its evidence; with history, every transition lately; with silence, stop one's messages " +
"for a while — a hand act, which says why (novox/hq to-be 45 §2).",
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 or mesh",
"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)",
"silence": "a condition's key: send no message for it for a while; it stays open and in status",
"for": "with silence: how long — 30m, 4h, 2d; at most 7d",
"why": "with silence: why — required, and recorded in the hand-act log",
"cause": "with silence: the cause in a word (the condition's kind when absent)",
}, nil, "history")},
// What the healers did (novox/hq to-be 45 §7).
{Name: "healers", Description: "The healers (novox/hq to-be 45 §7): each registered response to one kind " +
"of condition — its repair (the ordinary path again), its budget, what happens when it is spent — every act " +
"they took lately with its outcome, and the mesh-wide brake. A heal is never a hand act; whether it " +
"repaired anything is the condition's clearing to say. Each act is also in its condition's tried.",
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
{Name: "doctor", Description: "The self-check (novox/hq to-be 45 §4): the last run's verdict at once — " +
"each probe of the design's live invariants passed, failed or could not run, and how long ago. With " +
"run, a run now; with probes, the registry; with signals, every row of the signals table and the age " +
"of its newest signal. Runs every five minutes on its own; each failure is an open condition.",
Input: schema(map[string]string{
"run": "\"true\": run every probe now and answer the verdict",
"probes": "\"true\": the registry — what each probe asserts, and the condition it raises",
"signals": "\"true\": the signals table, each row with the age of its newest signal",
"probe": "one probe's id (D14): its verdict alone, with every finding — the verdict shows at most twenty a probe",
}, nil, "run", "probes", "signals")},
// How a module's new builds reach its machines, and the bus's planned step (novox/hq ADR 0236).
{Name: "upgrade", Description: "How each module's new builds reach its machines (novox/hq ADR 0236): rolled " +
"out one machine first and judged there at the gate, then the rest — or recorded, waiting for a person's " +
"push — with where that comes from (a person, the module, the bus, its irreplaceable data, the default) and " +
"why. With module, that one; with policy, a person's choice for it — roll-out, record (with why) or default " +
"to take the choice back. The bus is never rolled out.",
Input: schema(map[string]string{
"module": "one module",
"policy": "with module: roll-out, record or default",
"together": "\"true\": with roll-out, every machine at once instead of one machine first",
"why": "with policy or release-backlog: why — required for record and for a release, kept",
"backlog": "\"true\": every build waiting for a gate, per machine — what a release plan would send",
"release-backlog": "\"true\": release what waits now, one machine at a time, each judged — after a release " +
"plan failed, the mesh waits for this; with why",
"cause": "with release-backlog: the cause in a word (optional)",
}, nil, "together", "backlog", "release-backlog")},
{Name: "bus", Description: "The bus as a planned step (novox/hq to-be 45 §8, ADR 0236): what a bus upgrade " +
"would do — the bus's build on each machine against the one the mesh holds — and how the last step went. " +
"With upgrade, start one: a person's act with why, after the streams are snapshotted (snapshot-taken says " +
"where, while the mesh takes none itself), saying first whether it can be reverted; bus-maintenance is open " +
"while it runs and every stream, consumer and a round trip are checked after.",
Input: schema(map[string]string{
"upgrade": "\"true\": start the bus's upgrade",
"why": "with upgrade: why — required, recorded in the hand-act log",
"cause": "with upgrade: the cause in a word (default bus-upgrade)",
"reversible": "\"true\": with upgrade, the new version can be undone by putting the old one back",
"irreversible": "\"true\": with upgrade, it cannot — your explicit word that it runs anyway",
"snapshot-taken": "with upgrade: where the streams' snapshot you took is",
}, nil, "upgrade", "reversible", "irreversible")},
// A consumer the mesh stopped asking for: retired, waiting for a person, deleted only by one
// (novox/hq ADR 0230).
{Name: "retire", Description: "A consumer the mesh stops asking for is retired by its provider — access " +
"disabled, data kept — after the same answer in five passes; more than three at once, or more than half " +
"of those held, waits for a person. With no answer, every provider that waits and what it would retire. " +
"With answer approve or reject, a node and a module: retire exactly what that provider waits with, or " +
"keep it active — a hand act, which says why (novox/hq ADR 0230).",
Input: schema(map[string]string{
"answer": "approve or reject: answer what the provider waits with (needs node, module and why)",
"node": "with answer: the machine the provider runs on",
"module": "with answer: the provider module",
"why": "with answer: why — required, and recorded in the hand-act log",
"cause": "with answer: the cause in a word (retire-waiting when absent)",
}, nil)},
{Name: "cleanup", Description: "Every consumer a provider holds retired — its age, its size where the " +
"backend knows, and why it was retired — and every module's own data retired on its machine (ADR 0233). " +
"With consumer (and node, module): the provider deletes that one retired consumer — never an active one; " +
"for a module's own retired item, consumer names the item and the machine's backup holder takes a last " +
"restore point of it, then deletes it. With older-than: every retired consumer older than that many " +
"days, listed; deleted only with confirm. Deleting is a hand act, which says why (novox/hq ADR 0230).",
Input: schema(map[string]string{
"node": "with consumer: the machine the provider runs on",
"module": "with consumer: the provider module",
"consumer": "delete this retired consumer (needs node, module and why)",
"older-than": "delete every consumer retired more than this many days (needs why; lists only without confirm)",
"confirm": "\"true\": with older-than, delete what is listed",
"why": "with consumer or older-than: why — required, and recorded in the hand-act log",
"cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)",
}, nil, "confirm")},
{Name: "judge-directories", Description: "Read-only: which of the directories one machine is declared its " +
"node-engine's walk that follows no link another account could make would refuse (novox/hq issue 358), one line " +
"each: placed, would be refused and why (the link and who could replace it), refused already by the placement " +
"guard, or below a home. The engine judges the declaration it keeps and makes, chmods and chowns nothing.",
Input: schema(map[string]string{
"node": "the machine's name",
}, []string{"node"})},
// What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it.
{Name: "root-free", Description: "Whether each machine named is root-free now (novox/hq ADR 0259 §8): no agent " +
"there can become root without a person. Judged when asked, never from a condition: free only when the machine " +
"names an account its agents run as, its node-engine judged that account unable to become root within the " +
"last 15 minutes, and the login shell's execute is not served there. Anything else, a read that failed " +
"included, is not free and says why. The router asks it before an answer from a channel proving its sender " +
"may approve. Only reads.",
Input: listed(schema(map[string]string{
"machines": "the machines to judge, by name: a list, or one text separated by commas",
}, []string{"machines"}), "machines")},
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on after handing it over " +
"as often as it may, 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 what it said. With deliver: hand it " +
"again to the consumer that gave it up, and nobody else; with drop: let it go for good. Delivering and " +
"dropping are hand acts, which say why (novox/hq issue 330).",
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 in the hand-act log",
"cause": "with deliver or drop: the cause in a word (dead-letter when absent)",
}, nil)},
// The data every machine declares (novox/hq ADR 0233).
{Name: "data", Description: "Every item of data every machine declares, as the self-check last measured it: " +
"its class (irreplaceable, rebuildable, cache), where it is, its size, its newest write, its newest good " +
"backup and the bound on it — and what is retired: kept after its module left the machine, removed only by " +
"`cleanup delete` (novox/hq ADR 0233).",
Input: schema(map[string]string{
"machine": "one machine (optional)",
"retired": "\"true\": only what is retired",
}, nil, "retired")},
// Three shapes, as the command has them: one repository, every module standing on a base (on), or
// every module behind its source (behind). One per call; a second is refused as passed over.
{Name: "build", Description: "Have the build machine build a repository — or, instead, every module that " +
"stands on a base (on), or every module behind its source (behind). Answers at once with each build's id: " +
"`builds` with an id follows it line by line, and a module is registered when its outcome comes.",
Input: schema(map[string]string{
"repository": "the repository's URL, or its path on the forge holding the git seat (owner/name); " +
"needed unless on or behind is given",
"path": "with repository: the module's directory inside it (optional)",
"ref": "with repository: the branch, tag or commit to build (optional)",
"on": "instead of a repository: a module's name. Every module the mesh holds that was built against " +
"its artifacts is built again from its own source, bases among them asked first — the rebuild a " +
"changed base needs, which behind does not see because their sources did not move",
"behind": "\"true\", instead of a repository: build every module the mesh holds whose source has moved " +
"past the commit it was built from, bases asked first",
}, nil, "behind")},
// What the records say the registries may keep, asked on demand (novox/hq ADR 0251, to-be 51).
{Name: "artifacts", Description: "Every artifact the mesh recorded making that lives in its artifact store, " +
"each with its repository, digest, kind (image or archive) and state: kept — and why: a definition names " +
"it, or one of the five most recent builds of a module the mesh holds — or eligible: made, kept for no " +
"reason, not yet let go. Collected ones are counted, and listed only with collected. Reads only " +
"(novox/hq ADR 0251).",
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 (they only grow; counted always)",
}, nil, "collected")},
{Name: "collect", Description: "The artifact store's sweep, now: what the mesh made and keeps for no reason " +
"is let go of. Without confirm a dry run — every kept archive asked about with HEADs, what would be let go " +
"listed, nothing held or deleted. With confirm and why: every kept archive held first, then each eligible " +
"artifact let go of, oldest first, and recorded collected — a hand act, recorded with its why. A confirmed " +
"collect lets an index go with the platform manifests no kept index names (read first; any read that " +
"fails lets nothing go); the sweep after a build leaves an eligible index for such a collect (novox/hq ADR " +
"0257). Bounded by " +
"most (500 by default, at most 5000) and 45 seconds; what is left is said. Bytes are reclaimed by the " +
"store's nightly collector. Never touches a digest the mesh did not record (novox/hq ADR 0189, ADR 0251).",
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 in the hand-act log",
"most": "let go of at most this many (500 by default, at most 5000)",
}, nil, "confirm")},
{Name: "move-placement", Description: "The move of placement out of the settings layers, run once after the " +
"delivery that holds it (novox/hq ADR 0348 clause 11, issue 736): every placement key a settings layer still " +
"holds — endpoints, expose, reach and ports, which a layer set before then keeps among its settings — is put " +
"beside the assignment, where places, accesses and networks already are. Without confirm it says which layers " +
"hold which keys and moves nothing. With confirm and why: in one transaction, each such layer is kept whole " +
"in its history first (why: moved to placement), its keys are laid over the placement beside it, and the " +
"layer keeps its other keys, when it was set and who set it — and the act is recorded in the hand-act log. " +
"Answers each layer moved: its machine (empty for the whole mesh), its module and its keys. Nothing a " +
"machine is told changes: composition reads the keys as placement wherever they are. Asked again, it " +
"moves nothing.",
Input: schema(map[string]string{
"confirm": "\"true\": move them (needs why); without it nothing is moved",
"why": "with confirm: why — required, and recorded in the hand-act log",
}, nil, "confirm")},
{Name: "move-config", Description: "The move of config: every config value a settings layer holds is set in " +
"mesh-config, run once after the delivery that holds it and after move-placement (novox/hq ADR 0348 §9, " +
"issue 664). A machine's layer becomes the module's own value on that machine; a whole-mesh layer becomes " +
"mesh config for the predefined key its value falls back to where that module alone falls back to it, and " +
"otherwise the module's own value on each machine it is assigned to, laid under that machine's layer; a key " +
"a layer says by its file's name is set under its config key. Without confirm it says each value it would " +
"set, where from, each layer that stays (its module is in the settings form) and anything that refuses the " +
"move, and sets nothing. With confirm and why: each value is set through mesh-config in the controller's " +
"name, every module's resolved values are read back and compared with what its layers lay today, and only " +
"then, in one transaction, is each layer moved kept whole in its history first (why: moved to mesh-config) " +
"and emptied, and the controller switched to read mesh-config where it read the layers — the act recorded " +
"in the hand-act log. Nothing a machine is told changes. Asked again, it sets nothing set already.",
Input: schema(map[string]string{
"confirm": "\"true\": set the values and switch (needs why); without it nothing is set",
"why": "with confirm: why — required, and recorded in the hand-act log and with each value",
}, nil, "confirm")},
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store for builds to stand on, as " +
"the records hold them: each with its state — kept because a kept build stood on it, or eligible — and " +
"which build or person recorded it. With record: references of copies no record names (a copy made " +
"before copies were recorded, or by a build that failed before then) to record as the mesh's, so the " +
"sweep keeps or lets go of them like any other; only a repository the mirror writes is taken " +
"(upstream/<host>/<path>, or a module's <module>/on-<argument>), and each must be in the store. Without " +
"confirm a dry run; with confirm and why, recorded — a hand act. A recorded copy is eligible: the next " +
"sweep lets it go unless a kept build stood on it. Records only: this verb deletes nothing " +
"(novox/hq ADR 0257).",
Input: schema(map[string]string{
"record": "references to record, artifact-store://<repository>@sha256:<hex>, separated by spaces or commas",
"confirm": "\"true\": record them (needs why); without it nothing is recorded",
"why": "with confirm: why — required, and recorded with each copy and in the hand-act log",
}, nil, "confirm")},
{Name: "images", Description: "Every container image one machine's declaration names — the declaration the " +
"mesh would send it now and the one it was last sent — each with the resources naming it: what the " +
"machine keeps when it prunes its images. sent_known is false when the last one sent is not kept with " +
"its images. Reads only (novox/hq ADR 0251).",
Input: schema(map[string]string{
"node": "the machine's name",
}, []string{"node"})},
}
// 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
}
// 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
}
// unpromised is what a claim says it serves and the seat's protocol never promised.
func unpromised(serves []string, promised []Verb) []string {
has := map[string]bool{}
for _, v := range promised {
has[v.Name] = true
}
var extra []string
for _, s := range serves {
if !has[s] {
extra = append(extra, s)
}
}
return extra
}
// unservedVerbs is what a seat promises and a claimant's offer for it does not answer.
func unservedVerbs(tools []string, promised []Verb) []string {
has := map[string]bool{}
for _, t := range tools {
has[t] = true
}
var missing []string
for _, v := range promised {
if !has[v.Name] && !v.Optional {
missing = append(missing, v.Name)
}
}
return missing
}
+117
View File
@@ -0,0 +1,117 @@
package catalogue
import (
"fmt"
"sort"
"strings"
)
// Zones: names a module answers itself (novox/hq ADR 0199).
//
// The mesh's resolver holds each node's internal domain and nothing else (ADR 0191, 0194). A module
// whose names are its own — the lab's scenario machines, known only while a scenario runs — declares
// the zone it answers and the listen that answers it; the controller hands the resolver's holder every
// zone with the declaring node's private address and the port that listen is published on, and the
// holder forwards the zone there. **A definition names no address** (ADR 0112): the zone is a setting,
// the listen is the module's own, and where they are is the mesh's fact.
// Zone is the manifest's declaration that a module answers the names in one zone.
type Zone struct {
// Name is the zone: a label or a dotted name, normally `${setting:<key>}`, so the operator chooses
// it and the definition does not.
Name string `json:"name"`
// Listen names one of the module's listens: the DNS answerer for the zone.
Listen string `json:"listen"`
}
// ZoneAt is a declared zone where the mesh placed it: what the resolver's holder forwards, and where.
type ZoneAt struct {
Zone string
Node string
Module string
Address string
Port int
}
// zoneProblems is what is wrong with a module's zone declaration on its own, before any node.
func zoneProblems(m Manifest) []string {
if m.Zone == nil {
return nil
}
var problems []string
if strings.TrimSpace(m.Zone.Name) == "" {
problems = append(problems, fmt.Sprintf("%s declares a zone with no name", m.Module))
}
if !m.hasListen(m.Zone.Listen) {
problems = append(problems, fmt.Sprintf(
"%s declares zone %q answered by listen %q, and has no listen of that name",
m.Module, m.Zone.Name, m.Zone.Listen))
}
return problems
}
func (m Manifest) hasListen(name string) bool {
if name == "" {
return false
}
for _, l := range m.Listens {
if l.Name == name {
return true
}
}
return false
}
// ZoneOn is one module's zone as one node places it: the name settled from the node's settings, the
// port its answering listen is published on there. Nothing when the module declares no zone.
func ZoneOn(m Manifest, layers []Layer, published map[int]int, node, address string) (*ZoneAt, error) {
if m.Zone == nil {
return nil, nil
}
settled, err := Settle(map[string]any{"zone": m.Zone.Name}, layers)
if err != nil {
return nil, fmt.Errorf("%s's zone on %s: %w", m.Module, node, err)
}
zone := strings.Trim(strings.ToLower(fmt.Sprint(settled["zone"])), ".")
var port int
for _, l := range m.Listens {
if l.Name == m.Zone.Listen {
port = l.Port
if at, given := published[l.Port]; given {
port = at
}
}
}
return &ZoneAt{Zone: zone, Node: node, Module: m.Module, Address: address, Port: port}, nil
}
// ZonesProblems is what the mesh refuses about its zones together: one zone declared twice, a zone
// that is the mesh's suffix or under it, a zone that is a node's public domain or under one. A module
// may not shadow names the mesh's resolver or the public DNS answers.
func ZonesProblems(zones []ZoneAt, suffix string, publicDomains []string) []string {
var problems []string
under := func(zone, domain string) bool {
domain = strings.Trim(strings.ToLower(domain), ".")
return domain != "" && (zone == domain || strings.HasSuffix(zone, "."+domain))
}
seen := map[string]ZoneAt{}
for _, z := range zones {
if other, twice := seen[z.Zone]; twice && (other.Node != z.Node || other.Module != z.Module) {
problems = append(problems, fmt.Sprintf("zone %q is declared by %s on %s and by %s on %s; one module answers a zone",
z.Zone, other.Module, other.Node, z.Module, z.Node))
}
seen[z.Zone] = z
if under(z.Zone, suffix) {
problems = append(problems, fmt.Sprintf("%s on %s declares zone %q, which is the mesh's own suffix or under it",
z.Module, z.Node, z.Zone))
}
for _, d := range publicDomains {
if under(z.Zone, d) {
problems = append(problems, fmt.Sprintf("%s on %s declares zone %q, which is the public domain %q or under it",
z.Module, z.Node, z.Zone, d))
}
}
}
sort.Strings(problems)
return problems
}
@@ -0,0 +1,87 @@
package declaration
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
)
// Busy is how a long-running container says it holds work that would be lost if it were stopped now
// (novox/hq ADR 0335 §1): a command run inside it, by its shell, as an exec health check is, that exits 0
// while it is busy and non-zero when it is idle. Being asked is also the container's notice that it is
// about to be replaced: from the first ask it takes no new work, so its answer ends once the work in hand
// ends.
//
// **Not part of what the container is.** Busy is not in the container's spec: a changed command changes
// when the container is replaced, never whether — a container is not recreated for its busy command alone.
type Busy struct {
Command string `json:"command"`
// Timeout is how long one ask may take; empty is BusyTimeoutDefault.
Timeout string `json:"timeout,omitempty"`
}
// The bounds of an ask (novox/hq ADR 0335).
const (
BusyTimeoutDefault = 10 * time.Second
// BusyTimeoutCeiling is the longest one ask may take: an ask runs inside an apply, and an apply waits
// for nothing longer than a look.
BusyTimeoutCeiling = time.Minute
// BusyBound is how long a container that says busy is left running after the first apply that would
// have replaced it (ADR 0335 §3). The node-engine's, not the module's: no module holds its own
// replacement for ever.
BusyBound = 15 * time.Minute
)
// UnmarshalJSON reads a busy strictly, as everything a declaration carries is read.
func (b *Busy) UnmarshalJSON(raw []byte) error {
type plain Busy
var p plain
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("busy: %w", err)
}
*b = Busy(p)
return nil
}
// Within is how long one ask may take. Validated on arrival, so an unreadable timeout is the default.
func (b *Busy) Within() time.Duration {
if d, err := time.ParseDuration(b.Timeout); err == nil && d > 0 {
return d
}
return BusyTimeoutDefault
}
// problems holds a container's busy to its shape: a one-line command, a timeout within its bounds, and only
// on a container that stays up.
func (b *Busy) problems(where string, longRunning bool) []string {
if b == nil {
return nil
}
var problems []string
say := func(format string, args ...any) {
problems = append(problems, where+": "+fmt.Sprintf(format, args...))
}
if !longRunning {
say("busy is asked of a container that stays up; a step or a scheduled run is never replaced mid-run")
}
if strings.TrimSpace(b.Command) == "" {
say("busy needs a command")
}
if strings.ContainsAny(b.Command, "\n\r") {
say("busy's command is one line")
}
if b.Timeout != "" {
d, err := time.ParseDuration(b.Timeout)
switch {
case err != nil:
say("busy's timeout is a duration, not %q", b.Timeout)
case d <= 0 || d > BusyTimeoutCeiling:
say("an ask of busy takes more than nothing and at most %s, not %s", BusyTimeoutCeiling, d)
}
}
return problems
}
@@ -0,0 +1,182 @@
package declaration
// A minimal five-field cron, enough to say when a scheduled step is due (novox/hq ADR 0053).
//
// **Written rather than pulled in.** The host depends on nothing it does not have to
// (novox/hq ADR 0005), and a scheduled step needs exactly two questions answered — *is this minute
// a match* and *when is the next one* — over the ordinary five fields (minute, hour, day-of-month,
// month, day-of-week) with `*`, lists (`,`), ranges (`-`) and steps (`/`). That is small enough to
// keep in the vocabulary the host already owns, and a cron library would be a dependency carried
// for the parts of it nobody here uses.
//
// **The host both validates and evaluates.** The control plane refuses a malformed cron near its
// author (novox/hq ADR 0053), and the host refuses it again on arrival for the same near-versus-far
// reason every other field is checked here: a declaration the host does not fully understand is
// refused whole rather than half-applied. The evaluation half — `Next` — is what the scheduler
// fires on.
import (
"fmt"
"strconv"
"strings"
"time"
)
// Cron is a parsed five-field expression, each field held as a bitset of the values it permits.
//
// The two day fields carry a flag for whether they were written as a bare `*`, because standard
// cron gives them a special rule: when day-of-month and day-of-week are *both* restricted, a day
// matches if *either* does; when one is `*`, only the other is consulted. Getting that wrong is the
// classic cron surprise, so the flag is kept rather than rediscovered.
type Cron struct {
minute uint64
hour uint64
dom uint64
month uint64
dow uint64
domStar bool
dowStar bool
}
// ParseCron reads a five-field cron expression, or says why it is not one.
func ParseCron(expr string) (*Cron, error) {
fields := strings.Fields(expr)
if len(fields) != 5 {
return nil, fmt.Errorf(
"a schedule is a five-field cron expression (minute hour day-of-month month "+
"day-of-week), and %q has %d field(s)", expr, len(fields))
}
minute, _, err := parseCronField(fields[0], 0, 59)
if err != nil {
return nil, fmt.Errorf("schedule minute field: %w", err)
}
hour, _, err := parseCronField(fields[1], 0, 23)
if err != nil {
return nil, fmt.Errorf("schedule hour field: %w", err)
}
dom, domStar, err := parseCronField(fields[2], 1, 31)
if err != nil {
return nil, fmt.Errorf("schedule day-of-month field: %w", err)
}
month, _, err := parseCronField(fields[3], 1, 12)
if err != nil {
return nil, fmt.Errorf("schedule month field: %w", err)
}
// Day-of-week is 0-6 with Sunday at 0, and 7 is also accepted for Sunday — the convention every
// cron keeps, so a crontab copied from elsewhere is not refused for saying 7.
dow, dowStar, err := parseCronField(fields[4], 0, 7)
if err != nil {
return nil, fmt.Errorf("schedule day-of-week field: %w", err)
}
if dow&(1<<7) != 0 {
dow |= 1 << 0
dow &^= 1 << 7
}
return &Cron{
minute: minute, hour: hour, dom: dom, month: month, dow: dow,
domStar: domStar, dowStar: dowStar,
}, nil
}
// parseCronField turns one field into the bitset of values it permits, and reports whether it was a
// bare `*` (which the day fields treat specially).
func parseCronField(spec string, min, max int) (uint64, bool, error) {
if spec == "" {
return 0, false, fmt.Errorf("is empty")
}
star := spec == "*"
var bits uint64
for _, part := range strings.Split(spec, ",") {
if part == "" {
return 0, false, fmt.Errorf("%q has an empty element between commas", spec)
}
// A step may follow either `*` or a range: `*/15`, `0-30/5`.
step := 1
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 0, false, fmt.Errorf("step in %q is not a positive number", part)
}
step = n
}
lo, hi := min, max
switch {
case rangePart == "*":
// Full range, already set.
case strings.IndexByte(rangePart, '-') >= 0:
dash := strings.IndexByte(rangePart, '-')
a, errA := strconv.Atoi(rangePart[:dash])
b, errB := strconv.Atoi(rangePart[dash+1:])
if errA != nil || errB != nil {
return 0, false, fmt.Errorf("range %q is not two numbers", rangePart)
}
lo, hi = a, b
default:
n, err := strconv.Atoi(rangePart)
if err != nil {
return 0, false, fmt.Errorf("%q is not a number", rangePart)
}
lo, hi = n, n
}
if lo < min || hi > max || lo > hi {
return 0, false, fmt.Errorf(
"%q is outside the allowed range %d-%d", part, min, max)
}
for v := lo; v <= hi; v += step {
bits |= 1 << uint(v)
}
}
return bits, star, nil
}
// Matches reports whether a scheduled step is due at this minute.
func (c *Cron) Matches(t time.Time) bool {
if c.minute&(1<<uint(t.Minute())) == 0 {
return false
}
if c.hour&(1<<uint(t.Hour())) == 0 {
return false
}
if c.month&(1<<uint(int(t.Month()))) == 0 {
return false
}
domMatch := c.dom&(1<<uint(t.Day())) != 0
dowMatch := c.dow&(1<<uint(int(t.Weekday()))) != 0
// The standard day rule: both restricted means either may match; one as `*` defers to the other.
switch {
case c.domStar && c.dowStar:
return true
case c.domStar:
return dowMatch
case c.dowStar:
return domMatch
default:
return domMatch || dowMatch
}
}
// Next is the first minute strictly after t at which the step is due, and false if there is none
// within a bound generous enough to cover a once-a-year, leap-day schedule.
//
// Minute-by-minute rather than a closed form: the day rule above makes a closed form fiddly and
// error-prone, and this is called once per fire — rarely — so simple and obviously correct wins.
func (c *Cron) Next(after time.Time) (time.Time, bool) {
t := after.Truncate(time.Minute).Add(time.Minute)
// Five years of minutes: enough that "29 2 * * *" (Feb 29) always finds its next leap year.
for i := 0; i < 5*366*24*60; i++ {
if c.Matches(t) {
return t, true
}
t = t.Add(time.Minute)
}
return time.Time{}, false
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
package declaration
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
)
// Health is how a long-running resource is ready, as the controller composed it from the module's
// `health` (novox/hq ADR 0240 rule 2, to-be 48 §2–§3, Phase B): one kind and its timing, the endpoint
// already the port this machine published it on.
//
// **The node-engine runs every kind and owns every verdict.** http and tcp it makes itself, from the
// machine to the port; unit it reads from the service manager it already reads; exec and runtime it hands
// to the container runtime as the container's own check, with this timing, and reads the state; tool it
// asks of its own node tools. Nothing else on the machine sets a container's check.
//
// Refused here as the controller refuses it near the author, in the same bounds: an engine that took a
// check it could not judge would say a module ready that nothing looked at.
type Health struct {
Kind string `json:"kind"`
// Endpoint is the module's name for what Port is: for the words a verdict is said in.
Endpoint string `json:"endpoint,omitempty"`
Port int `json:"port,omitempty"`
Path string `json:"path,omitempty"`
Status int `json:"status,omitempty"`
Body string `json:"body,omitempty"`
Scheme string `json:"scheme,omitempty"`
Command string `json:"command,omitempty"`
Tool string `json:"tool,omitempty"`
Interval string `json:"interval"`
Timeout string `json:"timeout"`
Looks int `json:"looks"`
Grace string `json:"grace"`
// Needs is the provision the check exercises (to-be 48 §6): said with every verdict, so the
// controller can hold what it finds under the provider's own condition.
Needs string `json:"needs,omitempty"`
}
// UnmarshalJSON reads a health strictly, as everything a declaration carries is read: a field this host
// does not know is a part of the check the controller believes it asked for, and nothing would look at it.
func (h *Health) UnmarshalJSON(raw []byte) error {
type plain Health
var p plain
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("health: %w", err)
}
*h = Health(p)
return nil
}
// The kinds.
const (
HealthRuntime = "runtime"
HealthHTTP = "http"
HealthTCP = "tcp"
HealthExec = "exec"
HealthUnit = "unit"
HealthTool = "tool"
)
// The bounds (ADR 0240 rule 2) — the controller's, held again here.
const (
HealthIntervalFloor = 10 * time.Second
HealthLooksFloor = 2
HealthWithin = 5 * time.Minute
)
// Every, Within and GraceOf are the timing, read. Validated on arrival, so a parse error here is
// impossible on a declaration that was accepted; it reads as zero.
func (h *Health) Every() time.Duration { d, _ := time.ParseDuration(h.Interval); return d }
func (h *Health) Within() time.Duration { d, _ := time.ParseDuration(h.Timeout); return d }
func (h *Health) GraceOf() time.Duration { d, _ := time.ParseDuration(h.Grace); return d }
// RunByRuntime says the container runtime runs this check as the container's own: exec and runtime.
func (h *Health) RunByRuntime() bool {
return h != nil && (h.Kind == HealthExec || h.Kind == HealthRuntime)
}
// Words is the check in a few words, as a verdict is said: "http /healthz on web".
func (h *Health) Words() string {
switch h.Kind {
case HealthHTTP:
return "http " + h.Path + " on " + orPort(h.Endpoint, h.Port)
case HealthTCP:
return "tcp on " + orPort(h.Endpoint, h.Port)
case HealthTool:
return "its tool " + h.Tool
case HealthRuntime:
return "its image's own check"
case HealthExec:
return "its command"
case HealthUnit:
return "its unit"
}
return h.Kind
}
func orPort(endpoint string, port int) string {
if endpoint != "" {
return endpoint
}
return fmt.Sprint(port)
}
// problems holds a resource's health to its kind and bounds. container says whether the resource is a
// container; longRunning whether it stays up.
func (h *Health) problems(where string, container, longRunning bool) []string {
if h == nil {
return nil
}
var problems []string
say := func(format string, args ...any) {
problems = append(problems, where+": "+fmt.Sprintf(format, args...))
}
if !longRunning {
say("health is judged on what stays up; a step or a scheduled run is judged by its own outcome")
}
switch h.Kind {
case HealthHTTP, HealthTCP:
if h.Port < 1 || h.Port > 65535 {
say("a %s check needs the port it looks at", h.Kind)
}
case HealthExec:
if !container {
say("an exec check runs inside a container")
}
if strings.TrimSpace(h.Command) == "" {
say("an exec check needs a command")
}
case HealthRuntime:
if !container {
say("a runtime check is a container image's own")
}
case HealthUnit:
if container {
say("a unit check is a service's or a process's own")
}
case HealthTool:
if strings.TrimSpace(h.Tool) == "" {
say("a tool check names the tool")
}
default:
say("health of kind %q; it is runtime, http, tcp, exec, unit or tool", h.Kind)
}
if h.Kind == HealthHTTP {
if !strings.HasPrefix(h.Path, "/") {
say("an http check asks a path starting with /")
}
if h.Status != 0 && (h.Status < 100 || h.Status > 599) {
say("an http check expects status %d, which is not one", h.Status)
}
if h.Scheme != "" && h.Scheme != "http" && h.Scheme != "https" {
say("an http check is over http or https, not %q", h.Scheme)
}
}
if strings.ContainsAny(h.Command, "\n\r") {
say("an exec check's command is one line")
}
every, everyErr := time.ParseDuration(h.Interval)
within, withinErr := time.ParseDuration(h.Timeout)
grace, graceErr := time.ParseDuration(h.Grace)
switch {
case everyErr != nil || withinErr != nil || graceErr != nil:
say("health's interval, timeout and grace are durations")
default:
if every < HealthIntervalFloor {
say("a check looks no more often than every %s, not every %s", HealthIntervalFloor, every)
}
if within <= 0 || within >= every {
say("a look takes more than nothing and less than its interval")
}
if grace < 0 {
say("a grace is not negative")
}
if h.Looks >= HealthLooksFloor && grace+time.Duration(h.Looks)*every > HealthWithin {
say("a grace and the failing looks take at most %s", HealthWithin)
}
}
if h.Looks < HealthLooksFloor {
say("a check is unhealthy after at least %d failing looks, not %d", HealthLooksFloor, h.Looks)
}
return problems
}
@@ -0,0 +1,172 @@
package delivery
import (
"encoding/json"
"fmt"
"reflect"
"strings"
)
// The builder's keys (novox/hq ADR 0429 clauses 5 and 16, ADR 0437 clause 5, ADR 0405 clauses 2 and 7, ADR 0453
// clause 4): a build is `<module>.<fingerprint key part>` in the builder's bucket, one key per fingerprint, since equal
// fingerprints are equal content and the same source is never built twice. They are written only by the builder part
// from R4c and read by the checker, the assigner and the view, and from R4e by the controller. They are not the
// controller's build records (buildrecord.go: BuildsBucket, BuildKey), which stay as they are.
//
// **The rules its writer keeps.** A builder claims a fingerprint that has no build key by creating it queued or
// building, a create that fails when the key exists, and moves it by compare-and-set; a builder reclaims only a build
// whose `by` is offline in presence. A failed key is built again as a new revision of the same key (ADR 0429 clause
// 18). Only the builder's credential writes the bucket (clause 5); every other part reads it.
const (
// BuilderModule is the module of the builder part.
BuilderModule = "mesh-delivery-builder"
// BuilderBuildsBucket is the builder's bucket of builds: `<owner module>_<name>`.
BuilderBuildsBucket = BuilderModule + "_builds"
// BuilderBuildsHistory is the revisions the bucket keeps per key.
BuilderBuildsHistory = 5
)
// The states of a build.
const (
BuildQueued = "queued"
BuildBuilding = "building"
BuildDone = "done"
BuildFailed = "failed"
)
// BuilderBuildKey is the key, in BuilderBuildsBucket, of a module's build at a fingerprint: `<module>.<hex>`, the hex
// being the fingerprint's key part (FingerprintKeyPart), since a bus key may not hold ':'. A module holding a dot or a
// character a key refuses, and a fingerprint not of the new form, are refused, naming them.
func BuilderBuildKey(module, fingerprint string) (string, error) {
if err := checkName("module", module); err != nil {
return "", err
}
part, err := FingerprintKeyPart(fingerprint)
if err != nil {
return "", err
}
return module + "." + part, nil
}
// ParseBuilderBuildKey is the inverse of BuilderBuildKey: the module and the fingerprint (`src2:` and the hex).
func ParseBuilderBuildKey(key string) (module, fingerprint string, err error) {
module, part, ok := strings.Cut(key, ".")
if !ok {
return "", "", fmt.Errorf("delivery: %q is not a builder build key, <module>.<fingerprint hex>", key)
}
if err := checkName("module", module); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a builder build key: %w", key, err)
}
if fingerprint, err = ParseFingerprintKeyPart(part); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a builder build key: %w", key, err)
}
return module, fingerprint, nil
}
// BuiltArtifact is one artifact of a done build: its name in the manifest, its kind, its content digest and, for an
// image, the reference it was pushed to.
type BuiltArtifact struct {
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Digest string `json:"digest"`
Reference string `json:"reference,omitempty"`
}
// ToolchainUsed is what a build used for one language (ADR 0405 clauses 2 and 7): the installed toolchain's version and
// the build module's recipe version.
type ToolchainUsed struct {
Version string `json:"version"`
Recipe string `json:"recipe"`
}
// BuilderBuild is the value of a builder build key: its State (queued, building, done or failed), By the builder
// instance that holds it, Step what it is doing or did last, Manifest the digest of the module's manifest (the key of
// the indexer's manifests bucket), Artifacts when done, Toolchains by language when it built or is building (a done
// build names them, so two builders that differ are seen: ADR 0405 clause 2), and Error when failed.
type BuilderBuild struct {
V int `json:"v"`
State string `json:"state"`
By string `json:"by,omitempty"`
Step string `json:"step,omitempty"`
Manifest string `json:"manifest,omitempty"`
Artifacts []BuiltArtifact `json:"artifacts,omitempty"`
Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"`
Error string `json:"error,omitempty"`
}
func (b BuilderBuild) check() error {
switch b.State {
case BuildQueued, BuildBuilding, BuildDone, BuildFailed:
default:
return fmt.Errorf("delivery: the build state %q is not queued, building, done or failed", b.State)
}
if b.Manifest != "" {
if _, err := ManifestsKey(b.Manifest); err != nil {
return err
}
}
for language, t := range b.Toolchains {
if t.Version == "" {
return fmt.Errorf("delivery: the toolchain of %q has no version", language)
}
if t.Recipe == "" {
return fmt.Errorf("delivery: the toolchain of %q has no recipe version", language)
}
}
for i, a := range b.Artifacts {
if a.Name == "" {
return fmt.Errorf("delivery: artifact %d of the build has no name", i)
}
if a.Digest == "" {
return fmt.Errorf("delivery: the artifact %q of the build has no digest", a.Name)
}
}
switch b.State {
case BuildQueued, BuildBuilding:
if len(b.Artifacts) > 0 || b.Error != "" {
return fmt.Errorf("delivery: a %s build carries no artifact and no error", b.State)
}
case BuildDone:
if len(b.Artifacts) == 0 {
return fmt.Errorf("delivery: a done build names no artifact")
}
if b.Manifest == "" {
return fmt.Errorf("delivery: a done build names no manifest digest")
}
if b.Error != "" {
return fmt.Errorf("delivery: a done build carries an error")
}
case BuildFailed:
if b.Error == "" {
return fmt.Errorf("delivery: a failed build says no error")
}
}
return nil
}
// EncodeBuilderBuild is b as JSON at Version; it refuses a build its state does not allow (done without artifacts or a
// manifest digest, failed without an error, an artifact without a name or digest, a toolchain without a version or a
// recipe version).
func EncodeBuilderBuild(b BuilderBuild) ([]byte, error) {
if err := b.check(); err != nil {
return nil, err
}
b.V = Version
return json.Marshal(b)
}
// DecodeBuilderBuild reads a builder build value; it refuses an unknown version (ErrUnknownVersion) and what
// EncodeBuilderBuild refuses.
func DecodeBuilderBuild(data []byte) (BuilderBuild, error) {
var b BuilderBuild
if err := decode(data, &b); err != nil {
return BuilderBuild{}, err
}
if err := b.check(); err != nil {
return BuilderBuild{}, err
}
return b, nil
}
// SameBuilderBuild says whether two builds are the same value, so the builder writes only when they differ.
func SameBuilderBuild(a, b BuilderBuild) bool { return reflect.DeepEqual(a, b) }
@@ -0,0 +1,42 @@
package delivery
import "time"
// BuildRecord is the layout mesh-controller internal/chain/records.go Build writes (line 91), the fields a reader outside
// the controller reads (novox/hq ADR 0437 §4 §5, issue 1223). The controller's record carries more; a decode ignores
// what is not here. Path is the module's directory in its repository, as the controller writes it (Build.Path, records.go:100,
// set from the build request trimmed of slashes at internal/inventory/chain_requests.go:19); empty for a module whose
// manifest is the repository's root. SourceInputs has no json keys of its own: it is written under its Go field names, as the controller
// writes it.
type BuildRecord struct {
ID string `json:"id"`
State string `json:"state,omitempty"`
Failed string `json:"failed,omitempty"`
Repository string `json:"repository"`
Ref string `json:"ref,omitempty"`
Module string `json:"module,omitempty"`
Path string `json:"path,omitempty"`
Commit string `json:"commit,omitempty"`
At time.Time `json:"at,omitzero"`
SourceFingerprint string `json:"source-fingerprint,omitempty"`
SourceInputs *SourceInputs `json:"source-inputs,omitempty"`
}
// BuildsBucket is the bucket the builds are kept in: mesh-controller internal/broker/controller_buckets.go:101,
// BucketName(ControllerSeat, ChainBuilds), with ControllerSeat "mesh-controller" (internal/broker/streams.go:359) and
// ChainBuilds "builds" (controller_buckets.go:113).
const BuildsBucket = "mesh-controller_builds"
// BuildKey is a build's record under its module, a build that knew none under the request's key: copied from
// mesh-controller internal/chain/keys.go:35-40 (BuildKey, with RequestKey at line 42).
func BuildKey(module, id string) string {
if module == "" {
return "req." + id
}
return module + ".build." + id
}
// NewestKey is the key of a module's newest build, of any state: copied from mesh-controller internal/chain/keys.go:44.
// The key holds no record: its value is the newest build's id as raw bytes (internal/inventory/chain_builds.go:210 and
// :546), and the record lives at BuildKey(module, id). The pointer is a hint, checked against that record.
func NewestKey(module string) string { return module + ".newest" }
@@ -0,0 +1,295 @@
package delivery
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
)
// The checker's keys (novox/hq ADR 0447 clauses 1 to 3, ADR 0444, ADR 0446 clause 5, ADR 0453 clause 4): the verdicts of
// its checks and the passing verdicts it keeps per package and level. The checker writes them from R4c, dark; the
// forge bridge writes the status `mesh/check` from a verdict (unrequired until R4f), the gate reads it from R4f, and
// the view and the watchman read them (a running check carries a pulse).
//
// **The verdict's key is the group and the heads, not the fingerprints** (the lead's limit for R4a). Research 063
// section 01 drew `verdicts.<g>.check.<fpset>`; ADR 0447 clause 1 judges a pull request's head merged onto its
// trunk's head, and a repository without modules has no fingerprint set, so the key is `<group>.<heads hash>` as the
// heads hash of HeadsHash gives it. The fpset (FingerprintSet) is not part of it.
const (
// CheckerModule is the module of the checker part.
CheckerModule = "mesh-delivery-checker"
// CheckerVerdictsBucket is the checker's bucket of verdicts, `<owner module>_<name>`.
CheckerVerdictsBucket = CheckerModule + "_verdicts"
// CheckerVerdictsHistory is the revisions the verdicts bucket keeps per key.
CheckerVerdictsHistory = 5
// CheckerKeptBucket is the checker's bucket of kept verdicts. A key is written once, by a create that fails when
// the key exists, by the checker's credential alone, from the result the checker reads from the test run and
// never from anything the code under test writes (ADR 0444 clause 5); nothing changes it afterwards.
CheckerKeptBucket = CheckerModule + "_kept"
// CheckerKeptHistory is the revisions the kept bucket keeps per key.
CheckerKeptHistory = 1
)
// The test levels (ADR 0445 clause 2) and the states of a verdict.
const (
LevelUnit = "unit"
LevelIntegration = "integration"
VerdictRunning = "running"
VerdictPass = "pass"
VerdictFail = "fail"
)
func checkLevel(level string) error {
if level != LevelUnit && level != LevelIntegration {
return fmt.Errorf("delivery: the level %q is not %s or %s", level, LevelUnit, LevelIntegration)
}
return nil
}
func isHex12(s string) bool { return len(s) == 12 && strings.Trim(s, "0123456789abcdef") == "" }
// CheckerVerdictKey is the key, in CheckerVerdictsBucket, of a group's check at its heads: `<escaped group>.<heads
// hash>`, the group escaped as CandidatesKey does and the heads hash as HeadsHash gives it (12 lowercase hex digits).
func CheckerVerdictKey(group, headsHash string) (string, error) {
g, err := groupKey(group)
if err != nil {
return "", err
}
if !isHex12(headsHash) {
return "", fmt.Errorf("delivery: %q is not a heads hash, 12 lowercase hex digits", headsHash)
}
return g + "." + headsHash, nil
}
// ParseCheckerVerdictKey is the inverse of CheckerVerdictKey.
func ParseCheckerVerdictKey(key string) (group, headsHash string, err error) {
i := strings.LastIndex(key, ".")
if i < 0 {
return "", "", fmt.Errorf("delivery: %q is not a verdict key, <group>.<heads hash>", key)
}
part, headsHash := key[:i], key[i+1:]
if !isHex12(headsHash) {
return "", "", fmt.Errorf("delivery: %q is not a verdict key: %q is not a heads hash, 12 lowercase hex digits", key, headsHash)
}
if group, err = parseGroupKey(part); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a verdict key: %w", key, err)
}
if strings.Contains(part, ".") {
return "", "", fmt.Errorf("delivery: %q is not a verdict key: the group part holds a dot", key)
}
return group, headsHash, nil
}
// LevelResult is a level's tests: how many ran in this check and how many were taken from kept verdicts (ADR 0444
// clause 2).
type LevelResult struct {
Run int `json:"run"`
Kept int `json:"kept"`
}
// Took is the time of each part of a check in integer milliseconds (ADR 0447 clause 3): waiting in the queue, cloning,
// the unit level, the integration level's setup and its running, and the composition.
type Took struct {
Queue int64 `json:"queue"`
Clone int64 `json:"clone"`
Unit int64 `json:"unit"`
IntegrationSetup int64 `json:"integration-setup"`
IntegrationRun int64 `json:"integration-run"`
Composition int64 `json:"composition"`
}
// FailedFirst names what failed first: a test (its package and name) or a machine (and the words of the composition's
// refusal); one of them, with Words optional.
type FailedFirst struct {
Test string `json:"test,omitempty"`
Machine string `json:"machine,omitempty"`
Words string `json:"words,omitempty"`
}
// Verdict is the value of a verdict key: the group and its heads hash, the trunk head of each repository it was judged
// against (repository to full sha), the State (running, pass or fail), what failed first, the tests run and kept per
// level, each part's time, the machines composed and the mesh-sdk version the composition ran with (ADR 0446 clause 5).
type Verdict struct {
V int `json:"v"`
Group string `json:"group"`
Heads string `json:"heads"`
Trunk map[string]string `json:"trunk,omitempty"`
State string `json:"state"`
Failed FailedFirst `json:"failed,omitzero"`
Levels map[string]LevelResult `json:"levels,omitempty"`
Took Took `json:"took,omitzero"`
Machines []string `json:"machines,omitempty"`
SDK string `json:"sdk,omitempty"`
}
func (v Verdict) check() error {
if v.Group == "" {
return fmt.Errorf("delivery: a verdict names no group")
}
if !isHex12(v.Heads) {
return fmt.Errorf("delivery: the verdict's heads hash %q is not 12 lowercase hex digits", v.Heads)
}
for repository, head := range v.Trunk {
if head == "" {
return fmt.Errorf("delivery: the verdict's trunk head of %s is empty", repository)
}
}
for level, r := range v.Levels {
if err := checkLevel(level); err != nil {
return err
}
if r.Run < 0 || r.Kept < 0 {
return fmt.Errorf("delivery: the verdict's %s level counts a negative number of tests", level)
}
}
for part, ms := range map[string]int64{"queue": v.Took.Queue, "clone": v.Took.Clone, "unit": v.Took.Unit,
"integration-setup": v.Took.IntegrationSetup, "integration-run": v.Took.IntegrationRun, "composition": v.Took.Composition} {
if ms < 0 {
return fmt.Errorf("delivery: the verdict's time of the part %s is negative", part)
}
}
named := v.Failed.Test != "" || v.Failed.Machine != ""
switch v.State {
case VerdictRunning:
if v.Failed != (FailedFirst{}) {
return fmt.Errorf("delivery: a running verdict names no failure")
}
case VerdictPass:
if v.Failed != (FailedFirst{}) {
return fmt.Errorf("delivery: a pass names no failure")
}
for _, level := range []string{LevelUnit, LevelIntegration} {
if _, ok := v.Levels[level]; !ok {
return fmt.Errorf("delivery: a pass says nothing of the %s level", level)
}
}
case VerdictFail:
if !named {
return fmt.Errorf("delivery: a fail names neither a test nor a machine")
}
default:
return fmt.Errorf("delivery: the verdict state %q is not running, pass or fail", v.State)
}
return nil
}
// EncodeVerdict is v as JSON at Version; it refuses a fail that names neither a test nor a machine, a pass without both
// levels, an unknown state or level, and a negative count or time.
func EncodeVerdict(v Verdict) ([]byte, error) {
if err := v.check(); err != nil {
return nil, err
}
v.V = Version
return json.Marshal(v)
}
// DecodeVerdict reads a verdict; it refuses an unknown version (ErrUnknownVersion) and what EncodeVerdict refuses.
func DecodeVerdict(data []byte) (Verdict, error) {
var v Verdict
if err := decode(data, &v); err != nil {
return Verdict{}, err
}
if err := v.check(); err != nil {
return Verdict{}, err
}
return v, nil
}
// SameVerdict says whether two verdicts are the same value, so the checker writes only when they differ.
func SameVerdict(a, b Verdict) bool { return reflect.DeepEqual(a, b) }
// KeptKey is the key, in CheckerKeptBucket, of a kept verdict: `<level>.<digest>`, the level unit or integration (ADR
// 0445 clause 2) and the digest, lowercase hex, of everything that package's tests read at that level as the
// language's build module names it (ADR 0444 clause 1, ADR 0445 clause 5).
func KeptKey(level, digest string) (string, error) {
if err := checkLevel(level); err != nil {
return "", err
}
if digest == "" || strings.Trim(digest, "0123456789abcdef") != "" {
return "", fmt.Errorf("delivery: %q is not a digest, lowercase hex", digest)
}
return level + "." + digest, nil
}
// ParseKeptKey is the inverse of KeptKey.
func ParseKeptKey(key string) (level, digest string, err error) {
level, digest, ok := strings.Cut(key, ".")
if !ok {
return "", "", fmt.Errorf("delivery: %q is not a kept key, <level>.<digest>", key)
}
if _, err = KeptKey(level, digest); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a kept key: %w", key, err)
}
return level, digest, nil
}
// Kept is the value of a kept key: a passing verdict of one package at one level (State is pass and nothing else: a
// failure is never kept, ADR 0444 clause 1), with the repository, the package, the number of tests, the time they took
// in milliseconds, the toolchain's version and when it was kept.
type Kept struct {
V int `json:"v"`
State string `json:"state"`
Repository string `json:"repository"`
Package string `json:"package"`
Level string `json:"level"`
Tests int `json:"tests"`
Took int64 `json:"took"`
Toolchain string `json:"toolchain"`
At time.Time `json:"at"`
}
func (k Kept) check() error {
if k.State != VerdictPass {
return fmt.Errorf("delivery: a kept verdict is a pass, never %q: a failure is never kept", k.State)
}
if err := checkRepository(k.Repository); err != nil {
return err
}
if k.Package == "" {
return fmt.Errorf("delivery: a kept verdict names no package")
}
if err := checkLevel(k.Level); err != nil {
return err
}
if k.Tests < 0 || k.Took < 0 {
return fmt.Errorf("delivery: a kept verdict counts a negative number of tests or a negative time")
}
if k.Toolchain == "" {
return fmt.Errorf("delivery: a kept verdict names no toolchain version")
}
if k.At.IsZero() {
return fmt.Errorf("delivery: a kept verdict says no time")
}
return nil
}
// EncodeKept is k as JSON at Version; it refuses a kept value that is not a pass.
func EncodeKept(k Kept) ([]byte, error) {
if err := k.check(); err != nil {
return nil, err
}
k.V = Version
return json.Marshal(k)
}
// DecodeKept reads a kept value; it refuses an unknown version (ErrUnknownVersion) and what EncodeKept refuses.
func DecodeKept(data []byte) (Kept, error) {
var k Kept
if err := decode(data, &k); err != nil {
return Kept{}, err
}
if err := k.check(); err != nil {
return Kept{}, err
}
return k, nil
}
// SameKept says whether two kept values are the same value.
func SameKept(a, b Kept) bool {
at := a.At.Equal(b.At)
a.At, b.At = time.Time{}, time.Time{}
return at && a == b
}
+55 -2
View File
@@ -30,11 +30,17 @@
// **The source fingerprint** (issue 1203; ADR 0437 §5): one function of named inputs that the controller's builder and
// the indexer both call, so they agree byte for byte (fingerprint.go).
//
// **The release 4 layouts** (issues 1356 to 1359; ADR 0453 clauses 4, 6 and 16): the new fingerprint form (fingerprint2.go),
// the indexer's manifests by digest and the new fields of its modules and candidates (manifests.go, source.go), the
// builder's build keys (builder.go) and the checker's verdicts and kept verdicts (checker.go). A manifests value is the
// one exception to the size above: a manifest is up to about 21 KB, whole, byte for byte.
//
// Standard library only, and this module's own asks package.
package delivery
import (
"bytes"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -79,6 +85,10 @@ const (
PhaseChecking Phase = "checking"
PhaseRunning Phase = "running"
PhaseFailed Phase = "failed"
// PhaseRemoved is written by the node-engine, once, for a module its applied declaration no longer holds and
// for which it had written a have key; the key is never deleted (issue 1369, ADR 0408 §4), so a reader can tell
// that the module stopped.
PhaseRemoved Phase = "removed"
)
// Health is what the module's own check says.
@@ -105,6 +115,49 @@ func checkName(kind, name string) error {
return nil
}
// EscapeName is name as a key part: a character outside letters, digits, `-`, `/` and `_` is written `=` and its
// two lowercase hex digits per byte, and `=` itself is escaped, so any non-empty name keys and a name of allowed
// characters only keys as it is. UnescapeName is its inverse.
func EscapeName(name string) string {
var b strings.Builder
for i := 0; i < len(name); i++ {
c := name[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '/', c == '_':
b.WriteByte(c)
default:
fmt.Fprintf(&b, "=%02x", c)
}
}
return b.String()
}
// UnescapeName is the inverse of EscapeName; it refuses a part with a bare `=` or any character EscapeName leaves
// escaped.
func UnescapeName(part string) (string, error) {
var out []byte
for i := 0; i < len(part); i++ {
c := part[i]
if c != '=' {
if !nameOK.MatchString(string(c)) {
return "", fmt.Errorf("delivery: %q holds %q, which a key part escapes", part, c)
}
out = append(out, c)
continue
}
if i+3 > len(part) {
return "", fmt.Errorf("delivery: %q ends in an incomplete escape", part)
}
v, err := hex.DecodeString(part[i+1 : i+3])
if err != nil || part[i+1:i+3] != strings.ToLower(part[i+1:i+3]) {
return "", fmt.Errorf("delivery: %q holds a malformed escape %q", part, part[i:i+3])
}
out = append(out, v[0])
i += 2
}
return string(out), nil
}
// HaveKey is the key, in HaveBucket, of a module's have on a node: `<node>.<module>`. A module named `node` is
// refused, since `<node>.node` is the node's own facts.
func HaveKey(node, module string) (string, error) {
@@ -294,9 +347,9 @@ func decode(data []byte, into any) error {
func (h Have) check() error {
switch h.Phase {
case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed:
case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed, PhaseRemoved:
default:
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running or failed", h.Phase)
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed or removed", h.Phase)
}
switch h.Health {
case HealthOK, HealthFailed, HealthWaits, HealthUnknown:
@@ -0,0 +1,105 @@
package delivery
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
)
// SourcePrefix2 opens every source fingerprint of the new form.
const SourcePrefix2 = "src2:"
// Toolchain2 is what an artifact's toolchain enters the new fingerprint as (novox/hq ADR 0405 clause 7): the
// artifact's language, the installed toolchain's version and the build module's recipe version, in place of the
// toolchain image's digest. A toolchain update so changes the fingerprints of that language's modules only.
type Toolchain2 struct {
Language string
Version string
Recipe string
}
// SourceInputs2 are what a module's source fingerprint of the new form is of (novox/hq ADR 0429 clause 5 as ADR 0437
// clause 5 changed it; ADR 0453 clause 6). It differs from SourceInputs in exactly four things: a base enters by its
// module's fingerprint (src2:...), not its image digest; the builder enters by its own module's fingerprint, not its
// executable's digest; an artifact that resolves packages when it is built enters by the hash of its lock file; and
// an artifact's toolchain enters as Toolchain2. Module, Tree, Contexts and Unpinned are as in SourceInputs.
//
// Locks names the artifacts that resolve packages when built, each with the hash of its lock file; an entry with an
// empty hash is such an artifact with no lock, which has no fingerprint. An artifact that resolves nothing is not in
// Locks.
type SourceInputs2 struct {
Module string
Tree string
Bases []string
Builder string
Contexts map[string]string
Locks map[string]string
Toolchains map[string]Toolchain2
Unpinned string
}
// SourceFingerprint2 is the module's source fingerprint of the new form, `src2:` and the hex sha256 of its inputs'
// lines, or none and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is
// empty, "no lock" when an artifact resolves packages and has no lock. It is a pure function of its inputs: the order
// of maps and a base given twice change nothing. The lines are "module <m>", "tree <t>" and "builder <fp>", then
// "base <fp>" (once each), "context <artifact> <tree>", "lock <artifact> <hash>" and "toolchain <artifact> <language>
// <version> <recipe>", sorted, all joined by a newline.
func SourceFingerprint2(in SourceInputs2) (fp, none string) {
if in.Unpinned != "" {
return "", in.Unpinned
}
if in.Tree == "" {
return "", "no tree"
}
for _, h := range in.Locks {
if h == "" {
return "", "no lock"
}
}
lines := []string{"module " + in.Module, "tree " + in.Tree, "builder " + in.Builder}
bases := map[string]bool{}
for _, b := range in.Bases {
bases[b] = true
}
for b := range bases {
lines = append(lines, "base "+b)
}
for a, t := range in.Contexts {
lines = append(lines, "context "+a+" "+t)
}
for a, h := range in.Locks {
lines = append(lines, "lock "+a+" "+h)
}
for a, t := range in.Toolchains {
lines = append(lines, "toolchain "+a+" "+t.Language+" "+t.Version+" "+t.Recipe)
}
sort.Strings(lines[3:])
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return SourcePrefix2 + hex.EncodeToString(sum[:]), ""
}
// FingerprintKeyPart is a new-form fingerprint as a key part: its 64 hex digits without the prefix, since a bus key
// may not hold ':'. It refuses a value that is not `src2:` and 64 lowercase hex digits, a src1 fingerprint included,
// naming it.
func FingerprintKeyPart(fp string) (string, error) {
part, ok := strings.CutPrefix(fp, SourcePrefix2)
if !ok || !isHex64(part) {
return "", fmt.Errorf("delivery: %q is not a fingerprint of the new form, %s and 64 lowercase hex digits", fp, SourcePrefix2)
}
return part, nil
}
// ParseFingerprintKeyPart is the inverse of FingerprintKeyPart. It refuses a part that is not 64 lowercase hex
// digits, a src1 fingerprint or a prefixed value included, naming it.
func ParseFingerprintKeyPart(part string) (string, error) {
if !isHex64(part) {
return "", fmt.Errorf("delivery: %q is not the key part of a fingerprint, 64 lowercase hex digits", part)
}
return SourcePrefix2 + part, nil
}
func isHex64(s string) bool {
return len(s) == 64 && strings.Trim(s, "0123456789abcdef") == ""
}
+96 -30
View File
@@ -56,23 +56,21 @@ var hashOK = regexp.MustCompile(`^[0-9a-f]{12}$`)
// MergesKey is the key, in MergesBucket, of a group: `<group>`.
func MergesKey(group string) (string, error) {
if err := checkName("group", group); err != nil {
return "", err
}
return group, nil
return groupKey(group)
}
// MergeAskKey is the key, in AsksBucket, of the ask for merging a group at these heads: `merge.<group>.<heads
// hash>`. The teller's answer has the same key in AnswersBucket (AnswerKey).
func MergeAskKey(group string, heads []Head) (string, error) {
if err := checkName("group", group); err != nil {
part, err := groupKey(group)
if err != nil {
return "", err
}
hash, err := HeadsHash(heads)
if err != nil {
return "", err
}
return AskKindMerge + "." + group + "." + hash, nil
return AskKindMerge + "." + part + "." + hash, nil
}
// ParseAskKey is the inverse of MergeAskKey: the kind, the group and the heads hash.
@@ -81,13 +79,14 @@ func ParseAskKey(key string) (kind, group, hash string, err error) {
if len(parts) != 3 || parts[0] != AskKindMerge {
return "", "", "", fmt.Errorf("delivery: %q is not an ask key, merge.<group>.<heads hash>", key)
}
if err := checkName("group", parts[1]); err != nil {
group, err = parseGroupKey(parts[1])
if err != nil {
return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %w", key, err)
}
if !hashOK.MatchString(parts[2]) {
return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %q is not 12 hex digits", key, parts[2])
}
return parts[0], parts[1], parts[2], nil
return parts[0], group, parts[2], nil
}
// AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string.
@@ -120,6 +119,18 @@ func AskID(askKey string) string {
return string(readable) + "-" + hex.EncodeToString(sum[:])[:12]
}
// AskingID is the id of the asking-th writing of the ask at askKey. Asking 0 and 1 give AskID(askKey), so the first
// asking's id is unchanged; asking n of 2 or more gives the AskID form of askKey + "." + n, so each asking has an id
// of its own (at most 64 bytes, ending in the 12 hex digits of the hash of that whole string) and the router, which
// drops an ask under an id it keeps as a redelivery, takes each as new and an answer to an earlier asking is never
// taken for a later one.
func AskingID(askKey string, asking int) string {
if asking < 2 {
return AskID(askKey)
}
return AskID(askKey + "." + strconv.Itoa(asking))
}
func checkOwedAct(act string) (kind, context string, err error) {
kind, context, hasContext := strings.Cut(act, "=")
switch kind {
@@ -131,27 +142,44 @@ func checkOwedAct(act string) (kind, context string, err error) {
return "", "", err
}
return kind, context, nil
case OwedView, OwedNote, OwedComment:
case OwedView, OwedNote:
if hasContext {
return "", "", fmt.Errorf("delivery: the owed act %q takes no context", act)
}
return kind, "", nil
}
return "", "", fmt.Errorf("delivery: the owed act %q is not status=<context>, view, note or comment", act)
if kind, seq, ok := strings.Cut(act, "."); ok && kind == OwedComment {
if n, err := strconv.Atoi(seq); err != nil || n < 1 || strconv.Itoa(n) != seq {
return "", "", fmt.Errorf("delivery: the owed act %q is a comment without its sequence, comment.<seq> with seq of 1 or more", act)
}
return OwedComment, seq, nil
}
return "", "", fmt.Errorf("delivery: the owed act %q is not status=<context>, view, note or comment.<seq>", act)
}
// ParseOwedAct splits an owed act into its kind and, for a status, its context.
// OwedCommentAct is the act of the seq-th comment on a head: `comment.<seq>`, seq of 1 or more, so a second comment
// has a key of its own and does not overwrite the first.
func OwedCommentAct(seq int) (string, error) {
if seq < 1 {
return "", fmt.Errorf("delivery: a comment's sequence is 1 or more, not %d", seq)
}
return OwedComment + "." + strconv.Itoa(seq), nil
}
// ParseOwedAct splits an owed act into its kind and, for a status, its context, for a comment its sequence as
// digits.
func ParseOwedAct(act string) (kind, context string, err error) { return checkOwedAct(act) }
// OwedKey is the key, in OwedBucket, of an act the gate owes the forge on a pull request at a head:
// `<repository>.<number>.<head>.<act>`, the act being `status=<context>` (mesh/merge-gate), `view`, `note` or
// `comment`.
// `comment.<seq>`. The number is 0 for a commit no pull request names, and never negative.
func OwedKey(repository string, number int, head, act string) (string, error) {
if err := checkRepository(repository); err != nil {
repo, err := escapeRepository(repository)
if err != nil {
return "", err
}
if number <= 0 {
return "", fmt.Errorf("delivery: an owed key needs the pull request number, not %d", number)
if number < 0 {
return "", fmt.Errorf("delivery: an owed key's pull request number is 0 (none) or more, not %d", number)
}
if err := checkName("head", head); err != nil {
return "", err
@@ -159,12 +187,12 @@ func OwedKey(repository string, number int, head, act string) (string, error) {
if _, _, err := checkOwedAct(act); err != nil {
return "", err
}
return repository + "." + strconv.Itoa(number) + "." + head + "." + act, nil
return repo + "." + strconv.Itoa(number) + "." + head + "." + act, nil
}
// ParseOwedKey is the inverse of OwedKey; act is whole, as `status=<context>` for a status.
func ParseOwedKey(key string) (repository string, number int, head, act string, err error) {
parts := strings.Split(key, ".")
parts := strings.SplitN(key, ".", 4) // a comment's act holds a dot
if len(parts) != 4 {
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key, <repository>.<number>.<head>.<act>", key)
}
@@ -172,19 +200,31 @@ func ParseOwedKey(key string) (repository string, number int, head, act string,
if convErr != nil {
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %q is not a pull request number", key, parts[1])
}
if _, err := OwedKey(parts[0], number, parts[2], parts[3]); err != nil {
repository, err = unescapeRepository(parts[0])
if err != nil {
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err)
}
return parts[0], number, parts[2], parts[3], nil
if _, err := OwedKey(repository, number, parts[2], parts[3]); err != nil {
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err)
}
return repository, number, parts[2], parts[3], nil
}
// Merge is the value of a merges key: the ask approved and, for each pull request, the head the approval expects
// and how far its merge has come.
// and how far its merge has come. Approved is the Approve's heads of every member of the group, ApprovedKey of
// the member to its full head sha: the forge's merge tool wants them for every sibling whichever member it is
// handed (ADR 0450 §3). A merge written before Approved existed has none and is not checked against it.
type Merge struct {
V int `json:"v"`
Group string `json:"group"`
Ask string `json:"ask"`
Pulls []MergePull `json:"pulls"`
V int `json:"v"`
Group string `json:"group"`
Ask string `json:"ask"`
Approved map[string]string `json:"approved,omitempty"`
Pulls []MergePull `json:"pulls"`
}
// ApprovedKey is the key of a member in Merge.Approved: `<repository>@<number>`.
func ApprovedKey(repository string, number int) string {
return repository + "@" + strconv.Itoa(number)
}
// MergePull is one pull request of a merge: its expected head, its state, the merge commit once merged, the
@@ -197,8 +237,8 @@ type MergePull struct {
}
func (m Merge) check() error {
if err := checkName("group", m.Group); err != nil {
return err
if m.Group == "" {
return fmt.Errorf("delivery: a merge names no group")
}
if m.Ask == "" {
return fmt.Errorf("delivery: a merge names no ask")
@@ -206,8 +246,22 @@ func (m Merge) check() error {
if len(m.Pulls) == 0 {
return fmt.Errorf("delivery: a merge names no pull request")
}
for key, sha := range m.Approved {
if sha == "" {
return fmt.Errorf("delivery: the approved head of %s is empty", key)
}
}
for _, p := range m.Pulls {
who := fmt.Sprintf("%s#%d", p.Repository, p.Number)
if len(m.Approved) > 0 {
sha, ok := m.Approved[ApprovedKey(p.Repository, p.Number)]
if !ok {
return fmt.Errorf("delivery: the pull request %s is handed to the merge but is not among the approved heads", who)
}
if sha != p.Head.Head {
return fmt.Errorf("delivery: the pull request %s is handed at head %q but the Approve was for %q", who, p.Head.Head, sha)
}
}
switch p.State {
case MergeMerging:
case MergeMerged:
@@ -232,15 +286,22 @@ type Ask struct {
Group string `json:"group"`
Heads []Head `json:"heads"`
Ask asks.Ask `json:"ask"`
// Asking counts the times the gate wrote this group's ask at these heads: 0 or 1 for the first, n for the n-th.
// The router drops an ask whose id it has already kept as a redelivery, so each asking has its own id
// (AskingID); the first asking's id is unchanged and the field is omitted for it.
Asking int `json:"asking,omitempty"`
}
func (a Ask) check() error {
if a.Asking < 0 {
return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking)
}
key, err := MergeAskKey(a.Group, a.Heads)
if err != nil {
return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err)
}
if want := AskID(key); a.Ask.ID != want {
return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group and heads give", a.Ask.ID, want)
if want := AskingID(key, a.Asking); a.Ask.ID != want {
return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group, heads and asking %d give", a.Ask.ID, want, a.Asking)
}
if len(a.Ask.Options) == 0 {
return fmt.Errorf("delivery: the ask has no option")
@@ -265,12 +326,14 @@ func (a Answer) check() error {
return nil
}
// Owed is the value of an owed key: what the forge bridge performs. A status needs Context and State, a view and a
// comment need Text, a note needs Text and Ref (the notes ref, mesh-plan).
// Owed is the value of an owed key: what the forge bridge performs. A status needs Context, State and Target (the
// address the status points at), a view and a comment need Text, a note needs Text and Ref (the notes ref,
// mesh-plan).
type Owed struct {
V int `json:"v"`
Act string `json:"act"`
Context string `json:"context,omitempty"`
Target string `json:"target,omitempty"`
State string `json:"state,omitempty"`
Text string `json:"text,omitempty"`
Ref string `json:"ref,omitempty"`
@@ -282,6 +345,9 @@ func (o Owed) check() error {
if o.Context == "" {
return fmt.Errorf("delivery: an owed status has no context")
}
if o.Target == "" {
return fmt.Errorf("delivery: an owed status has no target")
}
for _, s := range statusStates {
if o.State == s {
return nil
@@ -0,0 +1,85 @@
package delivery
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"unicode/utf8"
)
// ManifestDigest is the hex sha256 of a manifest's bytes exactly as read at the commit: the key of a manifests value
// and the digest a Module and a candidate's module carry (novox/hq ADR 0429 clause 16).
func ManifestDigest(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
// ManifestsKey is the key, in ManifestsBucket, of a manifest: its digest, 64 lowercase hex digits. The indexer
// writes it once, by a create that fails when the key exists.
func ManifestsKey(digest string) (string, error) {
if !isHex64(digest) {
return "", fmt.Errorf("delivery: %q is not a manifest digest, 64 lowercase hex digits", digest)
}
return digest, nil
}
// ParseManifestsKey is the inverse of ManifestsKey.
func ParseManifestsKey(key string) (digest string, err error) {
if digest, err = ManifestsKey(key); err != nil {
return "", fmt.Errorf("delivery: %q is not a manifests key: %w", key, err)
}
return digest, nil
}
// Manifest is the value of a manifests key: the module and its manifest, the raw text of its module.json as read at the
// commit, byte for byte (ManifestDigest of JSON is the key). It is the one value of delivery that is larger than about
// 500 bytes: the largest manifest of the catalogue is about 21 KB. It is a string, not nested JSON, because nesting
// would re-write the manifest's spacing and so change its digest.
type Manifest struct {
V int `json:"v"`
Module string `json:"module"`
JSON string `json:"manifest"`
}
func (m Manifest) check() error {
if err := checkName("module", m.Module); err != nil {
return err
}
if m.JSON == "" {
return fmt.Errorf("delivery: the manifest of %s is empty", m.Module)
}
if !utf8.ValidString(m.JSON) {
return fmt.Errorf("delivery: the manifest of %s is not UTF-8 text, which a value cannot hold byte for byte", m.Module)
}
if !json.Valid([]byte(m.JSON)) {
return fmt.Errorf("delivery: the manifest of %s is not JSON", m.Module)
}
return nil
}
// EncodeManifest is m as JSON at Version; it refuses a module that is not a name, and a manifest that is empty, not
// UTF-8 or not JSON.
func EncodeManifest(m Manifest) ([]byte, error) {
if err := m.check(); err != nil {
return nil, err
}
m.V = Version
return json.Marshal(m)
}
// DecodeManifest reads a manifests value; it refuses an unknown version (ErrUnknownVersion) and what EncodeManifest
// refuses.
func DecodeManifest(data []byte) (Manifest, error) {
var m Manifest
if err := decode(data, &m); err != nil {
return Manifest{}, err
}
if err := m.check(); err != nil {
return Manifest{}, err
}
return m, nil
}
// SameManifest says whether two manifests are the same value, so a writer writes only when they differ.
func SameManifest(a, b Manifest) bool { return a == b }
+219 -17
View File
@@ -31,6 +31,10 @@ const (
ModulesBucket = IndexerModule + "_modules"
// ModulesHistory is the revisions the modules bucket keeps per key.
ModulesHistory = 20
// ManifestsBucket is the indexer's bucket of manifests by digest (novox/hq ADR 0429 clause 16); see manifests.go.
ManifestsBucket = IndexerModule + "_manifests"
// ManifestsHistory is the revisions the manifests bucket keeps per key: a key is written once.
ManifestsHistory = 1
)
// ForgeStream is the forge bridge's stream as its manifest declares it; the broker derives the real names.
@@ -41,8 +45,19 @@ const (
ForgePull = "pull"
ForgeMerged = "merged"
ForgeRefused = "refused"
ForgeOpen = "open"
)
// OpenPull names an open pull request into the trunk: the repository as `<owner>/<name>` and the number.
type OpenPull struct {
Repository string `json:"repository"`
Number int `json:"number"`
}
// OpenEvent is the name of an open message: `forge.open`. It names no repository: it says every open pull request of
// every repository the forge bridge reads (novox/hq ADR 0443 §5).
func OpenEvent() string { return "forge." + ForgeOpen }
// The actions of a pull message.
const (
PullOpened = "opened"
@@ -56,18 +71,62 @@ const SourcePrefix = "src1:"
// CandidatesKey is the key, in CandidatesBucket, of a group: `<group>`, the branch name the group's pull requests
// share (a slash is allowed); a lone pull request is a group of one under its branch name.
func CandidatesKey(group string) (string, error) {
if err := checkName("group", group); err != nil {
return groupKey(group)
}
// ParseCandidatesKey is the inverse of CandidatesKey.
func ParseCandidatesKey(key string) (group string, err error) {
if group, err = parseGroupKey(key); err != nil {
return "", fmt.Errorf("delivery: %q is not a candidates key: %w", key, err)
}
return group, nil
}
// groupKey is a group name as a key part: escaped (EscapeName), so a branch such as `fix/1.2` keys.
func groupKey(group string) (string, error) {
if group == "" {
return "", fmt.Errorf("delivery: the group name is empty")
}
return EscapeName(group), nil
}
// parseGroupKey is the inverse of groupKey.
func parseGroupKey(part string) (string, error) {
if part == "" {
return "", fmt.Errorf("delivery: the group name is empty")
}
group, err := UnescapeName(part)
if err != nil {
return "", err
}
return group, nil
}
// ParseCandidatesKey is the inverse of CandidatesKey.
func ParseCandidatesKey(key string) (group string, err error) {
if group, err = CandidatesKey(key); err != nil {
return "", fmt.Errorf("delivery: %q is not a candidates key: %w", key, err)
// escapeRepository is `<owner>/<name>` as a key part, each part escaped; the repository must have exactly one slash
// and neither part empty.
func escapeRepository(repository string) (string, error) {
owner, name, ok := strings.Cut(repository, "/")
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
return "", fmt.Errorf("delivery: the repository %q is not <owner>/<name>, with exactly one slash", repository)
}
return group, nil
return EscapeName(owner) + "/" + EscapeName(name), nil
}
// unescapeRepository is the inverse of escapeRepository.
func unescapeRepository(part string) (string, error) {
owner, name, ok := strings.Cut(part, "/")
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
return "", fmt.Errorf("delivery: the repository %q is not <owner>/<name>, with exactly one slash", part)
}
o, err := UnescapeName(owner)
if err != nil {
return "", err
}
n, err := UnescapeName(name)
if err != nil {
return "", err
}
return o + "/" + n, nil
}
// ModulesKey is the key, in ModulesBucket, of a module: `<module>`.
@@ -106,18 +165,21 @@ func checkRepository(repository string) error {
func checkForgeKind(kind string) error {
switch kind {
case ForgePull, ForgeMerged, ForgeRefused:
case ForgePull, ForgeMerged, ForgeRefused, ForgeOpen:
return nil
}
return fmt.Errorf("delivery: the forge kind %q is not pull, merged or refused", kind)
return fmt.Errorf("delivery: the forge kind %q is not pull, merged, refused or open", kind)
}
// ForgeEvent is the name of a forge message of the given kind: `forge.<owner>.<name>.<kind>`.
func ForgeEvent(repository, kind string) (string, error) {
if err := checkRepository(repository); err != nil {
if err := checkForgeKind(kind); err != nil {
return "", err
}
if err := checkForgeKind(kind); err != nil {
if kind == ForgeOpen {
return "", fmt.Errorf("delivery: an open message names no repository, its event is %s", OpenEvent())
}
if err := checkRepository(repository); err != nil {
return "", err
}
owner, name, _ := strings.Cut(repository, "/")
@@ -126,6 +188,9 @@ func ForgeEvent(repository, kind string) (string, error) {
// ParseForgeEvent is the inverse of ForgeEvent.
func ParseForgeEvent(event string) (repository, kind string, err error) {
if event == OpenEvent() {
return "", ForgeOpen, nil
}
parts := strings.Split(event, ".")
if len(parts) != 4 || parts[0] != "forge" {
return "", "", fmt.Errorf("delivery: %q is not a forge event, forge.<owner>.<name>.<kind>", event)
@@ -184,19 +249,86 @@ func (s Source) check() error {
// Candidate is the value of a candidates key: the group's pull requests and heads, the heads hash, the base (the
// source revision it was previewed onto) and the modules each pull request touches. It carries no fingerprint
// until release 4 (ADR 0437 §5).
// until release 4 (ADR 0437 §5); from R4c the indexer writes the new form's fingerprint of each touched module in
// the pull request's Fps and, over all of them, FPSet (novox/hq ADR 0453 clauses 6 and 16). Both are absent in a
// value written before, and a reader of today's shape ignores them.
type Candidate struct {
V int `json:"v"`
Group string `json:"group"`
Base uint64 `json:"base"`
HeadsHash string `json:"heads"`
Pulls []CandidatePull `json:"pulls"`
FPSet string `json:"fpset,omitempty"`
}
// CandidatePull is one pull request of a candidate and the modules it touches.
// CandidatePull is one pull request of a candidate and the modules it touches. Fps is, by module name, the new
// form's fingerprint of the module at the merge preview, or why it has none, and the digest of its manifest (the key
// of the indexer's manifests bucket); a module of Modules without an entry has none written yet.
type CandidatePull struct {
Head
Modules []string `json:"modules"`
Modules []string `json:"modules"`
Fps map[string]CandidateModule `json:"fps,omitempty"`
}
// CandidateModule is one touched module's new-form fingerprint FP2 (`src2:` and 64 hex digits), or None2, the reason
// it has none; exactly one of them. Manifest is the digest of the module's manifest as read at the preview.
type CandidateModule struct {
FP2 string `json:"fp2,omitempty"`
None2 string `json:"none2,omitempty"`
Manifest string `json:"manifest,omitempty"`
}
func (m CandidateModule) check() error {
switch {
case m.FP2 != "" && m.None2 != "":
return fmt.Errorf("delivery: a module has both fp2 and none2")
case m.FP2 == "" && m.None2 == "":
return fmt.Errorf("delivery: a module has neither fp2 nor none2")
}
return checkNewForm(m.FP2, m.Manifest)
}
// checkNewForm refuses an fp2 that is not of the new form and a manifest digest that is not a digest, naming it.
func checkNewForm(fp2, manifest string) error {
if fp2 != "" {
if _, err := FingerprintKeyPart(fp2); err != nil {
return err
}
}
if manifest != "" {
if _, err := ManifestsKey(manifest); err != nil {
return err
}
}
return nil
}
// FingerprintSet is the fpset of a candidate's pull requests: the first 12 hex digits of the sha256 over the lines
// `<module>=<fp2>` or `<module>=none:<reason>` of every module with an entry in Fps, sorted and joined by a newline,
// so it is the same for every order and changes with any fingerprint or reason. A manifest digest does not enter: the
// fingerprint already covers the manifest. It is the empty string when no pull request carries a fingerprint. The
// checker keys its verdict by it (research 063 section 01).
func FingerprintSet(pulls []CandidatePull) string {
lines := map[string]bool{}
for _, p := range pulls {
for module, m := range p.Fps {
if m.FP2 != "" {
lines[module+"="+m.FP2] = true
} else {
lines[module+"=none:"+m.None2] = true
}
}
}
if len(lines) == 0 {
return ""
}
all := make([]string, 0, len(lines))
for l := range lines {
all = append(all, l)
}
sort.Strings(all)
sum := sha256.Sum256([]byte(strings.Join(all, "\n")))
return hex.EncodeToString(sum[:])[:12]
}
func (c Candidate) check() error {
@@ -211,16 +343,46 @@ func (c Candidate) check() error {
if c.HeadsHash != want {
return fmt.Errorf("delivery: the candidate's heads hash %q is not the hash %q of its pull requests' heads", c.HeadsHash, want)
}
seen := map[string]CandidateModule{}
for _, p := range c.Pulls {
touched := map[string]bool{}
for _, m := range p.Modules {
touched[m] = true
}
for module, m := range p.Fps {
if !touched[module] {
return fmt.Errorf("delivery: the candidate gives a fingerprint of the module %q, which pull request %s#%d does not touch", module, p.Repository, p.Number)
}
if err := m.check(); err != nil {
return fmt.Errorf("delivery: the candidate's module %q: %w", module, err)
}
if before, ok := seen[module]; ok && before != m {
return fmt.Errorf("delivery: two pull requests of the candidate give the module %q different fingerprints", module)
}
seen[module] = m
}
}
if wantSet := FingerprintSet(c.Pulls); c.FPSet != wantSet {
return fmt.Errorf("delivery: the candidate's fpset %q is not the hash %q of its fingerprints", c.FPSet, wantSet)
}
return nil
}
// Module is the value of a modules key: the module's source fingerprint, or none and why (a build that resolves
// packages when built gets none, said here), and where it was computed: Repository and Commit are the trunk merge,
// Src the source revision then (0 before the gate writes source.main). Exactly one of FP and None is set.
//
// From R4c the indexer writes the new form beside it (novox/hq ADR 0453 clause 6): FP2, the fingerprint of the new
// form (`src2:` and 64 hex digits), or None2, why it has none; one of them whenever either is, never both; and
// Manifest, the digest of the module's manifest, the key of the manifests bucket. A value written before has none of
// the three, and a reader of today's shape ignores them.
type Module struct {
V int `json:"v"`
FP string `json:"fp,omitempty"`
None string `json:"none,omitempty"`
FP2 string `json:"fp2,omitempty"`
None2 string `json:"none2,omitempty"`
Manifest string `json:"manifest,omitempty"`
Src uint64 `json:"src,omitempty"`
Repository string `json:"repository"`
Commit string `json:"commit"`
@@ -234,14 +396,18 @@ func (m Module) check() error {
return fmt.Errorf("delivery: a module has neither a fingerprint nor a reason for none")
case m.FP != "" && !strings.HasPrefix(m.FP, SourcePrefix):
return fmt.Errorf("delivery: the module's fingerprint %q does not start with %s", m.FP, SourcePrefix)
case m.FP2 != "" && m.None2 != "":
return fmt.Errorf("delivery: a module has both fp2 and none2")
}
return nil
return checkNewForm(m.FP2, m.Manifest)
}
// Forge is a message of the forge bridge's stream, trimmed to repository, branch, pull request and head. A pull
// message says Action; a merged message carries the merge commit, the tree of every directory holding a
// module.json and the repository's root tree; a refused message names the owed act it answers and the forge's own
// words.
// words. A pull message may also carry the module directories its head changes (Dirs, in the order the forge said) and
// DirsKnown, true when the forge could say them: known and empty is DirsKnown with no Dirs, not known is neither
// (the forge's module_dirs and module_dirs_said, mesh-catalog modules/gitea/trunk.ts). Other messages carry neither.
type Forge struct {
V int `json:"v"`
Kind string `json:"kind"`
@@ -250,18 +416,27 @@ type Forge struct {
Number int `json:"number,omitempty"`
Head string `json:"head,omitempty"`
Action string `json:"action,omitempty"`
Dirs []string `json:"dirs,omitempty"`
DirsKnown bool `json:"dirs-known,omitempty"`
Merge string `json:"merge,omitempty"`
Trees map[string]string `json:"trees,omitempty"`
Root string `json:"root,omitempty"`
Act string `json:"act,omitempty"`
Words string `json:"words,omitempty"`
Open []OpenPull `json:"open,omitempty"`
}
func (f Forge) check() error {
if err := checkRepository(f.Repository); err != nil {
if err := checkForgeKind(f.Kind); err != nil {
return err
}
if err := checkForgeKind(f.Kind); err != nil {
if f.Kind == ForgeOpen {
return f.checkOpen()
}
if len(f.Open) > 0 {
return fmt.Errorf("delivery: only an open message names open pull requests")
}
if err := checkRepository(f.Repository); err != nil {
return err
}
switch f.Kind {
@@ -277,7 +452,13 @@ func (f Forge) check() error {
default:
return fmt.Errorf("delivery: the pull action %q is not opened, updated or closed", f.Action)
}
if len(f.Dirs) > 0 && !f.DirsKnown {
return fmt.Errorf("delivery: a pull message with module directories must say it knows them (dirs-known)")
}
case ForgeMerged:
if len(f.Dirs) > 0 || f.DirsKnown {
return fmt.Errorf("delivery: a merged message carries no module directories")
}
if f.Number <= 0 {
return fmt.Errorf("delivery: a merged message needs its pull request number")
}
@@ -299,6 +480,9 @@ func (f Forge) check() error {
}
}
case ForgeRefused:
if len(f.Dirs) > 0 || f.DirsKnown {
return fmt.Errorf("delivery: a refused message carries no module directories")
}
if f.Act == "" {
return fmt.Errorf("delivery: a refused message needs the act it answers")
}
@@ -309,6 +493,24 @@ func (f Forge) check() error {
return nil
}
// checkOpen refuses an open message that names a repository, a branch, a head or anything of the other kinds, or
// an open pull request that is not `<owner>/<name>` and a number.
func (f Forge) checkOpen() error {
if f.Repository != "" || f.Branch != "" || f.Number != 0 || f.Head != "" || f.Action != "" || len(f.Dirs) > 0 || f.DirsKnown ||
f.Merge != "" || len(f.Trees) > 0 || f.Root != "" || f.Act != "" || f.Words != "" {
return fmt.Errorf("delivery: an open message carries only the open pull requests it names")
}
for _, p := range f.Open {
if err := checkRepository(p.Repository); err != nil {
return err
}
if p.Number <= 0 {
return fmt.Errorf("delivery: the open pull request of %s has no number", p.Repository)
}
}
return nil
}
// EncodeSource is s as JSON at Version; it refuses a source with no repository or an empty head.
func EncodeSource(s Source) ([]byte, error) {
if err := s.check(); err != nil {
+32
View File
@@ -0,0 +1,32 @@
package delivery
import "strings"
// RepositoryName is the repository as the forge names it, "owner/name", from either spelling the mesh holds: the name
// itself, or the clone URL a build record carries ("http://host:port/owner/name.git", with or without ".git" or a
// trailing slash). A string of fewer than two path segments is returned as it is, trimmed (issue 1281).
func RepositoryName(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimRight(s, "/")
s = strings.TrimSuffix(s, ".git")
s = strings.TrimRight(s, "/")
parts := strings.FieldsFunc(s, func(r rune) bool { return r == '/' || r == ':' })
if len(parts) < 2 {
return s
}
return parts[len(parts)-2] + "/" + parts[len(parts)-1]
}
// SameRepository is whether two spellings name one repository: the same owner and name, whichever way each is written
// (RepositoryName), compared without regard to case as the forge does. An empty spelling names none (issue 1281).
func SameRepository(a, b string) bool {
a, b = RepositoryName(a), RepositoryName(b)
return a != "" && strings.EqualFold(a, b)
}
// IsTrunkBuild is whether the record is a successful build of the trunk that recorded its source inputs: an outcome
// (the controller writes no state on one; state asked is a request, records.go:57-59), not failed, and ref "main" or none. A build a delivery plan asked after a merge, or rebuild or build --behind, follows the branch
// without naming it, so it carries no ref; a build of any other branch or of a commit names it (issue 1281).
func (r BuildRecord) IsTrunkBuild() bool {
return r.State == "" && r.Failed == "" && r.SourceInputs != nil && (r.Ref == "" || r.Ref == "main")
}
@@ -0,0 +1,197 @@
package overlay
import (
"encoding/json"
"fmt"
"strings"
)
// The overlay is delivered as an ordinary declaration.
//
// novox/hq 08-connectivity: what the host receives is an interface configuration and a peer list,
// as files. It does not compute them and it does not know what a private network is — the shapes
// it already has are enough, which is why connectivity needs nothing new from tier 0.
//
// **No private key travels.** The configuration points at a file the node wrote from a key the
// mesh has never seen, using WireGuard's own ability to set one after the interface is up. So the
// control plane composes a complete configuration for a node it cannot impersonate.
// Interface is what the private network is called on a machine, and where the node's own key
// lives. A constant rather than a setting: two nodes disagreeing about the name would produce a
// mesh where each is configured correctly and nothing meets.
const (
Interface = "mesh0"
ConfigPath = "/etc/wireguard/" + Interface + ".conf"
Unit = "wg-quick@" + Interface
DefaultKeyPath = "/var/lib/mesh-host/overlay.key"
)
// Resource is one entry in a declaration, built here and read by the host.
type Resource map[string]any
// Declaration is what the mesh sends a node to put it on the network.
//
// Three resources and nothing clever: the tools, the configuration, and the interface running. A
// person can read it, which is the point — this is the first thing a node is ever told, and if it
// is wrong the node is unreachable and the mistake has to be findable by eye.
func Declaration(node Node, peers []Peer, keyPath string) ([]byte, error) {
if node.Address == "" {
return nil, fmt.Errorf("%s has no address on the overlay, so there is nothing to configure",
node.Name)
}
if keyPath == "" {
keyPath = DefaultKeyPath
}
up := Resource{
"id": "overlay-up", "type": "service", "unit": Unit,
"state": "running",
// Enabled, so the node comes back onto the network after a reboot without waiting to
// be told again. A node whose overlay only exists while something is watching is not
// a node that survives being switched off and on.
"boot": "enabled",
// And restarted when the peer list changes, because a running interface does not
// re-read its configuration.
//
// This is the whole of it: a node joins, every existing node's peer list changes,
// each file is replaced — and without this the service is already running, nothing
// reloads it, and every node keeps a network that no longer matches the mesh. It
// reports complete success. The lab found it the moment a third node arrived.
//
// Declared state rather than a command. The service must reflect the file; the host
// works out that it does not. A command to restart would be an action, and the link
// may not carry one (novox/hq ADR 0005) — the host refused exactly that, correctly,
// which is how this shape was arrived at.
"restart-on": []string{"overlay-config"},
}
if node.TakesOver != nil {
// The private network takes over the tunnel it found (novox/hq ADR 0105): before this
// unit starts, the host stops and disables the found one — never flushing it — and keeps
// its configuration like any held file. The key is already the found one: the node took
// it as its own overlay key when it enrolled, which is why the mesh's peer list for it
// carries the found peers under the key they know.
up["takes-over"] = map[string]any{
"interface": node.TakesOver.Interface,
"unit": node.TakesOver.Unit,
"config": node.TakesOver.Config,
}
}
resources := []Resource{
{
"id": "overlay-tools", "type": "package", "package": "wireguard-tools",
},
{
"id": "overlay-config", "type": "file", "path": ConfigPath,
// Readable only by root: it lists every peer's key and endpoint, which is a map of
// the mesh. Not secret in the way a private key is, and not something to leave
// world-readable on a machine somebody else also uses.
"mode": "0600",
"content": config(node, peers, keyPath),
},
up,
}
// The names used to be appended here, on the argument that a node with peers and no names is
// half on the network. True, and the wrong place to fix it: names would be identical over a
// different private network, so bundling them with WireGuard made one module out of two
// things. They are their own module now, requiring this one — which is what keeps them
// arriving together without pretending they are the same concern.
return json.Marshal(map[string]any{"declaration": 1, "resources": resources})
}
// config writes the interface file.
//
// Deliberately in the order a person would read it: who I am, then who I talk to, each with a
// line saying why it is there. A generated file that cannot be understood by the person it
// confuses is a generated file that gets edited by hand.
func config(node Node, peers []Peer, keyPath string) string {
var b strings.Builder
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n")
b.WriteString("# peer graph changes, and an edit would survive until the next change and\n")
b.WriteString("# then vanish, which is worse than not being applied at all.\n")
fmt.Fprintf(&b, "#\n# node %s", node.Name)
if node.Site != "" {
fmt.Fprintf(&b, ", at %s", node.Site)
}
if !node.Reachable() {
b.WriteString(", not dialable — it opens every path itself")
}
b.WriteString("\n\n[Interface]\n")
fmt.Fprintf(&b, "Address = %s/32\n", node.Address)
if node.Reachable() {
if port := portOf(node.Endpoint); port != "" {
fmt.Fprintf(&b, "ListenPort = %s\n", port)
}
} else if node.TakesOver != nil && node.TakesOver.Port != 0 {
// Not dialable from the hub, but a LAN peer dials this node on the tunnel it took over,
// so the mesh's interface must listen on that same port (novox/hq: a taken tunnel brings
// its port). Without this the takeover guard refuses overlay-up, and re-placing the node
// with an endpoint — the guard's suggested remedy — breaks a NAT'd node's path.
fmt.Fprintf(&b, "ListenPort = %d\n", node.TakesOver.Port)
}
// The MTU the found tunnel carried, when it set one: a path tuned to 1380 (say) stalls TLS
// and hangs transfers if the mesh's interface comes up at the 1420 default, and no ping shows
// it (novox/hq: a taken tunnel carries its MTU).
if node.TakesOver != nil && node.TakesOver.MTU != 0 {
fmt.Fprintf(&b, "MTU = %d\n", node.TakesOver.MTU)
}
// The private key is set from a file the node wrote, so it never appears here and never
// travelled. Everything else in this file came from the mesh; this one line is the node's.
fmt.Fprintf(&b, "PostUp = wg set %%i private-key %s\n", keyPath)
if node.Hub {
// A hub carries traffic *between* its spokes, and a Linux machine does not forward
// packets unless it is told to. Without this every spoke reaches the hub perfectly and
// no spoke reaches any other — which is exactly how it failed in the lab, and it looks
// like a peering problem rather than a kernel setting.
//
// Here rather than in a separate resource because it is part of what being a hub means,
// and because it should last exactly as long as the interface does: a machine that stops
// being the hub should stop forwarding, and PostDown below is how that happens.
b.WriteString("PostUp = sysctl -q -w net.ipv4.ip_forward=1\n")
b.WriteString("PostDown = sysctl -q -w net.ipv4.ip_forward=0\n")
// And past the machine's own firewall, which on any node with a container runtime is
// closed. Docker sets the FORWARD policy to DROP and inserts its chains, so the foundation
// this mesh installs at tier 1 silently breaks the network it builds at tier 2: every
// spoke reaches the hub, no spoke reaches any other, and every part of it reports
// success. Found in the lab; nothing about it is visible from the mesh's own state.
//
// Inserted at the top so it precedes those chains, and removed on the way down so a
// machine that stops being the hub stops carrying other people's traffic. Guarded on
// iptables existing: a machine without it has no policy to get past.
for _, direction := range []string{"-i", "-o"} {
fmt.Fprintf(&b,
"PostUp = command -v iptables >/dev/null && iptables -I FORWARD 1 %s %%i -j ACCEPT || true\n",
direction)
}
for _, direction := range []string{"-i", "-o"} {
fmt.Fprintf(&b,
"PostDown = command -v iptables >/dev/null && iptables -D FORWARD %s %%i -j ACCEPT || true\n",
direction)
}
}
for _, p := range peers {
fmt.Fprintf(&b, "\n# %s — %s\n[Peer]\n", p.Name, p.Why)
fmt.Fprintf(&b, "PublicKey = %s\n", p.Key)
fmt.Fprintf(&b, "AllowedIPs = %s\n", p.Allowed)
if p.Endpoint != "" {
fmt.Fprintf(&b, "Endpoint = %s\n", p.Endpoint)
} else {
b.WriteString("# no endpoint: this peer cannot be dialled and opens the path itself\n")
}
if p.Keepalive {
b.WriteString("PersistentKeepalive = 25\n")
}
}
return b.String()
}
func portOf(endpoint string) string {
if i := strings.LastIndex(endpoint, ":"); i >= 0 {
return endpoint[i+1:]
}
return ""
}
+5
View File
@@ -0,0 +1,5 @@
// Package overlay is the private network's generator, copied from mesh-controller internal/overlay at commit
// 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4): the files are unchanged but
// for the import path of the catalogue, which is mesh-sdk's go/catalogue, and this note. mesh-controller keeps its own copy
// until release 4b.
package overlay
@@ -0,0 +1,195 @@
package overlay
import (
"encoding/json"
"fmt"
"strconv"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The private network as a module rather than as code beside the module system.
//
// A machine's peer list is derived from every other machine, so it differs on each one and
// changes when any of them changes — there is nothing that could be written in a manifest. So the
// module says its resources are computed, and this computes them.
//
// What that buys, beyond one mechanism instead of two: **a machine is on the private network
// because it was assigned the module.** Before this, every machine with a key and an address was
// on it, and there was no way to say a machine should stay off.
//
// It is worth saying what is *not* here, because networking looks like it should be foundational.
// The host needs none of this. It has an address and a route to the broker before the mesh exists
// — that is the machine's own networking — and the broker's address is carried in the enrolment
// token rather than resolved. So the private network is something the mesh installs on top, like
// anything else, and a node without it is an ordinary node that nothing reaches directly.
// What a module asks for when it needs machines to reach each other. **This is the name that
// matters** — WireGuard is one way to answer it, and naming the requirement after the answer is
// how a mesh ends up unable to have a second one.
const Requirement = "private-network"
// Name is the module that answers it with WireGuard.
//
// **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq
// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hostname` (ADR 0199,
// ADR 0223): the controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it
// asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts
// until 2026-10-05; the host gives that region back at the first push without it.
const Name = "mesh-wireguard"
// Addressing is the mesh handing out addresses on the private network itself.
//
// The mesh's resolver answers names from it, which is why it requires this rather than a private
// network in general. A different VPN that hands out its own addresses would come with its own
// names — the mesh has nothing to answer about a machine whose address it did not choose.
const Addressing = "mesh-addressing"
// TheNetwork is what a machine can only have one of.
//
// Providing a private network is not the singular part — a machine could reasonably run two VPNs
// for two different purposes. Being **the** one the mesh runs over is singular, and without
// saying so a person who chose a different VPN can still end up with this one dragged back in by
// something that needed the mesh's own addresses. Which is exactly what happened, once.
const TheNetwork = "the-private-network"
// **A resolver's data went the same way as the names.** Two constants used to sit here — a
// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data`
// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that
// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and
// requires Addressing, since the file is made of the mesh's addresses and means nothing off the
// network. A requirement nothing provides is refused at resolution, so leaving the names here
// would only have documented a mechanism that does not exist.
// **There is no bundle any more** (novox/hq ADR 0226). A `networking` module requiring this one and
// nothing else used to be what a machine was assigned, so that "get the network working" was one
// word and a second VPN could be chosen by assigning it instead. The mesh has one private network,
// every machine is on it, and the bundle was a second name for this module that every machine
// carried. A machine is assigned Name directly; another VPN is still chosen by assigning it in its
// place, which is all the bundle ever did.
// Generator answers what one node's network configuration is.
type Generator struct {
nodes []Node
graph Graph
// keyPath is where each node keeps the private half it generated. Named rather than carried:
// the mesh has never seen it and never will.
keyPath string
// No registry. Being on the network is still what grants a machine the right to pull from the
// mesh's artifact store in the clear (novox/hq ADR 0082), but the runtime's file is the runtime's
// module's: the private network writes nothing into it, and that module states the registry
// itself, told where the store is reached by ${seat:mesh-artifact-store:reach} (novox/hq ADR
// 0222, issue 190).
}
// From builds a generator over the machines that are part of the network.
//
// The nodes given are the ones assigned the module — not every node the mesh knows. A machine
// that was never given it is absent from everybody's peer list and from the names, which is what
// "not on the network" has to mean.
func From(nodes []Node, cidr, keyPath string) (*Generator, error) {
graph, err := Compute(nodes, cidr)
if err != nil {
return nil, err
}
return &Generator{nodes: nodes, graph: graph, keyPath: keyPath}, nil
}
// Resources is one node's interface, peers and names.
func (g *Generator) Resources(node string) ([]map[string]any, bool, error) {
peers, part := g.graph[node]
if !part {
// Assigned and not yet placed on the network. Ordinary and brief, so it is an answer
// rather than an error.
return nil, false, nil
}
var self Node
for _, n := range g.nodes {
if n.Name == node {
self = n
}
}
raw, err := Declaration(self, peers, g.keyPath)
if err != nil {
return nil, false, err
}
var parsed struct {
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, false, err
}
return parsed.Resources, true, nil
}
// Nodes are the machines this generator was built over, so a caller can say who is on the network.
func (g *Generator) Nodes() []Node { return g.nodes }
// Graph is the peer list per node, for showing.
func (g *Generator) Graph() Graph { return g.graph }
// Manifest is the module the mesh provides for itself.
//
// It ships with the control plane rather than coming from a repository, because the thing that
// computes it ships with the control plane. That is the only way it is unusual: it is assigned,
// unassigned, resolved and settled exactly like a module somebody wrote.
func Manifest() map[string]any {
return map[string]any{
"module": Name,
"version": "1",
"computed": Name,
"provides": []string{Requirement, Addressing},
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
}
}
// Empty is a network nobody is on.
//
// A mesh where no machine was given the module. Legitimate rather than broken — every node still
// reaches the broker, which is what being in the mesh is — so it answers "not part of this" for
// everyone instead of refusing for want of a hub.
func Empty() *Generator { return &Generator{graph: Graph{}} }
// Listens is the port this node accepts the private network on, which only a hub has.
//
// **A fact about this machine's place in the mesh, not about the module.** Every machine on the
// network runs the same module; a hub is dialled by every node at other sites and needs its port
// open, and a machine that is not a hub dials out and needs nothing open at all. A static field in
// a manifest is one answer for every machine that runs it, so it cannot say this — and the machine
// it would get wrong is the one facing the public internet, which is the machine that most needs
// filtering.
//
// The port is the one in the endpoint, which is also where the interface takes its ListenPort
// from. One source, so a rule set cannot open a port the interface is not on.
func (g *Generator) Listens(node string) ([]catalogue.Listening, error) {
for _, n := range g.nodes {
if n.Name != node {
continue
}
if !n.Reachable() {
// It dials out and nothing dials it. Opening a port here would be opening one on a
// machine nothing connects to, which is not harmless — it is a rule with no source
// that somebody later has to work out the reason for.
return nil, nil
}
port := portOf(n.Endpoint)
if port == "" {
return nil, fmt.Errorf(
"%s is reachable at %q and no port can be read from it, so what it must accept "+
"the private network on is unknown", node, n.Endpoint)
}
number, err := strconv.Atoi(port)
if err != nil {
return nil, fmt.Errorf("%s is reachable at %q, and %q is not a port", node, n.Endpoint, port)
}
return []catalogue.Listening{{
Port: number, Protocol: "udp", From: catalogue.FromEverywhere,
// From everywhere, and deliberately: a node at another site is not on the private
// network until this port lets it on, so restricting this to the mesh would be a
// rule that can never be satisfied by the thing it exists for.
Why: "the private network — a node at another site has no other way in",
}}, nil
}
return nil, nil
}
+231
View File
@@ -0,0 +1,231 @@
// Package overlay computes the private network every node runs on.
//
// novox/hq 08-connectivity. This is control-plane work by definition: a peer list is derived from
// every node at once, and no node has that. A node computes nothing about the mesh — it generates
// a keypair, publishes the public half, and receives the rest.
//
// The shape is a hub, with direct peering between nodes at the same site. Not a full mesh, and
// the reason is a property of WireGuard rather than a preference: there is no failover. A more
// specific route to a dead endpoint blackholes; it does not fall back to the general one. So a
// node gets exactly one path to any peer, because two would mean one of them silently swallowing
// traffic.
package overlay
import (
"errors"
"fmt"
"sort"
"strings"
)
// Node is one machine's place on the network, as the mesh holds it.
type Node struct {
Name string
Key string
Endpoint string
Site string
Hub bool
Address string
// Carried are the peers of the tunnel this node took over (novox/hq ADR 0105): machines the
// mesh has no record of, each known by the public key and the address the found tunnel routed
// to it. Only a hub has any. They stay in its peer list until a node enrols with that key —
// from then on the node is the peer.
Carried []Carried
// TakesOver names the found tunnel this node's private network replaces: its unit is stopped
// and disabled, never flushed, and its configuration kept, before the mesh's interface comes
// up with the found key. Nil on a node that raises the mesh's interface beside whatever it has.
TakesOver *TakeOver
}
// Carried is one peer of an adopted tunnel that has not enrolled: a peer of the tunnel, not a
// node of the mesh.
type Carried struct {
Key string
Address string
}
// TakeOver is the found tunnel a node's private network takes over, as the host is told it.
type TakeOver struct {
Interface string
Unit string
Config string
// MTU is the found tunnel's, when it set one — emitted so a tuned path keeps its MTU.
MTU int
// Port is the port the found tunnel listened on. The mesh's interface must listen on it too,
// even on a node that is not dialable from the hub: a home node's LAN peers dial it there
// (novox/hq: a taken tunnel brings its port). Listening is "a peer dials me here"; it is not
// "the hub can dial me", which is Reachable — the two were conflated.
Port int
}
// HostPrefix is one address as a route: /32 for IPv4, /128 for IPv6.
func HostPrefix(address string) string {
if strings.Contains(address, ":") {
return address + "/128"
}
return address + "/32"
}
// CarriedName is how a carried peer is named in a peer list: it has no node name, so it is named
// by the key its packets arrive under.
func CarriedName(key string) string {
short := key
if len(short) > 8 {
short = short[:8] + "…"
}
return "a peer of the tunnel (" + short + ")"
}
// Reachable reports whether other nodes can dial this one. Declared, never inferred.
func (n Node) Reachable() bool { return strings.TrimSpace(n.Endpoint) != "" }
// Peer is one entry in a node's peer list.
type Peer struct {
Name string
Key string
// Endpoint is empty when this peer cannot be dialled — it must dial us instead.
Endpoint string
// Allowed is what traffic goes down this tunnel. A single address for a direct peer; the
// whole overlay for the hub, which is what makes it the route of last resort.
Allowed string
// Keepalive matters only on the side behind NAT: a node that cannot be dialled has to keep
// the path open from its end, or the peer's first packet arrives at a mapping that has
// already expired.
Keepalive bool
// Why this peer is in the list, for a person reading a generated file and wondering.
Why string
}
// Graph is every node's peer list.
type Graph map[string][]Peer
// ErrNoHub means nobody has said which node is the hub.
//
// Its own error rather than an empty graph: a mesh with no hub has no path between sites, and
// answering with "no peers" would look like a working mesh where nothing can reach anything.
var ErrNoHub = errors.New("this mesh has no hub, so there is no path between sites")
// Compute derives every node's peer list.
//
// Nodes without a key or an address are skipped rather than refused: a node that has enrolled and
// not yet been given a place on the network is an ordinary in-between state, and failing the whole
// graph because one node is half-configured would mean no node gets a network.
func Compute(nodes []Node, overlayCIDR string) (Graph, error) {
var hub *Node
usable := make([]Node, 0, len(nodes))
for i := range nodes {
n := nodes[i]
if n.Key == "" || n.Address == "" {
continue
}
usable = append(usable, n)
if n.Hub {
hub = &usable[len(usable)-1]
}
}
if len(usable) == 0 {
return Graph{}, nil
}
if hub == nil {
return nil, ErrNoHub
}
if !hub.Reachable() {
return nil, fmt.Errorf(
"%s is the hub and has no endpoint, so nothing can dial it. The hub is the one node "+
"that must be reachable from wherever the others are", hub.Name)
}
graph := Graph{}
for _, self := range usable {
var peers []Peer
// A node may share a site with the hub, and then the hub is one peer rather than two.
// Written before the loop because it changes what that loop may emit: WireGuard takes one
// entry per public key, so a hub appearing twice is a configuration it refuses — and the
// mesh would have produced it silently. The lab found this on the first two machines that
// shared a site with their hub.
hubIsHere := !self.Hub && self.Site != "" && self.Site == hub.Site
for _, other := range usable {
if other.Name == self.Name || (hubIsHere && other.Name == hub.Name) {
continue
}
// Two nodes at the same site peer directly — but only if one of them can be
// dialled. If neither can, nobody opens the path, and the direct route is more
// specific than the hub's, so it wins and blackholes. That is this design's own
// stated hazard arriving in it: *a more specific route to a dead endpoint
// blackholes; it does not fall back to the general one.*
//
// Found in the lab with two machines at one site behind no reachable address, which
// is the ordinary shape of a home: they were given each other as peers, neither
// could start, and they could not reach each other at all while both reached the hub
// perfectly.
if self.Site != "" && self.Site == other.Site && (self.Reachable() || other.Reachable()) {
peers = append(peers, Peer{
Name: other.Name, Key: other.Key,
Endpoint: other.Endpoint,
Allowed: other.Address + "/32",
Keepalive: !self.Reachable(),
Why: "at the same site",
})
}
}
if !self.Hub {
// Everything else goes through the hub, including a node that roams. AllowedIPs is
// the whole overlay, so this is the route of last resort — and because direct peers
// above are single addresses, they win on specificity without either being ambiguous.
why := "the hub — everything not at this site"
if hubIsHere {
// One entry doing both jobs: the direct path to a machine that happens to be
// here, and the route to everywhere else. Splitting them would need two entries
// for one key, which is the thing being avoided.
why = "the hub, which is also at this site — everything goes here"
}
peers = append(peers, Peer{
Name: hub.Name, Key: hub.Key,
Endpoint: hub.Endpoint,
Allowed: overlayCIDR,
Keepalive: !self.Reachable(),
Why: why,
})
} else {
// The hub holds every node that does not share a site with it, because those nodes
// route through it and it must know where to send the replies. Ones it cannot dial
// will dial it.
enrolled := map[string]bool{}
for _, other := range usable {
enrolled[other.Key] = true
if other.Name == self.Name || (self.Site != "" && self.Site == other.Site) {
continue
}
peers = append(peers, Peer{
Name: other.Name, Key: other.Key,
Endpoint: other.Endpoint,
Allowed: other.Address + "/32",
Why: "routes through this hub",
})
}
// And every peer of the tunnel it took over that has not enrolled (novox/hq ADR
// 0105): the same key and the same address the found tunnel had for it, so the
// machine behind it cannot tell the tunnel changed hands. No endpoint — it dials in,
// as it always did. Once a node enrols with that key, the node's entry above is the
// peer, and WireGuard takes one entry per key.
for _, c := range self.Carried {
if enrolled[c.Key] {
continue
}
peers = append(peers, Peer{
Name: CarriedName(c.Key), Key: c.Key,
Allowed: HostPrefix(c.Address),
Why: "carried from the tunnel this hub took over — a peer of the tunnel, not yet a node of the mesh",
})
}
}
sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name })
graph[self.Name] = peers
}
return graph, nil
}
+39
View File
@@ -0,0 +1,39 @@
package overlay
import (
"os"
"regexp"
"strings"
)
// Names are how one node reaches another by name rather than by address.
//
// novox/hq 08-connectivity: what the host receives is the resolver's configuration, as files,
// listing every peer's internal name and overlay address. Computed centrally for the same reason
// the peer graph is — it needs every node at once.
// SuffixVar lets a mesh choose what its internal names end in.
const SuffixVar = "MESH_INTERNAL_SUFFIX"
// DefaultSuffix is `.internal`, which IANA reserved for exactly this in 2024. A name under it can
// never collide with a public one, so an internal name that leaks into a public resolver fails
// rather than reaching a stranger's machine.
const DefaultSuffix = "internal"
// Suffix is what internal names end in.
func Suffix() string {
if v := strings.TrimSpace(os.Getenv(SuffixVar)); v != "" {
return strings.TrimPrefix(v, ".")
}
return DefaultSuffix
}
// nodeName is what a node may be called, so that it can also be a hostname.
var nodeName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
// InternalName is a node's name inside the mesh.
func InternalName(node string) string { return node + "." + Suffix() }
// **What remains of a larger file.** The rest wrote /etc/hosts, which the controller no longer
// writes at all (novox/hq ADR 0199): the mesh's resolver answers these names. The naming stays here, because several things compose a node's internal name and one of them
// writing the suffix differently would be a name nothing answers to.
@@ -0,0 +1,42 @@
// Package validate is the node-engine's own judgement of a declaration, for whoever must know before
// a declaration is sent whether a machine would take it (novox/hq to-be 45 §4, D1; §9, the merge gate).
//
// **One validator.** The controller composed declarations the host then refused whole — a manifest
// the catalogue check passed (novox/hq issue 236), a consumer's identity too long for the machine's
// names (issue 263) — because the only validator was the one the host runs as it applies. A second,
// written in the controller from the host's rules, would drift from them the first time either
// changed. So the host's own parsing is exported here, unchanged: what the controller's self-check and
// the merge gate run is what every machine runs.
//
// Copied from mesh-host at commit 619e14aa8e572c5056e3d2a2412c7f8b364bf840 (validate/validate.go), unchanged but for
// the import path of the parser, which is mesh-sdk's go/declaration (novox/hq ADR 0453 clause 3, ADR 0446 clause 4).
// mesh-host keeps its own copy until release 4b.
//
// It judges a declaration as it arrives over the link: actions are refused, as the host refuses them
// from the link (novox/hq ADR 0005). Nothing here touches a machine.
package validate
import (
"errors"
"git.novox.be/novox/mesh-sdk/go/declaration"
)
// Declaration is every problem the node-engine would refuse a declaration for, each in its own words;
// nil when it would take it. The body is the declaration as the controller composes it — the bytes a
// signature is made over — not the signed envelope.
func Declaration(raw []byte) []string {
_, err := declaration.Parse(raw)
if err == nil {
return nil
}
var refused *declaration.RefusalError
if errors.As(err, &refused) {
return refused.Problems
}
return []string{err.Error()}
}
// Version is the declaration vocabulary this validator speaks: a declaration of another version is
// refused whole by Declaration, as by the host.
const Version = declaration.Version