The mesh's knowledge is a fact a module asks for, not three modules
mesh-names, mesh-resolver and the names half of the overlay generators are gone.
They ran no software and could not be swapped for anything, which is the test of
whether something is a module at all — they existed because computed output
needed somewhere to live, and the control plane's only shape for output was a
module.
Now a module says where it wants what the mesh knows:
facts: { node-zones: /etc/mesh-resolver/nodes.conf }
and is given a file, under its own name, applied and removed like anything else
it declares. Two facts exist: node-names (a hosts file — exact names) and
node-zones (every machine as a wildcard, *.homer.internal is homer). Asking for
a fact the mesh does not compute is refused naming what would have worked,
because a daemon that starts and reads a file nobody wrote is a worse way to
find out.
The names ride with the network now: wireguard's manifest asks for node-names
into /etc/hosts, because being on the private network is what gives a machine a
name. networking no longer requires name-resolution — names are not a provision,
and the module that answered it ran nothing.
One behaviour inverted, deliberately: choosing another VPN used to drag
WireGuard in anyway, because only WireGuard provided the addressing the names
module required — the node-scope claim existed to at least make that loud. With
names as a fact there is nothing to drag in: tailscale assigned means tailscale,
alone. The claim still catches two VPNs assigned explicitly.
And a machine the mesh cannot place is left out of both files rather than named
at nothing: a name resolving to nothing hangs a connection, where an unknown
name fails at once and says so. In practice that is only ever a token issued and
not yet used — a machine that has announced itself has an address.
Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
@@ -138,30 +138,6 @@ func (g *Generator) Resources(node string) ([]map[string]any, bool, error) {
|
||||
// private network the peers would be written by something else and this would be unchanged.
|
||||
type NameGenerator struct{ nodes []Node }
|
||||
|
||||
// NamesFor builds the name generator over the machines on the private network.
|
||||
func NamesFor(nodes []Node) *NameGenerator { return &NameGenerator{nodes: nodes} }
|
||||
|
||||
// Resources is the one file.
|
||||
func (g *NameGenerator) Resources(node string) ([]map[string]any, bool, error) {
|
||||
var found bool
|
||||
for _, n := range g.nodes {
|
||||
if n.Name == node {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, false, nil
|
||||
}
|
||||
hosts, err := Hosts(g.nodes, node)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return []map[string]any{{
|
||||
"id": "mesh-names", "type": "file", "path": HostsPath,
|
||||
"mode": "0644", "content": hosts,
|
||||
}}, true, nil
|
||||
}
|
||||
|
||||
// Nodes are the machines this generator was built over, so a caller can say who is on the network.
|
||||
func (g *Generator) Nodes() []Node { return g.nodes }
|
||||
|
||||
@@ -179,55 +155,25 @@ func Manifest() map[string]any {
|
||||
"version": "1",
|
||||
"computed": Name,
|
||||
"provides": []string{Requirement, Addressing},
|
||||
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
|
||||
}
|
||||
}
|
||||
|
||||
// NamesManifest is the module that gives machines names on the private network.
|
||||
//
|
||||
// It requires the network rather than providing it, which is the whole reason it is separate: a
|
||||
// name resolves to an address on the private wire, so having names without being on it would
|
||||
// point every machine at somewhere it cannot reach.
|
||||
func NamesManifest() map[string]any {
|
||||
return map[string]any{
|
||||
"module": Names,
|
||||
"version": "1",
|
||||
"computed": Names,
|
||||
"provides": []string{Resolution},
|
||||
"requires": []string{Addressing},
|
||||
}
|
||||
}
|
||||
|
||||
// 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},
|
||||
// Being on the private network is what gives a machine a name, so the module that puts it
|
||||
// there is what writes them. Asked for rather than generated by a module of its own: the
|
||||
// mesh knows which machines exist and where; writing that into a hosts file is not a thing
|
||||
// that needs a module to run nowhere.
|
||||
"facts": map[string]string{"node-names": "/etc/hosts"},
|
||||
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
|
||||
}
|
||||
}
|
||||
|
||||
// DomainManifest is the module that means "get the network working".
|
||||
func DomainManifest() map[string]any {
|
||||
return map[string]any{
|
||||
"module": Domain,
|
||||
"version": "1",
|
||||
"requires": []string{Requirement, Resolution},
|
||||
"module": Domain,
|
||||
"version": "1",
|
||||
// **Only the network now.** It used to require name-resolution as well, answered by a
|
||||
// module that wrote a hosts file and ran nothing. Names are not a provision — they are a
|
||||
// fact the mesh computes, and whatever puts a machine on the private network writes them,
|
||||
// because a mesh name IS an address on that network.
|
||||
"requires": []string{Requirement},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Names are their own module.
|
||||
//
|
||||
// They used to arrive inside the WireGuard declaration, on the argument that a machine with peers
|
||||
// and no names is half on the network. True, and the wrong place to fix it: names would be
|
||||
// identical over a different private network, so bundling them made one module out of two things.
|
||||
|
||||
func TestAMachineNotOnTheNetworkGetsNoNames(t *testing.T) {
|
||||
// Names resolve to addresses on the private wire. Giving them to a machine that is not on it
|
||||
// would point every lookup somewhere it cannot reach — worse than having no names at all.
|
||||
g := NamesFor([]Node{at("anchor", "dc", "10.42.0.1", "198.51.100.10:51820", true)})
|
||||
_, part, err := g.Resources("laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if part {
|
||||
t.Fatal("a machine that is not on the private network was given the mesh's names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNamesAreOneFileAndSayWhoIsAsking(t *testing.T) {
|
||||
g := NamesFor([]Node{
|
||||
at("anchor", "dc", "10.42.0.1", "198.51.100.10:51820", true),
|
||||
at("workstation", "house", "10.42.0.2", "", false),
|
||||
})
|
||||
out, part, err := g.Resources("workstation")
|
||||
if err != nil || !part {
|
||||
t.Fatalf("part=%v err=%v", part, err)
|
||||
}
|
||||
if len(out) != 1 || out[0]["path"] != HostsPath {
|
||||
t.Fatalf("got %v", out)
|
||||
}
|
||||
content := out[0]["content"].(string)
|
||||
if !strings.Contains(content, "anchor.internal") {
|
||||
t.Fatalf("another machine on the network has no name here:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "this machine") {
|
||||
t.Fatalf("the file does not say which machine it is on:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWireGuardDeclarationNoLongerCarriesTheNames(t *testing.T) {
|
||||
// The split, asserted. Two modules, so a machine can have the peers from one and the names
|
||||
// from another — which is what makes a second VPN possible at all.
|
||||
raw, err := Declaration(
|
||||
at("workstation", "house", "10.42.0.2", "", false),
|
||||
[]Peer{{Name: "anchor", Key: "PUB", Allowed: "10.42.0.0/16",
|
||||
Endpoint: "198.51.100.10:51820"}}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), HostsPath) {
|
||||
t.Fatalf("the WireGuard declaration still writes %s", HostsPath)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user