/* Copyright 2023 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package cel import ( "context" "encoding/json" "fmt" "strings" "sync" "testing" "time" "k8s.io/api/admission/v1beta1" appsv1beta1 "k8s.io/api/apps/v1beta1" authenticationv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" extensionsv1beta1 "k8s.io/api/extensions/v1beta1" policyv1 "k8s.io/api/policy/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/dynamic" clientset "k8s.io/client-go/kubernetes" "k8s.io/client-go/util/retry" "k8s.io/kubernetes/test/integration/etcd" ) // Admission test framework copied from test/integration/apiserver/admissionwebhook/admission_test.go // // All differences between two are minor and called out in comments prefixed with // "DIFF" const ( testNamespace = "webhook-integration" testClientUsername = "webhook-integration-client" mutation = "mutation" validation = "validation" ) // DIFF: Added interface to replace direct *holder usage in testContext to be // able to inject a policy-specific holder type admissionTestExpectationHolder interface { reset(t *testing.T) expect(gvr schema.GroupVersionResource, gvk, optionsGVK schema.GroupVersionKind, operation v1beta1.Operation, name, namespace string, object, oldObject, options bool) verify(t *testing.T) } type testContext struct { t *testing.T // DIFF: Changed from *holder to interface admissionHolder admissionTestExpectationHolder client dynamic.Interface clientset clientset.Interface verb string gvr schema.GroupVersionResource resource metav1.APIResource resources map[schema.GroupVersionResource]metav1.APIResource } type testFunc func(*testContext) var ( // defaultResourceFuncs holds the default test functions. // may be overridden for specific resources by customTestFuncs. defaultResourceFuncs = map[string]testFunc{ "create": testResourceCreate, "update": testResourceUpdate, "patch": testResourcePatch, "delete": testResourceDelete, "deletecollection": testResourceDeletecollection, } // defaultSubresourceFuncs holds default subresource test functions. // may be overridden for specific resources by customTestFuncs. defaultSubresourceFuncs = map[string]testFunc{ "update": testSubresourceUpdate, "patch": testSubresourcePatch, } // customTestFuncs holds custom test functions by resource and verb. customTestFuncs = map[schema.GroupVersionResource]map[string]testFunc{ gvr("", "v1", "namespaces"): {"delete": testNamespaceDelete}, gvr("apps", "v1beta1", "deployments/rollback"): {"create": testDeploymentRollback}, gvr("extensions", "v1beta1", "deployments/rollback"): {"create": testDeploymentRollback}, gvr("", "v1", "pods/attach"): {"create": testPodConnectSubresource}, gvr("", "v1", "pods/exec"): {"create": testPodConnectSubresource}, gvr("", "v1", "pods/portforward"): {"create": testPodConnectSubresource}, gvr("", "v1", "bindings"): {"create": testPodBindingEviction}, gvr("", "v1", "pods/binding"): {"create": testPodBindingEviction}, gvr("", "v1", "pods/eviction"): {"create": testPodBindingEviction}, gvr("", "v1", "nodes/proxy"): {"*": testSubresourceProxy}, gvr("", "v1", "pods/proxy"): {"*": testSubresourceProxy}, gvr("", "v1", "services/proxy"): {"*": testSubresourceProxy}, gvr("", "v1", "serviceaccounts/token"): {"create": testTokenCreate}, gvr("random.numbers.com", "v1", "integers"): {"create": testPruningRandomNumbers}, // DIFF: This test is used for webhook test but disabled here until we have mutating // admission policy to write to "foo" field // gvr("custom.fancy.com", "v2", "pants"): {"create": testNoPruningCustomFancy}, } // admissionExemptResources lists objects which are exempt from admission validation/mutation, // only resources exempted from admission processing by API server should be listed here. admissionExemptResources = map[schema.GroupVersionResource]bool{ // DIFF: WebhookConfigurations are exempt for webhook admission but not // for policy admission. // gvr("admissionregistration.k8s.io", "v1beta1", "mutatingwebhookconfigurations"): true, // gvr("admissionregistration.k8s.io", "v1beta1", "validatingwebhookconfigurations"): true, // gvr("admissionregistration.k8s.io", "v1", "mutatingwebhookconfigurations"): true, // gvr("admissionregistration.k8s.io", "v1", "validatingwebhookconfigurations"): true, gvr("admissionregistration.k8s.io", "v1alpha1", "validatingadmissionpolicies"): true, gvr("admissionregistration.k8s.io", "v1alpha1", "validatingadmissionpolicies/status"): true, gvr("admissionregistration.k8s.io", "v1alpha1", "validatingadmissionpolicybindings"): true, gvr("admissionregistration.k8s.io", "v1beta1", "validatingadmissionpolicies"): true, gvr("admissionregistration.k8s.io", "v1beta1", "validatingadmissionpolicies/status"): true, gvr("admissionregistration.k8s.io", "v1beta1", "validatingadmissionpolicybindings"): true, gvr("admissionregistration.k8s.io", "v1", "validatingadmissionpolicies"): true, gvr("admissionregistration.k8s.io", "v1", "validatingadmissionpolicies/status"): true, gvr("admissionregistration.k8s.io", "v1", "validatingadmissionpolicybindings"): true, // transient resource exemption gvr("authentication.k8s.io", "v1", "selfsubjectreviews"): true, gvr("authentication.k8s.io", "v1beta1", "selfsubjectreviews"): true, gvr("authentication.k8s.io", "v1alpha1", "selfsubjectreviews"): true, gvr("authentication.k8s.io", "v1", "tokenreviews"): true, gvr("authorization.k8s.io", "v1", "localsubjectaccessreviews"): true, gvr("authorization.k8s.io", "v1", "selfsubjectaccessreviews"): true, gvr("authorization.k8s.io", "v1", "selfsubjectrulesreviews"): true, gvr("authorization.k8s.io", "v1", "subjectaccessreviews"): true, } parentResources = map[schema.GroupVersionResource]schema.GroupVersionResource{ gvr("extensions", "v1beta1", "replicationcontrollers/scale"): gvr("", "v1", "replicationcontrollers"), } // stubDataOverrides holds either non persistent resources' definitions or resources where default stub needs to be overridden. stubDataOverrides = map[schema.GroupVersionResource]string{ // Non persistent Reviews resource gvr("authentication.k8s.io", "v1", "tokenreviews"): `{"metadata": {"name": "tokenreview"}, "spec": {"token": "token", "audience": ["audience1","audience2"]}}`, gvr("authentication.k8s.io", "v1beta1", "tokenreviews"): `{"metadata": {"name": "tokenreview"}, "spec": {"token": "token", "audience": ["audience1","audience2"]}}`, gvr("authentication.k8s.io", "v1alpha1", "selfsubjectreviews"): `{"metadata": {"name": "SelfSubjectReview"},"status":{"userInfo":{}}}`, gvr("authentication.k8s.io", "v1beta1", "selfsubjectreviews"): `{"metadata": {"name": "SelfSubjectReview"},"status":{"userInfo":{}}}`, gvr("authentication.k8s.io", "v1", "selfsubjectreviews"): `{"metadata": {"name": "SelfSubjectReview"},"status":{"userInfo":{}}}`, gvr("authorization.k8s.io", "v1", "localsubjectaccessreviews"): `{"metadata": {"name": "", "namespace":"` + testNamespace + `"}, "spec": {"uid": "token", "user": "user1","groups": ["group1","group2"],"resourceAttributes": {"name":"name1","namespace":"` + testNamespace + `"}}}`, gvr("authorization.k8s.io", "v1", "subjectaccessreviews"): `{"metadata": {"name": "", "namespace":""}, "spec": {"user":"user1","resourceAttributes": {"name":"name1", "namespace":"` + testNamespace + `"}}}`, gvr("authorization.k8s.io", "v1", "selfsubjectaccessreviews"): `{"metadata": {"name": "", "namespace":""}, "spec": {"resourceAttributes": {"name":"name1", "namespace":""}}}`, gvr("authorization.k8s.io", "v1", "selfsubjectrulesreviews"): `{"metadata": {"name": "", "namespace":"` + testNamespace + `"}, "spec": {"namespace":"` + testNamespace + `"}}`, gvr("authorization.k8s.io", "v1beta1", "localsubjectaccessreviews"): `{"metadata": {"name": "", "namespace":"` + testNamespace + `"}, "spec": {"uid": "token", "user": "user1","groups": ["group1","group2"],"resourceAttributes": {"name":"name1","namespace":"` + testNamespace + `"}}}`, gvr("authorization.k8s.io", "v1beta1", "subjectaccessreviews"): `{"metadata": {"name": "", "namespace":""}, "spec": {"user":"user1","resourceAttributes": {"name":"name1", "namespace":"` + testNamespace + `"}}}`, gvr("authorization.k8s.io", "v1beta1", "selfsubjectaccessreviews"): `{"metadata": {"name": "", "namespace":""}, "spec": {"resourceAttributes": {"name":"name1", "namespace":""}}}`, gvr("authorization.k8s.io", "v1beta1", "selfsubjectrulesreviews"): `{"metadata": {"name": "", "namespace":"` + testNamespace + `"}, "spec": {"namespace":"` + testNamespace + `"}}`, // Other Non persistent resources } ) type webhookOptions struct { version string // phase indicates whether this is a mutating or validating webhook phase string // converted indicates if this webhook makes use of matchPolicy:equivalent and expects conversion. // if true, recordGVR and expectGVK are mapped through gvrToConvertedGVR/gvrToConvertedGVK. // if false, recordGVR and expectGVK are compared directly to the admission review. converted bool } type holder struct { lock sync.RWMutex t *testing.T // DIFF: Warning handler removed in policy test. // warningHandler *warningHandler recordGVR metav1.GroupVersionResource recordOperation string recordNamespace string recordName string expectGVK schema.GroupVersionKind expectObject bool expectOldObject bool expectOptionsGVK schema.GroupVersionKind expectOptions bool // gvrToConvertedGVR maps the GVR submitted to the API server to the expected GVR when converted to the webhook-recognized resource. // When a converted request is recorded, gvrToConvertedGVR[recordGVR] is compared to the GVR seen by the webhook. gvrToConvertedGVR map[metav1.GroupVersionResource]metav1.GroupVersionResource // gvrToConvertedGVR maps the GVR submitted to the API server to the expected GVK when converted to the webhook-recognized resource. // When a converted request is recorded, gvrToConvertedGVR[expectGVK] is compared to the GVK seen by the webhook. gvrToConvertedGVK map[metav1.GroupVersionResource]schema.GroupVersionKind recorded map[webhookOptions]*admissionRequest } func (h *holder) reset(t *testing.T) { h.lock.Lock() defer h.lock.Unlock() h.t = t h.recordGVR = metav1.GroupVersionResource{} h.expectGVK = schema.GroupVersionKind{} h.recordOperation = "" h.recordName = "" h.recordNamespace = "" h.expectObject = false h.expectOldObject = false h.expectOptionsGVK = schema.GroupVersionKind{} h.expectOptions = false // DIFF: Warning handler removed // h.warningHandler.reset() // Set up the recorded map with nil records for all combinations h.recorded = map[webhookOptions]*admissionRequest{} for _, phase := range []string{mutation, validation} { for _, converted := range []bool{true, false} { for _, version := range []string{"v1", "v1beta1"} { h.recorded[webhookOptions{version: version, phase: phase, converted: converted}] = nil } } } } func (h *holder) expect(gvr schema.GroupVersionResource, gvk, optionsGVK schema.GroupVersionKind, operation v1beta1.Operation, name, namespace string, object, oldObject, options bool) { // Special-case namespaces, since the object name shows up in request attributes if len(namespace) == 0 && gvk.Group == "" && gvk.Version == "v1" && gvk.Kind == "Namespace" { namespace = name } h.lock.Lock() defer h.lock.Unlock() h.recordGVR = metav1.GroupVersionResource{Group: gvr.Group, Version: gvr.Version, Resource: gvr.Resource} h.expectGVK = gvk h.recordOperation = string(operation) h.recordName = name h.recordNamespace = namespace h.expectObject = object h.expectOldObject = oldObject h.expectOptionsGVK = optionsGVK h.expectOptions = options // DIFF: Warning handler removed // h.warningHandler.reset() // Set up the recorded map with nil records for all combinations h.recorded = map[webhookOptions]*admissionRequest{} for _, phase := range []string{mutation, validation} { for _, converted := range []bool{true, false} { for _, version := range []string{"v1", "v1beta1"} { h.recorded[webhookOptions{version: version, phase: phase, converted: converted}] = nil } } } } type admissionRequest struct { Operation string Resource metav1.GroupVersionResource SubResource string Namespace string Name string Object runtime.RawExtension OldObject runtime.RawExtension Options runtime.RawExtension } func (h *holder) record(version string, phase string, converted bool, request *admissionRequest) { h.lock.Lock() defer h.lock.Unlock() // this is useful to turn on if items aren't getting recorded and you need to figure out why debug := false if debug { h.t.Logf("%s %#v %v", request.Operation, request.Resource, request.SubResource) } resource := request.Resource if len(request.SubResource) > 0 { resource.Resource += "/" + request.SubResource } // See if we should record this gvrToRecord := h.recordGVR if converted { // If this is a converted webhook, map to the GVR we expect the webhook to see gvrToRecord = h.gvrToConvertedGVR[h.recordGVR] } if resource != gvrToRecord { if debug { h.t.Log(resource, "!=", gvrToRecord) } return } if request.Operation != h.recordOperation { if debug { h.t.Log(request.Operation, "!=", h.recordOperation) } return } if request.Namespace != h.recordNamespace { if debug { h.t.Log(request.Namespace, "!=", h.recordNamespace) } return } name := request.Name if name != h.recordName { if debug { h.t.Log(name, "!=", h.recordName) } return } if debug { h.t.Logf("recording: %#v = %s %#v %v", webhookOptions{version: version, phase: phase, converted: converted}, request.Operation, request.Resource, request.SubResource) } h.recorded[webhookOptions{version: version, phase: phase, converted: converted}] = request } func (h *holder) verify(t *testing.T) { h.lock.Lock() defer h.lock.Unlock() for options, value := range h.recorded { if err := h.verifyRequest(options, value); err != nil { t.Errorf("version: %v, phase:%v, converted:%v error: %v", options.version, options.phase, options.converted, err) } } } func (h *holder) verifyRequest(webhookOptions webhookOptions, request *admissionRequest) error { converted := webhookOptions.converted // Check if current resource should be exempted from Admission processing if admissionExemptResources[gvr(h.recordGVR.Group, h.recordGVR.Version, h.recordGVR.Resource)] { if request == nil { return nil } return fmt.Errorf("admission webhook was called, but not supposed to") } if request == nil { return fmt.Errorf("no request received") } if h.expectObject { if err := h.verifyObject(converted, request.Object.Object); err != nil { return fmt.Errorf("object error: %v", err) } } else if request.Object.Object != nil { return fmt.Errorf("unexpected object: %#v", request.Object.Object) } if h.expectOldObject { if err := h.verifyObject(converted, request.OldObject.Object); err != nil { return fmt.Errorf("old object error: %v", err) } } else if request.OldObject.Object != nil { return fmt.Errorf("unexpected old object: %#v", request.OldObject.Object) } if h.expectOptions { if err := h.verifyOptions(request.Options.Object); err != nil { return fmt.Errorf("options error: %v", err) } } else if request.Options.Object != nil { return fmt.Errorf("unexpected options: %#v", request.Options.Object) } // DIFF: This check was removed for policy tests since it only applies // to webhook // if !h.warningHandler.hasWarning(makeWarning(webhookOptions.version, webhookOptions.phase, webhookOptions.converted)) { // return fmt.Errorf("no warning received from webhook") // } return nil } func (h *holder) verifyObject(converted bool, obj runtime.Object) error { if obj == nil { return fmt.Errorf("no object sent") } expectGVK := h.expectGVK if converted { expectGVK = h.gvrToConvertedGVK[h.recordGVR] } if obj.GetObjectKind().GroupVersionKind() != expectGVK { return fmt.Errorf("expected %#v, got %#v", expectGVK, obj.GetObjectKind().GroupVersionKind()) } return nil } func (h *holder) verifyOptions(options runtime.Object) error { if options == nil { return fmt.Errorf("no options sent") } if options.GetObjectKind().GroupVersionKind() != h.expectOptionsGVK { return fmt.Errorf("expected %#v, got %#v", h.expectOptionsGVK, options.GetObjectKind().GroupVersionKind()) } return nil } func getTestFunc(gvr schema.GroupVersionResource, verb string) testFunc { if f, found := customTestFuncs[gvr][verb]; found { return f } if f, found := customTestFuncs[gvr]["*"]; found { return f } if strings.Contains(gvr.Resource, "/") { if f, found := defaultSubresourceFuncs[verb]; found { return f } return unimplemented } if f, found := defaultResourceFuncs[verb]; found { return f } return unimplemented } func getStubObj(gvr schema.GroupVersionResource, resource metav1.APIResource) (*unstructured.Unstructured, error) { stub := "" if data, ok := etcd.GetEtcdStorageDataForNamespace(testNamespace)[gvr]; ok { stub = data.Stub } if data, ok := stubDataOverrides[gvr]; ok { stub = data } if len(stub) == 0 { return nil, fmt.Errorf("no stub data for %#v", gvr) } stubObj := &unstructured.Unstructured{Object: map[string]interface{}{}} if err := json.Unmarshal([]byte(stub), &stubObj.Object); err != nil { return nil, fmt.Errorf("error unmarshaling stub for %#v: %v", gvr, err) } return stubObj, nil } func createOrGetResource(client dynamic.Interface, gvr schema.GroupVersionResource, resource metav1.APIResource) (*unstructured.Unstructured, error) { stubObj, err := getStubObj(gvr, resource) if err != nil { return nil, err } ns := "" if resource.Namespaced { ns = testNamespace } obj, err := client.Resource(gvr).Namespace(ns).Get(context.TODO(), stubObj.GetName(), metav1.GetOptions{}) if err == nil { return obj, nil } if !apierrors.IsNotFound(err) { return nil, err } return client.Resource(gvr).Namespace(ns).Create(context.TODO(), stubObj, metav1.CreateOptions{}) } func gvr(group, version, resource string) schema.GroupVersionResource { return schema.GroupVersionResource{Group: group, Version: version, Resource: resource} } func gvk(group, version, kind string) schema.GroupVersionKind { return schema.GroupVersionKind{Group: group, Version: version, Kind: kind} } var ( gvkCreateOptions = metav1.SchemeGroupVersion.WithKind("CreateOptions") gvkUpdateOptions = metav1.SchemeGroupVersion.WithKind("UpdateOptions") gvkDeleteOptions = metav1.SchemeGroupVersion.WithKind("DeleteOptions") ) func shouldTestResource(gvr schema.GroupVersionResource, resource metav1.APIResource) bool { return sets.NewString(resource.Verbs...).HasAny("create", "update", "patch", "connect", "delete", "deletecollection") } func shouldTestResourceVerb(gvr schema.GroupVersionResource, resource metav1.APIResource, verb string) bool { return sets.NewString(resource.Verbs...).Has(verb) } // // generic resource testing // func testResourceCreate(c *testContext) { stubObj, err := getStubObj(c.gvr, c.resource) if err != nil { c.t.Error(err) return } ns := "" if c.resource.Namespaced { ns = testNamespace } c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), gvkCreateOptions, v1beta1.Create, stubObj.GetName(), ns, true, false, true) _, err = c.client.Resource(c.gvr).Namespace(ns).Create(context.TODO(), stubObj, metav1.CreateOptions{}) if err != nil { c.t.Error(err) return } } func testResourceUpdate(c *testContext) { if err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { obj, err := createOrGetResource(c.client, c.gvr, c.resource) if err != nil { return err } obj.SetAnnotations(map[string]string{"update": "true"}) c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), gvkUpdateOptions, v1beta1.Update, obj.GetName(), obj.GetNamespace(), true, true, true) _, err = c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Update(context.TODO(), obj, metav1.UpdateOptions{}) return err }); err != nil { c.t.Error(err) return } } func testResourcePatch(c *testContext) { obj, err := createOrGetResource(c.client, c.gvr, c.resource) if err != nil { c.t.Error(err) return } c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), gvkUpdateOptions, v1beta1.Update, obj.GetName(), obj.GetNamespace(), true, true, true) _, err = c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Patch( context.TODO(), obj.GetName(), types.MergePatchType, []byte(`{"metadata":{"annotations":{"patch":"true"}}}`), metav1.PatchOptions{}) if err != nil { c.t.Error(err) return } } func testResourceDelete(c *testContext) { // Verify that an immediate delete triggers the webhook and populates the admisssionRequest.oldObject. obj, err := createOrGetResource(c.client, c.gvr, c.resource) if err != nil { c.t.Error(err) return } background := metav1.DeletePropagationBackground zero := int64(0) c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), gvkDeleteOptions, v1beta1.Delete, obj.GetName(), obj.GetNamespace(), false, true, true) err = c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Delete(context.TODO(), obj.GetName(), metav1.DeleteOptions{GracePeriodSeconds: &zero, PropagationPolicy: &background}) if err != nil { c.t.Error(err) return } c.admissionHolder.verify(c.t) // wait for the item to be gone err = wait.PollImmediate(100*time.Millisecond, 10*time.Second, func() (bool, error) { obj, err := c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Get(context.TODO(), obj.GetName(), metav1.GetOptions{}) if apierrors.IsNotFound(err) { return true, nil } if err == nil { c.t.Logf("waiting for %#v to be deleted (name: %s, finalizers: %v)...\n", c.gvr, obj.GetName(), obj.GetFinalizers()) return false, nil } return false, err }) if err != nil { c.t.Error(err) return } // Verify that an update-on-delete triggers the webhook and populates the admisssionRequest.oldObject. obj, err = createOrGetResource(c.client, c.gvr, c.resource) if err != nil { c.t.Error(err) return } // Adding finalizer to the object, then deleting it. // We don't add finalizers by setting DeleteOptions.PropagationPolicy // because some resource (e.g., events) do not support garbage // collector finalizers. _, err = c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Patch( context.TODO(), obj.GetName(), types.MergePatchType, []byte(`{"metadata":{"finalizers":["test/k8s.io"]}}`), metav1.PatchOptions{}) if err != nil { c.t.Error(err) return } c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), gvkDeleteOptions, v1beta1.Delete, obj.GetName(), obj.GetNamespace(), false, true, true) err = c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Delete(context.TODO(), obj.GetName(), metav1.DeleteOptions{GracePeriodSeconds: &zero, PropagationPolicy: &background}) if err != nil { c.t.Error(err) return } c.admissionHolder.verify(c.t) // wait other finalizers (e.g., crd's customresourcecleanup finalizer) to be removed. err = wait.PollImmediate(100*time.Millisecond, 10*time.Second, func() (bool, error) { obj, err := c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Get(context.TODO(), obj.GetName(), metav1.GetOptions{}) if err != nil { return false, err } finalizers := obj.GetFinalizers() if len(finalizers) != 1 { c.t.Logf("waiting for other finalizers on %#v %s to be removed, existing finalizers are %v", c.gvr, obj.GetName(), obj.GetFinalizers()) return false, nil } if finalizers[0] != "test/k8s.io" { return false, fmt.Errorf("expected the single finalizer on %#v %s to be test/k8s.io, got %v", c.gvr, obj.GetName(), obj.GetFinalizers()) } return true, nil }) if err != nil { c.t.Error(err) return } // remove the finalizer _, err = c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Patch( context.TODO(), obj.GetName(), types.MergePatchType, []byte(`{"metadata":{"finalizers":[]}}`), metav1.PatchOptions{}) if err != nil { c.t.Error(err) return } // wait for the item to be gone err = wait.PollImmediate(100*time.Millisecond, 10*time.Second, func() (bool, error) { obj, err := c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Get(context.TODO(), obj.GetName(), metav1.GetOptions{}) if apierrors.IsNotFound(err) { return true, nil } if err == nil { c.t.Logf("waiting for %#v to be deleted (name: %s, finalizers: %v)...\n", c.gvr, obj.GetName(), obj.GetFinalizers()) return false, nil } return false, err }) if err != nil { c.t.Error(err) return } } func testResourceDeletecollection(c *testContext) { obj, err := createOrGetResource(c.client, c.gvr, c.resource) if err != nil { c.t.Error(err) return } background := metav1.DeletePropagationBackground zero := int64(0) // update the object with a label that matches our selector _, err = c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Patch( context.TODO(), obj.GetName(), types.MergePatchType, []byte(`{"metadata":{"labels":{"webhooktest":"true"}}}`), metav1.PatchOptions{}) if err != nil { c.t.Error(err) return } // set expectations c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), gvkDeleteOptions, v1beta1.Delete, "", obj.GetNamespace(), false, true, true) // delete err = c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).DeleteCollection(context.TODO(), metav1.DeleteOptions{GracePeriodSeconds: &zero, PropagationPolicy: &background}, metav1.ListOptions{LabelSelector: "webhooktest=true"}) if err != nil { c.t.Error(err) return } // wait for the item to be gone err = wait.PollImmediate(100*time.Millisecond, 10*time.Second, func() (bool, error) { obj, err := c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Get(context.TODO(), obj.GetName(), metav1.GetOptions{}) if apierrors.IsNotFound(err) { return true, nil } if err == nil { c.t.Logf("waiting for %#v to be deleted (name: %s, finalizers: %v)...\n", c.gvr, obj.GetName(), obj.GetFinalizers()) return false, nil } return false, err }) if err != nil { c.t.Error(err) return } } func getParentGVR(gvr schema.GroupVersionResource) schema.GroupVersionResource { parentGVR, found := parentResources[gvr] // if no special override is found, just drop the subresource if !found { parentGVR = gvr parentGVR.Resource = strings.Split(parentGVR.Resource, "/")[0] } return parentGVR } func testTokenCreate(c *testContext) { saGVR := gvr("", "v1", "serviceaccounts") sa, err := createOrGetResource(c.client, saGVR, c.resources[saGVR]) if err != nil { c.t.Error(err) return } c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), gvkCreateOptions, v1beta1.Create, sa.GetName(), sa.GetNamespace(), true, false, true) if err = c.clientset.CoreV1().RESTClient().Post().Namespace(sa.GetNamespace()).Resource("serviceaccounts").Name(sa.GetName()).SubResource("token").Body(&authenticationv1.TokenRequest{ ObjectMeta: metav1.ObjectMeta{Name: sa.GetName()}, Spec: authenticationv1.TokenRequestSpec{ Audiences: []string{"api"}, }, }).Do(context.TODO()).Error(); err != nil { c.t.Error(err) return } c.admissionHolder.verify(c.t) } func testSubresourceUpdate(c *testContext) { if err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { parentGVR := getParentGVR(c.gvr) parentResource := c.resources[parentGVR] obj, err := createOrGetResource(c.client, parentGVR, parentResource) if err != nil { return err } // Save the parent object as what we submit submitObj := obj gvrWithoutSubresources := c.gvr gvrWithoutSubresources.Resource = strings.Split(gvrWithoutSubresources.Resource, "/")[0] subresources := strings.Split(c.gvr.Resource, "/")[1:] // If the subresource supports get, fetch that as the object to submit (namespaces/finalize, */scale, etc) if sets.NewString(c.resource.Verbs...).Has("get") { submitObj, err = c.client.Resource(gvrWithoutSubresources).Namespace(obj.GetNamespace()).Get(context.TODO(), obj.GetName(), metav1.GetOptions{}, subresources...) if err != nil { return err } } // Modify the object submitObj.SetAnnotations(map[string]string{"subresourceupdate": "true"}) // set expectations c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), gvkUpdateOptions, v1beta1.Update, obj.GetName(), obj.GetNamespace(), true, true, true) _, err = c.client.Resource(gvrWithoutSubresources).Namespace(obj.GetNamespace()).Update( context.TODO(), submitObj, metav1.UpdateOptions{}, subresources..., ) return err }); err != nil { c.t.Error(err) } } func testSubresourcePatch(c *testContext) { parentGVR := getParentGVR(c.gvr) parentResource := c.resources[parentGVR] obj, err := createOrGetResource(c.client, parentGVR, parentResource) if err != nil { c.t.Error(err) return } gvrWithoutSubresources := c.gvr gvrWithoutSubresources.Resource = strings.Split(gvrWithoutSubresources.Resource, "/")[0] subresources := strings.Split(c.gvr.Resource, "/")[1:] // set expectations c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), gvkUpdateOptions, v1beta1.Update, obj.GetName(), obj.GetNamespace(), true, true, true) _, err = c.client.Resource(gvrWithoutSubresources).Namespace(obj.GetNamespace()).Patch( context.TODO(), obj.GetName(), types.MergePatchType, []byte(`{"metadata":{"annotations":{"subresourcepatch":"true"}}}`), metav1.PatchOptions{}, subresources..., ) if err != nil { c.t.Error(err) return } } func unimplemented(c *testContext) { c.t.Errorf("Test function for %+v has not been implemented...", c.gvr) } // // custom methods // // testNamespaceDelete verifies namespace-specific delete behavior: // - ensures admission is called on first delete (which only sets deletionTimestamp and terminating state) // - removes finalizer from namespace // - ensures admission is called on final delete once finalizers are removed func testNamespaceDelete(c *testContext) { obj, err := createOrGetResource(c.client, c.gvr, c.resource) if err != nil { c.t.Error(err) return } background := metav1.DeletePropagationBackground zero := int64(0) c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), gvkDeleteOptions, v1beta1.Delete, obj.GetName(), obj.GetNamespace(), false, true, true) err = c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Delete(context.TODO(), obj.GetName(), metav1.DeleteOptions{GracePeriodSeconds: &zero, PropagationPolicy: &background}) if err != nil { c.t.Error(err) return } c.admissionHolder.verify(c.t) // do the finalization so the namespace can be deleted obj, err = c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Get(context.TODO(), obj.GetName(), metav1.GetOptions{}) if err != nil { c.t.Error(err) return } err = unstructured.SetNestedField(obj.Object, nil, "spec", "finalizers") if err != nil { c.t.Error(err) return } _, err = c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Update(context.TODO(), obj, metav1.UpdateOptions{}, "finalize") if err != nil { c.t.Error(err) return } // verify namespace is gone obj, err = c.client.Resource(c.gvr).Namespace(obj.GetNamespace()).Get(context.TODO(), obj.GetName(), metav1.GetOptions{}) if err == nil || !apierrors.IsNotFound(err) { c.t.Errorf("expected namespace to be gone, got %#v, %v", obj, err) } } // testDeploymentRollback verifies rollback-specific behavior: // - creates a parent deployment // - creates a rollback object and posts it func testDeploymentRollback(c *testContext) { deploymentGVR := gvr("apps", "v1", "deployments") obj, err := createOrGetResource(c.client, deploymentGVR, c.resources[deploymentGVR]) if err != nil { c.t.Error(err) return } gvrWithoutSubresources := c.gvr gvrWithoutSubresources.Resource = strings.Split(gvrWithoutSubresources.Resource, "/")[0] subresources := strings.Split(c.gvr.Resource, "/")[1:] c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), gvkCreateOptions, v1beta1.Create, obj.GetName(), obj.GetNamespace(), true, false, true) var rollbackObj runtime.Object switch c.gvr { case gvr("apps", "v1beta1", "deployments/rollback"): rollbackObj = &appsv1beta1.DeploymentRollback{ TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1beta1", Kind: "DeploymentRollback"}, Name: obj.GetName(), RollbackTo: appsv1beta1.RollbackConfig{Revision: 0}, } case gvr("extensions", "v1beta1", "deployments/rollback"): rollbackObj = &extensionsv1beta1.DeploymentRollback{ TypeMeta: metav1.TypeMeta{APIVersion: "extensions/v1beta1", Kind: "DeploymentRollback"}, Name: obj.GetName(), RollbackTo: extensionsv1beta1.RollbackConfig{Revision: 0}, } default: c.t.Errorf("unknown rollback resource %#v", c.gvr) return } rollbackUnstructuredBody, err := runtime.DefaultUnstructuredConverter.ToUnstructured(rollbackObj) if err != nil { c.t.Errorf("ToUnstructured failed: %v", err) return } rollbackUnstructuredObj := &unstructured.Unstructured{Object: rollbackUnstructuredBody} rollbackUnstructuredObj.SetName(obj.GetName()) _, err = c.client.Resource(gvrWithoutSubresources).Namespace(obj.GetNamespace()).Create(context.TODO(), rollbackUnstructuredObj, metav1.CreateOptions{}, subresources...) if err != nil { c.t.Error(err) return } } // testPodConnectSubresource verifies connect subresources func testPodConnectSubresource(c *testContext) { podGVR := gvr("", "v1", "pods") pod, err := createOrGetResource(c.client, podGVR, c.resources[podGVR]) if err != nil { c.t.Error(err) return } // check all upgradeable verbs for _, httpMethod := range []string{"GET", "POST"} { c.t.Logf("verifying %v", httpMethod) c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), schema.GroupVersionKind{}, v1beta1.Connect, pod.GetName(), pod.GetNamespace(), true, false, false) var err error switch c.gvr { case gvr("", "v1", "pods/exec"): err = c.clientset.CoreV1().RESTClient().Verb(httpMethod).Namespace(pod.GetNamespace()).Resource("pods").Name(pod.GetName()).SubResource("exec").Do(context.TODO()).Error() case gvr("", "v1", "pods/attach"): err = c.clientset.CoreV1().RESTClient().Verb(httpMethod).Namespace(pod.GetNamespace()).Resource("pods").Name(pod.GetName()).SubResource("attach").Do(context.TODO()).Error() case gvr("", "v1", "pods/portforward"): err = c.clientset.CoreV1().RESTClient().Verb(httpMethod).Namespace(pod.GetNamespace()).Resource("pods").Name(pod.GetName()).SubResource("portforward").Do(context.TODO()).Error() default: c.t.Errorf("unknown subresource %#v", c.gvr) return } if err != nil { c.t.Logf("debug: result of subresource connect: %v", err) } c.admissionHolder.verify(c.t) } } // testPodBindingEviction verifies pod binding and eviction admission func testPodBindingEviction(c *testContext) { podGVR := gvr("", "v1", "pods") pod, err := createOrGetResource(c.client, podGVR, c.resources[podGVR]) if err != nil { c.t.Error(err) return } background := metav1.DeletePropagationBackground zero := int64(0) forceDelete := metav1.DeleteOptions{GracePeriodSeconds: &zero, PropagationPolicy: &background} defer func() { err := c.clientset.CoreV1().Pods(pod.GetNamespace()).Delete(context.TODO(), pod.GetName(), forceDelete) if err != nil && !apierrors.IsNotFound(err) { c.t.Error(err) return } }() c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), gvkCreateOptions, v1beta1.Create, pod.GetName(), pod.GetNamespace(), true, false, true) switch c.gvr { case gvr("", "v1", "bindings"): err = c.clientset.CoreV1().RESTClient().Post().Namespace(pod.GetNamespace()).Resource("bindings").Body(&corev1.Binding{ ObjectMeta: metav1.ObjectMeta{Name: pod.GetName()}, Target: corev1.ObjectReference{Name: "foo", Kind: "Node", APIVersion: "v1"}, }).Do(context.TODO()).Error() case gvr("", "v1", "pods/binding"): err = c.clientset.CoreV1().RESTClient().Post().Namespace(pod.GetNamespace()).Resource("pods").Name(pod.GetName()).SubResource("binding").Body(&corev1.Binding{ ObjectMeta: metav1.ObjectMeta{Name: pod.GetName()}, Target: corev1.ObjectReference{Name: "foo", Kind: "Node", APIVersion: "v1"}, }).Do(context.TODO()).Error() case gvr("", "v1", "pods/eviction"): err = c.clientset.CoreV1().RESTClient().Post().Namespace(pod.GetNamespace()).Resource("pods").Name(pod.GetName()).SubResource("eviction").Body(&policyv1.Eviction{ ObjectMeta: metav1.ObjectMeta{Name: pod.GetName()}, DeleteOptions: &forceDelete, }).Do(context.TODO()).Error() default: c.t.Errorf("unhandled resource %#v", c.gvr) return } if err != nil { c.t.Error(err) return } } // testSubresourceProxy verifies proxy subresources func testSubresourceProxy(c *testContext) { parentGVR := getParentGVR(c.gvr) parentResource := c.resources[parentGVR] obj, err := createOrGetResource(c.client, parentGVR, parentResource) if err != nil { c.t.Error(err) return } gvrWithoutSubresources := c.gvr gvrWithoutSubresources.Resource = strings.Split(gvrWithoutSubresources.Resource, "/")[0] subresources := strings.Split(c.gvr.Resource, "/")[1:] verbToHTTPMethods := map[string][]string{ "create": {"POST", "GET", "HEAD", "OPTIONS"}, // also test read-only verbs map to Connect admission "update": {"PUT"}, "patch": {"PATCH"}, "delete": {"DELETE"}, } httpMethodsToTest, ok := verbToHTTPMethods[c.verb] if !ok { c.t.Errorf("unknown verb %v", c.verb) return } for _, httpMethod := range httpMethodsToTest { c.t.Logf("testing %v", httpMethod) request := c.clientset.CoreV1().RESTClient().Verb(httpMethod) // add the namespace if required if len(obj.GetNamespace()) > 0 { request = request.Namespace(obj.GetNamespace()) } // set expectations c.admissionHolder.expect(c.gvr, gvk(c.resource.Group, c.resource.Version, c.resource.Kind), schema.GroupVersionKind{}, v1beta1.Connect, obj.GetName(), obj.GetNamespace(), true, false, false) // run the request. we don't actually care if the request is successful, just that admission gets called as expected err = request.Resource(gvrWithoutSubresources.Resource).Name(obj.GetName()).SubResource(subresources...).Do(context.TODO()).Error() if err != nil { c.t.Logf("debug: result of subresource proxy (error expected): %v", err) } // verify the result c.admissionHolder.verify(c.t) } } func testPruningRandomNumbers(c *testContext) { testResourceCreate(c) cr2pant, err := c.client.Resource(c.gvr).Get(context.TODO(), "fortytwo", metav1.GetOptions{}) if err != nil { c.t.Error(err) return } foo, found, err := unstructured.NestedString(cr2pant.Object, "foo") if err != nil { c.t.Error(err) return } if found { c.t.Errorf("expected .foo to be pruned, but got: %s", foo) } } // DIFF: Commented out for policy test. To be added back for mutating policy tests. // This test deoends on "foo" being set to test by admission webhook/policy. // func testNoPruningCustomFancy(c *testContext) { // testResourceCreate(c) // cr2pant, err := c.client.Resource(c.gvr).Get(context.TODO(), "cr2pant", metav1.GetOptions{}) // if err != nil { // c.t.Error(err) // return // } // foo, _, err := unstructured.NestedString(cr2pant.Object, "foo") // if err != nil { // c.t.Error(err) // return // } // // check that no pruning took place // if expected, got := "test", foo; expected != got { // c.t.Errorf("expected /foo to be %q, got: %q", expected, got) // } // }