Files
bind-operator/internal/controller/reconfig_test.go
T
unkin-agent 70e5b0ed55
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
apply config changes via rndc reconfig instead of restarting pods
- symlink named.conf to the projected file so kubelet updates reach named
- drop the config hash from the pod template; hash only the entrypoint
- reconfig each pod once its mounted config matches, tracked in status
- surface reconfig failures with checkconf detail on ConfigApplied
2026-10-16 23:30:51 +11:00

191 lines
6.7 KiB
Go

package controller
import (
"context"
"errors"
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/api/resource"
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"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
func testScheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(s); err != nil {
t.Fatal(err)
}
if err := bindv1alpha1.AddToScheme(s); err != nil {
t.Fatal(err)
}
return s
}
// fakeExec serves `cat` from mounted (per pod) and counts reconfigs.
type fakeExec struct {
mounted map[string]string
reconfigErr error
checkconf string
reconfigs map[string]int
}
func (f *fakeExec) Exec(_ context.Context, _, pod string, cmd []string, _ string) (string, error) {
if len(cmd) == 2 && cmd[0] == bind.CheckconfBin {
return f.checkconf, errors.New("exit 1")
}
if len(cmd) != 3 || cmd[0] != "cat" || cmd[1] != bind.NamedConfRun || cmd[2] != bind.KeysConfPath {
return "", errors.New("unexpected command")
}
return f.mounted[pod], nil
}
func (f *fakeExec) Reconfig(_ context.Context, _, pod string) error {
f.reconfigs[pod]++
return f.reconfigErr
}
func readyPod(cluster, name string) *corev1.Pod {
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "ns", Labels: commonLabels(cluster)},
Status: corev1.PodStatus{Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}},
}
}
func TestPodTemplateIgnoresConfigButTracksPodSpec(t *testing.T) {
scheme := testScheme(t)
cluster := &bindv1alpha1.BindCluster{
ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"},
Spec: bindv1alpha1.BindClusterSpec{Replicas: 2},
}
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cluster).Build()
r := &BindClusterReconciler{Client: cl, Scheme: scheme}
ctx := context.Background()
template := func() corev1.PodTemplateSpec {
t.Helper()
sts, err := r.reconcileStatefulSet(ctx, cluster)
if err != nil {
t.Fatal(err)
}
return sts.Spec.Template
}
setConfig := func(conf, keys string) {
t.Helper()
if err := r.upsertConfigMap(ctx, cluster, configMapName("c"), map[string]string{"named.conf.secondary": conf}); err != nil {
t.Fatal(err)
}
if err := r.upsertSecret(ctx, cluster, keysSecretName("c"), map[string][]byte{"keys.conf": []byte(keys)}); err != nil {
t.Fatal(err)
}
}
setConfig("options { recursion yes; };", "")
base := template()
setConfig(`options { recursion no; }; zone "x" { type forward; forwarders { 1.1.1.1; }; };`, "key k {};")
if got := template(); !equality.Semantic.DeepEqual(base, got) {
t.Fatal("pod template changed on a rendered-config change; that restarts every pod")
}
cluster.Spec.Image = "internetsystemsconsortium/bind9:9.21"
withImage := template()
if equality.Semantic.DeepEqual(base, withImage) {
t.Fatal("pod template must change when the image changes")
}
cluster.Spec.Resources = corev1.ResourceRequirements{Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("1Gi")}}
if equality.Semantic.DeepEqual(withImage, template()) {
t.Fatal("pod template must change when resources change")
}
}
func TestReconfigPodsWaitsForMountThenReconfigsOnce(t *testing.T) {
scheme := testScheme(t)
cluster := &bindv1alpha1.BindCluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(readyPod("c", "c-0"), readyPod("c", "c-1")).Build()
ex := &fakeExec{
mounted: map[string]string{"c-0": "old-primary", "c-1": "old-secondary"},
reconfigs: map[string]int{},
}
r := &BindClusterReconciler{Client: cl, Scheme: scheme, Exec: ex}
ctx := context.Background()
const primary, secondary, keys = "new-primary;", "new-secondary;", "key k {};"
if d := r.reconfigPods(ctx, cluster, primary, secondary, keys); d != requeueShort {
t.Fatalf("stale mounts should requeue short, got %v", d)
}
if len(ex.reconfigs) != 0 {
t.Fatalf("reconfig ran before the mount caught up: %v", ex.reconfigs)
}
if c := meta.FindStatusCondition(cluster.Status.Conditions, "ConfigApplied"); c == nil || c.Reason != "Propagating" {
t.Fatalf("want Propagating condition, got %+v", c)
}
// Only the primary's mount has propagated.
ex.mounted["c-0"] = primary + keys
if d := r.reconfigPods(ctx, cluster, primary, secondary, keys); d != requeueShort {
t.Fatalf("secondary still stale should requeue, got %v", d)
}
if ex.reconfigs["c-0"] != 1 || ex.reconfigs["c-1"] != 0 {
t.Fatalf("want only c-0 reconfigured, got %v", ex.reconfigs)
}
ex.mounted["c-1"] = secondary + keys
for range 3 {
if d := r.reconfigPods(ctx, cluster, primary, secondary, keys); d != 0 {
t.Fatalf("converged cluster should not requeue, got %v", d)
}
}
if ex.reconfigs["c-0"] != 1 || ex.reconfigs["c-1"] != 1 {
t.Fatalf("each pod must reconfig exactly once, got %v", ex.reconfigs)
}
if c := meta.FindStatusCondition(cluster.Status.Conditions, "ConfigApplied"); c == nil || c.Status != metav1.ConditionTrue {
t.Fatalf("want ConfigApplied=True, got %+v", c)
}
// A pod that no longer exists drops out of the applied map.
if err := cl.Delete(ctx, readyPod("c", "c-1")); err != nil {
t.Fatal(err)
}
r.reconfigPods(ctx, cluster, primary, secondary, keys)
if _, ok := cluster.Status.AppliedConfig["c-1"]; ok {
t.Fatal("applied map kept a deleted pod")
}
}
func TestReconfigPodsSurfacesFailure(t *testing.T) {
scheme := testScheme(t)
cluster := &bindv1alpha1.BindCluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(readyPod("c", "c-0")).Build()
ex := &fakeExec{
mounted: map[string]string{"c-0": "bad;"},
reconfigErr: errors.New("rndc: 'reconfig' failed: unexpected token"),
checkconf: "/run/named/named.conf:6: expected IP address near 'not-an-ip'",
reconfigs: map[string]int{},
}
r := &BindClusterReconciler{Client: cl, Scheme: scheme, Exec: ex}
if d := r.reconfigPods(context.Background(), cluster, "bad;", "", ""); d != requeueLong {
t.Fatalf("failure should requeue long, got %v", d)
}
c := meta.FindStatusCondition(cluster.Status.Conditions, "ConfigApplied")
if c == nil || c.Status != metav1.ConditionFalse || c.Reason != "ReconfigFailed" {
t.Fatalf("want ReconfigFailed condition, got %+v", c)
}
if !strings.Contains(c.Message, "named.conf:6: expected IP address") {
t.Fatalf("condition should carry the checkconf detail, got %q", c.Message)
}
if _, ok := cluster.Status.AppliedConfig["c-0"]; ok {
t.Fatal("failed reconfig must not be recorded as applied")
}
}