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.
This commit is contained in:
2026-07-12 22:40:02 +10:00
parent ef8c41cb0f
commit 2deea3e023
2 changed files with 113 additions and 1 deletions
+49 -1
View File
@@ -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
+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")
}
}