4 Commits

Author SHA1 Message Date
benvin 439aa9ea6b Merge pull request 'Notify secondaries immediately on primary zone changes' (#13) from benvin/notify-secondaries into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #13
2026-07-21 00:23:13 +10:00
unkinben 6a07f91ea1 Notify secondaries immediately on primary zone changes
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
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.
2026-07-21 00:15:43 +10:00
benvin e4ed9cfdb2 Merge pull request 'BindTSIGKey: add secretTemplate for Secret labels/annotations' (#12) from benvin/tsigkey-secret-annotations into main
Reviewed-on: #12
2026-07-20 23:47:21 +10:00
unkinben 9c81320df8 BindTSIGKey: add secretTemplate for labels/annotations on the managed Secret
The operator-generated TSIG Secret previously carried only the managed-by
label, so it could not be mirrored to another namespace by emberstack
reflector (which requires reflection-allowed annotations on the source).

Add spec.secretTemplate.{annotations,labels}, applied both when the Secret
is first generated and reconciled onto the existing Secret when the CR
changes (imported secrets are left untouched so we don't fight their
external manager). This lets the external-dns TSIG key be managed in
bind-internal and reflected into the externaldns namespace.
2026-07-20 23:45:41 +10:00
13 changed files with 324 additions and 10 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ script picks one based on the pod ordinal.
| `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. | | `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. |
| `DNSRecord` | A single record set applied via TSIG `nsupdate` — external-dns as a CRD. | | `DNSRecord` | A single record set applied via TSIG `nsupdate` — external-dns as a CRD. |
| `BindView` | A split-horizon view (`match-clients`, ordering, per-view recursion). | | `BindView` | A split-horizon view (`match-clients`, ordering, per-view recursion). |
| `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). | | `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). `spec.secretTemplate` stamps extra labels/annotations onto that Secret (e.g. reflection hints to mirror it into another namespace). |
| `BindACL` | A reusable named `address_match_list`. | | `BindACL` | A reusable named `address_match_list`. |
| `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. | | `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. |
| `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. | | `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. |
+18
View File
@@ -41,6 +41,24 @@ type BindTSIGKeySpec struct {
// `secret` key and the operator will not generate new material. // `secret` key and the operator will not generate new material.
// +optional // +optional
ImportExisting bool `json:"importExisting,omitempty"` ImportExisting bool `json:"importExisting,omitempty"`
// SecretTemplate customizes metadata written onto the managed key Secret.
// Useful, for example, to let secret-reflection tooling mirror the key into
// another namespace. Operator-managed labels are always preserved.
// +optional
SecretTemplate *SecretMetadata `json:"secretTemplate,omitempty"`
}
// SecretMetadata carries extra labels and annotations to stamp onto a
// Secret managed by the operator.
type SecretMetadata struct {
// Annotations to set on the Secret.
// +optional
Annotations map[string]string `json:"annotations,omitempty"`
// Labels to set on the Secret.
// +optional
Labels map[string]string `json:"labels,omitempty"`
} }
// BindTSIGKeyStatus reports observed TSIG key state. // BindTSIGKeyStatus reports observed TSIG key state.
+35 -1
View File
@@ -691,7 +691,7 @@ func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) {
*out = *in *out = *in
out.TypeMeta = in.TypeMeta out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status) in.Status.DeepCopyInto(&out.Status)
} }
@@ -748,6 +748,11 @@ func (in *BindTSIGKeyList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) { func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) {
*out = *in *out = *in
if in.SecretTemplate != nil {
in, out := &in.SecretTemplate, &out.SecretTemplate
*out = new(SecretMetadata)
(*in).DeepCopyInto(*out)
}
} }
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec. // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec.
@@ -1208,3 +1213,32 @@ func (in *Record) DeepCopy() *Record {
in.DeepCopyInto(out) in.DeepCopyInto(out)
return out return out
} }
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SecretMetadata) DeepCopyInto(out *SecretMetadata) {
*out = *in
if in.Annotations != nil {
in, out := &in.Annotations, &out.Annotations
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.Labels != nil {
in, out := &in.Labels, &out.Labels
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretMetadata.
func (in *SecretMetadata) DeepCopy() *SecretMetadata {
if in == nil {
return nil
}
out := new(SecretMetadata)
in.DeepCopyInto(out)
return out
}
@@ -87,6 +87,23 @@ spec:
SecretName is the Secret the key material is written to (or read from when SecretName is the Secret the key material is written to (or read from when
ImportExisting is set). Defaults to "<name>-tsig". ImportExisting is set). Defaults to "<name>-tsig".
type: string type: string
secretTemplate:
description: |-
SecretTemplate customizes metadata written onto the managed key Secret.
Useful, for example, to let secret-reflection tooling mirror the key into
another namespace. Operator-managed labels are always preserved.
properties:
annotations:
additionalProperties:
type: string
description: Annotations to set on the Secret.
type: object
labels:
additionalProperties:
type: string
description: Labels to set on the Secret.
type: object
type: object
type: object type: object
status: status:
description: BindTSIGKeyStatus reports observed TSIG key state. description: BindTSIGKeyStatus reports observed TSIG key state.
+17
View File
@@ -2376,6 +2376,23 @@ spec:
SecretName is the Secret the key material is written to (or read from when SecretName is the Secret the key material is written to (or read from when
ImportExisting is set). Defaults to "<name>-tsig". ImportExisting is set). Defaults to "<name>-tsig".
type: string type: string
secretTemplate:
description: |-
SecretTemplate customizes metadata written onto the managed key Secret.
Useful, for example, to let secret-reflection tooling mirror the key into
another namespace. Operator-managed labels are always preserved.
properties:
annotations:
additionalProperties:
type: string
description: Annotations to set on the Secret.
type: object
labels:
additionalProperties:
type: string
description: Labels to set on the Secret.
type: object
type: object
type: object type: object
status: status:
description: BindTSIGKeyStatus reports observed TSIG key state. description: BindTSIGKeyStatus reports observed TSIG key state.
+9 -1
View File
@@ -11,7 +11,9 @@ spec:
algorithm: hmac-sha256 algorithm: hmac-sha256
--- ---
# TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136 # TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136
# dynamic updates to the dynamic cluster's primary. # dynamic updates to the dynamic cluster's primary. secretTemplate mirrors the
# generated Secret into the external-dns namespace via emberstack reflector, so
# external-dns presents exactly the key the primary's allow-update accepts.
apiVersion: bind.unkin.net/v1alpha1 apiVersion: bind.unkin.net/v1alpha1
kind: BindTSIGKey kind: BindTSIGKey
metadata: metadata:
@@ -19,3 +21,9 @@ metadata:
namespace: bind-externaldns namespace: bind-externaldns
spec: spec:
algorithm: hmac-sha256 algorithm: hmac-sha256
secretTemplate:
annotations:
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "externaldns"
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "externaldns"
+1 -1
View File
@@ -3,6 +3,7 @@ module git.unkin.net/unkin/bind-operator
go 1.25 go 1.25
require ( require (
github.com/go-logr/logr v1.4.2
k8s.io/api v0.34.4 k8s.io/api v0.34.4
k8s.io/apimachinery v0.34.4 k8s.io/apimachinery v0.34.4
k8s.io/client-go v0.34.4 k8s.io/client-go v0.34.4
@@ -17,7 +18,6 @@ require (
github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect
+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 { func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, serial int64) error {
origin := dot(zone) origin := dot(zone)
ns := "ns1." + origin 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 content := fmt.Sprintf(`$TTL 3600
@ IN SOA %s hostmaster.%s ( @ IN SOA %s hostmaster.%s (
%d ; serial %d ; serial
3600 ; refresh 300 ; refresh
900 ; retry 60 ; retry
1209600 ; expire 1209600 ; expire
300 ) ; minimum 60 ) ; minimum
@ IN NS %s @ IN NS %s
ns1 IN A %s ns1 IN A %s
`, ns, origin, serial, ns, primaryIP) `, ns, origin, serial, ns, primaryIP)
+53 -1
View File
@@ -60,7 +60,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return ctrl.Result{}, genErr return ctrl.Result{}, genErr
} }
newSecret := &corev1.Secret{ newSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace, Labels: map[string]string{managedByLabel: managedByValue}}, ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace},
Data: map[string][]byte{ Data: map[string][]byte{
"algorithm": []byte(algorithm), "algorithm": []byte(algorithm),
"keyName": []byte(keyName), "keyName": []byte(keyName),
@@ -68,6 +68,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
"key.conf": []byte(bind.KeyClause(keyName, algorithm, material)), "key.conf": []byte(bind.KeyClause(keyName, algorithm, material)),
}, },
} }
applySecretTemplate(&newSecret.ObjectMeta, key.Spec.SecretTemplate)
if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil { if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil {
return ctrl.Result{}, err return ctrl.Result{}, err
} }
@@ -77,6 +78,21 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
logger.Info("generated TSIG key", "key", key.Name, "secret", secretName) logger.Info("generated TSIG key", "key", key.Name, "secret", secretName)
case err != nil: case err != nil:
return ctrl.Result{}, err return ctrl.Result{}, err
default:
// Secret already exists: reconcile the template-managed metadata so that
// annotation/label changes on the CR (e.g. reflection hints) propagate
// without regenerating key material. Skip imported secrets, which are
// owned by an external manager (Vault/VSO, reflector) that we must not
// fight over metadata.
if key.Spec.ImportExisting {
break
}
if updated := applySecretTemplate(&secret.ObjectMeta, key.Spec.SecretTemplate); updated {
if err := r.Update(ctx, &secret); err != nil {
return ctrl.Result{}, err
}
logger.Info("updated TSIG key secret metadata", "key", key.Name, "secret", secretName)
}
} }
key.Status.SecretName = secretName key.Status.SecretName = secretName
@@ -90,6 +106,42 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return ctrl.Result{}, nil return ctrl.Result{}, nil
} }
// applySecretTemplate stamps the operator-managed label plus any
// user-supplied labels/annotations onto the Secret's metadata. It returns true
// if it mutated meta, so callers can decide whether an update is needed.
func applySecretTemplate(meta *metav1.ObjectMeta, tmpl *bindv1alpha1.SecretMetadata) bool {
changed := false
setLabel := func(k, v string) {
if meta.Labels == nil {
meta.Labels = map[string]string{}
}
if meta.Labels[k] != v {
meta.Labels[k] = v
changed = true
}
}
setAnnotation := func(k, v string) {
if meta.Annotations == nil {
meta.Annotations = map[string]string{}
}
if meta.Annotations[k] != v {
meta.Annotations[k] = v
changed = true
}
}
setLabel(managedByLabel, managedByValue)
if tmpl != nil {
for k, v := range tmpl.Labels {
setLabel(k, v)
}
for k, v := range tmpl.Annotations {
setAnnotation(k, v)
}
}
return changed
}
func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) { func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) {
key.Status.Ready = false key.Status.Ready = false
key.Status.ObservedGeneration = key.Generation key.Status.ObservedGeneration = key.Generation
+71
View File
@@ -0,0 +1,71 @@
package controller
import (
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
func TestApplySecretTemplate(t *testing.T) {
t.Run("nil template still stamps managed-by label", func(t *testing.T) {
var meta metav1.ObjectMeta
if !applySecretTemplate(&meta, nil) {
t.Fatal("expected change on empty meta")
}
if meta.Labels[managedByLabel] != managedByValue {
t.Errorf("managed-by label = %q, want %q", meta.Labels[managedByLabel], managedByValue)
}
if meta.Annotations != nil {
t.Errorf("annotations = %v, want nil", meta.Annotations)
}
})
t.Run("applies labels and annotations", func(t *testing.T) {
meta := metav1.ObjectMeta{Labels: map[string]string{managedByLabel: managedByValue}}
tmpl := &bindv1alpha1.SecretMetadata{
Annotations: map[string]string{"reflector.v1.k8s.emberstack.com/reflection-allowed": "true"},
Labels: map[string]string{"team": "dns"},
}
if !applySecretTemplate(&meta, tmpl) {
t.Fatal("expected change when adding template metadata")
}
if got := meta.Annotations["reflector.v1.k8s.emberstack.com/reflection-allowed"]; got != "true" {
t.Errorf("reflection annotation = %q, want true", got)
}
if meta.Labels["team"] != "dns" {
t.Errorf("team label = %q, want dns", meta.Labels["team"])
}
// managed-by must survive user-supplied labels.
if meta.Labels[managedByLabel] != managedByValue {
t.Errorf("managed-by label dropped: %v", meta.Labels)
}
})
t.Run("idempotent when already applied", func(t *testing.T) {
tmpl := &bindv1alpha1.SecretMetadata{
Annotations: map[string]string{"a": "1"},
Labels: map[string]string{"b": "2"},
}
meta := metav1.ObjectMeta{}
applySecretTemplate(&meta, tmpl)
if applySecretTemplate(&meta, tmpl) {
t.Error("expected no change on second apply")
}
})
t.Run("updates drifted annotation value", func(t *testing.T) {
meta := metav1.ObjectMeta{
Labels: map[string]string{managedByLabel: managedByValue},
Annotations: map[string]string{"a": "old"},
}
tmpl := &bindv1alpha1.SecretMetadata{Annotations: map[string]string{"a": "new"}}
if !applySecretTemplate(&meta, tmpl) {
t.Fatal("expected change when annotation value drifts")
}
if meta.Annotations["a"] != "new" {
t.Errorf("annotation a = %q, want new", meta.Annotations["a"])
}
})
}
+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") 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 { if err != nil {
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error()) 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. // buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
// transferKey, when set, is the catalog transfer TSIG key name; catalog member // transferKey, when set, is the catalog transfer TSIG key name; catalog member
// primary zones must allow AXFR with it so secondaries can pull them. // 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 zType := zone.Spec.Type
if zType == "" { if zType == "" {
zType = bindv1alpha1.ZonePrimary zType = bindv1alpha1.ZonePrimary
@@ -154,6 +162,12 @@ func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1al
// Catalog member: permit key-authenticated AXFR from secondaries. // Catalog member: permit key-authenticated AXFR from secondaries.
parts = append(parts, fmt.Sprintf("allow-transfer { key \"%s\"; }", transferKey)) 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 != "" { if zone.Spec.DNSSECPolicyRef != "" {
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes") 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 ( import (
"context" "context"
"fmt" "fmt"
"sort"
"time" "time"
corev1 "k8s.io/api/core/v1" 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) 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. // resolveTSIG reads the material of a BindTSIGKey into TSIG credentials.
func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) { func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) {
var creds bind.TSIGCreds var creds bind.TSIGCreds