`requires` said a thing must be there. It never said what to do with it,
so a web application requiring a reverse proxy had nowhere to put "this
name, this port". The two modules that needed it most went round the
outside and opened a connection to the control plane's database, which is
why every node holds a credential to it permanently.
Two fields close it:
contributes: {reverse-proxy: {host: board, port: 8080}}
receives: {reverse-proxy: /etc/traefik/dynamic/mesh.json}
The control plane collects every contribution on a node and writes them
to the path the provider named, ordered by module so the file does not
churn. 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.
The control plane does not know what a reverse proxy is and does not
write one's configuration. It delivers facts; the module turns them into
whatever it runs. That is why swapping the proxy touches nothing that
publishes through it, and why the host needs no new vocabulary — a
received file is a file.
Settings reach a contribution the same way they reach a file, because a
hostname is exactly what differs between one mesh and the next.
Two things found by running it:
- the file had a `//` header, so it said "do not edit" to a person and
failed to parse for the program meant to read it. The note is inside
the document now.
- a provider with no consumers gets an empty file rather than none. It
cannot otherwise tell "nothing asked for me" from "the mesh never
wrote it", and those want different responses.
Also `plan <node> --json`, which is how the declaration gets handed to
the host's own parser.
245 lines
9.4 KiB
Go
245 lines
9.4 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 (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"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.
|
|
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
|
|
}
|
|
|
|
// 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 []string `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"`
|
|
}
|
|
|
|
// 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
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
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 _, p := range m.Provides {
|
|
if !name.MatchString(p) {
|
|
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
|
|
}
|
|
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))
|
|
}
|
|
}
|
|
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 := append([]string{m.Module}, m.Provides...)
|
|
sort.Strings(out)
|
|
return out
|
|
}
|