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 }