Files
mesh-controller/internal/catalogue/manifest.go
T
jschoubben f8ab9f2dcf The mesh composes the accounts; the module owns its server
The delivery question, decided. The alternative was a manifest field enumerating
the server's ports, TLS paths and store directory so the controller could write a
whole configuration file. That is wrong: those are properties of the container the
module raises, they live in its image and its mounts, and the controller would
have to be kept in step with a Dockerfile it never sees. So the mesh writes only
what only the mesh knows — who may connect — and the module's own configuration
includes it.

`ComposeAccounts` is that file. A test says what must *not* be in it as plainly as
what must: no port, no tls block, no store_dir. Each of those in the mesh's file
is a value the controller would then own, and the module could no longer change
its own image without the mesh agreeing.

`bus-users` is where a module wants it written, and **asking is not enough to
receive it**: the file holds every user's password hash, so a module that could ask
for it could read every credential on the bus. The claim on `mesh-broker`
authorises it, checked from the manifest alone. A holder with nothing composed is
refused rather than given an empty file, for the reason a certificate is — a bus
with no user list refuses every connection in the mesh and looks like a machine
problem.

Six claims checked against a running server before any of this was committed to,
and two of them changed what got written:

**An absolute include path is resolved relative to the including file's
directory.** `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf
makes the server look for /etc/nats-server/etc/nats/accounts.conf and refuse to
start. So both files share one directory, and the module declares its own as a
file resource beside the mesh's.

**`verify: true` was refusing every connection in the mesh.** It makes the server
demand a *client* certificate, and nothing in the mesh presents one: a host pins
this server's exact certificate and authenticates with the password the mesh
minted, and so does a module's runtime. Every connection died at the TLS handshake
before any password was looked at, with an error — "client didn't provide a
certificate" — that reads as a fault in the client. Removed. TLS is still
required; verify only decides whether client certificates are checked.

The other four: a user in an included file authenticates, an unknown user is
refused so the include is the whole authority rather than an addition, a publish
outside a grant is refused, and rewriting the mesh's half alone makes a new user
appear — noticed by the module's own watcher, with no signal from outside, and
without dropping the connection the mesh already had. That last one is task 1.2's
payoff, collected.
2026-09-27 02:50:23 +02:00

1562 lines
68 KiB
Go

// Package catalogue is what modules are, and what a node gets when it is assigned some.
//
// novox/hq ADR 0009: everything is a module, a module declares what it provides and requires,
// and a module declares what it claims. This turns a set of assignments into the one declaration
// a node is sent — which is the first thing the control plane decides rather than relays.
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// Scopes a claim can have.
//
// Not everything singular is singular per machine: a seat is one per node, a DHCP server is one
// per segment, and the hub is one per mesh. Scope says which, and it is the same idea the mesh
// already enforces by hand for the hub.
const (
ScopeNode = "node"
ScopeSite = "site"
ScopeMesh = "mesh"
)
// name is what a module, a provision or a claim may be called.
//
// Constrained because these become resource identities, permission patterns and error messages,
// and a name that is valid in one and not the others is a fault found late.
// renamed is what a field used to be called, and what it is now.
//
// Kept rather than dropped once the rename is done: a manifest written against the old name is
// refused either way, and the difference is whether whoever wrote it has to go and find out why.
var renamed = map[string]string{
// `needs` and `secrets` were both name-to-path and differed only in whose secret it was, so
// reaching for the wrong one parsed cleanly and failed somewhere else entirely.
"needs": "own-secrets",
}
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$`)
// Claim is a singular resource a module takes over.
type Claim struct {
Name string `json:"name"`
// Scope defaults to the node, which is where nearly everything singular is singular.
Scope string `json:"scope,omitempty"`
}
// At is this claim's scope, with the default applied.
func (c Claim) At() string {
if c.Scope == "" {
return ScopeNode
}
return c.Scope
}
// Modes an access may be granted at.
//
// **The grant states its extent, the way a listening port states its source** (the rule the
// manifest already keeps for `listens.from`). Absent narrows to read — the safe default, because
// the danger with an access is being given more than was meant, not less.
const (
AccessRead = "read"
AccessReadWrite = "read-write"
)
// Access is a pre-existing path on the machine that this module is GRANTED USE OF, and does not
// own.
//
// **The distinction this exists for** (novox/hq ADR 0051, 04-ISSUES/036): a `directory` resource
// is a thing the mesh owns — it creates it, sets its owner and mode, and removes it when it is
// empty and no longer declared ([ADR 0030](novox/hq)). Shared, pre-existing data is none of that.
// A media library, a download spool, an ingest folder is the **operator's**: it existed before the
// mesh, several modules read and write it at once, and the mesh must not create, chown, reconcile
// or remove it. It mounts it and owns nothing about it.
//
// Written as its own field rather than a flag on a directory because the two are opposite on every
// axis the host acts on, and 04-ISSUES/026 records what happens when *the directory my data lives
// in* and *a facility I was granted* are spelled the same: the second gets created as root and the
// ownership fields silently do not apply. Two modules may name the same access with no conflict —
// that is the whole point of it — whereas two owning one path is the fault the resolver refuses.
//
// If the path is absent when a machine applies, the host refuses clearly rather than creating it:
// the mesh does not own it, so conjuring it would be a lie the host then acts on.
type Access struct {
// Path is the absolute path on the machine, as the operator provides it.
Path string `json:"path"`
// Mode is "read" or "read-write". Absent narrows to read.
Mode string `json:"mode,omitempty"`
}
// At is this access's mode, with the default applied.
func (a Access) At() string {
if a.Mode == "" {
return AccessRead
}
return a.Mode
}
// Offer is something a module provides, and where the answer to it may live.
//
// **The distinction this exists for:** a shell, a display server and a private network have to be
// on the machine that needs them. A database, an object store and an identity provider do not —
// they run somewhere in the mesh and are reached over it. Treating the second as the first
// installs PostgreSQL on every machine that runs a web application, which is what happened until
// this field existed.
//
// Written as a bare string in the ordinary case, because nearly everything is node-scoped and
// making every manifest say so would bury the few that are not:
//
// "provides": ["shell"]
// "provides": [{"name": "postgres-database", "scope": "mesh"}]
type Offer struct {
Name string `json:"name"`
// Scope defaults to the node, which is where most things must be to be usable.
Scope string `json:"scope,omitempty"`
}
// At is this offer's scope, with the default applied.
func (o Offer) At() string {
if o.Scope == "" {
return ScopeNode
}
return o.Scope
}
// UnmarshalJSON accepts a plain name as well as an object.
func (o *Offer) UnmarshalJSON(raw []byte) error {
var plain string
if err := json.Unmarshal(raw, &plain); err == nil {
o.Name, o.Scope = plain, ""
return nil
}
var full struct {
Name string `json:"name"`
Scope string `json:"scope,omitempty"`
}
if err := json.Unmarshal(raw, &full); err != nil {
return fmt.Errorf("a provided name is either a string or {name, scope}: %w", err)
}
o.Name, o.Scope = full.Name, full.Scope
return nil
}
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
// went through the mesh comes out looking like the one that went in.
func (o Offer) MarshalJSON() ([]byte, error) {
if o.Scope == "" {
return json.Marshal(o.Name)
}
return json.Marshal(struct {
Name string `json:"name"`
Scope string `json:"scope"`
}{o.Name, o.Scope})
}
// Manifest is everything a module says about itself.
type Manifest struct {
Module string `json:"module"`
Version string `json:"version,omitempty"`
// Slug is a short identifier the mesh uses in place of the module name when it derives a
// consumer's login (novox/hq ADR 0049). Optional: a module with a short name needs none. It
// exists because `mesh_<node>_<module>` must fit the tightest backend a consumer reaches — an S3
// access key is 20 characters — and a long module name would overflow it. A person choosing
// `kc` for keycloak keeps the identity legible where a hash would not.
Slug string `json:"slug,omitempty"`
// Provides are the names other modules may require. A module always provides its own name;
// this is for the rest — `zsh` provides `shell`, `xorg` provides `display-server`.
Provides []Offer `json:"provides,omitempty"`
// Requires are names that must be provided by something assigned to the same node.
Requires []string `json:"requires,omitempty"`
// Emits are the event types this module publishes onto the broker — dotted topic keys, e.g.
// "module.umami.site.created". Declared so the mesh knows the event graph; events are
// provisioning's lighter sibling — 1:many and broadcast, no credential (novox/hq ADR 0041).
Emits []string `json:"emits,omitempty"`
// Consumes are the event patterns this module subscribes to — topic patterns over module,
// mesh and node events alike, e.g. "node.*.joined" or "#" (the audit logger). The runtime
// wires the subscription; the module ships the handler. A Consumes for an event nothing on
// the mesh Emits is a dangling edge.
Consumes []string `json:"consumes,omitempty"`
// Claims are singular resources. Two modules claiming one thing within a scope cannot both
// be assigned there — which is how exclusivity is expressed, rather than as a list of rivals
// that every new module would force its predecessors to update.
Claims []Claim `json:"claims,omitempty"`
// Seats this module declares of its own, with their protocols (novox/hq ADR 0118). The set
// of seats a mesh has is the mesh's own plus these, derived from what is registered rather
// than written in the controller — closed, and extensible without changing the mesh.
Seats []SeatDeclaration `json:"seats,omitempty"`
// Uses are seats this module sends to. It names the *seat*, never the module holding it, so
// the implementation can be replaced under it and no caller changes. A caller gets publish
// on that seat's inbound subjects and nothing else — not its outbound events, and not a
// subscription to the queue it writes to (design 29 §2).
Uses []string `json:"uses,omitempty"`
// Tools are the tools this module answers — request and reply, awaited.
//
// **New, and not `serves`**, which this manifest already uses for the facts a consumer needs
// in order to reach a provision. Two meanings under one key would be a footgun in the one
// file a module author reads most. Until now a module's tools were known only at runtime,
// from MESH_TOOL_MODULES in its image; declaring them is what lets the mesh check that a
// module claiming a seat answers what that seat's protocol promises (novox/hq ADR 0118).
Tools []string `json:"tools,omitempty"`
// Capabilities the machine must have. A different field from Requires because the remedy
// differs: a missing module can be assigned, and a missing capability means the wrong
// machine.
Capabilities []string `json:"capabilities,omitempty"`
// Resources are what this module puts on a node, in the host's own vocabulary.
Resources []map[string]any `json:"resources,omitempty"`
// Accesses are pre-existing, operator-owned paths this module is granted use of but does not
// own — a shared media library, a download spool (novox/hq ADR 0051). Distinct from a
// `directory` resource, which the mesh creates and owns: an access is mounted and nothing
// about it is reconciled, and several modules may name the same one without conflict.
Accesses []Access `json:"accesses,omitempty"`
// Computed names something in the control plane that works this module's resources out per
// node, instead of them being fixed here.
//
// Because some files cannot be written in advance. A machine's peer list on the private
// network is derived from every other machine, so it differs on each one and changes when any
// of them changes — there is nothing to put in a manifest.
//
// Being a module anyway is the point: it is assigned like anything else, so a machine that
// should not be on the private network simply is not given it, and the network is worked out
// over the machines that have it. Before this, connectivity was code beside the module system
// doing the same job, and every machine with an address was on the network whether or not
// anybody wanted it there.
Computed string `json:"computed,omitempty"`
// Contributes is what this module tells whatever answers a requirement.
//
// The other half of an edge. `requires` says a thing must be there; this says what to do with
// it — a web application requiring a reverse proxy has to say *which name, which port*, and
// until now there was nowhere to put that. Every module that needed it was reduced to
// reaching into the control plane's database directly, which is how two of them came to hold
// a credential to it permanently.
//
// Keyed by the requirement, because that is what the contribution is *about*. Contributing to
// something is requiring it: asking to be published means a publisher must exist, and a
// module that had to say both would eventually say one.
Contributes map[string]map[string]any `json:"contributes,omitempty"`
// ContributesMany is the same key, `contributes`, where a module tells one provider several
// things under local names — `"route": {"api": {"label": "files-api", "port": 9000}, "console":
// {"label": "files", "port": 9001}}` — because a module may answer one requirement more than
// once: an object store with a data API and a console are two different public names, not one
// (novox/hq ADR 0094's sibling for `contributes` rather than `secrets` — "a module may need more
// than one value from a provider that gives one per pair" applies exactly as well to what a
// module gives a provider as to what it keeps from one). Each local name is a contribution of
// its own, reaching the provider as its own entry in the file it receives.
//
// Filled from the manifest's `contributes` object by UnmarshalJSON; never written by hand.
ContributesMany map[string]map[string]map[string]any `json:"-"`
// Receives is where this module wants its consumers' contributions written, per requirement
// it provides.
//
// A file, in the mesh's own shape, replaced whenever the set changes. **The control plane
// does not know what a reverse proxy is** and does not write one's configuration — it
// delivers the facts, and the module turns them into whatever it runs. That boundary is why
// swapping the proxy does not touch a single module that publishes through it.
Receives map[string]string `json:"receives,omitempty"`
// Serves is what a consumer needs to know in order to use something this module provides — a
// port, a path, a realm. The module's half of the answer; the mesh adds the other half, which
// is *which machine* and *where it is on the private network*.
//
// It does not carry a credential and cannot: a manifest is the same on every mesh, and a
// secret is the one thing that must not be.
Serves map[string]map[string]any `json:"serves,omitempty"`
// Build says how this module's artifacts are produced from its source.
//
// The manifest in a repository names artifacts; the manifest the mesh holds names digests.
// **They are not the same document**, and that is deliberate: a digest is not knowable until
// something is built, and a repository that carried one would be a repository whose file is
// wrong the moment anybody edits anything.
Build *Build `json:"build,omitempty"`
// Binds is where this module wants to be told about something it requires, per requirement.
//
// Because "this machine needs a database from the anchor" is useless to the program that
// needs it unless the program is told. A file, like everything else — the host writes files
// and knows nothing about provisions, which is what keeps this from needing anything new
// down there.
Binds map[string]string `json:"binds,omitempty"`
// Secrets is where this module wants the credential for something it requires, per
// requirement. The file holds the value and nothing else, so a program can read it without
// parsing anything.
//
// **Its own file, separate from Binds, because the mesh cannot compose a document containing
// it.** The value was sealed to this node when it was made and the plaintext discarded — so
// there is nothing to interpolate into a larger file, and that is the property worth keeping
// rather than an inconvenience to work around. It also means the readable half stays readable
// in the declaration, and the secret half changes only when the secret does, which is what
// makes `restart-on` precise.
Secrets map[string]string `json:"secrets,omitempty"`
// SecretsMany is the same key, `secrets`, where a requirement maps to SEVERAL files under local
// names — `"secret": {"admin": "/…/admin", "token": "/…/token"}` — because a module may need
// more than one value from a provider that gives one per pair (novox/hq 04-ISSUES/069, ADR
// 0094). Each local name is a pair credential of its own, keyed on that name, delivered as its
// own file, served to the provider as its own holder, and rotated with the others. Filled from
// the manifest's `secrets` object by UnmarshalJSON; never written by hand.
SecretsMany map[string]map[string]string `json:"-"`
// OwnSecrets are secrets this module needs in order to be itself, and where to put them.
//
// **Named for whose they are, not how secret they are.** `secrets` above is a credential for
// reaching something else, keyed by the provision it belongs to. These are keyed by a name the
// module chose and belong to nobody else. Both were `map[string]string` of name to path, and
// the field was called `needs` — so reaching for the wrong one parsed cleanly and failed
// somewhere else entirely, which is the shape of fault this whole design exists to prevent.
//
// Not tied to a consumer. A database has a superuser password, a broker has an administrator,
// a registry has an account — each is a secret the module needs in order to be itself, and
// none of them is *for* anybody. Keyed by a name of the module's choosing, valued by the file
// it lands in.
//
// **Generated per node and sealed to it**, like everything else the mesh hands out, so a
// module running on three machines has three passwords and the mesh can read none of them. A
// manifest carrying one instead would put the same secret on every machine that ever runs the
// module, in a file anybody can read, for ever.
OwnSecrets map[string]string `json:"own-secrets,omitempty"`
// SecretsOwner is who the files holding this module's secrets belong to on the machine —
// `uid:gid`, or a name — when its process is not root.
//
// **A secret reaches a process as a file** (novox/hq ADR 0086), and a file the host writes at
// 0600 as root is a file a container running as another account cannot read: the control
// plane, `USER 65534` in a scratch image, crash-looped on `permission denied` the first time
// its credentials were mounted instead of read from an env-file (which the daemon reads, as
// root, on the host side — which is exactly why that shape hid the problem). Absent means
// root, which is what a process that runs as root needs and what a process that does not
// cannot use.
SecretsOwner string `json:"secrets-owner,omitempty"`
// Keeps is where this module wants every operator-sealed secret in the mesh written — the
// vault's field, and so far nobody else's (novox/hq ADR 0085, amended).
//
// A directory. The mesh writes one file into it, `export.json`: every secret a module holds for
// itself, sealed to the operator's key, with the key's public half and the list of what is NOT
// in it. Ciphertext to the machine that holds it and to everything on the bus it crossed —
// only the operator, holding the private half off the mesh, can open a line of it. That is
// what lets a vault's disk stand in for the store when the store is gone: recovery needs the
// export and the key, and the mesh holds neither in a form it can use.
Keeps string `json:"keeps,omitempty"`
// Listens is what this module accepts connections on, and from where.
//
// **A rule names its source** ([ADR 0007](novox/hq)). A port with no source is open to
// everything that can reach the machine, and saying so is the difference between a manifest
// that restricts something and one that appears to — which is the fault
// [04-ISSUES/003](novox/hq) records, where five manifests carried a `scope:` nothing read.
//
// **Derived, not kept in step by hand.** A machine's open ports are a consequence of what runs
// on it; the mesh gathers these and hands the whole set to whatever enforces them.
Listens []Listening `json:"listens,omitempty"`
// Filtering is where this module wants the node's whole computed rule set written.
//
// One module per node asks for it, and what it receives is derived from every module's
// `listens` rather than from its own — a firewall is a property of the machine, and a module
// that could only see its own ports would write a rule set that closed everything else.
Filtering *Filtering `json:"filtering,omitempty"`
// Guards are ports of this module's the mesh refuses on an adopted node except from the
// private network and from the machine itself (novox/hq ADR 0100) — the store's port and the
// broker's management port. The ports the software uses; the mesh guards where the machine
// publishes them. On an adopted node the found firewall stays in force and the mesh loads no
// filter of its own, so this is what keeps them unreachable from outside whatever that
// firewall does. Ignored on a converged node, whose derived filter already closes them.
Guards []int `json:"guards,omitempty"`
// Facts are things only the mesh knows, written where this module asks for them.
//
// **The graph is the control plane's; how a machine uses it is the module's.** The mesh knows
// which machines exist, what they are called and where they are. Making a name resolve, or a
// peer reachable, is somebody's software — dnsmasq, a resolver, a VPN — and the mesh has no
// business shipping one, choosing which, or knowing its configuration language.
//
// So a module says *put the node names here* and owns everything after that. The same shape as
// `filtering`, generalised: a fact, and a path.
//
// It replaces 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.
//
// Keyed by fact name; the names are a closed list, because a module asking for one the mesh
// does not compute is asking for something nobody will write, and finding that out on a machine
// is worse than being told here.
Facts map[string]string `json:"facts,omitempty"`
// Certificate is where this module wants a certificate for its machine's name inside the
// mesh, and where the key that goes with it can be found.
//
// **The key is named, not delivered.** The node generated it at enrolment and keeps it; the
// mesh only ever signs the public half. So what arrives is a certificate, which is public,
// and a path to a file the machine already has.
//
// Two authorities are kept apart on purpose (novox/hq 08-connectivity): this is the mesh's,
// for names only the mesh knows. A name the outside world reaches is a different authority
// and a different problem.
Certificate *Certificate `json:"certificate,omitempty"`
// Grants is a directory this module wants the credentials of its consumers written into, per
// provision it offers — one file per consumer, named for it, holding the value alone.
//
// A directory rather than one document for the same reason as above: each value is sealed
// separately and the mesh cannot open any of them to build a list.
Grants map[string]string `json:"grants,omitempty"`
// BusUsers is where this module wants the mesh's user list written, and it is only ever
// answered for the module holding `mesh-broker`.
//
// **The mesh writes who may connect; the module owns everything else about its server**
// (novox/hq design 25 §4, task 1.7). Ports, TLS paths and a store directory live in this
// module's image and its mounts and change when it does, so the module's own configuration
// carries them and includes this file. A controller that wrote the whole configuration would
// have to be kept in step with a Dockerfile it never sees.
//
// **Asking for it is not enough to receive it.** This file holds every user's password hash, so
// a module that could ask for it could read every credential on the bus — and the claim on
// `mesh-broker` is what authorises it, checked from this manifest alone.
BusUsers string `json:"bus-users,omitempty"`
}
// Build says how to produce this module's artifacts from its source.
//
// **Absent means nothing is built.** A module can be entirely configuration — a shell's rc file,
// a set of firewall rules — and having to declare an empty build for it would be a field that
// exists to be left blank.
type Build struct {
// Artifacts are what the source produces, each named so a resource can refer to it before
// anybody knows its digest.
Artifacts []Artifact `json:"artifacts,omitempty"`
// On is what this module's own build stands on: another module's artifact, named rather than
// pinned.
//
// **A module may not write down which copy of its base to use** (novox/hq issue 044). Every
// module in a scripted toolchain is compiled inside one shared image, and a fingerprint typed
// into a recipe names one particular copy of it — the copy on whichever machine the person
// typing was using. On any other mesh that copy has never existed, so the build stops on its
// first line. Naming the module instead lets the mesh answer with the copy *this* mesh has,
// which is the only one it can fetch.
//
// It does not make the build edge declared. What this says is where to start; what the build
// was actually built against is still read back out of the build itself (ADR 0009), and the
// two can disagree — a recipe that names a base and then bakes in a second one is exactly the
// drift that reading it back catches.
On []BuildsOn `json:"on,omitempty"`
}
// BuildsOn is one base a build needs, and the name the recipe knows it by: another module's
// artifact, or an image published elsewhere.
type BuildsOn struct {
// Arg is the build argument the recipe reads it from.
Arg string `json:"arg"`
// Module is whose artifact it is.
Module string `json:"module,omitempty"`
// Artifact is which of that module's artifacts, by its own name for it.
Artifact string `json:"artifact,omitempty"`
// Image is an image published elsewhere, pinned by digest, that the build copies out of — a
// vendor's tool, a base nobody in the mesh builds. Declared, the mesh copies it into its own
// registry before the build and hands the recipe the copy (novox/hq 04-ISSUES/064, ADR 0097);
// a recipe fetching from a public registry on its own is refused.
Image string `json:"image,omitempty"`
}
// ArtifactContext names the repository an image artifact's build context is cloned from, when
// that is not this module's own repository.
type ArtifactContext struct {
// Repository is cloned fresh, the same way the module's own repository is — a working tree
// nothing has touched, so what was built is reproducible from the two commits named rather
// than from whatever a previous build happened to leave behind.
Repository string `json:"repository"`
// Ref is the branch, tag or commit of that repository to build. Empty means its own default
// branch — the same meaning an empty module ref already has.
Ref string `json:"ref,omitempty"`
}
// Artifact is one thing built from a module's source.
type Artifact struct {
// Name is how resources refer to it. Local to the module.
Name string `json:"name"`
// Kind is "image" or "archive".
Kind string `json:"kind"`
// From is what it is built from, relative to the repository root: a Dockerfile for an image,
// a directory for an archive.
From string `json:"from"`
// Target is which stage of that Dockerfile to stop at, for a recipe that describes more than
// one image.
//
// **One source, two images, and they are meant to differ.** A toolchain image carries a
// compiler and everything a build needs; the image the same module *runs* in should carry
// neither. Describing both in one recipe keeps them in step — they share a base, a library
// version and an operating system — while letting each be built and published separately.
// Empty means the whole recipe, which is what a module with one image says by saying nothing.
Target string `json:"target,omitempty"`
// Context names a second repository this image's build reaches into for its own source — the
// recipe itself is still read from this module's own directory, at this module's own commit;
// only the build context `docker build`'s final argument names comes from here instead.
//
// **Packaging and source are allowed to live apart.** A module that only ships the recipe for
// source that lives elsewhere — the reference route-proxy in mesh-controller's own repository,
// packaged as a module in the catalogue rather than vendored a second time the two copies
// could drift from — names where that source actually is. Empty means the ordinary case: an
// image built from this same module's own repository, the same as every other artifact.
Context *ArtifactContext `json:"context,omitempty"`
// Language is what this module's code is written in, for a bundle.
//
// **Declared, never guessed.** Inferring it from what files happen to be present makes a
// module's build depend on a directory listing, and a module that adds a stray file builds
// differently for a reason nobody can see. It is also the only thing a bundle needs to say:
// everything else about the toolchain — which compiler, which flags, which base — is the
// mesh's, and a module that could override it would be writing a Dockerfile again.
//
// Empty for every other kind, which do not compile.
Language string `json:"language,omitempty"`
// Entrypoints are the compiled files a tool host should load from this module, relative to the
// bundle's root.
//
// **Named rather than derived from which files exist**, for the same reason as the language:
// the module knows what it serves, and a build that guesses would change meaning when
// somebody adds a helper. An empty list is a bundle that is run rather than loaded — a
// provisioner or a step, named by whatever runs it.
Entrypoints []string `json:"entrypoints,omitempty"`
}
// Kinds an artifact may be.
const (
// ArtifactImage is built from a Dockerfile in this repository.
ArtifactImage = "image"
// ArtifactArchive is a directory in this repository, packed.
ArtifactArchive = "archive"
// ArtifactBundle is this module's own code, COMPILED by a toolchain and then packed.
//
// **The one recipe that both builds and packs**, and the reason it exists is the authoring
// burden. An `archive` packs a directory as it stands, so shipping compiled output means
// compiling somewhere first — which means a Dockerfile, repeating the same incantation in
// every module: two base arguments, a working directory chosen so the SDK resolves upward, the
// compiler invoked by absolute path because the usual symlink is resolved away when the base is
// assembled, a second stage, an environment variable naming the entrypoints. Most of the
// catalogue is unconverted and that is why.
//
// A bundle says what the module is written in and nothing about how. The mesh knows what a
// language implies, which is the whole of the difference: a Dockerfile is right for software
// that needs a particular base, and wrong for "compile my module's code", which is the same
// operation every time.
ArtifactBundle = "bundle"
// ArtifactUpstream is an image somebody else built, mirrored into the mesh's own registry and
// pinned by the digest it lands with.
//
// **Because a module usually runs software it did not write.** A database module ships
// configuration and a provisioner and does not build a database. It could name the upstream
// reference directly, and then every machine needs a route to a public registry and the
// reference is a tag somebody else can move — which is what pinning exists to prevent
// (novox/hq ADR 0006).
//
// Mirroring is what the bootstrap already does by hand: the lab stocks upstream images into
// the registry a first node pulls from. This makes that a thing a module can say.
ArtifactUpstream = "upstream"
// ArtifactPackage is this module's own code, compiled and published to the mesh's package
// registry by version, for other modules to consume when they are built — the SDK above all
// (novox/hq ADR 0076). Like a bundle it is built from the module's own directory and names a
// language; unlike a bundle it is not a resource on any machine, it is a build input. It is
// compiled on a PUBLIC base, never the mesh toolchain, because the toolchain is built from it.
ArtifactPackage = "package"
)
// ArtifactStoreProvision is the name a module offers when it is the mesh's store for what modules
// ship — images, and archives, which are directories from a repository packed as blobs.
//
// Named here because a rule depends on it: what provides this cannot be delivered through it
// (novox/hq 04-ISSUES/029). A string compared in one place is a convention; a string a rule turns
// on is a fact, and it should be written once.
const ArtifactStoreProvision = "artifact-store"
// Listening is one port a module accepts connections on.
type Listening struct {
Port int `json:"port"`
// Protocol is "tcp" or "udp". Absent means tcp, which is what almost everything is — and a
// field that had to be written every time would be written wrongly some of the time.
Protocol string `json:"protocol,omitempty"`
// From is who may reach it. Required, because a rule with no source is open and must say so
// rather than appear to restrict something.
From string `json:"from"`
// Fixed means the protocol chose this number, so the machine must use it too.
//
// **The exception, and it is a real one** (novox/hq ADR 0038). Mail is 25, submission is 587,
// IMAP over TLS is 993 — a mail system on a port the mesh picked is a mail system nothing can
// deliver to. Everything else the mesh assigns, because a module cannot know what else is on
// the machine it lands on.
//
// A fixed port is a **claim**: one holder per machine, and the second is refused by name when
// it is assigned rather than by a container runtime when it is applied.
Fixed bool `json:"fixed,omitempty"`
// Why this port is open, for somebody reading a generated rule set and wondering.
Why string `json:"why,omitempty"`
}
// Where a listening port may be reached from.
const (
// FromMesh is any machine on the private network. What almost everything wants.
FromMesh = "mesh"
// FromEverywhere is the public internet. Deliberately spelled out: a port open to everything
// should be legible as such in the manifest, not the consequence of an omission.
FromEverywhere = "anywhere"
// FromMachine is this machine only — a port bound for something else on the same host.
FromMachine = "machine"
)
// At is this port's protocol, with the default applied.
func (l Listening) At() string {
if l.Protocol == "" {
return "tcp"
}
return l.Protocol
}
// Filtering says where a module wants the computed rule set.
type Filtering struct {
// Into is the path to write it to. Whatever loads it is this module's own business — an
// action beside this field, ordinarily — because how a machine enforces rules is a fact about
// the machine and the mesh has no business knowing it.
Into string `json:"into"`
}
// Certificate says where a module wants what the mesh issued for its machine.
type Certificate struct {
// Into is where the certificate is written.
Into string `json:"into"`
// Authority is where the mesh's own certificate is written, so something connecting to this
// machine can be told what to believe. Optional: a module that only serves does not need it.
Authority string `json:"authority,omitempty"`
}
// CertificateID and AuthorityID are the resource identities of what the mesh issued.
func CertificateID() string { return "certificate" }
// ClaimsSeat says whether this manifest claims one named seat.
func (m Manifest) ClaimsSeat(seat string) bool {
for _, c := range m.Claims {
if c.Name == seat {
return true
}
}
return false
}
// BusUsersID names the mesh's composed user list, so it is the same resource across every
// declaration and a change to it is an update rather than a second file beside the old one — which
// on a bus reading a directory would be two account lists, and the server would take both.
func BusUsersID() string { return "bus-users" }
func AuthorityID() string { return "certificate-authority" }
// FilteringID names the computed rule set, so it is the same resource across every declaration
// and a change to it is an update rather than an addition beside the old one.
func FilteringID() string { return "filtering" }
// NeedID is the resource identity of the file a module's own secret lands in.
func NeedID(name string) string { return "needs-" + name }
// SecretID is the resource identity of the file a module is given a credential in.
func SecretID(requirement string) string { return "secret-" + requirement }
// GrantID is the resource identity of one consumer's credential on the providing machine.
func GrantID(provision, consumer string) string { return "grant-" + provision + "-" + consumer }
// BoundID is the resource identity of the file a module is told about a provision in.
func BoundID(requirement string) string { return "bound-" + requirement }
// AccessID is the resource identity of an operator-owned path this module is granted use of.
//
// Derived from the path rather than a name the module chose, so two modules granted the same
// access name the same identity within their own qualification — and neither has to invent a
// label for something that is not theirs.
func AccessID(path string) string { return "access-" + strings.TrimPrefix(path, "/") }
// Wants is everything that must be provided on the same node: what this module requires, and what
// it contributes to.
func (m Manifest) Wants() []string {
out := append([]string{}, m.Requires...)
add := func(to string) {
for _, r := range m.Requires {
if r == to {
return
}
}
for _, already := range out {
if already == to {
return
}
}
out = append(out, to)
}
for to := range m.Contributes {
add(to)
}
for to := range m.ContributesMany {
add(to)
}
sort.Strings(out)
return out
}
// ReceivedID is the resource identity of the file a provider is given its contributions in.
//
// Named rather than positional so a module can point `restart-on` at it: a proxy that got a new
// route and did not reload is a route that silently does not work, which is the same fault the
// overlay had when a peer list changed under a running interface.
func ReceivedID(requirement string) string { return "received-" + requirement }
// ParseManifest reads a module manifest, refusing anything it cannot act on.
//
// Every problem is reported rather than the first, because somebody writing a manifest fixes
// them in one pass or in four.
// manifestFields is Manifest without its methods, so the JSON methods below can use the ordinary
// field decoding for everything but `secrets`.
type manifestFields Manifest
// UnmarshalJSON reads `secrets` in both of its shapes — a path, or an object of local names to
// paths (ADR 0094) — and everything else exactly as the fields declare, unknown keys refused.
func (m *Manifest) UnmarshalJSON(raw []byte) error {
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return err
}
plain := map[string]string{}
many := map[string]map[string]string{}
if secrets, ok := keys["secrets"]; ok && string(secrets) != "null" {
var byName map[string]json.RawMessage
if err := json.Unmarshal(secrets, &byName); err != nil {
return fmt.Errorf("secrets: an object of requirement to path, or to {local name: path}: %w", err)
}
for to, v := range byName {
switch {
case len(v) > 0 && v[0] == '"':
var path string
if err := json.Unmarshal(v, &path); err != nil {
return err
}
plain[to] = path
case len(v) > 0 && v[0] == '{':
var paths map[string]string
if err := json.Unmarshal(v, &paths); err != nil {
return fmt.Errorf("secrets.%s: an object of local name to path: %w", to, err)
}
many[to] = paths
default:
return fmt.Errorf("secrets.%s: a path, or an object of local name to path, not %s", to, v)
}
}
delete(keys, "secrets")
}
contributesPlain := map[string]map[string]any{}
contributesMany := map[string]map[string]map[string]any{}
if contributes, ok := keys["contributes"]; ok && string(contributes) != "null" {
var byTo map[string]json.RawMessage
if err := json.Unmarshal(contributes, &byTo); err != nil {
return fmt.Errorf("contributes: an object of requirement to values, or to {local name: values}: %w", err)
}
for to, v := range byTo {
// Both shapes are JSON objects, unlike secrets' path-vs-object split, so the shapes are
// told apart by what is INSIDE: an ordinary contribution's fields are scalars (a label,
// a port); the several-instance shape is an object of local names, each itself an
// object of fields. Confirmed against the whole catalogue before relying on it — no
// contribution anywhere has an object-valued field.
var fields map[string]json.RawMessage
if err := json.Unmarshal(v, &fields); err != nil {
return fmt.Errorf("contributes.%s: an object of values, or of local name to values: %w", to, err)
}
many := len(fields) > 0
for _, field := range fields {
trimmed := bytes.TrimSpace(field)
if len(trimmed) == 0 || trimmed[0] != '{' {
many = false
break
}
}
if many {
var locals map[string]map[string]any
if err := json.Unmarshal(v, &locals); err != nil {
return fmt.Errorf("contributes.%s: an object of local name to values: %w", to, err)
}
contributesMany[to] = locals
continue
}
var values map[string]any
if err := json.Unmarshal(v, &values); err != nil {
return fmt.Errorf("contributes.%s: an object of values: %w", to, err)
}
contributesPlain[to] = values
}
delete(keys, "contributes")
}
rest, err := json.Marshal(keys)
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(rest))
decoder.DisallowUnknownFields()
var fields manifestFields
if err := decoder.Decode(&fields); err != nil {
return err
}
*m = Manifest(fields)
if len(plain) > 0 {
m.Secrets = plain
}
if len(many) > 0 {
m.SecretsMany = many
}
if len(contributesPlain) > 0 {
m.Contributes = contributesPlain
}
if len(contributesMany) > 0 {
m.ContributesMany = contributesMany
}
return nil
}
// MarshalJSON writes `secrets` and `contributes` back in the shape they were read: single values,
// and objects of local names.
func (m Manifest) MarshalJSON() ([]byte, error) {
raw, err := json.Marshal(manifestFields(m))
if err != nil {
return nil, err
}
if len(m.SecretsMany) == 0 && len(m.ContributesMany) == 0 {
return raw, nil
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return nil, err
}
if len(m.ContributesMany) > 0 {
mergedContributes := map[string]any{}
for to, values := range m.Contributes {
mergedContributes[to] = values
}
for to, locals := range m.ContributesMany {
mergedContributes[to] = locals
}
contributes, err := json.Marshal(mergedContributes)
if err != nil {
return nil, err
}
keys["contributes"] = contributes
}
if len(m.SecretsMany) == 0 {
return json.Marshal(keys)
}
merged := map[string]any{}
for to, path := range m.Secrets {
merged[to] = path
}
for to, paths := range m.SecretsMany {
merged[to] = paths
}
secrets, err := json.Marshal(merged)
if err != nil {
return nil, err
}
keys["secrets"] = secrets
return json.Marshal(keys)
}
// SecretFile is one file a module is given a credential in: the local name it goes by inside
// the module (empty for the ordinary one-file case, where the requirement's name serves) and where.
type SecretFile struct {
Local string
Path string
}
// SecretFiles is every file a module wants the credential for one requirement in, in a stable
// order: the plain path as one entry with no local name, or one entry per local name.
func (m Manifest) SecretFiles(to string) []SecretFile {
if path, ok := m.Secrets[to]; ok {
return []SecretFile{{Path: path}}
}
paths := m.SecretsMany[to]
out := make([]SecretFile, 0, len(paths))
for _, local := range sortedKeys(paths) {
out = append(out, SecretFile{Local: local, Path: paths[local]})
}
return out
}
// SecretRequirements is every requirement this module wants a credential file for, sorted.
func (m Manifest) SecretRequirements() []string {
seen := map[string]bool{}
for to := range m.Secrets {
seen[to] = true
}
for to := range m.SecretsMany {
seen[to] = true
}
return sortedKeys(seen)
}
// SecretLocal is the name a credential goes by inside the module: the local name where the
// requirement maps to several, else the requirement itself. It is what `${secret:<name>}` says.
func SecretLocal(to, local string) string {
if local == "" {
return to
}
return local
}
func ParseManifest(raw []byte) (Manifest, error) {
var m Manifest
// Strictly. **An unknown key is refused**, which is the discipline the host's declaration
// parser has and manifests lacked (novox/hq 04-ISSUES/003): a `scope:` key survived in five
// manifests, read by nothing, making them appear to restrict a port and restrict nothing.
//
// "An unenforced rule is indistinguishable from a wrong one, and costs more, because people
// believe it" — and a silently-accepted key is worse than unenforced, because a reviewer
// checking whether something is restricted will find that it is, and be wrong.
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&m); err != nil {
// A key that used to mean something says what it became. Refusing a renamed field with
// "unknown field" is correct and unhelpful: whoever wrote it knew what they meant, and
// the mesh knows what it is called now.
for was, is := range renamed {
if strings.Contains(err.Error(), `"`+was+`"`) {
return Manifest{}, fmt.Errorf(
"this manifest says %q, which is now called %q: %w", was, is, err)
}
}
return Manifest{}, fmt.Errorf("this is not a module manifest: %w", err)
}
var problems []string
if !name.MatchString(m.Module) {
problems = append(problems, fmt.Sprintf(
"%q is not a usable module name: lower-case letters, digits, dashes and dots", m.Module))
}
// A slug is a short identifier the mesh derives a login from (novox/hq ADR 0049). The same
// charset as a name; its length is checked against a backend's limit at assignment, where the
// node it joins is known — a slug that is fine on one machine's short name can overflow another's.
if m.Slug != "" && !name.MatchString(m.Slug) {
problems = append(problems, fmt.Sprintf(
"%q is not a usable slug: lower-case letters, digits, dashes and dots", m.Slug))
}
for _, offer := range m.Provides {
p := offer.Name
if !name.MatchString(p) {
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
}
if instead, generic := engineGeneric[p]; generic {
// A consumer is written against an engine, not a role (novox/hq ADR 0027). Providing
// the role means a requirement for it matches any engine, resolves as satisfied, and
// fails on the first query — with nothing pointing back at the match.
problems = append(problems, fmt.Sprintf(
"%s provides %q, which hides which engine it is: a requirement for %q would match "+
"any of them and fail on the first query. Name the engine — %s",
m.Module, p, p, instead))
}
if s := offer.At(); s != ScopeNode && s != ScopeMesh {
// Site scope is meaningful for a claim — one DHCP server per segment — and is not
// yet meaningful for a provision, because nothing knows how to reach "the one at my
// site". Refused rather than silently treated as mesh-wide.
problems = append(problems, fmt.Sprintf(
"%s provides %q at scope %q; a provision is %q or %q",
m.Module, p, s, ScopeNode, ScopeMesh))
}
if p == m.Module {
// Harmless and worth saying: a module always provides its own name, so writing it
// suggests the author expected it not to.
problems = append(problems, fmt.Sprintf(
"%s provides its own name already; listing it says nothing", m.Module))
}
}
for _, r := range m.Requires {
if !name.MatchString(r) {
problems = append(problems, fmt.Sprintf("%q is not a usable name to require", r))
}
if r == m.Module {
problems = append(problems, fmt.Sprintf("%s requires itself", m.Module))
}
}
wellFormed := true
for _, c := range m.Claims {
if !name.MatchString(c.Name) {
problems = append(problems, fmt.Sprintf("%q is not a usable claim name", c.Name))
wellFormed = false
}
switch c.At() {
case ScopeNode, ScopeSite, ScopeMesh:
default:
problems = append(problems, fmt.Sprintf(
"%s claims %s at scope %q; a claim is held per node, per site or per mesh",
m.Module, c.Name, c.Scope))
wellFormed = false
}
}
// Against the seats the mesh defines (novox/hq ADR 0110), once every claim is at least a name
// and a scope — a malformed claim is refused for that, not a second time for being unknown.
if wellFormed {
problems = append(problems, claimProblems(m)...)
}
// What one manifest can be judged on: a declaration's shape, its scope, and the reserved
// prefix. Whether a seat anybody names exists, and whether a holder answers for it, are
// facts about the catalogue and are checked at registration (CatalogueProblems).
problems = append(problems, declaredSeatProblems(m)...)
if m.Computed != "" && len(m.Resources) > 0 {
// One or the other. A module that both ships files and has them computed would leave
// nobody able to say where a given file came from.
problems = append(problems, fmt.Sprintf(
"%s has resources of its own and says they are computed by %q; it is one or the other",
m.Module, m.Computed))
}
for to, values := range m.Contributes {
if !name.MatchString(to) {
problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to))
}
if len(values) == 0 {
// An empty contribution is either a mistake or a requirement written the long way
// round, and both are better said plainly.
problems = append(problems, fmt.Sprintf(
"%s contributes nothing to %q; if it only needs one, require it", m.Module, to))
}
}
for to, locals := range m.ContributesMany {
if !name.MatchString(to) {
problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to))
}
if len(locals) == 0 {
problems = append(problems, fmt.Sprintf(
"%s contributes nothing to %q; if it only needs one, require it", m.Module, to))
}
for local, values := range locals {
if !name.MatchString(local) {
problems = append(problems, fmt.Sprintf(
"%s contributes to %q under %q, which is not a usable name", m.Module, to, local))
}
if len(values) == 0 {
problems = append(problems, fmt.Sprintf(
"%s contributes nothing to %q under %q", m.Module, to, local))
}
}
}
problems = append(problems, m.Build.problems(m.Module)...)
// **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029).
//
// Building publishes to the store, and the builder will not start without one. So a module
// that provides the store and also builds something asks the mesh to put an artifact into the
// thing that artifact is needed to create.
//
// It is the question the foundation record asks of every candidate — can it grant itself the
// thing it provides? The store cannot create its own database, the broker cannot create its
// own virtual host, and a registry cannot grant itself a repository. Such a module names its
// image, exactly as the bundle names the three a first node starts from.
//
// Refused here because the alternative is a build that never returns, on a mesh new enough
// that nobody is watching it yet.
if m.Build != nil && len(m.Build.Artifacts) > 0 {
for _, o := range m.Offers() {
if o != ArtifactStoreProvision {
continue
}
problems = append(problems, fmt.Sprintf(
"%s provides %q and also builds %d artifact(s), which cannot both be true: "+
"building publishes to the artifact store, so this asks the mesh to put an "+
"artifact into the thing that artifact is needed to create. A module that "+
"provides the store names its image instead. One that wants more beside it "+
"— an interface, a tool server — is a second module, mirrored in the "+
"ordinary way once this one is running",
m.Module, ArtifactStoreProvision, len(m.Build.Artifacts)))
}
}
for to := range m.Serves {
var offered bool
for _, o := range m.Offers() {
if o == to {
offered = true
}
}
if !offered {
problems = append(problems, fmt.Sprintf(
"%s serves %q to whoever requires it, and does not provide it", m.Module, to))
}
}
for to, where := range m.Binds {
if !placedOrAbsolute(where) {
problems = append(problems, fmt.Sprintf(
"%s binds %q at %q, which is neither an absolute path nor a placed one", m.Module, to, where))
}
var wanted bool
for _, w := range m.Wants() {
if w == to {
wanted = true
}
}
if !wanted {
// Being told about something you never asked for would write a file describing a
// machine this one has no business talking to.
problems = append(problems, fmt.Sprintf(
"%s binds %q and does not require it", m.Module, to))
}
}
if f := m.Filtering; f != nil && strings.TrimSpace(f.Into) == "" {
problems = append(problems, fmt.Sprintf(
"%s asks for the computed rule set and does not say where to put it", m.Module))
}
for _, l := range m.Listens {
if l.Port < 1 || l.Port > 65535 {
problems = append(problems, fmt.Sprintf(
"%s listens on port %d, which is not a port", m.Module, l.Port))
}
switch l.From {
case FromMesh, FromEverywhere, FromMachine:
case "":
// The fault this field exists to prevent. A rule with no source is open, and a
// manifest that omitted it would read as a restriction and be none.
problems = append(problems, fmt.Sprintf(
"%s listens on %d and does not say from where; it is %q, %q or %q",
m.Module, l.Port, FromMesh, FromEverywhere, FromMachine))
default:
problems = append(problems, fmt.Sprintf(
"%s listens on %d from %q; it is %q, %q or %q",
m.Module, l.Port, l.From, FromMesh, FromEverywhere, FromMachine))
}
if p := l.At(); p != "tcp" && p != "udp" {
problems = append(problems, fmt.Sprintf(
"%s listens on %d over %q, which is tcp or udp", m.Module, l.Port, p))
}
}
for _, port := range m.Guards {
if port < 1 || port > 65535 {
problems = append(problems, fmt.Sprintf(
"%s guards port %d, which is not a port", m.Module, port))
}
}
if c := m.Certificate; c != nil {
if !strings.HasPrefix(c.Into, "/") {
problems = append(problems, fmt.Sprintf(
"%s wants its certificate at %q, which is not an absolute path", m.Module, c.Into))
}
if c.Authority != "" && !strings.HasPrefix(c.Authority, "/") {
problems = append(problems, fmt.Sprintf(
"%s wants the authority at %q, which is not an absolute path",
m.Module, c.Authority))
}
}
// **A module may not declare an action, and this is where it is said** (novox/hq ADR 0005).
//
// The host already refuses one, correctly and for the right reason: the link may not carry a
// command to run, and that bound is what limits a compromised control plane to shapes it
// cannot turn into arbitrary code. But a module's resources reach a machine over the link, so
// a manifest carrying an action was accepted here, stored, resolved, planned and pushed — and
// refused on the machine, in the host's log, with nothing connecting it back to the manifest
// that caused it.
//
// That is the same failure as the network shape earlier today: the refusal was right, arrived
// far from its cause, and nobody was reading the log. A rule enforced only at the far end is
// enforced; it is just not usable.
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "action" {
continue
}
problems = append(problems, fmt.Sprintf(
"%s declares %v, which is an action, and a module may not: the link may not carry a "+
"command to run (novox/hq ADR 0005). A module that needs something done ships a "+
"program that reads what the mesh delivered and reconciles",
m.Module, r["id"]))
}
// **A run-once container is a step the host runs to completion** (novox/hq ADR 0052). It is a
// boolean modifier on the container shape — the host runs the container, requires it to exit 0,
// and starts whatever the declaration places after it only once it has. A value that is not a
// boolean is refused here rather than only on the machine, for the same near-versus-far reason
// the action ban above records. A run-once step may name what it reads under restart-on: for a
// step the word means *run again* when one of those changed, which is how a fact fetched from a
// provider is fetched again when the provider moved (novox/hq ADR 0099).
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "container" {
continue
}
var runOnce bool
if raw, present := r["run-once"]; present {
once, ok := raw.(bool)
if !ok {
problems = append(problems, fmt.Sprintf(
"%s declares run-once on %v as a %T; run-once is true or false",
m.Module, r["id"], raw))
} else {
runOnce = once
}
}
// **A scheduled container runs on a recurring cadence** (novox/hq ADR 0053), the recurring
// twin of run-once. The control plane carries the field to the host unchanged (the resolver
// copies every key of a resource, so `schedule` reaches the rendered declaration on its own);
// what belongs here is refusing, near its author, a value the host would only refuse far away.
// Three things: a value that is not a string, a string that is not a valid cron, and the pair
// run-once + schedule — a container runs once, or on a cadence, or stays up, never two.
if raw, present := r["schedule"]; present {
cron, ok := raw.(string)
switch {
case !ok:
problems = append(problems, fmt.Sprintf(
"%s declares schedule on %v as a %T; a schedule is a five-field cron string",
m.Module, r["id"], raw))
default:
if err := validateCron(cron); err != nil {
problems = append(problems, fmt.Sprintf(
"%s declares schedule %q on %v, which is not a valid cron: %v",
m.Module, cron, r["id"], err))
}
if runOnce {
problems = append(problems, fmt.Sprintf(
"%s declares %v as both run-once and schedule; a container runs once and "+
"gates, or on a cadence, or stays up — never two (novox/hq ADR 0053)",
m.Module, r["id"]))
}
}
}
}
for name, where := range m.OwnSecrets {
if !placedOrAbsolute(where) {
problems = append(problems, fmt.Sprintf(
"%s needs %q at %q, which is neither an absolute path nor a placed one", m.Module, name, where))
}
if name == "" {
problems = append(problems, m.Module+" needs a secret with no name")
}
}
localOf := map[string]string{}
for _, to := range m.SecretRequirements() {
if _, plain := m.Secrets[to]; plain {
if _, also := m.SecretsMany[to]; also {
problems = append(problems, fmt.Sprintf(
"%s keeps the credential for %q both as one file and as several", m.Module, to))
}
}
for _, f := range m.SecretFiles(to) {
if !placedOrAbsolute(f.Path) {
problems = append(problems, fmt.Sprintf(
"%s keeps the credential for %q at %q, which is neither an absolute path nor a placed one",
m.Module, SecretLocal(to, f.Local), f.Path))
}
if f.Local != "" && !name.MatchString(f.Local) {
problems = append(problems, fmt.Sprintf(
"%s keeps a credential for %q under %q, which is not a usable name",
m.Module, to, f.Local))
}
// A local name is what `${secret:<name>}` says, so it may not be another requirement's
// name, another requirement's local name, or one of the module's own secrets — the
// file would hold the wrong credential while every check passed.
if f.Local != "" {
if other, taken := localOf[f.Local]; taken && other != to {
problems = append(problems, fmt.Sprintf(
"%s keeps credentials for %q and %q both under %q — a local name names one",
m.Module, other, to, f.Local))
}
localOf[f.Local] = to
if _, own := m.OwnSecrets[f.Local]; own {
problems = append(problems, fmt.Sprintf(
"%s keeps a credential for %q under %q, which is also one of its own secrets",
m.Module, to, f.Local))
}
for _, w := range m.Wants() {
if w == f.Local {
problems = append(problems, fmt.Sprintf(
"%s keeps a credential for %q under %q, which is also something it requires",
m.Module, to, f.Local))
}
}
}
}
if len(m.SecretsMany[to]) == 0 && m.Secrets[to] == "" {
if _, many := m.SecretsMany[to]; many {
problems = append(problems, fmt.Sprintf(
"%s keeps the credential for %q as several files and names none", m.Module, to))
}
}
var wanted bool
for _, w := range m.Wants() {
if w == to {
wanted = true
}
}
if !wanted {
problems = append(problems, fmt.Sprintf(
"%s wants the credential for %q and does not require it", m.Module, to))
}
}
for to, where := range m.Grants {
if !placedOrAbsolute(where) {
problems = append(problems, fmt.Sprintf(
"%s grants %q into %q, which is neither an absolute path nor a placed one", m.Module, to, where))
}
var offered bool
for _, o := range m.Offers() {
if o == to {
offered = true
}
}
if !offered {
problems = append(problems, fmt.Sprintf(
"%s grants %q to its consumers and does not provide it", m.Module, to))
}
}
if m.Keeps != "" && !strings.HasPrefix(m.Keeps, "/") {
problems = append(problems, fmt.Sprintf(
"%s keeps the operator-sealed secrets at %q, which is not an absolute path", m.Module, m.Keeps))
}
for to, where := range m.Receives {
if !name.MatchString(to) {
problems = append(problems, fmt.Sprintf("%q is not a usable name to receive", to))
}
if !placedOrAbsolute(where) {
problems = append(problems, fmt.Sprintf(
"%s receives %q at %q, which is neither an absolute path nor a placed one", m.Module, to, where))
}
var offered bool
for _, o := range m.Offers() {
if o == to {
offered = true
}
}
if !offered {
// Receiving contributions to something you do not provide would create a file nobody
// ever writes to, on a machine where nothing asked for it.
problems = append(problems, fmt.Sprintf(
"%s receives contributions to %q and does not provide it", m.Module, to))
}
}
for _, a := range m.Accesses {
if !strings.HasPrefix(a.Path, "/") {
problems = append(problems, fmt.Sprintf(
"%s accesses %q, which is not an absolute path", m.Module, a.Path))
}
switch a.At() {
case AccessRead, AccessReadWrite:
default:
problems = append(problems, fmt.Sprintf(
"%s accesses %q at mode %q; an access is %q or %q",
m.Module, a.Path, a.Mode, AccessRead, AccessReadWrite))
}
}
// **A container may not mount a path the module never declared** (novox/hq 04-ISSUES/026,
// ADR 0091). A bind mount whose source does not exist is created by the container runtime, as
// root, with whatever mode it picks — so `owner` and `mode` never reach the directory holding
// the module's data, and the rule that keeps data when a module goes away (ADR 0030) does not
// cover it, because the mesh has never heard of it.
//
// Three things declare a path, and they are the three kinds of thing a path can be:
// - the module's own: a directory or file resource, or where a secret, a grant or a
// contribution lands — created and owned by the mesh for this module;
// - the operator's: an `accesses` entry (ADR 0051) — pre-existing, shared, granted for use;
// - the machine's: a facility a declared capability grants, such as the container runtime's
// socket. It exists, the machine owns it, and declaring it as the module's own directory
// would be a lie the host would act on — which is why the first version of this check was
// withdrawn: it refused the builder.
//
// Checked here rather than on the machine because the machine cannot tell the difference: by
// the time it sees the mount it is being asked to create the directory, which it can do.
problems = append(problems, m.undeclaredMounts()...)
problems = append(problems, m.unknownDirRefs()...)
for i, r := range m.Resources {
id, _ := r["id"].(string)
if id == "" {
problems = append(problems, fmt.Sprintf("resource %d has no id", i))
}
if _, ok := r["type"].(string); !ok {
problems = append(problems, fmt.Sprintf("resource %q has no type", id))
}
}
if len(problems) > 0 {
sort.Strings(problems)
return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s",
strings.Join(problems, "\n - "))
}
return m, nil
}
// Offers is everything this module can satisfy: its own name, and what it provides.
func (m Manifest) Offers() []string {
out := []string{m.Module}
for _, p := range m.Provides {
out = append(out, p.Name)
}
sort.Strings(out)
return out
}
// OffersAt is what this module provides at one scope, with its own name counted as node-scoped:
// a module is only ever itself on the machine it is installed on.
func (m Manifest) OffersAt(scope string) []string {
var out []string
if scope == ScopeNode {
out = append(out, m.Module)
}
for _, p := range m.Provides {
if p.At() == scope {
out = append(out, p.Name)
}
}
sort.Strings(out)
return out
}
// MachineSide says where a module's declared port reaches this machine, and whether the mesh is
// free to choose it.
//
// **The mesh may only move a port it actually publishes** (novox/hq ADR 0038). A container's
// mapping is the thing that translates, so where there is one the mesh can put the machine side
// anywhere it likes. Where there is not, the software binds what it binds: assigning a port then
// does not move the service, it just opens the wrong number in the rule set and leaves the real
// one shut — a firewall that reports success and blocks the thing it was asked to admit.
//
// Three cases, and only the first belongs to the mesh:
//
// - a container publishes it in short form — the mesh chooses
// - a container publishes it as host:container — the manifest already chose
// - nothing publishes it — whatever binds it, binds it
//
// Either side of a long mapping counts as naming it, and the host side is what comes back. A
// module may reasonably read `listens` as the port its software uses or as the port the machine
// exposes, and both readings have the same right answer here.
func (m Manifest) MachineSide(port int) (at int, mayAssign bool) {
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "container" {
continue
}
listed, ok := r["ports"].([]any)
if !ok {
continue
}
for _, entry := range listed {
written := strings.TrimSpace(fmt.Sprint(entry))
// "8080", "8080:80", or "127.0.0.1:8080:80" when an address was named — the machine
// side is always the second-from-last part, the same reading the host applies. The
// first shape said only the software's port, so the mesh may choose; the others chose.
parts := strings.Split(written, ":")
if len(parts) == 1 {
if n, err := strconv.Atoi(written); err == nil && n == port {
return port, true
}
continue
}
outer, err := strconv.Atoi(strings.TrimSpace(parts[len(parts)-2]))
if err != nil {
continue
}
inner, err := strconv.Atoi(strings.TrimSpace(parts[len(parts)-1]))
if err == nil && (outer == port || inner == port) {
return outer, false
}
}
}
return port, false
}
// facilitiesOf is what each capability lets a container mount: paths the machine owns and a module
// is granted the use of by declaring the capability, never by declaring them as its own.
var facilitiesOf = map[string][]string{
// Both spellings: /var/run is a link to /run on every machine the mesh runs on.
"container-runtime": {"/var/run/docker.sock", "/run/docker.sock"},
}
// undeclaredMounts is every bind-mount source no declaration covers — see the check above.
func (m Manifest) undeclaredMounts() []string {
declared := map[string]bool{}
claim := func(p string) {
if strings.HasPrefix(p, "/") {
declared[strings.TrimRight(p, "/")] = true
}
}
for _, r := range m.Resources {
switch fmt.Sprint(r["type"]) {
case "directory", "file":
claim(fmt.Sprint(r["path"]))
}
}
for _, where := range m.OwnSecrets {
claim(where)
}
for _, to := range m.SecretRequirements() {
for _, f := range m.SecretFiles(to) {
claim(f.Path)
}
}
for _, where := range m.Receives {
claim(where)
}
for _, where := range m.Grants {
claim(where)
}
for _, where := range m.Binds {
claim(where)
}
for _, a := range m.Accesses {
claim(a.Path)
}
for _, c := range m.Capabilities {
for _, p := range facilitiesOf[c] {
claim(p)
}
}
// Under a declared directory is declared: a module that says where its data lives has said so
// for what it puts inside.
covers := func(path string) bool {
for at := path; strings.HasPrefix(at, "/"); {
if declared[at] {
return true
}
cut := strings.LastIndex(at, "/")
if cut <= 0 {
return false
}
at = at[:cut]
}
return false
}
var problems []string
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "container" {
continue
}
mounts, _ := r["volumes"].([]any)
for _, v := range mounts {
from, _, _ := strings.Cut(fmt.Sprint(v), ":")
if !strings.HasPrefix(from, "/") || covers(strings.TrimRight(from, "/")) {
continue
}
problems = append(problems, fmt.Sprintf(
"%s mounts %q into %v, and nothing in %s declares it. A bind mount the module did "+
"not declare is created by the container runtime as root, so the module's own "+
"owner and mode do not reach the directory that holds its data, and the rule "+
"that keeps data when a module goes away (novox/hq ADR 0030) does not cover it. "+
"Declare it: a directory resource if it is the module's, an `accesses` entry if "+
"it is the operator's, or the capability that grants it if it is the machine's",
m.Module, from, r["id"], m.Module))
}
}
return problems
}