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