k8s.io/kubernetes@v1.29.3/test/integration/apiserver/admissionwebhook/timeout_test.go (about)

     1  /*
     2  Copyright 2019 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  	"fmt"
    25  	"io"
    26  	"net/http"
    27  	"net/http/httptest"
    28  	"sort"
    29  	"strings"
    30  	"sync"
    31  	"testing"
    32  	"time"
    33  
    34  	"k8s.io/api/admission/v1beta1"
    35  	admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
    36  	corev1 "k8s.io/api/core/v1"
    37  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    38  	"k8s.io/apimachinery/pkg/types"
    39  	"k8s.io/apimachinery/pkg/util/sets"
    40  	"k8s.io/apimachinery/pkg/util/wait"
    41  	clientset "k8s.io/client-go/kubernetes"
    42  	"k8s.io/client-go/rest"
    43  	kubeapiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing"
    44  	"k8s.io/kubernetes/test/integration/framework"
    45  )
    46  
    47  const (
    48  	testTimeoutClientUsername = "webhook-timeout-integration-client"
    49  )
    50  
    51  // TestWebhookTimeoutWithWatchCache ensures that the admission webhook timeout policy is applied correctly with the watch cache enabled.
    52  func TestWebhookTimeoutWithWatchCache(t *testing.T) {
    53  	testWebhookTimeout(t, true)
    54  }
    55  
    56  // TestWebhookTimeoutWithoutWatchCache ensures that the admission webhook timeout policy is applied correctly without the watch cache enabled.
    57  func TestWebhookTimeoutWithoutWatchCache(t *testing.T) {
    58  	testWebhookTimeout(t, false)
    59  }
    60  
    61  type invocation struct {
    62  	path           string
    63  	timeoutSeconds int
    64  }
    65  
    66  // testWebhookTimeout ensures that the admission webhook timeout policy is applied correctly.
    67  func testWebhookTimeout(t *testing.T, watchCache bool) {
    68  	type testWebhook struct {
    69  		path           string
    70  		timeoutSeconds int32
    71  		policy         admissionregistrationv1.FailurePolicyType
    72  		objectSelector *metav1.LabelSelector
    73  	}
    74  
    75  	testCases := []struct {
    76  		name               string
    77  		timeoutSeconds     int32
    78  		mutatingWebhooks   []testWebhook
    79  		validatingWebhooks []testWebhook
    80  		expectInvocations  []invocation
    81  		expectError        bool
    82  		errorContainsAnyOf []string
    83  	}{
    84  		{
    85  			name:           "minimum of request timeout or webhook timeout propagated",
    86  			timeoutSeconds: 10,
    87  			mutatingWebhooks: []testWebhook{
    88  				{path: "/mutating/1/0s", policy: admissionregistrationv1.Fail, timeoutSeconds: 20},
    89  				{path: "/mutating/2/0s", policy: admissionregistrationv1.Fail, timeoutSeconds: 5},
    90  			},
    91  			validatingWebhooks: []testWebhook{
    92  				{path: "/validating/3/0s", policy: admissionregistrationv1.Fail, timeoutSeconds: 20},
    93  				{path: "/validating/4/0s", policy: admissionregistrationv1.Fail, timeoutSeconds: 5},
    94  			},
    95  			expectInvocations: []invocation{
    96  				{path: "/mutating/1/0s", timeoutSeconds: 10},   // from request
    97  				{path: "/mutating/2/0s", timeoutSeconds: 5},    // from webhook config
    98  				{path: "/validating/3/0s", timeoutSeconds: 10}, // from request
    99  				{path: "/validating/4/0s", timeoutSeconds: 5},  // from webhook config
   100  			},
   101  		},
   102  		{
   103  			name:           "webhooks consume client timeout available, not webhook timeout",
   104  			timeoutSeconds: 10,
   105  			mutatingWebhooks: []testWebhook{
   106  				{path: "/mutating/1/1s", policy: admissionregistrationv1.Fail, timeoutSeconds: 20},
   107  				{path: "/mutating/2/1s", policy: admissionregistrationv1.Fail, timeoutSeconds: 5},
   108  				{path: "/mutating/3/1s", policy: admissionregistrationv1.Fail, timeoutSeconds: 20},
   109  			},
   110  			validatingWebhooks: []testWebhook{
   111  				{path: "/validating/4/1s", policy: admissionregistrationv1.Fail, timeoutSeconds: 5},
   112  				{path: "/validating/5/1s", policy: admissionregistrationv1.Fail, timeoutSeconds: 10},
   113  				{path: "/validating/6/1s", policy: admissionregistrationv1.Fail, timeoutSeconds: 20},
   114  			},
   115  			expectInvocations: []invocation{
   116  				{path: "/mutating/1/1s", timeoutSeconds: 10},  // from request
   117  				{path: "/mutating/2/1s", timeoutSeconds: 5},   // from webhook config (less than request - 1s consumed)
   118  				{path: "/mutating/3/1s", timeoutSeconds: 8},   // from request - 2s consumed
   119  				{path: "/validating/4/1s", timeoutSeconds: 5}, // from webhook config (less than request - 3s consumed by mutating)
   120  				{path: "/validating/5/1s", timeoutSeconds: 7}, // from request - 3s consumed by mutating
   121  				{path: "/validating/6/1s", timeoutSeconds: 7}, // from request - 3s consumed by mutating
   122  			},
   123  		},
   124  		{
   125  			name:           "timed out client requests skip later mutating webhooks (regardless of failure policy) and fail",
   126  			timeoutSeconds: 3,
   127  			mutatingWebhooks: []testWebhook{
   128  				{path: "/mutating/1/5s", policy: admissionregistrationv1.Ignore, timeoutSeconds: 4},
   129  				{path: "/mutating/2/1s", policy: admissionregistrationv1.Ignore, timeoutSeconds: 5},
   130  				{path: "/mutating/3/1s", policy: admissionregistrationv1.Ignore, timeoutSeconds: 5},
   131  			},
   132  			expectInvocations: []invocation{
   133  				{path: "/mutating/1/5s", timeoutSeconds: 3}, // from request
   134  			},
   135  			expectError: true,
   136  			errorContainsAnyOf: []string{
   137  				// refer to https://github.com/kubernetes/kubernetes/issues/98606#issuecomment-774832633
   138  				// for the reason for triggering this scenario
   139  				"stream error",
   140  				"the server was unable to return a response in the time allotted",
   141  			},
   142  		},
   143  	}
   144  
   145  	roots := x509.NewCertPool()
   146  	if !roots.AppendCertsFromPEM(localhostCert) {
   147  		t.Fatal("Failed to append Cert from PEM")
   148  	}
   149  	cert, err := tls.X509KeyPair(localhostCert, localhostKey)
   150  	if err != nil {
   151  		t.Fatalf("Failed to build cert with error: %+v", err)
   152  	}
   153  
   154  	recorder := &timeoutRecorder{invocations: []invocation{}, markers: sets.NewString()}
   155  	webhookServer := httptest.NewUnstartedServer(newTimeoutWebhookHandler(recorder))
   156  	webhookServer.TLS = &tls.Config{
   157  
   158  		RootCAs:      roots,
   159  		Certificates: []tls.Certificate{cert},
   160  	}
   161  	webhookServer.StartTLS()
   162  	defer webhookServer.Close()
   163  
   164  	s := kubeapiservertesting.StartTestServerOrDie(t, kubeapiservertesting.NewDefaultTestServerOptions(), []string{
   165  		"--disable-admission-plugins=ServiceAccount",
   166  		fmt.Sprintf("--watch-cache=%v", watchCache),
   167  	}, framework.SharedEtcd())
   168  	defer s.TearDownFn()
   169  
   170  	// Configure a client with a distinct user name so that it is easy to distinguish requests
   171  	// made by the client from requests made by controllers. We use this to filter out requests
   172  	// before recording them to ensure we don't accidentally mistake requests from controllers
   173  	// as requests made by the client.
   174  	clientConfig := rest.CopyConfig(s.ClientConfig)
   175  	clientConfig.Timeout = 0 // no timeout, we want to set this manually
   176  	clientConfig.Impersonate.UserName = testTimeoutClientUsername
   177  	clientConfig.Impersonate.Groups = []string{"system:masters", "system:authenticated"}
   178  	client, err := clientset.NewForConfig(clientConfig)
   179  	if err != nil {
   180  		t.Fatalf("unexpected error: %v", err)
   181  	}
   182  
   183  	_, err = client.CoreV1().Pods("default").Create(context.TODO(), timeoutMarkerFixture, metav1.CreateOptions{})
   184  	if err != nil {
   185  		t.Fatal(err)
   186  	}
   187  
   188  	for i, tt := range testCases {
   189  		t.Run(tt.name, func(t *testing.T) {
   190  			recorder.Reset()
   191  			ns := fmt.Sprintf("reinvoke-%d", i)
   192  			_, err = client.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}, metav1.CreateOptions{})
   193  			if err != nil {
   194  				t.Fatal(err)
   195  			}
   196  
   197  			mutatingWebhooks := []admissionregistrationv1.MutatingWebhook{}
   198  			for j, webhook := range tt.mutatingWebhooks {
   199  				name := fmt.Sprintf("admission.integration.test.%d.%s", j, strings.Replace(strings.TrimPrefix(webhook.path, "/"), "/", "-", -1))
   200  				endpoint := webhookServer.URL + webhook.path
   201  				mutatingWebhooks = append(mutatingWebhooks, admissionregistrationv1.MutatingWebhook{
   202  					Name: name,
   203  					ClientConfig: admissionregistrationv1.WebhookClientConfig{
   204  						URL:      &endpoint,
   205  						CABundle: localhostCert,
   206  					},
   207  					Rules: []admissionregistrationv1.RuleWithOperations{{
   208  						Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll},
   209  						Rule:       admissionregistrationv1.Rule{APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}},
   210  					}},
   211  					ObjectSelector:          webhook.objectSelector,
   212  					FailurePolicy:           &tt.mutatingWebhooks[j].policy,
   213  					TimeoutSeconds:          &tt.mutatingWebhooks[j].timeoutSeconds,
   214  					AdmissionReviewVersions: []string{"v1beta1"},
   215  					SideEffects:             &noSideEffects,
   216  				})
   217  			}
   218  			mutatingCfg, err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().Create(context.TODO(), &admissionregistrationv1.MutatingWebhookConfiguration{
   219  				ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("admission.integration.test-%d", i)},
   220  				Webhooks:   mutatingWebhooks,
   221  			}, metav1.CreateOptions{})
   222  			if err != nil {
   223  				t.Fatal(err)
   224  			}
   225  			defer func() {
   226  				err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().Delete(context.TODO(), mutatingCfg.GetName(), metav1.DeleteOptions{})
   227  				if err != nil {
   228  					t.Fatal(err)
   229  				}
   230  			}()
   231  
   232  			validatingWebhooks := []admissionregistrationv1.ValidatingWebhook{}
   233  			for j, webhook := range tt.validatingWebhooks {
   234  				name := fmt.Sprintf("admission.integration.test.%d.%s", j, strings.Replace(strings.TrimPrefix(webhook.path, "/"), "/", "-", -1))
   235  				endpoint := webhookServer.URL + webhook.path
   236  				validatingWebhooks = append(validatingWebhooks, admissionregistrationv1.ValidatingWebhook{
   237  					Name: name,
   238  					ClientConfig: admissionregistrationv1.WebhookClientConfig{
   239  						URL:      &endpoint,
   240  						CABundle: localhostCert,
   241  					},
   242  					Rules: []admissionregistrationv1.RuleWithOperations{{
   243  						Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll},
   244  						Rule:       admissionregistrationv1.Rule{APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}},
   245  					}},
   246  					ObjectSelector:          webhook.objectSelector,
   247  					FailurePolicy:           &tt.validatingWebhooks[j].policy,
   248  					TimeoutSeconds:          &tt.validatingWebhooks[j].timeoutSeconds,
   249  					AdmissionReviewVersions: []string{"v1beta1"},
   250  					SideEffects:             &noSideEffects,
   251  				})
   252  			}
   253  			validatingCfg, err := client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Create(context.TODO(), &admissionregistrationv1.ValidatingWebhookConfiguration{
   254  				ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("admission.integration.test-%d", i)},
   255  				Webhooks:   validatingWebhooks,
   256  			}, metav1.CreateOptions{})
   257  			if err != nil {
   258  				t.Fatal(err)
   259  			}
   260  			defer func() {
   261  				err := client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Delete(context.TODO(), validatingCfg.GetName(), metav1.DeleteOptions{})
   262  				if err != nil {
   263  					t.Fatal(err)
   264  				}
   265  			}()
   266  
   267  			// wait until new webhook is called the first time
   268  			if err := wait.PollImmediate(time.Millisecond*5, wait.ForeverTestTimeout, func() (bool, error) {
   269  				_, err = client.CoreV1().Pods("default").Patch(context.TODO(), timeoutMarkerFixture.Name, types.JSONPatchType, []byte("[]"), metav1.PatchOptions{})
   270  				received := recorder.MarkerReceived()
   271  				if len(tt.mutatingWebhooks) > 0 && !received.Has("mutating") {
   272  					t.Logf("Waiting for mutating webhooks to become effective, getting marker object: %v", err)
   273  					return false, nil
   274  				}
   275  				if len(tt.validatingWebhooks) > 0 && !received.Has("validating") {
   276  					t.Logf("Waiting for validating webhooks to become effective, getting marker object: %v", err)
   277  					return false, nil
   278  				}
   279  				return true, nil
   280  			}); err != nil {
   281  				t.Fatal(err)
   282  			}
   283  
   284  			pod := &corev1.Pod{
   285  				TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"},
   286  				ObjectMeta: metav1.ObjectMeta{
   287  					Namespace: ns,
   288  					Name:      "labeled",
   289  					Labels:    map[string]string{"x": "true"},
   290  				},
   291  				Spec: corev1.PodSpec{
   292  					Containers: []corev1.Container{{
   293  						Name:  "fake-name",
   294  						Image: "fakeimage",
   295  					}},
   296  				},
   297  			}
   298  
   299  			body, err := json.Marshal(pod)
   300  			if err != nil {
   301  				t.Fatal(err)
   302  			}
   303  
   304  			// set the timeout parameter manually so we don't actually cut off the request client-side, and wait for the server response
   305  			err = client.CoreV1().RESTClient().Post().Resource("pods").Namespace(ns).Body(body).Param("timeout", fmt.Sprintf("%ds", tt.timeoutSeconds)).Do(context.TODO()).Error()
   306  			// _, err = testClient.CoreV1().Pods(ns).Create(pod)
   307  
   308  			if tt.expectError {
   309  				if err == nil {
   310  					t.Fatalf("expected error but got none")
   311  				}
   312  
   313  				expected := false
   314  				if len(tt.errorContainsAnyOf) != 0 {
   315  					for _, errStr := range tt.errorContainsAnyOf {
   316  						if strings.Contains(err.Error(), errStr) {
   317  							expected = true
   318  							break
   319  						}
   320  					}
   321  				}
   322  				if !expected {
   323  					t.Errorf("expected the error to be any of %q, but got: %v", tt.errorContainsAnyOf, err)
   324  				}
   325  				return
   326  			}
   327  
   328  			if err != nil {
   329  				t.Fatal(err)
   330  			}
   331  
   332  			if tt.expectInvocations != nil {
   333  				for i, invocation := range tt.expectInvocations {
   334  					if len(recorder.invocations) <= i {
   335  						t.Errorf("expected invocation of %s, got none", invocation.path)
   336  						continue
   337  					}
   338  
   339  					if recorder.invocations[i].path != invocation.path {
   340  						t.Errorf("expected invocation of %s, got %s", invocation.path, recorder.invocations[i].path)
   341  						continue
   342  					}
   343  					if recorder.invocations[i].timeoutSeconds != invocation.timeoutSeconds {
   344  						t.Errorf("expected invocation of %s with timeout %d, got %d", invocation.path, invocation.timeoutSeconds, recorder.invocations[i].timeoutSeconds)
   345  						continue
   346  					}
   347  				}
   348  
   349  				if len(recorder.invocations) > len(tt.expectInvocations) {
   350  					for _, invocation := range recorder.invocations[len(tt.expectInvocations):] {
   351  						t.Errorf("unexpected invocation of %s", invocation.path)
   352  					}
   353  				}
   354  			}
   355  		})
   356  	}
   357  }
   358  
   359  type timeoutRecorder struct {
   360  	mu          sync.Mutex
   361  	markers     sets.String
   362  	invocations []invocation
   363  }
   364  
   365  // Reset zeros out all counts
   366  func (i *timeoutRecorder) Reset() {
   367  	i.mu.Lock()
   368  	defer i.mu.Unlock()
   369  	i.invocations = []invocation{}
   370  	i.markers = sets.NewString()
   371  }
   372  
   373  // MarkerReceived records the specified markers were received and returns the set of received markers
   374  func (i *timeoutRecorder) MarkerReceived(markers ...string) sets.String {
   375  	i.mu.Lock()
   376  	defer i.mu.Unlock()
   377  	i.markers.Insert(markers...)
   378  	return i.markers.Union(nil)
   379  }
   380  
   381  func (i *timeoutRecorder) RecordInvocation(call invocation) {
   382  	i.mu.Lock()
   383  	defer i.mu.Unlock()
   384  	i.invocations = append(i.invocations, call)
   385  	sort.SliceStable(i.invocations, func(a, b int) bool {
   386  		aValidating := strings.Contains(i.invocations[a].path, "validating")
   387  		bValidating := strings.Contains(i.invocations[b].path, "validating")
   388  		switch {
   389  		case aValidating && bValidating:
   390  			// sort validating by path
   391  			return strings.Compare(i.invocations[a].path, i.invocations[b].path) < 0
   392  		case !aValidating && !bValidating:
   393  			// keep mutating in original order
   394  			return a < b
   395  		case aValidating && !bValidating:
   396  			// put validating last
   397  			return false
   398  		default:
   399  			return true
   400  		}
   401  	})
   402  }
   403  
   404  func newTimeoutWebhookHandler(recorder *timeoutRecorder) http.Handler {
   405  	allow := func(w http.ResponseWriter) {
   406  		w.Header().Set("Content-Type", "application/json")
   407  		json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{
   408  			Response: &v1beta1.AdmissionResponse{
   409  				Allowed: true,
   410  			},
   411  		})
   412  	}
   413  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   414  		defer r.Body.Close()
   415  		data, err := io.ReadAll(r.Body)
   416  		if err != nil {
   417  			http.Error(w, err.Error(), 400)
   418  		}
   419  		review := v1beta1.AdmissionReview{}
   420  		if err := json.Unmarshal(data, &review); err != nil {
   421  			http.Error(w, err.Error(), 400)
   422  		}
   423  		if review.Request.UserInfo.Username != testTimeoutClientUsername {
   424  			// skip requests not originating from this integration test's client
   425  			allow(w)
   426  			return
   427  		}
   428  
   429  		if len(review.Request.Object.Raw) == 0 {
   430  			http.Error(w, err.Error(), 400)
   431  		}
   432  		pod := &corev1.Pod{}
   433  		if err := json.Unmarshal(review.Request.Object.Raw, pod); err != nil {
   434  			http.Error(w, err.Error(), 400)
   435  		}
   436  
   437  		// When resetting between tests, a marker object is patched until this webhook
   438  		// observes it, at which point it is considered ready.
   439  		if pod.Namespace == timeoutMarkerFixture.Namespace && pod.Name == timeoutMarkerFixture.Name {
   440  			if strings.HasPrefix(r.URL.Path, "/mutating/") {
   441  				recorder.MarkerReceived("mutating")
   442  			}
   443  			if strings.HasPrefix(r.URL.Path, "/validating/") {
   444  				recorder.MarkerReceived("validating")
   445  			}
   446  			allow(w)
   447  			return
   448  		}
   449  
   450  		timeout, err := time.ParseDuration(r.URL.Query().Get("timeout"))
   451  		if err != nil {
   452  			http.Error(w, err.Error(), http.StatusBadRequest)
   453  		}
   454  		invocation := invocation{path: r.URL.Path, timeoutSeconds: int(timeout.Round(time.Second) / time.Second)}
   455  		recorder.RecordInvocation(invocation)
   456  
   457  		switch {
   458  		case strings.HasSuffix(r.URL.Path, "/0s"):
   459  			allow(w)
   460  		case strings.HasSuffix(r.URL.Path, "/1s"):
   461  			time.Sleep(time.Second)
   462  			allow(w)
   463  		case strings.HasSuffix(r.URL.Path, "/5s"):
   464  			time.Sleep(5 * time.Second)
   465  			allow(w)
   466  		default:
   467  			http.NotFound(w, r)
   468  		}
   469  	})
   470  }
   471  
   472  var timeoutMarkerFixture = &corev1.Pod{
   473  	ObjectMeta: metav1.ObjectMeta{
   474  		Namespace: "default",
   475  		Name:      "marker",
   476  	},
   477  	Spec: corev1.PodSpec{
   478  		Containers: []corev1.Container{{
   479  			Name:  "fake-name",
   480  			Image: "fakeimage",
   481  		}},
   482  	},
   483  }