Facts carry the format as a template, so the control plane holds none (ADR 0120) #80
@@ -1,184 +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 a hosts file.
|
||||
//
|
||||
// Exact names only: `homer` and `homer.internal` resolve to homer. Anything *under* a machine
|
||||
// is a wildcard, which a hosts file cannot express — that is FactNodeZones.
|
||||
FactNodeNames = "node-names"
|
||||
|
||||
// FactNodeZones is every machine as a wildcard: `*.homer.internal` is homer.
|
||||
//
|
||||
// Written in the form a resolver reads. A machine's own name and everything under it are one
|
||||
// fact — if homer is at an address, so is anything homer serves.
|
||||
FactNodeZones = "node-zones"
|
||||
)
|
||||
|
||||
// facts is every fact the mesh computes, and what writes it.
|
||||
//
|
||||
// **A closed list.** A module asking for a fact the mesh does not have is asking for a file nobody
|
||||
// will write, and finding that out on a machine — as a daemon that starts, reads nothing, and
|
||||
// answers no queries — is worse than being told where the manifest is.
|
||||
// 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).
|
||||
var facts = map[string]func(r Resolution, every, machines map[string]string, suffix string) string{
|
||||
FactNodeNames: func(r Resolution, every, _ map[string]string, suffix string) string {
|
||||
return nodeNames(r, every, suffix)
|
||||
},
|
||||
FactNodeZones: 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 {
|
||||
write, known := facts[name]
|
||||
if !known {
|
||||
return nil, fmt.Errorf(
|
||||
"%s asks the mesh for %q, which it does not compute. It has %s",
|
||||
m.Module, name, spokenFacts())
|
||||
}
|
||||
path := m.Facts[name]
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return nil, fmt.Errorf(
|
||||
"%s asks for %q at %q, which is not an absolute path", m.Module, name, path)
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": "fact-" + name, "type": "file", "path": path, "mode": "0644",
|
||||
"content": write(r, addresses, machines, suffix),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// spokenFacts lists them, so a refusal says what would have worked.
|
||||
func spokenFacts() string {
|
||||
names := make([]string, 0, len(facts))
|
||||
for name := range facts {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// nodeNames is every machine's name and address, as a hosts file.
|
||||
//
|
||||
// **A machine with no address is left out.** The mesh has a record for it — somebody added it —
|
||||
// and does not yet know where it is, which is the ordinary state between adding a machine and it
|
||||
// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to
|
||||
// that hangs; leaving it out fails at once and says the name is unknown.
|
||||
func nodeNames(r Resolution, addresses map[string]string, 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")
|
||||
// The floor every Linux expects, and which removing would break things that have nothing to do
|
||||
// with the mesh.
|
||||
b.WriteString("127.0.0.1\tlocalhost\n")
|
||||
b.WriteString("::1\t\tlocalhost ip6-localhost ip6-loopback\n")
|
||||
if r.Node != "" {
|
||||
fmt.Fprintf(&b, "127.0.1.1\t%s\n", r.Node)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
for _, name := range sortedNames(addresses) {
|
||||
at := addresses[name]
|
||||
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.
|
||||
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,188 +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)
|
||||
}
|
||||
// And the loopback floor is still there, or things with nothing to do with the mesh break.
|
||||
if !strings.Contains(out, "127.0.0.1\tlocalhost") {
|
||||
t.Fatalf("the loopback floor was removed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A module says where it wants a fact, and is given a file.
|
||||
func TestAModuleIsGivenTheFactsItAskedFor(t *testing.T) {
|
||||
m := Manifest{Module: "dnsmasq", Facts: map[string]string{FactNodeZones: "/etc/mesh/zones.conf"}}
|
||||
given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, 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.
|
||||
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-<name>`) — 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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": "fact-" + name, "type": "file", "path": fact.Path, "mode": "0644",
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
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,195 @@
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
// **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,23 @@ 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 `/etc/hosts` the network module asks the mesh to write — every machine's
|
||||
// mesh name at its private address, so a service binding the name it was given stays reachable from
|
||||
// everywhere else. It is a roster template like any module's (catalogue.RosterFile): the mesh owns
|
||||
// the data, this owns the format.
|
||||
//
|
||||
// - The loopback floor stays, or things with nothing to do with the mesh break.
|
||||
// - `127.0.1.1 <node>` only when there is a node, the ordinary Debian self-name line.
|
||||
// - 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 = "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n" +
|
||||
"# joins or leaves, and an edit would survive until then and vanish.\n\n" +
|
||||
"127.0.0.1\tlocalhost\n" +
|
||||
"::1\t\tlocalhost ip6-localhost ip6-loopback\n" +
|
||||
"{{if .Node}}127.0.1.1\t{{.Node}}\n{{end}}\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 +192,12 @@ 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).
|
||||
"facts": map[string]any{
|
||||
"node-names": map[string]any{"path": "/etc/hosts", "template": hostsTemplate},
|
||||
},
|
||||
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The network module's `/etc/hosts` is a roster template like any module's (novox/hq: the graph is
|
||||
// the control plane's, the format is the module's). These pin the format that used to be a Go
|
||||
// formatter in the control plane, so the file a machine gets does not change with the mechanism:
|
||||
// the loopback floor, the Debian self-name line, the machine's own line marked and at its mesh
|
||||
// address, one line per machine, every served name (issue 111).
|
||||
func hostsFor(t *testing.T, node string, every map[string]string) string {
|
||||
t.Helper()
|
||||
m := catalogue.Manifest{Module: "net", Facts: map[string]catalogue.RosterFile{
|
||||
"node-names": {Path: "/etc/hosts", Template: hostsTemplate},
|
||||
}}
|
||||
given, err := catalogue.FactsInto(m, catalogue.Resolution{Node: node}, every, every, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return given[0]["content"].(string)
|
||||
}
|
||||
|
||||
func TestTheHostsFileIsThisExactly(t *testing.T) {
|
||||
got := hostsFor(t, "homer", map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"})
|
||||
want := "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n" +
|
||||
"# joins or leaves, and an edit would survive until then and vanish.\n\n" +
|
||||
"127.0.0.1\tlocalhost\n" +
|
||||
"::1\t\tlocalhost ip6-localhost ip6-loopback\n" +
|
||||
"127.0.1.1\thomer\n\n" +
|
||||
"10.42.0.1\thomer.internal\thomer\t# this machine\n" +
|
||||
"10.42.0.2\tmarge.internal\tmarge\n"
|
||||
if got != want {
|
||||
t.Fatalf("the hosts file changed with the mechanism:\ngot:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// With no node named there is no `127.0.1.1` self-line — but the blank line before the machines
|
||||
// stays, exactly as the old formatter wrote it unconditionally.
|
||||
func TestTheHostsFileWithoutASelfNameKeepsItsShape(t *testing.T) {
|
||||
got := hostsFor(t, "", map[string]string{"homer.internal": "10.42.0.1"})
|
||||
want := "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n" +
|
||||
"# joins or leaves, and an edit would survive until then and vanish.\n\n" +
|
||||
"127.0.0.1\tlocalhost\n" +
|
||||
"::1\t\tlocalhost ip6-localhost ip6-loopback\n\n" +
|
||||
"10.42.0.1\thomer.internal\thomer\n"
|
||||
if got != want {
|
||||
t.Fatalf("the hosts file without a self-name changed shape:\ngot:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user