k8s.io/kubernetes@v1.31.0-alpha.0.0.20240520171757-56147500dadc/test/e2e/apimachinery/crd_conversion_webhook.go (about) 1 /* 2 Copyright 2018 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package apimachinery 18 19 import ( 20 "context" 21 "fmt" 22 "time" 23 24 "github.com/onsi/ginkgo/v2" 25 "github.com/onsi/gomega" 26 27 appsv1 "k8s.io/api/apps/v1" 28 v1 "k8s.io/api/core/v1" 29 rbacv1 "k8s.io/api/rbac/v1" 30 apierrors "k8s.io/apimachinery/pkg/api/errors" 31 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 32 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 33 "k8s.io/apimachinery/pkg/util/intstr" 34 "k8s.io/apimachinery/pkg/util/wait" 35 "k8s.io/client-go/dynamic" 36 clientset "k8s.io/client-go/kubernetes" 37 "k8s.io/kubernetes/test/e2e/framework" 38 e2edeployment "k8s.io/kubernetes/test/e2e/framework/deployment" 39 "k8s.io/kubernetes/test/utils/crd" 40 "k8s.io/kubernetes/test/utils/format" 41 imageutils "k8s.io/kubernetes/test/utils/image" 42 admissionapi "k8s.io/pod-security-admission/api" 43 "k8s.io/utils/pointer" 44 45 apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 46 "k8s.io/apiextensions-apiserver/test/integration" 47 48 // ensure libs have a chance to initialize 49 _ "github.com/stretchr/testify/assert" 50 ) 51 52 const ( 53 secretCRDName = "sample-custom-resource-conversion-webhook-secret" 54 deploymentCRDName = "sample-crd-conversion-webhook-deployment" 55 serviceCRDName = "e2e-test-crd-conversion-webhook" 56 roleBindingCRDName = "crd-conversion-webhook-auth-reader" 57 ) 58 59 var apiVersions = []apiextensionsv1.CustomResourceDefinitionVersion{ 60 { 61 Name: "v1", 62 Served: true, 63 Storage: true, 64 Schema: &apiextensionsv1.CustomResourceValidation{ 65 OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ 66 Type: "object", 67 Properties: map[string]apiextensionsv1.JSONSchemaProps{ 68 "hostPort": {Type: "string"}, 69 }, 70 }, 71 }, 72 }, 73 { 74 Name: "v2", 75 Served: true, 76 Storage: false, 77 Schema: &apiextensionsv1.CustomResourceValidation{ 78 OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ 79 Type: "object", 80 Properties: map[string]apiextensionsv1.JSONSchemaProps{ 81 "host": {Type: "string"}, 82 "port": {Type: "string"}, 83 }, 84 }, 85 }, 86 }, 87 } 88 89 var alternativeAPIVersions = []apiextensionsv1.CustomResourceDefinitionVersion{ 90 { 91 Name: "v1", 92 Served: true, 93 Storage: false, 94 Schema: &apiextensionsv1.CustomResourceValidation{ 95 OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ 96 Type: "object", 97 Properties: map[string]apiextensionsv1.JSONSchemaProps{ 98 "hostPort": {Type: "string"}, 99 }, 100 }, 101 }, 102 }, 103 { 104 Name: "v2", 105 Served: true, 106 Storage: true, 107 Schema: &apiextensionsv1.CustomResourceValidation{ 108 OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ 109 Type: "object", 110 Properties: map[string]apiextensionsv1.JSONSchemaProps{ 111 "host": {Type: "string"}, 112 "port": {Type: "string"}, 113 }, 114 }, 115 }, 116 }, 117 } 118 119 var _ = SIGDescribe("CustomResourceConversionWebhook [Privileged:ClusterAdmin]", func() { 120 var certCtx *certContext 121 f := framework.NewDefaultFramework("crd-webhook") 122 f.NamespacePodSecurityLevel = admissionapi.LevelBaseline 123 servicePort := int32(9443) 124 containerPort := int32(9444) 125 126 ginkgo.BeforeEach(func(ctx context.Context) { 127 ginkgo.DeferCleanup(cleanCRDWebhookTest, f.ClientSet, f.Namespace.Name) 128 129 ginkgo.By("Setting up server cert") 130 certCtx = setupServerCert(f.Namespace.Name, serviceCRDName) 131 createAuthReaderRoleBindingForCRDConversion(ctx, f, f.Namespace.Name) 132 133 deployCustomResourceWebhookAndService(ctx, f, imageutils.GetE2EImage(imageutils.Agnhost), certCtx, servicePort, containerPort) 134 }) 135 136 /* 137 Release: v1.16 138 Testname: Custom Resource Definition Conversion Webhook, conversion custom resource 139 Description: Register a conversion webhook and a custom resource definition. Create a v1 custom 140 resource. Attempts to read it at v2 MUST succeed. 141 */ 142 framework.ConformanceIt("should be able to convert from CR v1 to CR v2", func(ctx context.Context) { 143 testcrd, err := crd.CreateMultiVersionTestCRD(f, "stable.example.com", func(crd *apiextensionsv1.CustomResourceDefinition) { 144 crd.Spec.Versions = apiVersions 145 crd.Spec.Conversion = &apiextensionsv1.CustomResourceConversion{ 146 Strategy: apiextensionsv1.WebhookConverter, 147 Webhook: &apiextensionsv1.WebhookConversion{ 148 ClientConfig: &apiextensionsv1.WebhookClientConfig{ 149 CABundle: certCtx.signingCert, 150 Service: &apiextensionsv1.ServiceReference{ 151 Namespace: f.Namespace.Name, 152 Name: serviceCRDName, 153 Path: pointer.String("/crdconvert"), 154 Port: pointer.Int32(servicePort), 155 }, 156 }, 157 ConversionReviewVersions: []string{"v1", "v1beta1"}, 158 }, 159 } 160 crd.Spec.PreserveUnknownFields = false 161 }) 162 if err != nil { 163 return 164 } 165 ginkgo.DeferCleanup(testcrd.CleanUp) 166 waitWebhookConversionReady(ctx, f, testcrd.Crd, testcrd.DynamicClients, "v2") 167 testCustomResourceConversionWebhook(ctx, f, testcrd.Crd, testcrd.DynamicClients) 168 }) 169 170 /* 171 Release: v1.16 172 Testname: Custom Resource Definition Conversion Webhook, convert mixed version list 173 Description: Register a conversion webhook and a custom resource definition. Create a custom resource stored at 174 v1. Change the custom resource definition storage to v2. Create a custom resource stored at v2. Attempt to list 175 the custom resources at v2; the list result MUST contain both custom resources at v2. 176 */ 177 framework.ConformanceIt("should be able to convert a non homogeneous list of CRs", func(ctx context.Context) { 178 testcrd, err := crd.CreateMultiVersionTestCRD(f, "stable.example.com", func(crd *apiextensionsv1.CustomResourceDefinition) { 179 crd.Spec.Versions = apiVersions 180 crd.Spec.Conversion = &apiextensionsv1.CustomResourceConversion{ 181 Strategy: apiextensionsv1.WebhookConverter, 182 Webhook: &apiextensionsv1.WebhookConversion{ 183 ClientConfig: &apiextensionsv1.WebhookClientConfig{ 184 CABundle: certCtx.signingCert, 185 Service: &apiextensionsv1.ServiceReference{ 186 Namespace: f.Namespace.Name, 187 Name: serviceCRDName, 188 Path: pointer.String("/crdconvert"), 189 Port: pointer.Int32(servicePort), 190 }, 191 }, 192 ConversionReviewVersions: []string{"v1", "v1beta1"}, 193 }, 194 } 195 crd.Spec.PreserveUnknownFields = false 196 }) 197 if err != nil { 198 return 199 } 200 ginkgo.DeferCleanup(testcrd.CleanUp) 201 waitWebhookConversionReady(ctx, f, testcrd.Crd, testcrd.DynamicClients, "v2") 202 testCRListConversion(ctx, f, testcrd) 203 }) 204 }) 205 206 func cleanCRDWebhookTest(ctx context.Context, client clientset.Interface, namespaceName string) { 207 _ = client.CoreV1().Services(namespaceName).Delete(ctx, serviceCRDName, metav1.DeleteOptions{}) 208 _ = client.AppsV1().Deployments(namespaceName).Delete(ctx, deploymentCRDName, metav1.DeleteOptions{}) 209 _ = client.CoreV1().Secrets(namespaceName).Delete(ctx, secretCRDName, metav1.DeleteOptions{}) 210 _ = client.RbacV1().RoleBindings("kube-system").Delete(ctx, roleBindingCRDName, metav1.DeleteOptions{}) 211 } 212 213 func createAuthReaderRoleBindingForCRDConversion(ctx context.Context, f *framework.Framework, namespace string) { 214 ginkgo.By("Create role binding to let cr conversion webhook read extension-apiserver-authentication") 215 client := f.ClientSet 216 // Create the role binding to allow the webhook read the extension-apiserver-authentication configmap 217 _, err := client.RbacV1().RoleBindings("kube-system").Create(ctx, &rbacv1.RoleBinding{ 218 ObjectMeta: metav1.ObjectMeta{ 219 Name: roleBindingCRDName, 220 }, 221 RoleRef: rbacv1.RoleRef{ 222 APIGroup: "", 223 Kind: "Role", 224 Name: "extension-apiserver-authentication-reader", 225 }, 226 227 Subjects: []rbacv1.Subject{ 228 { 229 Kind: "ServiceAccount", 230 Name: "default", 231 Namespace: namespace, 232 }, 233 }, 234 }, metav1.CreateOptions{}) 235 if err != nil && apierrors.IsAlreadyExists(err) { 236 framework.Logf("role binding %s already exists", roleBindingCRDName) 237 } else { 238 framework.ExpectNoError(err, "creating role binding %s:webhook to access configMap", namespace) 239 } 240 } 241 242 func deployCustomResourceWebhookAndService(ctx context.Context, f *framework.Framework, image string, certCtx *certContext, servicePort int32, containerPort int32) { 243 ginkgo.By("Deploying the custom resource conversion webhook pod") 244 client := f.ClientSet 245 246 // Creating the secret that contains the webhook's cert. 247 secret := &v1.Secret{ 248 ObjectMeta: metav1.ObjectMeta{ 249 Name: secretCRDName, 250 }, 251 Type: v1.SecretTypeOpaque, 252 Data: map[string][]byte{ 253 "tls.crt": certCtx.cert, 254 "tls.key": certCtx.key, 255 }, 256 } 257 namespace := f.Namespace.Name 258 _, err := client.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}) 259 framework.ExpectNoError(err, "creating secret %q in namespace %q", secretName, namespace) 260 261 // Create the deployment of the webhook 262 podLabels := map[string]string{"app": "sample-crd-conversion-webhook", "crd-webhook": "true"} 263 replicas := int32(1) 264 mounts := []v1.VolumeMount{ 265 { 266 Name: "crd-conversion-webhook-certs", 267 ReadOnly: true, 268 MountPath: "/webhook.local.config/certificates", 269 }, 270 } 271 volumes := []v1.Volume{ 272 { 273 Name: "crd-conversion-webhook-certs", 274 VolumeSource: v1.VolumeSource{ 275 Secret: &v1.SecretVolumeSource{SecretName: secretCRDName}, 276 }, 277 }, 278 } 279 containers := []v1.Container{ 280 { 281 Name: "sample-crd-conversion-webhook", 282 VolumeMounts: mounts, 283 Args: []string{ 284 "crd-conversion-webhook", 285 "--tls-cert-file=/webhook.local.config/certificates/tls.crt", 286 "--tls-private-key-file=/webhook.local.config/certificates/tls.key", 287 "-v=4", 288 // Use a non-default port for containers. 289 fmt.Sprintf("--port=%d", containerPort), 290 }, 291 ReadinessProbe: &v1.Probe{ 292 ProbeHandler: v1.ProbeHandler{ 293 HTTPGet: &v1.HTTPGetAction{ 294 Scheme: v1.URISchemeHTTPS, 295 Port: intstr.FromInt32(containerPort), 296 Path: "/readyz", 297 }, 298 }, 299 PeriodSeconds: 1, 300 SuccessThreshold: 1, 301 FailureThreshold: 30, 302 }, 303 Image: image, 304 Ports: []v1.ContainerPort{{ContainerPort: containerPort}}, 305 }, 306 } 307 d := e2edeployment.NewDeployment(deploymentCRDName, replicas, podLabels, "", "", appsv1.RollingUpdateDeploymentStrategyType) 308 d.Spec.Template.Spec.Containers = containers 309 d.Spec.Template.Spec.Volumes = volumes 310 311 deployment, err := client.AppsV1().Deployments(namespace).Create(ctx, d, metav1.CreateOptions{}) 312 framework.ExpectNoError(err, "creating deployment %s in namespace %s", deploymentCRDName, namespace) 313 314 ginkgo.By("Wait for the deployment to be ready") 315 316 err = e2edeployment.WaitForDeploymentRevisionAndImage(client, namespace, deploymentCRDName, "1", image) 317 framework.ExpectNoError(err, "waiting for the deployment of image %s in %s in %s to complete", image, deploymentCRDName, namespace) 318 319 err = e2edeployment.WaitForDeploymentComplete(client, deployment) 320 framework.ExpectNoError(err, "waiting for %s deployment status valid", deploymentCRDName) 321 322 ginkgo.By("Deploying the webhook service") 323 324 serviceLabels := map[string]string{"crd-webhook": "true"} 325 service := &v1.Service{ 326 ObjectMeta: metav1.ObjectMeta{ 327 Namespace: namespace, 328 Name: serviceCRDName, 329 Labels: map[string]string{"test": "crd-webhook"}, 330 }, 331 Spec: v1.ServiceSpec{ 332 Selector: serviceLabels, 333 Ports: []v1.ServicePort{ 334 { 335 Protocol: v1.ProtocolTCP, 336 Port: servicePort, 337 TargetPort: intstr.FromInt32(containerPort), 338 }, 339 }, 340 }, 341 } 342 _, err = client.CoreV1().Services(namespace).Create(ctx, service, metav1.CreateOptions{}) 343 framework.ExpectNoError(err, "creating service %s in namespace %s", serviceCRDName, namespace) 344 345 ginkgo.By("Verifying the service has paired with the endpoint") 346 err = framework.WaitForServiceEndpointsNum(ctx, client, namespace, serviceCRDName, 1, 1*time.Second, 30*time.Second) 347 framework.ExpectNoError(err, "waiting for service %s/%s have %d endpoint", namespace, serviceCRDName, 1) 348 } 349 350 func verifyV1Object(crd *apiextensionsv1.CustomResourceDefinition, obj *unstructured.Unstructured) { 351 gomega.Expect(obj.GetAPIVersion()).To(gomega.BeEquivalentTo(crd.Spec.Group + "/v1")) 352 hostPort, exists := obj.Object["hostPort"] 353 if !exists { 354 framework.Failf("HostPort not found.") 355 } 356 357 gomega.Expect(hostPort).To(gomega.BeEquivalentTo("localhost:8080")) 358 _, hostExists := obj.Object["host"] 359 if hostExists { 360 framework.Failf("Host should not have been declared.") 361 } 362 _, portExists := obj.Object["port"] 363 if portExists { 364 framework.Failf("Port should not have been declared.") 365 } 366 } 367 368 func verifyV2Object(crd *apiextensionsv1.CustomResourceDefinition, obj *unstructured.Unstructured) { 369 gomega.Expect(obj.GetAPIVersion()).To(gomega.BeEquivalentTo(crd.Spec.Group + "/v2")) 370 _, hostPortExists := obj.Object["hostPort"] 371 if hostPortExists { 372 framework.Failf("HostPort should not have been declared.") 373 } 374 host, hostExists := obj.Object["host"] 375 if !hostExists { 376 framework.Failf("Host declaration not found.") 377 } 378 gomega.Expect(host).To(gomega.BeEquivalentTo("localhost")) 379 port, portExists := obj.Object["port"] 380 if !portExists { 381 framework.Failf("Port declaration not found.") 382 } 383 gomega.Expect(port).To(gomega.BeEquivalentTo("8080")) 384 } 385 386 func testCustomResourceConversionWebhook(ctx context.Context, f *framework.Framework, crd *apiextensionsv1.CustomResourceDefinition, customResourceClients map[string]dynamic.ResourceInterface) { 387 name := "cr-instance-1" 388 ginkgo.By("Creating a v1 custom resource") 389 crInstance := &unstructured.Unstructured{ 390 Object: map[string]interface{}{ 391 "kind": crd.Spec.Names.Kind, 392 "apiVersion": crd.Spec.Group + "/v1", 393 "metadata": map[string]interface{}{ 394 "name": name, 395 "namespace": f.Namespace.Name, 396 }, 397 "hostPort": "localhost:8080", 398 }, 399 } 400 _, err := customResourceClients["v1"].Create(ctx, crInstance, metav1.CreateOptions{}) 401 framework.ExpectNoError(err) 402 ginkgo.By("v2 custom resource should be converted") 403 v2crd, err := customResourceClients["v2"].Get(ctx, name, metav1.GetOptions{}) 404 framework.ExpectNoError(err, "Getting v2 of custom resource %s", name) 405 verifyV2Object(crd, v2crd) 406 } 407 408 func testCRListConversion(ctx context.Context, f *framework.Framework, testCrd *crd.TestCrd) { 409 crd := testCrd.Crd 410 customResourceClients := testCrd.DynamicClients 411 name1 := "cr-instance-1" 412 name2 := "cr-instance-2" 413 ginkgo.By("Creating a v1 custom resource") 414 crInstance := &unstructured.Unstructured{ 415 Object: map[string]interface{}{ 416 "kind": crd.Spec.Names.Kind, 417 "apiVersion": crd.Spec.Group + "/v1", 418 "metadata": map[string]interface{}{ 419 "name": name1, 420 "namespace": f.Namespace.Name, 421 }, 422 "hostPort": "localhost:8080", 423 }, 424 } 425 _, err := customResourceClients["v1"].Create(ctx, crInstance, metav1.CreateOptions{}) 426 framework.ExpectNoError(err) 427 428 // Now cr-instance-1 is stored as v1. lets change storage version 429 crd, err = integration.UpdateV1CustomResourceDefinitionWithRetry(testCrd.APIExtensionClient, crd.Name, func(c *apiextensionsv1.CustomResourceDefinition) { 430 c.Spec.Versions = alternativeAPIVersions 431 }) 432 framework.ExpectNoError(err) 433 ginkgo.By("Create a v2 custom resource") 434 crInstance = &unstructured.Unstructured{ 435 Object: map[string]interface{}{ 436 "kind": crd.Spec.Names.Kind, 437 "apiVersion": crd.Spec.Group + "/v1", 438 "metadata": map[string]interface{}{ 439 "name": name2, 440 "namespace": f.Namespace.Name, 441 }, 442 "hostPort": "localhost:8080", 443 }, 444 } 445 446 // After changing a CRD, the resources for versions will be re-created that can be result in 447 // cancelled connection (e.g. "grpc connection closed" or "context canceled"). 448 // Just retrying fixes that. 449 // 450 // TODO: we have to wait for the storage version to become effective. Storage version changes are not instant. 451 for i := 0; i < 5; i++ { 452 _, err = customResourceClients["v1"].Create(ctx, crInstance, metav1.CreateOptions{}) 453 if err == nil { 454 break 455 } 456 } 457 framework.ExpectNoError(err) 458 459 // Now that we have a v1 and v2 object, both list operation in v1 and v2 should work as expected. 460 461 ginkgo.By("List CRs in v1") 462 list, err := customResourceClients["v1"].List(ctx, metav1.ListOptions{}) 463 framework.ExpectNoError(err) 464 gomega.Expect(list.Items).To(gomega.HaveLen(2)) 465 if !((list.Items[0].GetName() == name1 && list.Items[1].GetName() == name2) || 466 (list.Items[0].GetName() == name2 && list.Items[1].GetName() == name1)) { 467 framework.Failf("failed to find v1 objects with names %s and %s in the list: \n%s", name1, name2, format.Object(list.Items, 1)) 468 } 469 verifyV1Object(crd, &list.Items[0]) 470 verifyV1Object(crd, &list.Items[1]) 471 472 ginkgo.By("List CRs in v2") 473 list, err = customResourceClients["v2"].List(ctx, metav1.ListOptions{}) 474 framework.ExpectNoError(err) 475 gomega.Expect(list.Items).To(gomega.HaveLen(2)) 476 if !((list.Items[0].GetName() == name1 && list.Items[1].GetName() == name2) || 477 (list.Items[0].GetName() == name2 && list.Items[1].GetName() == name1)) { 478 framework.Failf("failed to find v2 objects with names %s and %s in the list: \n%s", name1, name2, format.Object(list.Items, 1)) 479 } 480 verifyV2Object(crd, &list.Items[0]) 481 verifyV2Object(crd, &list.Items[1]) 482 } 483 484 // waitWebhookConversionReady sends stub custom resource creation requests requiring conversion until one succeeds. 485 func waitWebhookConversionReady(ctx context.Context, f *framework.Framework, crd *apiextensionsv1.CustomResourceDefinition, customResourceClients map[string]dynamic.ResourceInterface, version string) { 486 framework.ExpectNoError(wait.PollUntilContextTimeout(ctx, 100*time.Millisecond, 30*time.Second, true, func(ctx context.Context) (bool, error) { 487 crInstance := &unstructured.Unstructured{ 488 Object: map[string]interface{}{ 489 "kind": crd.Spec.Names.Kind, 490 "apiVersion": crd.Spec.Group + "/" + version, 491 "metadata": map[string]interface{}{ 492 "name": f.UniqueName, 493 "namespace": f.Namespace.Name, 494 }, 495 }, 496 } 497 _, err := customResourceClients[version].Create(ctx, crInstance, metav1.CreateOptions{}) 498 if err != nil { 499 // tolerate clusters that do not set --enable-aggregator-routing and have to wait for kube-proxy 500 // to program the service network, during which conversion requests return errors 501 framework.Logf("error waiting for conversion to succeed during setup: %v", err) 502 return false, nil 503 } 504 505 framework.ExpectNoError(customResourceClients[version].Delete(ctx, crInstance.GetName(), metav1.DeleteOptions{}), "cleaning up stub object") 506 return true, nil 507 })) 508 }