Networking is a module, and a domain module is how you avoid choosing

Connectivity was code beside the module system doing the module system's
job: every machine with an address was on the private network and there
was no way to keep one off.

A manifest can now say its resources are computed by the control plane,
which is what a peer list needs — it is derived from every machine at
once, so nothing could be written in advance. The network is a module
from there on: assigned, resolved, settled, and absent from a machine
nobody gave it to.

Three modules rather than one, because WireGuard is one VPN of several:

  mesh-wireguard   provides private-network, mesh-addressing
                   claims the-private-network, one per node
  mesh-names       provides name-resolution, requires mesh-addressing
  networking       requires both, and ships no files of its own

The last is the point. Most people want the network up and do not want
to choose a VPN, so `assign networking` takes the only answer to each
requirement silently. The day the catalogue holds a second one there are
two answers, the resolver refuses and names them, and choosing is
assigning the one you want. No flavor field, nothing to configure.

Names left the WireGuard declaration for their own module. They would be
identical over a different private network, and bundling them made one
module out of two things.

Three faults the walk found:

- choosing tailscale still installed WireGuard, dragged back in by the
  names needing the mesh's own addresses. Caught now by a claim: running
  two VPNs is fine, being *the* mesh network is singular.
- a requirement wanted by two modules was reported twice, identically.
- "this mesh has no hub" was reported when the real cause was that a
  node could not be resolved at all. It now names the node and the why.

