Files
mesh-controller/internal/catalogue/manifest.go
T
jschoubben d4064122d6 Where the answer to a requirement is allowed to live
Two different things were both written `requires`. A shell, a display
server and a private network have to be on the machine that needs them.
A database does not — it runs somewhere and is reached over the network.
Both were answered the same way, so requiring a database installed
PostgreSQL on every machine that ran a web application.

What a module provides now carries a scope, the same idea claims already
use, written short in the ordinary case:

  "provides": ["shell"]
  "provides": [{"name": "database", "scope": "mesh"}]

A mesh-scoped requirement is answered by finding the node already running
it — never by installing it here. Choosing a machine to put a database on
is a decision with consequences, and nothing resolving a web application
should make it silently. With nothing anywhere it refuses and says which
module to assign; with two it refuses and says how to choose.

Choosing is `pin <node> <provision> <from>`, kept per node because that
is the granularity the choice has. A pin at a machine that does not
provide it refuses rather than falling back — a fallback would quietly
move somebody's data. One provider does not overrule a pin either.

Resolving a node now needs to know what the others offer, and working
that out needs them resolved, so it is two passes: the first answers only
what each node offers, the second answers everything. Nothing is ever
declared from the first.

A node's plan says what it takes from elsewhere. It is the only part of a
set that stops working when a different machine goes away, and nothing
else in that output would have said so. It is also where a credential
will hang once there is a mechanism for handing one back.

One test found passing for the wrong reason: it read pins through a join
on the provider, which hides a dangling row whether or not it was cleaned
up. It counts rows now, and bites when the cascade is removed.
2026-08-29 23:51:50 +02:00

330 lines
12 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
}
// 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": "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"`
}
// 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 _, 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 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))
}
}
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
}