Repository: kubernetes/kubernetes (language: go)

Issue:
compatibility version: should allow using v1beta1 instead of older alpha version
### What would you like to be added?

https://github.com/kubernetes/kubernetes/blame/d001d5684e69b08e120dc328917925318c5f324c/staging/src/k8s.io/apiserver/pkg/server/storage/resource_encoding_config.go#L170-L179
skips over a beta version of an API group if that version gets introduced in the current release. An older alpha gets used instead.

Seen with https://github.com/kubernetes/kubernetes/pull/127511 (v1alpha3 -> v1beta1, with `scheme.SetVersionPriority(v1beta1.SchemeGroupVersion, v1alpha3.SchemeGroupVersion))` being ignored).

That code shouldn't override the explicit choice by the developer to prefer the beta encoding.

### Why is this needed?

In this particular case, it was a conscious choice to not support downgrades in favor of using v1beta1 as long-term storage version because:
- it enables removing v1alpha3
- future changes might make round-tripping to v1alpha3 harder

cc @jpbetz @logicalhan @siyuanfoundation 


Discussion / hints:

Fix patch:
diff --git a/staging/src/k8s.io/apiserver/pkg/server/storage/resource_encoding_config.go b/staging/src/k8s.io/apiserver/pkg/server/storage/resource_encoding_config.go
index 271948c147f31..ce1bec6763ac0 100644
--- a/staging/src/k8s.io/apiserver/pkg/server/storage/resource_encoding_config.go
+++ b/staging/src/k8s.io/apiserver/pkg/server/storage/resource_encoding_config.go
@@ -117,6 +117,10 @@ type introducedInterface interface {
 	APILifecycleIntroduced() (major, minor int)
 }
 
