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  }