Skip to content

Commit 493f655

Browse files
matka12claudejdheyburn
authored
fix: stop updating workloads on every reconcile by owning API-server defaults (#317)
Fixes #315. ## Problem `ensureStatefulSet` / `ensureDeployment` assign the whole desired spec inside their `CreateOrUpdate` mutate functions, while the builders leave every API-server-defaulted field unset. Each reconcile clobbers the stored defaults back to zero values, `CreateOrUpdate` sees a diff, and the operator issues an Update — every pass, forever, with `metadata.generation` stuck. Observed impact: ~1–2k `statefulsets.update` Admin Activity audit events/hour on a 3-shard/6-node cluster; on GKE these land in the non-excludable `_Required` bucket (details in #315). This implements the **narrow fix** discussed in the issue thread (own the defaults in the builders), as suggested there — small and backportable; a Server-Side Apply migration can follow separately. ## Changes **`buildValkeyNodePodTemplateSpec` / `buildContainersDef`** — set the pod-template fields the API server would default, so the built template already equals the stored one: - pod: `restartPolicy`, `dnsPolicy`, `schedulerName`, `terminationGracePeriodSeconds` (30 when unset), empty `securityContext` when none is configured, volume `defaultMode`s - containers (applied after `mergePatchContainers`, so user container patches are normalized the same way the API server would): `imagePullPolicy` (tag-aware, mirroring API-server logic), `terminationMessagePath`/`Policy`, port `protocol`, env `fieldRef.apiVersion` **`buildValkeyNodeStatefulSet`** — `podManagementPolicy`, `updateStrategy` (RollingUpdate, partition 0), `revisionHistoryLimit`, `persistentVolumeClaimRetentionPolicy` (Retain/Retain; note: requires the `StatefulSetAutoDeletePVC` gate, on by default since 1.27, GA 1.32 — on older clusters with the gate off the API server drops the field and this one field would resume churning). **`buildValkeyNodeDeployment`** — `revisionHistoryLimit`, `progressDeadlineSeconds`. **`upsertService`** — the rebuilt `ports` slice now sets `protocol` and `targetPort`, which made the headless Service churn the same way. The PDB reconcile already mutates field-wise with values that round-trip cleanly, so it needed no change. ## Tests - New envtest regression test: reconcile a ValkeyNode, capture the StatefulSet `resourceVersion`, reconcile 3 more times, assert it is unchanged. Fails on `main`, passes here (envtest runs a real API server, so real defaulting is exercised). - `TestBuildValkeyNodePodTemplateSpec_PodSecurityContext_NilIsNoop` updated: omitting the field now yields the empty `securityContext` the API server defaults to — semantically identical (no security settings applied), and required for the no-op property. - `make test` green (75/75 specs + unit tests, envtest k8s 1.35). ## Caveat This is intentionally the treadmill-y narrow fix: a future Kubernetes version defaulting a new field would reintroduce churn for that field until added here. The durable fix is SSA with generated ApplyConfigurations, per the discussion in #315. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matan David <matan.david@eon.io> Signed-off-by: Joseph Heyburn <34041368+jdheyburn@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Joseph Heyburn <34041368+jdheyburn@users.noreply.github.com> Co-authored-by: Joseph Heyburn <jdheyburn@gmail.com>
1 parent f8715f6 commit 493f655

6 files changed

Lines changed: 240 additions & 5 deletions

internal/controller/valkeycluster_controller.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
"k8s.io/apimachinery/pkg/api/meta"
3636
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3737
"k8s.io/apimachinery/pkg/runtime"
38+
"k8s.io/apimachinery/pkg/util/intstr"
3839
"k8s.io/client-go/tools/events"
3940
ctrl "sigs.k8s.io/controller-runtime"
4041
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -489,7 +490,15 @@ func (r *ValkeyClusterReconciler) upsertService(ctx context.Context, cluster *va
489490
svc.Spec.ClusterIP = "None"
490491
}
491492
svc.Spec.Selector = map[string]string{LabelCluster: cluster.Name}
492-
svc.Spec.Ports = []corev1.ServicePort{{Name: appName, Port: DefaultPort}}
493+
// Protocol and TargetPort are API-server defaults; set them explicitly
494+
// so the rebuilt slice deep-equals the stored one and CreateOrUpdate
495+
// stops updating the Service on every reconcile (#315).
496+
svc.Spec.Ports = []corev1.ServicePort{{
497+
Name: appName,
498+
Port: DefaultPort,
499+
Protocol: corev1.ProtocolTCP,
500+
TargetPort: intstr.FromInt32(DefaultPort),
501+
}}
493502
return controllerutil.SetControllerReference(cluster, svc, r.Scheme)
494503
})
495504
if err != nil {

internal/controller/valkeycluster_controller_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,37 @@ var _ = Describe("EventRecorder", func() {
755755
Expect(events).To(ContainElement(ContainSubstring("Created headless Service")))
756756
})
757757

758+
It("should not update the headless Service on repeated upserts", func() {
759+
cluster := &valkeyiov1alpha1.ValkeyCluster{
760+
ObjectMeta: metav1.ObjectMeta{
761+
Name: "svc-noop-cluster",
762+
Namespace: "default",
763+
},
764+
Spec: valkeyiov1alpha1.ValkeyClusterSpec{
765+
Shards: 3,
766+
Replicas: 1,
767+
},
768+
}
769+
Expect(k8sClient.Create(ctx, cluster)).To(Succeed())
770+
defer func() { _ = k8sClient.Delete(ctx, cluster) }()
771+
772+
Expect(r.upsertService(ctx, cluster)).To(Succeed())
773+
svc := &corev1.Service{}
774+
svcKey := types.NamespacedName{Name: headlessServiceName(cluster.Name), Namespace: cluster.Namespace}
775+
Expect(k8sClient.Get(ctx, svcKey, svc)).To(Succeed())
776+
createdResourceVersion := svc.ResourceVersion
777+
778+
// The API server defaults the port's protocol and targetPort when
779+
// the Service is stored; the rebuilt ports slice must already carry
780+
// them or every upsert Updates the Service (#315).
781+
for range 3 {
782+
Expect(r.upsertService(ctx, cluster)).To(Succeed())
783+
}
784+
Expect(k8sClient.Get(ctx, svcKey, svc)).To(Succeed())
785+
Expect(svc.ResourceVersion).To(Equal(createdResourceVersion),
786+
"an upsert with no changes must not write the Service")
787+
})
788+
758789
It("should emit ConfigMapCreated event on successful configmap creation", func() {
759790
cluster := &valkeyiov1alpha1.ValkeyCluster{
760791
ObjectMeta: metav1.ObjectMeta{

internal/controller/valkeycluster_pdb_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,25 @@ var _ = Describe("reconcilePodDisruptionBudget", func() {
101101

102102
Expect(k8sClient.Get(ctx, pdbKey, pdb)).To(Succeed())
103103
})
104+
105+
It("does not update the PDB on repeated reconciles when nothing changed", func() {
106+
Expect(reconciler.reconcilePodDisruptionBudget(ctx, cluster)).To(Succeed())
107+
108+
pdb := &policyv1.PodDisruptionBudget{}
109+
Expect(k8sClient.Get(ctx, pdbKey, pdb)).To(Succeed())
110+
createdResourceVersion := pdb.ResourceVersion
111+
112+
// The PDB mutate assigns fields individually (it never replaces the
113+
// whole spec), so anything the API server defaults on the stored
114+
// object — e.g. unhealthyPodEvictionPolicy on newer clusters — must
115+
// survive and repeated reconciles must be no-ops (#315).
116+
for range 3 {
117+
Expect(reconciler.reconcilePodDisruptionBudget(ctx, cluster)).To(Succeed())
118+
}
119+
Expect(k8sClient.Get(ctx, pdbKey, pdb)).To(Succeed())
120+
Expect(pdb.ResourceVersion).To(Equal(createdResourceVersion),
121+
"a reconcile with no changes must not write the PDB")
122+
})
104123
})
105124

106125
Context("when PodDisruptionBudget mode is Cluster", func() {

internal/controller/valkeynode_controller_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,37 @@ var _ = Describe("ValkeyNode Controller", func() {
192192
Expect(sts.Spec.Template.Labels).To(HaveKeyWithValue("app.kubernetes.io/component", "valkey-node"))
193193
})
194194

195+
It("should not update the StatefulSet on reconciles when nothing changed", func() {
196+
r := &ValkeyNodeReconciler{
197+
Client: k8sClient,
198+
Scheme: k8sClient.Scheme(),
199+
Recorder: events.NewFakeRecorder(100),
200+
}
201+
202+
By("creating the StatefulSet on first reconcile")
203+
_, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName})
204+
Expect(err).NotTo(HaveOccurred())
205+
206+
sts := &appsv1.StatefulSet{}
207+
Expect(k8sClient.Get(ctx, statefulSetName, sts)).To(Succeed())
208+
createdResourceVersion := sts.ResourceVersion
209+
210+
// The API server fills in defaults (podManagementPolicy,
211+
// updateStrategy, imagePullPolicy, ...) when the StatefulSet is
212+
// stored. The desired object built on the next pass must already
213+
// carry those values, otherwise CreateOrUpdate sees a diff and
214+
// issues an Update on every reconcile (#315).
215+
By("reconciling again without any spec change")
216+
for range 3 {
217+
_, err = r.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName})
218+
Expect(err).NotTo(HaveOccurred())
219+
}
220+
221+
Expect(k8sClient.Get(ctx, statefulSetName, sts)).To(Succeed())
222+
Expect(sts.ResourceVersion).To(Equal(createdResourceVersion),
223+
"a reconcile with no changes must not write the StatefulSet")
224+
})
225+
195226
It("should set Ready=false with PodNotReady condition when no pod exists", func() {
196227
By("Reconciling the created resource")
197228
r := &ValkeyNodeReconciler{

internal/controller/valkeynode_resources.go

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package controller
1919
import (
2020
"encoding/json"
2121
"fmt"
22+
"strings"
2223

2324
valkeyiov1alpha1 "github.com/valkey-io/valkey-operator/api/v1alpha1"
2425
appsv1 "k8s.io/api/apps/v1"
@@ -307,7 +308,76 @@ func buildContainersDef(node *valkeyiov1alpha1.ValkeyNode) ([]corev1.Container,
307308
containers = append(containers, generateMetricsExporterContainerDef(node.Spec.Exporter, node.Labels[LabelCluster], node.Spec.TLS))
308309
}
309310

310-
return mergePatchContainers(containers, node.Spec.Containers)
311+
merged, err := mergePatchContainers(containers, node.Spec.Containers)
312+
if err != nil {
313+
return nil, err
314+
}
315+
applyContainerAPIDefaults(merged)
316+
return merged, nil
317+
}
318+
319+
// applyContainerAPIDefaults fills the container fields the API server would
320+
// otherwise default (imagePullPolicy, terminationMessagePath/Policy, port
321+
// protocol, env fieldRef apiVersion). The workload reconcilers assign the
322+
// whole desired spec in their CreateOrUpdate mutate functions, so a field
323+
// left unset here is clobbered to its zero value on every pass and the
324+
// operator updates the workload on every reconcile even though nothing
325+
// changed (#315). Runs after mergePatchContainers so user-supplied container
326+
// patches are normalized the same way the API server would normalize them.
327+
func applyContainerAPIDefaults(containers []corev1.Container) {
328+
for i := range containers {
329+
c := &containers[i]
330+
if c.ImagePullPolicy == "" {
331+
c.ImagePullPolicy = defaultImagePullPolicy(c.Image)
332+
}
333+
if c.TerminationMessagePath == "" {
334+
c.TerminationMessagePath = corev1.TerminationMessagePathDefault
335+
}
336+
if c.TerminationMessagePolicy == "" {
337+
c.TerminationMessagePolicy = corev1.TerminationMessageReadFile
338+
}
339+
for j := range c.Ports {
340+
if c.Ports[j].Protocol == "" {
341+
c.Ports[j].Protocol = corev1.ProtocolTCP
342+
}
343+
}
344+
for j := range c.Env {
345+
if vf := c.Env[j].ValueFrom; vf != nil && vf.FieldRef != nil && vf.FieldRef.APIVersion == "" {
346+
vf.FieldRef.APIVersion = "v1"
347+
}
348+
}
349+
}
350+
}
351+
352+
// defaultImagePullPolicy mirrors the API server's imagePullPolicy defaulting
353+
// (SetDefaults_Container + parsers.ParseImageName): the effective tag is the
354+
// explicit tag when present — even alongside a digest — "latest" when the
355+
// reference has neither tag nor digest, and empty for a digest-only reference.
356+
// The policy is Always exactly when the effective tag is "latest",
357+
// IfNotPresent otherwise.
358+
func defaultImagePullPolicy(image string) corev1.PullPolicy {
359+
name := image
360+
if i := strings.IndexByte(name, '@'); i >= 0 {
361+
name = name[:i] // strip the digest; an explicit tag before it still counts
362+
}
363+
tag := ""
364+
// ':' only introduces a tag after the last '/' (a registry port such as
365+
// "reg:5000/img" is not a tag).
366+
if i := strings.LastIndexByte(name, ':'); i > strings.LastIndexByte(name, '/') {
367+
tag = name[i+1:]
368+
}
369+
switch {
370+
case tag == "latest":
371+
return corev1.PullAlways
372+
case tag != "":
373+
return corev1.PullIfNotPresent
374+
case len(name) < len(image):
375+
// Digest-only reference (no tag): IfNotPresent.
376+
return corev1.PullIfNotPresent
377+
default:
378+
// No tag, no digest: the reference normalizes to :latest.
379+
return corev1.PullAlways
380+
}
311381
}
312382

313383
// buildValkeyNodePodTemplateSpec constructs a PodTemplateSpec for a single
@@ -335,6 +405,15 @@ func buildValkeyNodePodTemplateSpec(node *valkeyiov1alpha1.ValkeyNode, labels ma
335405
TopologySpreadConstraints: node.Spec.TopologySpreadConstraints,
336406
SecurityContext: node.Spec.PodSecurityContext,
337407
TerminationGracePeriodSeconds: node.Spec.TerminationGracePeriodSeconds,
408+
// Fields below are set to the API server's defaults. The workload
409+
// reconcilers assign the whole desired spec in their CreateOrUpdate
410+
// mutate functions, so any field left unset here gets clobbered back
411+
// to its zero value on every pass, the API server re-defaults it, and
412+
// the operator issues an Update on every reconcile even though nothing
413+
// changed (#315).
414+
RestartPolicy: corev1.RestartPolicyAlways,
415+
DNSPolicy: corev1.DNSClusterFirst,
416+
SchedulerName: corev1.DefaultSchedulerName,
338417
Volumes: []corev1.Volume{
339418
{
340419
Name: "scripts",
@@ -404,6 +483,23 @@ func buildValkeyNodePodTemplateSpec(node *valkeyiov1alpha1.ValkeyNode, labels ma
404483
}
405484
podSpec.Volumes = append(podSpec.Volumes, dataVolume)
406485

486+
// Mirror the remaining API-server defaults (see the comment on the struct
487+
// literal above): a nil here is not "unset" once stored, it is a diff.
488+
if podSpec.SecurityContext == nil {
489+
podSpec.SecurityContext = &corev1.PodSecurityContext{}
490+
}
491+
if podSpec.TerminationGracePeriodSeconds == nil {
492+
podSpec.TerminationGracePeriodSeconds = func(i int64) *int64 { return &i }(corev1.DefaultTerminationGracePeriodSeconds)
493+
}
494+
for i := range podSpec.Volumes {
495+
if cm := podSpec.Volumes[i].ConfigMap; cm != nil && cm.DefaultMode == nil {
496+
cm.DefaultMode = func(i int32) *int32 { return &i }(corev1.ConfigMapVolumeSourceDefaultMode)
497+
}
498+
if sec := podSpec.Volumes[i].Secret; sec != nil && sec.DefaultMode == nil {
499+
sec.DefaultMode = func(i int32) *int32 { return &i }(corev1.SecretVolumeSourceDefaultMode)
500+
}
501+
}
502+
407503
return corev1.PodTemplateSpec{
408504
ObjectMeta: metav1.ObjectMeta{
409505
Labels: labels,
@@ -435,6 +531,11 @@ func buildValkeyNodeDeployment(node *valkeyiov1alpha1.ValkeyNode) (*appsv1.Deplo
435531
MatchLabels: labels,
436532
},
437533
Template: tmpl,
534+
// API-server defaults, set explicitly so the desired spec equals
535+
// the stored one and ensureDeployment's wholesale spec assignment
536+
// stops producing an Update on every reconcile (#315).
537+
RevisionHistoryLimit: func(i int32) *int32 { return &i }(10),
538+
ProgressDeadlineSeconds: func(i int32) *int32 { return &i }(600),
438539
},
439540
}, nil
440541
}
@@ -461,6 +562,21 @@ func buildValkeyNodeStatefulSet(node *valkeyiov1alpha1.ValkeyNode) (*appsv1.Stat
461562
MatchLabels: labels,
462563
},
463564
Template: tmpl,
565+
// API-server defaults, set explicitly so the desired spec equals
566+
// the stored one and ensureStatefulSet's wholesale spec assignment
567+
// stops producing an Update on every reconcile (#315).
568+
PodManagementPolicy: appsv1.OrderedReadyPodManagement,
569+
UpdateStrategy: appsv1.StatefulSetUpdateStrategy{
570+
Type: appsv1.RollingUpdateStatefulSetStrategyType,
571+
RollingUpdate: &appsv1.RollingUpdateStatefulSetStrategy{
572+
Partition: func(i int32) *int32 { return &i }(0),
573+
},
574+
},
575+
RevisionHistoryLimit: func(i int32) *int32 { return &i }(10),
576+
PersistentVolumeClaimRetentionPolicy: &appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{
577+
WhenDeleted: appsv1.RetainPersistentVolumeClaimRetentionPolicyType,
578+
WhenScaled: appsv1.RetainPersistentVolumeClaimRetentionPolicyType,
579+
},
464580
},
465581
}, nil
466582
}

internal/controller/valkeynode_resources_test.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,13 +1017,42 @@ func TestBuildValkeyNodePodTemplateSpec_PodSecurityContext_Passthrough(t *testin
10171017
}
10181018

10191019
// TestBuildValkeyNodePodTemplateSpec_PodSecurityContext_NilIsNoop confirms
1020-
// backward compatibility: omitting the field results in no pod-level
1021-
// SecurityContext (existing CRs unchanged).
1020+
// backward compatibility: omitting the field applies no security settings.
1021+
// The built template carries the empty SecurityContext the API server would
1022+
// default a stored pod template to — semantically identical to nil, and
1023+
// required so an unchanged reconcile stays a no-op (#315).
10221024
func TestBuildValkeyNodePodTemplateSpec_PodSecurityContext_NilIsNoop(t *testing.T) {
10231025
node := newTestValkeyNode("mynode", "test-ns")
10241026

10251027
pts, err := buildValkeyNodePodTemplateSpec(node, valkeyNodeLabels(node))
10261028
require.NoError(t, err)
10271029

1028-
assert.Nil(t, pts.Spec.SecurityContext, "omitting PodSecurityContext must leave pod-level SecurityContext nil")
1030+
assert.Equal(t, &corev1.PodSecurityContext{}, pts.Spec.SecurityContext,
1031+
"omitting PodSecurityContext must produce the empty SecurityContext the API server defaults to")
1032+
}
1033+
1034+
// TestDefaultImagePullPolicy pins the mirror of the API server's defaulting
1035+
// rule branch by branch — a silent divergence on any of these makes that
1036+
// container churn on every reconcile (#315). Rule: the effective tag is the
1037+
// explicit tag when present (even alongside a digest), "latest" when the
1038+
// reference has neither tag nor digest, empty for digest-only; policy is
1039+
// Always exactly when the effective tag is "latest".
1040+
func TestDefaultImagePullPolicy(t *testing.T) {
1041+
cases := []struct {
1042+
image string
1043+
want corev1.PullPolicy
1044+
}{
1045+
{"valkey/valkey:8.0.1", corev1.PullIfNotPresent}, // plain tag
1046+
{"valkey/valkey:latest", corev1.PullAlways}, // latest tag
1047+
{"valkey/valkey", corev1.PullAlways}, // no tag, no digest
1048+
{"valkey/valkey@sha256:deadbeef", corev1.PullIfNotPresent}, // digest only
1049+
{"valkey/valkey:8.0.1@sha256:deadbeef", corev1.PullIfNotPresent}, // tag + digest
1050+
{"valkey/valkey:latest@sha256:deadbeef", corev1.PullAlways}, // latest tag wins over digest
1051+
{"registry:5000/valkey", corev1.PullAlways}, // registry port is not a tag
1052+
{"registry:5000/valkey:8.0.1", corev1.PullIfNotPresent}, // registry port + tag
1053+
{"registry:5000/valkey@sha256:deadbeef", corev1.PullIfNotPresent}, // registry port + digest
1054+
}
1055+
for _, tc := range cases {
1056+
assert.Equal(t, tc.want, defaultImagePullPolicy(tc.image), "image %q", tc.image)
1057+
}
10291058
}

0 commit comments

Comments
 (0)