diff --git a/test/integration/apiserver/apidefinitions/generation_test.go b/test/integration/apiserver/apidefinitions/generation_test.go new file mode 100644 index 00000000000..7d08e837ac3 --- /dev/null +++ b/test/integration/apiserver/apidefinitions/generation_test.go @@ -0,0 +1,128 @@ +/* +Copyright 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 apidefinitions + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" +) + +// TestGenerationManagement tests that metadata.generation is managed when a resource is updated. +// +// The test ensures: +// 1. Generation initializes to 1. +// 2. Generation monotonically increases for each spec update. +// 3. Generation does not increase for status updates. +func TestGenerationManagement(t *testing.T) { + + // DO NOT ADD NEW ENTRIES HERE. + // This tracks resources that have status but do not manage generation. + generationExempt := sets.New[schema.GroupResource]( + schema.GroupResource{Group: "apiregistration.k8s.io", Resource: "apiservices"}, + schema.GroupResource{Group: "autoscaling", Resource: "horizontalpodautoscalers"}, + schema.GroupResource{Group: "certificates.k8s.io", Resource: "certificatesigningrequests"}, + schema.GroupResource{Group: "networking.k8s.io", Resource: "servicecidrs"}, + schema.GroupResource{Group: "resource.k8s.io", Resource: "resourceclaims"}, + schema.GroupResource{Group: "storage.k8s.io", Resource: "volumeattachments"}, + schema.GroupResource{Group: "", Resource: "persistentvolumeclaims"}, + schema.GroupResource{Group: "", Resource: "namespaces"}, + schema.GroupResource{Group: "", Resource: "nodes"}, + schema.GroupResource{Group: "", Resource: "persistentvolumes"}, + schema.GroupResource{Group: "", Resource: "resourcequotas"}, + schema.GroupResource{Group: "", Resource: "services"}, + ) + + TestAllDefinitions(t, "generation-namespace", func(t *testing.T, api Definition) { + if !api.HasStatus() { + t.Skip() + } + if !api.HasVerb("patch") || !api.HasVerb("get") || !api.HasVerb("update") { + t.Skip("Resource does not support patch, get, and update") + } + + differentSpec := api.StorageData.MutatedStub + if differentSpec == "" { + t.Skipf("No conflicting spec data defined for %v to test generation bump", api.Mapping.Resource) + } + + status := api.StorageData.GetStatusStub() + obj := TestObj(t, api.StorageData.Stub, status, api.Mapping.GroupVersionKind) + name := obj.GetName() + rsc := api.ResourceClient() + + _, err := rsc.Create(context.TODO(), obj, metav1.CreateOptions{FieldManager: "spec-manager"}) + if err != nil { + t.Fatalf("Failed to create via SSA: %v", err) + } + + baseline, err := rsc.Get(context.TODO(), name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("Failed to get baseline: %v", err) + } + + // Verify that generation initializes to 1. + if generationExempt.Has(api.Mapping.Resource.GroupResource()) { + if baseline.GetGeneration() != 0 { + t.Errorf("Expected generation exempt resource always have generation 0, but got %v", baseline.GetGeneration()) + } + } else if baseline.GetGeneration() != int64(1) { + t.Errorf("Expected generation to be %v on create, got %v", 1, baseline.GetGeneration()) + } + + // Verify that updating status does NOT bump generation. + update := api.StorageData.GetMutatedStatusStub() + statusObj := TestObj(t, api.StorageData.Stub, update, api.Mapping.GroupVersionKind) + statusObj.SetName(name) + + _, err = rsc.ApplyStatus(context.TODO(), name, statusObj, metav1.ApplyOptions{FieldManager: "status-manager", Force: true}) + if err != nil { + t.Fatalf("Failed to apply status via SSA: %v", err) + } + + afterStatus, err := rsc.Get(context.TODO(), name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("Failed to get after status update: %v", err) + } + + if afterStatus.GetGeneration() != baseline.GetGeneration() { + t.Errorf("Expected generation to remain %v after status update, but got %v", baseline.GetGeneration(), afterStatus.GetGeneration()) + } + + // Verify that updating spec does bump generation. + result, err := rsc.Patch(context.TODO(), name, types.MergePatchType, []byte(differentSpec), metav1.PatchOptions{}) + if err != nil { + t.Logf("Patch to main endpoint failed: %v", err) + } else if result.GetGeneration() <= afterStatus.GetGeneration() { + if generationExempt.Has(api.Mapping.Resource.GroupResource()) { + if result.GetGeneration() != 0 { + t.Errorf("Expected generation exempt resource always have generation 0, but got %v", result.GetGeneration()) + } + } else { + t.Errorf("Expected generation to monotonically increase after spec update (was %v, got %v)", afterStatus.GetGeneration(), result.GetGeneration()) + } + } + + if err := rsc.Delete(context.TODO(), name, *metav1.NewDeleteOptions(0)); err != nil { + t.Logf("Failed to delete %v: %v", name, err) + } + }) +} diff --git a/test/integration/apiserver/apidefinitions/helper.go b/test/integration/apiserver/apidefinitions/helper.go new file mode 100644 index 00000000000..3b08f877214 --- /dev/null +++ b/test/integration/apiserver/apidefinitions/helper.go @@ -0,0 +1,190 @@ +/* +Copyright 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 apidefinitions + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + v1 "k8s.io/api/core/v1" + apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + apiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" + "k8s.io/kubernetes/test/integration/etcd" + "k8s.io/kubernetes/test/integration/framework" +) + +type DefinitionTestFunc func(t *testing.T, setup Definition) + +type Definition struct { + Config *rest.Config + Client kubernetes.Interface + DynamicClient dynamic.Interface + Mapping *meta.RESTMapping + Resource metav1.APIResource + Namespace string + StorageData etcd.StorageData + Subresources []string +} + +func (d *Definition) HasVerb(verb string) bool { + return slices.Contains(d.Resource.Verbs, verb) +} + +func (d *Definition) HasSubresource(subresource string) bool { + return slices.Contains(d.Subresources, subresource) +} + +func (d *Definition) HasStatus() bool { + return d.HasSubresource("status") +} + +// ResourceClient returns a dynamic resource client scoped to the appropriate namespace. +func (d *Definition) ResourceClient() dynamic.ResourceInterface { + namespace := d.Namespace + if d.Mapping.Scope == meta.RESTScopeRoot { + namespace = "" + } + return d.DynamicClient.Resource(d.Mapping.Resource).Namespace(namespace) +} + +func TestAllDefinitions(t *testing.T, testNamespace string, testFunc DefinitionTestFunc) { + server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), []string{"--disable-admission-plugins", "ServiceAccount,TaintNodesByCondition"}, framework.SharedEtcd()) + if err != nil { + t.Fatal(err) + } + defer server.TearDownFn() + + client, err := kubernetes.NewForConfig(server.ClientConfig) + if err != nil { + t.Fatal(err) + } + dynamicClient, err := dynamic.NewForConfig(server.ClientConfig) + if err != nil { + t.Fatal(err) + } + + etcd.CreateTestCRDs(t, apiextensionsclientset.NewForConfigOrDie(server.ClientConfig), false, etcd.GetCustomResourceDefinitionData()...) + + if _, err := client.CoreV1().Namespaces().Create(context.TODO(), &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNamespace}}, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + + storageData := etcd.GetEtcdStorageDataForNamespace(testNamespace) + + _, resourceLists, err := client.Discovery().ServerGroupsAndResources() + if err != nil { + t.Fatalf("Failed to get ServerGroupsAndResources: %v", err) + } + + for _, resourceList := range resourceLists { + for _, resource := range resourceList.APIResources { + // Iterate over root resources + if strings.Contains(resource.Name, "/") { + continue + } + + mapping, err := CreateMapping(resourceList.GroupVersion, resource) + if err != nil { + t.Fatal(err) + } + + t.Run(mapping.Resource.String(), func(t *testing.T) { + + storageData, ok := storageData[mapping.Resource] + if !ok { + t.Skipf("no test data for %s in etcd.GetEtcdStorageData, skipping", mapping.Resource) + } + + var subresources []string + for _, r := range resourceList.APIResources { + if suffix, ok := strings.CutPrefix(r.Name, resource.Name+"/"); ok { + subresources = append(subresources, suffix) + } + } + + setup := Definition{ + Config: server.ClientConfig, + Client: client, + DynamicClient: dynamicClient, + Mapping: mapping, + Resource: resource, + Namespace: testNamespace, + StorageData: storageData, + Subresources: subresources, + } + + testFunc(t, setup) + }) + } + } +} + +func CreateMapping(groupVersion string, resource metav1.APIResource) (*meta.RESTMapping, error) { + gv, err := schema.ParseGroupVersion(groupVersion) + if err != nil { + return nil, err + } + if len(resource.Group) > 0 || len(resource.Version) > 0 { + gv = schema.GroupVersion{ + Group: resource.Group, + Version: resource.Version, + } + } + gvk := gv.WithKind(resource.Kind) + gvr := gv.WithResource(resource.Name) + + scope := meta.RESTScopeRoot + if resource.Namespaced { + scope = meta.RESTScopeNamespace + } + return &meta.RESTMapping{ + Resource: gvr, + GroupVersionKind: gvk, + Scope: scope, + }, nil +} + +// TestObj is a generic test helper that creates an Unstructured object from a creation stub +// and explicitly sets the status from a separate JSON payload. +func TestObj(t *testing.T, stub, status string, gvk schema.GroupVersionKind) *unstructured.Unstructured { + t.Helper() + obj := &unstructured.Unstructured{} + if err := json.Unmarshal([]byte(stub), &obj.Object); err != nil { + t.Fatal(err) + } + var statusObj map[string]interface{} + if err := json.Unmarshal([]byte(status), &statusObj); err != nil { + t.Fatal(err) + } + if s, ok := statusObj["status"]; ok { + obj.Object["status"] = s + } + obj.SetAPIVersion(gvk.GroupVersion().String()) + obj.SetKind(gvk.Kind) + return obj +} diff --git a/test/integration/apiserver/apidefinitions/main_test.go b/test/integration/apiserver/apidefinitions/main_test.go new file mode 100644 index 00000000000..2ccd52f47fd --- /dev/null +++ b/test/integration/apiserver/apidefinitions/main_test.go @@ -0,0 +1,27 @@ +/* +Copyright 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 apidefinitions + +import ( + "testing" + + "k8s.io/kubernetes/test/integration/framework" +) + +func TestMain(m *testing.M) { + framework.EtcdMain(m.Run) +} diff --git a/test/integration/apiserver/apply/apply_crd_beta_test.go b/test/integration/apiserver/apply/apply_crd_beta_test.go index 72e23f82f94..92620c4134b 100644 --- a/test/integration/apiserver/apply/apply_crd_beta_test.go +++ b/test/integration/apiserver/apply/apply_crd_beta_test.go @@ -22,10 +22,13 @@ import ( "reflect" "testing" + v1 "k8s.io/api/core/v1" + apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + "k8s.io/client-go/kubernetes" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" - "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" "k8s.io/apiextensions-apiserver/test/integration/fixtures" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" @@ -44,7 +47,7 @@ func TestApplyCRDNoSchema(t *testing.T) { defer server.TearDownFn() config := server.ClientConfig - apiExtensionClient, err := clientset.NewForConfig(config) + apiExtensionClient, err := apiextensionsclientset.NewForConfig(config) if err != nil { t.Fatal(err) } @@ -65,13 +68,13 @@ func TestApplyCRDNoSchema(t *testing.T) { name := "mytest" rest := apiExtensionClient.Discovery().RESTClient() - yamlBody := []byte(fmt.Sprintf(` + yamlBody := fmt.Appendf(nil, ` apiVersion: %s kind: %s metadata: name: %s spec: - replicas: 1`, apiVersion, kind, name)) + replicas: 1`, apiVersion, kind, name) result, err := rest.Patch(types.ApplyPatchType). AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Versions[0].Name, noxuDefinition.Spec.Names.Plural). Name(name). @@ -306,3 +309,111 @@ func nearlyRemovedBetaMultipleVersionNoxuCRDWithStatus(scope apiextensionsv1beta }, } } + +func TestUpdateStatusWithOldVersion(t *testing.T) { + server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), []string{"--disable-admission-plugins", "ServiceAccount,TaintNodesByCondition"}, framework.SharedEtcd()) + if err != nil { + t.Fatal(err) + } + defer server.TearDownFn() + + client, err := kubernetes.NewForConfig(server.ClientConfig) + if err != nil { + t.Fatal(err) + } + + apiExtensionClient, err := apiextensionsclientset.NewForConfig(server.ClientConfig) + if err != nil { + t.Fatal(err) + } + dynamicClient, err := dynamic.NewForConfig(server.ClientConfig) + if err != nil { + t.Fatal(err) + } + + noxuBetaDefinition := nearlyRemovedBetaMultipleVersionNoxuCRDWithStatus(apiextensionsv1beta1.NamespaceScoped) + + noxuDefinition, err := fixtures.CreateCRDUsingRemovedAPI(server.EtcdClient, server.EtcdStoragePrefix, noxuBetaDefinition, apiExtensionClient, dynamicClient) + if err != nil { + t.Fatal(err) + } + kind := noxuDefinition.Spec.Names.Kind + apiVersion := noxuDefinition.Spec.Group + "/" + noxuDefinition.Spec.Versions[1].Name + name := "mytest" + + rest := apiExtensionClient.Discovery().RESTClient() + // create namespace ns test + if _, err := client.CoreV1().Namespaces().Create(context.TODO(), &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "reset-fields-namespace"}}, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + + // Create the resource using the v1 CRD API. + yamlBody := fmt.Appendf(nil, ` +apiVersion: %s +kind: %s +metadata: + name: %s + namespace: %s +spec: + a: value-for-a + b: value-for-b`, apiVersion, kind, name, "reset-fields-namespace") + result, err := rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Versions[1].Name, "/namespaces", "reset-fields-namespace", noxuDefinition.Spec.Names.Plural). + Name(name). + Param("fieldManager", "apply_test"). + Body(yamlBody). + DoRaw(context.TODO()) + if err != nil { + t.Fatalf("failed to create custom resource with apply: %v:\n%v", err, string(result)) + } + t.Logf("result: %s", string(result)) + oldManagedFields, err := getManagedFields(result) + if err != nil { + t.Fatalf("failed to get managed fields: %v", err) + } + // When updating the status subresource via the v1beta1 CRD API, + // we assign a value to the spec field for testing purposes. + // However, in this case, the operation should NOT trigger any field manager updates + // related to server-side apply tracking. + updateStatusBytes := []byte(`{ + "spec": { "a": "value-for-a-update" }, + "status": { + "a": "status-for-a" + } +}`) + result, err = rest.Patch(types.MergePatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Versions[0].Name, "/namespaces", "reset-fields-namespace", noxuDefinition.Spec.Names.Plural). + Name(name). + SubResource("status"). + Param("fieldManager", "subresource_test"). + Body(updateStatusBytes). + DoRaw(context.TODO()) + if err != nil { + t.Fatalf("Error updating subresource: %v ", err) + } + t.Logf("result: %s", string(result)) + newManagedFields, err := getManagedFields(result) + if err != nil { + t.Fatalf("failed to get managed fields: %v", err) + } + // newManagedFields should include oldManagedFields + var applyManagerFound, subresourceManagerFound bool + for i, field := range newManagedFields { + if field.Manager == "apply_test" { + if !reflect.DeepEqual(newManagedFields[i], oldManagedFields[0]) { + t.Fatalf("Expected managed fields to not have changed when trying manually setting them via subresoures.\n\nExpected: %#v\n\nGot: %#v", oldManagedFields[0], newManagedFields[i]) + } + applyManagerFound = true + } + if field.Manager == "subresource_test" { + subresourceManagerFound = true + } + } + if !applyManagerFound { + t.Errorf("expected field manager 'apply_test' to be present in newManagedFields") + } + if !subresourceManagerFound { + t.Errorf("expected field manager 'subresource_test' to be present in newManagedFields") + } + +} diff --git a/test/integration/apiserver/apply/reset_fields_test.go b/test/integration/apiserver/apply/reset_fields_test.go index 89a598aeb16..7a76d955785 100644 --- a/test/integration/apiserver/apply/reset_fields_test.go +++ b/test/integration/apiserver/apply/reset_fields_test.go @@ -24,63 +24,12 @@ import ( "strings" "testing" - v1 "k8s.io/api/core/v1" - apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" - apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" - "k8s.io/apiextensions-apiserver/test/integration/fixtures" - "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" - apiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" - - "k8s.io/kubernetes/test/integration/etcd" - "k8s.io/kubernetes/test/integration/framework" - "k8s.io/kubernetes/test/utils/image" + "k8s.io/kubernetes/test/integration/apiserver/apidefinitions" ) -// namespace used for all tests, do not change this -const resetFieldsNamespace = "reset-fields-namespace" - -// resetFieldsStatusData contains statuses for all the resources in the -// statusData list with slightly different data to create a field manager -// conflict. -var resetFieldsStatusData = map[schema.GroupVersionResource]string{ - gvr("", "v1", "persistentvolumes"): `{"status": {"message": "hello2"}}`, - gvr("", "v1", "resourcequotas"): `{"status": {"used": {"cpu": "25M"}}}`, - gvr("", "v1", "services"): `{"status": {"loadBalancer": {"ingress": [{"ip": "127.0.0.2", "ipMode": "VIP"}]}}}`, - gvr("extensions", "v1beta1", "ingresses"): `{"status": {"loadBalancer": {"ingress": [{"ip": "127.0.0.2"}]}}}`, - gvr("networking.k8s.io", "v1beta1", "ingresses"): `{"status": {"loadBalancer": {"ingress": [{"ip": "127.0.0.2"}]}}}`, - gvr("networking.k8s.io", "v1", "ingresses"): `{"status": {"loadBalancer": {"ingress": [{"ip": "127.0.0.2"}]}}}`, - gvr("autoscaling", "v1", "horizontalpodautoscalers"): `{"status": {"currentReplicas": 25}}`, - gvr("autoscaling", "v2", "horizontalpodautoscalers"): `{"status": {"currentReplicas": 25}}`, - gvr("batch", "v1", "cronjobs"): `{"status": {"lastScheduleTime": "2020-01-01T00:00:00Z"}}`, - gvr("batch", "v1beta1", "cronjobs"): `{"status": {"lastScheduleTime": "2020-01-01T00:00:00Z"}}`, - gvr("storage.k8s.io", "v1", "volumeattachments"): `{"status": {"attached": false}}`, - gvr("policy", "v1", "poddisruptionbudgets"): `{"status": {"currentHealthy": 25}}`, - gvr("policy", "v1beta1", "poddisruptionbudgets"): `{"status": {"currentHealthy": 25}}`, - gvr("resource.k8s.io", "v1beta1", "resourceclaims"): `{"status": {"allocation": {"nodeSelector": {"nodeSelectorTerms": [{"matchExpressions": [{"key": "some-label", "operator": "In", "values": ["some-other-value"]}] }]}}}}`, - gvr("resource.k8s.io", "v1beta2", "resourceclaims"): `{"status": {"allocation": {"nodeSelector": {"nodeSelectorTerms": [{"matchExpressions": [{"key": "some-label", "operator": "In", "values": ["some-other-value"]}] }]}}}}`, - gvr("resource.k8s.io", "v1", "resourceclaims"): `{"status": {"allocation": {"nodeSelector": {"nodeSelectorTerms": [{"matchExpressions": [{"key": "some-label", "operator": "In", "values": ["some-other-value"]}] }]}}}}`, - gvr("internal.apiserver.k8s.io", "v1alpha1", "storageversions"): `{"status": {"commonEncodingVersion":"v1","storageVersions":[{"apiServerID":"1","decodableVersions":["v1","v2"],"encodingVersion":"v1"}],"conditions":[{"type":"AllEncodingVersionsEqual","status":"False","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"allEncodingVersionsEqual","message":"all encoding versions are set to v1"}]}}`, - // standard for []metav1.Condition - gvr("admissionregistration.k8s.io", "v1alpha1", "validatingadmissionpolicies"): `{"status": {"conditions":[{"type":"Accepted","status":"True","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"RuleApplied","message":"Rule was applied"}]}}`, - gvr("admissionregistration.k8s.io", "v1beta1", "validatingadmissionpolicies"): `{"status": {"conditions":[{"type":"Accepted","status":"True","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"RuleApplied","message":"Rule was applied"}]}}`, - gvr("admissionregistration.k8s.io", "v1", "validatingadmissionpolicies"): `{"status": {"conditions":[{"type":"Accepted","status":"True","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"RuleApplied","message":"Rule was applied"}]}}`, - gvr("networking.k8s.io", "v1alpha1", "servicecidrs"): `{"status": {"conditions":[{"type":"Accepted","status":"True","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"RuleApplied","message":"Rule was applied"}]}}`, - gvr("networking.k8s.io", "v1beta1", "servicecidrs"): `{"status": {"conditions":[{"type":"Accepted","status":"True","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"RuleApplied","message":"Rule was applied"}]}}`, - gvr("networking.k8s.io", "v1", "servicecidrs"): `{"status": {"conditions":[{"type":"Accepted","status":"True","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"RuleApplied","message":"Rule was applied"}]}}`, -} - -// resetFieldsStatusDefault conflicts with statusDefault -const resetFieldsStatusDefault = `{"status": {"conditions": [{"type": "MyStatus", "status":"False"}]}}` - -var resetFieldsSkippedResources = map[string]struct{}{} - // noConflicts is the set of resources for which // a conflict cannot occur. var noConflicts = map[string]struct{}{ @@ -98,656 +47,71 @@ var noConflicts = map[string]struct{}{ "namespaces": {}, } -var image2 = image.GetE2EImage(image.Etcd) - -// resetFieldsSpecData contains conflicting data with the objects in -// etcd.GetEtcdStorageDataForNamespace() -// It contains the minimal changes needed to conflict with all the fields -// added to resetFields by the strategy of each resource. -// In most cases, just one field on the spec is changed, but -// some also wipe metadata or other fields. -var resetFieldsSpecData = map[schema.GroupVersionResource]string{ - gvr("", "v1", "resourcequotas"): `{"spec": {"hard": {"cpu": "25M"}}}`, - gvr("", "v1", "namespaces"): `{"spec": {"finalizers": ["kubernetes2"]}}`, - gvr("", "v1", "nodes"): `{"spec": {"unschedulable": false}}`, - gvr("", "v1", "persistentvolumes"): `{"spec": {"capacity": {"storage": "23M"}}}`, - gvr("", "v1", "persistentvolumeclaims"): `{"spec": {"resources": {"limits": {"storage": "21M"}}}}`, - gvr("", "v1", "pods"): `{"metadata": {"deletionTimestamp": "2020-01-01T00:00:00Z", "ownerReferences":[]}, "spec": {"containers": [{"image": "` + image2 + `", "name": "container7"}]}}`, - gvr("", "v1", "replicationcontrollers"): `{"spec": {"selector": {"new": "stuff2"}}}`, - gvr("", "v1", "resourcequotas"): `{"spec": {"hard": {"cpu": "25M"}}}`, - gvr("", "v1", "services"): `{"spec": {"type": "ClusterIP"}}`, - gvr("apps", "v1", "daemonsets"): `{"spec": {"template": {"spec": {"containers": [{"image": "` + image2 + `", "name": "container6"}]}}}}`, - gvr("apps", "v1", "deployments"): `{"metadata": {"labels": {"a":"c"}}, "spec": {"template": {"spec": {"containers": [{"image": "` + image2 + `", "name": "container6"}]}}}}`, - gvr("apps", "v1", "replicasets"): `{"spec": {"template": {"spec": {"containers": [{"image": "` + image2 + `", "name": "container4"}]}}}}`, - gvr("apps", "v1", "statefulsets"): `{"spec": {"selector": {"matchLabels": {"a2": "b2"}}}}`, - gvr("autoscaling", "v1", "horizontalpodautoscalers"): `{"spec": {"maxReplicas": 23}}`, - gvr("autoscaling", "v2", "horizontalpodautoscalers"): `{"spec": {"maxReplicas": 23}}`, - gvr("autoscaling", "v2beta1", "horizontalpodautoscalers"): `{"spec": {"maxReplicas": 23}}`, - gvr("autoscaling", "v2beta2", "horizontalpodautoscalers"): `{"spec": {"maxReplicas": 23}}`, - gvr("batch", "v1", "jobs"): `{"spec": {"template": {"spec": {"containers": [{"image": "` + image2 + `", "name": "container1"}]}}}}`, - gvr("batch", "v1", "cronjobs"): `{"spec": {"jobTemplate": {"spec": {"template": {"spec": {"containers": [{"image": "` + image2 + `", "name": "container0"}]}}}}}}`, - gvr("batch", "v1beta1", "cronjobs"): `{"spec": {"jobTemplate": {"spec": {"template": {"spec": {"containers": [{"image": "` + image2 + `", "name": "container0"}]}}}}}}`, - gvr("certificates.k8s.io", "v1", "certificatesigningrequests"): `{}`, - gvr("certificates.k8s.io", "v1beta1", "certificatesigningrequests"): `{}`, - gvr("flowcontrol.apiserver.k8s.io", "v1alpha1", "flowschemas"): `{"metadata": {"labels":{"a":"c"}}, "spec": {"priorityLevelConfiguration": {"name": "name2"}}}`, - gvr("flowcontrol.apiserver.k8s.io", "v1beta1", "flowschemas"): `{"metadata": {"labels":{"a":"c"}}, "spec": {"priorityLevelConfiguration": {"name": "name2"}}}`, - gvr("flowcontrol.apiserver.k8s.io", "v1beta2", "flowschemas"): `{"metadata": {"labels":{"a":"c"}}, "spec": {"priorityLevelConfiguration": {"name": "name2"}}}`, - gvr("flowcontrol.apiserver.k8s.io", "v1beta3", "flowschemas"): `{"metadata": {"labels":{"a":"c"}}, "spec": {"priorityLevelConfiguration": {"name": "name2"}}}`, - gvr("flowcontrol.apiserver.k8s.io", "v1", "flowschemas"): `{"metadata": {"labels":{"a":"c"}}, "spec": {"priorityLevelConfiguration": {"name": "name2"}}}`, - gvr("flowcontrol.apiserver.k8s.io", "v1alpha1", "prioritylevelconfigurations"): `{"metadata": {"labels":{"a":"c"}}, "spec": {"limited": {"assuredConcurrencyShares": 23}}}`, - gvr("flowcontrol.apiserver.k8s.io", "v1beta1", "prioritylevelconfigurations"): `{"metadata": {"labels":{"a":"c"}}, "spec": {"limited": {"assuredConcurrencyShares": 23}}}`, - gvr("flowcontrol.apiserver.k8s.io", "v1beta2", "prioritylevelconfigurations"): `{"metadata": {"labels":{"a":"c"}}, "spec": {"limited": {"assuredConcurrencyShares": 23}}}`, - gvr("flowcontrol.apiserver.k8s.io", "v1beta3", "prioritylevelconfigurations"): `{"metadata": {"labels":{"a":"c"}}, "spec": {"limited": {"nominalConcurrencyShares": 23}}}`, - gvr("flowcontrol.apiserver.k8s.io", "v1", "prioritylevelconfigurations"): `{"metadata": {"labels":{"a":"c"}}, "spec": {"limited": {"nominalConcurrencyShares": 23}}}`, - gvr("extensions", "v1beta1", "ingresses"): `{"spec": {"backend": {"serviceName": "service2"}}}`, - gvr("networking.k8s.io", "v1beta1", "ingresses"): `{"spec": {"backend": {"serviceName": "service2"}}}`, - gvr("networking.k8s.io", "v1", "ingresses"): `{"spec": {"defaultBackend": {"service": {"name": "service2"}}}}`, - gvr("networking.k8s.io", "v1alpha1", "servicecidrs"): `{}`, - gvr("networking.k8s.io", "v1beta1", "servicecidrs"): `{}`, - gvr("networking.k8s.io", "v1", "servicecidrs"): `{}`, - gvr("policy", "v1", "poddisruptionbudgets"): `{"spec": {"selector": {"matchLabels": {"anokkey2": "anokvalue"}}}}`, - gvr("policy", "v1beta1", "poddisruptionbudgets"): `{"spec": {"selector": {"matchLabels": {"anokkey2": "anokvalue"}}}}`, - gvr("storage.k8s.io", "v1alpha1", "volumeattachments"): `{"metadata": {"name": "va3"}, "spec": {"nodeName": "localhost2"}}`, - gvr("storage.k8s.io", "v1", "volumeattachments"): `{"metadata": {"name": "va3"}, "spec": {"nodeName": "localhost2"}}`, - gvr("apiextensions.k8s.io", "v1", "customresourcedefinitions"): `{"metadata": {"labels":{"a":"c"}}, "spec": {"group": "webconsole22.operator.openshift.io"}}`, - gvr("apiextensions.k8s.io", "v1beta1", "customresourcedefinitions"): `{"metadata": {"labels":{"a":"c"}}, "spec": {"group": "webconsole22.operator.openshift.io"}}`, - gvr("awesome.bears.com", "v1", "pandas"): `{"spec": {"replicas": 102}}`, - gvr("awesome.bears.com", "v3", "pandas"): `{"spec": {"replicas": 302}}`, - gvr("apiregistration.k8s.io", "v1beta1", "apiservices"): `{"metadata": {"labels": {"a":"c"}}, "spec": {"group": "foo2.com"}}`, - gvr("apiregistration.k8s.io", "v1", "apiservices"): `{"metadata": {"labels": {"a":"c"}}, "spec": {"group": "foo2.com"}}`, - gvr("resource.k8s.io", "v1alpha3", "devicetaintrules"): `{"metadata": {"labels":{"a":"c"}}}`, - gvr("resource.k8s.io", "v1beta1", "deviceclasses"): `{"metadata": {"labels":{"a":"c"}}}`, - gvr("resource.k8s.io", "v1beta1", "resourceclaims"): `{"spec": {"devices": {"requests": [{"name": "req-0", "deviceClassName": "other-class"}]}}}`, // spec is immutable, but that doesn't matter for the test. - gvr("resource.k8s.io", "v1beta1", "resourceclaimtemplates"): `{"spec": {"spec": {"resourceClassName": "class2name"}}}`, - gvr("resource.k8s.io", "v1beta2", "deviceclasses"): `{"metadata": {"labels":{"a":"c"}}}`, - gvr("resource.k8s.io", "v1beta2", "devicetaintrules"): `{"metadata": {"labels":{"a":"c"}}}`, - gvr("resource.k8s.io", "v1beta2", "resourceclaims"): `{"spec": {"devices": {"requests": [{"name": "req-0", "exactly": {"deviceClassName": "other-class"}}]}}}`, // spec is immutable, but that doesn't matter for the test. - gvr("resource.k8s.io", "v1beta2", "resourceclaimtemplates"): `{"spec": {"spec": {"resourceClassName": "class2name"}}}`, - gvr("resource.k8s.io", "v1", "deviceclasses"): `{"metadata": {"labels":{"a":"c"}}}`, - gvr("resource.k8s.io", "v1", "resourceclaims"): `{"spec": {"devices": {"requests": [{"name": "req-0", "exactly": {"deviceClassName": "other-class"}}]}}}`, // spec is immutable, but that doesn't matter for the test. - gvr("resource.k8s.io", "v1", "resourceclaimtemplates"): `{"spec": {"spec": {"resourceClassName": "class2name"}}}`, - gvr("internal.apiserver.k8s.io", "v1alpha1", "storageversions"): `{}`, - gvr("admissionregistration.k8s.io", "v1alpha1", "validatingadmissionpolicies"): `{"metadata": {"labels": {"a":"c"}}, "spec": {"paramKind": {"apiVersion": "apps/v1", "kind": "Deployment"}}}`, - gvr("admissionregistration.k8s.io", "v1beta1", "validatingadmissionpolicies"): `{"metadata": {"labels": {"a":"c"}}, "spec": {"paramKind": {"apiVersion": "apps/v1", "kind": "Deployment"}}}`, - gvr("admissionregistration.k8s.io", "v1", "validatingadmissionpolicies"): `{"metadata": {"labels": {"a":"c"}}, "spec": {"paramKind": {"apiVersion": "apps/v1", "kind": "Deployment"}}}`, -} - // TestResetFields makes sure that fieldManager does not own fields reset by the storage strategy. // It takes 2 objects obj1 and obj2 that differ by one field in the spec and one field in the status. // It applies obj1 to the spec endpoint and obj2 to the status endpoint, the lack of conflicts // confirms that the fieldmanager1 is wiped of the status and fieldmanager2 is wiped of the spec. // We then attempt to apply obj2 to the spec endpoint which fails with an expected conflict. func TestApplyResetFields(t *testing.T) { - server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), []string{"--disable-admission-plugins", "ServiceAccount,TaintNodesByCondition"}, framework.SharedEtcd()) - if err != nil { - t.Fatal(err) - } - defer server.TearDownFn() - - client, err := kubernetes.NewForConfig(server.ClientConfig) - if err != nil { - t.Fatal(err) - } - dynamicClient, err := dynamic.NewForConfig(server.ClientConfig) - if err != nil { - t.Fatal(err) - } - - // create CRDs so we can make sure that custom resources do not get lost - etcd.CreateTestCRDs(t, apiextensionsclientset.NewForConfigOrDie(server.ClientConfig), false, etcd.GetCustomResourceDefinitionData()...) - - if _, err := client.CoreV1().Namespaces().Create(context.TODO(), &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: resetFieldsNamespace}}, metav1.CreateOptions{}); err != nil { - t.Fatal(err) - } - - createData := etcd.GetEtcdStorageDataForNamespace(resetFieldsNamespace) - // gather resources to test - _, resourceLists, err := client.Discovery().ServerGroupsAndResources() - if err != nil { - t.Fatalf("Failed to get ServerGroupsAndResources with error: %+v", err) - } - - for _, resourceList := range resourceLists { - for _, resource := range resourceList.APIResources { - if !strings.HasSuffix(resource.Name, "/status") { - continue - } - mapping, err := createMapping(resourceList.GroupVersion, resource) - if err != nil { - t.Fatal(err) - } - t.Run(mapping.Resource.String(), func(t *testing.T) { - if _, ok := resetFieldsSkippedResources[mapping.Resource.Resource]; ok { - t.Skip() - } - - namespace := resetFieldsNamespace - if mapping.Scope == meta.RESTScopeRoot { - namespace = "" - } - - // assemble first object - status, ok := statusData[mapping.Resource] - if !ok { - status = statusDefault - } - - resource, ok := createData[mapping.Resource] - if !ok { - t.Fatalf("no test data for %s. Please add a test for your new type to etcd.GetEtcdStorageData() or getResetFieldsEtcdStorageData()", mapping.Resource) - } - - obj1 := unstructured.Unstructured{} - if err := json.Unmarshal([]byte(resource.Stub), &obj1.Object); err != nil { - t.Fatal(err) - } - if err := json.Unmarshal([]byte(status), &obj1.Object); err != nil { - t.Fatal(err) - } - - name := obj1.GetName() - obj1.SetAPIVersion(mapping.GroupVersionKind.GroupVersion().String()) - obj1.SetKind(mapping.GroupVersionKind.Kind) - obj1.SetName(name) - - // apply the spec of the first object - _, err = dynamicClient. - Resource(mapping.Resource). - Namespace(namespace). - Apply(context.TODO(), name, &obj1, metav1.ApplyOptions{FieldManager: "fieldmanager1"}) - if err != nil { - t.Fatalf("Failed to apply obj1: %v", err) - } - - // create second object - obj2 := &unstructured.Unstructured{} - obj1.DeepCopyInto(obj2) - if err := json.Unmarshal([]byte(resetFieldsSpecData[mapping.Resource]), &obj2.Object); err != nil { - t.Fatal(err) - } - status2, ok := resetFieldsStatusData[mapping.Resource] - if !ok { - status2 = resetFieldsStatusDefault - } - if err := json.Unmarshal([]byte(status2), &obj2.Object); err != nil { - t.Fatal(err) - } - - if reflect.DeepEqual(obj1, obj2) { - t.Fatalf("obj1 and obj2 should not be equal %v", obj2) - } - - // apply the status of the second object - // this won't conflict if resetfields are set correctly - // and will conflict if they are not - _, err = dynamicClient. - Resource(mapping.Resource). - Namespace(namespace). - ApplyStatus(context.TODO(), name, obj2, metav1.ApplyOptions{FieldManager: "fieldmanager2"}) - if err != nil { - t.Fatalf("Failed to apply obj2: %v", err) - } - - // skip checking for conflicts on resources - // that will never have conflicts - if _, ok = noConflicts[mapping.Resource.Resource]; !ok { - var objRet *unstructured.Unstructured - - // reapply second object to the spec endpoint - // that should fail with a conflict - objRet, err = dynamicClient. - Resource(mapping.Resource). - Namespace(namespace). - Apply(context.TODO(), name, obj2, metav1.ApplyOptions{FieldManager: "fieldmanager2"}) - err = expectConflict(objRet, err, dynamicClient, mapping.Resource, namespace, name) - if err != nil { - t.Fatalf("Did not get expected conflict in spec of %s %s/%s: %v", mapping.Resource, namespace, name, err) - } - - // reapply first object to the status endpoint - // that should fail with a conflict - objRet, err = dynamicClient. - Resource(mapping.Resource). - Namespace(namespace). - ApplyStatus(context.TODO(), name, &obj1, metav1.ApplyOptions{FieldManager: "fieldmanager1"}) - err = expectConflict(objRet, err, dynamicClient, mapping.Resource, namespace, name) - if err != nil { - t.Fatalf("Did not get expected conflict in status of %s %s/%s: %v", mapping.Resource, namespace, name, err) - } - } - - // cleanup - rsc := dynamicClient.Resource(mapping.Resource).Namespace(namespace) - if err := rsc.Delete(context.TODO(), name, *metav1.NewDeleteOptions(0)); err != nil { - t.Fatalf("deleting final object failed: %v", err) - } - }) + apidefinitions.TestAllDefinitions(t, "reset-fields-test", func(t *testing.T, api apidefinitions.Definition) { + if !api.HasStatus() { + t.Skip() } - } -} - -// TestFieldsWipingConsistency verifies that field wiping is applied consistently across the API -// and that field wiping is consistent GetResetFields. -func TestFieldsWipingConsistency(t *testing.T) { - // DO NOT ADD NEW ENTRIES HERE. - // This tracks pre-existing APIs where status is allowed to update metadata. - // All new APIs should use ResetObjectMetaForStatus. - statusDoesNotWipeMetadataAllowed := sets.New( - // https://github.com/kubernetes/kubernetes/issues/137681 - "apiextensions.k8s.io/customresourcedefinitions", - - // APIs that do not use ResetObjectMetaForStatus: - "apps/daemonsets", - "apps/replicasets", - "apps/statefulsets", - "batch/cronjobs", - "batch/jobs", - "autoscaling/horizontalpodautoscalers", - "networking.k8s.io/ingresses", - "nodes", - "persistentvolumes", - "persistentvolumeclaims", - "pods", - "replicationcontrollers", - "resourcequotas", - "services", - "policy/poddisruptionbudgets", - "namespaces", - "certificates.k8s.io/certificatesigningrequests", - ) - - server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), []string{"--disable-admission-plugins", "ServiceAccount,TaintNodesByCondition"}, framework.SharedEtcd()) - if err != nil { - t.Fatal(err) - } - defer server.TearDownFn() - - client, err := kubernetes.NewForConfig(server.ClientConfig) - if err != nil { - t.Fatal(err) - } - dynamicClient, err := dynamic.NewForConfig(server.ClientConfig) - if err != nil { - t.Fatal(err) - } - - etcd.CreateTestCRDs(t, apiextensionsclientset.NewForConfigOrDie(server.ClientConfig), false, etcd.GetCustomResourceDefinitionData()...) - - ns := "field-wiping-consistency-ns" - if _, err := client.CoreV1().Namespaces().Create(context.TODO(), &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}, metav1.CreateOptions{}); err != nil { - t.Fatal(err) - } - - storageData := etcd.GetEtcdStorageDataForNamespace(ns) - - _, resourceLists, err := client.Discovery().ServerGroupsAndResources() - if err != nil { - t.Fatalf("Failed to get ServerGroupsAndResources: %v", err) - } - - for _, resourceList := range resourceLists { - for _, resource := range resourceList.APIResources { - - // Only test resources that have a /status subresource, since the - // test verifies consistency between / and /status strategies. - if !strings.HasSuffix(resource.Name, "/status") { - continue - } - mapping, err := createMapping(resourceList.GroupVersion, resource) - if err != nil { - t.Fatal(err) - } - - t.Run(mapping.Resource.String(), func(t *testing.T) { - if _, ok := resetFieldsSkippedResources[mapping.Resource.Resource]; ok { - t.Skip() - } - - resourceStub, ok := storageData[mapping.Resource] - if !ok { - t.Fatalf("no test data for %s for type in etcd.GetEtcdStorageData", mapping.Resource) - } - - status, ok := statusData[mapping.Resource] - if !ok { - status = statusDefault - } - - obj := testObj(t, resourceStub.Stub, status, mapping.GroupVersionKind) - name := obj.GetName() - - namespace := ns - if mapping.Scope == meta.RESTScopeRoot { - namespace = "" - } - rsc := dynamicClient.Resource(mapping.Resource).Namespace(namespace) - - // Step 1: Create the resource - _, err = rsc.Apply(context.TODO(), name, obj, metav1.ApplyOptions{FieldManager: "spec-manager"}) - if err != nil { - t.Fatalf("Failed to create via SSA: %v", err) - } - - // Step 2: Apply to /status endpoint with spec, status and metadata field changes. - statusObj := testObj(t, resourceStub.Stub, status, mapping.GroupVersionKind) - statusObj.SetName(name) - statusLabels := statusObj.GetLabels() - if statusLabels == nil { - statusLabels = map[string]string{} - } - statusLabels["test-status-ssa"] = "true" - statusObj.SetLabels(statusLabels) - _, err = rsc.ApplyStatus(context.TODO(), name, statusObj, metav1.ApplyOptions{FieldManager: "status-manager", Force: true}) - if err != nil { - t.Fatalf("Failed to apply status via SSA: %v", err) - } - - // Step 3: Read after writing to observe field wiping behavior and managedField state - baseline, err := rsc.Get(context.TODO(), name, metav1.GetOptions{}) - if err != nil { - t.Fatalf("Failed to get baseline: %v", err) - } - baselineStatus := baseline.Object["status"] - baselineSpec := baseline.Object["spec"] - - // Infer GetResetFields behavior from managedFields. - ssaMainResetsStatus := true - ssaStatusResetsSpec := true - ssaStatusResetsMetadata := true - for _, mf := range baseline.GetManagedFields() { - if mf.Manager == "spec-manager" && mf.Subresource == "" { - ssaMainResetsStatus = !managedFieldsOwnTopLevelField(t, mf.FieldsV1, "status") - } - if mf.Manager == "status-manager" && mf.Subresource == "status" { - ssaStatusResetsSpec = !managedFieldsOwnTopLevelField(t, mf.FieldsV1, "spec") - ssaStatusResetsMetadata = !managedFieldsOwnLabel(t, mf.FieldsV1, "test-status-ssa") - } - } - - // Check / PrepareForUpdate status wiping - var mainWipesStatus bool - if baselineStatus != nil { - differentStatus, ok := resetFieldsStatusData[mapping.Resource] - if !ok { - differentStatus = resetFieldsStatusDefault - } - result, err := rsc.Patch(context.TODO(), name, types.MergePatchType, []byte(differentStatus), metav1.PatchOptions{}) - if err != nil { - t.Fatalf("Failed to patch main endpoint with different status: %v", err) - } - mainWipesStatus = !checkPatch(t, differentStatus, "status", result.Object) - } else { - mainWipesStatus = true - } - - // Check /status PrepareForUpdate spec wiping - var statusWipesSpec bool - differentSpec, hasSpecData := resetFieldsSpecData[mapping.Resource] - if baselineSpec != nil && hasSpecData { - result, err := rsc.Patch(context.TODO(), name, types.MergePatchType, []byte(differentSpec), metav1.PatchOptions{}, "status") - if err != nil { - statusWipesSpec = true - t.Logf("Patch to status endpoint with different spec returned an error (OK if validation rejects it): %v", err) - } else { - statusWipesSpec = !checkPatch(t, differentSpec, "spec", result.Object) - } - } else { - statusWipesSpec = true - } - - // Check /status PrepareForUpdate metadata wiping - var statusWipesMetadata bool - labelPatch := []byte(`{"metadata": {"labels": {"test-wipe-label": "test-value"}}}`) - result, err := rsc.Patch(context.TODO(), name, types.MergePatchType, labelPatch, metav1.PatchOptions{}, "status") - if err != nil { - t.Logf("Label patch to status endpoint failed: %v", err) - statusWipesMetadata = false - } else { - statusWipesMetadata = result.GetLabels()["test-wipe-label"] != "test-value" - } - - // Check consistency between field wiping and field resetting - checkConsistency := func(endpoint, field string, wipes, resets bool) { - if wipes == resets { - return - } - direction := "PrepareForUpdate wipes the field but GetResetFields does not declare it" - if resets && !wipes { - direction = "GetResetFields declares the field but PrepareForUpdate does not wipe it" - } - t.Errorf("Mismatch between PrepareForUpdate and GetResetFields (%s endpoint, %s field): %s (wipes=%v, resets=%v)", - endpoint, field, direction, wipes, resets) - } - checkConsistency("/", "status", mainWipesStatus, ssaMainResetsStatus) - checkConsistency("/status", "spec", statusWipesSpec, ssaStatusResetsSpec) - checkConsistency("/status", "metadata", statusWipesMetadata, ssaStatusResetsMetadata) - - requireWiped := func(wipes bool, endpoint, field string) { - if wipes { - return - } - t.Errorf("%s did NOT wipe %s via PrepareForUpdate", endpoint, field) - } - requireWiped(mainWipesStatus, "/", "status") - requireWiped(statusWipesSpec, "/status", "spec") - - if !statusWipesMetadata && !statusDoesNotWipeMetadataAllowed.Has(groupResource(mapping.Resource)) { - t.Errorf("/status does not wipe metadata. Add ResetObjectMetaForStatus to status strategy, or add %q to statusDoesNotWipeMetadataAllowed", groupResource(mapping.Resource)) - } - - if err := rsc.Delete(context.TODO(), name, *metav1.NewDeleteOptions(0)); err != nil { - t.Fatalf("deleting final object failed: %v", err) - } - }) + if !api.HasVerb("patch") || !api.HasVerb("get") || !api.HasVerb("update") { + t.Skip("Resource does not support patch, get, and update") } - } -} -func testObj(t *testing.T, stub, status string, gvk schema.GroupVersionKind) *unstructured.Unstructured { - t.Helper() - obj := &unstructured.Unstructured{} - if err := json.Unmarshal([]byte(stub), &obj.Object); err != nil { - t.Fatal(err) - } - if err := json.Unmarshal([]byte(status), &obj.Object); err != nil { - t.Fatal(err) - } - obj.SetAPIVersion(gvk.GroupVersion().String()) - obj.SetKind(gvk.Kind) - return obj -} + // assemble first object + status := api.StorageData.GetStatusStub() + obj1 := apidefinitions.TestObj(t, api.StorageData.Stub, status, api.Mapping.GroupVersionKind) + name := obj1.GetName() -func groupResource(gvr schema.GroupVersionResource) string { - if gvr.Group == "" { - return gvr.Resource - } - return gvr.Group + "/" + gvr.Resource -} + rsc := api.ResourceClient() -// checkPatch checks if field values under fieldScope (e.g. spec, status, metdata) in objData match the values -// in the applyManifest. -func checkPatch(t *testing.T, applyManifest string, fieldScope string, objData map[string]interface{}) bool { - t.Helper() - var applyObj map[string]interface{} - if err := json.Unmarshal([]byte(applyManifest), &applyObj); err != nil { - t.Fatalf("Failed to parse apply JSON: %v", err) - } - applyValue, ok := applyObj[fieldScope] - if !ok { - return false - } - objValue, ok := objData[fieldScope] - if !ok { - return false - } - return containsAll(applyValue, objValue) -} + // apply the spec of the first object + _, err := rsc.Apply(context.TODO(), name, obj1, metav1.ApplyOptions{FieldManager: "fieldmanager1"}) + if err != nil { + t.Fatalf("Failed to apply obj1: %v", err) + } -// containsAll checks if all keys in want are present in got and if the values of those keys are equal. -func containsAll(want, got any) bool { - wantMap, wantIsMap := want.(map[string]any) - gotMap, gotIsMap := got.(map[string]any) - if wantIsMap && gotIsMap { - for k, wv := range wantMap { - gv, exists := gotMap[k] - if !exists || !containsAll(wv, gv) { - return false + // create second object + status2 := api.StorageData.GetMutatedStatusStub() + differentSpec := api.StorageData.MutatedStub + if differentSpec == "" { + t.Skipf("No conflicting spec data defined for %v to test reset fields", api.Mapping.Resource) + } + + obj2 := apidefinitions.TestObj(t, api.StorageData.Stub, status2, api.Mapping.GroupVersionKind) + if err := json.Unmarshal([]byte(differentSpec), &obj2.Object); err != nil { + t.Fatal(err) + } + obj2.SetName(name) + + if reflect.DeepEqual(obj1, obj2) { + t.Fatalf("obj1 and obj2 should not be equal %v", obj2) + } + + // apply the status of the second object + _, err = rsc.ApplyStatus(context.TODO(), name, obj2, metav1.ApplyOptions{FieldManager: "fieldmanager2"}) + if err != nil { + t.Fatalf("Failed to apply obj2: %v", err) + } + + if _, ok := noConflicts[api.Mapping.Resource.Resource]; !ok { + // reapply second object to the spec endpoint + _, err = rsc.Apply(context.TODO(), name, obj2, metav1.ApplyOptions{FieldManager: "fieldmanager2"}) + if err := expectConflict(nil, err, rsc, name); err != nil { + t.Fatalf("Did not get expected conflict on spec apply: %v", err) } } - return true - } - return reflect.DeepEqual(want, got) -} -// managedFieldsOwnTopLevelField checks whether a FieldsV1 set contains a given top-level field. -func managedFieldsOwnTopLevelField(t *testing.T, fieldsV1 *metav1.FieldsV1, field string) bool { - t.Helper() - if fieldsV1 == nil { - return false - } - var fields map[string]interface{} - if err := json.Unmarshal(fieldsV1.GetRawBytes(), &fields); err != nil { - t.Logf("Failed to unmarshal FieldsV1: %v", err) - return false - } - _, ok := fields["f:"+field] - return ok -} - -// managedFieldsOwnLabel checks whether a FieldsV1 set contains a metadata label. -func managedFieldsOwnLabel(t *testing.T, fieldsV1 *metav1.FieldsV1, labelKey string) bool { - t.Helper() - if fieldsV1 == nil { - return false - } - var fields map[string]interface{} - if err := json.Unmarshal(fieldsV1.GetRawBytes(), &fields); err != nil { - t.Logf("Failed to unmarshal FieldsV1: %v", err) - return false - } - metadata, ok := fields["f:metadata"].(map[string]interface{}) - if !ok { - return false - } - labels, ok := metadata["f:labels"].(map[string]interface{}) - if !ok { - return false - } - _, ok = labels["f:"+labelKey] - return ok -} - -// TestUpdateStatusWithOldVersion tests that apply with resetFields works correctly when updating -// a custom resource's status subresource using an older API version while maintaining field ownership. -func TestUpdateStatusWithOldVersion(t *testing.T) { - server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), []string{"--disable-admission-plugins", "ServiceAccount,TaintNodesByCondition"}, framework.SharedEtcd()) - if err != nil { - t.Fatal(err) - } - defer server.TearDownFn() - - client, err := kubernetes.NewForConfig(server.ClientConfig) - if err != nil { - t.Fatal(err) - } - - apiExtensionClient, err := apiextensionsclientset.NewForConfig(server.ClientConfig) - if err != nil { - t.Fatal(err) - } - dynamicClient, err := dynamic.NewForConfig(server.ClientConfig) - if err != nil { - t.Fatal(err) - } - - noxuBetaDefinition := nearlyRemovedBetaMultipleVersionNoxuCRDWithStatus(apiextensionsv1beta1.NamespaceScoped) - - noxuDefinition, err := fixtures.CreateCRDUsingRemovedAPI(server.EtcdClient, server.EtcdStoragePrefix, noxuBetaDefinition, apiExtensionClient, dynamicClient) - if err != nil { - t.Fatal(err) - } - kind := noxuDefinition.Spec.Names.Kind - apiVersion := noxuDefinition.Spec.Group + "/" + noxuDefinition.Spec.Versions[1].Name - name := "mytest" - - rest := apiExtensionClient.Discovery().RESTClient() - // create namespace ns test - if _, err := client.CoreV1().Namespaces().Create(context.TODO(), &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: resetFieldsNamespace}}, metav1.CreateOptions{}); err != nil { - t.Fatal(err) - } - - // Create the resource using the v1 CRD API. - yamlBody := []byte(fmt.Sprintf(` -apiVersion: %s -kind: %s -metadata: - name: %s - namespace: %s -spec: - a: value-for-a - b: value-for-b`, apiVersion, kind, name, resetFieldsNamespace)) - result, err := rest.Patch(types.ApplyPatchType). - AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Versions[1].Name, "/namespaces", resetFieldsNamespace, noxuDefinition.Spec.Names.Plural). - Name(name). - Param("fieldManager", "apply_test"). - Body(yamlBody). - DoRaw(context.TODO()) - if err != nil { - t.Fatalf("failed to create custom resource with apply: %v:\n%v", err, string(result)) - } - t.Logf("result: %s", string(result)) - oldManagedFields, err := getManagedFields(result) - if err != nil { - t.Fatalf("failed to get managed fields: %v", err) - } - // When updating the status subresource via the v1beta1 CRD API, - // we assign a value to the spec field for testing purposes. - // However, in this case, the operation should NOT trigger any field manager updates - // related to server-side apply tracking. - updateStatusBytes := []byte(`{ - "spec": { "a": "value-for-a-update" }, - "status": { - "a": "status-for-a" - } -}`) - result, err = rest.Patch(types.MergePatchType). - AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Versions[0].Name, "/namespaces", resetFieldsNamespace, noxuDefinition.Spec.Names.Plural). - Name(name). - SubResource("status"). - Param("fieldManager", "subresource_test"). - Body(updateStatusBytes). - DoRaw(context.TODO()) - if err != nil { - t.Fatalf("Error updating subresource: %v ", err) - } - t.Logf("result: %s", string(result)) - newManagedFields, err := getManagedFields(result) - if err != nil { - t.Fatalf("failed to get managed fields: %v", err) - } - // newManagedFields should include oldManagedFields - var applyManagerFound, subresourceManagerFound bool - for i, field := range newManagedFields { - if field.Manager == "apply_test" { - if !reflect.DeepEqual(newManagedFields[i], oldManagedFields[0]) { - t.Fatalf("Expected managed fields to not have changed when trying manually setting them via subresoures.\n\nExpected: %#v\n\nGot: %#v", oldManagedFields[0], newManagedFields[i]) - } - applyManagerFound = true + if err := rsc.Delete(context.TODO(), name, *metav1.NewDeleteOptions(0)); err != nil { + t.Fatalf("deleting final object failed: %v", err) } - if field.Manager == "subresource_test" { - subresourceManagerFound = true - } - } - if !applyManagerFound { - t.Errorf("expected field manager 'apply_test' to be present in newManagedFields") - } - if !subresourceManagerFound { - t.Errorf("expected field manager 'subresource_test' to be present in newManagedFields") - } - + }) } -func expectConflict(objRet *unstructured.Unstructured, err error, dynamicClient dynamic.Interface, resource schema.GroupVersionResource, namespace, name string) error { +func expectConflict(objRet *unstructured.Unstructured, err error, rsc dynamic.ResourceInterface, name string) error { if err != nil && strings.Contains(err.Error(), "conflict") { return nil } @@ -756,10 +120,7 @@ func expectConflict(objRet *unstructured.Unstructured, err error, dynamicClient if objRet == nil { which = "subsequently fetched" var err2 error - objRet, err2 = dynamicClient. - Resource(resource). - Namespace(namespace). - Get(context.TODO(), name, metav1.GetOptions{}) + objRet, err2 = rsc.Get(context.TODO(), name, metav1.GetOptions{}) if err2 != nil { return fmt.Errorf("instead got error %w, and failed to Get object: %v", err, err2) } diff --git a/test/integration/apiserver/apply/status_test.go b/test/integration/apiserver/apply/status_test.go index f0b0f4b232a..fb3b811af3d 100644 --- a/test/integration/apiserver/apply/status_test.go +++ b/test/integration/apiserver/apply/status_test.go @@ -18,88 +18,27 @@ package apiserver import ( "context" - "strings" "testing" - v1 "k8s.io/api/core/v1" - apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + "k8s.io/kubernetes/test/integration/apiserver/apidefinitions" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/json" "k8s.io/apiserver/pkg/features" utilfeature "k8s.io/apiserver/pkg/util/feature" "k8s.io/client-go/dynamic" clientfeatures "k8s.io/client-go/features" clientfeaturestesting "k8s.io/client-go/features/testing" - "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" featuregatetesting "k8s.io/component-base/featuregate/testing" - apiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" - "k8s.io/kubernetes/test/integration/etcd" "k8s.io/kubernetes/test/integration/framework" ) // namespace used for all tests, do not change this const testNamespace = "statusnamespace" -var statusData = map[schema.GroupVersionResource]string{ - gvr("", "v1", "persistentvolumes"): `{"status": {"message": "hello"}}`, - gvr("", "v1", "resourcequotas"): `{"status": {"used": {"cpu": "5M"}}}`, - gvr("", "v1", "services"): `{"status": {"loadBalancer": {"ingress": [{"ip": "127.0.0.1"}]}}}`, - gvr("extensions", "v1beta1", "ingresses"): `{"status": {"loadBalancer": {"ingress": [{"ip": "127.0.0.1"}]}}}`, - gvr("networking.k8s.io", "v1beta1", "ingresses"): `{"status": {"loadBalancer": {"ingress": [{"ip": "127.0.0.1"}]}}}`, - gvr("networking.k8s.io", "v1", "ingresses"): `{"status": {"loadBalancer": {"ingress": [{"ip": "127.0.0.1"}]}}}`, - gvr("autoscaling", "v1", "horizontalpodautoscalers"): `{"status": {"currentReplicas": 5}}`, - gvr("autoscaling", "v2", "horizontalpodautoscalers"): `{"status": {"currentReplicas": 5}}`, - gvr("batch", "v1", "cronjobs"): `{"status": {"lastScheduleTime": null}}`, - gvr("batch", "v1beta1", "cronjobs"): `{"status": {"lastScheduleTime": null}}`, - gvr("storage.k8s.io", "v1", "volumeattachments"): `{"status": {"attached": true}}`, - gvr("policy", "v1", "poddisruptionbudgets"): `{"status": {"currentHealthy": 5}}`, - gvr("policy", "v1beta1", "poddisruptionbudgets"): `{"status": {"currentHealthy": 5}}`, - gvr("resource.k8s.io", "v1alpha3", "devicetaintrules"): `{"status": {"conditions": [{"type": "EvictionInProgress", "status": "True", "reason: "PodsLeft", "message: "100 pods left", "lastTransitionTime": "2020-01-01T00:00:00Z"}]}}`, - gvr("resource.k8s.io", "v1beta1", "resourceclaims"): `{"status": {"allocation": {"nodeSelector": {"nodeSelectorTerms": [{"matchExpressions": [{"key": "some-label", "operator": "In", "values": ["some-value"]}] }]}}}}`, - gvr("resource.k8s.io", "v1beta2", "devicetaintrules"): `{"status": {"conditions": [{"type": "EvictionInProgress", "status": "True", "reason: "PodsLeft", "message: "100 pods left", "lastTransitionTime": "2020-01-01T00:00:00Z"}]}}`, - gvr("resource.k8s.io", "v1beta2", "resourceclaims"): `{"status": {"allocation": {"nodeSelector": {"nodeSelectorTerms": [{"matchExpressions": [{"key": "some-label", "operator": "In", "values": ["some-value"]}] }]}}}}`, - gvr("resource.k8s.io", "v1", "resourceclaims"): `{"status": {"allocation": {"nodeSelector": {"nodeSelectorTerms": [{"matchExpressions": [{"key": "some-label", "operator": "In", "values": ["some-value"]}] }]}}}}`, - gvr("internal.apiserver.k8s.io", "v1alpha1", "storageversions"): `{"status": {"commonEncodingVersion":"v1","storageVersions":[{"apiServerID":"1","decodableVersions":["v1","v2"],"encodingVersion":"v1"}],"conditions":[{"type":"AllEncodingVersionsEqual","status":"True","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"allEncodingVersionsEqual","message":"all encoding versions are set to v1"}]}}`, - // standard for []metav1.Condition - gvr("admissionregistration.k8s.io", "v1alpha1", "validatingadmissionpolicies"): `{"status": {"conditions":[{"type":"Accepted","status":"False","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"RuleApplied","message":"Rule was applied"}]}}`, - gvr("admissionregistration.k8s.io", "v1beta1", "validatingadmissionpolicies"): `{"status": {"conditions":[{"type":"Accepted","status":"False","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"RuleApplied","message":"Rule was applied"}]}}`, - gvr("admissionregistration.k8s.io", "v1", "validatingadmissionpolicies"): `{"status": {"conditions":[{"type":"Accepted","status":"False","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"RuleApplied","message":"Rule was applied"}]}}`, -} - -const statusDefault = `{"status": {"conditions": [{"type": "MyStatus", "status":"True"}]}}` - -func gvr(g, v, r string) schema.GroupVersionResource { - return schema.GroupVersionResource{Group: g, Version: v, Resource: r} -} - -func createMapping(groupVersion string, resource metav1.APIResource) (*meta.RESTMapping, error) { - gv, err := schema.ParseGroupVersion(groupVersion) - if err != nil { - return nil, err - } - if len(resource.Group) > 0 || len(resource.Version) > 0 { - gv = schema.GroupVersion{ - Group: resource.Group, - Version: resource.Version, - } - } - gvk := gv.WithKind(resource.Kind) - gvr := gv.WithResource(strings.TrimSuffix(resource.Name, "/status")) - scope := meta.RESTScopeRoot - if resource.Namespaced { - scope = meta.RESTScopeNamespace - } - return &meta.RESTMapping{ - Resource: gvr, - GroupVersionKind: gvk, - Scope: scope, - }, nil -} - // TestApplyStatus makes sure that applying the status works for all known types. func TestApplyStatus(t *testing.T) { testApplyStatus(t, func(testing.TB, *rest.Config) {}) @@ -116,121 +55,84 @@ func TestApplyStatusWithCBOR(t *testing.T) { } func testApplyStatus(t *testing.T, reconfigureClient func(testing.TB, *rest.Config)) { - server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), []string{"--disable-admission-plugins", "ServiceAccount,TaintNodesByCondition"}, framework.SharedEtcd()) - if err != nil { - t.Fatal(err) - } - defer server.TearDownFn() - - client, err := kubernetes.NewForConfig(server.ClientConfig) - if err != nil { - t.Fatal(err) - } - - // create CRDs so we can make sure that custom resources do not get lost - etcd.CreateTestCRDs(t, apiextensionsclientset.NewForConfigOrDie(server.ClientConfig), false, etcd.GetCustomResourceDefinitionData()...) - if _, err := client.CoreV1().Namespaces().Create(context.TODO(), &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNamespace}}, metav1.CreateOptions{}); err != nil { - t.Fatal(err) - } - - createData := etcd.GetEtcdStorageData() - - // gather resources to test - _, resourceLists, err := client.Discovery().ServerGroupsAndResources() - if err != nil { - t.Fatalf("Failed to get ServerGroupsAndResources with error: %+v", err) - } - - for _, resourceList := range resourceLists { - for _, resource := range resourceList.APIResources { - if !strings.HasSuffix(resource.Name, "/status") { - continue - } - mapping, err := createMapping(resourceList.GroupVersion, resource) - if err != nil { - t.Fatal(err) - } - t.Run(mapping.Resource.String(), func(t *testing.T) { - // both spec and status get wiped for CSRs, - // nothing is expected to be managed for it, skip it - if mapping.Resource.Resource == "certificatesigningrequests" { - t.SkipNow() - } - - status, ok := statusData[mapping.Resource] - if !ok { - status = statusDefault - } - newResource, ok := createData[mapping.Resource] - if !ok { - t.Fatalf("no test data for %s. Please add a test for your new type to etcd.GetEtcdStorageData().", mapping.Resource) - } - newObj := unstructured.Unstructured{} - if err := json.Unmarshal([]byte(newResource.Stub), &newObj.Object); err != nil { - t.Fatal(err) - } - - namespace := testNamespace - if mapping.Scope == meta.RESTScopeRoot { - namespace = "" - } - name := newObj.GetName() - - // etcd test stub data doesn't contain apiVersion/kind (!), but apply requires it - newObj.SetGroupVersionKind(mapping.GroupVersionKind) - - dynamicClientConfig := rest.CopyConfig(server.ClientConfig) - reconfigureClient(t, dynamicClientConfig) - dynamicClient, err := dynamic.NewForConfig(dynamicClientConfig) - if err != nil { - t.Fatal(err) - } - - rsc := dynamicClient.Resource(mapping.Resource).Namespace(namespace) - // apply to create - _, err = rsc.Apply(context.TODO(), name, &newObj, metav1.ApplyOptions{FieldManager: "create_test"}) - if err != nil { - t.Fatal(err) - } - - statusObj := unstructured.Unstructured{} - if err := json.Unmarshal([]byte(status), &statusObj.Object); err != nil { - t.Fatal(err) - } - statusObj.SetAPIVersion(mapping.GroupVersionKind.GroupVersion().String()) - statusObj.SetKind(mapping.GroupVersionKind.Kind) - statusObj.SetName(name) - - obj, err := dynamicClient. - Resource(mapping.Resource). - Namespace(namespace). - ApplyStatus(context.TODO(), name, &statusObj, metav1.ApplyOptions{FieldManager: "apply_status_test", Force: true}) - if err != nil { - t.Fatalf("Failed to apply: %v", err) - } - - accessor, err := meta.Accessor(obj) - if err != nil { - t.Fatalf("Failed to get meta accessor: %v:\n%v", err, obj) - } - - managedFields := accessor.GetManagedFields() - if managedFields == nil { - t.Fatal("Empty managed fields") - } - if !findManager(managedFields, "apply_status_test") { - t.Fatalf("Couldn't find apply_status_test: %v", managedFields) - } - if !findManager(managedFields, "create_test") { - t.Fatalf("Couldn't find create_test: %v", managedFields) - } - - if err := rsc.Delete(context.TODO(), name, *metav1.NewDeleteOptions(0)); err != nil { - t.Fatalf("deleting final object failed: %v", err) - } - }) + apidefinitions.TestAllDefinitions(t, testNamespace, func(t *testing.T, api apidefinitions.Definition) { + if !api.HasStatus() { + t.Skip() } - } + if !api.HasVerb("patch") || !api.HasVerb("get") || !api.HasVerb("update") { + t.Skip("Resource does not support patch, get, and update") + } + // both spec and status get wiped for CSRs, + // nothing is expected to be managed for it, skip it + if api.Mapping.Resource.Resource == "certificatesigningrequests" { + t.Skip() + } + + status := api.StorageData.GetStatusStub() + newObj := unstructured.Unstructured{} + if err := json.Unmarshal([]byte(api.StorageData.Stub), &newObj.Object); err != nil { + t.Fatal(err) + } + + namespace := api.Namespace + if api.Mapping.Scope == meta.RESTScopeRoot { + namespace = "" + } + name := newObj.GetName() + + // etcd test stub data doesn't contain apiVersion/kind (!), but apply requires it + newObj.SetGroupVersionKind(api.Mapping.GroupVersionKind) + + dynamicClientConfig := rest.CopyConfig(api.Config) + reconfigureClient(t, dynamicClientConfig) + dynamicClient, err := dynamic.NewForConfig(dynamicClientConfig) + if err != nil { + t.Fatal(err) + } + + rsc := dynamicClient.Resource(api.Mapping.Resource).Namespace(namespace) + // apply to create + _, err = rsc.Apply(context.TODO(), name, &newObj, metav1.ApplyOptions{FieldManager: "create_test"}) + if err != nil { + t.Fatal(err) + } + + statusObj := unstructured.Unstructured{} + if err := json.Unmarshal([]byte(status), &statusObj.Object); err != nil { + t.Fatal(err) + } + statusObj.SetAPIVersion(api.Mapping.GroupVersionKind.GroupVersion().String()) + statusObj.SetKind(api.Mapping.GroupVersionKind.Kind) + statusObj.SetName(name) + + obj, err := dynamicClient. + Resource(api.Mapping.Resource). + Namespace(namespace). + ApplyStatus(context.TODO(), name, &statusObj, metav1.ApplyOptions{FieldManager: "apply_status_test", Force: true}) + if err != nil { + t.Fatalf("Failed to apply: %v", err) + } + + accessor, err := meta.Accessor(obj) + if err != nil { + t.Fatalf("Failed to get meta accessor: %v:\n%v", err, obj) + } + + managedFields := accessor.GetManagedFields() + if managedFields == nil { + t.Fatal("Empty managed fields") + } + if !findManager(managedFields, "apply_status_test") { + t.Fatalf("Couldn't find apply_status_test: %v", managedFields) + } + if !findManager(managedFields, "create_test") { + t.Fatalf("Couldn't find create_test: %v", managedFields) + } + + if err := rsc.Delete(context.TODO(), name, *metav1.NewDeleteOptions(0)); err != nil { + t.Fatalf("deleting final object failed: %v", err) + } + }) } func findManager(managedFields []metav1.ManagedFieldsEntry, manager string) bool { diff --git a/test/integration/etcd/data.go b/test/integration/etcd/data.go index 44341f31743..846382be475 100644 --- a/test/integration/etcd/data.go +++ b/test/integration/etcd/data.go @@ -70,6 +70,7 @@ func GetEtcdStorageDataForNamespace(namespace string) map[schema.GroupVersionRes // It returns a new map on every invocation to prevent different tests from mutating shared state. // Namespaced objects keys are computed for the specified namespace. func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulation bool) map[schema.GroupVersionResource]StorageData { + alternateImage := image.GetE2EImage(image.Etcd) image := image.GetE2EImage(image.BusyBox) etcdStorageData := map[schema.GroupVersionResource]StorageData{ // k8s.io/kubernetes/pkg/api/v1 @@ -80,8 +81,11 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, gvr("", "v1", "services"): { Stub: `{"metadata": {"name": "service1"}, "spec": {"type": "LoadBalancer", "ports": [{"port": 10000, "targetPort": 11000}], "selector": {"test": "data"}}}`, + MutatedStub: `{"spec": {"type": "ClusterIP"}}`, ExpectedEtcdPath: "/registry/services/specs/" + namespace + "/service1", IntroducedVersion: "1.0", + StatusStub: `{"status": {"loadBalancer": {"ingress": [{"ip": "127.0.0.1", "ipMode": "VIP"}]}}}`, + MutatedStatusStub: `{"status": {"loadBalancer": {"ingress": [{"ip": "127.0.0.2", "ipMode": "VIP"}]}}}`, }, gvr("", "v1", "podtemplates"): { Stub: `{"metadata": {"name": "pt1name"}, "template": {"metadata": {"labels": {"pt": "01"}}, "spec": {"containers": [{"image": "` + image + `", "name": "container9"}]}}}`, @@ -90,6 +94,7 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, gvr("", "v1", "pods"): { Stub: `{"metadata": {"name": "pod1"}, "spec": {"containers": [{"image": "` + image + `", "name": "container7", "resources": {"limits": {"cpu": "1M"}, "requests": {"cpu": "1M"}}}]}}`, + MutatedStub: `{"metadata": {"deletionTimestamp": "2020-01-01T00:00:00Z", "ownerReferences":[]}, "spec": {"containers": [{"image": "` + alternateImage + `", "name": "container7"}]}}`, ExpectedEtcdPath: "/registry/pods/" + namespace + "/pod1", IntroducedVersion: "1.0", }, @@ -100,8 +105,11 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, gvr("", "v1", "resourcequotas"): { Stub: `{"metadata": {"name": "rq1name"}, "spec": {"hard": {"cpu": "5M"}}}`, + MutatedStub: `{"spec": {"hard": {"cpu": "25M"}}}`, ExpectedEtcdPath: "/registry/resourcequotas/" + namespace + "/rq1name", IntroducedVersion: "1.0", + StatusStub: `{"status": {"used": {"cpu": "5M"}}}`, + MutatedStatusStub: `{"status": {"used": {"cpu": "25M"}}}`, }, gvr("", "v1", "limitranges"): { Stub: `{"metadata": {"name": "lr1name"}, "spec": {"limits": [{"type": "Pod"}]}}`, @@ -110,18 +118,23 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, gvr("", "v1", "namespaces"): { Stub: `{"metadata": {"name": "namespace1"}, "spec": {"finalizers": ["kubernetes"]}}`, + MutatedStub: `{"spec": {"finalizers": ["kubernetes2"]}}`, ExpectedEtcdPath: "/registry/namespaces/namespace1", IntroducedVersion: "1.0", }, gvr("", "v1", "nodes"): { Stub: `{"metadata": {"name": "node1"}, "spec": {"unschedulable": true}}`, + MutatedStub: `{"spec": {"unschedulable": false}}`, ExpectedEtcdPath: "/registry/minions/node1", IntroducedVersion: "1.0", }, gvr("", "v1", "persistentvolumes"): { Stub: `{"metadata": {"name": "pv1name"}, "spec": {"accessModes": ["ReadWriteOnce"], "capacity": {"storage": "3M"}, "hostPath": {"path": "/tmp/test/"}}}`, + MutatedStub: `{"spec": {"capacity": {"storage": "23M"}}}`, ExpectedEtcdPath: "/registry/persistentvolumes/pv1name", IntroducedVersion: "1.0", + StatusStub: `{"status": {"message": "hello"}}`, + MutatedStatusStub: `{"status": {"message": "hello2"}}`, }, gvr("", "v1", "events"): { Stub: `{"involvedObject": {"namespace": "` + namespace + `"}, "message": "some data here", "metadata": {"name": "event1"}}`, @@ -130,6 +143,7 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, gvr("", "v1", "persistentvolumeclaims"): { Stub: `{"metadata": {"name": "pvc1"}, "spec": {"accessModes": ["ReadWriteOnce"], "resources": {"limits": {"storage": "1M"}, "requests": {"storage": "2M"}}, "selector": {"matchLabels": {"pvc": "stuff"}}}}`, + MutatedStub: `{"spec": {"resources": {"requests": {"storage": "21M"}}}}`, ExpectedEtcdPath: "/registry/persistentvolumeclaims/" + namespace + "/pvc1", IntroducedVersion: "1.0", }, @@ -145,6 +159,7 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, gvr("", "v1", "replicationcontrollers"): { Stub: `{"metadata": {"name": "rc1"}, "spec": {"selector": {"new": "stuff"}, "template": {"metadata": {"labels": {"new": "stuff"}}, "spec": {"containers": [{"image": "` + image + `", "name": "container8"}]}}}}`, + MutatedStub: `{"spec": {"selector": {"new": "stuff2"}}}`, ExpectedEtcdPath: "/registry/controllers/" + namespace + "/rc1", IntroducedVersion: "1.0", }, @@ -153,21 +168,25 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // k8s.io/kubernetes/pkg/apis/apps/v1 gvr("apps", "v1", "daemonsets"): { Stub: `{"metadata": {"name": "ds6"}, "spec": {"selector": {"matchLabels": {"a": "b"}}, "template": {"metadata": {"labels": {"a": "b"}}, "spec": {"containers": [{"image": "` + image + `", "name": "container6"}]}}}}`, + MutatedStub: `{"spec": {"template": {"spec": {"containers": [{"image": "` + alternateImage + `", "name": "container6"}]}}}}`, ExpectedEtcdPath: "/registry/daemonsets/" + namespace + "/ds6", IntroducedVersion: "1.9", }, gvr("apps", "v1", "deployments"): { Stub: `{"metadata": {"name": "deployment4"}, "spec": {"selector": {"matchLabels": {"f": "z"}}, "template": {"metadata": {"labels": {"f": "z"}}, "spec": {"containers": [{"image": "` + image + `", "name": "container6"}]}}}}`, + MutatedStub: `{"metadata": {"labels": {"a":"c"}}, "spec": {"template": {"spec": {"containers": [{"image": "` + alternateImage + `", "name": "container6"}]}}}}`, ExpectedEtcdPath: "/registry/deployments/" + namespace + "/deployment4", IntroducedVersion: "1.9", }, gvr("apps", "v1", "statefulsets"): { Stub: `{"metadata": {"name": "ss3"}, "spec": {"selector": {"matchLabels": {"a": "b"}}, "template": {"metadata": {"labels": {"a": "b"}}, "spec": {"restartPolicy": "Always", "terminationGracePeriodSeconds": 30, "containers": [{"image": "` + image + `", "name": "container6", "terminationMessagePolicy": "File"}]}}}}`, + MutatedStub: `{"spec": {"selector": {"matchLabels": {"a2": "b2"}}}}`, ExpectedEtcdPath: "/registry/statefulsets/" + namespace + "/ss3", IntroducedVersion: "1.9", }, gvr("apps", "v1", "replicasets"): { Stub: `{"metadata": {"name": "rs3"}, "spec": {"selector": {"matchLabels": {"g": "h"}}, "template": {"metadata": {"labels": {"g": "h"}}, "spec": {"containers": [{"image": "` + image + `", "name": "container4"}]}}}}`, + MutatedStub: `{"spec": {"template": {"spec": {"containers": [{"image": "` + alternateImage + `", "name": "container4"}]}}}}`, ExpectedEtcdPath: "/registry/replicasets/" + namespace + "/rs3", IntroducedVersion: "1.9", }, @@ -181,36 +200,47 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // k8s.io/kubernetes/pkg/apis/autoscaling/v1 gvr("autoscaling", "v1", "horizontalpodautoscalers"): { Stub: `{"metadata": {"name": "hpa2"}, "spec": {"maxReplicas": 3, "scaleTargetRef": {"kind": "something", "name": "cross", "apiVersion": "apps/v1"}}}`, + MutatedStub: `{"spec": {"maxReplicas": 23}}`, ExpectedEtcdPath: "/registry/horizontalpodautoscalers/" + namespace + "/hpa2", ExpectedGVK: gvkP("autoscaling", "v2", "HorizontalPodAutoscaler"), IntroducedVersion: "1.2", + StatusStub: `{"status": {"currentReplicas": 5}}`, + MutatedStatusStub: `{"status": {"currentReplicas": 25}}`, }, // -- // k8s.io/kubernetes/pkg/apis/autoscaling/v2 gvr("autoscaling", "v2", "horizontalpodautoscalers"): { Stub: `{"metadata": {"name": "hpa4"}, "spec": {"maxReplicas": 3, "scaleTargetRef": {"kind": "something", "name": "cross", "apiVersion": "apps/v1"}}}`, + MutatedStub: `{"spec": {"maxReplicas": 23}}`, ExpectedEtcdPath: "/registry/horizontalpodautoscalers/" + namespace + "/hpa4", IntroducedVersion: "1.23", + StatusStub: `{"status": {"currentReplicas": 5}}`, + MutatedStatusStub: `{"status": {"currentReplicas": 25}}`, }, // -- // k8s.io/kubernetes/pkg/apis/batch/v1 gvr("batch", "v1", "jobs"): { Stub: `{"metadata": {"name": "job1"}, "spec": {"manualSelector": true, "selector": {"matchLabels": {"controller-uid": "uid1"}}, "template": {"metadata": {"labels": {"controller-uid": "uid1"}}, "spec": {"containers": [{"image": "` + image + `", "name": "container1"}], "dnsPolicy": "ClusterFirst", "restartPolicy": "Never"}}}}`, + MutatedStub: `{"spec": {"template": {"spec": {"containers": [{"image": "` + alternateImage + `", "name": "container1"}]}}}}`, ExpectedEtcdPath: "/registry/jobs/" + namespace + "/job1", IntroducedVersion: "1.2", }, gvr("batch", "v1", "cronjobs"): { Stub: `{"metadata": {"name": "cjv1"}, "spec": {"jobTemplate": {"spec": {"template": {"metadata": {"labels": {"controller-uid": "uid0"}}, "spec": {"containers": [{"image": "` + image + `", "name": "container0"}], "dnsPolicy": "ClusterFirst", "restartPolicy": "Never"}}}}, "schedule": "* * * * *"}}`, + MutatedStub: `{"spec": {"jobTemplate": {"spec": {"template": {"spec": {"containers": [{"image": "` + alternateImage + `", "name": "container0"}]}}}}}}`, ExpectedEtcdPath: "/registry/cronjobs/" + namespace + "/cjv1", IntroducedVersion: "1.21", + StatusStub: `{"status": {"lastScheduleTime": null}}`, + MutatedStatusStub: `{"status": {"lastScheduleTime": "2020-01-01T00:00:00Z"}}`, }, // -- // k8s.io/kubernetes/pkg/apis/certificates/v1 gvr("certificates.k8s.io", "v1", "certificatesigningrequests"): { Stub: `{"metadata": {"name": "csr2"}, "spec": {"signerName":"example.com/signer", "usages":["any"], "request": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURSBSRVFVRVNULS0tLS0KTUlJQnlqQ0NBVE1DQVFBd2dZa3hDekFKQmdOVkJBWVRBbFZUTVJNd0VRWURWUVFJRXdwRFlXeHBabTl5Ym1saApNUll3RkFZRFZRUUhFdzFOYjNWdWRHRnBiaUJXYVdWM01STXdFUVlEVlFRS0V3cEhiMjluYkdVZ1NXNWpNUjh3CkhRWURWUVFMRXhaSmJtWnZjbTFoZEdsdmJpQlVaV05vYm05c2IyZDVNUmN3RlFZRFZRUURFdzUzZDNjdVoyOXYKWjJ4bExtTnZiVENCbnpBTkJna3Foa2lHOXcwQkFRRUZBQU9CalFBd2dZa0NnWUVBcFp0WUpDSEo0VnBWWEhmVgpJbHN0UVRsTzRxQzAzaGpYK1prUHl2ZFlkMVE0K3FiQWVUd1htQ1VLWUhUaFZSZDVhWFNxbFB6eUlCd2llTVpyCldGbFJRZGRaMUl6WEFsVlJEV3dBbzYwS2VjcWVBWG5uVUsrNWZYb1RJL1VnV3NocmU4dEoreC9UTUhhUUtSL0oKY0lXUGhxYVFoc0p1elpidkFkR0E4MEJMeGRNQ0F3RUFBYUFBTUEwR0NTcUdTSWIzRFFFQkJRVUFBNEdCQUlobAo0UHZGcStlN2lwQVJnSTVaTStHWng2bXBDejQ0RFRvMEprd2ZSRGYrQnRyc2FDMHE2OGVUZjJYaFlPc3E0ZmtIClEwdUEwYVZvZzNmNWlKeENhM0hwNWd4YkpRNnpWNmtKMFRFc3VhYU9oRWtvOXNkcENvUE9uUkJtMmkvWFJEMkQKNmlOaDhmOHowU2hHc0ZxakRnRkh5RjNvK2xVeWorVUM2SDFRVzdibgotLS0tLUVORCBDRVJUSUZJQ0FURSBSRVFVRVNULS0tLS0="}}`, + MutatedStub: `{}`, ExpectedEtcdPath: "/registry/certificatesigningrequests/csr2", IntroducedVersion: "1.19", }, @@ -296,8 +326,11 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // k8s.io/kubernetes/pkg/apis/networking/v1 gvr("networking.k8s.io", "v1", "ingresses"): { Stub: `{"metadata": {"name": "ingress3"}, "spec": {"defaultBackend": {"service":{"name":"service", "port":{"number": 5000}}}}}`, + MutatedStub: `{"spec": {"defaultBackend": {"service": {"name": "service2"}}}}`, ExpectedEtcdPath: "/registry/ingress/" + namespace + "/ingress3", IntroducedVersion: "1.19", + StatusStub: `{"status": {"loadBalancer": {"ingress": [{"ip": "127.0.0.1"}]}}}`, + MutatedStatusStub: `{"status": {"loadBalancer": {"ingress": [{"ip": "127.0.0.2"}]}}}`, }, gvr("networking.k8s.io", "v1", "ingressclasses"): { Stub: `{"metadata": {"name": "ingressclass3"}, "spec": {"controller": "example.com/controller"}}`, @@ -317,9 +350,11 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, gvr("networking.k8s.io", "v1", "servicecidrs"): { Stub: `{"metadata": {"name": "range-b2"}, "spec": {"cidrs": ["192.168.0.0/16","fd00:1::/120"]}}`, + MutatedStub: `{}`, ExpectedEtcdPath: "/registry/servicecidrs/range-b2", ExpectedGVK: gvkP("networking.k8s.io", "v1", "ServiceCIDR"), IntroducedVersion: "1.33", + MutatedStatusStub: `{"status": {"conditions":[{"type":"Accepted","status":"True","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"RuleApplied","message":"Rule was applied"}]}}`, }, // -- @@ -336,18 +371,23 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // k8s.io/kubernetes/pkg/apis/networking/v1beta1 gvr("networking.k8s.io", "v1beta1", "servicecidrs"): { Stub: `{"metadata": {"name": "range-b1"}, "spec": {"cidrs": ["192.168.0.0/16","fd00:1::/120"]}}`, + MutatedStub: `{}`, ExpectedEtcdPath: "/registry/servicecidrs/range-b1", ExpectedGVK: gvkP("networking.k8s.io", "v1", "ServiceCIDR"), IntroducedVersion: "1.31", RemovedVersion: "1.37", + MutatedStatusStub: `{"status": {"conditions":[{"type":"Accepted","status":"True","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"RuleApplied","message":"Rule was applied"}]}}`, }, // -- // k8s.io/kubernetes/pkg/apis/policy/v1 gvr("policy", "v1", "poddisruptionbudgets"): { Stub: `{"metadata": {"name": "pdbv1"}, "spec": {"selector": {"matchLabels": {"anokkey": "anokvalue"}}}}`, + MutatedStub: `{"spec": {"selector": {"matchLabels": {"anokkey2": "anokvalue"}}}}`, ExpectedEtcdPath: "/registry/poddisruptionbudgets/" + namespace + "/pdbv1", IntroducedVersion: "1.21", + StatusStub: `{"status": {"currentHealthy": 5}}`, + MutatedStatusStub: `{"status": {"currentHealthy": 25}}`, }, // -- @@ -363,6 +403,7 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // k8s.io/kubernetes/pkg/apis/flowcontrol/v1 gvr("flowcontrol.apiserver.k8s.io", "v1", "flowschemas"): { Stub: `{"metadata": {"name": "fs-3"}, "spec": {"priorityLevelConfiguration": {"name": "name1"}}}`, + MutatedStub: `{"metadata": {"labels":{"a":"c"}}, "spec": {"priorityLevelConfiguration": {"name": "name2"}}}`, ExpectedEtcdPath: "/registry/flowschemas/fs-3", IntroducedVersion: "1.29", }, @@ -371,6 +412,7 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // k8s.io/kubernetes/pkg/apis/flowcontrol/v1 gvr("flowcontrol.apiserver.k8s.io", "v1", "prioritylevelconfigurations"): { Stub: `{"metadata": {"name": "conf5"}, "spec": {"type": "Limited", "limited": {"nominalConcurrencyShares":3, "limitResponse": {"type": "Reject"}}}}`, + MutatedStub: `{"metadata": {"labels":{"a":"c"}}, "spec": {"limited": {"nominalConcurrencyShares": 23}}}`, ExpectedEtcdPath: "/registry/prioritylevelconfigurations/conf5", IntroducedVersion: "1.29", }, @@ -379,8 +421,11 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // k8s.io/kubernetes/pkg/apis/storage/v1 gvr("storage.k8s.io", "v1", "volumeattachments"): { Stub: `{"metadata": {"name": "va3"}, "spec": {"attacher": "gce", "nodeName": "localhost", "source": {"persistentVolumeName": "pv3"}}}`, + MutatedStub: `{"metadata": {"name": "va3"}, "spec": {"nodeName": "localhost2"}}`, ExpectedEtcdPath: "/registry/volumeattachments/va3", IntroducedVersion: "1.13", + StatusStub: `{"status": {"attached": true}}`, + MutatedStatusStub: `{"status": {"attached": false}}`, }, // -- @@ -452,6 +497,7 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, gvr("admissionregistration.k8s.io", "v1", "validatingadmissionpolicies"): { Stub: `{"metadata":{"name":"vap1"},"spec":{"paramKind":{"apiVersion":"test.example.com/v1","kind":"Example"},"matchConstraints":{"resourceRules": [{"resourceNames": ["fakeName"], "apiGroups":["apps"],"apiVersions":["v1"],"operations":["CREATE", "UPDATE"], "resources":["deployments"]}]},"validations":[{"expression":"object.spec.replicas <= params.maxReplicas","message":"Too many replicas"}]}}`, + MutatedStub: `{"metadata": {"labels": {"a":"c"}}, "spec": {"paramKind": {"apiVersion": "apps/v1", "kind": "Deployment"}}}`, ExpectedEtcdPath: "/registry/validatingadmissionpolicies/vap1", IntroducedVersion: "1.30", }, @@ -547,6 +593,7 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // depends on aggregator using the same ungrouped RESTOptionsGetter as the kube apiserver, not SimpleRestOptionsFactory in aggregator.go gvr("apiregistration.k8s.io", "v1", "apiservices"): { Stub: `{"metadata": {"name": "as2.foo.com"}, "spec": {"group": "foo.com", "version": "as2", "groupPriorityMinimum":100, "versionPriority":10}}`, + MutatedStub: `{"metadata": {"labels": {"a":"c"}}, "spec": {"versionPriority": 100}}`, ExpectedEtcdPath: "/registry/apiregistration.k8s.io/apiservices/as2.foo.com", IntroducedVersion: "1.10", }, @@ -558,6 +605,7 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat `"scope": "Cluster","group": "webconsole2.operator.openshift.io",` + `"versions": [{"name":"v1alpha1","storage":true,"served":true,"schema":{"openAPIV3Schema":{"type":"object"}}}],` + `"names": {"kind": "OpenShiftWebConsoleConfig","plural": "openshiftwebconsoleconfigs","singular": "openshiftwebconsoleconfig"}}}`, + MutatedStub: `{"metadata": {"labels":{"a":"c"}}, "spec": {"group": "webconsole22.operator.openshift.io"}}`, ExpectedEtcdPath: "/registry/apiextensions.k8s.io/customresourcedefinitions/openshiftwebconsoleconfigs.webconsole2.operator.openshift.io", ExpectedGVK: gvkP("apiextensions.k8s.io", "v1beta1", "CustomResourceDefinition"), IntroducedVersion: "1.16", @@ -574,11 +622,13 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, gvr("awesome.bears.com", "v1", "pandas"): { Stub: `{"kind": "Panda", "apiVersion": "awesome.bears.com/v1", "metadata": {"name": "cr3panda"}, "spec":{"replicas": 100}}`, // requires TypeMeta due to CRD scheme's UnstructuredObjectTyper + MutatedStub: `{"spec": {"replicas": 102}}`, ExpectedEtcdPath: "/registry/awesome.bears.com/pandas/cr3panda", IntroducedVersion: "1.0", }, gvr("awesome.bears.com", "v3", "pandas"): { Stub: `{"kind": "Panda", "apiVersion": "awesome.bears.com/v3", "metadata": {"name": "cr4panda"}, "spec":{"replicas": 300}}`, // requires TypeMeta due to CRD scheme's UnstructuredObjectTyper + MutatedStub: `{"spec": {"replicas": 302}}`, ExpectedEtcdPath: "/registry/awesome.bears.com/pandas/cr4panda", ExpectedGVK: gvkP("awesome.bears.com", "v1", "Panda"), IntroducedVersion: "1.0", @@ -601,6 +651,7 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // k8s.io/kubernetes/pkg/apis/resource/v1alpha3 gvr("resource.k8s.io", "v1alpha3", "devicetaintrules"): { Stub: `{"metadata": {"name": "taint1name"}, "spec": {"taint": {"key": "example.com/taintkey", "value": "taintvalue", "effect": "NoSchedule"}}}`, + MutatedStub: `{"metadata": {"labels":{"a":"c"}}}`, ExpectedEtcdPath: "/registry/devicetaintrules/taint1name", IntroducedVersion: "1.33", RemovedVersion: "1.39", @@ -616,6 +667,7 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // k8s.io/kubernetes/pkg/apis/resource/v1beta1 gvr("resource.k8s.io", "v1beta1", "deviceclasses"): { Stub: `{"metadata": {"name": "class2name"}}`, + MutatedStub: `{"metadata": {"labels":{"a":"c"}}}`, ExpectedEtcdPath: "/registry/deviceclasses/class2name", ExpectedGVK: gvkP("resource.k8s.io", "v1", "DeviceClass"), IntroducedVersion: "1.32", @@ -623,13 +675,17 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, gvr("resource.k8s.io", "v1beta1", "resourceclaims"): { Stub: `{"metadata": {"name": "claim2name"}, "spec": {"devices": {"requests": [{"name": "req-0", "deviceClassName": "example-class", "allocationMode": "ExactCount", "count": 1}]}}}`, + MutatedStub: `{"spec": {"devices": {"requests": [{"name": "req-0", "deviceClassName": "other-class"}]}}}`, ExpectedEtcdPath: "/registry/resourceclaims/" + namespace + "/claim2name", ExpectedGVK: gvkP("resource.k8s.io", "v1", "ResourceClaim"), IntroducedVersion: "1.32", RemovedVersion: "1.38", + StatusStub: `{"status": {"allocation": {"nodeSelector": {"nodeSelectorTerms": [{"matchExpressions": [{"key": "some-label", "operator": "In", "values": ["some-value"]}] }]}}}}`, + MutatedStatusStub: `{"status": {"allocation": {"nodeSelector": {"nodeSelectorTerms": [{"matchExpressions": [{"key": "some-label", "operator": "In", "values": ["some-other-value"]}] }]}}}}`, }, gvr("resource.k8s.io", "v1beta1", "resourceclaimtemplates"): { Stub: `{"metadata": {"name": "claimtemplate2name"}, "spec": {"spec": {"devices": {"requests": [{"name": "req-0", "deviceClassName": "example-class", "allocationMode": "ExactCount", "count": 1}]}}}}`, + MutatedStub: `{"spec": {"spec": {"resourceClassName": "class2name"}}}`, ExpectedEtcdPath: "/registry/resourceclaimtemplates/" + namespace + "/claimtemplate2name", ExpectedGVK: gvkP("resource.k8s.io", "v1", "ResourceClaimTemplate"), IntroducedVersion: "1.32", @@ -650,6 +706,7 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // v1beta1 is the default although the actual storage version is v1beta2. gvr("resource.k8s.io", "v1beta2", "deviceclasses"): { Stub: `{"metadata": {"name": "class3name"}}`, + MutatedStub: `{"metadata": {"labels":{"a":"c"}}}`, ExpectedEtcdPath: "/registry/deviceclasses/class3name", ExpectedGVK: gvkP("resource.k8s.io", "v1", "DeviceClass"), IntroducedVersion: "1.33", @@ -657,6 +714,7 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, gvr("resource.k8s.io", "v1beta2", "devicetaintrules"): { Stub: `{"metadata": {"name": "taint2name"}, "spec": {"taint": {"key": "example.com/taintkey", "value": "taintvalue", "effect": "NoSchedule"}}}`, + MutatedStub: `{"metadata": {"labels":{"a":"c"}}}`, ExpectedEtcdPath: "/registry/devicetaintrules/taint2name", ExpectedGVK: gvkP("resource.k8s.io", "v1alpha3", "DeviceTaintRule"), // v1beta2 has higher priority, but to support downgrades v1alpha3 is picked automatically. IntroducedVersion: "1.36", @@ -664,13 +722,17 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, gvr("resource.k8s.io", "v1beta2", "resourceclaims"): { Stub: `{"metadata": {"name": "claim3name"}, "spec": {"devices": {"requests": [{"name": "req-0", "exactly": {"deviceClassName": "example-class", "allocationMode": "ExactCount", "count": 1}}]}}}`, + MutatedStub: `{"spec": {"devices": {"requests": [{"name": "req-0", "exactly": {"deviceClassName": "other-class"}}]}}}`, ExpectedEtcdPath: "/registry/resourceclaims/" + namespace + "/claim3name", ExpectedGVK: gvkP("resource.k8s.io", "v1", "ResourceClaim"), IntroducedVersion: "1.33", RemovedVersion: "1.39", + StatusStub: `{"status": {"allocation": {"nodeSelector": {"nodeSelectorTerms": [{"matchExpressions": [{"key": "some-label", "operator": "In", "values": ["some-value"]}] }]}}}}`, + MutatedStatusStub: `{"status": {"allocation": {"nodeSelector": {"nodeSelectorTerms": [{"matchExpressions": [{"key": "some-label", "operator": "In", "values": ["some-other-value"]}] }]}}}}`, }, gvr("resource.k8s.io", "v1beta2", "resourceclaimtemplates"): { Stub: `{"metadata": {"name": "claimtemplate3name"}, "spec": {"spec": {"devices": {"requests": [{"name": "req-0", "exactly": {"deviceClassName": "example-class", "allocationMode": "ExactCount", "count": 1}}]}}}}`, + MutatedStub: `{"spec": {"spec": {"resourceClassName": "class2name"}}}`, ExpectedEtcdPath: "/registry/resourceclaimtemplates/" + namespace + "/claimtemplate3name", ExpectedGVK: gvkP("resource.k8s.io", "v1", "ResourceClaimTemplate"), IntroducedVersion: "1.33", @@ -688,18 +750,23 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // k8s.io/kubernetes/pkg/apis/resource/v1 gvr("resource.k8s.io", "v1", "deviceclasses"): { Stub: `{"metadata": {"name": "class4name"}}`, + MutatedStub: `{"metadata": {"labels":{"a":"c"}}}`, ExpectedEtcdPath: "/registry/deviceclasses/class4name", ExpectedGVK: gvkP("resource.k8s.io", "v1", "DeviceClass"), IntroducedVersion: "1.34", }, gvr("resource.k8s.io", "v1", "resourceclaims"): { Stub: `{"metadata": {"name": "claim4name"}, "spec": {"devices": {"requests": [{"name": "req-0", "exactly": {"deviceClassName": "example-class", "allocationMode": "ExactCount", "count": 1}}]}}}`, + MutatedStub: `{"spec": {"devices": {"requests": [{"name": "req-0", "exactly": {"deviceClassName": "other-class"}}]}}}`, ExpectedEtcdPath: "/registry/resourceclaims/" + namespace + "/claim4name", ExpectedGVK: gvkP("resource.k8s.io", "v1", "ResourceClaim"), IntroducedVersion: "1.34", + StatusStub: `{"status": {"allocation": {"nodeSelector": {"nodeSelectorTerms": [{"matchExpressions": [{"key": "some-label", "operator": "In", "values": ["some-value"]}] }]}}}}`, + MutatedStatusStub: `{"status": {"allocation": {"nodeSelector": {"nodeSelectorTerms": [{"matchExpressions": [{"key": "some-label", "operator": "In", "values": ["some-other-value"]}] }]}}}}`, }, gvr("resource.k8s.io", "v1", "resourceclaimtemplates"): { Stub: `{"metadata": {"name": "claimtemplate4name"}, "spec": {"spec": {"devices": {"requests": [{"name": "req-0", "exactly": {"deviceClassName": "example-class", "allocationMode": "ExactCount", "count": 1}}]}}}}`, + MutatedStub: `{"spec": {"spec": {"resourceClassName": "class2name"}}}`, ExpectedEtcdPath: "/registry/resourceclaimtemplates/" + namespace + "/claimtemplate4name", ExpectedGVK: gvkP("resource.k8s.io", "v1", "ResourceClaimTemplate"), IntroducedVersion: "1.34", @@ -714,8 +781,11 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat // k8s.io/apiserver/pkg/apis/apiserverinternal/v1alpha1 gvr("internal.apiserver.k8s.io", "v1alpha1", "storageversions"): { - Stub: `{"metadata":{"name":"sv1.test"},"spec":{}}`, - ExpectedEtcdPath: "/registry/storageversions/sv1.test", + Stub: `{"metadata":{"name":"sv1.test"},"spec":{}}`, + MutatedStub: `{}`, + ExpectedEtcdPath: "/registry/storageversions/sv1.test", + StatusStub: `{"status": {"commonEncodingVersion":"v1","storageVersions":[{"apiServerID":"1","decodableVersions":["v1","v2"],"encodingVersion":"v1"}],"conditions":[{"type":"AllEncodingVersionsEqual","status":"True","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"allEncodingVersionsEqual","message":"all encoding versions are set to v1"}]}}`, + MutatedStatusStub: `{"status": {"commonEncodingVersion":"v1","storageVersions":[{"apiServerID":"1","decodableVersions":["v1","v2"],"encodingVersion":"v1"}],"conditions":[{"type":"AllEncodingVersionsEqual","status":"False","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"allEncodingVersionsEqual","message":"all encoding versions are set to v1"}]}}`, }, // -- @@ -839,7 +909,24 @@ func storageVersionAtEmulationVersion(key schema.GroupVersionResource, expectedG // StorageData contains information required to create an object and verify its storage in etcd // It must be paired with a specific resource type StorageData struct { - Stub string // Valid JSON stub to use during create + // Stub is a valid JSON object used to create the resource. + Stub string + // MutatedStub is Stub with at least one spec or metadata field changed to a different value. + // This should mutate a spec field if possible. Only mutate a metadata field if the entire spec is immutable. + // This mutation is applied using a server-side apply patch. + // Required for resources that have a status subresource; leave empty otherwise. + // Example: if Stub has `"replicas": 1`, MutatedStub might have `"replicas": 2`. + MutatedStub string + // StatusStub is a valid JSON status payload used to write to the /status subresource. + // If empty, a metav1.Condition style status condition is used. Required only + // for resources without status conditions. + StatusStub string + // MutatedStatusStub is StatusStub with at least one status field changed to a different value. + // If empty, the default status payload with a flipped condition value is used. + // This mutation is applied using a server-side apply patch. + // Must be set whenever StatusStub is set. + MutatedStatusStub string + Prerequisites []Prerequisite // Optional, ordered list of JSON objects to create before stub ExpectedEtcdPath string // Expected location of object in etcd, do not use any variables, constants, etc to derive this value - always supply the full raw string ExpectedGVK *schema.GroupVersionKind // The GVK that we expect this object to be stored as - leave this nil to use the default @@ -847,6 +934,26 @@ type StorageData struct { RemovedVersion string // The version that this type is removed. May be empty for stable resources } +const defaultStatusStub = `{"status": {"conditions": [{"type": "MyStatus", "status":"True", "lastTransitionTime": "2020-01-01T00:00:00Z", "reason": "MyReason", "message": "some message"}]}}` +const defaultMutatedStatusStub = `{"status": {"conditions": [{"type": "MyStatus", "status":"False", "lastTransitionTime": "2020-01-01T00:00:00Z", "reason": "MyReason", "message": "some message"}]}}` + +// GetStatusStub returns the StatusStub, or a default conditions-based status if empty. +func (s StorageData) GetStatusStub() string { + if s.StatusStub != "" { + return s.StatusStub + } + return defaultStatusStub +} + +// GetMutatedStatusStub returns the MutatedStatusStub, or a default conditions-based status +// with a flipped condition value if empty. +func (s StorageData) GetMutatedStatusStub() string { + if s.MutatedStatusStub != "" { + return s.MutatedStatusStub + } + return defaultMutatedStatusStub +} + // Prerequisite contains information required to create a resource (but not verify it) type Prerequisite struct { GvrData schema.GroupVersionResource