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
+49 -5
View File
@@ -34,6 +34,9 @@ type Held struct {
// Resolution is what a node should run, and why.
type Resolution struct {
// Node is which machine this was resolved for, so a generator can be asked about it.
Node string
// Modules in the order they were resolved: assigned first, then what they pulled in.
Modules []Manifest
// Because says why each module is here — assigned, or required by something.
@@ -99,10 +102,15 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, elsewh
}
}
// What has already been complained about. A requirement can be wanted by several modules at
// once, and saying the same thing twice makes a person hunt for the difference between two
// identical lines before realising there is none.
reported := map[string]bool{}
for len(queue) > 0 {
want := queue[0]
queue = queue[1:]
if chosen[want] {
if chosen[want] || reported[want] {
continue
}
// Already answered by something in the set. This is the case that makes assigning zsh do
@@ -114,6 +122,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, elsewh
candidates := offers[want]
switch len(candidates) {
case 0:
reported[want] = true
problems = append(problems, fmt.Sprintf(
"nothing provides %q, wanted by %s", want, because[want]))
continue
@@ -121,6 +130,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, elsewh
// No choice to make, so none is made. This is the case that lets `install i3` bring
// in xorg without anybody being asked anything.
default:
reported[want] = true
problems = append(problems, fmt.Sprintf(
"%q is wanted by %s and %d modules provide it — choose one and assign it: %s",
want, because[want], len(candidates), strings.Join(candidates, ", ")))
@@ -148,7 +158,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, elsewh
}
}
resolution := Resolution{Because: because}
resolution := Resolution{Node: node.Name, Because: because}
for _, n := range order {
resolution.Modules = append(resolution.Modules, catalogue[n])
}
@@ -275,16 +285,50 @@ func checkResources(modules []Manifest) []string {
// SettingsBy is the layers that apply to each module, keyed by module name.
type SettingsBy map[string][]Layer
// Generator works out a module's resources for one node, where they cannot be written in advance.
type Generator interface {
// Resources for this node. Absent means the node is not part of whatever this generates,
// which is an ordinary answer rather than a failure — a machine assigned the module before it
// has an address on the network is in exactly that state.
Resources(node string) ([]map[string]any, bool, error)
}
// Rendering is everything needed to turn a resolution into the declaration a node is sent.
type Rendering struct {
Settings SettingsBy
Generators map[string]Generator
}
// Declaration is everything the resolved modules put on the node, with settings applied.
//
// Resource identities are prefixed with the module they came from. Two modules may reasonably
// both call something "config", and without this the second would silently replace the first —
// the node applying one of them and reporting success.
func (r Resolution) Declaration(settings SettingsBy) ([]map[string]any, error) {
func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
var out []map[string]any
for _, m := range r.Modules {
for _, unsettled := range m.Resources {
resource, err := ApplySettings(unsettled, settings[m.Module])
resources := m.Resources
if m.Computed != "" {
generator, known := with.Generators[m.Computed]
if !known {
return nil, fmt.Errorf(
"%s says its resources are computed by %q, and this control plane has no %q",
m.Module, m.Computed, m.Computed)
}
generated, part, err := generator.Resources(r.Node)
if err != nil {
return nil, err
}
if !part {
// Assigned, and not yet part of what this generates. Nothing to put on the
// machine, which is different from an error: a node given the network module
// before it has an address is in exactly that state, briefly.
continue
}
resources = generated
}
for _, unsettled := range resources {
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
if err != nil {
return nil, err
}