4 Commits

Author SHA1 Message Date
benvin 8fac152537 Merge pull request 'Roll pods on config change via a pod-template config hash' (#10) from benvin/config-hash-rollout into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #10
2026-07-12 22:46:51 +10:00
unkinben 2deea3e023 Roll pods on config change via a pod-template config hash
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Pods copy config from the projected volume into an emptyDir once at
startup, so a ConfigMap or keys.conf change never reaches a running pod:
rndc reconfig re-reads the stale startup copy, and a manual
`kubectl rollout restart` is reverted because the operator overwrites the
pod template every reconcile. The only thing that applies new config is a
restart, and nothing triggered one.

Stamp a hash of the projected config (rendered ConfigMap + keys.conf
Secret) onto the pod template as bind.unkin.net/config-hash. When config
changes the hash flips, the template changes, and the StatefulSet does a
normal rolling restart so every pod re-copies fresh config. The operator
owns the template, so the restart is operator-driven and not reverted; a
stable hash means no spurious restarts.

Covers named.conf changes (ACLs, views, forwarders, validate-except,
primary address) and TSIG key rotation.
2026-07-12 22:40:02 +10:00
benvin ef8c41cb0f Merge pull request 'Fix authoritative secondary replication (TSIG transfer + stable primary)' (#9) from benvin/fix-secondary-replication into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #9
2026-07-12 21:33:39 +10:00
unkinben ea330bd767 Fix authoritative secondary replication (TSIG transfer + stable primary)
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Secondaries never replicated any member zone: the master's catalog zone
requires key-authenticated AXFR (allow-transfer { key "transfer-key"; }),
but the rendered secondary config transferred without presenting the key,
so every catalog transfer was REFUSED and no member zones provisioned.
Two further gaps compounded it: member zones had no allow-transfer at all,
and secondaries pointed at the primary's pod IP, which dies on restart.

- Render the catalog transfer key into the secondary catalog-zones
  default-primaries and the secondary catalog zone primaries, so
  key-authenticated AXFR from the primary is accepted.
- Add allow-transfer { key "<transfer-key>"; } to catalog member primary
  zones (when the zone does not set an explicit allow-transfer), so
  secondaries can pull them; applied to existing zones via modzone.
- Point secondaries at the stable primary Service ClusterIP instead of the
  primary pod IP, so replication survives primary pod restarts (falls back
  to the pod IP when no primary Service exists).
2026-07-12 19:42:38 +10:00
6 changed files with 213 additions and 16 deletions
+30 -8
View File
@@ -230,15 +230,40 @@ func responsePolicyClause(policies []bindv1alpha1.BindPolicy, indent string) str
return b.String()
}
// transferPrimaries returns the primaries list secondaries use to AXFR the
// catalog (and, by inheritance, its member zones), each annotated with the
// catalog transfer TSIG key. The primary requires key-authenticated transfers
// (allow-transfer { key ... }), so an unkeyed primaries list is REFUSED.
func transferPrimaries(in RenderInput) []string {
primaries := in.Catalog.Spec.DefaultPrimaries
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
key := in.Catalog.Spec.TransferKeyRef
if key == "" {
return primaries
}
out := make([]string, 0, len(primaries))
for _, p := range primaries {
p = strings.TrimSpace(strings.TrimRight(p, ";"))
if p == "" {
continue
}
if strings.Contains(p, " key ") {
out = append(out, p)
} else {
out = append(out, fmt.Sprintf("%s key \"%s\"", p, key))
}
}
return out
}
func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string {
// Only secondaries consume the catalog to auto-provision member zones.
if in.Catalog == nil || isPrimary {
return ""
}
primaries := in.Catalog.Spec.DefaultPrimaries
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
primaries := transferPrimaries(in)
if len(primaries) == 0 {
return ""
}
@@ -259,10 +284,7 @@ func renderCatalogZoneDecl(in RenderInput, isPrimary bool, indent string) string
}
cat := in.Catalog
file := CatalogFilePath(cat.Spec.ZoneName)
primaries := cat.Spec.DefaultPrimaries
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
primaries := transferPrimaries(in)
if len(primaries) == 0 {
// Primary IP not known yet; omit the secondary catalog zone rather than
// emit an invalid empty primaries list. A Pod-triggered reconcile renders
+18
View File
@@ -80,6 +80,24 @@ func TestRenderCatalogUsesPrimaryIP(t *testing.T) {
}
}
func TestRenderCatalogPrimariesCarryTransferKey(t *testing.T) {
// When the catalog declares a transfer key, secondaries must present it in
// both the catalog-zones default-primaries and the secondary catalog zone,
// or the key-authenticated primary REFUSES the AXFR.
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal", TransferKeyRef: "transfer-key"}},
PrimaryAddress: "10.43.0.5",
}
_, secondary := RenderNamedConf(in)
if !strings.Contains(secondary, `default-primaries { 10.43.0.5 key "transfer-key"; }`) {
t.Fatalf("catalog-zones default-primaries must carry the transfer key:\n%s", secondary)
}
if !strings.Contains(secondary, `primaries { 10.43.0.5 key "transfer-key"; }`) {
t.Fatalf("secondary catalog zone primaries must carry the transfer key:\n%s", secondary)
}
}
func TestRenderForwardZoneInView(t *testing.T) {
rec := true
in := RenderInput{
+54 -5
View File
@@ -1,7 +1,10 @@
package controller
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
@@ -168,10 +171,11 @@ func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bind
}
func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error {
// BIND primaries/default-primaries need the primary's IP address, not a DNS
// name, so render with pod-0's current IP (empty until it is scheduled; the
// Pod watch re-renders when it appears or changes).
in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryPodIP(ctx, r.Client, c)}
// BIND primaries/default-primaries need an IP address, not a DNS name. Use
// the stable primary Service ClusterIP so secondaries keep transferring
// across primary pod restarts (falls back to the pod IP when no primary
// Service exists; the Pod/Service watches re-render when it changes).
in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryTransferAddress(ctx, r.Client, c)}
var acls bindv1alpha1.BindACLList
if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil {
@@ -350,6 +354,12 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
}}},
}
// Pods copy config from the projected volume into an emptyDir once at
// startup; a ConfigMap/keys change never reaches a running pod (rndc
// reconfig re-reads the stale startup copy). Stamp a hash of the projected
// config onto the pod template so a config change rolls the StatefulSet,
// which is the only way the change takes effect. The operator owns the
// template, so this restart is operator-driven and not reverted.
sts := &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{Name: c.Name, Namespace: c.Namespace, Labels: labels},
Spec: appsv1.StatefulSetSpec{
@@ -357,7 +367,7 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: labels},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: map[string]string{configHashAnnotation: r.configHash(ctx, c)}},
Spec: corev1.PodSpec{
NodeSelector: c.Spec.NodeSelector,
Tolerations: c.Spec.Tolerations,
@@ -425,6 +435,45 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
return &existing, nil
}
// configHashAnnotation carries a hash of the projected config on the pod
// template; changing it triggers a rolling restart so pods pick up new config.
const configHashAnnotation = "bind.unkin.net/config-hash"
// configHash returns a deterministic hash of the config projected into the pods
// (the rendered ConfigMap and the keys.conf Secret). It is read after those are
// reconciled, so it reflects the current desired config. A stable hash means no
// spurious restarts; any config or TSIG-key change flips it and rolls the pods.
func (r *BindClusterReconciler) configHash(ctx context.Context, c *bindv1alpha1.BindCluster) string {
var buf bytes.Buffer
var cm corev1.ConfigMap
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: configMapName(c.Name)}, &cm); err == nil {
for _, k := range sortedKeys(cm.Data) {
fmt.Fprintf(&buf, "%s\x00%s\x00", k, cm.Data[k])
}
}
var keys corev1.Secret
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: keysSecretName(c.Name)}, &keys); err == nil {
data := make(map[string]string, len(keys.Data))
for k, v := range keys.Data {
data[k] = string(v)
}
for _, k := range sortedKeys(data) {
fmt.Fprintf(&buf, "%s\x00%s\x00", k, data[k])
}
}
sum := sha256.Sum256(buf.Bytes())
return hex.EncodeToString(sum[:])
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func (r *BindClusterReconciler) reloadReadyPods(ctx context.Context, c *bindv1alpha1.BindCluster) {
if r.Exec == nil {
return
+29 -3
View File
@@ -80,7 +80,7 @@ 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)
zoneConfig, err := r.buildZoneConfig(ctx, &zone, r.zoneTransferKeyRef(ctx, &zone, cluster))
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
}
@@ -133,7 +133,9 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
}
// buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone) (string, error) {
// 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) {
zType := zone.Spec.Type
if zType == "" {
zType = bindv1alpha1.ZonePrimary
@@ -145,8 +147,12 @@ func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1al
if zone.Spec.DynamicUpdate && zone.Spec.UpdateKeyRef != "" {
parts = append(parts, fmt.Sprintf("allow-update { key \"%s\"; }", updateKeyName(ctx, r.Client, zone)))
}
if len(zone.Spec.AllowTransfer) > 0 {
switch {
case len(zone.Spec.AllowTransfer) > 0:
parts = append(parts, fmt.Sprintf("allow-transfer { %s }", matchListInline(zone.Spec.AllowTransfer)))
case transferKey != "":
// Catalog member: permit key-authenticated AXFR from secondaries.
parts = append(parts, fmt.Sprintf("allow-transfer { key \"%s\"; }", transferKey))
}
if zone.Spec.DNSSECPolicyRef != "" {
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
@@ -202,6 +208,26 @@ func (r *BindZoneReconciler) deregisterCatalog(ctx context.Context, zone *bindv1
_ = r.Exec.RemoveCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds)
}
// zoneTransferKeyRef returns the catalog transfer TSIG key name that a catalog
// member primary zone must allow AXFR with, so secondaries (which present that
// key) can pull it. Returns "" for non-member zones, non-primary zones, or when
// the cluster has no catalog.
func (r *BindZoneReconciler) zoneTransferKeyRef(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) string {
if !isPrimaryType(zone.Spec.Type) || !catalogEnabled(zone) {
return ""
}
var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
return ""
}
for i := range catalogs.Items {
if catalogs.Items[i].Spec.ClusterRef == cluster.Name {
return catalogs.Items[i].Spec.TransferKeyRef
}
}
return ""
}
func (r *BindZoneReconciler) catalogFor(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (*bindv1alpha1.BindCatalogZone, bind.TSIGCreds, bool) {
var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
+64
View File
@@ -0,0 +1,64 @@
package controller
import (
"context"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
func TestConfigHashStableAndSensitive(t *testing.T) {
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := bindv1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
cluster := &bindv1alpha1.BindCluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: configMapName("c"), Namespace: "ns"},
Data: map[string]string{"named.conf.secondary": "options { recursion yes; };"},
}
keys := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: keysSecretName("c"), Namespace: "ns"},
Data: map[string][]byte{"keys.conf": []byte("key x {};")},
}
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm, keys).Build()
r := &BindClusterReconciler{Client: c, Scheme: scheme}
ctx := context.Background()
h1 := r.configHash(ctx, cluster)
if h1 == "" {
t.Fatal("hash should not be empty when config exists")
}
// Stable across calls when nothing changes.
if h2 := r.configHash(ctx, cluster); h2 != h1 {
t.Fatalf("hash not stable: %s != %s", h1, h2)
}
// A config change flips the hash (this is what rolls the StatefulSet).
cm.Data["named.conf.secondary"] = "options { recursion yes; validate-except { unkin.net; }; };"
if err := c.Update(ctx, cm); err != nil {
t.Fatal(err)
}
if h3 := r.configHash(ctx, cluster); h3 == h1 {
t.Fatal("hash must change when the ConfigMap changes")
}
// A TSIG key (keys.conf) change also flips it.
afterCM := r.configHash(ctx, cluster)
keys.Data["keys.conf"] = []byte("key x { algorithm hmac-sha256; };")
if err := c.Update(ctx, keys); err != nil {
t.Fatal(err)
}
if h4 := r.configHash(ctx, cluster); h4 == afterCM {
t.Fatal("hash must change when keys.conf changes")
}
}
+18
View File
@@ -108,6 +108,24 @@ func primaryPodIP(ctx context.Context, c client.Client, cluster *bindv1alpha1.Bi
return pod.Status.PodIP
}
// primaryTransferAddress returns the address secondaries use to reach the
// primary for catalog and zone AXFR. It prefers the primary Service ClusterIP,
// which is stable across primary pod restarts (the pod IP is not: it changes on
// every restart, leaving secondaries pointed at a dead address). It falls back
// to the primary pod IP when no primary Service is configured or its ClusterIP
// is not yet assigned.
func primaryTransferAddress(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) string {
if cluster.Spec.PrimaryService != nil {
var svc corev1.Service
if err := c.Get(ctx, client.ObjectKey{Namespace: cluster.Namespace, Name: primaryServiceName(cluster.Name)}, &svc); err == nil {
if ip := svc.Spec.ClusterIP; ip != "" && ip != corev1.ClusterIPNone {
return ip
}
}
}
return primaryPodIP(ctx, c, cluster)
}
// 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