217 lines
10 KiB
Go
217 lines
10 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Defends novox/hq ADR 0247: a machine's own resolver is a node seat, held only where something requires
|
|
// `split-dns`; where it is held it writes the resolver file and the uplink's holder steps back from it.
|
|
|
|
// The seat: node-scoped, decided by ADR 0247, and its three verbs required of every holder — a holder
|
|
// exists only once the module serving them does, so nothing has to be optional while it catches up.
|
|
func TestTheMachinesOwnResolverIsANodeSeatWithItsVerbs(t *testing.T) {
|
|
s, ok := SeatNamed(ResolverSeat)
|
|
if !ok {
|
|
t.Fatalf("%s is not in the mesh's set", ResolverSeat)
|
|
}
|
|
if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0247" || s.Delivers != "" || s.Replicated {
|
|
t.Errorf("%s is %+v; a node seat under ADR 0247 that delivers nothing", ResolverSeat, s)
|
|
}
|
|
var got []string
|
|
for _, v := range s.Serves {
|
|
got = append(got, v.Name)
|
|
if v.Optional {
|
|
t.Errorf("%s.%s is optional; its first holder serves it", ResolverSeat, v.Name)
|
|
}
|
|
if v.Description == "" || v.Input["type"] != "object" || len(v.Replaces) == 0 {
|
|
t.Errorf("%s.%s has no description, no object schema or says it replaces nothing", ResolverSeat, v.Name)
|
|
}
|
|
}
|
|
if strings.Join(got, " ") != "routes route unroute" {
|
|
t.Errorf("%s serves %v, not routes, route and unroute", ResolverSeat, got)
|
|
}
|
|
// The verbs name a link, domains and servers, and never a VPN: the resolver knows nothing of one.
|
|
for _, v := range s.Serves {
|
|
if strings.Contains(strings.ToLower(v.Description), "forti") {
|
|
t.Errorf("%s.%s names a VPN client: %s", ResolverSeat, v.Name, v.Description)
|
|
}
|
|
}
|
|
}
|
|
|
|
// localResolver is a stand-in holder: it claims the seat and renders the resolver file naming the
|
|
// machine's own address. What is under test is the controller's rule, not the catalogue's module.
|
|
func localResolver() Manifest {
|
|
return Manifest{Module: "local-resolver", Version: "1",
|
|
Claims: []Claim{{Name: ResolverSeat, Scope: ScopeNode}},
|
|
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile,
|
|
Template: "# Managed by the mesh\n{{range .Machines}}{{if eq .Name $.Node}}nameserver {{.Address}}\n{{end}}{{end}}"}}}
|
|
}
|
|
|
|
func splitDNSLaptop() Node {
|
|
return Node{Name: "laptop", At: "laptop.internal", Capabilities: map[string]bool{
|
|
"package-manager": true, "service-manager": true, "uplink-systemd-networkd": true}}
|
|
}
|
|
|
|
// Where a module holds node-resolver, the machine is composed one resolver file, the holder's, naming
|
|
// the machine's own address; the uplink's holder composes everything else it declares, and not that file.
|
|
func TestWhereTheResolverIsHeldItWritesTheFileAndTheUplinkStepsBack(t *testing.T) {
|
|
shelf := resolverShelf(t)
|
|
shelf["local-resolver"] = localResolver()
|
|
got, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{})
|
|
if err != nil {
|
|
t.Fatalf("the resolver's holder and the uplink's were refused together: %v", err)
|
|
}
|
|
out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
|
Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}},
|
|
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
|
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var files []string
|
|
for _, r := range out {
|
|
if r["path"] == ResolverFile {
|
|
files = append(files, r["id"].(string))
|
|
}
|
|
}
|
|
if strings.Join(files, " ") != "local-resolver.fact-resolvers" {
|
|
t.Fatalf("the resolver file is composed as %v; once, the resolver's", files)
|
|
}
|
|
content := byID(out)["local-resolver.fact-resolvers"]["content"].(string)
|
|
if !strings.Contains(content, "nameserver 10.42.0.2\n") || strings.Contains(content, "10.42.0.1") {
|
|
t.Errorf("the resolver file does not name this machine's own resolver alone:\n%s", content)
|
|
}
|
|
// The uplink's holder is still composed: only the one file moved.
|
|
uplinkComposed := false
|
|
for _, r := range out {
|
|
if id, _ := r["id"].(string); strings.HasPrefix(id, "systemd-networkd.") {
|
|
uplinkComposed = true
|
|
}
|
|
}
|
|
if !uplinkComposed {
|
|
t.Errorf("the uplink's holder composed nothing once the resolver was held")
|
|
}
|
|
}
|
|
|
|
// Where nobody holds it, nothing changes: the uplink's holder writes the file, listing the mesh's
|
|
// resolvers (ADR 0223) — every machine but the one that requires split-dns.
|
|
func TestWithoutTheResolverTheUplinkWritesTheFileAsBefore(t *testing.T) {
|
|
got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "systemd-networkd"}, splitDNSLaptop(), World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
|
Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}},
|
|
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
|
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r := byID(out)["systemd-networkd.fact-resolvers"]; r == nil || r["path"] != ResolverFile {
|
|
t.Fatalf("without the resolver, the uplink's holder no longer writes the resolver file: %v", r)
|
|
}
|
|
}
|
|
|
|
// Only the uplink's holder steps back. A third module rendering or declaring the file beside the
|
|
// resolver's is two owners of one path, refused as before; and a module rendering it without holding
|
|
// the seat is not the resolver, so the uplink's holder does not step back for it.
|
|
func TestOnlyTheUplinkStepsBackForTheResolver(t *testing.T) {
|
|
uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}},
|
|
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}}
|
|
if p := checkResources([]Manifest{uplink, localResolver()}); len(p) != 0 {
|
|
t.Errorf("the uplink's holder and the resolver's were refused together: %v", p)
|
|
}
|
|
other := Manifest{Module: "other", Version: "1",
|
|
Facts: map[string]RosterFile{"mine": {Path: ResolverFile, Template: "nameserver 10.42.0.9\n"}}}
|
|
if p := checkResources([]Manifest{uplink, localResolver(), other}); len(p) == 0 {
|
|
t.Error("a third module wrote the resolver file beside the resolver's")
|
|
}
|
|
if p := checkResources([]Manifest{uplink, other}); len(p) == 0 {
|
|
t.Error("a module that does not hold node-resolver took the resolver file from the uplink's holder")
|
|
}
|
|
declared := Manifest{Module: "declared", Version: "1", Resources: []map[string]any{
|
|
{"id": "mine", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.9\n"}}}
|
|
if p := checkResources([]Manifest{uplink, localResolver(), declared}); len(p) == 0 {
|
|
t.Error("a module declaring the resolver file was let beside the resolver's")
|
|
}
|
|
// What steps back is the one file: the uplink's other facts stay.
|
|
uplink.Facts["hosts"] = RosterFile{Path: "/etc/elsewhere", Template: "x"}
|
|
if got := stepsBack(uplink, []Manifest{uplink, localResolver()}); len(got.Facts) != 1 || got.Facts["hosts"].Path == "" {
|
|
t.Errorf("the uplink's holder lost more than the resolver file: %v", got.Facts)
|
|
}
|
|
if got := stepsBack(uplink, []Manifest{uplink}); len(got.Facts) != 2 {
|
|
t.Errorf("the uplink's holder stepped back with no resolver held: %v", got.Facts)
|
|
}
|
|
}
|
|
|
|
// The catalogue as it is: every holder of node-resolver renders the resolver file as the mesh's own
|
|
// (its header is how the uplink's verb and the node-engine read a file as the mesh's), and provides
|
|
// split-dns at the machine's reach — a requirement is answered only on the same machine and never pulls
|
|
// the resolver in. Skipped while the catalogue has no holder.
|
|
func TestTheCataloguesResolverHoldersWriteTheFileAndProvideSplitDNS(t *testing.T) {
|
|
held := 0
|
|
for _, m := range theCatalogue(t) {
|
|
if !holdsSeat(m, ResolverSeat) {
|
|
continue
|
|
}
|
|
held++
|
|
f, ok := m.Facts["resolvers"]
|
|
if !ok || f.Path != ResolverFile || !strings.HasPrefix(f.Template, "# Managed by the mesh") {
|
|
t.Errorf("%s holds %s and does not render the resolver file as the mesh's: %+v", m.Module, ResolverSeat, f)
|
|
}
|
|
provides := false
|
|
for _, o := range m.Provides {
|
|
if o.Name == "split-dns" && o.Reach == ReachMachine {
|
|
provides = true
|
|
}
|
|
}
|
|
if !provides {
|
|
t.Errorf("%s holds %s and does not provide split-dns at the machine's reach", m.Module, ResolverSeat)
|
|
}
|
|
}
|
|
if held == 0 {
|
|
t.Skip("no module of the catalogue holds node-resolver yet")
|
|
}
|
|
}
|
|
|
|
// The catalogue's systemd-resolved, composed on a machine beside its uplink: the resolver file names the
|
|
// machine's own private address alone, its kept copy is the same file, and resolved is given every mesh
|
|
// resolver as its default route and the private address to listen on. Skipped without the catalogue.
|
|
func TestTheCataloguesResolverComposesOnAMachine(t *testing.T) {
|
|
shelf := resolverShelf(t)
|
|
shelf["systemd-resolved"] = catalogueManifest(t, "systemd-resolved")
|
|
got, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "systemd-resolved"}, splitDNSLaptop(), World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
|
Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1", "home.internal": "10.42.0.3"}},
|
|
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
|
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids := byID(out)
|
|
resolv, _ := ids["systemd-resolved.fact-resolvers"]["content"].(string)
|
|
kept, _ := ids["systemd-resolved.fact-kept"]["content"].(string)
|
|
if !strings.Contains(resolv, "\nnameserver 10.42.0.2\noptions timeout:1 attempts:2 edns0\n") || resolv != kept {
|
|
t.Errorf("the resolver file does not name this machine's own resolver alone, or its copy differs:\n%s", resolv)
|
|
}
|
|
if ids["systemd-networkd.fact-resolvers"] != nil {
|
|
t.Error("the uplink's holder still writes the resolver file beside the resolver's")
|
|
}
|
|
conf, _ := ids["systemd-resolved.fact-resolved"]["content"].(string)
|
|
for _, want := range []string{"\nDNS=10.42.0.1 10.42.0.3 \n", "\nDNSStubListenerExtra=10.42.0.2\n", "\nFallbackDNS=\n"} {
|
|
if !strings.Contains(conf, want) {
|
|
t.Errorf("resolved's drop-in lacks %q:\n%s", want, conf)
|
|
}
|
|
}
|
|
if s, _ := ids["systemd-resolved.fact-suffix"]["content"].(string); s != "internal\n" {
|
|
t.Errorf("the mesh's domain is rendered as %q", s)
|
|
}
|
|
}
|