diff --git a/staging/src/k8s.io/api/core/v1/generated.proto b/staging/src/k8s.io/api/core/v1/generated.proto index dbe130e51ea..3a13c53fa38 100644 --- a/staging/src/k8s.io/api/core/v1/generated.proto +++ b/staging/src/k8s.io/api/core/v1/generated.proto @@ -816,6 +816,7 @@ message ContainerPort { // Protocol for port. Must be UDP, TCP, or SCTP. // Defaults to "TCP". // +optional + // +default="TCP" optional string protocol = 4; // What host IP to bind the external port to. diff --git a/staging/src/k8s.io/api/core/v1/types.go b/staging/src/k8s.io/api/core/v1/types.go index 698baa9aa33..2bba9725192 100644 --- a/staging/src/k8s.io/api/core/v1/types.go +++ b/staging/src/k8s.io/api/core/v1/types.go @@ -1837,6 +1837,7 @@ type ContainerPort struct { // Protocol for port. Must be UDP, TCP, or SCTP. // Defaults to "TCP". // +optional + // +default="TCP" Protocol Protocol `json:"protocol,omitempty" protobuf:"bytes,4,opt,name=protocol,casttype=Protocol"` // What host IP to bind the external port to. // +optional diff --git a/test/integration/apiserver/apply/BUILD b/test/integration/apiserver/apply/BUILD index db4a791b0be..4d5071fb8ba 100644 --- a/test/integration/apiserver/apply/BUILD +++ b/test/integration/apiserver/apply/BUILD @@ -18,6 +18,7 @@ go_test( "//pkg/controlplane:go_default_library", "//staging/src/k8s.io/api/apps/v1:go_default_library", "//staging/src/k8s.io/api/core/v1:go_default_library", + "//staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/test/integration/fixtures:go_default_library", diff --git a/test/integration/apiserver/apply/apply_crd_test.go b/test/integration/apiserver/apply/apply_crd_test.go index db5e4020e1a..a6e910050e3 100644 --- a/test/integration/apiserver/apply/apply_crd_test.go +++ b/test/integration/apiserver/apply/apply_crd_test.go @@ -23,6 +23,7 @@ import ( "reflect" "testing" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/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" @@ -825,3 +826,138 @@ func getManagedFields(rawResponse []byte) ([]metav1.ManagedFieldsEntry, error) { } return obj.GetManagedFields(), nil } + +func TestDefaultMissingKeyCRD(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.ServerSideApply, true)() + + server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), nil, framework.SharedEtcd()) + if err != nil { + t.Fatal(err) + } + defer server.TearDownFn() + config := server.ClientConfig + + apiExtensionClient, err := clientset.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + + noxuDefinition := fixtures.NewNoxuV1CustomResourceDefinition(apiextensionsv1.ClusterScoped) + err = json.Unmarshal([]byte(`{ + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "x-kubernetes-preserve-unknown-fields": true, + "properties": { + "cronSpec": { + "type": "string", + "pattern": "^(\\d+|\\*)(/\\d+)?(\\s+(\\d+|\\*)(/\\d+)?){4}$" + }, + "ports": { + "type": "array", + "x-kubernetes-list-map-keys": [ + "containerPort", + "protocol" + ], + "x-kubernetes-list-type": "map", + "items": { + "properties": { + "containerPort": { + "format": "int32", + "type": "integer" + }, + "hostIP": { + "type": "string" + }, + "hostPort": { + "format": "int32", + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "default": "TCP", + "type": "string" + } + }, + "required": [ + "containerPort" + ], + "type": "object" + } + } + } + } + } + } + }`), &noxuDefinition.Spec.Versions[0].Schema) + if err != nil { + t.Fatal(err) + } + noxuDefinition, err = fixtures.CreateNewV1CustomResourceDefinition(noxuDefinition, apiExtensionClient, dynamicClient) + if err != nil { + t.Fatal(err) + } + + kind := noxuDefinition.Spec.Names.Kind + apiVersion := noxuDefinition.Spec.Group + "/" + noxuDefinition.Spec.Versions[0].Name + name := "mytest" + + rest := apiExtensionClient.Discovery().RESTClient() + yamlBody := []byte(fmt.Sprintf(` +apiVersion: %s +kind: %s +metadata: + name: %s + finalizers: + - test-finalizer +spec: + cronSpec: "* * * * */5" + replicas: 1 + ports: + - name: x + containerPort: 80`, 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). + 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)) + } + + // New applier tries to edit an existing list item, we should get conflicts. + result, err = rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Versions[0].Name, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("fieldManager", "apply_test_2"). + Body([]byte(fmt.Sprintf(` +apiVersion: %s +kind: %s +metadata: + name: %s +spec: + ports: + - name: "y" + containerPort: 80 + protocol: TCP`, apiVersion, kind, name))). + DoRaw(context.TODO()) + if err == nil { + t.Fatalf("Expecting to get conflicts when a different applier updates existing list item, got no error: %s", result) + } + status, ok := err.(*apierrors.StatusError) + if !ok { + t.Fatalf("Expecting to get conflicts as API error") + } + if len(status.Status().Details.Causes) != 1 { + t.Fatalf("Expecting to get one conflict when a different applier updates existing list item, got: %v", status.Status().Details.Causes) + } +} diff --git a/test/integration/apiserver/apply/apply_test.go b/test/integration/apiserver/apply/apply_test.go index 7083cc5837a..f654f886b89 100644 --- a/test/integration/apiserver/apply/apply_test.go +++ b/test/integration/apiserver/apply/apply_test.go @@ -2231,6 +2231,115 @@ func TestApplyCanRemoveMapItemsContributedToByControllers(t *testing.T) { } } +// TestDefaultMissingKeys makes sure that the missing keys default is used when merging. +func TestDefaultMissingKeys(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.ServerSideApply, true)() + + _, client, closeFn := setup(t) + defer closeFn() + + // Applier creates a deployment with containerPort but no protocol + apply := []byte(`{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": "deployment-shared-map-item-removal", + "labels": {"app": "nginx"} + }, + "spec": { + "selector": { + "matchLabels": { + "app": "nginx" + } + }, + "template": { + "metadata": { + "labels": { + "app": "nginx" + } + }, + "spec": { + "containers": [{ + "name": "nginx", + "image": "nginx:latest", + "ports": [{ + "name": "foo", + "containerPort": 80 + }] + }] + } + } + } + }`) + + _, err := client.CoreV1().RESTClient().Patch(types.ApplyPatchType). + AbsPath("/apis/apps/v1"). + Namespace("default"). + Resource("deployments"). + Name("deployment-shared-map-item-removal"). + Param("fieldManager", "test_applier"). + Body(apply). + Do(context.TODO()). + Get() + if err != nil { + t.Fatalf("Failed to create object using Apply patch: %v", err) + } + + // Applier updates the name, and uses the protocol, we should get a conflict. + apply = []byte(`{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": "deployment-shared-map-item-removal", + "labels": {"app": "nginx"} + }, + "spec": { + "selector": { + "matchLabels": { + "app": "nginx" + } + }, + "template": { + "metadata": { + "labels": { + "app": "nginx" + } + }, + "spec": { + "containers": [{ + "name": "nginx", + "image": "nginx:latest", + "ports": [{ + "name": "bar", + "containerPort": 80, + "protocol": "TCP" + }] + }] + } + } + } + }`) + patched, err := client.CoreV1().RESTClient().Patch(types.ApplyPatchType). + AbsPath("/apis/apps/v1"). + Namespace("default"). + Resource("deployments"). + Name("deployment-shared-map-item-removal"). + Param("fieldManager", "test_applier_conflict"). + Body(apply). + Do(context.TODO()). + Get() + if err == nil { + t.Fatalf("Expecting to get conflicts when a different applier updates existing list item, got no error: %s", patched) + } + status, ok := err.(*apierrors.StatusError) + if !ok { + t.Fatalf("Expecting to get conflicts as API error") + } + if len(status.Status().Details.Causes) != 1 { + t.Fatalf("Expecting to get one conflict when a different applier updates existing list item, got: %v", status.Status().Details.Causes) + } +} + var podBytes = []byte(` apiVersion: v1 kind: Pod