Files
mesh-catalog/modules/dnsmasq/cmd/dnsmasq-tools/templates_test.go
T
jschoubben a7dc4534d7
mesh/delivery merged: source.main moved to novox/mesh-catalog@6d2ce96630f0
mesh/delivery-group group fix/1966-resolver-test merged: every member is merged
mesh/check checking a7dc4534d73a on 6d2ce96630f0
Hold the resolver's configuration guards in the dnsmasq module's tests (issue 1966)
Moved from mesh-sdk's catalogue tests, which read a literal the file no longer has.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 20:59:46 +01:00

205 lines
7.6 KiB
Go

package main
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
// The packaged dnsmasq.service orders itself Before= network-online.target and nss-lookup.target. A drop-in
// that orders it After= either of them makes an ordering cycle, and systemd drops the start job: the mesh
// resolver stayed down for 90 minutes at a boot (novox/hq issue 1126). Wants=, Requires= and BindsTo= are
// refused too: they pull the target in, and with the packaged ordering they are the same mistake. Because the
// tunnel is ordered after those targets, the drop-in cannot wait for the address either: a start that finds it
// missing fails at once and Restart=on-failure brings it up (see the next test).
func TestDropInDoesNotOrderAfterATargetThePackagedUnitOrdersBefore(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "mesh.conf"))
if err != nil {
t.Fatal(err)
}
forbidden := []string{"network-online.target", "nss-lookup.target"}
for n, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
if !ok || (key != "After" && key != "Requires" && key != "Wants" && key != "BindsTo") {
continue
}
for _, target := range forbidden {
if strings.Contains(val, target) {
t.Errorf("mesh.conf line %d: %s=%s orders dnsmasq after a target its packaged unit orders itself before: a cycle", n+1, key, val)
}
}
}
}
// The wait for the address in ExecStartPre is at most two seconds: the tunnel that brings the address is ordered
// after network-online.target, which the resolver is ordered before, so a longer wait would hold the tunnel back.
func TestAddressWaitIsAtMostTwoSeconds(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "mesh.conf"))
if err != nil {
t.Fatal(err)
}
found := false
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "ExecStartPre=") {
continue
}
m := regexp.MustCompile(`seq 1 (\d+)`).FindStringSubmatch(line)
if m == nil {
t.Fatalf("ExecStartPre has no bounded wait (seq 1 N): %s", line)
}
found = true
if n, _ := strconv.Atoi(m[1]); n > 2 {
t.Errorf("ExecStartPre waits up to %d s for the address, more than 2 s", n)
}
}
if !found {
t.Fatal("mesh.conf has no ExecStartPre")
}
}
// The two public upstreams are the module's settings, defaulting to the two the resolver always used, placed
// as server lines beside no-resolv: a person sets them per node or mesh-wide without a build.
func TestUpstreamsAreConfigValues(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Config map[string]struct {
Default string `json:"default"`
Pattern string `json:"pattern"`
} `json:"config"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
conf, err := os.ReadFile(filepath.Join(moduleDir, "files", "dnsmasq.conf"))
if err != nil {
t.Fatal(err)
}
for key, def := range map[string]string{"upstream-resolver-first": "1.1.1.1", "upstream-resolver-second": "8.8.8.8"} {
c, ok := m.Config[key]
if !ok || c.Default != def {
t.Errorf("config %s = %+v, want default %s", key, c, def)
}
if !regexp.MustCompile(c.Pattern).MatchString(def) {
t.Errorf("pattern %q refuses the default %s", c.Pattern, def)
}
if !strings.Contains(string(conf), "\nserver=${config:"+key+"}\n") {
t.Errorf("dnsmasq.conf does not place %s as a server line", key)
}
}
if strings.Contains(string(conf), "server=1.1.1.1") || strings.Contains(string(conf), "server=8.8.8.8") {
t.Error("dnsmasq.conf still hard-codes an upstream")
}
if !strings.Contains(string(conf), "\nno-resolv\n") {
t.Error("dnsmasq.conf lost no-resolv")
}
}
// The resolver's configuration keeps what made every earlier resolver of the mesh go wrong (moved from mesh-sdk's
// catalogue tests, which could not follow the file when its upstreams became settings): it answers on the private
// address and loopback only, by address and never by interface (novox/hq issue 110), binds once and never
// dynamically (issue 348), reads no hosts file and no operator's files (ADR 0199), and takes none of the addresses
// systemd-resolved holds. no-resolv and the two server lines are held by TestUpstreamsAreConfigValues.
func TestTheResolverConfigurationKeepsItsGuards(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "dnsmasq.conf"))
if err != nil {
t.Fatal(err)
}
config := "\n" + string(raw)
for _, want := range []string{
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n",
"\nno-hosts\n", "\nconf-file=/etc/mesh-resolver/zones.conf\n", "\nconf-file=/etc/mesh-resolver/nodes.conf\n",
"\ndomain-needed\n", "\nbogus-priv\n", "\nbind-interfaces\n",
} {
if !strings.Contains(config, want) {
t.Errorf("the resolver's configuration lacks %q", strings.TrimSpace(want))
}
}
for _, line := range strings.Split(config, "\n") {
for _, never := range []string{"bind-dynamic", "interface=", "conf-dir=", "addn-hosts=", "listen-address=${setting:",
"listen-address=127.0.0.53", "listen-address=127.0.0.54", "listen-address=127.0.0.55"} {
if strings.HasPrefix(line, never) {
t.Errorf("the resolver's configuration says %q", line)
}
}
}
// The zone and node files the configuration reads are the ones the manifest's facts place.
manifest, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
for _, p := range []string{"/etc/mesh-resolver/zones.conf", "/etc/mesh-resolver/nodes.conf"} {
if !strings.Contains(string(manifest), `"path": "`+p+`"`) {
t.Errorf("the manifest places no file at %s, which the configuration reads", p)
}
}
}