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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user