The bundle recipe: the one that both builds and packs. An archive packs a directory as it stands, so shipping compiled output meant compiling somewhere first — which meant a Dockerfile repeating the same incantation in every module. Two base arguments with no defaults, 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 image is assembled, a second stage, an environment variable naming the entrypoints. Most of the catalogue is unconverted and that is why; two conversions done in one session were each wrong twice with a working example open in the next window. A bundle says a language and a list of entrypoints. The mesh knows what the language implies. Anything a module could override there it would be writing a Dockerfile to override, so a toolchain is deliberately not configurable. Declared rather than inferred, both of them: guessing the language from which files are present makes a build depend on a directory listing, and guessing the entrypoints makes it change meaning when somebody adds a helper. A toolchain the mesh does not hold is refused before anything is compiled, naming what to build first — the same treatment a missing base already gets, because it is the same question and somebody can answer it. A language the mesh does not build is refused saying what would have worked, since the author is usually one word away. The list of languages is closed and adding to it is a decision. Every language is another implementation of the contracts every module shares, and those change rarely and cascade when they do (ADR 0039) — a mesh whose SDKs disagree about the envelope fails by ignoring messages rather than by failing to compile. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
1025 lines
46 KiB
Go
1025 lines
46 KiB
Go
// Package catalogue is what modules are, and what a node gets when it is assigned some.
|
|
//
|
|
// novox/hq ADR 0009: everything is a module, a module declares what it provides and requires,
|
|
// and a module declares what it claims. This turns a set of assignments into the one declaration
|
|
// a node is sent — which is the first thing the control plane decides rather than relays.
|
|
package catalogue
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Scopes a claim can have.
|
|
//
|
|
// Not everything singular is singular per machine: a seat is one per node, a DHCP server is one
|
|
// per segment, and the hub is one per mesh. Scope says which, and it is the same idea the mesh
|
|
// already enforces by hand for the hub.
|
|
const (
|
|
ScopeNode = "node"
|
|
ScopeSite = "site"
|
|
ScopeMesh = "mesh"
|
|
)
|
|
|
|
// name is what a module, a provision or a claim may be called.
|
|
//
|
|
// Constrained because these become resource identities, permission patterns and error messages,
|
|
// and a name that is valid in one and not the others is a fault found late.
|
|
// renamed is what a field used to be called, and what it is now.
|
|
//
|
|
// Kept rather than dropped once the rename is done: a manifest written against the old name is
|
|
// refused either way, and the difference is whether whoever wrote it has to go and find out why.
|
|
var renamed = map[string]string{
|
|
// `needs` and `secrets` were both name-to-path and differed only in whose secret it was, so
|
|
// reaching for the wrong one parsed cleanly and failed somewhere else entirely.
|
|
"needs": "own-secrets",
|
|
}
|
|
|
|
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$`)
|
|
|
|
// Claim is a singular resource a module takes over.
|
|
type Claim struct {
|
|
Name string `json:"name"`
|
|
// Scope defaults to the node, which is where nearly everything singular is singular.
|
|
Scope string `json:"scope,omitempty"`
|
|
}
|
|
|
|
// At is this claim's scope, with the default applied.
|
|
func (c Claim) At() string {
|
|
if c.Scope == "" {
|
|
return ScopeNode
|
|
}
|
|
return c.Scope
|
|
}
|
|
|
|
// Modes an access may be granted at.
|
|
//
|
|
// **The grant states its extent, the way a listening port states its source** (the rule the
|
|
// manifest already keeps for `listens.from`). Absent narrows to read — the safe default, because
|
|
// the danger with an access is being given more than was meant, not less.
|
|
const (
|
|
AccessRead = "read"
|
|
AccessReadWrite = "read-write"
|
|
)
|
|
|
|
// Access is a pre-existing path on the machine that this module is GRANTED USE OF, and does not
|
|
// own.
|
|
//
|
|
// **The distinction this exists for** (novox/hq ADR 0051, 04-ISSUES/036): a `directory` resource
|
|
// is a thing the mesh owns — it creates it, sets its owner and mode, and removes it when it is
|
|
// empty and no longer declared ([ADR 0030](novox/hq)). Shared, pre-existing data is none of that.
|
|
// A media library, a download spool, an ingest folder is the **operator's**: it existed before the
|
|
// mesh, several modules read and write it at once, and the mesh must not create, chown, reconcile
|
|
// or remove it. It mounts it and owns nothing about it.
|
|
//
|
|
// Written as its own field rather than a flag on a directory because the two are opposite on every
|
|
// axis the host acts on, and 04-ISSUES/026 records what happens when *the directory my data lives
|
|
// in* and *a facility I was granted* are spelled the same: the second gets created as root and the
|
|
// ownership fields silently do not apply. Two modules may name the same access with no conflict —
|
|
// that is the whole point of it — whereas two owning one path is the fault the resolver refuses.
|
|
//
|
|
// If the path is absent when a machine applies, the host refuses clearly rather than creating it:
|
|
// the mesh does not own it, so conjuring it would be a lie the host then acts on.
|
|
type Access struct {
|
|
// Path is the absolute path on the machine, as the operator provides it.
|
|
Path string `json:"path"`
|
|
// Mode is "read" or "read-write". Absent narrows to read.
|
|
Mode string `json:"mode,omitempty"`
|
|
}
|
|
|
|
// At is this access's mode, with the default applied.
|
|
func (a Access) At() string {
|
|
if a.Mode == "" {
|
|
return AccessRead
|
|
}
|
|
return a.Mode
|
|
}
|
|
|
|
// Offer is something a module provides, and where the answer to it may live.
|
|
//
|
|
// **The distinction this exists for:** a shell, a display server and a private network have to be
|
|
// on the machine that needs them. A database, an object store and an identity provider do not —
|
|
// they run somewhere in the mesh and are reached over it. Treating the second as the first
|
|
// installs PostgreSQL on every machine that runs a web application, which is what happened until
|
|
// this field existed.
|
|
//
|
|
// Written as a bare string in the ordinary case, because nearly everything is node-scoped and
|
|
// making every manifest say so would bury the few that are not:
|
|
//
|
|
// "provides": ["shell"]
|
|
// "provides": [{"name": "postgres-database", "scope": "mesh"}]
|
|
type Offer struct {
|
|
Name string `json:"name"`
|
|
// Scope defaults to the node, which is where most things must be to be usable.
|
|
Scope string `json:"scope,omitempty"`
|
|
}
|
|
|
|
// At is this offer's scope, with the default applied.
|
|
func (o Offer) At() string {
|
|
if o.Scope == "" {
|
|
return ScopeNode
|
|
}
|
|
return o.Scope
|
|
}
|
|
|
|
// UnmarshalJSON accepts a plain name as well as an object.
|
|
func (o *Offer) UnmarshalJSON(raw []byte) error {
|
|
var plain string
|
|
if err := json.Unmarshal(raw, &plain); err == nil {
|
|
o.Name, o.Scope = plain, ""
|
|
return nil
|
|
}
|
|
var full struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope,omitempty"`
|
|
}
|
|
if err := json.Unmarshal(raw, &full); err != nil {
|
|
return fmt.Errorf("a provided name is either a string or {name, scope}: %w", err)
|
|
}
|
|
o.Name, o.Scope = full.Name, full.Scope
|
|
return nil
|
|
}
|
|
|
|
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
|
|
// went through the mesh comes out looking like the one that went in.
|
|
func (o Offer) MarshalJSON() ([]byte, error) {
|
|
if o.Scope == "" {
|
|
return json.Marshal(o.Name)
|
|
}
|
|
return json.Marshal(struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
}{o.Name, o.Scope})
|
|
}
|
|
|
|
// Manifest is everything a module says about itself.
|
|
type Manifest struct {
|
|
Module string `json:"module"`
|
|
Version string `json:"version,omitempty"`
|
|
|
|
// Slug is a short identifier the mesh uses in place of the module name when it derives a
|
|
// consumer's login (novox/hq ADR 0049). Optional: a module with a short name needs none. It
|
|
// exists because `mesh_<node>_<module>` must fit the tightest backend a consumer reaches — an S3
|
|
// access key is 20 characters — and a long module name would overflow it. A person choosing
|
|
// `kc` for keycloak keeps the identity legible where a hash would not.
|
|
Slug string `json:"slug,omitempty"`
|
|
|
|
// Provides are the names other modules may require. A module always provides its own name;
|
|
// this is for the rest — `zsh` provides `shell`, `xorg` provides `display-server`.
|
|
Provides []Offer `json:"provides,omitempty"`
|
|
|
|
// Requires are names that must be provided by something assigned to the same node.
|
|
Requires []string `json:"requires,omitempty"`
|
|
|
|
// Emits are the event types this module publishes onto the broker — dotted topic keys, e.g.
|
|
// "module.umami.site.created". Declared so the mesh knows the event graph; events are
|
|
// provisioning's lighter sibling — 1:many and broadcast, no credential (novox/hq ADR 0041).
|
|
Emits []string `json:"emits,omitempty"`
|
|
|
|
// Consumes are the event patterns this module subscribes to — topic patterns over module,
|
|
// mesh and node events alike, e.g. "node.*.joined" or "#" (the audit logger). The runtime
|
|
// wires the subscription; the module ships the handler. A Consumes for an event nothing on
|
|
// the mesh Emits is a dangling edge.
|
|
Consumes []string `json:"consumes,omitempty"`
|
|
|
|
// Claims are singular resources. Two modules claiming one thing within a scope cannot both
|
|
// be assigned there — which is how exclusivity is expressed, rather than as a list of rivals
|
|
// that every new module would force its predecessors to update.
|
|
Claims []Claim `json:"claims,omitempty"`
|
|
|
|
// 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"`
|
|
|
|
// 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"`
|
|
// 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.
|
|
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"`
|
|
// Artifact is which of that module's artifacts, by its own name for it.
|
|
Artifact string `json:"artifact"`
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// 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"
|
|
)
|
|
|
|
// 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 }
|
|
|
|
// 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...)
|
|
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"]))
|
|
}
|
|
// **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. Two things are refused
|
|
// here rather than only on the machine, for the same near-versus-far reason the action ban
|
|
// above records: a value that is not a boolean, and the pair run-once + restart-on, which asks
|
|
// for two contradictory lifecycles — restart-on brings a *running* container back, and a
|
|
// run-once step does not stay running.
|
|
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
|
|
if once {
|
|
if _, hasRestart := r["restart-on"]; hasRestart {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s declares %v as run-once and with restart-on; a run-once step runs to "+
|
|
"completion rather than staying running to be restarted (novox/hq ADR 0052)",
|
|
m.Module, r["id"]))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// **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 !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 _, 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))
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|