From 39541575552576f6267b4a56c05f23fe0629d3d7 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 31 Aug 2026 11:39:21 +0200 Subject: [PATCH] The mesh computes every name under a machine, for a resolver to answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/mesh-control/main.go | 8 ++- internal/overlay/generator.go | 36 +++++++++++ internal/overlay/resolver.go | 82 +++++++++++++++++++++++++ internal/overlay/resolver_test.go | 99 +++++++++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 internal/overlay/resolver.go create mode 100644 internal/overlay/resolver_test.go diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index d09ee7d..5327b1b 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -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 } diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go index d4474a2..5e50899 100644 --- a/internal/overlay/generator.go +++ b/internal/overlay/generator.go @@ -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{ diff --git a/internal/overlay/resolver.go b/internal/overlay/resolver.go new file mode 100644 index 0000000..fbdef37 --- /dev/null +++ b/internal/overlay/resolver.go @@ -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 .." + 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 +} diff --git a/internal/overlay/resolver_test.go b/internal/overlay/resolver_test.go new file mode 100644 index 0000000..c59f44a --- /dev/null +++ b/internal/overlay/resolver_test.go @@ -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) + } +}