70e5b0ed55
- 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
191 lines
6.7 KiB
Go
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")
|
|
}
|
|
}
|