Facts carry the format as a template, so the control plane holds none (ADR 0120) #80
@@ -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 <id>` and `# END mesh <id>` 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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -366,24 +366,29 @@ type Manifest struct {
|
|||||||
// firewall does. Ignored on a converged node, whose derived filter already closes them.
|
// firewall does. Ignored on a converged node, whose derived filter already closes them.
|
||||||
Guards []int `json:"guards,omitempty"`
|
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
|
// **The graph is the control plane's; the format is the module's.** The mesh knows which
|
||||||
// which machines exist, what they are called and where they are. Making a name resolve, or a
|
// machines exist, what they are called and where they are. Turning that into a name that
|
||||||
// peer reachable, is somebody's software — dnsmasq, a resolver, a VPN — and the mesh has no
|
// resolves, a peer that is reachable, a host a client trusts, is somebody's software — dnsmasq,
|
||||||
// business shipping one, choosing which, or knowing its configuration language.
|
// 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
|
// This used to be a closed list of fact names, each formatted in Go in the control plane, so a
|
||||||
// `filtering`, generalised: a fact, and a path.
|
// 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
|
// 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
|
// 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.
|
// 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
|
// Keyed by a name the module chooses, which is the rendered file's id (`fact-<name>`) — what a
|
||||||
// does not compute is asking for something nobody will write, and finding that out on a machine
|
// `restart-on` names to restart when the roster changes.
|
||||||
// is worse than being told here.
|
Facts map[string]RosterFile `json:"facts,omitempty"`
|
||||||
Facts map[string]string `json:"facts,omitempty"`
|
|
||||||
|
|
||||||
// Certificate is where this module wants a certificate for its machine's name inside the
|
// 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.
|
// mesh, and where the key that goes with it can be found.
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// there is nothing else to bring in. A module that ran nothing used to be here.
|
||||||
for _, m := range got.Modules {
|
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)
|
t.Fatalf("the network's provider does not ask for the names: %+v", m.Facts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
|||||||
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
"\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",
|
"\nlisten-address=127.0.0.1\n", "\ninterface=mesh0\n", "\nbind-dynamic\n",
|
||||||
"\ndomain-needed\n", "\nbogus-priv\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) {
|
if !strings.Contains(config, want) {
|
||||||
t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config)
|
t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config)
|
||||||
|
|||||||
@@ -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 <id>` and `# END mesh <id>` 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
|
||||||
|
}
|
||||||
@@ -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-<name>, 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-<name>, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,19 @@ func (g *Generator) Nodes() []Node { return g.nodes }
|
|||||||
// Graph is the peer list per node, for showing.
|
// Graph is the peer list per node, for showing.
|
||||||
func (g *Generator) Graph() Graph { return g.graph }
|
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.
|
// 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
|
// 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
|
// 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
|
// 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
|
// mesh knows which machines exist and where; writing that into a hosts file is not a thing
|
||||||
// that needs a module to run nowhere.
|
// that needs a module to run nowhere. The format is a template like any other roster fact —
|
||||||
"facts": map[string]string{"node-names": "/etc/hosts"},
|
// 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"}},
|
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user