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