From 2deea3e023104e19fb97f5c6284a6e3c9b72e4f4 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 12 Jul 2026 22:40:02 +1000 Subject: [PATCH] Roll pods on config change via a pod-template config hash 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. --- internal/controller/bindcluster_controller.go | 50 ++++++++++++++- internal/controller/config_hash_test.go | 64 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 internal/controller/config_hash_test.go diff --git a/internal/controller/bindcluster_controller.go b/internal/controller/bindcluster_controller.go index 061c39e..e885f4a 100644 --- a/internal/controller/bindcluster_controller.go +++ b/internal/controller/bindcluster_controller.go @@ -1,7 +1,10 @@ package controller import ( + "bytes" "context" + "crypto/sha256" + "encoding/hex" "fmt" "sort" "strings" @@ -351,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{ @@ -358,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, @@ -426,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 diff --git a/internal/controller/config_hash_test.go b/internal/controller/config_hash_test.go new file mode 100644 index 0000000..ebb4a27 --- /dev/null +++ b/internal/controller/config_hash_test.go @@ -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") + } +} -- 2.47.3