+type replacementInterface interface {
+	APILifecycleReplacement() schema.GroupVersionKind
+}
+
 func emulatedStorageVersion(binaryVersionOfResource schema.GroupVersion, example runtime.Object, effectiveVersion version.EffectiveVersion, scheme *runtime.Scheme) (schema.GroupVersion, error) {
 	if example == nil || effectiveVersion == nil {
 		return binaryVersionOfResource, nil
@@ -170,6 +174,14 @@ func emulatedStorageVersion(binaryVersionOfResource schema.GroupVersion, example
 		// If it was introduced after current compatibility version, don't use it
 		// skip the introduced check for test when currentVersion is 0.0 to test all apis
 		if introduced, hasIntroduced := exampleOfGVK.(introducedInterface); hasIntroduced && (compatibilityVersion.Major() > 0 || compatibilityVersion.Minor() > 0) {
+
+			// Skip versions that have a replacement.
+			// This can be used to override this storage version selection by
+			// marking a storage version has having a replacement and preventing a
+			// that storage version from being selected.
+			if _, hasReplacement := exampleOfGVK.(replacementInterface); hasReplacement {
+				continue
+			}
 			// API resource lifecycles should be relative to k8s api version
 			majorIntroduced, minorIntroduced := introduced.APILifecycleIntroduced()
 			introducedVer := apimachineryversion.MajorMinor(uint(majorIntroduced), uint(minorIntroduced))


Test patch:
diff --git a/staging/src/k8s.io/apiserver/pkg/server/storage/resource_encoding_config_test.go b/staging/src/k8s.io/apiserver/pkg/server/storage/resource_encoding_config_test.go
new file mode 100644
index 0000000000000..a96e7e321d876
--- /dev/null
+++ b/staging/src/k8s.io/apiserver/pkg/server/storage/resource_encoding_config_test.go
@@ -0,0 +1,135 @@
+/*
+Copyright 2024 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 storage
+
+import (
+	"testing"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	runtimetesting "k8s.io/apimachinery/pkg/runtime/testing"
+	"k8s.io/apimachinery/pkg/test"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	utilversion "k8s.io/apimachinery/pkg/util/version"
+	"k8s.io/component-base/version"
+)
+
+func TestEmulatedStorageVersion(t *testing.T) {
+	cases := []struct {
+		name             string
+		scheme           *runtime.Scheme
+		binaryVersion    schema.GroupVersion
+		effectiveVersion version.EffectiveVersion
+		want             schema.GroupVersion
+	}{
+		{
+			name:             "pick compatible",
+			scheme:           AlphaBetaScheme(utilversion.MustParse("1.31"), utilversion.MustParse("1.32")),
+			binaryVersion:    v1beta1,
+			effectiveVersion: version.NewEffectiveVersion("1.32"),
+			want:             v1alpha1,
+		},
+		{
+			name:             "alpha has been replaced, pick binary version",
+			scheme:           AlphaReplacedBetaScheme(utilversion.MustParse("1.31"), utilversion.MustParse("1.32")),
+			binaryVersion:    v1beta1,
+			effectiveVersion: version.NewEffectiveVersion("1.32"),
+			want:             v1beta1,
+		},
+	}
+
+	for _, tc := range cases {
+		test.TestScheme()
+		t.Run(tc.name, func(t *testing.T) {
+			found, err := emulatedStorageVersion(tc.binaryVersion, &CronJob{}, tc.effectiveVersion, tc.scheme)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if found != tc.want {
+				t.Errorf("got %v; want %v", found, tc.want)
+			}
+		})
+	}
+}
+
+var internalGV = schema.GroupVersion{Group: "workload.example.com", Version: runtime.APIVersionInternal}
+var v1alpha1 = schema.GroupVersion{Group: "workload.example.com", Version: "v1alpha1"}
+var v1beta1 = schema.GroupVersion{Group: "workload.example.com", Version: "v1beta1"}
+
+type CronJobWithReplacement struct {
+	introduced *utilversion.Version
+	A          string `json:"A,omitempty"`
+	B          int    `json:"B,omitempty"`
+}
+
+func (*CronJobWithReplacement) GetObjectKind() schema.ObjectKind { panic("not implemented") }
+func (*CronJobWithReplacement) DeepCopyObject() runtime.Object {
+	panic("not implemented")
+}
+
+func (in *CronJobWithReplacement) APILifecycleIntroduced() (major, minor int) {
+	if in.introduced == nil {
+		return 0, 0
+	}
+	return int(in.introduced.Major()), int(in.introduced.Minor())
+}
+
+func (in *CronJobWithReplacement) APILifecycleReplacement() schema.GroupVersionKind {
+	return schema.GroupVersionKind{Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "ClusterRole"}
+}
+
+type CronJob struct {
+	introduced *utilversion.Version
+	A          string `json:"A,omitempty"`
+	B          int    `json:"B,omitempty"`
+}
+
+func (*CronJob) GetObjectKind() schema.ObjectKind { panic("not implemented") }
+func (*CronJob) DeepCopyObject() runtime.Object {
+	panic("not implemented")
+}
+
+func (in *CronJob) APILifecycleIntroduced() (major, minor int) {
+	if in.introduced == nil {
+		return 0, 0
+	}
+	return int(in.introduced.Major()), int(in.introduced.Minor())
+}
+
+func AlphaBetaScheme(alphaVersion, betaVersion *utilversion.Version) *runtime.Scheme {
+	s := runtime.NewScheme()
+	s.AddKnownTypes(internalGV, &CronJob{})
+	s.AddKnownTypes(v1alpha1, &CronJob{introduced: alphaVersion})
+	s.AddKnownTypes(v1beta1, &CronJob{introduced: betaVersion})
+	s.AddKnownTypeWithName(internalGV.WithKind("CronJob"), &CronJob{})
+	s.AddKnownTypeWithName(v1alpha1.WithKind("CronJob"), &CronJob{introduced: alphaVersion})
+	s.AddKnownTypeWithName(v1beta1.WithKind("CronJob"), &CronJob{introduced: betaVersion})
+	utilruntime.Must(runtimetesting.RegisterConversions(s))
+	return s
+}
+
+func AlphaReplacedBetaScheme(alphaVersion, betaVersion *utilversion.Version) *runtime.Scheme {
+	s := runtime.NewScheme()
+	s.AddKnownTypes(internalGV, &CronJob{})
+	s.AddKnownTypes(v1alpha1, &CronJobWithReplacement{introduced: alphaVersion})
+	s.AddKnownTypes(v1beta1, &CronJob{introduced: betaVersion})
+	s.AddKnownTypeWithName(internalGV.WithKind("CronJob"), &CronJob{})
+	s.AddKnownTypeWithName(v1alpha1.WithKind("CronJob"), &CronJobWithReplacement{introduced: alphaVersion})
+	s.AddKnownTypeWithName(v1beta1.WithKind("CronJob"), &CronJob{introduced: betaVersion})
+	utilruntime.Must(runtimetesting.RegisterConversions(s))
+	return s
+}