A machine with a VPN client that writes /etc/resolv.conf needs its domains routed to the VPN's servers while every other name still goes to the mesh's resolvers. node-resolver's holder does that on the machine, owns the resolver file there, and serves routes, route and unroute. The uplink's holder steps back from the file only where the resolver is held; every other machine composes as before.
179 lines
8.3 KiB
Go
179 lines
8.3 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")
|
|
}
|
|
}
|