From 4a41cbc42714c4c0ec4a7207bd131238336f96c2 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 26 Sep 2026 18:27:11 +1000 Subject: [PATCH] reconcile zone apex NS off the pod IP placeholder Add BindZone.spec.nameservers and sync the apex NS RRset on every reconcile. --- api/v1alpha1/bindzone_types.go | 9 ++ api/v1alpha1/zz_generated.deepcopy.go | 5 + .../crd/bases/bind.unkin.net_bindzones.yaml | 11 +++ config/crd/install.yaml | 11 +++ config/samples/01-authoritative.yaml | 7 +- internal/bind/seed.go | 53 +++++++--- internal/bind/seed_nameservers_test.go | 75 ++++++++++++++ internal/bind/seed_test.go | 14 +-- internal/bind/zonestate_test.go | 10 +- internal/controller/apex_ns_test.go | 98 +++++++++++++++++++ .../controller/bindcatalogzone_controller.go | 2 +- internal/controller/bindpolicy_controller.go | 2 +- internal/controller/bindzone_controller.go | 33 +++++-- internal/controller/zone_helpers.go | 55 +++++++++++ 14 files changed, 346 insertions(+), 39 deletions(-) create mode 100644 internal/bind/seed_nameservers_test.go create mode 100644 internal/controller/apex_ns_test.go diff --git a/api/v1alpha1/bindzone_types.go b/api/v1alpha1/bindzone_types.go index f2b7354..87d99c4 100644 --- a/api/v1alpha1/bindzone_types.go +++ b/api/v1alpha1/bindzone_types.go @@ -64,6 +64,15 @@ type BindZoneSpec struct { // +optional DefaultTTL int32 `json:"defaultTTL,omitempty"` + // Nameservers are the names published in the zone's apex NS RRset, kept in + // sync on every reconcile. Each entry is a full domain name, never relative + // to the zone. Prefer out-of-zone names glued by the parent: an + // in-zone name needs an address record in the zone, and the seed can only + // supply the primary pod's (unstable) IP for it. When empty the apex NS is + // the primary's stable in-cluster DNS name. + // +optional + Nameservers []string `json:"nameservers,omitempty"` + // Records are static record sets seeded into a primary zone. // +optional Records []Record `json:"records,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d0eb94e..c9cb4cc 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -970,6 +970,11 @@ func (in *BindZoneList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BindZoneSpec) DeepCopyInto(out *BindZoneSpec) { *out = *in + if in.Nameservers != nil { + in, out := &in.Nameservers, &out.Nameservers + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.Records != nil { in, out := &in.Records, &out.Records *out = make([]Record, len(*in)) diff --git a/config/crd/bases/bind.unkin.net_bindzones.yaml b/config/crd/bases/bind.unkin.net_bindzones.yaml index 37f7406..4f2dfb7 100644 --- a/config/crd/bases/bind.unkin.net_bindzones.yaml +++ b/config/crd/bases/bind.unkin.net_bindzones.yaml @@ -94,6 +94,17 @@ spec: items: type: string type: array + nameservers: + description: |- + Nameservers are the names published in the zone's apex NS RRset, kept in + sync on every reconcile. Each entry is a full domain name, never relative + to the zone. Prefer out-of-zone names glued by the parent: an + in-zone name needs an address record in the zone, and the seed can only + supply the primary pod's (unstable) IP for it. When empty the apex NS is + the primary's stable in-cluster DNS name. + items: + type: string + type: array primaries: description: Primaries lists source servers for a secondary/stub-type zone. diff --git a/config/crd/install.yaml b/config/crd/install.yaml index 407d905..dae3e7b 100644 --- a/config/crd/install.yaml +++ b/config/crd/install.yaml @@ -2760,6 +2760,17 @@ spec: items: type: string type: array + nameservers: + description: |- + Nameservers are the names published in the zone's apex NS RRset, kept in + sync on every reconcile. Each entry is a full domain name, never relative + to the zone. Prefer out-of-zone names glued by the parent: an + in-zone name needs an address record in the zone, and the seed can only + supply the primary pod's (unstable) IP for it. When empty the apex NS is + the primary's stable in-cluster DNS name. + items: + type: string + type: array primaries: description: Primaries lists source servers for a secondary/stub-type zone. diff --git a/config/samples/01-authoritative.yaml b/config/samples/01-authoritative.yaml index 1ba73ca..adf6412 100644 --- a/config/samples/01-authoritative.yaml +++ b/config/samples/01-authoritative.yaml @@ -71,10 +71,11 @@ spec: - key transfer-key updateKeyRef: transfer-key dynamicUpdate: true + # Published apex NS, kept in sync on every reconcile. Full names only; an + # in-zone name (as here) needs its address record below. + nameservers: + - ns1.internal.example.com. records: - - name: "@" - type: NS - values: ["ns1.internal.example.com."] - name: ns1 type: A values: ["10.0.0.53"] diff --git a/internal/bind/seed.go b/internal/bind/seed.go index a7a8425..f144982 100644 --- a/internal/bind/seed.go +++ b/internal/bind/seed.go @@ -26,36 +26,65 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st return err == nil } -// renderSeedZone renders a minimal loadable zone (SOA + apex NS + glue). The -// apex NS is the in-zone name ns1, and a glue A record pointing at primaryIP is -// included so BIND's check-integrity accepts the zone (an in-zone NS without an -// address record is a load error). -func renderSeedZone(zone, primaryIP string, serial int64) string { +// renderSeedZone renders a minimal loadable zone (SOA + apex NS). nameservers +// are the names published in the apex NS RRset; when empty the in-zone name ns1 +// is used. A glue A pointing at primaryIP is emitted only for a nameserver that +// falls inside the zone, because BIND refuses to load a zone whose in-zone NS +// has no address record. Out-of-zone nameservers therefore keep pod IPs out of +// the zone file entirely. +func renderSeedZone(zone, primaryIP string, nameservers []string, serial int64) string { origin := dot(zone) - ns := "ns1." + origin + ns := make([]string, 0, len(nameservers)) + for _, n := range nameservers { + ns = append(ns, dot(n)) + } + if len(ns) == 0 { + ns = []string{"ns1." + origin} + } // Short refresh/retry so a secondary that misses a NOTIFY (e.g. its pod IP // changed and the primary's also-notify was briefly stale) still converges // in minutes, not the hour a 3600s refresh would impose. minimum is the // negative-cache TTL: keep it low so a stale-secondary NXDOMAIN does not // stick in downstream resolvers for long. NOTIFY (also-notify on the // primary) remains the fast path; these are the fallback. - return fmt.Sprintf(`$TTL 3600 + var b strings.Builder + fmt.Fprintf(&b, `$TTL 3600 @ IN SOA %s hostmaster.%s ( %d ; serial 300 ; refresh 60 ; retry 1209600 ; expire 60 ) ; minimum -@ IN NS %s -ns1 IN A %s -`, ns, origin, serial, ns, primaryIP) +`, ns[0], origin, serial) + for _, n := range ns { + fmt.Fprintf(&b, "@ IN NS %s\n", n) + } + for _, n := range ns { + if owner, ok := InZoneOwner(n, zone); ok { + fmt.Fprintf(&b, "%s IN A %s\n", owner, primaryIP) + } + } + return b.String() +} + +// InZoneOwner reports whether name sits inside zone, and if so returns its owner +// name relative to the apex ("@" for the apex itself). +func InZoneOwner(name, zone string) (string, bool) { + name, origin := dot(name), dot(zone) + switch { + case name == origin: + return "@", true + case strings.HasSuffix(name, "."+origin): + return strings.TrimSuffix(name, "."+origin), true + } + return "", false } // EnsureSeedZone makes path loadable without discarding live data: it probes // the zone file and journal, moves aside whatever cannot load, and writes a // skeleton only when there is nothing to preserve. Every caller that needs a // zone database file on disk goes through here. -func (e *Executor) EnsureSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string) error { +func (e *Executor) EnsureSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, nameservers []string) error { state, err := e.ZoneDiskState(ctx, namespace, pod, path) if err != nil { return err @@ -67,7 +96,7 @@ func (e *Executor) EnsureSeedZone(ctx context.Context, namespace, pod, zone, pat if !plan.WriteSeed { return e.Quarantine(ctx, namespace, pod, path, plan) } - content := renderSeedZone(zone, primaryIP, plan.Serial) + content := renderSeedZone(zone, primaryIP, nameservers, plan.Serial) cmd := []string{"sh", "-c", seedScript(path, plan, len(content))} if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil { return fmt.Errorf("seed zone %s: %w (out: %s)", zone, err, out) diff --git a/internal/bind/seed_nameservers_test.go b/internal/bind/seed_nameservers_test.go new file mode 100644 index 0000000..09639ea --- /dev/null +++ b/internal/bind/seed_nameservers_test.go @@ -0,0 +1,75 @@ +package bind + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// checkZone runs named-checkzone with full integrity checking, the same check +// named applies when loading a primary zone. Skips where the tool is absent. +func checkZone(t *testing.T, zone, content string) { + t.Helper() + bin, err := exec.LookPath("named-checkzone") + if err != nil { + t.Skip("named-checkzone not installed") + } + path := filepath.Join(t.TempDir(), "db") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(bin, "-i", "full", zone, path).CombinedOutput(); err != nil { + t.Fatalf("zone not loadable: %v\n%s\n%s", err, out, content) + } +} + +func TestRenderSeedZoneDeclaredNameservers(t *testing.T) { + got := renderSeedZone("acme.unkin.net", "10.42.6.38", []string{"ns1.unkin.net", "ns2.unkin.net."}, 7) + if strings.Contains(got, "10.42.6.38") { + t.Errorf("declared nameservers must not pull a pod IP into the zone:\n%s", got) + } + for _, want := range []string{"@ IN NS ns1.unkin.net.\n", "@ IN NS ns2.unkin.net.\n", "SOA ns1.unkin.net. hostmaster.acme.unkin.net."} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } + checkZone(t, "acme.unkin.net", got) +} + +// An in-zone nameserver has no address anywhere else, so the seed must glue it +// or named refuses to load the zone. +func TestRenderSeedZoneInZoneNameserverGetsGlue(t *testing.T) { + got := renderSeedZone("example.com", "10.0.0.1", []string{"ns.example.com"}, 1) + if !strings.Contains(got, "ns IN A 10.0.0.1\n") { + t.Errorf("in-zone nameserver needs glue:\n%s", got) + } + checkZone(t, "example.com", got) +} + +func TestRenderSeedZoneFallbackLoads(t *testing.T) { + got := renderSeedZone("example.com", "10.0.0.1", nil, 1) + if !strings.Contains(got, "@ IN NS ns1.example.com.\n") || !strings.Contains(got, "ns1 IN A 10.0.0.1\n") { + t.Errorf("fallback seed changed shape:\n%s", got) + } + checkZone(t, "example.com", got) +} + +func TestInZoneOwner(t *testing.T) { + cases := []struct { + name, zone, owner string + in bool + }{ + {"ns1.example.com.", "example.com", "ns1", true}, + {"example.com", "example.com.", "@", true}, + {"ns1.unkin.net", "acme.unkin.net", "", false}, + {"notexample.com", "example.com", "", false}, + } + for _, c := range cases { + owner, in := InZoneOwner(c.name, c.zone) + if in != c.in || owner != c.owner { + t.Errorf("InZoneOwner(%q, %q) = %q, %v; want %q, %v", c.name, c.zone, owner, in, c.owner, c.in) + } + } +} diff --git a/internal/bind/seed_test.go b/internal/bind/seed_test.go index 71aaa9e..af77322 100644 --- a/internal/bind/seed_test.go +++ b/internal/bind/seed_test.go @@ -29,7 +29,7 @@ func inFlightZone(t *testing.T) (dir, path string) { t.Helper() dir = t.TempDir() path = filepath.Join(dir, "db.example.com") - if err := os.WriteFile(path, []byte(renderSeedZone("example.com", "10.0.0.1", 1)), 0o600); err != nil { + if err := os.WriteFile(path, []byte(renderSeedZone("example.com", "10.0.0.1", nil, 1)), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(JournalPath(path), journalHeader(";BIND LOG V9.2\n", 10, 16), 0o600); err != nil { @@ -134,7 +134,7 @@ func TestSeedScriptInterruptedWriteLeavesDiskUntouched(t *testing.T) { if !plan.WriteSeed || !plan.QuarantineZoneFile || !plan.QuarantineJournal { t.Fatalf("expected a reseed over both files, got %+v", plan) } - content := renderSeedZone("example.com", "10.0.0.1", plan.Serial) + content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial) if err := runSeedScript(t, sh, path, plan, content, content[:len(content)/2]); err == nil { t.Fatal("a truncated seed write must fail rather than install a torn zone file") @@ -165,7 +165,7 @@ func TestSeedScriptInstallsOverQuarantinedFiles(t *testing.T) { dir, path := inFlightZone(t) plan := PlanSeed(probeState(t, sh, path)) - content := renderSeedZone("example.com", "10.0.0.1", plan.Serial) + content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial) if err := runSeedScript(t, sh, path, plan, content, content); err != nil { t.Fatalf("seed script: %v", err) } @@ -198,7 +198,7 @@ func TestSeedScriptFreshInstall(t *testing.T) { path := filepath.Join(t.TempDir(), "zones", "db.example.com") plan := PlanSeed(ZoneDiskState{}) - content := renderSeedZone("example.com", "10.0.0.1", plan.Serial) + content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial) if err := runSeedScript(t, sh, path, plan, content, content); err != nil { t.Fatalf("seed script: %v", err) } @@ -225,7 +225,7 @@ func TestSeedScriptFailedQuarantineAbortsInstall(t *testing.T) { exec `+realTool(t, "mv")+` "$@"`) plan := PlanSeed(probeState(t, sh, path)) - content := renderSeedZone("example.com", "10.0.0.1", plan.Serial) + content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial) if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil { t.Fatal("a failed quarantine must fail the seed") } @@ -240,7 +240,7 @@ func TestSeedScriptUnmeasurableStagingAbortsInstall(t *testing.T) { pathEnv := shimPath(t, "wc", "exit 127") plan := PlanSeed(probeState(t, sh, path)) - content := renderSeedZone("example.com", "10.0.0.1", plan.Serial) + content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial) if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil { t.Fatal("an unmeasurable staging file must fail the seed") } @@ -256,7 +256,7 @@ func TestSeedScriptFailedMkdirAbortsInstall(t *testing.T) { pathEnv := shimPath(t, "mkdir", "exit 1") plan := PlanSeed(probeState(t, sh, path)) - content := renderSeedZone("example.com", "10.0.0.1", plan.Serial) + content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial) if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil { t.Fatal("a failed mkdir must fail the seed") } diff --git a/internal/bind/zonestate_test.go b/internal/bind/zonestate_test.go index 5d78490..2d9de1a 100644 --- a/internal/bind/zonestate_test.go +++ b/internal/bind/zonestate_test.go @@ -48,7 +48,7 @@ k8s.syd1.au.unkin.net IN SOA ns1.k8s.syd1.au.unkin.net. hostmaster.k8s.syd1.au.u ok bool }{ {"bind dump", bindDump, 16, true}, - {"seed", renderSeedZone("example.com", "10.0.0.1", 42), 42, true}, + {"seed", renderSeedZone("example.com", "10.0.0.1", nil, 42), 42, true}, {"single line", "@ IN SOA ns1.example.com. hostmaster.example.com. 7 300 60 1209600 60\n", 7, true}, {"glued paren", "@ IN SOA ns. host. (9 300 60 1209600 60)\n", 9, true}, {"no soa", "$TTL 3600\nwww IN A 192.0.2.1\n", 0, false}, @@ -308,7 +308,7 @@ func TestPlanSeedBlocksOnUnreadableOrphanJournal(t *testing.T) { } func TestSeedZoneRoundTripsThroughParser(t *testing.T) { - content := renderSeedZone("200.18.198.in-addr.arpa", "198.18.200.8", 17) + content := renderSeedZone("200.18.198.in-addr.arpa", "198.18.200.8", nil, 17) got, ok := ParseZoneSerial(content) if !ok || got != 17 { t.Fatalf("seed zone serial = (%d,%v) want (17,true)", got, ok) @@ -418,12 +418,12 @@ func TestZoneStateProbeRoundTrip(t *testing.T) { {name: "fresh install"}, { name: "zone file only", - zone: renderSeedZone("example.com", "10.0.0.1", 42), + zone: renderSeedZone("example.com", "10.0.0.1", nil, 42), want: ZoneDiskState{ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true}, }, { name: "zone file and journal", - zone: renderSeedZone("example.com", "10.0.0.1", 12), + zone: renderSeedZone("example.com", "10.0.0.1", nil, 12), jnl: journalHeader(";BIND LOG V9.2\n", 10, 16), want: ZoneDiskState{ ZoneFile: true, ZoneSerial: 12, ZoneSerialOK: true, @@ -452,7 +452,7 @@ func TestZoneStateProbeRoundTrip(t *testing.T) { }, { name: "live zone beside old quarantine evidence", - zone: renderSeedZone("example.com", "10.0.0.1", 42), + zone: renderSeedZone("example.com", "10.0.0.1", nil, 42), orphans: []string{".orphaned-16"}, want: ZoneDiskState{ ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true, diff --git a/internal/controller/apex_ns_test.go b/internal/controller/apex_ns_test.go new file mode 100644 index 0000000..9495453 --- /dev/null +++ b/internal/controller/apex_ns_test.go @@ -0,0 +1,98 @@ +package controller + +import ( + "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 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) + } + if got := zoneNameservers([]string{"ns1.unkin.net."}, cluster); got[0] != "ns1.unkin.net." { + t.Fatalf("declared nameservers must win, got %v", got) + } +} + +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."}}, + {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 { + 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) +} + +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 { + 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]) + 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]) + } + } + } +} diff --git a/internal/controller/bindcatalogzone_controller.go b/internal/controller/bindcatalogzone_controller.go index 28c9d98..64cfdfe 100644 --- a/internal/controller/bindcatalogzone_controller.go +++ b/internal/controller/bindcatalogzone_controller.go @@ -54,7 +54,7 @@ func (r *BindCatalogZoneReconciler) Reconcile(ctx context.Context, req ctrl.Requ if primaryIP == "" { return r.fail(ctx, &catalog, "PrimaryNoIP", "waiting for primary pod IP") } - if err := r.Exec.EnsureSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), primaryIP); err != nil { + if err := r.Exec.EnsureSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), primaryIP, zoneNameservers(nil, cluster)); err != nil { return r.fail(ctx, &catalog, "SeedFailed", err.Error()) } } diff --git a/internal/controller/bindpolicy_controller.go b/internal/controller/bindpolicy_controller.go index 33c15fc..76b7e8c 100644 --- a/internal/controller/bindpolicy_controller.go +++ b/internal/controller/bindpolicy_controller.go @@ -67,7 +67,7 @@ func (r *BindPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) if primaryIP == "" { return r.fail(ctx, &policy, "PrimaryNoIP", "waiting for primary pod IP") } - if err := r.Exec.EnsureSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), primaryIP); err != nil { + if err := r.Exec.EnsureSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), primaryIP, zoneNameservers(nil, cluster)); err != nil { return r.fail(ctx, &policy, "SeedFailed", err.Error()) } } diff --git a/internal/controller/bindzone_controller.go b/internal/controller/bindzone_controller.go index f326ff8..d9cb9ad 100644 --- a/internal/controller/bindzone_controller.go +++ b/internal/controller/bindzone_controller.go @@ -99,6 +99,8 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error()) } + nameservers := zoneNameservers(zone.Spec.Nameservers, cluster) + created := !r.Exec.ZoneExists(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef) if created && (zone.Spec.Type == bindv1alpha1.ZonePrimary || zone.Spec.Type == "") { primaryIP := primaryPodIP(ctx, r.Client, cluster) @@ -108,7 +110,7 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c // The zone is absent from named's memory, but its database file and // journal may still be on the PVC from a previous incarnation. path := bind.ZoneFilePath(zone.Spec.ZoneName) - if err := r.Exec.EnsureSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, path, primaryIP); err != nil { + if err := r.Exec.EnsureSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, path, primaryIP, nameservers); err != nil { return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error()) } } @@ -116,18 +118,29 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return r.setPhase(ctx, &zone, "Error", "AddZoneFailed", err.Error()) } - // Seed static records (primary zones only). + // Sync the apex NS on every pass, not only at seed time, so an existing zone + // converges off the seed placeholder. Best-effort: a zone that permits no + // dynamic update keeps what it was seeded with rather than failing to + // reconcile. recordCount := 0 - if isPrimaryType(zone.Spec.Type) && len(zone.Spec.Records) > 0 { - creds, err := r.zoneUpdateCreds(ctx, &zone) - if err != nil { - return r.setPhase(ctx, &zone, "Error", "NoUpdateKey", err.Error()) + if isPrimaryType(zone.Spec.Type) { + creds, credErr := r.zoneUpdateCreds(ctx, &zone) + if apex := apexNSUpdates(&zone, nameservers); len(apex) > 0 && credErr == nil { + if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, apex); err != nil { + logger.V(1).Info("apex NS sync failed", "zone", zone.Spec.ZoneName, "err", err.Error()) + } } - updates := recordsToUpdates(zone.Spec.ZoneName, zone.Spec.Records, zone.Spec.DefaultTTL) - if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, updates); err != nil { - return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error()) + // Seed static records. + if len(zone.Spec.Records) > 0 { + if credErr != nil { + return r.setPhase(ctx, &zone, "Error", "NoUpdateKey", credErr.Error()) + } + updates := recordsToUpdates(zone.Spec.ZoneName, zone.Spec.Records, zone.Spec.DefaultTTL) + if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, updates); err != nil { + return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error()) + } + recordCount = len(updates) } - recordCount = len(updates) } // Register in the catalog so secondaries auto-provision. diff --git a/internal/controller/zone_helpers.go b/internal/controller/zone_helpers.go index 7af8c5f..074a469 100644 --- a/internal/controller/zone_helpers.go +++ b/internal/controller/zone_helpers.go @@ -114,3 +114,58 @@ func alsoNotifyList(addrs []string, key string) string { } return strings.Join(parts, " ") } + +// absolute qualifies a nameserver name. Unlike record owner names, a +// spec.nameservers entry is always a full domain name, never relative to the +// zone: an in-zone nameserver is spelled out in full. +func absolute(name string) string { return strings.TrimSuffix(name, ".") + "." } + +// zoneNameservers resolves the names to publish in a zone's apex NS RRset: the +// declared nameservers, else the primary's stable in-cluster DNS name. The +// fallback is deliberately out-of-zone, so no pod IP is needed as glue. +func zoneNameservers(declared []string, cluster *bindv1alpha1.BindCluster) []string { + if len(declared) > 0 { + return declared + } + return []string{primaryAddress(cluster.Name, cluster.Namespace) + "."} +} + +// apexNSUpdates returns the dynamic-update ops that keep a zone's apex NS RRset +// equal to nameservers, plus removal of the seed's ns1 glue once the zone +// declares its own nameservers. Ops colliding with a spec.records entry are +// dropped: records are applied afterwards and would re-add them, and the churn +// would bump the serial on every reconcile. +func apexNSUpdates(zone *bindv1alpha1.BindZone, nameservers []string) []bind.RecordUpdate { + owns := func(name, typ string) bool { + for _, rec := range zone.Spec.Records { + if strings.EqualFold(rec.Type, typ) && fqdn(rec.Name, zone.Spec.ZoneName) == fqdn(name, zone.Spec.ZoneName) { + return true + } + } + return false + } + ttl := zone.Spec.DefaultTTL + if ttl <= 0 { + ttl = 3600 + } + var updates []bind.RecordUpdate + if !owns("@", "NS") { + values := make([]string, 0, len(nameservers)) + for _, ns := range nameservers { + values = append(values, absolute(ns)) + } + updates = append(updates, bind.RecordUpdate{FQDN: fqdn("@", zone.Spec.ZoneName), Type: "NS", TTL: ttl, Values: values}) + } + // The seed's placeholder glue pins a pod IP that goes stale on the first + // reschedule; drop it once the zone names its real nameservers. + if glue := fqdn("ns1", zone.Spec.ZoneName); len(zone.Spec.Nameservers) > 0 && !owns("ns1", "A") { + published := false + for _, ns := range nameservers { + published = published || absolute(ns) == glue + } + if !published { + updates = append(updates, bind.RecordUpdate{FQDN: glue, Type: "A", Delete: true}) + } + } + return updates +}