converge apex NS per record, not by RRset replace
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

BIND ignores an RRset-wide delete of apex NS, so the previous replace only
appended to the seed placeholder.
This commit is contained in:
2026-09-26 18:50:55 +10:00
parent 4a41cbc427
commit e4ed6c8052
9 changed files with 304 additions and 130 deletions
+98 -61
View File
@@ -1,6 +1,7 @@
package controller
import (
"strings"
"testing"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
@@ -12,67 +13,108 @@ func zoneWith(spec bindv1alpha1.BindZoneSpec) *bindv1alpha1.BindZone {
return &bindv1alpha1.BindZone{Spec: spec}
}
func TestZoneNameserversFallbackIsOutOfZone(t *testing.T) {
cluster := &bindv1alpha1.BindCluster{}
cluster.Name, cluster.Namespace = "auth", "bind-internal"
got := zoneNameservers(nil, cluster)
want := "auth-0.auth-headless.bind-internal.svc.cluster.local."
if len(got) != 1 || got[0] != want {
t.Fatalf("fallback = %v; want [%s]", got, want)
func testCluster() *bindv1alpha1.BindCluster {
c := &bindv1alpha1.BindCluster{}
c.Name, c.Namespace = "auth", "bind-internal"
return c
}
const stableNS = "auth-0.auth-headless.bind-internal.svc.cluster.local."
func TestZoneNameservers(t *testing.T) {
cases := []struct {
name string
spec bindv1alpha1.BindZoneSpec
want []string
declared bool
}{
{"fallback is out-of-zone, so it needs no glue", bindv1alpha1.BindZoneSpec{}, []string{stableNS}, false},
{"declared wins", bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net"}}, []string{"ns1.unkin.net."}, true},
// An apex NS in spec.records cannot converge on its own: BIND ignores an
// RRset-wide delete at the apex, so it has to go through the apex path.
{"apex NS in records counts as declared", bindv1alpha1.BindZoneSpec{Records: []bindv1alpha1.Record{
{Name: "@", Type: "ns", Values: []string{"a.ns.unkin.net.", "b.ns.unkin.net."}},
{Name: "www", Type: "A", Values: []string{"10.0.0.1"}},
}}, []string{"a.ns.unkin.net.", "b.ns.unkin.net."}, true},
{"spec.nameservers beats records", bindv1alpha1.BindZoneSpec{
Nameservers: []string{"ns1.unkin.net."},
Records: []bindv1alpha1.Record{{Name: "@", Type: "NS", Values: []string{"other.unkin.net."}}},
}, []string{"ns1.unkin.net."}, true},
}
if got := zoneNameservers([]string{"ns1.unkin.net."}, cluster); got[0] != "ns1.unkin.net." {
t.Fatalf("declared nameservers must win, got %v", got)
for _, c := range cases {
got, declared := zoneNameservers(zoneWith(c.spec), testCluster())
if declared != c.declared || strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Errorf("%s: got %v/%v; want %v/%v", c.name, got, declared, c.want, c.declared)
}
}
}
func TestApexNSUpdatesReplacesRRsetAndDropsGlue(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{
Nameservers: []string{"ns1.unkin.net", "ns2.unkin.net."},
DefaultTTL: 300,
})
got := apexNSUpdates(zone, zone.Spec.Nameservers)
want := []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 300, Values: []string{"ns1.unkin.net.", "ns2.unkin.net."}},
// The apex NS RRset must be converged per record: an RRset-wide delete at the
// apex is ignored by BIND, which would leave the placeholder published alongside
// the real nameservers.
func TestApexNSUpdatesConverges(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net."}, DefaultTTL: 60})
got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, []string{"ns1.acme.unkin.net."}, 60)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 60, Values: []string{"ns1.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"ns1.acme.unkin.net."}, PerValue: true, Delete: true},
{FQDN: "ns1.acme.unkin.net.", Type: "A", Delete: true},
}
assertUpdates(t, got, want)
}
// Without declared nameservers the apex NS still converges onto the stable
// primary name, but the ns1 glue is left alone: it may be a real record.
func TestApexNSUpdatesFallbackKeepsGlue(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{})
got := apexNSUpdates(zone, []string{"auth-0.auth-headless.bind-internal.svc.cluster.local."})
want := []bind.RecordUpdate{{
FQDN: "acme.unkin.net.",
Type: "NS",
TTL: 3600,
Values: []string{"auth-0.auth-headless.bind-internal.svc.cluster.local."},
}}
assertUpdates(t, got, want)
}
// spec.records is applied after the apex sync, so anything it owns must not be
// touched here: the ops would be undone and the serial would churn every pass.
func TestApexNSUpdatesYieldsToRecords(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{
Nameservers: []string{"ns1.unkin.net."},
Records: []bindv1alpha1.Record{
{Name: "@", Type: "ns", Values: []string{"ns.other.net."}},
{Name: "ns1", Type: "A", Values: []string{"10.0.0.53"}},
},
})
if got := apexNSUpdates(zone, zone.Spec.Nameservers); len(got) != 0 {
}
func TestApexNSUpdatesNoopWhenConverged(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net"}})
// Case differs: DNS names compare case-insensitively, so this is converged.
if got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, []string{"NS1.Unkin.Net."}, 3600); len(got) != 0 {
t.Fatalf("expected no updates, got %+v", got)
}
}
// A declared in-zone nameserver owns the ns1 record; it is glue, not a leftover.
func TestApexNSUpdatesKeepsDeclaredNs1Glue(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.acme.unkin.net"}})
got := apexNSUpdates(zone, zone.Spec.Nameservers)
want := []bind.RecordUpdate{{FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"ns1.acme.unkin.net."}}}
assertUpdates(t, got, want)
// Changing the declared set replaces only what changed, keeping the overlap.
func TestApexNSUpdatesPartialChange(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"a.ns.unkin.net.", "c.ns.unkin.net."}})
got := apexNSUpdates(zone, []string{"a.ns.unkin.net.", "c.ns.unkin.net."}, []string{"a.ns.unkin.net.", "b.ns.unkin.net."}, 3600)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"c.ns.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"b.ns.unkin.net."}, PerValue: true, Delete: true},
})
}
// A declared in-zone ns1 owns the glue; removing it would fail named's
// post-update nameserver sanity check.
func TestApexNSUpdatesKeepsNeededGlue(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.acme.unkin.net."}})
if got := apexNSUpdates(zone, []string{"ns1.acme.unkin.net."}, []string{"ns1.acme.unkin.net."}, 3600); len(got) != 0 {
t.Fatalf("expected no updates, got %+v", got)
}
}
// spec.records owning the ns1 address means the glue is real data, not the seed
// placeholder.
func TestApexNSUpdatesLeavesRecordOwnedGlue(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{
Nameservers: []string{"ns1.unkin.net."},
Records: []bindv1alpha1.Record{{Name: "ns1", Type: "a", Values: []string{"10.0.0.53"}}},
})
got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, []string{"ns1.acme.unkin.net."}, 3600)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"ns1.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"ns1.acme.unkin.net."}, PerValue: true, Delete: true},
})
}
// The apex NS is converged by the apex path, so it must not also be emitted as a
// record: an RRset-wide delete there is ignored and the add would append.
func TestRecordsToUpdatesSkipsApexNS(t *testing.T) {
records := []bindv1alpha1.Record{
{Name: "@", Type: "NS", Values: []string{"a.ns.unkin.net."}},
{Name: "sub", Type: "NS", Values: []string{"d.ns.unkin.net."}},
{Name: "@", Type: "MX", Values: []string{"10 mail.unkin.net."}},
}
got := recordsToUpdates("acme.unkin.net", records, 3600)
if len(got) != 2 || got[0].FQDN != "sub.acme.unkin.net." || got[1].Type != "MX" {
t.Fatalf("got %+v", got)
}
}
func assertUpdates(t *testing.T, got, want []bind.RecordUpdate) {
@@ -81,18 +123,13 @@ func assertUpdates(t *testing.T, got, want []bind.RecordUpdate) {
t.Fatalf("got %d updates %+v; want %d %+v", len(got), got, len(want), want)
}
for i := range want {
if got[i].FQDN != want[i].FQDN || got[i].Type != want[i].Type || got[i].TTL != want[i].TTL || got[i].Delete != want[i].Delete {
t.Errorf("update %d = %+v; want %+v", i, got[i], want[i])
g, w := got[i], want[i]
if g.FQDN != w.FQDN || g.Type != w.Type || g.TTL != w.TTL || g.Delete != w.Delete || g.PerValue != w.PerValue {
t.Errorf("update %d = %+v; want %+v", i, g, w)
continue
}
if len(got[i].Values) != len(want[i].Values) {
t.Errorf("update %d values = %v; want %v", i, got[i].Values, want[i].Values)
continue
}
for j := range want[i].Values {
if got[i].Values[j] != want[i].Values[j] {
t.Errorf("update %d value %d = %q; want %q", i, j, got[i].Values[j], want[i].Values[j])
}
if strings.Join(g.Values, ",") != strings.Join(w.Values, ",") {
t.Errorf("update %d values = %v; want %v", i, g.Values, w.Values)
}
}
}