From 6a07f91ea153a245437d80267be6a3b2a0219ac0 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Tue, 21 Jul 2026 00:08:00 +1000 Subject: [PATCH] Notify secondaries immediately on primary zone changes Dynamically-updated primary zones were only reaching the secondary pods on the hardcoded 1h SOA refresh: the operator emitted no NOTIFY, and a zone's only apex NS is the primary itself, so default 'notify yes' reached no one. Queries load-balanced across the serve VIP hit stale secondaries and returned NXDOMAIN (negatively cached downstream for the 300s SOA minimum), so records flapped for up to an hour after every update. Add 'notify explicit' + 'also-notify' with the secondary pod IPs to primary zone stanzas so an update NOTIFYs the secondaries for an immediate IXFR. Applied via modzone, so existing zones pick it up on the next reconcile. Also shorten the seed SOA refresh/retry/minimum as a fallback for missed NOTIFYs and to shrink stale-NXDOMAIN negative caching. --- internal/bind/seed.go | 12 ++++-- internal/controller/bindzone_controller.go | 18 +++++++- internal/controller/bindzone_notify_test.go | 48 +++++++++++++++++++++ internal/controller/helpers.go | 29 +++++++++++++ 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 internal/controller/bindzone_notify_test.go diff --git a/internal/bind/seed.go b/internal/bind/seed.go index 8176b71..e168a64 100644 --- a/internal/bind/seed.go +++ b/internal/bind/seed.go @@ -35,13 +35,19 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, serial int64) error { origin := dot(zone) ns := "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. content := fmt.Sprintf(`$TTL 3600 @ IN SOA %s hostmaster.%s ( %d ; serial - 3600 ; refresh - 900 ; retry + 300 ; refresh + 60 ; retry 1209600 ; expire - 300 ) ; minimum + 60 ) ; minimum @ IN NS %s ns1 IN A %s `, ns, origin, serial, ns, primaryIP) diff --git a/internal/controller/bindzone_controller.go b/internal/controller/bindzone_controller.go index 69bb6f2..1c9a0f6 100644 --- a/internal/controller/bindzone_controller.go +++ b/internal/controller/bindzone_controller.go @@ -80,7 +80,15 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready") } - zoneConfig, err := r.buildZoneConfig(ctx, &zone, r.zoneTransferKeyRef(ctx, &zone, cluster)) + // Primary zones replicated to secondaries (catalog members) get an + // also-notify pointing at the secondary pods, so a dynamic update NOTIFYs + // them immediately rather than waiting for the SOA refresh. + var notifyTargets []string + if isPrimaryType(zone.Spec.Type) && catalogEnabled(&zone) { + notifyTargets = secondaryPodIPs(ctx, r.Client, cluster) + } + + zoneConfig, err := r.buildZoneConfig(ctx, &zone, r.zoneTransferKeyRef(ctx, &zone, cluster), notifyTargets) if err != nil { return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error()) } @@ -135,7 +143,7 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c // buildZoneConfig renders the inner clause passed to rndc addzone/modzone. // transferKey, when set, is the catalog transfer TSIG key name; catalog member // primary zones must allow AXFR with it so secondaries can pull them. -func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string) (string, error) { +func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string, notifyTargets []string) (string, error) { zType := zone.Spec.Type if zType == "" { zType = bindv1alpha1.ZonePrimary @@ -154,6 +162,12 @@ func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1al // Catalog member: permit key-authenticated AXFR from secondaries. parts = append(parts, fmt.Sprintf("allow-transfer { key \"%s\"; }", transferKey)) } + // NOTIFY only the secondaries we know about (their apex NS is the primary + // itself, so default `notify yes` would reach no one). `notify explicit` + // keeps NOTIFY off the query-serving VIP and scoped to the pod IPs. + if len(notifyTargets) > 0 { + parts = append(parts, "notify explicit", fmt.Sprintf("also-notify { %s }", terminateInline(notifyTargets))) + } if zone.Spec.DNSSECPolicyRef != "" { parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes") } diff --git a/internal/controller/bindzone_notify_test.go b/internal/controller/bindzone_notify_test.go new file mode 100644 index 0000000..a5502a8 --- /dev/null +++ b/internal/controller/bindzone_notify_test.go @@ -0,0 +1,48 @@ +package controller + +import ( + "context" + "strings" + "testing" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" +) + +// A primary zone with known secondaries renders notify explicit + also-notify +// so a dynamic update NOTIFYs the secondaries immediately. +func TestBuildZoneConfigPrimaryAlsoNotify(t *testing.T) { + r := &BindZoneReconciler{} + zone := &bindv1alpha1.BindZone{ + Spec: bindv1alpha1.BindZoneSpec{ + ZoneName: "main.unkin.net", + Type: bindv1alpha1.ZonePrimary, + }, + } + + cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", []string{"10.42.2.6", "10.42.1.5"}) + if err != nil { + t.Fatalf("buildZoneConfig: %v", err) + } + if !strings.Contains(cfg, "notify explicit") { + t.Errorf("expected notify explicit in %q", cfg) + } + if !strings.Contains(cfg, "also-notify { 10.42.2.6; 10.42.1.5; }") { + t.Errorf("expected also-notify with the secondary IPs in %q", cfg) + } +} + +// With no secondaries, no also-notify is emitted (single-replica cluster). +func TestBuildZoneConfigPrimaryNoNotifyTargets(t *testing.T) { + r := &BindZoneReconciler{} + zone := &bindv1alpha1.BindZone{ + Spec: bindv1alpha1.BindZoneSpec{ZoneName: "main.unkin.net", Type: bindv1alpha1.ZonePrimary}, + } + + cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", nil) + if err != nil { + t.Fatalf("buildZoneConfig: %v", err) + } + if strings.Contains(cfg, "also-notify") || strings.Contains(cfg, "notify explicit") { + t.Errorf("did not expect notify clauses with no targets: %q", cfg) + } +} diff --git a/internal/controller/helpers.go b/internal/controller/helpers.go index 469a1e3..62067db 100644 --- a/internal/controller/helpers.go +++ b/internal/controller/helpers.go @@ -3,6 +3,7 @@ package controller import ( "context" "fmt" + "sort" "time" corev1 "k8s.io/api/core/v1" @@ -126,6 +127,34 @@ func primaryTransferAddress(ctx context.Context, c client.Client, cluster *bindv return primaryPodIP(ctx, c, cluster) } +// secondaryPodIPs returns the pod IPs of a cluster's secondary pods (every pod +// except the ordinal-0 primary) that currently have an address. The primary +// uses this list as its zone `also-notify` set, so a change to a primary zone +// (in particular a dynamic update) triggers an immediate NOTIFY -> IXFR to the +// secondaries instead of leaving them stale until the next SOA refresh. The +// list is sorted so the rendered zone config is stable and does not churn +// modzone on every reconcile. Pod IPs change across restarts, so the caller +// relies on the zone controller's periodic requeue to refresh the set (a +// restarted secondary re-transfers the whole zone on load regardless). Returns +// nil for a single-replica cluster. +func secondaryPodIPs(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) []string { + var pods corev1.PodList + if err := c.List(ctx, &pods, client.InNamespace(cluster.Namespace), client.MatchingLabels(commonLabels(cluster.Name))); err != nil { + return nil + } + primary := primaryPodName(cluster.Name) + var ips []string + for i := range pods.Items { + p := &pods.Items[i] + if p.Name == primary || p.Status.PodIP == "" { + continue + } + ips = append(ips, p.Status.PodIP) + } + sort.Strings(ips) + return ips +} + // resolveTSIG reads the material of a BindTSIGKey into TSIG credentials. func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) { var creds bind.TSIGCreds -- 2.47.3