Notify secondaries immediately on primary zone changes #13

Merged
benvin merged 1 commits from benvin/notify-secondaries into main 2026-07-21 00:23:14 +10:00
4 changed files with 102 additions and 5 deletions
+9 -3
View File
@@ -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)
+16 -2
View File
@@ -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")
}
@@ -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)
}
}
+29
View File
@@ -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