// 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__` 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 events this module publishes, named **locally**: `order.placed`, not a subject // and not a routing key. The mesh derives where it lands (design 29 §1), so reorganising the // subject space leaves this manifest correct. Events are provisioning's lighter sibling — 1:many // and broadcast, no credential (novox/hq ADR 0041). // // A module publishes under its own name only. If the event is about a *role* rather than about // this module, it belongs on that seat, where the name outlives whoever holds it. // // **This said "dotted topic keys, e.g. module.umami.site.created" until 04-ISSUES/127**, which // is the old bus's routing key, and is why every manifest in the catalogue had the same mistake: // nobody was guessing, everybody followed this comment. Emits []string `json:"emits,omitempty"` // Consumes are the events this module reacts to, each naming its emitter and the event: // `billing.order.placed`. `*` stands for one name and `**` for the rest, so `*.download.completed` // is that event from any module and `**` is every event in the mesh. // // Spelled the mesh's way rather than the wire's, for the reason Emits is: the bus the mesh runs // on today spells these `*` and `#`, the one being built spells them `*` and `>`, and a manifest // naming either would stop being true when the wire changed. // // 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"` // DefinesSeats are the seats this module defines for itself, with their protocols // (novox/hq ADR 0121, ADR 0129). The control plane defines the system seats — `mesh-*` and // `node-*` — and a module may define its own, named outside that namespace, to coordinate its // own instances: the mesh enforces one-holder-per-scope for it without knowing what it means. A // module's declared seat is the only non-system name it may then claim; a claim to a name // neither the mesh nor the module defines is refused. // // **Two lines of work built this at once**, one calling it `Seats` with a protocol and one // `DefinesSeats` without. Same key in the file, so no manifest is affected: this is the trunk's // name with the richer type, because what a role accepts, emits and serves is what lets the mesh // check that a holder answers what its seat promises. DefinesSeats []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"` // Jails are the fail2ban jails this module declares for its own service (novox/hq to-be 31). // Written into whichever node runs the module, the same way `listens` become that node's rules. Jails []Jail `json:"jails,omitempty"` // Jailing marks the module that composes the node's fail2ban jails — the intrusion-prevention // holder. Like Filtering: one module per node gathers what every module declared and writes it. Jailing *Jailing `json:"jailing,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 — 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 name that // resolves, a peer that is reachable, a host a client trusts, is somebody's software — dnsmasq, // a resolver, a VPN, ssh — in its own configuration language, and the mesh has no business // knowing it. So a module gives a path and a template; the mesh renders the roster through it // and owns nothing of what the file says. // // This used to be a closed list of fact names, each formatted in Go in the control plane, so a // new consumer meant a new formatter here in the consumer's language. Now the data is the mesh's // and the format is the module's: the two built-in cases — the network module's `/etc/hosts` and // dnsmasq's zones — render through the same template path any module uses, and no format lives // in the control plane at all. See RosterFile for what a template sees. // // 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 a name the module chooses, which is the rendered file's id (`fact-`) — what a // `restart-on` names to restart when the roster changes. Facts map[string]RosterFile `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 } // Jail is a fail2ban jail a module declares for its own service (novox/hq to-be 31). // // **The module names no node and no path** (ADR 0112): it says what a break-in on its service looks // like — the failregex — and the jail's own keys (the port it watches, where it logs, how many // tries, how long to ban). The mesh writes it into whichever node's fail2ban runs the module, the // same way a module's `listens` become that node's firewall rules. A node not running the module // has no such jail. type Jail struct { // Name is the jail and its filter, e.g. "postgres-auth". One holder of the name per node. Name string `json:"name"` // Failregex is what a failed authentication looks like in the service's log — the filter. Failregex string `json:"failregex"` // Jail is the body of the jail's stanza: the keys under [] the module knows and the mesh // does not — the port it watches, its logpath and backend, maxretry, bantime. Jail string `json:"jail"` } // Jailing says a module composes the node's fail2ban jails — the intrusion-prevention holder. Like // Filtering for the firewall: one module gathers what every other module declared and writes it // where it owns. Into is the one jail file the stanzas are composed into (so the fail2ban service // can restart on a single resource); FilterInto is the directory each jail's filter file goes in. type Jailing struct { Into string `json:"into"` FilterInto string `json:"filter-into"` } // ComposedJailsID is the single jail file the mesh composes every declared jail into, so the // fail2ban service names one resource in its restart-on and a jail added or removed reaches it. func ComposedJailsID() string { return "composed-jails" } // 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:}` 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)) } } // What it may call an event, and what it may ask to hear (events.go). Checked here because a // module whose event names are wrong installs, starts, connects and reacts to nothing, with // every log line saying it is fine (novox/hq 04-ISSUES/127). problems = append(problems, EventProblems(m)...) 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:}` 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 }