The mesh computes every name under a machine, for a resolver to answer

Services are named under the machine they run on — postgres.novox.internal,
plex.ace.internal. The first label is the service and the rest is the node, so
what has to resolve is anything under a node's name. What routes it once it
arrives is a proxy's concern and stays separate.

A hosts file cannot do that. It answers exact names, and a wildcard there would
mean writing down every service in advance — which is the enumeration the
arrangement exists to avoid. novox/hq 08-connectivity named this exact case as
the trigger for needing a resolver rather than a file, and it is the first
thing to meet it.

The mesh writes the data and runs no daemon. A resolver is third-party
software, and third-party software runs on the mesh rather than being of it
(ADR 0001): the mesh has no business shipping one, choosing which one, or
knowing its configuration language. What only the mesh can know is which
machines exist and where they are. A module that runs a resolver requires what
this provides and reads one file, so swapping the daemon changes that module
and nothing here.

Separate from names rather than part of them: a machine with no container
runtime can still have a hosts file, and folding them together would take exact
names away from a machine that cannot run a daemon in order to give it a
wildcard it cannot use either.

A machine with no address is left out. A wildcard pointing at nothing is worse
than no wildcard — every name under it resolves and then hangs, where an
unresolvable name fails at once and says which name it was.
This commit is contained in:
2026-08-31 11:39:21 +02:00
parent 4d67c48342
commit 3954157555
4 changed files with 222 additions and 3 deletions
+5 -3
View File
@@ -243,7 +243,8 @@ func migrate(ctx context.Context) error {
func providedModules() []catalogue.Manifest {
var out []catalogue.Manifest
for _, raw := range []map[string]any{
overlay.Manifest(), overlay.NamesManifest(), overlay.DomainManifest(),
overlay.Manifest(), overlay.NamesManifest(), overlay.ResolverManifest(),
overlay.DomainManifest(),
} {
var m catalogue.Manifest
b, _ := json.Marshal(raw)
@@ -762,8 +763,9 @@ func generators(ctx context.Context, inv *inventory.Inventory) (
// 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()),
overlay.Name: net,
overlay.Names: overlay.NamesFor(net.Nodes()),
overlay.Resolver: overlay.ResolverFor(net.Nodes()),
}, nil
}
+36
View File
@@ -59,6 +59,18 @@ const Addressing = "mesh-addressing"
// something that needed the mesh's own addresses. Which is exactly what happened, once.
const TheNetwork = "the-private-network"
// Resolver is the module that answers every name under a machine, and the claim it holds.
//
// A claim because a machine has one resolver: two daemons answering the same names on one machine
// is a coin toss about which one a query reaches, and the answer differing between them is the
// kind of fault nobody finds by looking at either.
const (
Resolver = "mesh-resolver"
// ResolverData is what a module running a resolver requires: the mesh's own account of which
// machines exist and where, in a file.
ResolverData = "resolver-data"
)
// Domain is the module for people who want a network and do not want to choose one.
//
// It has no files of its own — it is requirements and nothing else. Assigning it finds one
@@ -186,6 +198,30 @@ func NamesManifest() map[string]any {
}
}
// ResolverManifest is what a resolver on this machine must know: every name under every machine.
//
// **It writes the data and runs no daemon.** A resolver is third-party software, and third-party
// software runs *on* the mesh rather than being *of* it
// ([ADR 0001](novox/hq)) — the mesh has no business shipping one, choosing which one, or knowing
// its configuration language. What only the mesh can know is which machines exist and where they
// are, so that is what it computes.
//
// So a module that runs a resolver requires what this provides, and reads one file. Swapping the
// daemon changes that module and nothing here.
//
// **Separate from names rather than part of them**, because a machine with no container runtime
// can still have a hosts file. Folding them together would take exact names away from a machine
// that cannot run a daemon, to give it a wildcard it cannot use either.
func ResolverManifest() map[string]any {
return map[string]any{
"module": Resolver,
"version": "1",
"computed": Resolver,
"requires": []string{Resolution},
"provides": []string{ResolverData},
}
}
// DomainManifest is the module that means "get the network working".
func DomainManifest() map[string]any {
return map[string]any{
+82
View File
@@ -0,0 +1,82 @@
package overlay
import (
"fmt"
"sort"
"strings"
)
// A resolver answers every name under a node, not just the node.
//
// **Services are named under the machine they run on** — `postgres.novox.internal`,
// `plex.ace.internal`. The first label is the service and the rest is the node, so what has to
// resolve is *anything* under a node's name, going to that node's address. A reverse proxy there
// routes by the name it was asked for, which is a separate concern and stays separate.
//
// **This is what a hosts file cannot do.** It answers exact names; a wildcard would mean writing
// down every service name in advance, which is the enumeration the arrangement exists to avoid.
// novox/hq 08-connectivity named exactly this as the trigger for needing a resolver rather than a
// file, and it is the first thing to meet it.
//
// What is generated is the data, not the daemon's configuration language. One line per node,
// in the form dnsmasq reads because that is what the module runs — and if a mesh runs something
// else, this is the shape it translates from rather than a second thing to compute.
// ResolverPath is where the mesh writes what a node must answer.
const ResolverPath = "/etc/mesh-resolver/nodes.conf"
// Wildcards is one line per node: everything under its name, and the name itself.
//
// A machine with no address is left out. A wildcard pointing at nothing is worse than no wildcard:
// every name under it would resolve and then hang, where an unresolvable name fails at once and
// says which name it was.
func Wildcards(nodes []Node) string {
var b strings.Builder
b.WriteString("# Generated by the mesh. Do not edit — it is replaced whenever a machine\n")
b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n")
b.WriteString("#\n")
b.WriteString("# Each line answers the node's own name AND everything under it, so a service\n")
b.WriteString("# is reached at <service>.<node>." + Suffix() + " without the mesh being told\n")
b.WriteString("# the service exists. What routes it there once it arrives is the proxy's.\n\n")
named := make([]Node, 0, len(nodes))
for _, n := range nodes {
if strings.TrimSpace(n.Address) == "" {
continue
}
named = append(named, n)
}
sort.Slice(named, func(i, j int) bool { return named[i].Name < named[j].Name })
for _, n := range named {
fmt.Fprintf(&b, "address=/%s/%s\n", InternalName(n.Name), n.Address)
}
if len(named) == 0 {
b.WriteString("# No machine in this mesh has an address on the private network.\n")
}
return b.String()
}
// ResolverGenerator answers what one node's resolver must know.
type ResolverGenerator struct{ nodes []Node }
// ResolverFor builds it over the machines on the private network.
func ResolverFor(nodes []Node) *ResolverGenerator { return &ResolverGenerator{nodes: nodes} }
// Resources is the one file. The daemon that reads it is the module's, not the mesh's.
func (g *ResolverGenerator) Resources(node string) ([]map[string]any, bool, error) {
var here bool
for _, n := range g.nodes {
if n.Name == node {
here = true
}
}
if !here {
// Assigned and not yet on the network. Ordinary and brief.
return nil, false, nil
}
return []map[string]any{{
"id": "nodes", "type": "file", "path": ResolverPath,
"content": Wildcards(g.nodes), "mode": "0644",
}}, true, nil
}
+99
View File
@@ -0,0 +1,99 @@
package overlay
import (
"strings"
"testing"
)
// Services are named under the machine they run on, so what must resolve is anything under a
// node's name — not the node's name alone.
//
// This is what a hosts file cannot do: it answers exact names, and a wildcard there would mean
// writing down every service in advance, which is the enumeration the arrangement exists to
// avoid.
func TestEverythingUnderANodesNameGoesToThatNode(t *testing.T) {
written := Wildcards([]Node{
{Name: "novox", Address: "10.42.0.1"},
{Name: "ace", Address: "10.42.0.2"},
})
for _, want := range []string{
"address=/novox.internal/10.42.0.1",
"address=/ace.internal/10.42.0.2",
} {
if !strings.Contains(written, want) {
t.Fatalf("missing %q:\n%s", want, written)
}
}
// Sorted, because this file is compared against its last version on every apply and a set
// that reorders itself would rewrite it — and restart what reads it — for no change.
if strings.Index(written, "/ace.") > strings.Index(written, "/novox.") {
t.Fatalf("the machines are not in a stable order:\n%s", written)
}
}
// A machine with no address is left out.
//
// A wildcard pointing at nothing is worse than no wildcard: every name under it resolves and then
// hangs, where an unresolvable name fails at once and says which name it was.
func TestAMachineWithNoAddressGetsNoWildcard(t *testing.T) {
written := Wildcards([]Node{
{Name: "novox", Address: "10.42.0.1"},
{Name: "unplaced"},
})
if strings.Contains(written, "unplaced") {
t.Fatalf("a machine with no address was given a wildcard:\n%s", written)
}
if !strings.Contains(written, "novox.internal") {
t.Fatalf("the machine that does have one lost it:\n%s", written)
}
}
// A mesh where nobody is on the private network says so rather than producing an empty file that
// reads as "nothing was generated".
func TestAMeshWithNoAddressesSaysSo(t *testing.T) {
written := Wildcards(nil)
if !strings.Contains(written, "No machine in this mesh has an address") {
t.Fatalf("an empty answer is indistinguishable from a failure to answer:\n%s", written)
}
}
// The suffix a mesh chose is used, not a hardcoded one.
func TestTheMeshsOwnSuffixIsUsed(t *testing.T) {
t.Setenv(SuffixVar, "mesh.example")
written := Wildcards([]Node{{Name: "novox", Address: "10.42.0.1"}})
if !strings.Contains(written, "address=/novox.mesh.example/10.42.0.1") {
t.Fatalf("the mesh's own suffix was not used:\n%s", written)
}
}
// A machine not on the network is given no resolver data, which is an answer rather than an
// error: a node assigned the module before it is placed is in exactly that state.
func TestAMachineNotOnTheNetworkGetsNoResolverData(t *testing.T) {
_, part, err := ResolverFor([]Node{{Name: "novox", Address: "10.42.0.1"}}).Resources("stranger")
if err != nil {
t.Fatal(err)
}
if part {
t.Fatal("a machine not on the network was given the mesh's resolver data")
}
}
// And a machine on it gets the whole set, including itself: a service on this machine reached by
// its own mesh name must arrive the same way it would from anywhere else.
func TestAMachineGetsTheWholeSetIncludingItself(t *testing.T) {
got, part, err := ResolverFor([]Node{
{Name: "novox", Address: "10.42.0.1"},
{Name: "ace", Address: "10.42.0.2"},
}).Resources("novox")
if err != nil {
t.Fatal(err)
}
if !part || len(got) != 1 {
t.Fatalf("expected one file for a machine on the network, got %d (part=%v)", len(got), part)
}
content, _ := got[0]["content"].(string)
if !strings.Contains(content, "novox.internal") || !strings.Contains(content, "ace.internal") {
t.Fatalf("the machine was not given the whole mesh:\n%s", content)
}
}