From d5ffe89038e20362ae230f7faa96aaa0b3c71f47 Mon Sep 17 00:00:00 2001 From: Jordan Liggitt Date: Fri, 31 Oct 2025 15:05:43 -0400 Subject: [PATCH] Add unit test detecting spurious statefulset rollout --- go.mod | 2 +- .../stateful_set_compatibility_test.go | 149 ++++++++++++++++++ .../compatibility_revision_1.33.0.json | 25 +++ .../compatibility_revision_1.34.0.json | 25 +++ .../testdata/compatibility_set_1.33.0.json | 104 ++++++++++++ .../testdata/compatibility_set_1.34.0.json | 102 ++++++++++++ 6 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 pkg/controller/statefulset/stateful_set_compatibility_test.go create mode 100644 pkg/controller/statefulset/testdata/compatibility_revision_1.33.0.json create mode 100644 pkg/controller/statefulset/testdata/compatibility_revision_1.34.0.json create mode 100644 pkg/controller/statefulset/testdata/compatibility_set_1.33.0.json create mode 100644 pkg/controller/statefulset/testdata/compatibility_set_1.34.0.json diff --git a/go.mod b/go.mod index e99218b5223..1acab4b1bc7 100644 --- a/go.mod +++ b/go.mod @@ -118,6 +118,7 @@ require ( k8s.io/sample-apiserver v0.0.0 k8s.io/system-validators v1.10.2 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 sigs.k8s.io/knftables v0.0.17 sigs.k8s.io/randfill v1.0.0 sigs.k8s.io/structured-merge-diff/v6 v6.3.0 @@ -217,7 +218,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/kustomize/api v0.20.1 // indirect sigs.k8s.io/kustomize/kustomize/v5 v5.7.1 // indirect sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect diff --git a/pkg/controller/statefulset/stateful_set_compatibility_test.go b/pkg/controller/statefulset/stateful_set_compatibility_test.go new file mode 100644 index 00000000000..ce3d3dcb1bd --- /dev/null +++ b/pkg/controller/statefulset/stateful_set_compatibility_test.go @@ -0,0 +1,149 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package statefulset + +import ( + "os" + "reflect" + "testing" + + "github.com/google/go-cmp/cmp" + "sigs.k8s.io/json" + + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/kubernetes/pkg/api/legacyscheme" +) + +func TestStatefulSetCompatibility(t *testing.T) { + set133 := &appsv1.StatefulSet{} + set134 := &appsv1.StatefulSet{} + rev133 := &appsv1.ControllerRevision{} + rev134 := &appsv1.ControllerRevision{} + load(t, "compatibility_set_1.33.0.json", set133) + load(t, "compatibility_set_1.34.0.json", set134) + load(t, "compatibility_revision_1.33.0.json", rev133) + load(t, "compatibility_revision_1.34.0.json", rev134) + + testcases := []struct { + name string + set *appsv1.StatefulSet + revisions []*appsv1.ControllerRevision + }{ + { + name: "1.33 set, 1.33 rev", + set: set133.DeepCopy(), + revisions: []*appsv1.ControllerRevision{rev133.DeepCopy()}, + }, + { + name: "1.34 set, 1.34 rev", + set: set134.DeepCopy(), + revisions: []*appsv1.ControllerRevision{rev134.DeepCopy()}, + }, + { + name: "1.34 set, 1.33+1.34 rev", + set: set134.DeepCopy(), + revisions: []*appsv1.ControllerRevision{rev133.DeepCopy(), rev134.DeepCopy()}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + latestRev := tc.revisions[len(tc.revisions)-1] + client := fake.NewClientset(tc.set) + _, _, ssc := setupController(client) + currentRev, updateRev, _, err := ssc.(*defaultStatefulSetControl).getStatefulSetRevisions(tc.set, tc.revisions) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(currentRev, latestRev) { + t.Fatalf("expected no change from latestRev, got %s", cmp.Diff(latestRev, currentRev)) + } + if !reflect.DeepEqual(updateRev, latestRev) { + t.Fatalf("expected no change from latestRev, got %s", cmp.Diff(latestRev, updateRev)) + } + }) + } +} + +func BenchmarkStatefulSetCompatibility(b *testing.B) { + set133 := &appsv1.StatefulSet{} + set134 := &appsv1.StatefulSet{} + rev133 := &appsv1.ControllerRevision{} + rev134 := &appsv1.ControllerRevision{} + load(b, "compatibility_set_1.33.0.json", set133) + load(b, "compatibility_set_1.34.0.json", set134) + load(b, "compatibility_revision_1.33.0.json", rev133) + load(b, "compatibility_revision_1.34.0.json", rev134) + + testcases := []struct { + name string + set *appsv1.StatefulSet + revisions []*appsv1.ControllerRevision + }{ + { + name: "1.33 set, 1.33 rev", + set: set133.DeepCopy(), + revisions: []*appsv1.ControllerRevision{rev133.DeepCopy()}, + }, + { + name: "1.34 set, 1.34 rev", + set: set134.DeepCopy(), + revisions: []*appsv1.ControllerRevision{rev134.DeepCopy()}, + }, + { + name: "1.34 set, 1.33+1.34 rev", + set: set134.DeepCopy(), + revisions: []*appsv1.ControllerRevision{rev133.DeepCopy(), rev134.DeepCopy()}, + }, + } + + for _, tc := range testcases { + b.Run(tc.name, func(b *testing.B) { + latestRev := tc.revisions[len(tc.revisions)-1] + client := fake.NewClientset(tc.set) + _, _, ssc := setupController(client) + for i := 0; i < b.N; i++ { + currentRev, updateRev, _, err := ssc.(*defaultStatefulSetControl).getStatefulSetRevisions(tc.set, tc.revisions) + if err != nil { + b.Fatal(err) + } + if !reflect.DeepEqual(currentRev, latestRev) { + b.Fatalf("expected no change from latestRev, got %s", cmp.Diff(latestRev, currentRev)) + } + if !reflect.DeepEqual(updateRev, latestRev) { + b.Fatalf("expected no change from latestRev, got %s", cmp.Diff(latestRev, updateRev)) + } + } + }) + } +} + +func load(t testing.TB, filename string, object runtime.Object) { + data, err := os.ReadFile("testdata/" + filename) + if err != nil { + t.Fatal(err) + } + if strictErrs, err := json.UnmarshalStrict(data, object); err != nil { + t.Fatal(err) + } else if len(strictErrs) > 0 { + t.Fatal(strictErrs) + } + // apply defaulting just as if it was read from etcd + legacyscheme.Scheme.Default(object) +} diff --git a/pkg/controller/statefulset/testdata/compatibility_revision_1.33.0.json b/pkg/controller/statefulset/testdata/compatibility_revision_1.33.0.json new file mode 100644 index 00000000000..0b874adf4c3 --- /dev/null +++ b/pkg/controller/statefulset/testdata/compatibility_revision_1.33.0.json @@ -0,0 +1,25 @@ +{ + "apiVersion":"apps/v1", + "kind":"ControllerRevision", + "metadata":{ + "creationTimestamp":"2025-10-31T18:19:02Z", + "labels":{ + "app":"foo", + "controller.kubernetes.io/hash":"c77f6d978" + }, + "name":"test-c77f6d978", + "namespace":"default", + "ownerReferences":[{ + "apiVersion":"apps/v1", + "blockOwnerDeletion":true, + "controller":true, + "kind":"StatefulSet", + "name":"test", + "uid":"ec335e25-1045-4216-8634-50cfbe05f3d6" + }], + "resourceVersion":"2209", + "uid":"af6e1945-ed14-4d1a-b420-813aa683a0fd" + }, + "data":{"spec":{"template":{"$patch":"replace","metadata":{"annotations":{"test":"value"},"creationTimestamp":null,"labels":{"app":"foo"}},"spec":{"containers":[{"image":"test","imagePullPolicy":"Always","name":"test","resources":{},"terminationMessagePath":"/dev/termination-log","terminationMessagePolicy":"File"}],"dnsPolicy":"ClusterFirst","restartPolicy":"Always","schedulerName":"default-scheduler","securityContext":{},"terminationGracePeriodSeconds":30}}}}, + "revision":1 +} diff --git a/pkg/controller/statefulset/testdata/compatibility_revision_1.34.0.json b/pkg/controller/statefulset/testdata/compatibility_revision_1.34.0.json new file mode 100644 index 00000000000..085eea4da02 --- /dev/null +++ b/pkg/controller/statefulset/testdata/compatibility_revision_1.34.0.json @@ -0,0 +1,25 @@ +{ + "apiVersion":"apps/v1", + "kind":"ControllerRevision", + "metadata":{ + "creationTimestamp":"2025-11-03T19:46:23Z", + "labels":{ + "app":"foo", + "controller.kubernetes.io/hash":"776999688b" + }, + "name":"test-776999688b", + "namespace":"default", + "ownerReferences":[{ + "apiVersion":"apps/v1", + "blockOwnerDeletion":true, + "controller":true, + "kind":"StatefulSet", + "name":"test", + "uid":"ec335e25-1045-4216-8634-50cfbe05f3d6" + }], + "resourceVersion":"16318", + "uid":"47df387b-5f17-40b6-9964-4c43cf6ad5d1" + }, + "data":{"spec":{"template":{"$patch":"replace","metadata":{"annotations":{"test":"value"},"labels":{"app":"foo"}},"spec":{"containers":[{"image":"test","imagePullPolicy":"Always","name":"test","resources":{},"terminationMessagePath":"/dev/termination-log","terminationMessagePolicy":"File"}],"dnsPolicy":"ClusterFirst","restartPolicy":"Always","schedulerName":"default-scheduler","securityContext":{},"terminationGracePeriodSeconds":30}}}}, + "revision":2 +} diff --git a/pkg/controller/statefulset/testdata/compatibility_set_1.33.0.json b/pkg/controller/statefulset/testdata/compatibility_set_1.33.0.json new file mode 100644 index 00000000000..e263ffad123 --- /dev/null +++ b/pkg/controller/statefulset/testdata/compatibility_set_1.33.0.json @@ -0,0 +1,104 @@ +{ + "apiVersion": "apps/v1", + "kind": "StatefulSet", + "metadata": { + "creationTimestamp": "2025-10-31T18:19:02Z", + "generation": 1, + "labels": { + "sslabel": "value" + }, + "name": "test", + "namespace": "default", + "resourceVersion": "2219", + "uid": "ec335e25-1045-4216-8634-50cfbe05f3d6" + }, + "spec": { + "persistentVolumeClaimRetentionPolicy": { + "whenDeleted": "Retain", + "whenScaled": "Retain" + }, + "podManagementPolicy": "OrderedReady", + "replicas": 1, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "foo" + } + }, + "serviceName": "", + "template": { + "metadata": { + "annotations": { + "test": "value" + }, + "creationTimestamp": null, + "labels": { + "app": "foo" + } + }, + "spec": { + "containers": [ + { + "image": "test", + "imagePullPolicy": "Always", + "name": "test", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + }, + "updateStrategy": { + "rollingUpdate": { + "partition": 0 + }, + "type": "RollingUpdate" + }, + "volumeClaimTemplates": [ + { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "annotations": { + "key": "value" + }, + "creationTimestamp": null, + "labels": { + "key": "value" + }, + "name": "test" + }, + "spec": { + "accessModes": [ + "ReadWriteOnce" + ], + "resources": { + "requests": { + "storage": "1Gi" + } + }, + "volumeMode": "Filesystem" + }, + "status": { + "phase": "Pending" + } + } + ] + }, + "status": { + "availableReplicas": 1, + "collisionCount": 0, + "currentReplicas": 1, + "currentRevision": "test-c77f6d978", + "observedGeneration": 1, + "replicas": 1, + "updateRevision": "test-c77f6d978", + "updatedReplicas": 1 + } +} \ No newline at end of file diff --git a/pkg/controller/statefulset/testdata/compatibility_set_1.34.0.json b/pkg/controller/statefulset/testdata/compatibility_set_1.34.0.json new file mode 100644 index 00000000000..45a3fc56ec4 --- /dev/null +++ b/pkg/controller/statefulset/testdata/compatibility_set_1.34.0.json @@ -0,0 +1,102 @@ +{ + "apiVersion": "apps/v1", + "kind": "StatefulSet", + "metadata": { + "creationTimestamp": "2025-10-31T18:19:02Z", + "generation": 1, + "labels": { + "sslabel": "value" + }, + "name": "test", + "namespace": "default", + "resourceVersion": "16319", + "uid": "ec335e25-1045-4216-8634-50cfbe05f3d6" + }, + "spec": { + "persistentVolumeClaimRetentionPolicy": { + "whenDeleted": "Retain", + "whenScaled": "Retain" + }, + "podManagementPolicy": "OrderedReady", + "replicas": 1, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "foo" + } + }, + "serviceName": "", + "template": { + "metadata": { + "annotations": { + "test": "value" + }, + "labels": { + "app": "foo" + } + }, + "spec": { + "containers": [ + { + "image": "test", + "imagePullPolicy": "Always", + "name": "test", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + }, + "updateStrategy": { + "rollingUpdate": { + "partition": 0 + }, + "type": "RollingUpdate" + }, + "volumeClaimTemplates": [ + { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "annotations": { + "key": "value" + }, + "labels": { + "key": "value" + }, + "name": "test" + }, + "spec": { + "accessModes": [ + "ReadWriteOnce" + ], + "resources": { + "requests": { + "storage": "1Gi" + } + }, + "volumeMode": "Filesystem" + }, + "status": { + "phase": "Pending" + } + } + ] + }, + "status": { + "availableReplicas": 1, + "collisionCount": 0, + "currentReplicas": 1, + "currentRevision": "test-776999688b", + "observedGeneration": 1, + "replicas": 1, + "updateRevision": "test-776999688b", + "updatedReplicas": 1 + } +} \ No newline at end of file