package resolution import ( "strings" "testing" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) // 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() mcat.Manifest { return mcat.Manifest{Module: "local-resolver", Version: "1", Claims: []mcat.Claim{{Name: mcat.ResolverSeat, Scope: mcat.ScopeNode}}, Facts: map[string]mcat.RosterFile{"resolvers": {Path: mcat.ResolverFile, Template: "# Managed by the mesh\n{{range .Machines}}{{if eq .Name $.Node}}nameserver {{.Address}}\n{{end}}{{end}}"}}} } func splitDNSLaptop() mcat.Node { return mcat.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(), mcat.World{}) if err != nil { t.Fatalf("the resolver's holder and the uplink's were refused together: %v", err) } out, err := Declaration(got, mcat.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: mcat.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"] == mcat.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(), mcat.World{}) if err != nil { t.Fatal(err) } out, err := Declaration(got, mcat.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: mcat.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"] != mcat.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 := mcat.Manifest{Module: "uplink", Version: "1", Claims: []mcat.Claim{{Name: "node-uplink"}}, Facts: map[string]mcat.RosterFile{"resolvers": {Path: mcat.ResolverFile, Template: "nameserver 10.42.0.1\n"}}} if p := checkResources([]mcat.Manifest{uplink, localResolver()}); len(p) != 0 { t.Errorf("the uplink's holder and the resolver's were refused together: %v", p) } other := mcat.Manifest{Module: "other", Version: "1", Facts: map[string]mcat.RosterFile{"mine": {Path: mcat.ResolverFile, Template: "nameserver 10.42.0.9\n"}}} if p := checkResources([]mcat.Manifest{uplink, localResolver(), other}); len(p) == 0 { t.Error("a third module wrote the resolver file beside the resolver's") } if p := checkResources([]mcat.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 := mcat.Manifest{Module: "declared", Version: "1", Resources: []map[string]any{ {"id": "mine", "type": "file", "path": mcat.ResolverFile, "content": "nameserver 10.42.0.9\n"}}} if p := checkResources([]mcat.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"] = mcat.RosterFile{Path: "/etc/elsewhere", Template: "x"} if got := stepsBack(uplink, []mcat.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, []mcat.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, mcat.ResolverSeat) { continue } held++ f, ok := m.Facts["resolvers"] if !ok || f.Path != mcat.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, mcat.ResolverSeat, f) } provides := false for _, o := range m.Provides { if o.Name == "split-dns" && o.Reach == mcat.ReachMachine { provides = true } } if !provides { t.Errorf("%s holds %s and does not provide split-dns at the machine's reach", m.Module, mcat.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(), mcat.World{}) if err != nil { t.Fatal(err) } out, err := Declaration(got, mcat.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: mcat.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) } }