Three faults, one file split. All from reading, all verified to bite. Unassign now releases the module's ports. ReleasePorts existed, said "for when it is unassigned" in its own comment, and was called by nothing — so a fixed port stayed claimed in the name of a module that was gone, and the next module needing it was refused by a ghost. Kept-once-chosen is a promise about a module that is still here. MachineSide reads addressed mappings. "127.0.0.1:8080:80" was split at the first colon, "127.0.0.1" failed to parse as a port, and the mapping was silently skipped — putting the filter back on the declared port, the exact fault the function was written to end. The machine side is the second-from-last part, which is the reading the host already applies, and the substrate bundle writes that shape today. An allocation race answers in the mesh's words. Two concurrent picks of the same port used to surface as a Postgres constraint violation, verbatim. The table has two keys, so the collision is one of two facts: the racer was this same assignment — then its answer is the answer, kept-once-chosen does not care who chose — or another module took the machine port, and an unfixed pick is simply made again against the moved free list. A fixed port that lost the race is refused by name. Told apart by re-reading the row, not by the constraint's name, so this does not couple to the migration's spelling. And the artifact-store cycle tests moved to bootstrap_cycle_test.go; machineside_test.go had quietly become three subjects.
803 lines
34 KiB
Go
803 lines
34 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"`
|
|
|
|
// 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"`
|
|
|
|
// 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))
|
|
}
|
|
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
|
|
}
|