Self-upgrade installer: SDK-by-version, mesh-controller rename, foundation adopted #26
@@ -36,8 +36,12 @@ import (
|
||||
func providedModules() []catalogue.Manifest {
|
||||
var out []catalogue.Manifest
|
||||
for _, raw := range []map[string]any{
|
||||
overlay.Manifest(), overlay.NamesManifest(), overlay.ResolverManifest(),
|
||||
overlay.DomainManifest(),
|
||||
// **Two used to be here and are gone**: one wrote the mesh's names into a hosts file, the
|
||||
// other wrote the same machines as wildcards for a resolver to read. Neither ran software
|
||||
// and neither could be swapped for anything, which is the test of whether a thing is a
|
||||
// module at all (novox/hq ADR 0040). They existed because computed output needed somewhere
|
||||
// to live, and now a module says where it wants it — `facts` in its own manifest.
|
||||
overlay.Manifest(), overlay.DomainManifest(),
|
||||
} {
|
||||
var m catalogue.Manifest
|
||||
b, _ := json.Marshal(raw)
|
||||
|
||||
@@ -231,12 +231,13 @@ func generators(ctx context.Context, open *stores) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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.
|
||||
// **One generator now.** Two more used to sit beside it — the names and a resolver's zone
|
||||
// file — as modules that ran nothing. Both are facts a module asks for in its manifest
|
||||
// (`facts:` — catalogue.FactsInto), computed from the same machines this sees: the ones on the
|
||||
// private network, because a name for a machine not on it would resolve to an address nothing
|
||||
// can reach.
|
||||
return map[string]catalogue.Generator{
|
||||
overlay.Name: net,
|
||||
overlay.Names: overlay.NamesFor(net.Nodes()),
|
||||
overlay.Resolver: overlay.ResolverFor(net.Nodes()),
|
||||
overlay.Name: net,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"module": "dnsmasq",
|
||||
"version": "1",
|
||||
"requires": [
|
||||
"resolver-data"
|
||||
],
|
||||
"provides": [
|
||||
"wildcard-resolution"
|
||||
],
|
||||
@@ -43,8 +40,11 @@
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"config",
|
||||
"mesh-resolver.nodes"
|
||||
"dnsmasq.fact-node-zones"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"facts": {
|
||||
"node-zones": "/etc/mesh-resolver/nodes.conf"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,13 +64,16 @@ func TestTheResolverReadsTheMeshsNamesAndFollowsThem(t *testing.T) {
|
||||
if config == nil || service == nil {
|
||||
t.Fatal("the module has no configuration or no service")
|
||||
}
|
||||
if !strings.Contains(config["content"].(string), overlay.ResolverPath) {
|
||||
t.Fatalf("it does not read what the mesh writes at %s", overlay.ResolverPath)
|
||||
// The zone file is a FACT the module asks for, at a path it chose. The mesh writes it there;
|
||||
// what reads it and how is this module's own business, which is the whole shape.
|
||||
const zones = "/etc/mesh-resolver/nodes.conf"
|
||||
if !strings.Contains(config["content"].(string), zones) {
|
||||
t.Fatalf("it does not read what the mesh writes at %s", zones)
|
||||
}
|
||||
|
||||
var follows bool
|
||||
for _, id := range service["restart-on"].([]any) {
|
||||
if id.(string) == overlay.Resolver+".nodes" {
|
||||
if id.(string) == "dnsmasq.fact-node-zones" {
|
||||
follows = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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