k8s.io/kubernetes@v1.29.3/test/integration/apiserver/admissionwebhook/mutating_webhook_gvk_conversion_test.go (about) 1 /* 2 Copyright 2023 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 admissionwebhook 18 19 import ( 20 "context" 21 "crypto/tls" 22 "crypto/x509" 23 "encoding/json" 24 "io" 25 "net/http" 26 "net/http/httptest" 27 "sync" 28 "testing" 29 "time" 30 31 admissionv1 "k8s.io/api/admission/v1" 32 admissionregistrationv1 "k8s.io/api/admissionregistration/v1" 33 corev1 "k8s.io/api/core/v1" 34 apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 35 apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" 36 "k8s.io/apiextensions-apiserver/test/integration/fixtures" 37 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 38 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 39 "k8s.io/apimachinery/pkg/runtime" 40 "k8s.io/apimachinery/pkg/runtime/schema" 41 "k8s.io/apimachinery/pkg/runtime/serializer" 42 "k8s.io/apimachinery/pkg/types" 43 "k8s.io/apimachinery/pkg/util/wait" 44 genericfeatures "k8s.io/apiserver/pkg/features" 45 utilfeature "k8s.io/apiserver/pkg/util/feature" 46 "k8s.io/client-go/dynamic" 47 clientset "k8s.io/client-go/kubernetes" 48 featuregatetesting "k8s.io/component-base/featuregate/testing" 49 apiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" 50 "k8s.io/kubernetes/test/integration/etcd" 51 "k8s.io/kubernetes/test/integration/framework" 52 ) 53 54 var ( 55 runtimeSchemeGVKTest = runtime.NewScheme() 56 codecFactoryGVKTest = serializer.NewCodecFactory(runtimeSchemeGVKTest) 57 deserializerGVKTest = codecFactoryGVKTest.UniversalDeserializer() 58 ) 59 60 type admissionTypeChecker struct { 61 mu sync.Mutex 62 upCh chan struct{} 63 upOnce sync.Once 64 requests []*admissionv1.AdmissionRequest 65 } 66 67 func (r *admissionTypeChecker) Reset() chan struct{} { 68 r.mu.Lock() 69 defer r.mu.Unlock() 70 r.upCh = make(chan struct{}) 71 r.upOnce = sync.Once{} 72 r.requests = []*admissionv1.AdmissionRequest{} 73 return r.upCh 74 } 75 76 func (r *admissionTypeChecker) TypeCheck(req *admissionv1.AdmissionRequest, version string) *admissionv1.AdmissionResponse { 77 r.mu.Lock() 78 defer r.mu.Unlock() 79 r.requests = append(r.requests, req) 80 raw := req.Object.Raw 81 var into runtime.Object 82 if _, gvk, err := deserializerGVKTest.Decode(raw, nil, into); err != nil { 83 if gvk.Version != version { 84 return &admissionv1.AdmissionResponse{ 85 UID: req.UID, 86 Allowed: false, 87 } 88 } 89 } 90 91 return &admissionv1.AdmissionResponse{ 92 UID: req.UID, 93 Allowed: true, 94 } 95 } 96 97 func (r *admissionTypeChecker) MarkerReceived() { 98 r.mu.Lock() 99 defer r.mu.Unlock() 100 r.upOnce.Do(func() { 101 close(r.upCh) 102 }) 103 } 104 105 func newAdmissionTypeCheckerHandler(recorder *admissionTypeChecker) http.Handler { 106 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 107 defer r.Body.Close() 108 data, err := io.ReadAll(r.Body) 109 if err != nil { 110 http.Error(w, err.Error(), 400) 111 } 112 review := admissionv1.AdmissionReview{} 113 if err := json.Unmarshal(data, &review); err != nil { 114 http.Error(w, err.Error(), 400) 115 } 116 117 switch r.URL.Path { 118 case "/marker": 119 recorder.MarkerReceived() 120 return 121 case "/v1": 122 review.Response = recorder.TypeCheck(review.Request, "v1") 123 case "/v2": 124 review.Response = recorder.TypeCheck(review.Request, "v2") 125 } 126 127 w.Header().Set("Content-Type", "application/json") 128 if err := json.NewEncoder(w).Encode(review); err != nil { 129 http.Error(w, err.Error(), 400) 130 return 131 } 132 133 }) 134 } 135 136 // Test_MutatingWebhookConvertsGVKWithMatchPolicyEquivalent tests if a equivalent resource is properly converted between mutating webhooks 137 func Test_MutatingWebhookConvertsGVKWithMatchPolicyEquivalent(t *testing.T) { 138 139 roots := x509.NewCertPool() 140 if !roots.AppendCertsFromPEM(localhostCert) { 141 t.Fatal("Failed to append Cert from PEM") 142 } 143 cert, err := tls.X509KeyPair(localhostCert, localhostKey) 144 if err != nil { 145 t.Fatalf("Failed to build cert with error: %+v", err) 146 } 147 148 typeChecker := &admissionTypeChecker{} 149 150 webhookServer := httptest.NewUnstartedServer(newAdmissionTypeCheckerHandler(typeChecker)) 151 webhookServer.TLS = &tls.Config{ 152 RootCAs: roots, 153 Certificates: []tls.Certificate{cert}, 154 } 155 webhookServer.StartTLS() 156 defer webhookServer.Close() 157 158 upCh := typeChecker.Reset() 159 defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.AdmissionWebhookMatchConditions, true)() 160 server, err := apiservertesting.StartTestServer(t, nil, []string{ 161 "--disable-admission-plugins=ServiceAccount", 162 }, framework.SharedEtcd()) 163 if err != nil { 164 t.Fatal(err) 165 } 166 defer server.TearDownFn() 167 168 etcd.CreateTestCRDs(t, apiextensionsclientset.NewForConfigOrDie(server.ClientConfig), false, versionedCustomResourceDefinition()) 169 if err != nil { 170 t.Fatal(err) 171 } 172 173 config := server.ClientConfig 174 175 client, err := clientset.NewForConfig(config) 176 if err != nil { 177 t.Fatal(err) 178 } 179 180 // Write markers to a separate namespace to avoid cross-talk 181 markerNs := "marker" 182 _, err = client.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: markerNs}}, metav1.CreateOptions{}) 183 if err != nil { 184 t.Fatal(err) 185 } 186 187 // Create a marker object to use to check for the webhook configurations to be ready. 188 marker, err := client.CoreV1().Pods(markerNs).Create(context.TODO(), newMarkerPodGVKConversion(markerNs), metav1.CreateOptions{}) 189 if err != nil { 190 t.Fatal(err) 191 } 192 193 equivalent := admissionregistrationv1.Equivalent 194 ignore := admissionregistrationv1.Ignore 195 196 v1Endpoint := webhookServer.URL + "/v1" 197 markerEndpoint := webhookServer.URL + "/marker" 198 v2Endpoint := webhookServer.URL + "/v2" 199 mutatingWebhook := &admissionregistrationv1.MutatingWebhookConfiguration{ 200 ObjectMeta: metav1.ObjectMeta{ 201 Name: "admission.integration.test", 202 }, 203 Webhooks: []admissionregistrationv1.MutatingWebhook{ 204 { 205 Name: "admission.integration.test.v2", 206 Rules: []admissionregistrationv1.RuleWithOperations{{ 207 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.Create}, 208 Rule: admissionregistrationv1.Rule{ 209 APIGroups: []string{"awesome.example.com"}, 210 APIVersions: []string{"v2"}, 211 Resources: []string{"*/*"}, 212 }, 213 }}, 214 MatchPolicy: &equivalent, 215 ClientConfig: admissionregistrationv1.WebhookClientConfig{ 216 URL: &v2Endpoint, 217 CABundle: localhostCert, 218 }, 219 FailurePolicy: &ignore, 220 SideEffects: &noSideEffects, 221 AdmissionReviewVersions: []string{"v1"}, 222 MatchConditions: []admissionregistrationv1.MatchCondition{ 223 { 224 Name: "test-v2", 225 Expression: "object.apiVersion == 'awesome.example.com/v2'", 226 }, 227 }, 228 }, 229 { 230 Name: "admission.integration.test", 231 Rules: []admissionregistrationv1.RuleWithOperations{{ 232 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.Create}, 233 Rule: admissionregistrationv1.Rule{ 234 APIGroups: []string{"awesome.example.com"}, 235 APIVersions: []string{"v1"}, 236 Resources: []string{"*/*"}, 237 }, 238 }}, 239 MatchPolicy: &equivalent, 240 ClientConfig: admissionregistrationv1.WebhookClientConfig{ 241 URL: &v1Endpoint, 242 CABundle: localhostCert, 243 }, 244 SideEffects: &noSideEffects, 245 AdmissionReviewVersions: []string{"v1"}, 246 MatchConditions: []admissionregistrationv1.MatchCondition{ 247 { 248 Name: "test-v1", 249 Expression: "object.apiVersion == 'awesome.example.com/v1'", 250 }, 251 }, 252 }, 253 { 254 Name: "admission.integration.test.marker", 255 Rules: []admissionregistrationv1.RuleWithOperations{{ 256 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, 257 Rule: admissionregistrationv1.Rule{APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}}, 258 }}, 259 ClientConfig: admissionregistrationv1.WebhookClientConfig{ 260 URL: &markerEndpoint, 261 CABundle: localhostCert, 262 }, 263 NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{ 264 corev1.LabelMetadataName: "marker", 265 }}, 266 ObjectSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"marker": "true"}}, 267 SideEffects: &noSideEffects, 268 AdmissionReviewVersions: []string{"v1"}, 269 }, 270 }, 271 } 272 273 mutatingCfg, err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().Create(context.TODO(), mutatingWebhook, metav1.CreateOptions{}) 274 if err != nil { 275 t.Fatal(err) 276 } 277 defer func() { 278 err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().Delete(context.TODO(), mutatingCfg.GetName(), metav1.DeleteOptions{}) 279 if err != nil { 280 t.Fatal(err) 281 } 282 }() 283 284 // wait until new webhook is called the first time 285 if err := wait.PollImmediate(time.Millisecond*5, wait.ForeverTestTimeout, func() (bool, error) { 286 _, err = client.CoreV1().Pods(markerNs).Patch(context.TODO(), marker.Name, types.JSONPatchType, []byte("[]"), metav1.PatchOptions{}) 287 select { 288 case <-upCh: 289 return true, nil 290 default: 291 t.Logf("Waiting for webhook to become effective, getting marker object: %v", err) 292 return false, nil 293 } 294 }); err != nil { 295 t.Fatal(err) 296 } 297 dynamicClient, err := dynamic.NewForConfig(config) 298 if err != nil { 299 t.Fatal(err) 300 } 301 302 v1Resource := &unstructured.Unstructured{ 303 Object: map[string]interface{}{ 304 "apiVersion": "awesome.example.com" + "/" + "v1", 305 "kind": "Panda", 306 "metadata": map[string]interface{}{ 307 "name": "v1-bears", 308 }, 309 }, 310 } 311 312 v2Resource := &unstructured.Unstructured{ 313 Object: map[string]interface{}{ 314 "apiVersion": "awesome.example.com" + "/" + "v2", 315 "kind": "Panda", 316 "metadata": map[string]interface{}{ 317 "name": "v2-bears", 318 }, 319 }, 320 } 321 322 _, err = dynamicClient.Resource(schema.GroupVersionResource{Group: "awesome.example.com", Version: "v1", Resource: "pandas"}).Create(context.TODO(), v1Resource, metav1.CreateOptions{}) 323 if err != nil { 324 t.Errorf("error1 %v", err.Error()) 325 } 326 327 _, err = dynamicClient.Resource(schema.GroupVersionResource{Group: "awesome.example.com", Version: "v2", Resource: "pandas"}).Create(context.TODO(), v2Resource, metav1.CreateOptions{}) 328 if err != nil { 329 t.Errorf("error2 %v", err.Error()) 330 } 331 332 if len(typeChecker.requests) != 4 { 333 t.Errorf("expected 4 request got %v", len(typeChecker.requests)) 334 } 335 } 336 337 func newMarkerPodGVKConversion(namespace string) *corev1.Pod { 338 return &corev1.Pod{ 339 ObjectMeta: metav1.ObjectMeta{ 340 Namespace: namespace, 341 Name: "marker", 342 Labels: map[string]string{ 343 "marker": "true", 344 }, 345 }, 346 Spec: corev1.PodSpec{ 347 Containers: []corev1.Container{{ 348 Name: "fake-name", 349 Image: "fakeimage", 350 }}, 351 }, 352 } 353 } 354 355 // Copied from etcd.GetCustomResourceDefinitionData 356 func versionedCustomResourceDefinition() *apiextensionsv1.CustomResourceDefinition { 357 return &apiextensionsv1.CustomResourceDefinition{ 358 ObjectMeta: metav1.ObjectMeta{ 359 Name: "pandas.awesome.example.com", 360 }, 361 Spec: apiextensionsv1.CustomResourceDefinitionSpec{ 362 Group: "awesome.example.com", 363 Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ 364 { 365 Name: "v1", 366 Served: true, 367 Storage: true, 368 Schema: fixtures.AllowAllSchema(), 369 Subresources: &apiextensionsv1.CustomResourceSubresources{ 370 Status: &apiextensionsv1.CustomResourceSubresourceStatus{}, 371 Scale: &apiextensionsv1.CustomResourceSubresourceScale{ 372 SpecReplicasPath: ".spec.replicas", 373 StatusReplicasPath: ".status.replicas", 374 LabelSelectorPath: func() *string { path := ".status.selector"; return &path }(), 375 }, 376 }, 377 }, 378 { 379 Name: "v2", 380 Served: true, 381 Storage: false, 382 Schema: fixtures.AllowAllSchema(), 383 Subresources: &apiextensionsv1.CustomResourceSubresources{ 384 Status: &apiextensionsv1.CustomResourceSubresourceStatus{}, 385 Scale: &apiextensionsv1.CustomResourceSubresourceScale{ 386 SpecReplicasPath: ".spec.replicas", 387 StatusReplicasPath: ".status.replicas", 388 LabelSelectorPath: func() *string { path := ".status.selector"; return &path }(), 389 }, 390 }, 391 }, 392 }, 393 Scope: apiextensionsv1.ClusterScoped, 394 Names: apiextensionsv1.CustomResourceDefinitionNames{ 395 Plural: "pandas", 396 Kind: "Panda", 397 }, 398 }, 399 } 400 }