k8s.io/kubernetes@v1.31.0-alpha.0.0.20240520171757-56147500dadc/test/integration/apiserver/admissionwebhook/match_conditions_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 "strconv" 28 "strings" 29 "sync" 30 "testing" 31 "time" 32 33 admissionv1 "k8s.io/api/admission/v1" 34 admissionregistrationv1 "k8s.io/api/admissionregistration/v1" 35 corev1 "k8s.io/api/core/v1" 36 apierrors "k8s.io/apimachinery/pkg/api/errors" 37 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 38 "k8s.io/apimachinery/pkg/types" 39 "k8s.io/apimachinery/pkg/util/wait" 40 genericfeatures "k8s.io/apiserver/pkg/features" 41 utilfeature "k8s.io/apiserver/pkg/util/feature" 42 clientset "k8s.io/client-go/kubernetes" 43 featuregatetesting "k8s.io/component-base/featuregate/testing" 44 apiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" 45 "k8s.io/kubernetes/test/integration/framework" 46 ) 47 48 type admissionRecorder struct { 49 mu sync.Mutex 50 upCh chan struct{} 51 upOnce sync.Once 52 requests []*admissionv1.AdmissionRequest 53 } 54 55 func (r *admissionRecorder) Record(req *admissionv1.AdmissionRequest) { 56 r.mu.Lock() 57 defer r.mu.Unlock() 58 r.requests = append(r.requests, req) 59 } 60 61 func (r *admissionRecorder) MarkerReceived() { 62 r.mu.Lock() 63 defer r.mu.Unlock() 64 r.upOnce.Do(func() { 65 close(r.upCh) 66 }) 67 } 68 69 func (r *admissionRecorder) Reset() chan struct{} { 70 r.mu.Lock() 71 defer r.mu.Unlock() 72 r.requests = []*admissionv1.AdmissionRequest{} 73 r.upCh = make(chan struct{}) 74 r.upOnce = sync.Once{} 75 return r.upCh 76 } 77 78 func newMatchConditionHandler(recorder *admissionRecorder) http.Handler { 79 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 80 defer r.Body.Close() 81 data, err := io.ReadAll(r.Body) 82 if err != nil { 83 http.Error(w, err.Error(), 400) 84 } 85 review := admissionv1.AdmissionReview{} 86 if err := json.Unmarshal(data, &review); err != nil { 87 http.Error(w, err.Error(), 400) 88 } 89 90 review.Response = &admissionv1.AdmissionResponse{ 91 Allowed: true, 92 UID: review.Request.UID, 93 Result: &metav1.Status{Message: "admitted"}, 94 } 95 96 w.Header().Set("Content-Type", "application/json") 97 if err := json.NewEncoder(w).Encode(review); err != nil { 98 http.Error(w, err.Error(), 400) 99 return 100 } 101 102 switch r.URL.Path { 103 case "/marker": 104 recorder.MarkerReceived() 105 return 106 } 107 108 recorder.Record(review.Request) 109 }) 110 } 111 112 // TestMatchConditions tests ValidatingWebhookConfigurations and MutatingWebhookConfigurations that validates different cases of matchCondition fields 113 func TestMatchConditions(t *testing.T) { 114 featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.StrictCostEnforcementForWebhooks, false) 115 fail := admissionregistrationv1.Fail 116 ignore := admissionregistrationv1.Ignore 117 118 testcases := []struct { 119 name string 120 matchConditions []admissionregistrationv1.MatchCondition 121 pods []*corev1.Pod 122 matchedPods []*corev1.Pod 123 expectErrorPod bool 124 failPolicy *admissionregistrationv1.FailurePolicyType 125 errMessage string 126 }{ 127 { 128 name: "pods in namespace kube-system is ignored", 129 matchConditions: []admissionregistrationv1.MatchCondition{ 130 { 131 Name: "pods-in-kube-system-exempt.kubernetes.io", 132 Expression: "object.metadata.namespace != 'kube-system'", 133 }, 134 }, 135 pods: []*corev1.Pod{ 136 matchConditionsTestPod("test1", "kube-system"), 137 matchConditionsTestPod("test2", "default"), 138 }, 139 matchedPods: []*corev1.Pod{ 140 matchConditionsTestPod("test2", "default"), 141 }, 142 }, 143 { 144 name: "matchConditions are ANDed together", 145 matchConditions: []admissionregistrationv1.MatchCondition{ 146 { 147 Name: "pods-in-kube-system-exempt.kubernetes.io", 148 Expression: "object.metadata.namespace != 'kube-system'", 149 }, 150 { 151 Name: "pods-with-name-test1.kubernetes.io", 152 Expression: "object.metadata.name == 'test1'", 153 }, 154 }, 155 pods: []*corev1.Pod{ 156 matchConditionsTestPod("test1", "kube-system"), 157 matchConditionsTestPod("test1", "default"), 158 matchConditionsTestPod("test2", "default"), 159 }, 160 matchedPods: []*corev1.Pod{ 161 matchConditionsTestPod("test1", "default"), 162 }, 163 }, 164 { 165 name: "mix of true, error and false should not match and not call webhook", 166 matchConditions: []admissionregistrationv1.MatchCondition{ 167 { 168 Name: "test1", 169 Expression: "object.nonExistentProperty == 'someval'", 170 }, 171 { 172 Name: "test2", 173 Expression: "true", 174 }, 175 { 176 Name: "test3", 177 Expression: "false", 178 }, 179 { 180 Name: "test4", 181 Expression: "true", 182 }, 183 { 184 Name: "test5", 185 Expression: "object.nonExistentProperty == 'someval'", 186 }, 187 }, 188 pods: []*corev1.Pod{ 189 matchConditionsTestPod("test1", "kube-system"), 190 matchConditionsTestPod("test2", "default"), 191 }, 192 matchedPods: []*corev1.Pod{}, 193 expectErrorPod: false, 194 }, 195 { 196 name: "mix of true and error should reject request without fail policy", 197 matchConditions: []admissionregistrationv1.MatchCondition{ 198 { 199 Name: "test1", 200 Expression: "object.nonExistentProperty == 'someval'", 201 }, 202 { 203 Name: "test2", 204 Expression: "true", 205 }, 206 { 207 Name: "test4", 208 Expression: "true", 209 }, 210 { 211 Name: "test5", 212 Expression: "object.nonExistentProperty == 'someval'", 213 }, 214 }, 215 pods: []*corev1.Pod{ 216 matchConditionsTestPod("test1", "kube-system"), 217 matchConditionsTestPod("test2", "default"), 218 }, 219 matchedPods: []*corev1.Pod{}, 220 expectErrorPod: true, 221 }, 222 { 223 name: "mix of true and error should reject request with fail policy fail", 224 matchConditions: []admissionregistrationv1.MatchCondition{ 225 { 226 Name: "test1", 227 Expression: "object.nonExistentProperty == 'someval'", 228 }, 229 { 230 Name: "test2", 231 Expression: "true", 232 }, 233 { 234 Name: "test4", 235 Expression: "true", 236 }, 237 { 238 Name: "test5", 239 Expression: "object.nonExistentProperty == 'someval'", 240 }, 241 }, 242 pods: []*corev1.Pod{ 243 matchConditionsTestPod("test1", "kube-system"), 244 matchConditionsTestPod("test2", "default"), 245 }, 246 matchedPods: []*corev1.Pod{}, 247 failPolicy: &fail, 248 expectErrorPod: true, 249 }, 250 { 251 name: "mix of true and error should match request and call webhook with fail policy ignore", 252 matchConditions: []admissionregistrationv1.MatchCondition{ 253 { 254 Name: "tes1", 255 Expression: "object.nonExistentProperty == 'someval'", 256 }, 257 { 258 Name: "test2", 259 Expression: "true", 260 }, 261 { 262 Name: "test4", 263 Expression: "true", 264 }, 265 { 266 Name: "test5", 267 Expression: "object.nonExistentProperty == 'someval'", 268 }, 269 }, 270 pods: []*corev1.Pod{ 271 matchConditionsTestPod("test1", "kube-system"), 272 matchConditionsTestPod("test2", "default"), 273 }, 274 matchedPods: []*corev1.Pod{}, 275 failPolicy: &ignore, 276 }, 277 { 278 name: "has access to oldObject", 279 matchConditions: []admissionregistrationv1.MatchCondition{ 280 { 281 Name: "old-object-is-null.kubernetes.io", 282 Expression: "oldObject == null", 283 }, 284 }, 285 pods: []*corev1.Pod{ 286 matchConditionsTestPod("test2", "default"), 287 }, 288 matchedPods: []*corev1.Pod{ 289 matchConditionsTestPod("test2", "default"), 290 }, 291 }, 292 { 293 name: "without strict cost enforcement: Authz check does not exceed per call limit", 294 matchConditions: []admissionregistrationv1.MatchCondition{ 295 { 296 Name: "test1", 297 Expression: "authorizer.group('').resource('pods').name('test1').check('create').allowed() && authorizer.group('').resource('pods').name('test1').check('create').allowed() && authorizer.group('').resource('pods').name('test1').check('create').allowed()", 298 }, 299 }, 300 pods: []*corev1.Pod{ 301 matchConditionsTestPod("test1", "kube-system"), 302 }, 303 matchedPods: []*corev1.Pod{ 304 matchConditionsTestPod("test1", "kube-system"), 305 }, 306 failPolicy: &fail, 307 expectErrorPod: false, 308 }, 309 { 310 name: "without strict cost enforcement: Authz check does not exceed overall cost limit", 311 matchConditions: generateMatchConditionsWithAuthzCheck(8, "authorizer.group('').resource('pods').name('test1').check('create').allowed() && authorizer.group('').resource('pods').name('test1').check('create').allowed()"), 312 pods: []*corev1.Pod{ 313 matchConditionsTestPod("test1", "kube-system"), 314 }, 315 matchedPods: []*corev1.Pod{ 316 matchConditionsTestPod("test1", "kube-system"), 317 }, 318 failPolicy: &fail, 319 expectErrorPod: false, 320 }, 321 } 322 323 roots := x509.NewCertPool() 324 if !roots.AppendCertsFromPEM(localhostCert) { 325 t.Fatal("Failed to append Cert from PEM") 326 } 327 cert, err := tls.X509KeyPair(localhostCert, localhostKey) 328 if err != nil { 329 t.Fatalf("Failed to build cert with error: %+v", err) 330 } 331 332 recorder := &admissionRecorder{requests: []*admissionv1.AdmissionRequest{}} 333 334 webhookServer := httptest.NewUnstartedServer(newMatchConditionHandler(recorder)) 335 webhookServer.TLS = &tls.Config{ 336 RootCAs: roots, 337 Certificates: []tls.Certificate{cert}, 338 } 339 webhookServer.StartTLS() 340 defer webhookServer.Close() 341 342 dryRunCreate := metav1.CreateOptions{ 343 DryRun: []string{metav1.DryRunAll}, 344 } 345 346 for _, testcase := range testcases { 347 t.Run(testcase.name, func(t *testing.T) { 348 upCh := recorder.Reset() 349 350 server, err := apiservertesting.StartTestServer(t, nil, []string{ 351 "--disable-admission-plugins=ServiceAccount", 352 }, framework.SharedEtcd()) 353 if err != nil { 354 t.Fatal(err) 355 } 356 defer server.TearDownFn() 357 358 config := server.ClientConfig 359 360 client, err := clientset.NewForConfig(config) 361 if err != nil { 362 t.Fatal(err) 363 } 364 365 // Write markers to a separate namespace to avoid cross-talk 366 markerNs := "marker" 367 _, err = client.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: markerNs}}, metav1.CreateOptions{}) 368 if err != nil { 369 t.Fatal(err) 370 } 371 372 // Create a marker object to use to check for the webhook configurations to be ready. 373 marker, err := client.CoreV1().Pods(markerNs).Create(context.TODO(), newMarkerPod(markerNs), metav1.CreateOptions{}) 374 if err != nil { 375 t.Fatal(err) 376 } 377 378 endpoint := webhookServer.URL 379 markerEndpoint := webhookServer.URL + "/marker" 380 validatingwebhook := &admissionregistrationv1.ValidatingWebhookConfiguration{ 381 ObjectMeta: metav1.ObjectMeta{ 382 Name: "admission.integration.test", 383 }, 384 Webhooks: []admissionregistrationv1.ValidatingWebhook{ 385 { 386 Name: "admission.integration.test", 387 Rules: []admissionregistrationv1.RuleWithOperations{{ 388 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.Create}, 389 Rule: admissionregistrationv1.Rule{ 390 APIGroups: []string{""}, 391 APIVersions: []string{"v1"}, 392 Resources: []string{"pods"}, 393 }, 394 }}, 395 ClientConfig: admissionregistrationv1.WebhookClientConfig{ 396 URL: &endpoint, 397 CABundle: localhostCert, 398 }, 399 // ignore pods in the marker namespace 400 NamespaceSelector: &metav1.LabelSelector{ 401 MatchExpressions: []metav1.LabelSelectorRequirement{ 402 { 403 Key: corev1.LabelMetadataName, 404 Operator: metav1.LabelSelectorOpNotIn, 405 Values: []string{"marker"}, 406 }, 407 }}, 408 FailurePolicy: testcase.failPolicy, 409 SideEffects: &noSideEffects, 410 AdmissionReviewVersions: []string{"v1"}, 411 MatchConditions: testcase.matchConditions, 412 }, 413 { 414 Name: "admission.integration.test.marker", 415 Rules: []admissionregistrationv1.RuleWithOperations{{ 416 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, 417 Rule: admissionregistrationv1.Rule{APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}}, 418 }}, 419 ClientConfig: admissionregistrationv1.WebhookClientConfig{ 420 URL: &markerEndpoint, 421 CABundle: localhostCert, 422 }, 423 NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{ 424 corev1.LabelMetadataName: "marker", 425 }}, 426 ObjectSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"marker": "true"}}, 427 FailurePolicy: testcase.failPolicy, 428 SideEffects: &noSideEffects, 429 AdmissionReviewVersions: []string{"v1"}, 430 }, 431 }, 432 } 433 434 validatingcfg, err := client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Create(context.TODO(), validatingwebhook, metav1.CreateOptions{}) 435 if err != nil { 436 t.Fatal(err) 437 } 438 439 vhwHasBeenCleanedUp := false 440 defer func() { 441 if !vhwHasBeenCleanedUp { 442 err := client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Delete(context.TODO(), validatingcfg.GetName(), metav1.DeleteOptions{}) 443 if err != nil { 444 t.Fatal(err) 445 } 446 } 447 }() 448 449 // wait until new webhook is called the first time 450 if err := wait.PollImmediate(time.Millisecond*5, wait.ForeverTestTimeout, func() (bool, error) { 451 _, err = client.CoreV1().Pods(markerNs).Patch(context.TODO(), marker.Name, types.JSONPatchType, []byte("[]"), metav1.PatchOptions{}) 452 select { 453 case <-upCh: 454 return true, nil 455 default: 456 t.Logf("Waiting for webhook to become effective, getting marker object: %v", err) 457 return false, nil 458 } 459 }); err != nil { 460 t.Fatal(err) 461 } 462 463 for _, pod := range testcase.pods { 464 _, err := client.CoreV1().Pods(pod.Namespace).Create(context.TODO(), pod, dryRunCreate) 465 if !testcase.expectErrorPod && err != nil { 466 t.Fatalf("unexpected error creating test pod: %v", err) 467 } else if testcase.expectErrorPod && err == nil { 468 t.Fatal("expected error creating pods") 469 } else if testcase.expectErrorPod && err != nil && !strings.Contains(err.Error(), testcase.errMessage) { 470 t.Fatalf("expected error message includes: %v, but get %v", testcase.errMessage, err) 471 } 472 } 473 474 if len(recorder.requests) != len(testcase.matchedPods) { 475 t.Errorf("unexpected requests %v, expected %v", recorder.requests, testcase.matchedPods) 476 } 477 478 for i, request := range recorder.requests { 479 if request.Name != testcase.matchedPods[i].Name { 480 t.Errorf("unexpected pod name %v, expected %v", request.Name, testcase.matchedPods[i].Name) 481 } 482 if request.Namespace != testcase.matchedPods[i].Namespace { 483 t.Errorf("unexpected pod namespace %v, expected %v", request.Namespace, testcase.matchedPods[i].Namespace) 484 } 485 } 486 487 // Reset and rerun against mutating webhook configuration 488 // TODO: private helper function for validation after creating vwh or mwh 489 upCh = recorder.Reset() 490 err = client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Delete(context.TODO(), validatingcfg.GetName(), metav1.DeleteOptions{}) 491 if err != nil { 492 t.Fatal(err) 493 } else { 494 vhwHasBeenCleanedUp = true 495 } 496 497 mutatingwebhook := &admissionregistrationv1.MutatingWebhookConfiguration{ 498 ObjectMeta: metav1.ObjectMeta{ 499 Name: "admission.integration.test", 500 }, 501 Webhooks: []admissionregistrationv1.MutatingWebhook{ 502 { 503 Name: "admission.integration.test", 504 Rules: []admissionregistrationv1.RuleWithOperations{{ 505 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.Create}, 506 Rule: admissionregistrationv1.Rule{ 507 APIGroups: []string{""}, 508 APIVersions: []string{"v1"}, 509 Resources: []string{"pods"}, 510 }, 511 }}, 512 ClientConfig: admissionregistrationv1.WebhookClientConfig{ 513 URL: &endpoint, 514 CABundle: localhostCert, 515 }, 516 // ignore pods in the marker namespace 517 NamespaceSelector: &metav1.LabelSelector{ 518 MatchExpressions: []metav1.LabelSelectorRequirement{ 519 { 520 Key: corev1.LabelMetadataName, 521 Operator: metav1.LabelSelectorOpNotIn, 522 Values: []string{"marker"}, 523 }, 524 }}, 525 FailurePolicy: testcase.failPolicy, 526 SideEffects: &noSideEffects, 527 AdmissionReviewVersions: []string{"v1"}, 528 MatchConditions: testcase.matchConditions, 529 }, 530 { 531 Name: "admission.integration.test.marker", 532 Rules: []admissionregistrationv1.RuleWithOperations{{ 533 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, 534 Rule: admissionregistrationv1.Rule{APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}}, 535 }}, 536 ClientConfig: admissionregistrationv1.WebhookClientConfig{ 537 URL: &markerEndpoint, 538 CABundle: localhostCert, 539 }, 540 NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{ 541 corev1.LabelMetadataName: "marker", 542 }}, 543 ObjectSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"marker": "true"}}, 544 FailurePolicy: testcase.failPolicy, 545 SideEffects: &noSideEffects, 546 AdmissionReviewVersions: []string{"v1"}, 547 }, 548 }, 549 } 550 551 mutatingcfg, err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().Create(context.TODO(), mutatingwebhook, metav1.CreateOptions{}) 552 if err != nil { 553 t.Fatal(err) 554 } 555 defer func() { 556 err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().Delete(context.TODO(), mutatingcfg.GetName(), metav1.DeleteOptions{}) 557 if err != nil { 558 t.Fatal(err) 559 } 560 }() 561 562 // wait until new webhook is called the first time 563 if err := wait.PollUntilContextTimeout(context.Background(), time.Millisecond*5, wait.ForeverTestTimeout, true, func(_ context.Context) (bool, error) { 564 _, err = client.CoreV1().Pods(markerNs).Patch(context.TODO(), marker.Name, types.JSONPatchType, []byte("[]"), metav1.PatchOptions{}) 565 select { 566 case <-upCh: 567 return true, nil 568 default: 569 t.Logf("Waiting for webhook to become effective, getting marker object: %v", err) 570 return false, nil 571 } 572 }); err != nil { 573 t.Fatal(err) 574 } 575 576 for _, pod := range testcase.pods { 577 _, err = client.CoreV1().Pods(pod.Namespace).Create(context.TODO(), pod, dryRunCreate) 578 if testcase.expectErrorPod == false && err != nil { 579 t.Fatalf("unexpected error creating test pod: %v", err) 580 } else if testcase.expectErrorPod == true && err == nil { 581 t.Fatal("expected error creating pods") 582 } 583 } 584 585 if len(recorder.requests) != len(testcase.matchedPods) { 586 t.Errorf("unexpected requests %v, expected %v", recorder.requests, testcase.matchedPods) 587 } 588 589 for i, request := range recorder.requests { 590 if request.Name != testcase.matchedPods[i].Name { 591 t.Errorf("unexpected pod name %v, expected %v", request.Name, testcase.matchedPods[i].Name) 592 } 593 if request.Namespace != testcase.matchedPods[i].Namespace { 594 t.Errorf("unexpected pod namespace %v, expected %v", request.Namespace, testcase.matchedPods[i].Namespace) 595 } 596 } 597 }) 598 } 599 } 600 601 func TestMatchConditionsWithStrictCostEnforcement(t *testing.T) { 602 featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.StrictCostEnforcementForWebhooks, true) 603 604 testcases := []struct { 605 name string 606 matchConditions []admissionregistrationv1.MatchCondition 607 pods []*corev1.Pod 608 matchedPods []*corev1.Pod 609 expectErrorPod bool 610 failPolicy *admissionregistrationv1.FailurePolicyType 611 errMessage string 612 }{ 613 { 614 name: "with strict cost enforcement: exceed per call limit should reject request with fail policy fail", 615 matchConditions: []admissionregistrationv1.MatchCondition{ 616 { 617 Name: "test1", 618 Expression: "authorizer.group('').resource('pods').namespace('default').check('create').allowed() && authorizer.group('').resource('pods').namespace('default').check('create').allowed() && authorizer.group('').resource('pods').namespace('default').check('create').allowed()", 619 }, 620 }, 621 pods: []*corev1.Pod{ 622 matchConditionsTestPod("test1", "default"), 623 }, 624 matchedPods: []*corev1.Pod{}, 625 expectErrorPod: true, 626 errMessage: "operation cancelled: actual cost limit exceeded", 627 }, 628 { 629 name: "with strict cost enforcement: exceed overall cost limit should reject request with fail policy fail", 630 matchConditions: generateMatchConditionsWithAuthzCheck(8, "authorizer.group('').resource('pods').name('test1').check('create').allowed() && authorizer.group('').resource('pods').name('test1').check('create').allowed()"), 631 pods: []*corev1.Pod{ 632 matchConditionsTestPod("test1", "kube-system"), 633 }, 634 matchedPods: []*corev1.Pod{}, 635 expectErrorPod: true, 636 errMessage: "validation failed due to running out of cost budget, no further validation rules will be run", 637 }, 638 } 639 640 roots := x509.NewCertPool() 641 if !roots.AppendCertsFromPEM(localhostCert) { 642 t.Fatal("Failed to append Cert from PEM") 643 } 644 cert, err := tls.X509KeyPair(localhostCert, localhostKey) 645 if err != nil { 646 t.Fatalf("Failed to build cert with error: %+v", err) 647 } 648 649 recorder := &admissionRecorder{requests: []*admissionv1.AdmissionRequest{}} 650 651 webhookServer := httptest.NewUnstartedServer(newMatchConditionHandler(recorder)) 652 webhookServer.TLS = &tls.Config{ 653 RootCAs: roots, 654 Certificates: []tls.Certificate{cert}, 655 } 656 webhookServer.StartTLS() 657 defer webhookServer.Close() 658 659 dryRunCreate := metav1.CreateOptions{ 660 DryRun: []string{metav1.DryRunAll}, 661 } 662 663 for _, testcase := range testcases { 664 t.Run(testcase.name, func(t *testing.T) { 665 upCh := recorder.Reset() 666 server, err := apiservertesting.StartTestServer(t, nil, []string{ 667 "--disable-admission-plugins=ServiceAccount", 668 }, framework.SharedEtcd()) 669 if err != nil { 670 t.Fatal(err) 671 } 672 defer server.TearDownFn() 673 674 config := server.ClientConfig 675 676 client, err := clientset.NewForConfig(config) 677 if err != nil { 678 t.Fatal(err) 679 } 680 681 // Write markers to a separate namespace to avoid cross-talk 682 markerNs := "marker" 683 _, err = client.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: markerNs}}, metav1.CreateOptions{}) 684 if err != nil { 685 t.Fatal(err) 686 } 687 688 // Create a marker object to use to check for the webhook configurations to be ready. 689 marker, err := client.CoreV1().Pods(markerNs).Create(context.TODO(), newMarkerPod(markerNs), metav1.CreateOptions{}) 690 if err != nil { 691 t.Fatal(err) 692 } 693 694 endpoint := webhookServer.URL 695 markerEndpoint := webhookServer.URL + "/marker" 696 validatingwebhook := &admissionregistrationv1.ValidatingWebhookConfiguration{ 697 ObjectMeta: metav1.ObjectMeta{ 698 Name: "admission.integration.test", 699 }, 700 Webhooks: []admissionregistrationv1.ValidatingWebhook{ 701 { 702 Name: "admission.integration.test", 703 Rules: []admissionregistrationv1.RuleWithOperations{{ 704 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.Create}, 705 Rule: admissionregistrationv1.Rule{ 706 APIGroups: []string{""}, 707 APIVersions: []string{"v1"}, 708 Resources: []string{"pods"}, 709 }, 710 }}, 711 ClientConfig: admissionregistrationv1.WebhookClientConfig{ 712 URL: &endpoint, 713 CABundle: localhostCert, 714 }, 715 // ignore pods in the marker namespace 716 NamespaceSelector: &metav1.LabelSelector{ 717 MatchExpressions: []metav1.LabelSelectorRequirement{ 718 { 719 Key: corev1.LabelMetadataName, 720 Operator: metav1.LabelSelectorOpNotIn, 721 Values: []string{"marker"}, 722 }, 723 }}, 724 FailurePolicy: testcase.failPolicy, 725 SideEffects: &noSideEffects, 726 AdmissionReviewVersions: []string{"v1"}, 727 MatchConditions: testcase.matchConditions, 728 }, 729 { 730 Name: "admission.integration.test.marker", 731 Rules: []admissionregistrationv1.RuleWithOperations{{ 732 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, 733 Rule: admissionregistrationv1.Rule{APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}}, 734 }}, 735 ClientConfig: admissionregistrationv1.WebhookClientConfig{ 736 URL: &markerEndpoint, 737 CABundle: localhostCert, 738 }, 739 NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{ 740 corev1.LabelMetadataName: "marker", 741 }}, 742 ObjectSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"marker": "true"}}, 743 FailurePolicy: testcase.failPolicy, 744 SideEffects: &noSideEffects, 745 AdmissionReviewVersions: []string{"v1"}, 746 }, 747 }, 748 } 749 750 validatingcfg, err := client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Create(context.TODO(), validatingwebhook, metav1.CreateOptions{}) 751 if err != nil { 752 t.Fatal(err) 753 } 754 755 vhwHasBeenCleanedUp := false 756 defer func() { 757 if !vhwHasBeenCleanedUp { 758 err := client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Delete(context.TODO(), validatingcfg.GetName(), metav1.DeleteOptions{}) 759 if err != nil { 760 t.Fatal(err) 761 } 762 } 763 }() 764 765 // wait until new webhook is called the first time 766 if err := wait.PollUntilContextTimeout(context.Background(), time.Millisecond*5, wait.ForeverTestTimeout, true, func(_ context.Context) (bool, error) { 767 _, err = client.CoreV1().Pods(markerNs).Patch(context.TODO(), marker.Name, types.JSONPatchType, []byte("[]"), metav1.PatchOptions{}) 768 select { 769 case <-upCh: 770 return true, nil 771 default: 772 t.Logf("Waiting for webhook to become effective, getting marker object: %v", err) 773 return false, nil 774 } 775 }); err != nil { 776 t.Fatal(err) 777 } 778 779 for _, pod := range testcase.pods { 780 _, err := client.CoreV1().Pods(pod.Namespace).Create(context.TODO(), pod, dryRunCreate) 781 if !testcase.expectErrorPod && err != nil { 782 t.Fatalf("unexpected error creating test pod: %v", err) 783 } else if testcase.expectErrorPod && err == nil { 784 t.Fatal("expected error creating pods") 785 } else if testcase.expectErrorPod && err != nil && !strings.Contains(err.Error(), testcase.errMessage) { 786 t.Fatalf("expected error message includes: %v, but get %v", testcase.errMessage, err) 787 } 788 } 789 790 if len(recorder.requests) != len(testcase.matchedPods) { 791 t.Errorf("unexpected requests %v, expected %v", recorder.requests, testcase.matchedPods) 792 } 793 794 for i, request := range recorder.requests { 795 if request.Name != testcase.matchedPods[i].Name { 796 t.Errorf("unexpected pod name %v, expected %v", request.Name, testcase.matchedPods[i].Name) 797 } 798 if request.Namespace != testcase.matchedPods[i].Namespace { 799 t.Errorf("unexpected pod namespace %v, expected %v", request.Namespace, testcase.matchedPods[i].Namespace) 800 } 801 } 802 803 // Reset and rerun against mutating webhook configuration 804 // TODO: private helper function for validation after creating vwh or mwh 805 upCh = recorder.Reset() 806 err = client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Delete(context.TODO(), validatingcfg.GetName(), metav1.DeleteOptions{}) 807 if err != nil { 808 t.Fatal(err) 809 } else { 810 vhwHasBeenCleanedUp = true 811 } 812 813 mutatingwebhook := &admissionregistrationv1.MutatingWebhookConfiguration{ 814 ObjectMeta: metav1.ObjectMeta{ 815 Name: "admission.integration.test", 816 }, 817 Webhooks: []admissionregistrationv1.MutatingWebhook{ 818 { 819 Name: "admission.integration.test", 820 Rules: []admissionregistrationv1.RuleWithOperations{{ 821 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.Create}, 822 Rule: admissionregistrationv1.Rule{ 823 APIGroups: []string{""}, 824 APIVersions: []string{"v1"}, 825 Resources: []string{"pods"}, 826 }, 827 }}, 828 ClientConfig: admissionregistrationv1.WebhookClientConfig{ 829 URL: &endpoint, 830 CABundle: localhostCert, 831 }, 832 // ignore pods in the marker namespace 833 NamespaceSelector: &metav1.LabelSelector{ 834 MatchExpressions: []metav1.LabelSelectorRequirement{ 835 { 836 Key: corev1.LabelMetadataName, 837 Operator: metav1.LabelSelectorOpNotIn, 838 Values: []string{"marker"}, 839 }, 840 }}, 841 FailurePolicy: testcase.failPolicy, 842 SideEffects: &noSideEffects, 843 AdmissionReviewVersions: []string{"v1"}, 844 MatchConditions: testcase.matchConditions, 845 }, 846 { 847 Name: "admission.integration.test.marker", 848 Rules: []admissionregistrationv1.RuleWithOperations{{ 849 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, 850 Rule: admissionregistrationv1.Rule{APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}}, 851 }}, 852 ClientConfig: admissionregistrationv1.WebhookClientConfig{ 853 URL: &markerEndpoint, 854 CABundle: localhostCert, 855 }, 856 NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{ 857 corev1.LabelMetadataName: "marker", 858 }}, 859 ObjectSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"marker": "true"}}, 860 FailurePolicy: testcase.failPolicy, 861 SideEffects: &noSideEffects, 862 AdmissionReviewVersions: []string{"v1"}, 863 }, 864 }, 865 } 866 867 mutatingcfg, err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().Create(context.TODO(), mutatingwebhook, metav1.CreateOptions{}) 868 if err != nil { 869 t.Fatal(err) 870 } 871 defer func() { 872 err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().Delete(context.TODO(), mutatingcfg.GetName(), metav1.DeleteOptions{}) 873 if err != nil { 874 t.Fatal(err) 875 } 876 }() 877 878 // wait until new webhook is called the first time 879 if err := wait.PollImmediate(time.Millisecond*5, wait.ForeverTestTimeout, func() (bool, error) { 880 _, err = client.CoreV1().Pods(markerNs).Patch(context.TODO(), marker.Name, types.JSONPatchType, []byte("[]"), metav1.PatchOptions{}) 881 select { 882 case <-upCh: 883 return true, nil 884 default: 885 t.Logf("Waiting for webhook to become effective, getting marker object: %v", err) 886 return false, nil 887 } 888 }); err != nil { 889 t.Fatal(err) 890 } 891 892 for _, pod := range testcase.pods { 893 _, err = client.CoreV1().Pods(pod.Namespace).Create(context.TODO(), pod, dryRunCreate) 894 if testcase.expectErrorPod == false && err != nil { 895 t.Fatalf("unexpected error creating test pod: %v", err) 896 } else if testcase.expectErrorPod == true && err == nil { 897 t.Fatal("expected error creating pods") 898 } 899 } 900 901 if len(recorder.requests) != len(testcase.matchedPods) { 902 t.Errorf("unexpected requests %v, expected %v", recorder.requests, testcase.matchedPods) 903 } 904 905 for i, request := range recorder.requests { 906 if request.Name != testcase.matchedPods[i].Name { 907 t.Errorf("unexpected pod name %v, expected %v", request.Name, testcase.matchedPods[i].Name) 908 } 909 if request.Namespace != testcase.matchedPods[i].Namespace { 910 t.Errorf("unexpected pod namespace %v, expected %v", request.Namespace, testcase.matchedPods[i].Namespace) 911 } 912 } 913 }) 914 } 915 } 916 917 func TestMatchConditions_validation(t *testing.T) { 918 919 server := apiservertesting.StartTestServerOrDie(t, nil, []string{ 920 "--disable-admission-plugins=ServiceAccount", 921 }, framework.SharedEtcd()) 922 defer server.TearDownFn() 923 924 client := clientset.NewForConfigOrDie(server.ClientConfig) 925 926 testcases := []struct { 927 name string 928 matchConditions []admissionregistrationv1.MatchCondition 929 expectError bool 930 }{{ 931 name: "valid match condition", 932 matchConditions: []admissionregistrationv1.MatchCondition{{ 933 Name: "true", 934 Expression: "true", 935 }}, 936 expectError: false, 937 }, { 938 name: "multiple valid match conditions", 939 matchConditions: []admissionregistrationv1.MatchCondition{{ 940 Name: "exclude-leases", 941 Expression: "!(request.resource.group == 'coordination.k8s.io' && request.resource.resource == 'leases')", 942 }, { 943 Name: "exclude-kubelet-requests", 944 Expression: "!('system:nodes' in request.userInfo.groups)", 945 }, { 946 Name: "breakglass", 947 Expression: "!authorizer.group('admissionregistration.k8s.io').resource('validatingwebhookconfigurations').name('my-webhook.example.com').check('breakglass').allowed()", 948 }}, 949 expectError: false, 950 }, { 951 name: "invalid field should error", 952 matchConditions: []admissionregistrationv1.MatchCondition{{ 953 Name: "old-object-is-null.kubernetes.io", 954 Expression: "imnotafield == null", 955 }}, 956 expectError: true, 957 }, { 958 name: "missing expression should error", 959 matchConditions: []admissionregistrationv1.MatchCondition{{ 960 Name: "old-object-is-null.kubernetes.io", 961 }}, 962 expectError: true, 963 }, { 964 name: "missing name should error", 965 matchConditions: []admissionregistrationv1.MatchCondition{{ 966 Expression: "oldObject == null", 967 }}, 968 expectError: true, 969 }, { 970 name: "empty name should error", 971 matchConditions: []admissionregistrationv1.MatchCondition{{ 972 Name: "", 973 Expression: "oldObject == null", 974 }}, 975 expectError: true, 976 }, { 977 name: "empty expression should error", 978 matchConditions: []admissionregistrationv1.MatchCondition{{ 979 Name: "test-empty-expression.kubernetes.io", 980 Expression: "", 981 }}, 982 expectError: true, 983 }, { 984 name: "duplicate name should error", 985 matchConditions: []admissionregistrationv1.MatchCondition{{ 986 Name: "test1", 987 Expression: "oldObject == null", 988 }, { 989 Name: "test1", 990 Expression: "oldObject == null", 991 }}, 992 expectError: true, 993 }, { 994 name: "name must be qualified name", 995 matchConditions: []admissionregistrationv1.MatchCondition{{ 996 Name: " test1", 997 Expression: "oldObject == null", 998 }}, 999 expectError: true, 1000 }, { 1001 name: "less than 65 match conditions should pass", 1002 matchConditions: repeatedMatchConditions(64), 1003 expectError: false, 1004 }, { 1005 name: "more than 64 match conditions should error", 1006 matchConditions: repeatedMatchConditions(65), 1007 expectError: true, 1008 }, 1009 } 1010 1011 dryRunCreate := metav1.CreateOptions{ 1012 DryRun: []string{metav1.DryRunAll}, 1013 } 1014 endpoint := "https://localhost:1234/server" 1015 for _, testcase := range testcases { 1016 t.Run(testcase.name, func(t *testing.T) { 1017 rules := []admissionregistrationv1.RuleWithOperations{{ 1018 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.Create}, 1019 Rule: admissionregistrationv1.Rule{ 1020 APIGroups: []string{""}, 1021 APIVersions: []string{"v1"}, 1022 Resources: []string{"pods"}, 1023 }, 1024 }} 1025 clientConfig := admissionregistrationv1.WebhookClientConfig{ 1026 URL: &endpoint, 1027 CABundle: localhostCert, 1028 } 1029 versions := []string{"v1"} 1030 validatingwebhook := &admissionregistrationv1.ValidatingWebhookConfiguration{ 1031 ObjectMeta: metav1.ObjectMeta{ 1032 Name: "admission.integration.test", 1033 }, 1034 Webhooks: []admissionregistrationv1.ValidatingWebhook{ 1035 { 1036 Name: "admission.integration.test", 1037 Rules: rules, 1038 ClientConfig: clientConfig, 1039 SideEffects: &noSideEffects, 1040 AdmissionReviewVersions: versions, 1041 MatchConditions: testcase.matchConditions, 1042 }, 1043 }, 1044 } 1045 1046 _, err := client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Create(context.TODO(), validatingwebhook, dryRunCreate) 1047 if testcase.expectError { 1048 if err == nil { 1049 t.Fatalf("Expected error creating ValidatingWebhookConfiguration; got nil") 1050 } else if !apierrors.IsInvalid(err) { 1051 t.Errorf("Expected Invalid error creating ValidatingWebhookConfiguration; got: %v", err) 1052 } 1053 } else if !testcase.expectError && err != nil { 1054 t.Fatalf("Unexpected error creating ValidatingWebhookConfiguration: %v", err) 1055 } 1056 1057 mutatingwebhook := &admissionregistrationv1.MutatingWebhookConfiguration{ 1058 ObjectMeta: metav1.ObjectMeta{ 1059 Name: "admission.integration.test", 1060 }, 1061 Webhooks: []admissionregistrationv1.MutatingWebhook{ 1062 { 1063 Name: "admission.integration.test", 1064 Rules: rules, 1065 ClientConfig: clientConfig, 1066 SideEffects: &noSideEffects, 1067 AdmissionReviewVersions: versions, 1068 MatchConditions: testcase.matchConditions, 1069 }, 1070 }, 1071 } 1072 1073 _, err = client.AdmissionregistrationV1().MutatingWebhookConfigurations().Create(context.TODO(), mutatingwebhook, dryRunCreate) 1074 if testcase.expectError { 1075 if err == nil { 1076 t.Fatalf("Expected error creating MutatingWebhookConfiguration; got: nil") 1077 } else if !apierrors.IsInvalid(err) { 1078 t.Errorf("Expected Invalid error creating MutatingWebhookConfiguration; got: %v", err) 1079 } 1080 } else if !testcase.expectError && err != nil { 1081 t.Fatalf("Unexpected error creating MutatingWebhookConfiguration: %v", err) 1082 } 1083 }) 1084 } 1085 } 1086 1087 func matchConditionsTestPod(name, ns string) *corev1.Pod { 1088 return &corev1.Pod{ 1089 ObjectMeta: metav1.ObjectMeta{ 1090 Name: name, 1091 Namespace: ns, 1092 }, 1093 Spec: corev1.PodSpec{ 1094 Containers: []corev1.Container{ 1095 { 1096 Name: "test", 1097 Image: "test", 1098 }, 1099 }, 1100 }, 1101 } 1102 } 1103 1104 func newMarkerPod(namespace string) *corev1.Pod { 1105 return &corev1.Pod{ 1106 ObjectMeta: metav1.ObjectMeta{ 1107 Namespace: namespace, 1108 Name: "marker", 1109 Labels: map[string]string{ 1110 "marker": "true", 1111 }, 1112 }, 1113 Spec: corev1.PodSpec{ 1114 Containers: []corev1.Container{{ 1115 Name: "fake-name", 1116 Image: "fakeimage", 1117 }}, 1118 }, 1119 } 1120 } 1121 1122 func repeatedMatchConditions(size int) []admissionregistrationv1.MatchCondition { 1123 matchConditions := make([]admissionregistrationv1.MatchCondition, 0, size) 1124 for i := 0; i < size; i++ { 1125 matchConditions = append(matchConditions, admissionregistrationv1.MatchCondition{ 1126 Name: "repeated-" + strconv.Itoa(i), 1127 Expression: "true", 1128 }) 1129 } 1130 return matchConditions 1131 } 1132 1133 // generate n matchConditions with provided expression 1134 func generateMatchConditionsWithAuthzCheck(num int, exp string) []admissionregistrationv1.MatchCondition { 1135 var conditions = make([]admissionregistrationv1.MatchCondition, num) 1136 for i := 0; i < num; i++ { 1137 conditions[i].Name = "test" + strconv.Itoa(i) 1138 conditions[i].Expression = exp 1139 } 1140 return conditions 1141 }