diff --git a/internal/catalogue/facts.go b/internal/catalogue/facts.go deleted file mode 100644 index c28ce17..0000000 --- a/internal/catalogue/facts.go +++ /dev/null @@ -1,232 +0,0 @@ -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 lines of a hosts file. - // - // Written *into* the machine's hosts file as a region of its own, never as the file: the rest - // of that file is the distribution's, the operator's and other tools' (novox/hq issue 128). - // 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" -) - -// fact is one thing the mesh computes, and how it is written. -type fact struct { - // render is the fact's content. A fact is written from the names it is about. `every` is every - // name the mesh serves — machines and the names it was told to route; `machines` is only the - // machines. A fact takes the set it is true of, and the two must not be confused - // (novox/hq 04-ISSUES/111). - render func(r Resolution, every, machines map[string]string, suffix string) string - // shared is whether the file the fact goes to belongs to the machine rather than to the mesh. - // - // **A property of the fact, not of the path a module asked for.** A hosts file is the - // machine's wherever it lives: the distribution put `localhost` in it, the operator added - // their own lines, and a local development tool keeps marked blocks of its own there. The - // mesh writing it whole replaced all of that the moment the private network was taken, and - // every later write by the other tool was lost at the next machine joining — silently, with - // both sides believing they owned the file (novox/hq issue 128). That is ADR 0102's failure in - // a file 0102 did not name, because its merge is structured and a hosts file is not; the - // answer is the same idea for text — a marked region the host owns, everything outside it - // kept byte for byte. A resolver's zones file is the other way about: the mesh owns it, and - // nothing else writes there. - shared bool -} - -// facts is every fact the mesh computes, and how each is written. -// -// **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]fact{ - FactNodeNames: { - render: func(r Resolution, every, _ map[string]string, suffix string) string { - return nodeNames(r, every, suffix) - }, - shared: true, - }, - FactNodeZones: { - render: func(r Resolution, _, machines map[string]string, suffix string) string { - return nodeZones(r, machines, suffix) - }, - }, -} - -// 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, machines map[string]string, suffix 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 { - f, 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) - } - file := map[string]any{ - "id": "fact-" + name, "type": "file", "path": path, "mode": "0644", - "content": f.render(r, addresses, machines, suffix), - } - if f.shared { - // The host owns only the lines between `# BEGIN mesh ` and `# END mesh ` and - // keeps the rest of the file byte for byte; undeclared, the region goes and nothing - // else does. Replacing nothing, it is written on an adopted node without being held, - // so a machine is named on the private network before its module is taken. - // - // **Hosts first, then this — an order to roll out in, not a note.** A host older than - // the block mode refuses the whole declaration on an `into` it does not know: not just - // this file, everything the node was sent, so it stops converging on anything at all. - // Every node on the private network receives this fact, so every node's host — - // the controller's own machine included, which would otherwise stop taking the - // declaration that runs the controller — must run a block-aware host before a - // controller emitting it is rolled out. The same order ADR 0102 set for `into: json` - // (novox/hq issue 128). - file["into"] = "block" - } - out = append(out, file) - } - 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 the mesh's region of a hosts file. -// -// **Only the mesh's names.** No header claiming the file, no `localhost`, no `127.0.1.1` line for -// the machine itself. Those used to be written here as the floor every Linux expects, because the -// mesh wrote the whole file and removing them would have broken things with nothing to do with -// the mesh. They were never the mesh's: the distribution wrote them before the mesh arrived and -// will want them after it leaves, and a machine's own name belongs to whoever named the machine. -// Now the host writes this into a marked region (novox/hq issue 128) and leaves the rest of the -// file as it found it, so the floor stays where it always was — the machine's — and the mesh -// writing a second `localhost` beside it would be one more line nobody could say the owner of. -// The one comment line is for a person reading the file: which lines are the mesh's, and that -// editing them is pointless. -// -// **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, suffix string) string { - var b strings.Builder - b.WriteString("# The mesh's names. This region is replaced whenever a machine joins or leaves.\n") - for _, name := range sortedNames(addresses) { - at := addresses[name] - internal, bare := meshName(name, suffix) - // 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. The - // bare name may be answered first by a line of the machine's own — `127.0.1.1 homer`, - // above the region — and that is the machine's choice to have made; the mesh name is the - // one nothing else in the file writes. - fmt.Fprintf(&b, "%s\t%s\t%s", at, internal, bare) - if bare == 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. -// -// **And the suffix itself, as a local domain.** A resolver that forwards what it cannot answer -// would otherwise send a mesh name it does not know — a machine that left, a typo — to a public -// resolver, which is a leak of the mesh's names for no answer. `local=` keeps everything under the -// suffix here: answered from the lines below or refused. Written in this file rather than in the -// resolver's own configuration because the suffix is the mesh's choice (the operator may have -// picked another) and this file is the one place the mesh writes what it chose. -func nodeZones(_ Resolution, addresses map[string]string, suffix 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") - fmt.Fprintf(&b, "local=/%s/\n", strings.TrimPrefix(suffixOr(suffix), ".")) - for _, name := range sortedNames(addresses) { - internal, _ := meshName(name, suffix) - fmt.Fprintf(&b, "address=/%s/%s\n", internal, addresses[name]) - } - return b.String() -} - -// meshName is a machine's internal name and its bare one, from either. The control plane keys -// the names it hands a resolution by the internal name (`homer.internal`), the same map a -// container gets as its hosts; a caller that keys by the bare name gets the same answer. The -// suffix is the one the control plane composed those names with, handed down rather than written -// here a second time — the alternative was `homer.internal.internal` on every machine. -func meshName(name, suffix string) (internal, bare string) { - dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".") - if strings.HasSuffix(name, dotted) { - return name, strings.TrimSuffix(name, dotted) - } - return name + dotted, name -} - -// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down. -// The one place the default is written in this file, so a fact and a name cannot disagree about it. -func suffixOr(suffix string) string { - if suffix == "" { - return "internal" - } - return suffix -} - -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 -} diff --git a/internal/catalogue/facts_test.go b/internal/catalogue/facts_test.go deleted file mode 100644 index 577b309..0000000 --- a/internal/catalogue/facts_test.go +++ /dev/null @@ -1,239 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// Keyed by the internal name, as the control plane hands them (issue 079). -var threeMachines = map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", "bart.internal": ""} - -// **`*.homer.internal` is homer. That is the whole rule.** And the suffix itself is local: a -// resolver that forwards what it cannot answer must not send a mesh name it does not know — a -// machine that left, a typo — to a public resolver (hal dnsmasq-app conversion, novox/hq -// 08-connectivity). -func TestEveryMachineIsAWildcardUnderItsOwnName(t *testing.T) { - out := nodeZones(Resolution{Node: "homer"}, threeMachines, "") - for _, want := range []string{ - "local=/internal/", - "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) - } -} - -// novox/hq issue 128: the names are the mesh's region of the machine's hosts file, and only that. -// -// **The loopback floor is the machine's now, not the mesh's.** It was written here while the mesh -// wrote the whole file. Written into a region, a `localhost` or a `127.0.1.1 homer` of the mesh's -// would stand beside the distribution's own — a second answer nobody could say the owner of, and -// one that goes when the mesh leaves. A header claiming the file would be a lie about the rest of -// it. And no line may look like the host's own markers, or the host would refuse the region. -func TestTheNamesAreOnlyTheMeshsRegionOfTheFile(t *testing.T) { - out := nodeNames(Resolution{Node: "homer"}, threeMachines, "") - want := "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" + - "10.42.0.1\thomer.internal\thomer\t# this machine\n" + - "10.42.0.2\tmarge.internal\tmarge\n" - if out != want { - t.Fatalf("the region is not exactly the mesh's names:\n%s\n--- want ---\n%s", out, want) - } - for _, floor := range []string{"localhost", "127.0.", "::1", "Generated by the mesh", "# BEGIN mesh ", "# END mesh "} { - if strings.Contains(out, floor) { - t.Fatalf("the region carries %q, which is not the mesh's to write:\n%s", floor, out) - } - } -} - -// The hosts file is written into; the resolver's zones are written whole. A property of each fact, -// so a module asking for node-names at another path still does not get a file of the mesh's. -func TestOnlyTheNamesAreWrittenIntoASharedFile(t *testing.T) { - m := Manifest{Module: "resolver", Facts: map[string]string{ - FactNodeZones: "/etc/mesh-resolver/nodes.conf", FactNodeNames: "/etc/hosts", - }} - given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, "") - if err != nil { - t.Fatal(err) - } - if len(given) != 2 { - t.Fatalf("expected two files, got %v", given) - } - for _, f := range given { - switch f["path"] { - case "/etc/hosts": - if f["into"] != "block" { - t.Errorf("the hosts file is written over rather than into: %v", f) - } - case "/etc/mesh-resolver/nodes.conf": - // The mesh owns the resolver's zones; nothing else writes there. - if into, set := f["into"]; set { - t.Errorf("the zones file is written into (%v), and it is the mesh's whole", into) - } - if !strings.HasPrefix(f["content"].(string), "# Generated by the mesh.") { - t.Errorf("the zones file lost its header: %v", f["content"]) - } - default: - t.Errorf("a file nobody asked for: %v", f) - } - } -} - -// 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, 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, 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, nil, ""); err == nil { - t.Fatal("a relative path was accepted") - } -} - -// **The names the control plane hands a resolution are already internal names** — `homer.internal`, -// the same map every container gets as its hosts. Appending the suffix again wrote -// `homer.internal.internal` into every hosts file and every resolver's zones, and the large mesh -// bed's name test was the first to read it back. Either key gives the same files. -func TestNamesKeyedByInternalNameAreNotSuffixedTwice(t *testing.T) { - internal := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} - bare := map[string]string{"homer": "10.42.0.1", "marge": "10.42.0.2"} - if a, b := nodeZones(Resolution{Node: "homer"}, internal, ""), nodeZones(Resolution{Node: "homer"}, bare, ""); a != b { - t.Fatalf("the zones differ by how the names were keyed:\n%s\n---\n%s", a, b) - } - if a, b := nodeNames(Resolution{Node: "homer"}, internal, ""), nodeNames(Resolution{Node: "homer"}, bare, ""); a != b { - t.Fatalf("the hosts differ by how the names were keyed:\n%s\n---\n%s", a, b) - } - zones := nodeZones(Resolution{Node: "homer"}, internal, "") - if strings.Contains(zones, "internal.internal") || !strings.Contains(zones, "address=/homer.internal/10.42.0.1") { - t.Fatalf("the zones carry a doubled suffix or miss the name:\n%s", zones) - } - hosts := nodeNames(Resolution{Node: "homer"}, internal, "") - if !strings.Contains(hosts, "10.42.0.1\thomer.internal\thomer\t# this machine") { - t.Fatalf("the hosts line for the machine itself is not name, bare name and the mark:\n%s", hosts) - } -} - -// The suffix the control plane composed the names with is the one the facts write — an operator -// who chose another does not get `.internal` appended to it. -func TestTheFactsWriteTheSuffixTheNamesWereComposedWith(t *testing.T) { - names := map[string]string{"homer.lan": "10.42.0.1"} - zones := nodeZones(Resolution{Node: "homer"}, names, "lan") - if !strings.Contains(zones, "address=/homer.lan/10.42.0.1") || strings.Contains(zones, "internal") { - t.Fatalf("the zones do not carry the operator's suffix as given:\n%s", zones) - } - if !strings.Contains(zones, "local=/lan/") { - t.Fatalf("the local domain is not the operator's suffix, so its names would leak upstream:\n%s", zones) - } - hosts := nodeNames(Resolution{Node: "homer"}, names, "lan") - if !strings.Contains(hosts, "10.42.0.1\thomer.lan\thomer\t# this machine") { - t.Fatalf("the hosts line does not carry the operator's suffix as given:\n%s", hosts) - } -} - -// novox/hq 04-ISSUES/111: the map the control plane hands a resolution holds every name the mesh -// serves — the machines, and the names it was told to route to whichever machine serves them. A -// container's hosts wants all of it. A resolver's zones want only the machines: told the mesh's -// suffix is its own, it answers authoritatively for everything under it and forwards nothing, so a -// routed name written there with the suffix appended is a name nobody will ever ask for, standing -// beside the machines and looking as real. -func TestTheResolverIsToldTheMachinesAndNotTheNamesTheMeshMerelyServes(t *testing.T) { - machines := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} - every := map[string]string{ - "homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", - "drive.example.test": "10.42.0.1", "git.example.test": "10.42.0.2", - } - m := Manifest{Module: "resolver", Facts: map[string]string{ - FactNodeZones: "/etc/zones.conf", FactNodeNames: "/etc/hosts", - }} - given, err := FactsInto(m, Resolution{Node: "homer"}, every, machines, "") - if err != nil { - t.Fatal(err) - } - by := map[string]string{} - for _, f := range given { - by[f["path"].(string)] = f["content"].(string) - } - - zones := by["/etc/zones.conf"] - for _, machine := range []string{"address=/homer.internal/10.42.0.1", "address=/marge.internal/10.42.0.2"} { - if !strings.Contains(zones, machine) { - t.Fatalf("the resolver was not told %q:\n%s", machine, zones) - } - } - for _, served := range []string{"drive.example.test", "git.example.test"} { - if strings.Contains(zones, served) { - t.Fatalf("the resolver was told %q, a name the mesh serves rather than a machine:\n%s", served, zones) - } - } - - // And the hosts file is the other way about: every name, so a container reaching a routed name - // finds the machine serving it. - hosts := by["/etc/hosts"] - for _, name := range []string{"homer.internal", "drive.example.test", "git.example.test"} { - if !strings.Contains(hosts, name) { - t.Fatalf("a container would not resolve %q from its hosts:\n%s", name, hosts) - } - } -} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index e73da8b..5702437 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -366,24 +366,29 @@ type Manifest struct { // firewall does. Ignored on a converged node, whose derived filter already closes them. Guards []int `json:"guards,omitempty"` - // Facts are things only the mesh knows, written where this module asks for them. + // Facts are things only the mesh knows, written where this module asks for them — in the + // module's own format. // - // **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. + // **The graph is the control plane's; the format 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, a peer that is reachable, a host a client trusts, is somebody's software — dnsmasq, + // a resolver, a VPN, ssh — in its own configuration language, and the mesh has no business + // knowing it. So a module gives a path and a template; the mesh renders the roster through it + // and owns nothing of what the file says. // - // 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. + // This used to be a closed list of fact names, each formatted in Go in the control plane, so a + // new consumer meant a new formatter here in the consumer's language. Now the data is the mesh's + // and the format is the module's: the two built-in cases — the network module's `/etc/hosts` and + // dnsmasq's zones — render through the same template path any module uses, and no format lives + // in the control plane at all. See RosterFile for what a template sees. // // 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"` + // Keyed by a name the module chooses, which is the rendered file's id (`fact-`) — what a + // `restart-on` names to restart when the roster changes. + Facts map[string]RosterFile `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. diff --git a/internal/catalogue/provided_test.go b/internal/catalogue/provided_test.go index 17719e6..029b19d 100644 --- a/internal/catalogue/provided_test.go +++ b/internal/catalogue/provided_test.go @@ -54,7 +54,7 @@ func TestTheShippedNetworkingModulesResolveOnTheirOwn(t *testing.T) { // 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"] == "" { + if m.Module == overlay.Name && m.Facts["node-names"].Path == "" { t.Fatalf("the network's provider does not ask for the names: %+v", m.Facts) } } diff --git a/internal/catalogue/resolver_manifests_test.go b/internal/catalogue/resolver_manifests_test.go index 3336380..82d2bf0 100644 --- a/internal/catalogue/resolver_manifests_test.go +++ b/internal/catalogue/resolver_manifests_test.go @@ -50,7 +50,7 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T "\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n", "\nlisten-address=127.0.0.1\n", "\ninterface=mesh0\n", "\nbind-dynamic\n", "\ndomain-needed\n", "\nbogus-priv\n", - "\nconf-file=" + m.Facts[FactNodeZones] + "\n", + "\nconf-file=" + m.Facts["node-zones"].Path + "\n", } { if !strings.Contains(config, want) { t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config) diff --git a/internal/catalogue/roster.go b/internal/catalogue/roster.go new file mode 100644 index 0000000..48db115 --- /dev/null +++ b/internal/catalogue/roster.go @@ -0,0 +1,181 @@ +package catalogue + +import ( + "bytes" + "fmt" + "sort" + "strings" + "text/template" +) + +// What only the mesh knows, written where a module asks for it — in the module's own format. +// +// **The graph is the control plane's; the format is the module's.** The mesh knows which machines +// exist, what they are called and where they are. Turning that into a hosts file, a resolver's +// zones, an ssh known_hosts is somebody's configuration language, and the mesh has no business +// knowing it. So the mesh hands the roster to a template the module wrote and renders it; it never +// learns what the file means. +// +// This used to be a closed list of fact names, each with its format written in Go here — a hosts +// file, a resolver's zones. Every new consumer meant a new formatter in the control plane, in the +// consumer's configuration language. Now the data is the mesh's and the format is a template the +// module ships: the two built-in cases (the network module's `/etc/hosts`, dnsmasq's zones) render +// the same way any module's would, and the control plane holds no format at all. +// +// It replaced 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 (novox/hq ADR 0040). + +// A RosterFile is a file the mesh renders from the roster of machines, in the format the module +// gives as a Go text/template. The template sees a rosterView: `.Node` (this machine's bare name), +// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names`, every +// name the mesh serves, and `.Machines`, only the nodes of the mesh. Which set a template ranges is +// how the hq issue 111 distinction is drawn: a container's hosts wants every name; a resolver told +// the suffix is its own wants only the machines. +type RosterFile struct { + // Path is where on the machine the rendered file goes. Absolute, or it is refused here rather + // than discovered as a daemon that reads nothing. + Path string `json:"path"` + // Template is the module's format, a Go text/template over the rosterView. It is the module's, + // not the mesh's: the mesh renders it and does not read it. + Template string `json:"template"` + // Shared is whether the file the fact goes to belongs to the machine rather than the mesh. When + // it does, the mesh owns only a marked region of it and keeps the rest byte for byte (novox/hq + // issue 128) — a hosts file is shared, since the distribution's `localhost`, the operator's own + // lines and other tools' blocks live there too; a resolver's zones file is not, the mesh owns it + // whole. A property of the fact, not of the path: the format determines whether the file is + // wholly the mesh's, not where a module happened to ask for it. + Shared bool `json:"shared,omitempty"` +} + +// rosterView is what a RosterFile's template sees. A closed shape — a template referencing a field +// the mesh does not compute fails to render here, not on a machine. +type rosterView struct { + Node string + Suffix string + Names []rosterEntry + Machines []rosterEntry +} + +// rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address. +type rosterEntry struct { + Name string + FQDN string + Address string +} + +// FactsInto renders the roster files 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 +// or a client does with it. This only puts it there. `every` is every name the mesh serves; +// `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both +// are given and the template chooses. +func FactsInto(m Manifest, r Resolution, every, machines map[string]string, suffix 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) + + view := rosterView{ + Node: r.Node, + Suffix: strings.TrimPrefix(suffixOr(suffix), "."), + Names: entriesFrom(every, suffix), + Machines: entriesFrom(machines, suffix), + } + + out := make([]map[string]any, 0, len(names)) + for _, name := range names { + fact := m.Facts[name] + if !strings.HasPrefix(fact.Path, "/") { + return nil, fmt.Errorf( + "%s asks for %q at %q, which is not an absolute path", m.Module, name, fact.Path) + } + content, err := renderRoster(fact.Template, view) + if err != nil { + return nil, fmt.Errorf("%s cannot render %q: %w", m.Module, name, err) + } + file := map[string]any{ + "id": "fact-" + name, "type": "file", "path": fact.Path, "mode": "0644", + "content": content, + } + if fact.Shared { + // The host owns only the lines between `# BEGIN mesh ` and `# END mesh ` and + // keeps the rest of the file byte for byte; undeclared, the region goes and nothing else + // does (novox/hq issue 128). Every node on the private network receives this, so every + // node's host — the controller's own machine included — must be block-aware before a + // controller emitting it is rolled out: the order ADR 0102 set for `into: json`. + file["into"] = "block" + } + out = append(out, file) + } + return out, nil +} + +// renderRoster runs a module's template over the roster. A template that will not parse, or reads +// a field the mesh does not have, is an error here — where the manifest is — rather than an empty +// file on a machine. +func renderRoster(tmpl string, view rosterView) (string, error) { + t, err := template.New("roster").Option("missingkey=error").Parse(tmpl) + if err != nil { + return "", err + } + var b bytes.Buffer + if err := t.Execute(&b, view); err != nil { + return "", err + } + return b.String(), nil +} + +// entriesFrom is a name→address map as sorted roster entries. +// +// **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 entriesFrom(addresses map[string]string, suffix string) []rosterEntry { + out := make([]rosterEntry, 0, len(addresses)) + for _, name := range sortedNames(addresses) { + internal, bare := meshName(name, suffix) + out = append(out, rosterEntry{Name: bare, FQDN: internal, Address: addresses[name]}) + } + return out +} + +// meshName is a machine's internal name and its bare one, from either. The control plane keys +// the names it hands a resolution by the internal name (`homer.internal`), the same map a +// container gets as its hosts; a caller that keys by the bare name gets the same answer. The +// suffix is the one the control plane composed those names with, handed down rather than written +// here a second time — the alternative was `homer.internal.internal` on every machine. +func meshName(name, suffix string) (internal, bare string) { + dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".") + if strings.HasSuffix(name, dotted) { + return name, strings.TrimSuffix(name, dotted) + } + return name + dotted, name +} + +// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down. +// The one place the default is written, so a fact and a name cannot disagree about it. +func suffixOr(suffix string) string { + if suffix == "" { + return "internal" + } + return suffix +} + +func sortedNames(addresses map[string]string) []string { + out := make([]string, 0, len(addresses)) + for name, at := range addresses { + // 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 +} diff --git a/internal/catalogue/roster_test.go b/internal/catalogue/roster_test.go new file mode 100644 index 0000000..252f99d --- /dev/null +++ b/internal/catalogue/roster_test.go @@ -0,0 +1,221 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Keyed by the internal name, as the control plane hands them (issue 079). bart has no address — +// the ordinary state between adding a machine and it joining. +var threeMachines = map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", "bart.internal": ""} + +// A module says where it wants a roster file and in what format, and is given the rendered file. +func TestAModuleIsGivenTheFileItAskedFor(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/mesh/zones.conf", Template: "{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\n{{end}}"}, + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, 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]) + } + // The id is fact-, which is what a restart-on names when the roster changes. + if given[0]["id"] != "fact-zones" { + t.Fatalf("the file's id is not fact-, so a restart-on cannot find it: %v", given[0]["id"]) + } + if !strings.Contains(given[0]["content"].(string), "address=/homer.internal/10.42.0.1") { + t.Fatalf("the file does not hold the fact: %v", given[0]["content"]) + } +} + +// **A shared fact is written into a region of the machine's file, not over it** (novox/hq issue +// 128). A hosts file is the machine's — its localhost, the operator's lines, other tools' blocks — +// so the mesh owns only a marked region (`into: block`); a resolver's zones file is the mesh's +// whole, and carries no `into`. +func TestASharedFactIsWrittenIntoARegion(t *testing.T) { + roster := map[string]string{"homer.internal": "10.42.0.1"} + m := Manifest{Module: "net", Facts: map[string]RosterFile{ + "node-names": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}", Shared: true}, + "node-zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"}, + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, roster, roster, "") + if err != nil { + t.Fatal(err) + } + by := map[string]map[string]any{} + for _, f := range given { + by[f["path"].(string)] = f + } + if by["/etc/hosts"]["into"] != "block" { + t.Fatalf("a shared fact is not written into a region, so the mesh writes the file whole: %v", by["/etc/hosts"]) + } + if _, has := by["/etc/zones"]["into"]; has { + t.Fatalf("an unshared fact was written into a region, so the mesh does not own its own file whole: %v", by["/etc/zones"]) + } +} + +// **The format is the module's — the mesh renders whatever template it gives.** The same roster +// through two templates is two entirely different files, and the control plane reads neither. +func TestTheFormatIsTheModulesOwn(t *testing.T) { + roster := map[string]string{"homer.internal": "10.42.0.1"} + hostsish := Manifest{Module: "a", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "{{range .Names}}{{.Address}}\t{{.Name}}\n{{end}}"}}} + sshish := Manifest{Module: "b", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "{{range .Names}}Host {{.Name}}\n HostName {{.FQDN}}\n{{end}}"}}} + + h, err := FactsInto(hostsish, Resolution{Node: "homer"}, roster, roster, "") + if err != nil { + t.Fatal(err) + } + s, err := FactsInto(sshish, Resolution{Node: "homer"}, roster, roster, "") + if err != nil { + t.Fatal(err) + } + if h[0]["content"] != "10.42.0.1\thomer\n" { + t.Fatalf("the hosts-shaped template did not render its format: %q", h[0]["content"]) + } + if s[0]["content"] != "Host homer\n HostName homer.internal\n" { + t.Fatalf("the ssh-shaped template did not render its format: %q", s[0]["content"]) + } +} + +// **A template that will not parse is refused here, not on a machine.** A daemon that starts, reads +// a file the mesh could not render, and answers nothing is a much worse way to find out. +func TestABrokenTemplateIsRefusedHere(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/zones", Template: "{{range .Machines}}oops"}}} + _, err := FactsInto(m, Resolution{}, nil, nil, "") + if err == nil { + t.Fatal("a template that does not parse was accepted, so the machine gets an empty file") + } + if !strings.Contains(err.Error(), "resolver") || !strings.Contains(err.Error(), "zones") { + t.Fatalf("the refusal does not say whose template, or which: %v", err) + } +} + +// A template reading something the mesh does not compute is refused, not rendered empty. The roster +// is a closed shape; asking it for the weather fails where the manifest is. +func TestATemplateReadingWhatTheMeshDoesNotHaveIsRefused(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/zones", Template: "{{.Weather}}"}}} + if _, err := FactsInto(m, Resolution{}, nil, nil, ""); err == nil { + t.Fatal("a template read a field nobody computes and rendered anyway, silently") + } +} + +// A relative path is refused, or a module decides where the mesh writes on a machine. +func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "hosts": {Path: "etc/hosts", Template: "x"}}} + if _, err := FactsInto(m, Resolution{}, nil, nil, ""); err == nil { + t.Fatal("a relative path was accepted") + } +} + +// A machine the mesh has a record for and cannot place is left out of the roster. +// +// **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 TestAMachineWithNoAddressIsNotInTheRoster(t *testing.T) { + m := Manifest{Module: "a", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "{{range .Machines}}{{.Name}}\n{{end}}"}}} + given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, "") + if err != nil { + t.Fatal(err) + } + if strings.Contains(given[0]["content"].(string), "bart") { + t.Fatalf("a machine with no address was in the roster, so its name resolves to nothing:\n%s", given[0]["content"]) + } +} + +// **The names the control plane hands a resolution are already internal names** — `homer.internal`, +// the same map every container gets as its hosts. A roster entry's FQDN is that name, not it with +// the suffix appended a second time; either key gives the same entries. +func TestNamesAreNotSuffixedTwice(t *testing.T) { + internal := map[string]string{"homer.internal": "10.42.0.1"} + bare := map[string]string{"homer": "10.42.0.1"} + tmpl := RosterFile{Path: "/f", Template: "{{range .Machines}}{{.FQDN}} {{.Name}}\n{{end}}"} + + fromInternal, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, internal, internal, "") + if err != nil { + t.Fatal(err) + } + fromBare, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, bare, bare, "") + if err != nil { + t.Fatal(err) + } + if fromInternal[0]["content"] != fromBare[0]["content"] { + t.Fatalf("the roster differs by how the names were keyed:\n%q\n%q", fromInternal[0]["content"], fromBare[0]["content"]) + } + got := fromInternal[0]["content"].(string) + if strings.Contains(got, "internal.internal") || !strings.Contains(got, "homer.internal homer") { + t.Fatalf("the entry carries a doubled suffix or the wrong bare name:\n%s", got) + } +} + +// The suffix the control plane composed the names with is the one a template sees — an operator who +// chose another does not get `.internal`. `.Suffix` is the bare form, and FQDNs carry it. +func TestTheSuffixIsCarriedAsComposed(t *testing.T) { + names := map[string]string{"homer.lan": "10.42.0.1"} + m := Manifest{Module: "a", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "local=/{{.Suffix}}/\n{{range .Machines}}{{.FQDN}}\n{{end}}"}}} + given, err := FactsInto(m, Resolution{Node: "homer"}, names, names, "lan") + if err != nil { + t.Fatal(err) + } + got := given[0]["content"].(string) + if !strings.Contains(got, "local=/lan/") || strings.Contains(got, "internal") { + t.Fatalf("the operator's suffix was not carried, so its names would be wrong:\n%s", got) + } + if !strings.Contains(got, "homer.lan") { + t.Fatalf("the FQDN does not carry the operator's suffix:\n%s", got) + } +} + +// novox/hq 04-ISSUES/111: a template is given both sets and chooses. `.Names` is every name the mesh +// serves — the machines and the names it was told to route; `.Machines` is only the machines. A +// container's hosts wants every name so a routed name resolves to the machine serving it; a resolver +// told the suffix is its own wants only the machines, or a routed name written there with the suffix +// is a name nobody will ever ask for, standing beside the machines and looking as real. +func TestATemplateChoosesMachinesOrEveryName(t *testing.T) { + machines := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} + every := map[string]string{ + "homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", + "drive.example.test": "10.42.0.1", "git.example.test": "10.42.0.2", + } + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"}, + "hosts": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}"}, + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, every, machines, "") + if err != nil { + t.Fatal(err) + } + by := map[string]string{} + for _, f := range given { + by[f["path"].(string)] = f["content"].(string) + } + + zones := by["/etc/zones"] + for _, served := range []string{"drive.example.test", "git.example.test"} { + if strings.Contains(zones, served) { + t.Fatalf("a template over .Machines saw %q, a name the mesh serves rather than a machine:\n%s", served, zones) + } + } + if !strings.Contains(zones, "homer.internal") { + t.Fatalf("a template over .Machines did not see the machines:\n%s", zones) + } + + hosts := by["/etc/hosts"] + for _, name := range []string{"homer.internal", "drive.example.test", "git.example.test"} { + if !strings.Contains(hosts, name) { + t.Fatalf("a template over .Names did not see %q, so a container would not resolve it:\n%s", name, hosts) + } + } +} diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go index eb0b9c9..d41559a 100644 --- a/internal/overlay/generator.go +++ b/internal/overlay/generator.go @@ -161,6 +161,19 @@ func (g *Generator) Nodes() []Node { return g.nodes } // Graph is the peer list per node, for showing. func (g *Generator) Graph() Graph { return g.graph } +// hostsTemplate is the mesh's region of `/etc/hosts` — every machine's mesh name at its private +// address, written into a marked region and merged (RosterFile.Shared → `into: block`), so the rest +// of the file (localhost, the machine's own name, other tools' blocks) is kept byte for byte +// (novox/hq issue 128). It is a roster template like any module's: the mesh owns the data, this owns +// the format, and the control plane holds no formatter. +// +// - No floor: no header, no localhost, no `127.0.1.1` — those are the machine's, above the region. +// - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback. +// - `.Names` is every name the mesh serves (issue 111), so a container reaching a routed name +// finds the machine serving it; machines with no address yet are already left out of the set. +const hostsTemplate = "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" + + "{{range .Names}}{{.Address}}\t{{.FQDN}}\t{{.Name}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}" + // Manifest is the module the mesh provides for itself. // // It ships with the control plane rather than coming from a repository, because the thing that @@ -175,8 +188,13 @@ func Manifest() map[string]any { // 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"}, + // that needs a module to run nowhere. The format is a template like any other roster fact — + // the mesh's own module owns the `/etc/hosts` layout the way dnsmasq owns its zones, and the + // control plane holds no formatter (see catalogue.RosterFile). `shared`: the mesh owns only + // its region of the file and keeps the rest (novox/hq issue 128). + "facts": map[string]any{ + "node-names": map[string]any{"path": "/etc/hosts", "template": hostsTemplate, "shared": true}, + }, "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}}, } }