k8s.io/apiserver@v0.31.1/pkg/endpoints/metrics/metrics_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 metrics
    18  
    19  import (
    20  	"context"
    21  	"net/http"
    22  	"net/url"
    23  	"strings"
    24  	"testing"
    25  
    26  	metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
    27  	"k8s.io/apimachinery/pkg/fields"
    28  	"k8s.io/apiserver/pkg/endpoints/request"
    29  	"k8s.io/apiserver/pkg/endpoints/responsewriter"
    30  	"k8s.io/component-base/metrics/legacyregistry"
    31  	"k8s.io/component-base/metrics/testutil"
    32  )
    33  
    34  func TestCleanVerb(t *testing.T) {
    35  	testCases := []struct {
    36  		desc          string
    37  		initialVerb   string
    38  		suggestedVerb string
    39  		request       *http.Request
    40  		requestInfo   *request.RequestInfo
    41  		expectedVerb  string
    42  	}{
    43  		{
    44  			desc:         "An empty string should be designated as unknown",
    45  			initialVerb:  "",
    46  			request:      nil,
    47  			expectedVerb: "other",
    48  		},
    49  		{
    50  			desc:         "LIST should normally map to LIST",
    51  			initialVerb:  "LIST",
    52  			request:      nil,
    53  			expectedVerb: "LIST",
    54  		},
    55  		{
    56  			desc:        "LIST should be transformed to WATCH if we have the right query param on the request",
    57  			initialVerb: "LIST",
    58  			request: &http.Request{
    59  				Method: "GET",
    60  				URL: &url.URL{
    61  					RawQuery: "watch=true",
    62  				},
    63  			},
    64  			expectedVerb: "WATCH",
    65  		},
    66  		{
    67  			desc:        "LIST isn't transformed to WATCH if we have query params that do not include watch",
    68  			initialVerb: "LIST",
    69  			request: &http.Request{
    70  				Method: "GET",
    71  				URL: &url.URL{
    72  					RawQuery: "blah=asdf&something=else",
    73  				},
    74  			},
    75  			expectedVerb: "LIST",
    76  		},
    77  		{
    78  			// The above may seem counter-intuitive, but it actually is needed for cases like
    79  			// watching a single item, e.g.:
    80  			//  /api/v1/namespaces/foo/pods/bar?fieldSelector=metadata.name=baz&watch=true
    81  			desc:        "GET is transformed to WATCH if we have the right query param on the request",
    82  			initialVerb: "GET",
    83  			request: &http.Request{
    84  				Method: "GET",
    85  				URL: &url.URL{
    86  					RawQuery: "watch=true",
    87  				},
    88  			},
    89  			expectedVerb: "WATCH",
    90  		},
    91  		{
    92  			desc:          "LIST is transformed to WATCH for the old pattern watch",
    93  			initialVerb:   "LIST",
    94  			suggestedVerb: "WATCH",
    95  			request: &http.Request{
    96  				Method: "GET",
    97  				URL: &url.URL{
    98  					RawQuery: "/api/v1/watch/pods",
    99  				},
   100  			},
   101  			expectedVerb: "WATCH",
   102  		},
   103  		{
   104  			desc:          "LIST is transformed to WATCH for the old pattern watchlist",
   105  			initialVerb:   "LIST",
   106  			suggestedVerb: "WATCHLIST",
   107  			request: &http.Request{
   108  				Method: "GET",
   109  				URL: &url.URL{
   110  					RawQuery: "/api/v1/watch/pods",
   111  				},
   112  			},
   113  			expectedVerb: "WATCH",
   114  		},
   115  		{
   116  			desc:         "WATCHLIST should be transformed to WATCH",
   117  			initialVerb:  "WATCHLIST",
   118  			request:      nil,
   119  			expectedVerb: "WATCH",
   120  		},
   121  		{
   122  			desc:        "PATCH should be transformed to APPLY with the right content type",
   123  			initialVerb: "PATCH",
   124  			request: &http.Request{
   125  				Header: http.Header{
   126  					"Content-Type": []string{"application/apply-patch+yaml"},
   127  				},
   128  			},
   129  			expectedVerb: "APPLY",
   130  		},
   131  		{
   132  			desc:         "PATCH shouldn't be transformed to APPLY without the right content type",
   133  			initialVerb:  "PATCH",
   134  			request:      nil,
   135  			expectedVerb: "PATCH",
   136  		},
   137  		{
   138  			desc:         "WATCHLIST should be transformed to WATCH",
   139  			initialVerb:  "WATCHLIST",
   140  			request:      nil,
   141  			expectedVerb: "WATCH",
   142  		},
   143  		{
   144  			desc:         "unexpected verbs should be designated as unknown",
   145  			initialVerb:  "notValid",
   146  			request:      nil,
   147  			expectedVerb: "other",
   148  		},
   149  		{
   150  			desc:        "Pod logs should be transformed to CONNECT",
   151  			initialVerb: "GET",
   152  			request: &http.Request{
   153  				Method: "GET",
   154  				URL: &url.URL{
   155  					RawQuery: "/api/v1/namespaces/default/pods/test-pod/log",
   156  				},
   157  			},
   158  			requestInfo: &request.RequestInfo{
   159  				Verb:              "GET",
   160  				Resource:          "pods",
   161  				IsResourceRequest: true,
   162  				Subresource:       "log",
   163  			},
   164  			expectedVerb: "CONNECT",
   165  		},
   166  		{
   167  			desc:        "Pod exec should be transformed to CONNECT",
   168  			initialVerb: "POST",
   169  			request: &http.Request{
   170  				Method: "POST",
   171  				URL: &url.URL{
   172  					RawQuery: "/api/v1/namespaces/default/pods/test-pod/exec?command=sh",
   173  				},
   174  				Header: map[string][]string{
   175  					"Connection": {"Upgrade"},
   176  					"Upgrade":    {"SPDY/3.1"},
   177  					"X-Stream-Protocol-Version": {
   178  						"v4.channel.k8s.io", "v3.channel.k8s.io", "v2.channel.k8s.io", "channel.k8s.io",
   179  					},
   180  				},
   181  			},
   182  			requestInfo: &request.RequestInfo{
   183  				Verb:              "POST",
   184  				Resource:          "pods",
   185  				IsResourceRequest: true,
   186  				Subresource:       "exec",
   187  			},
   188  			expectedVerb: "CONNECT",
   189  		},
   190  		{
   191  			desc:        "Pod portforward should be transformed to CONNECT",
   192  			initialVerb: "POST",
   193  			request: &http.Request{
   194  				Method: "POST",
   195  				URL: &url.URL{
   196  					RawQuery: "/api/v1/namespaces/default/pods/test-pod/portforward",
   197  				},
   198  				Header: map[string][]string{
   199  					"Connection": {"Upgrade"},
   200  					"Upgrade":    {"SPDY/3.1"},
   201  					"X-Stream-Protocol-Version": {
   202  						"v4.channel.k8s.io", "v3.channel.k8s.io", "v2.channel.k8s.io", "channel.k8s.io",
   203  					},
   204  				},
   205  			},
   206  			requestInfo: &request.RequestInfo{
   207  				Verb:              "POST",
   208  				Resource:          "pods",
   209  				IsResourceRequest: true,
   210  				Subresource:       "portforward",
   211  			},
   212  			expectedVerb: "CONNECT",
   213  		},
   214  		{
   215  			desc:        "Deployment scale should not be transformed to CONNECT",
   216  			initialVerb: "PUT",
   217  			request: &http.Request{
   218  				Method: "PUT",
   219  				URL: &url.URL{
   220  					RawQuery: "/apis/apps/v1/namespaces/default/deployments/test-1/scale",
   221  				},
   222  				Header: map[string][]string{},
   223  			},
   224  			requestInfo: &request.RequestInfo{
   225  				Verb:              "PUT",
   226  				Resource:          "deployments",
   227  				IsResourceRequest: true,
   228  				Subresource:       "scale",
   229  			},
   230  			expectedVerb: "PUT",
   231  		},
   232  	}
   233  	for _, tt := range testCases {
   234  		t.Run(tt.initialVerb, func(t *testing.T) {
   235  			req := &http.Request{URL: &url.URL{}}
   236  			if tt.request != nil {
   237  				req = tt.request
   238  			}
   239  			cleansedVerb := cleanVerb(tt.initialVerb, tt.suggestedVerb, req, tt.requestInfo)
   240  			if cleansedVerb != tt.expectedVerb {
   241  				t.Errorf("Got %s, but expected %s", cleansedVerb, tt.expectedVerb)
   242  			}
   243  		})
   244  	}
   245  }
   246  
   247  func TestCleanScope(t *testing.T) {
   248  	testCases := []struct {
   249  		name          string
   250  		requestInfo   *request.RequestInfo
   251  		expectedScope string
   252  	}{
   253  		{
   254  			name:          "empty scope",
   255  			requestInfo:   &request.RequestInfo{},
   256  			expectedScope: "",
   257  		},
   258  		{
   259  			name: "resource scope",
   260  			requestInfo: &request.RequestInfo{
   261  				Name:              "my-resource",
   262  				Namespace:         "my-namespace",
   263  				IsResourceRequest: false,
   264  			},
   265  			expectedScope: "resource",
   266  		},
   267  		{
   268  			name: "POST resource scope",
   269  			requestInfo: &request.RequestInfo{
   270  				Verb:              "create",
   271  				Namespace:         "my-namespace",
   272  				IsResourceRequest: false,
   273  			},
   274  			expectedScope: "resource",
   275  		},
   276  		{
   277  			name: "namespace scope",
   278  			requestInfo: &request.RequestInfo{
   279  				Namespace:         "my-namespace",
   280  				IsResourceRequest: false,
   281  			},
   282  			expectedScope: "namespace",
   283  		},
   284  		{
   285  			name: "cluster scope",
   286  			requestInfo: &request.RequestInfo{
   287  				Namespace:         "",
   288  				IsResourceRequest: true,
   289  			},
   290  			expectedScope: "cluster",
   291  		},
   292  	}
   293  
   294  	for _, test := range testCases {
   295  		t.Run(test.name, func(t *testing.T) {
   296  			if CleanScope(test.requestInfo) != test.expectedScope {
   297  				t.Errorf("failed to clean scope: %v", test.requestInfo)
   298  			}
   299  		})
   300  	}
   301  }
   302  
   303  func TestCleanFieldValidation(t *testing.T) {
   304  	testCases := []struct {
   305  		name                    string
   306  		url                     *url.URL
   307  		expectedFieldValidation string
   308  	}{
   309  		{
   310  			name:                    "empty field validation",
   311  			url:                     &url.URL{},
   312  			expectedFieldValidation: "",
   313  		},
   314  		{
   315  			name: "ignore field validation",
   316  			url: &url.URL{
   317  				RawQuery: "fieldValidation=Ignore",
   318  			},
   319  			expectedFieldValidation: "Ignore",
   320  		},
   321  		{
   322  			name: "warn field validation",
   323  			url: &url.URL{
   324  				RawQuery: "fieldValidation=Warn",
   325  			},
   326  			expectedFieldValidation: "Warn",
   327  		},
   328  		{
   329  			name: "strict field validation",
   330  			url: &url.URL{
   331  				RawQuery: "fieldValidation=Strict",
   332  			},
   333  			expectedFieldValidation: "Strict",
   334  		},
   335  		{
   336  			name: "invalid field validation",
   337  			url: &url.URL{
   338  				RawQuery: "fieldValidation=foo",
   339  			},
   340  			expectedFieldValidation: "invalid",
   341  		},
   342  		{
   343  			name: "multiple field validation",
   344  			url: &url.URL{
   345  				RawQuery: "fieldValidation=Strict&fieldValidation=Ignore",
   346  			},
   347  			expectedFieldValidation: "invalid",
   348  		},
   349  	}
   350  	for _, test := range testCases {
   351  		t.Run(test.name, func(t *testing.T) {
   352  			if fieldValidation := cleanFieldValidation(test.url); fieldValidation != test.expectedFieldValidation {
   353  				t.Errorf("failed to clean field validation, expected: %s, got: %s", test.expectedFieldValidation, fieldValidation)
   354  			}
   355  		})
   356  	}
   357  }
   358  
   359  func TestResponseWriterDecorator(t *testing.T) {
   360  	decorator := &ResponseWriterDelegator{
   361  		ResponseWriter: &responsewriter.FakeResponseWriter{},
   362  	}
   363  	var w http.ResponseWriter = decorator
   364  
   365  	if inner := w.(responsewriter.UserProvidedDecorator).Unwrap(); inner != decorator.ResponseWriter {
   366  		t.Errorf("Expected the decorator to return the inner http.ResponseWriter object")
   367  	}
   368  }
   369  
   370  func TestRecordDroppedRequests(t *testing.T) {
   371  	testedMetrics := []string{
   372  		"apiserver_request_total",
   373  	}
   374  
   375  	testCases := []struct {
   376  		desc        string
   377  		request     *http.Request
   378  		requestInfo *request.RequestInfo
   379  		isMutating  bool
   380  		want        string
   381  	}{
   382  		{
   383  			desc: "list pods",
   384  			request: &http.Request{
   385  				Method: "GET",
   386  				URL: &url.URL{
   387  					RawPath: "/api/v1/pods",
   388  				},
   389  			},
   390  			requestInfo: &request.RequestInfo{
   391  				Verb:              "list",
   392  				APIGroup:          "",
   393  				APIVersion:        "v1",
   394  				Resource:          "pods",
   395  				IsResourceRequest: true,
   396  			},
   397  			isMutating: false,
   398  			want: `
   399  			            # HELP apiserver_request_total [STABLE] Counter of apiserver requests broken out for each verb, dry run value, group, version, resource, scope, component, and HTTP response code.
   400  			            # TYPE apiserver_request_total counter
   401  			            apiserver_request_total{code="429",component="apiserver",dry_run="",group="",resource="pods",scope="cluster",subresource="",verb="LIST",version="v1"} 1
   402  				`,
   403  		},
   404  		{
   405  			desc: "post pods",
   406  			request: &http.Request{
   407  				Method: "POST",
   408  				URL: &url.URL{
   409  					RawPath: "/api/v1/namespaces/foo/pods",
   410  				},
   411  			},
   412  			requestInfo: &request.RequestInfo{
   413  				Verb:              "create",
   414  				APIGroup:          "",
   415  				APIVersion:        "v1",
   416  				Resource:          "pods",
   417  				IsResourceRequest: true,
   418  			},
   419  			isMutating: true,
   420  			want: `
   421  			            # HELP apiserver_request_total [STABLE] Counter of apiserver requests broken out for each verb, dry run value, group, version, resource, scope, component, and HTTP response code.
   422  			            # TYPE apiserver_request_total counter
   423  			            apiserver_request_total{code="429",component="apiserver",dry_run="",group="",resource="pods",scope="resource",subresource="",verb="POST",version="v1"} 1
   424  				`,
   425  		},
   426  		{
   427  			desc: "dry-run patch job status",
   428  			request: &http.Request{
   429  				Method: "PATCH",
   430  				URL: &url.URL{
   431  					RawPath:  "/apis/batch/v1/namespaces/foo/jobs/bar/status",
   432  					RawQuery: "dryRun=All",
   433  				},
   434  			},
   435  			requestInfo: &request.RequestInfo{
   436  				Verb:              "patch",
   437  				APIGroup:          "batch",
   438  				APIVersion:        "v1",
   439  				Resource:          "jobs",
   440  				Name:              "bar",
   441  				Subresource:       "status",
   442  				IsResourceRequest: true,
   443  			},
   444  			isMutating: true,
   445  			want: `
   446  			            # HELP apiserver_request_total [STABLE] Counter of apiserver requests broken out for each verb, dry run value, group, version, resource, scope, component, and HTTP response code.
   447  			            # TYPE apiserver_request_total counter
   448  			            apiserver_request_total{code="429",component="apiserver",dry_run="All",group="batch",resource="jobs",scope="resource",subresource="status",verb="PATCH",version="v1"} 1
   449  				`,
   450  		},
   451  	}
   452  
   453  	// Since prometheus' gatherer is global, other tests may have updated metrics already, so
   454  	// we need to reset them prior running this test.
   455  	// This also implies that we can't run this test in parallel with other tests.
   456  	Register()
   457  	requestCounter.Reset()
   458  
   459  	for _, test := range testCases {
   460  		t.Run(test.desc, func(t *testing.T) {
   461  			defer requestCounter.Reset()
   462  
   463  			RecordDroppedRequest(test.request, test.requestInfo, APIServerComponent, test.isMutating)
   464  
   465  			if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(test.want), testedMetrics...); err != nil {
   466  				t.Fatal(err)
   467  			}
   468  
   469  		})
   470  	}
   471  }
   472  
   473  func TestCleanListScope(t *testing.T) {
   474  	scenarios := []struct {
   475  		name          string
   476  		ctx           context.Context
   477  		opts          *metainternalversion.ListOptions
   478  		expectedScope string
   479  	}{
   480  		{
   481  			name: "empty scope",
   482  		},
   483  		{
   484  			name: "empty scope with empty request info",
   485  			ctx:  request.WithRequestInfo(context.TODO(), &request.RequestInfo{}),
   486  		},
   487  		{
   488  			name:          "namespace from ctx",
   489  			ctx:           request.WithNamespace(context.TODO(), "foo"),
   490  			expectedScope: "namespace",
   491  		},
   492  		{
   493  			name: "namespace from field selector",
   494  			opts: &metainternalversion.ListOptions{
   495  				FieldSelector: fields.ParseSelectorOrDie("metadata.namespace=foo"),
   496  			},
   497  			expectedScope: "namespace",
   498  		},
   499  		{
   500  			name:          "name from request info",
   501  			ctx:           request.WithRequestInfo(context.TODO(), &request.RequestInfo{Name: "bar"}),
   502  			expectedScope: "resource",
   503  		},
   504  		{
   505  			name: "name from field selector",
   506  			opts: &metainternalversion.ListOptions{
   507  				FieldSelector: fields.ParseSelectorOrDie("metadata.name=bar"),
   508  			},
   509  			expectedScope: "resource",
   510  		},
   511  		{
   512  			name:          "cluster scope request",
   513  			ctx:           request.WithRequestInfo(context.TODO(), &request.RequestInfo{IsResourceRequest: true}),
   514  			expectedScope: "cluster",
   515  		},
   516  	}
   517  
   518  	for _, scenario := range scenarios {
   519  		t.Run(scenario.name, func(t *testing.T) {
   520  			if scenario.ctx == nil {
   521  				scenario.ctx = context.TODO()
   522  			}
   523  			actualScope := CleanListScope(scenario.ctx, scenario.opts)
   524  			if actualScope != scenario.expectedScope {
   525  				t.Errorf("unexpected scope = %s, expected = %s", actualScope, scenario.expectedScope)
   526  			}
   527  		})
   528  	}
   529  }