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:
2026-09-15 21:31:18 +02:00
parent 18ec632baa
commit fcdb065660
13 changed files with 348 additions and 344 deletions
+13
View File
@@ -498,6 +498,19 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
}
out = append(out, copied)
}
// **What only the mesh knows, where this module asked for it.** The graph is the control
// plane's; making a name resolve is the module's software. Emitted as ordinary files under
// this module's name, so they are applied, reported and removed exactly as anything else
// it declares.
given, err := FactsInto(m, r, with.Names)
if err != nil {
return nil, err
}
for _, fact := range given {
fact["id"] = m.Module + "." + fmt.Sprint(fact["id"])
out = append(out, fact)
}
}
return out, nil
}
+145
View File
@@ -0,0 +1,145 @@
package catalogue
import (
"fmt"
"sort"
"strings"
)
// What only the mesh knows, written where a module asks for it.
//
// **The graph is the control plane's; using it is the module's.** The mesh knows which machines
// exist, what they are called, and where they are. Turning that into a name that resolves is
// somebody's software, and which software is a choice the mesh should not be making.
//
// This replaced three modules — names, a resolver's data, and the private network's own
// configuration — that existed only because computed output needed somewhere to live. They ran no
// software and could not be swapped for anything, which is the test of whether something is a
// module at all (novox/hq ADR 0040).
const (
// FactNodeNames is every machine's name and address, as a hosts file.
//
// Exact names only: `homer` and `homer.internal` resolve to homer. Anything *under* a machine
// is a wildcard, which a hosts file cannot express — that is FactNodeZones.
FactNodeNames = "node-names"
// FactNodeZones is every machine as a wildcard: `*.homer.internal` is homer.
//
// Written in the form a resolver reads. A machine's own name and everything under it are one
// fact — if homer is at an address, so is anything homer serves.
FactNodeZones = "node-zones"
)
// facts is every fact the mesh computes, and what writes it.
//
// **A closed list.** A module asking for a fact the mesh does not have is asking for a file nobody
// will write, and finding that out on a machine — as a daemon that starts, reads nothing, and
// answers no queries — is worse than being told where the manifest is.
var facts = map[string]func(Resolution, map[string]string) string{
FactNodeNames: nodeNames,
FactNodeZones: nodeZones,
}
// FactsInto renders the facts a module asked for, as files it will be given.
//
// The module owns everything after the file exists: loading it, restarting on it, what a resolver
// does with it. This only puts it there.
func FactsInto(m Manifest, r Resolution, addresses map[string]string) ([]map[string]any, error) {
if len(m.Facts) == 0 {
return nil, nil
}
names := make([]string, 0, len(m.Facts))
for name := range m.Facts {
names = append(names, name)
}
sort.Strings(names)
out := make([]map[string]any, 0, len(names))
for _, name := range names {
write, known := facts[name]
if !known {
return nil, fmt.Errorf(
"%s asks the mesh for %q, which it does not compute. It has %s",
m.Module, name, spokenFacts())
}
path := m.Facts[name]
if !strings.HasPrefix(path, "/") {
return nil, fmt.Errorf(
"%s asks for %q at %q, which is not an absolute path", m.Module, name, path)
}
out = append(out, map[string]any{
"id": "fact-" + name, "type": "file", "path": path, "mode": "0644",
"content": write(r, addresses),
})
}
return out, nil
}
// spokenFacts lists them, so a refusal says what would have worked.
func spokenFacts() string {
names := make([]string, 0, len(facts))
for name := range facts {
names = append(names, name)
}
sort.Strings(names)
return strings.Join(names, ", ")
}
// nodeNames is every machine's name and address, as a hosts file.
//
// **A machine with no address is left out.** The mesh has a record for it — somebody added it —
// and does not yet know where it is, which is the ordinary state between adding a machine and it
// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to
// that hangs; leaving it out fails at once and says the name is unknown.
func nodeNames(r Resolution, addresses map[string]string) string {
var b strings.Builder
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n")
b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n")
// The floor every Linux expects, and which removing would break things that have nothing to do
// with the mesh.
b.WriteString("127.0.0.1\tlocalhost\n")
b.WriteString("::1\t\tlocalhost ip6-localhost ip6-loopback\n")
if r.Node != "" {
fmt.Fprintf(&b, "127.0.1.1\t%s\n", r.Node)
}
b.WriteString("\n")
for _, name := range sortedNames(addresses) {
at := addresses[name]
// Its mesh name resolves to its address on the private network rather than to loopback,
// so a service binding the name it was given stays reachable from everywhere else.
fmt.Fprintf(&b, "%s\t%s.internal\t%s", at, name, name)
if name == r.Node {
b.WriteString("\t# this machine")
}
b.WriteString("\n")
}
return b.String()
}
// nodeZones is every machine as a wildcard, in the form a resolver reads.
//
// `*.homer.internal` is homer, which is the whole rule: if homer is at an address, so is anything
// homer serves. A module wanting this runs the resolver; the mesh only says what is true.
func nodeZones(_ Resolution, addresses map[string]string) string {
var b strings.Builder
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n")
b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n")
for _, name := range sortedNames(addresses) {
fmt.Fprintf(&b, "address=/%s.internal/%s\n", name, addresses[name])
}
return b.String()
}
func sortedNames(addresses map[string]string) []string {
out := make([]string, 0, len(addresses))
for name, at := range addresses {
// See nodeNames: a machine the mesh cannot place is left out rather than named at nothing.
if at == "" {
continue
}
out = append(out, name)
}
sort.Strings(out)
return out
}
+97
View File
@@ -0,0 +1,97 @@
package catalogue
import (
"strings"
"testing"
)
var threeMachines = map[string]string{"homer": "10.42.0.1", "marge": "10.42.0.2", "bart": ""}
// **`*.homer.internal` is homer. That is the whole rule.**
func TestEveryMachineIsAWildcardUnderItsOwnName(t *testing.T) {
out := nodeZones(Resolution{Node: "homer"}, threeMachines)
for _, want := range []string{
"address=/homer.internal/10.42.0.1",
"address=/marge.internal/10.42.0.2",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
// A machine the mesh has a record for and cannot place is left out of both.
//
// **Not an oversight — the alternative is worse.** A name written with no address resolves to
// nothing, and a connection to that hangs. Leaving it out fails at once and says the name is
// unknown, which is a thing somebody can act on.
func TestAMachineWithNoAddressIsNotNamed(t *testing.T) {
for _, out := range []string{
nodeNames(Resolution{Node: "homer"}, threeMachines),
nodeZones(Resolution{Node: "homer"}, threeMachines),
} {
if strings.Contains(out, "bart") {
t.Fatalf("a machine with no address was named, so its name resolves to nothing:\n%s", out)
}
}
}
// A machine's own mesh name points at its address on the private network, not at loopback — or a
// service binding the name it was given is unreachable from everywhere else.
func TestAMachinesOwnNameIsItsMeshAddress(t *testing.T) {
out := nodeNames(Resolution{Node: "homer"}, threeMachines)
var line string
for _, l := range strings.Split(out, "\n") {
if strings.Contains(l, "homer.internal") {
line = l
}
}
if !strings.HasPrefix(line, "10.42.0.1") {
t.Fatalf("a machine's own mesh name is not its mesh address: %q", line)
}
// And the loopback floor is still there, or things with nothing to do with the mesh break.
if !strings.Contains(out, "127.0.0.1\tlocalhost") {
t.Fatalf("the loopback floor was removed:\n%s", out)
}
}
// A module says where it wants a fact, and is given a file.
func TestAModuleIsGivenTheFactsItAskedFor(t *testing.T) {
m := Manifest{Module: "dnsmasq", Facts: map[string]string{FactNodeZones: "/etc/mesh/zones.conf"}}
given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines)
if err != nil {
t.Fatal(err)
}
if len(given) != 1 {
t.Fatalf("expected one file, got %d", len(given))
}
if given[0]["path"] != "/etc/mesh/zones.conf" || given[0]["type"] != "file" {
t.Fatalf("not written where it was asked for: %v", given[0])
}
if !strings.Contains(given[0]["content"].(string), "homer.internal") {
t.Fatalf("the file does not hold the fact: %v", given[0]["content"])
}
}
// **Asking for a fact the mesh does not have is refused here, not on a machine.** A daemon that
// starts, reads a file nobody wrote, and answers no queries is a much worse way to find out.
func TestAskingForAFactTheMeshDoesNotHaveIsRefused(t *testing.T) {
m := Manifest{Module: "dnsmasq", Facts: map[string]string{"the-weather": "/etc/weather"}}
_, err := FactsInto(m, Resolution{}, nil)
if err == nil {
t.Fatal("a module asked for something nobody computes and was given nothing, silently")
}
for _, known := range []string{FactNodeNames, FactNodeZones} {
if !strings.Contains(err.Error(), known) {
t.Fatalf("the refusal does not say what would have worked: %v", err)
}
}
}
// And a relative path is refused, or a module decides where the mesh writes on a machine.
func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) {
m := Manifest{Module: "dnsmasq", Facts: map[string]string{FactNodeNames: "etc/hosts"}}
if _, err := FactsInto(m, Resolution{}, nil); err == nil {
t.Fatal("a relative path was accepted")
}
}
+19
View File
@@ -315,6 +315,25 @@ type Manifest struct {
// that could only see its own ports would write a rule set that closed everything else.
Filtering *Filtering `json:"filtering,omitempty"`
// Facts are things only the mesh knows, written where this module asks for them.
//
// **The graph is the control plane's; how a machine uses it is the module's.** The mesh knows
// which machines exist, what they are called and where they are. Making a name resolve, or a
// peer reachable, is somebody's software — dnsmasq, a resolver, a VPN — and the mesh has no
// business shipping one, choosing which, or knowing its configuration language.
//
// So a module says *put the node names here* and owns everything after that. The same shape as
// `filtering`, generalised: a fact, and a path.
//
// It replaces three modules that existed only because computed output needed somewhere to
// live — they ran no software, could not be swapped for anything, and appeared in the graph as
// modules while being a data channel wearing a costume.
//
// Keyed by fact name; the names are a closed list, because a module asking for one the mesh
// does not compute is asking for something nobody will write, and finding that out on a machine
// is worse than being told here.
Facts map[string]string `json:"facts,omitempty"`
// Certificate is where this module wants a certificate for its machine's name inside the
// mesh, and where the key that goes with it can be found.
//
+38 -20
View File
@@ -19,7 +19,7 @@ func provided(t *testing.T) map[string]catalogue.Manifest {
t.Helper()
out := map[string]catalogue.Manifest{}
for _, raw := range []map[string]any{
overlay.Manifest(), overlay.NamesManifest(), overlay.DomainManifest(),
overlay.Manifest(), overlay.DomainManifest(),
} {
b, err := json.Marshal(raw)
if err != nil {
@@ -44,22 +44,51 @@ func TestTheShippedNetworkingModulesResolveOnTheirOwn(t *testing.T) {
for _, m := range got.Modules {
have = append(have, m.Module)
}
for _, want := range []string{overlay.Domain, overlay.Name, overlay.Names} {
for _, want := range []string{overlay.Domain, overlay.Name} {
if !strings.Contains(strings.Join(have, " "), want) {
t.Fatalf("%s did not bring in %s: %v", overlay.Domain, want, have)
}
}
// **The names come WITH the network now, not from a third module.** Being on the private
// network is what gives a machine a name, so the provider asks for the node-names fact and
// there is nothing else to bring in. A module that ran nothing used to be here.
for _, m := range got.Modules {
if m.Module == overlay.Name && m.Facts["node-names"] == "" {
t.Fatalf("the network's provider does not ask for the names: %+v", m.Facts)
}
}
}
func TestTheShippedWireGuardModuleClaimsBeingTheNetwork(t *testing.T) {
// Without this, a person who chose another VPN gets WireGuard as well, dragged in by the
// names, and is not told. The claim is the only thing that catches it.
func TestAnotherVPNSatisfiesNetworkingWithoutDraggingWireGuardIn(t *testing.T) {
// **This inverted, and the inversion is the improvement.** The names used to be a module that
// required the mesh's own addressing, which only WireGuard provided — so choosing another VPN
// dragged WireGuard in anyway, and the node-scoped claim existed to at least make that
// collision loud. With the names a fact rather than a provision, a person who chose tailscale
// gets tailscale, and there is nothing left to collide.
shipped := provided(t)
_, err := catalogue.Resolve(
got, err := catalogue.Resolve(
withTailscale(shipped),
[]string{overlay.Domain, "tailscale"},
catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{})
if err != nil {
t.Fatalf("choosing another VPN was refused: %v", err)
}
for _, m := range got.Modules {
if m.Module == overlay.Name {
t.Fatalf("the other VPN was chosen and WireGuard came anyway: %v", got.Modules)
}
}
}
func TestTwoVPNsAssignedTogetherStillCollide(t *testing.T) {
// The claim still guards the case it was always for: both assigned EXPLICITLY, which is a
// machine with two private networks and a coin toss about which one a peer reaches it on.
shipped := provided(t)
_, err := catalogue.Resolve(
withTailscale(shipped),
[]string{overlay.Name, "tailscale"},
catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{})
if err == nil {
t.Fatal("a machine was given two private networks and nobody was told")
}
@@ -68,20 +97,9 @@ func TestTheShippedWireGuardModuleClaimsBeingTheNetwork(t *testing.T) {
}
}
func TestTheShippedNamesModuleNeedsTheMeshsOwnAddresses(t *testing.T) {
// Over a VPN whose addresses the mesh does not hand out, it has no names to write. Refusing
// is what stops a machine getting a hosts file that means nothing on it.
shipped := provided(t)
delete(shipped, overlay.Name)
_, err := catalogue.Resolve(shipped, []string{overlay.Names}, catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{})
if err == nil {
t.Fatal("the mesh's names resolved with nothing handing out the mesh's addresses")
}
if !strings.Contains(err.Error(), overlay.Addressing) {
t.Fatalf("the refusal does not name what is missing: %v", err)
}
}
// The names-need-addressing test went with the names module: names are a fact now, and a machine
// the mesh cannot place is simply left out of the file (facts_test.go) — which is the same
// protection, enforced where the file is written rather than by a provision refusing.
func withTailscale(shelf map[string]catalogue.Manifest) map[string]catalogue.Manifest {
out := map[string]catalogue.Manifest{}
+13 -67
View File
@@ -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},
}
}
-61
View File
@@ -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)
}
}
-82
View File
@@ -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
}
-99
View File
@@ -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)
}
}