The gap that has been named at the end of every report for a week. Until now a
declaration came from a person handing over a file; now it comes from what was
assigned, resolved against the catalogue, and the control plane is deciding
rather than relaying.
Everything from the module conversation, built and run on real machines:
assign laptop i3 -> accepted, brings xorg, because nothing else provides
it and there was no choice to make
assign laptop sway -> refused: xorg and wayland both claim the-seat
assign laptop editor -> refused: three modules provide a shell -- bash,
fish, zsh -- choose one
assign laptop zsh -> accepted, and the editor's requirement is answered
bash, fish beside it -> fine, nothing is claimed
Claims rather than pairwise exclusion, so a third display server would say what
it claims and need no edit to xorg or wayland. Scoped to node, site or mesh:
two DHCP servers at one site collide and at two sites do not, and the mesh-wide
one is the hub said as a claim instead of hard-coded.
Some conflicts cost no manifest field at all. The refusal above names the seat
AND the two files, because the mesh already holds every resource of every
module -- neither i3 nor sway knows the other exists.
Resource identities carry their module, so two modules may both call something
"config" without the second silently replacing the first. What a service
reflects is qualified the same way, or it would name a resource that no longer
exists and stop being restarted when its own configuration changes.
Nothing is sent until every node resolves. A push that configured three and
refused on the fourth would leave the mesh in a state nobody asked for, and the
fourth is exactly where a claim collision appears.
One real flaw found by using it rather than by testing it: assigning zsh did
not satisfy a requirement for a shell. Requirements were counted against the
catalogue without first asking what the set already offers, so "choose one and
assign it" named three modules and then ignored the one you chose. The remedy
was useless and every test passed.
144 lines
4.9 KiB
Go
144 lines
4.9 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"`
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
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
|
|
}
|