package controller import ( "strings" "testing" bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" "git.unkin.net/unkin/bind-operator/internal/bind" ) func zoneWith(spec bindv1alpha1.BindZoneSpec) *bindv1alpha1.BindZone { spec.ZoneName = "acme.unkin.net" return &bindv1alpha1.BindZone{Spec: spec} } 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) { ttl60 := int32(60) cases := []struct { name string spec bindv1alpha1.BindZoneSpec want []string ttl int32 declared bool }{ {"fallback is out-of-zone, so it needs no glue", bindv1alpha1.BindZoneSpec{}, []string{stableNS}, 3600, false}, {"declared wins", bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net"}}, []string{"ns1.unkin.net."}, 3600, true}, {"spec.defaultTTL applies", bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net."}, DefaultTTL: 60}, []string{"ns1.unkin.net."}, 60, 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."}, 3600, true}, // A TTL on the apex NS record itself must survive the fold. {"record TTL beats the zone default", bindv1alpha1.BindZoneSpec{DefaultTTL: 3600, Records: []bindv1alpha1.Record{ {Name: "@", Type: "NS", TTL: &ttl60, Values: []string{"a.ns.unkin.net."}}, }}, []string{"a.ns.unkin.net."}, 60, 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."}, 3600, true}, } for _, c := range cases { got, ttl, declared := zoneNameservers(zoneWith(c.spec), testCluster()) if declared != c.declared || ttl != c.ttl || strings.Join(got, ",") != strings.Join(c.want, ",") { t.Errorf("%s: got %v/%d/%v; want %v/%d/%v", c.name, got, ttl, declared, c.want, c.ttl, c.declared) } } } // A query that cannot see the zone returns nothing, which must not be read as an // empty apex: retracting blind means deleting the last NS record, which named // rejects, leaving the zone stuck. func TestApexNSUpdatesUnreadableLiveSetIsAdditive(t *testing.T) { zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net."}}) got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, nil, 3600) assertUpdates(t, got, []bind.RecordUpdate{ {FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"ns1.unkin.net."}, PerValue: true}, }) } // 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. Retracting an in-zone name takes its glue with it. 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}, }) } // Glue retirement is not special-cased to the name ns1: the seed glues every // declared in-zone nameserver. func TestApexNSUpdatesRetiresAnyInZoneGlue(t *testing.T) { zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"a.ns.unkin.net."}}) got := apexNSUpdates(zone, []string{"a.ns.unkin.net."}, []string{"dns.acme.unkin.net."}, 3600) assertUpdates(t, got, []bind.RecordUpdate{ {FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"a.ns.unkin.net."}, PerValue: true}, {FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"dns.acme.unkin.net."}, PerValue: true, Delete: true}, {FQDN: "dns.acme.unkin.net.", Type: "A", Delete: true}, }) } 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) } } // 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 nameserver owns its 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) { t.Helper() if len(got) != len(want) { t.Fatalf("got %d updates %+v; want %d %+v", len(got), got, len(want), want) } for i := range want { 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 strings.Join(g.Values, ",") != strings.Join(w.Values, ",") { t.Errorf("update %d values = %v; want %v", i, g.Values, w.Values) } } }