musl takes the first reply from any listed nameserver, so a public fallback beside the mesh's resolver answered NXDOMAIN for mesh names in every Alpine container (hq ADR 0223). The fix is two mesh resolvers and no public one, which needs mesh-dns-resolver held on two machines: a seat can now be replicated, each holder recorded by 'seat <name> --add', checkClaims accepts every holder on record and still refuses a second holder of any other mesh seat, a holder answers its own requirement, and a roster fact gives each replicated seat's holders, this machine first, so resolv-conf can list them. Migration 0062 keys a holding by seat and assignment.
200 lines
8.6 KiB
Go
200 lines
8.6 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The mesh has two resolvers (novox/hq ADR 0223): `mesh-dns-resolver` is replicated, held on the
|
|
// anchor and on the home server, each answering the same names; every machine's resolver file lists
|
|
// every holder — its own first when it is one — and no public resolver. ADR 0196 listed the mesh's
|
|
// resolver then a public one, and musl asks both at once and takes the first reply: from the home
|
|
// server the public "no such name" for the anchor's mesh name won, every time, in every Alpine build.
|
|
|
|
// resolverMachines is the anchor and the home server holding the resolver, and a laptop holding nothing.
|
|
var resolverMachines = map[string]string{
|
|
"anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2", "home.internal": "10.42.0.3"}
|
|
|
|
// bothResolvers is the two holders on record, as `seat mesh-dns-resolver --add` leaves them.
|
|
var bothResolvers = []Held{
|
|
{Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "anchor", Module: "dnsmasq"},
|
|
{Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "home", Module: "dnsmasq"},
|
|
}
|
|
|
|
// twoResolverShelf is the resolver, what asks it, and a stand-in answering `mesh-addressing`.
|
|
func twoResolverShelf(t *testing.T) map[string]Manifest {
|
|
t.Helper()
|
|
return map[string]Manifest{
|
|
"net": {Module: "net", Version: "1", Provides: []Offer{{Name: "mesh-addressing"}}},
|
|
"dnsmasq": catalogueManifest(t, "dnsmasq"),
|
|
"resolv-conf": catalogueManifest(t, "resolv-conf"),
|
|
}
|
|
}
|
|
|
|
// worldWithout is the rest of the mesh as a plan for one machine sees it: every other holder's claim
|
|
// and offer, and both holders on record.
|
|
func worldWithout(node string) World {
|
|
w := World{Holdings: bothResolvers, Offered: map[string][]Provider{}}
|
|
for _, h := range bothResolvers {
|
|
if h.Node == node {
|
|
continue
|
|
}
|
|
w.Held = append(w.Held, h)
|
|
w.Offered["wildcard-resolution"] = append(w.Offered["wildcard-resolution"],
|
|
Provider{Node: h.Node, At: h.Node + ".internal", Module: h.Module})
|
|
}
|
|
return w
|
|
}
|
|
|
|
// resolvConfOn resolves and composes one machine and answers with the nameservers its resolver file
|
|
// lists, in order, and the file.
|
|
func resolvConfOn(t *testing.T, node string, assigned []string) ([]string, string) {
|
|
t.Helper()
|
|
got, err := Resolve(twoResolverShelf(t), assigned, Node{Name: node, At: node + ".internal"}, worldWithout(node))
|
|
if err != nil {
|
|
t.Fatalf("%s does not resolve with two resolvers on record: %v", node, err)
|
|
}
|
|
out, err := got.Declaration(Rendering{
|
|
Names: resolverMachines, Machines: resolverMachines, 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"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("%s does not compose: %v", node, err)
|
|
}
|
|
file := byID(out)["resolv-conf.fact-resolvers"]
|
|
if file == nil || file["path"] != "/etc/resolv.conf" {
|
|
t.Fatalf("%s was given no resolver file: %v", node, file)
|
|
}
|
|
content, _ := file["content"].(string)
|
|
var servers []string
|
|
for _, line := range strings.Split(content, "\n") {
|
|
if strings.HasPrefix(line, "nameserver ") {
|
|
servers = append(servers, strings.TrimPrefix(line, "nameserver "))
|
|
}
|
|
}
|
|
return servers, content
|
|
}
|
|
|
|
func TestTwoResolversComposeOnBothHoldersAndEachListsItselfFirst(t *testing.T) {
|
|
for node, want := range map[string][]string{
|
|
"anchor": {"10.42.0.1", "10.42.0.3"},
|
|
"home": {"10.42.0.3", "10.42.0.1"},
|
|
} {
|
|
servers, content := resolvConfOn(t, node, []string{"dnsmasq", "resolv-conf"})
|
|
if strings.Join(servers, " ") != strings.Join(want, " ") {
|
|
t.Errorf("%s lists %v; itself first, then the other holder: %v\n%s", node, servers, want, content)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAMachineHoldingNoResolverListsBothAndNoPublicOne(t *testing.T) {
|
|
servers, content := resolvConfOn(t, "laptop", []string{"resolv-conf"})
|
|
if strings.Join(servers, " ") != "10.42.0.1 10.42.0.3" {
|
|
t.Errorf("the laptop lists %v; every holder, by name, and nothing else:\n%s", servers, content)
|
|
}
|
|
for _, public := range []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"} {
|
|
if strings.Contains(content, public) {
|
|
t.Errorf("a public resolver is listed beside the mesh's (ADR 0223):\n%s", content)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A holder answers its own requirement, even though the other holder sorts first (issue 258 kept).
|
|
func TestAHolderAnswersItsOwnRequirement(t *testing.T) {
|
|
got, err := Resolve(twoResolverShelf(t), []string{"dnsmasq", "resolv-conf"},
|
|
Node{Name: "home", At: "home.internal"}, worldWithout("home"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, n := range got.Needs {
|
|
if n.Name == "wildcard-resolution" && n.From != "home" {
|
|
t.Errorf("the home server's resolver configuration is bound to %s; it holds the seat itself", n.From)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A seat held once is still held once: a second claimant on another machine is refused while
|
|
// nothing is on record, and a store recording two holders of it is refused, naming the seat.
|
|
func TestASingleHolderMeshSeatStillRefusesASecondHolder(t *testing.T) {
|
|
store := shelf(mod("postgres", nil, nil, nil, Claim{Name: "mesh-store", Scope: ScopeMesh}))
|
|
other := Held{Claim: "mesh-store", Scope: ScopeMesh, Node: "anchor", Module: "postgres"}
|
|
if _, err := Resolve(store, []string{"postgres"}, workstation(), World{Held: []Held{other}}); err == nil ||
|
|
!strings.Contains(err.Error(), "one per mesh") {
|
|
t.Errorf("a second claimant of a seat held once was not refused: %v", err)
|
|
}
|
|
here := Held{Claim: "mesh-store", Scope: ScopeMesh, Node: workstation().Name, Module: "postgres"}
|
|
_, err := Resolve(store, []string{"postgres"}, workstation(),
|
|
World{Held: []Held{other}, Holdings: []Held{other, here}})
|
|
if err == nil || !strings.Contains(err.Error(), "on record as held by 2") {
|
|
t.Errorf("two holders on record for a seat held once were not refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// Replicated is not "whoever is assigned": two claimants with nothing on record are refused, as for
|
|
// any mesh seat, and each holder is added by an act.
|
|
func TestTwoUnrecordedClaimantsOfTheReplicatedSeatAreRefused(t *testing.T) {
|
|
w := worldWithout("home")
|
|
w.Holdings = nil
|
|
_, err := Resolve(twoResolverShelf(t), []string{"dnsmasq"}, Node{Name: "home", At: "home.internal"}, w)
|
|
if err == nil || !strings.Contains(err.Error(), "seat mesh-dns-resolver --to") {
|
|
t.Errorf("a second resolver with nothing on record was not refused, naming the handover: %v", err)
|
|
}
|
|
}
|
|
|
|
// Only the mesh's resolver is replicated: a seat being replicated is a decision, recorded.
|
|
func TestOnlyTheResolverIsReplicated(t *testing.T) {
|
|
for _, s := range Seats() {
|
|
if s.Replicated != (s.Name == "mesh-dns-resolver") {
|
|
t.Errorf("%s replicated = %v; only mesh-dns-resolver is (ADR 0223)", s.Name, s.Replicated)
|
|
}
|
|
}
|
|
UseSeats([]Seat{{Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution"}})
|
|
defer UseSeats(DefaultSeats())
|
|
if s, _ := SeatNamed("mesh-dns-resolver"); !s.Replicated {
|
|
t.Error("loading the set from the store, which has no column for it, lost the resolver's replication")
|
|
}
|
|
}
|
|
|
|
// Every consumer is bound to the same holder whatever order the mesh resolved its machines in.
|
|
func TestTheFirstHolderIsTheFirstProvider(t *testing.T) {
|
|
providers := []Provider{{Node: "anchor", Module: "dnsmasq"}, {Node: "home", Module: "dnsmasq"}}
|
|
for _, held := range [][]Held{bothResolvers, {bothResolvers[1], bothResolvers[0]}} {
|
|
if p, ok := HolderAmong("wildcard-resolution", providers, held); !ok || p.Node != "anchor" {
|
|
t.Errorf("held in order %v answered %v", held, p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// One host record per machine, beside its wildcard (novox/hq issue 262): a name with a host record
|
|
// says it exists and has no IPv6 address, where the wildcard alone said there is no such name, and
|
|
// musl reads that as final.
|
|
func TestTheResolverHasOneHostRecordPerMachine(t *testing.T) {
|
|
got, err := Resolve(twoResolverShelf(t), []string{"dnsmasq"}, Node{Name: "anchor", At: "anchor.internal"},
|
|
World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := got.Declaration(Rendering{Names: resolverMachines, Machines: resolverMachines, Suffix: "internal",
|
|
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, _ := byID(out)["dnsmasq.fact-node-zones"]["content"].(string)
|
|
records := map[string]int{}
|
|
for _, line := range strings.Split(content, "\n") {
|
|
if strings.HasPrefix(line, "host-record=") {
|
|
records[strings.TrimPrefix(line, "host-record=")]++
|
|
}
|
|
}
|
|
for name, at := range resolverMachines {
|
|
if records[name+","+at] != 1 {
|
|
t.Errorf("%s has %d host records at %s, and has one:\n%s", name, records[name+","+at], at, content)
|
|
}
|
|
}
|
|
if len(records) != len(resolverMachines) {
|
|
t.Errorf("%d host records for %d machines:\n%s", len(records), len(resolverMachines), content)
|
|
}
|
|
}
|