The mesh decides what a node runs

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.
This commit is contained in:
2026-08-29 22:00:06 +02:00
parent f0cff88172
commit 409cd16a09
6 changed files with 1297 additions and 0 deletions
+302
View File
@@ -8,16 +8,19 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"time"
"github.com/novox/mesh-control/internal/broker"
"github.com/novox/mesh-control/internal/catalogue"
"github.com/novox/mesh-control/internal/identity"
"github.com/novox/mesh-control/internal/inventory"
"github.com/novox/mesh-control/internal/link"
@@ -76,6 +79,14 @@ func run() error {
return declare(ctx, args[1:])
case "overlay":
return overlayCommand(ctx, args[1:])
case "module":
return moduleCommand(ctx, args[1:])
case "assign", "unassign":
return assignCommand(ctx, args[0], args[1:])
case "plan":
return planCommand(ctx, args[1:])
case "push":
return pushCommand(ctx, args[1:])
case "version":
fmt.Println(version)
return nil
@@ -103,6 +114,13 @@ func usage() {
overlay place <node> [flags] say where a node is and how it is reached
overlay show the private network, as the mesh computes it
overlay push send every node its part of the private network
module add <file> register a module from its manifest
module list what modules this mesh knows about
module forget <name> remove one, unless a node is running it
assign <node> <module> put a module on a node
unassign <node> <module> take it off
plan <node> what that node would run, and why
push [<node>] send a node everything it should be
version what this binary is
Each context reaches its own store through its own credential (novox/hq ADR 0008), named
@@ -668,3 +686,287 @@ func roughly(d time.Duration) string {
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
}
func moduleCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New("module add <file>, module list, or module forget <name>")
}
inv, err := openInventory(ctx)
if err != nil {
return err
}
defer inv.Close()
switch args[0] {
case "add":
if len(args) != 2 {
return errors.New("module add <manifest.json>")
}
raw, err := os.ReadFile(args[1])
if err != nil {
return err
}
m, err := catalogue.ParseManifest(raw)
if err != nil {
return err
}
if err := inv.RegisterModule(ctx, m); err != nil {
return err
}
fmt.Printf("%s registered", m.Module)
if len(m.Provides) > 0 {
fmt.Printf(", providing %s", strings.Join(m.Provides, ", "))
}
fmt.Println()
for _, c := range m.Claims {
fmt.Printf(" claims %s, one per %s\n", c.Name, c.At())
}
return nil
case "list":
shelf, err := inv.Catalogue(ctx)
if err != nil {
return err
}
if len(shelf) == 0 {
fmt.Println("this mesh knows about no modules yet")
return nil
}
var names []string
for n := range shelf {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
m := shelf[n]
fmt.Printf("%-20s", m.Module)
if len(m.Provides) > 0 {
fmt.Printf(" provides %s", strings.Join(m.Provides, ", "))
}
for _, c := range m.Claims {
fmt.Printf(" claims %s/%s", c.At(), c.Name)
}
fmt.Println()
}
return nil
case "forget":
if len(args) != 2 {
return errors.New("module forget <name>")
}
if err := inv.ForgetModule(ctx, args[1]); err != nil {
return err
}
fmt.Printf("%s forgotten\n", args[1])
return nil
default:
return fmt.Errorf("module has no %q; it has add, list and forget", args[0])
}
}
func assignCommand(ctx context.Context, verb string, args []string) error {
if len(args) != 2 {
return fmt.Errorf("%s <node> <module>", verb)
}
inv, err := openInventory(ctx)
if err != nil {
return err
}
defer inv.Close()
if verb == "unassign" {
if err := inv.Unassign(ctx, args[0], args[1]); err != nil {
return err
}
fmt.Printf("%s no longer runs %s — run `push %s` to make it so\n", args[0], args[1], args[0])
return nil
}
if err := inv.Assign(ctx, args[0], args[1]); err != nil {
return err
}
fmt.Printf("%s is assigned %s\n", args[0], args[1])
// Resolved immediately, because an assignment that cannot be applied should be said now
// rather than at the next push. The assignment is kept either way: it is what a person meant,
// and the refusal is about the set rather than about this one.
if _, err := planFor(ctx, inv, args[0]); err != nil {
fmt.Println()
return err
}
fmt.Printf(" run `push %s` to send it\n", args[0])
return nil
}
// planFor works out everything a node should run, from what was assigned to it.
func planFor(ctx context.Context, inv *inventory.Inventory, nodeName string) (catalogue.Resolution, error) {
shelf, err := inv.Catalogue(ctx)
if err != nil {
return catalogue.Resolution{}, err
}
assigned, err := inv.Assigned(ctx, nodeName)
if err != nil {
return catalogue.Resolution{}, err
}
capabilities, err := inv.ProfileOf(ctx, nodeName)
if err != nil {
return catalogue.Resolution{}, err
}
places, err := inv.Overlays(ctx)
if err != nil {
return catalogue.Resolution{}, err
}
var site string
for _, p := range places {
if p.Name == nodeName {
site = p.Site
}
}
// What every other node already holds, so the claims wider than one machine can be checked.
// Resolved rather than read from a table: a claim is held by whatever a node actually runs,
// and a record of it would be a second answer that could disagree with the first.
var elsewhere []catalogue.Held
for _, p := range places {
if p.Name == nodeName {
continue
}
theirs, err := inv.Assigned(ctx, p.Name)
if err != nil || len(theirs) == 0 {
continue
}
theirCaps, _ := inv.ProfileOf(ctx, p.Name)
got, err := catalogue.Resolve(shelf, theirs,
catalogue.Node{Name: p.Name, Site: p.Site, Capabilities: theirCaps}, nil)
if err != nil {
// Their set does not resolve either. Not this node's problem to report, and their
// claims cannot be counted because nothing of theirs is running.
continue
}
elsewhere = append(elsewhere, got.Claims...)
}
return catalogue.Resolve(shelf, assigned,
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities}, elsewhere)
}
func planCommand(ctx context.Context, args []string) error {
if len(args) != 1 {
return errors.New("plan <node>")
}
inv, err := openInventory(ctx)
if err != nil {
return err
}
defer inv.Close()
plan, err := planFor(ctx, inv, args[0])
if err != nil {
return err
}
if len(plan.Modules) == 0 {
fmt.Printf("%s is assigned nothing\n", args[0])
return nil
}
fmt.Printf("%s would run:\n", args[0])
for _, m := range plan.Modules {
fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module])
}
for _, c := range plan.Claims {
fmt.Printf(" holds %s, one per %s\n", c.Claim, c.Scope)
}
fmt.Printf("\n%d resource(s)\n", len(plan.Declaration()))
return nil
}
// pushCommand sends nodes everything they should be: their place on the network, and what their
// assignments resolve to.
//
// One declaration, not two. A node holding its network and not its modules, or the reverse, is
// half-configured for as long as that lasts — and the two are computed from the same picture of
// the mesh, so sending them apart would let them disagree.
func pushCommand(ctx context.Context, args []string) error {
if len(args) > 1 {
return errors.New("push [<node>] — one node, or all of them")
}
inv, err := openInventory(ctx)
if err != nil {
return err
}
defer inv.Close()
ident, err := openIdentity(ctx)
if err != nil {
return err
}
defer ident.Close()
nodes, computed, err := graph(ctx, inv)
if err != nil {
return err
}
server, err := link.Connect(nil, nil)
if err != nil {
return err
}
defer server.Close()
// Every node is resolved before anything is sent. A push that configured three nodes and then
// refused on the fourth would leave the mesh in a state nobody asked for, and the fourth is
// exactly where a claim collision shows up.
type ready struct {
node overlay.Node
resources []map[string]any
}
var sending []ready
var refusals []string
for _, n := range nodes {
if len(args) == 1 && n.Name != args[0] {
continue
}
peers, onOverlay := computed[n.Name]
if !onOverlay {
fmt.Printf("%s is not on the overlay yet — skipped\n", n.Name)
continue
}
declaration, err := overlay.Declaration(n, peers, nodes, "")
if err != nil {
return err
}
var resources struct {
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(declaration, &resources); err != nil {
return err
}
plan, err := planFor(ctx, inv, n.Name)
if err != nil {
refusals = append(refusals, fmt.Sprintf("%s:\n%v", n.Name, err))
continue
}
sending = append(sending, ready{n, append(resources.Resources, plan.Declaration()...)})
}
if len(refusals) > 0 {
return fmt.Errorf("nothing was sent. %d node(s) could not be resolved:\n\n%s",
len(refusals), strings.Join(refusals, "\n\n"))
}
for _, s := range sending {
body, err := json.Marshal(map[string]any{"declaration": 1, "resources": s.resources})
if err != nil {
return err
}
if err := link.Declare(ctx, server.Channel(), ident, s.node.Name, body, 15*time.Second); err != nil {
return err
}
fmt.Printf("sent %s %d resource(s)\n", s.node.Name, len(s.resources))
}
fmt.Printf("\n%d node(s) told\n", len(sending))
return nil
}