828 lines
36 KiB
Go
828 lines
36 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
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// 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"`
|
|
|
|
// 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"`
|
|
|
|
// 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"`
|
|
|
|
// 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"`
|
|
|
|
// 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"`
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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"
|
|
// 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"
|
|
)
|
|
|
|
// 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" }
|
|
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 }
|
|
|
|
// 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...)
|
|
for to := range m.Contributes {
|
|
var already bool
|
|
for _, r := range m.Requires {
|
|
if r == to {
|
|
already = true
|
|
}
|
|
}
|
|
if !already {
|
|
out = append(out, 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.
|
|
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))
|
|
}
|
|
}
|
|
for _, c := range m.Claims {
|
|
if !name.MatchString(c.Name) {
|
|
problems = append(problems, fmt.Sprintf("%q is not a usable claim name", c.Name))
|
|
}
|
|
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))
|
|
}
|
|
}
|
|
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))
|
|
}
|
|
}
|
|
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 substrate 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 !strings.HasPrefix(where, "/") {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s binds %q at %q, which is not an absolute path", 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))
|
|
}
|
|
}
|
|
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"]))
|
|
}
|
|
for name, where := range m.OwnSecrets {
|
|
if !strings.HasPrefix(where, "/") {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s needs %q at %q, which is not an absolute path", m.Module, name, where))
|
|
}
|
|
if name == "" {
|
|
problems = append(problems, m.Module+" needs a secret with no name")
|
|
}
|
|
}
|
|
for to, where := range m.Secrets {
|
|
if !strings.HasPrefix(where, "/") {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s keeps the credential for %q at %q, which is not an absolute path",
|
|
m.Module, to, where))
|
|
}
|
|
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 !strings.HasPrefix(where, "/") {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s grants %q into %q, which is not an absolute path", 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))
|
|
}
|
|
}
|
|
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 !strings.HasPrefix(where, "/") {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s receives %q at %q, which is not an absolute path", 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 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
|
|
}
|