And a test that asserts the manifests actually shipped, after the claim
went missing from the real one while every test stayed green.
This commit is contained in:
2026-08-29 23:19:32 +02:00
parent 65ade756f2
commit 44d134ba25
12 changed files with 940 additions and 53 deletions
+173 -31
View File
@@ -181,9 +181,48 @@ func migrate(ctx context.Context) error {
fmt.Printf("%s: already up to date — %d migration(s)\n", c.name, len(applied))
}
}
// The modules the control plane ships with itself. Recorded here rather than by hand, because
// a mesh whose own private network is missing from the catalogue would have nothing to assign
// and no way to say why.
inv, err := openInventory(ctx)
if err != nil {
return err
}
defer inv.Close()
for _, m := range provided {
if err := inv.Provide(ctx, m); err != nil {
return err
}
fmt.Printf("provided %s\n", m.Module)
}
return nil
}
// provided is what comes with the control plane rather than from a repository.
//
// WireGuard, the names, and the domain module over both. The first two are here because the code
// that works out their files is here:
// a peer list is derived from every machine at once, so it cannot be written in a manifest, and
// whatever computes it has to live wherever the whole picture is.
//
// **It is a module in every other respect** — assigned, unassigned, resolved, settled, and absent
// from a machine nobody gave it to.
func providedModules() []catalogue.Manifest {
var out []catalogue.Manifest
for _, raw := range []map[string]any{
overlay.Manifest(), overlay.NamesManifest(), overlay.DomainManifest(),
} {
var m catalogue.Manifest
b, _ := json.Marshal(raw)
_ = json.Unmarshal(b, &m)
out = append(out, m)
}
return out
}
var provided = providedModules()
// openInventory connects and waits, the way every command that touches it needs to.
func openInventory(ctx context.Context) (*inventory.Inventory, error) {
inv, err := inventory.Open(ctx)
@@ -572,22 +611,120 @@ func overlayPlace(ctx context.Context, inv *inventory.Inventory, args []string)
return nil
}
// graph reads every node's place and computes the network. Every node at once, which is the whole
// reason this is the control plane's work.
func graph(ctx context.Context, inv *inventory.Inventory) ([]overlay.Node, overlay.Graph, error) {
// network builds the private network over the machines that resolved the module for it.
//
// Not over every node the mesh knows. **A machine is on the private network because it was given
// the module**, and one that was not is absent from every peer list and from the names — which is
// the only thing "not on the network" can mean. Until this, having an address was enough, and
// there was no way to keep a machine off.
//
// Every node at once, which is the whole reason this is the control plane's work: a peer list is
// derived from all the others, so no node could compute its own.
func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool,
refused map[string]string) (*overlay.Generator, error) {
places, err := inv.Overlays(ctx)
if err != nil {
return nil, nil, err
return nil, err
}
nodes := make([]overlay.Node, 0, len(places))
for _, p := range places {
if !on[p.Name] {
continue
}
nodes = append(nodes, overlay.Node{
Name: p.Name, Key: p.Key, Endpoint: p.Endpoint,
Site: p.Site, Hub: p.Hub, Address: p.Address,
})
}
computed, err := overlay.Compute(nodes, overlayCIDR())
return nodes, computed, err
if len(nodes) == 0 {
// Nobody was given it. An empty network is a legitimate mesh, not a broken one, so this
// answers rather than refusing -- Compute would refuse for want of a hub, and reporting
// "no hub" to somebody who never asked for a network would be a lie about the cause.
return overlay.Empty(), nil
}
g, err := overlay.From(nodes, overlayCIDR(), "")
if err != nil && len(refused) > 0 {
// The network is missing something, and some machines could not be resolved at all. Those
// are almost always the same fact: a node that does not resolve contributes nothing, so
// reporting "no hub" would name a consequence and hide the cause.
var who []string
for name, why := range refused {
who = append(who, fmt.Sprintf(" %s: %s", name, why))
}
sort.Strings(who)
return nil, fmt.Errorf("%w\n\nand %d node(s) could not be resolved at all, which is "+
"probably why:\n%s", err, len(refused), strings.Join(who, "\n"))
}
return g, err
}
// graph is the whole mesh's network, for showing it.
func graph(ctx context.Context, inv *inventory.Inventory) ([]overlay.Node, overlay.Graph, error) {
on, refused, err := whoResolves(ctx, inv, overlay.Requirement)
if err != nil {
return nil, nil, err
}
g, err := network(ctx, inv, on, refused)
if err != nil {
return nil, nil, err
}
return g.Nodes(), g.Graph(), nil
}
// whoResolves is the machines whose resolution answers a requirement, and why the others did not.
//
// By what a module **provides**, not by its name. WireGuard is one way to have a private network
// and there could be others, so a machine is on the network because something it runs provides
// one — asking for a particular module by name would be the mistake this whole mechanism exists
// to avoid.
//
// Resolved rather than read from the assignment table, because a module can arrive by being
// required by something else, and a machine that needs the private network to do its job is on it
// for the same reason as one that was handed it directly.
func whoResolves(ctx context.Context, inv *inventory.Inventory, requirement string) (
map[string]bool, map[string]string, error) {
nodes, err := inv.Nodes(ctx)
if err != nil {
return nil, nil, err
}
on := map[string]bool{}
// Why a node could not be resolved, kept rather than raised: one broken node must not stop
// the rest being described, and whoever is rendering that node will raise it themselves.
refused := map[string]string{}
for _, n := range nodes {
plan, _, err := planFor(ctx, inv, n.Name)
if err != nil {
refused[n.Name] = err.Error()
continue
}
for _, m := range plan.Modules {
for _, offered := range m.Offers() {
if offered == requirement {
on[n.Name] = true
}
}
}
}
return on, refused, nil
}
// rendering is everything a declaration needs, computed over the whole mesh.
func generators(ctx context.Context, inv *inventory.Inventory) (
map[string]catalogue.Generator, error) {
on, refused, err := whoResolves(ctx, inv, overlay.Addressing)
if err != nil {
return nil, err
}
net, err := network(ctx, inv, on, refused)
if err != nil {
return nil, err
}
// Both generators see the same machines: the ones on the private network. Names for a machine
// that is not on it would resolve to addresses it cannot reach, which is worse than no names.
return map[string]catalogue.Generator{
overlay.Name: net,
overlay.Names: overlay.NamesFor(net.Nodes()),
}, nil
}
func overlayShow(ctx context.Context, inv *inventory.Inventory) error {
@@ -596,7 +733,11 @@ func overlayShow(ctx context.Context, inv *inventory.Inventory) error {
return err
}
if len(nodes) == 0 {
fmt.Println("this mesh has no nodes")
// Not "this mesh has no nodes", which it said until the network became a module and was
// then a lie about the cause: a mesh can have every node it will ever have and nobody on
// the private network, because nobody asked for one.
fmt.Printf("nobody is on the private network — assign %s to put a machine on it\n",
overlay.Name)
return nil
}
@@ -926,7 +1067,11 @@ func planCommand(ctx context.Context, args []string) error {
for _, c := range plan.Claims {
fmt.Printf(" holds %s, one per %s\n", c.Claim, c.Scope)
}
resources, err := plan.Declaration(settings)
gens, err := generators(ctx, inv)
if err != nil {
return err
}
resources, err := plan.Declaration(catalogue.Rendering{Settings: settings, Generators: gens})
if err != nil {
return err
}
@@ -971,7 +1116,14 @@ func pushCommand(ctx context.Context, args []string) error {
}
defer ident.Close()
nodes, computed, err := graph(ctx, inv)
// Every node, not only the ones on the private network. A machine that was never given the
// network module still takes modules, and iterating the network here is what used to make
// "on the network" and "managed" the same thing.
nodes, err := inv.Nodes(ctx)
if err != nil {
return err
}
gens, err := generators(ctx, inv)
if err != nil {
return err
}
@@ -986,7 +1138,7 @@ func pushCommand(ctx context.Context, args []string) error {
// 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
node string
resources []map[string]any
}
var sending []ready
@@ -996,34 +1148,24 @@ func pushCommand(ctx context.Context, args []string) error {
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, settings, err := planFor(ctx, inv, n.Name)
if err != nil {
refusals = append(refusals, fmt.Sprintf("%s:\n%v", n.Name, err))
continue
}
fromModules, err := plan.Declaration(settings)
// The private network is in here with everything else. It used to be composed separately
// and prepended, which meant every machine with an address was on it and no machine could
// be kept off. It is a module now, so it arrives the way a module does.
resources, err := plan.Declaration(catalogue.Rendering{Settings: settings, Generators: gens})
if err != nil {
refusals = append(refusals, fmt.Sprintf("%s:\n%v", n.Name, err))
continue
}
sending = append(sending, ready{n, append(resources.Resources, fromModules...)})
if len(resources) == 0 {
fmt.Printf("%s is assigned nothing — skipped\n", n.Name)
continue
}
sending = append(sending, ready{n.Name, resources})
}
if len(refusals) > 0 {
@@ -1036,10 +1178,10 @@ func pushCommand(ctx context.Context, args []string) error {
if err != nil {
return err
}
if err := link.Declare(ctx, server.Channel(), ident, s.node.Name, body, 15*time.Second); err != nil {
if err := link.Declare(ctx, server.Channel(), ident, s.node, body, 15*time.Second); err != nil {
return err
}
fmt.Printf("sent %s %d resource(s)\n", s.node.Name, len(s.resources))
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.resources))
}
fmt.Printf("\n%d node(s) told\n", len(sending))
return nil