k8s.io/apiserver@v0.31.1/pkg/endpoints/handlers/responsewriters/writers_test.go (about) 1 /* 2 Copyright 2016 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 responsewriters 18 19 import ( 20 "bytes" 21 "compress/gzip" 22 "encoding/hex" 23 "encoding/json" 24 "errors" 25 "fmt" 26 "io" 27 "io/ioutil" 28 "math/rand" 29 "net/http" 30 "net/http/httptest" 31 "net/url" 32 "os" 33 "reflect" 34 "strconv" 35 "testing" 36 "time" 37 38 "github.com/google/go-cmp/cmp" 39 v1 "k8s.io/api/core/v1" 40 kerrors "k8s.io/apimachinery/pkg/api/errors" 41 "k8s.io/apimachinery/pkg/runtime" 42 "k8s.io/apimachinery/pkg/runtime/schema" 43 "k8s.io/apimachinery/pkg/util/uuid" 44 "k8s.io/apiserver/pkg/features" 45 utilfeature "k8s.io/apiserver/pkg/util/feature" 46 featuregatetesting "k8s.io/component-base/featuregate/testing" 47 ) 48 49 const benchmarkSeed = 100 50 51 func TestSerializeObjectParallel(t *testing.T) { 52 largePayload := bytes.Repeat([]byte("0123456789abcdef"), defaultGzipThresholdBytes/16+1) 53 type test struct { 54 name string 55 56 mediaType string 57 out []byte 58 outErrs []error 59 req *http.Request 60 statusCode int 61 object runtime.Object 62 63 wantCode int 64 wantHeaders http.Header 65 } 66 newTest := func() test { 67 return test{ 68 name: "compress on gzip", 69 out: largePayload, 70 mediaType: "application/json", 71 req: &http.Request{ 72 Header: http.Header{ 73 "Accept-Encoding": []string{"gzip"}, 74 }, 75 URL: &url.URL{Path: "/path"}, 76 }, 77 wantCode: http.StatusOK, 78 wantHeaders: http.Header{ 79 "Content-Type": []string{"application/json"}, 80 "Content-Encoding": []string{"gzip"}, 81 "Vary": []string{"Accept-Encoding"}, 82 }, 83 } 84 } 85 featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.APIResponseCompression, true) 86 for i := 0; i < 100; i++ { 87 ctt := newTest() 88 t.Run(ctt.name, func(t *testing.T) { 89 defer func() { 90 if r := recover(); r != nil { 91 t.Fatalf("recovered from err %v", r) 92 } 93 }() 94 t.Parallel() 95 96 encoder := &fakeEncoder{ 97 buf: ctt.out, 98 errs: ctt.outErrs, 99 } 100 if ctt.statusCode == 0 { 101 ctt.statusCode = http.StatusOK 102 } 103 recorder := &fakeResponseRecorder{ 104 ResponseRecorder: httptest.NewRecorder(), 105 fe: encoder, 106 errorAfterEncoding: true, 107 } 108 SerializeObject(ctt.mediaType, encoder, recorder, ctt.req, ctt.statusCode, ctt.object) 109 result := recorder.Result() 110 if result.StatusCode != ctt.wantCode { 111 t.Fatalf("unexpected code: %v", result.StatusCode) 112 } 113 if !reflect.DeepEqual(result.Header, ctt.wantHeaders) { 114 t.Fatal(cmp.Diff(ctt.wantHeaders, result.Header)) 115 } 116 }) 117 } 118 } 119 120 func TestSerializeObject(t *testing.T) { 121 smallPayload := []byte("{test-object,test-object}") 122 largePayload := bytes.Repeat([]byte("0123456789abcdef"), defaultGzipThresholdBytes/16+1) 123 tests := []struct { 124 name string 125 126 compressionEnabled bool 127 128 mediaType string 129 out []byte 130 outErrs []error 131 req *http.Request 132 statusCode int 133 object runtime.Object 134 135 wantCode int 136 wantHeaders http.Header 137 wantBody []byte 138 }{ 139 { 140 name: "serialize object", 141 out: smallPayload, 142 req: &http.Request{Header: http.Header{}, URL: &url.URL{Path: "/path"}}, 143 wantCode: http.StatusOK, 144 wantHeaders: http.Header{"Content-Type": []string{""}}, 145 wantBody: smallPayload, 146 }, 147 148 { 149 name: "return content type", 150 out: smallPayload, 151 mediaType: "application/json", 152 req: &http.Request{Header: http.Header{}, URL: &url.URL{Path: "/path"}}, 153 wantCode: http.StatusOK, 154 wantHeaders: http.Header{"Content-Type": []string{"application/json"}}, 155 wantBody: smallPayload, 156 }, 157 158 { 159 name: "return status code", 160 statusCode: http.StatusBadRequest, 161 out: smallPayload, 162 mediaType: "application/json", 163 req: &http.Request{Header: http.Header{}, URL: &url.URL{Path: "/path"}}, 164 wantCode: http.StatusBadRequest, 165 wantHeaders: http.Header{"Content-Type": []string{"application/json"}}, 166 wantBody: smallPayload, 167 }, 168 169 { 170 name: "fail to encode object", 171 out: smallPayload, 172 outErrs: []error{fmt.Errorf("bad")}, 173 mediaType: "application/json", 174 req: &http.Request{Header: http.Header{}, URL: &url.URL{Path: "/path"}}, 175 wantCode: http.StatusInternalServerError, 176 wantHeaders: http.Header{"Content-Type": []string{"application/json"}}, 177 wantBody: smallPayload, 178 }, 179 180 { 181 name: "fail to encode object or status", 182 out: smallPayload, 183 outErrs: []error{fmt.Errorf("bad"), fmt.Errorf("bad2")}, 184 mediaType: "application/json", 185 req: &http.Request{Header: http.Header{}, URL: &url.URL{Path: "/path"}}, 186 wantCode: http.StatusInternalServerError, 187 wantHeaders: http.Header{"Content-Type": []string{"text/plain"}}, 188 wantBody: []byte(": bad"), 189 }, 190 191 { 192 name: "fail to encode object or status with status code", 193 out: smallPayload, 194 outErrs: []error{kerrors.NewNotFound(schema.GroupResource{}, "test"), fmt.Errorf("bad2")}, 195 mediaType: "application/json", 196 req: &http.Request{Header: http.Header{}, URL: &url.URL{Path: "/path"}}, 197 statusCode: http.StatusOK, 198 wantCode: http.StatusNotFound, 199 wantHeaders: http.Header{"Content-Type": []string{"text/plain"}}, 200 wantBody: []byte("NotFound: \"test\" not found"), 201 }, 202 203 { 204 name: "fail to encode object or status with status code and keeps previous error", 205 out: smallPayload, 206 outErrs: []error{kerrors.NewNotFound(schema.GroupResource{}, "test"), fmt.Errorf("bad2")}, 207 mediaType: "application/json", 208 req: &http.Request{Header: http.Header{}, URL: &url.URL{Path: "/path"}}, 209 statusCode: http.StatusNotAcceptable, 210 wantCode: http.StatusNotAcceptable, 211 wantHeaders: http.Header{"Content-Type": []string{"text/plain"}}, 212 wantBody: []byte("NotFound: \"test\" not found"), 213 }, 214 215 { 216 name: "compression requires feature gate", 217 out: largePayload, 218 mediaType: "application/json", 219 req: &http.Request{ 220 Header: http.Header{ 221 "Accept-Encoding": []string{"gzip"}, 222 }, 223 URL: &url.URL{Path: "/path"}, 224 }, 225 wantCode: http.StatusOK, 226 wantHeaders: http.Header{"Content-Type": []string{"application/json"}}, 227 wantBody: largePayload, 228 }, 229 230 { 231 name: "compress on gzip", 232 compressionEnabled: true, 233 out: largePayload, 234 mediaType: "application/json", 235 req: &http.Request{ 236 Header: http.Header{ 237 "Accept-Encoding": []string{"gzip"}, 238 }, 239 URL: &url.URL{Path: "/path"}, 240 }, 241 wantCode: http.StatusOK, 242 wantHeaders: http.Header{ 243 "Content-Type": []string{"application/json"}, 244 "Content-Encoding": []string{"gzip"}, 245 "Vary": []string{"Accept-Encoding"}, 246 }, 247 wantBody: gzipContent(largePayload, defaultGzipContentEncodingLevel), 248 }, 249 250 { 251 name: "compression is not performed on small objects", 252 compressionEnabled: true, 253 out: smallPayload, 254 mediaType: "application/json", 255 req: &http.Request{ 256 Header: http.Header{ 257 "Accept-Encoding": []string{"gzip"}, 258 }, 259 URL: &url.URL{Path: "/path"}, 260 }, 261 wantCode: http.StatusOK, 262 wantHeaders: http.Header{ 263 "Content-Type": []string{"application/json"}, 264 }, 265 wantBody: smallPayload, 266 }, 267 268 { 269 name: "compress when multiple encodings are requested", 270 compressionEnabled: true, 271 out: largePayload, 272 mediaType: "application/json", 273 req: &http.Request{ 274 Header: http.Header{ 275 "Accept-Encoding": []string{"deflate, , gzip,"}, 276 }, 277 URL: &url.URL{Path: "/path"}, 278 }, 279 wantCode: http.StatusOK, 280 wantHeaders: http.Header{ 281 "Content-Type": []string{"application/json"}, 282 "Content-Encoding": []string{"gzip"}, 283 "Vary": []string{"Accept-Encoding"}, 284 }, 285 wantBody: gzipContent(largePayload, defaultGzipContentEncodingLevel), 286 }, 287 288 { 289 name: "ignore compression on deflate", 290 compressionEnabled: true, 291 out: largePayload, 292 mediaType: "application/json", 293 req: &http.Request{ 294 Header: http.Header{ 295 "Accept-Encoding": []string{"deflate"}, 296 }, 297 URL: &url.URL{Path: "/path"}, 298 }, 299 wantCode: http.StatusOK, 300 wantHeaders: http.Header{ 301 "Content-Type": []string{"application/json"}, 302 }, 303 wantBody: largePayload, 304 }, 305 306 { 307 name: "ignore compression on unrecognized types", 308 compressionEnabled: true, 309 out: largePayload, 310 mediaType: "application/json", 311 req: &http.Request{ 312 Header: http.Header{ 313 "Accept-Encoding": []string{", , other, nothing, what, "}, 314 }, 315 URL: &url.URL{Path: "/path"}, 316 }, 317 wantCode: http.StatusOK, 318 wantHeaders: http.Header{ 319 "Content-Type": []string{"application/json"}, 320 }, 321 wantBody: largePayload, 322 }, 323 324 { 325 name: "errors are compressed", 326 compressionEnabled: true, 327 statusCode: http.StatusInternalServerError, 328 out: smallPayload, 329 outErrs: []error{fmt.Errorf(string(largePayload)), fmt.Errorf("bad2")}, 330 mediaType: "application/json", 331 req: &http.Request{ 332 Header: http.Header{ 333 "Accept-Encoding": []string{"gzip"}, 334 }, 335 URL: &url.URL{Path: "/path"}, 336 }, 337 wantCode: http.StatusInternalServerError, 338 wantHeaders: http.Header{ 339 "Content-Type": []string{"text/plain"}, 340 "Content-Encoding": []string{"gzip"}, 341 "Vary": []string{"Accept-Encoding"}, 342 }, 343 wantBody: gzipContent([]byte(": "+string(largePayload)), defaultGzipContentEncodingLevel), 344 }, 345 } 346 for _, tt := range tests { 347 t.Run(tt.name, func(t *testing.T) { 348 featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.APIResponseCompression, tt.compressionEnabled) 349 350 encoder := &fakeEncoder{ 351 buf: tt.out, 352 errs: tt.outErrs, 353 } 354 if tt.statusCode == 0 { 355 tt.statusCode = http.StatusOK 356 } 357 recorder := httptest.NewRecorder() 358 SerializeObject(tt.mediaType, encoder, recorder, tt.req, tt.statusCode, tt.object) 359 result := recorder.Result() 360 if result.StatusCode != tt.wantCode { 361 t.Fatalf("unexpected code: %v", result.StatusCode) 362 } 363 if !reflect.DeepEqual(result.Header, tt.wantHeaders) { 364 t.Fatal(cmp.Diff(tt.wantHeaders, result.Header)) 365 } 366 body, _ := ioutil.ReadAll(result.Body) 367 if !bytes.Equal(tt.wantBody, body) { 368 t.Fatalf("wanted:\n%s\ngot:\n%s", hex.Dump(tt.wantBody), hex.Dump(body)) 369 } 370 }) 371 } 372 } 373 374 func randTime(t *time.Time, r *rand.Rand) { 375 *t = time.Unix(r.Int63n(1000*365*24*60*60), r.Int63()) 376 } 377 378 func randIP(s *string, r *rand.Rand) { 379 *s = fmt.Sprintf("10.20.%d.%d", r.Int31n(256), r.Int31n(256)) 380 } 381 382 // randPod changes fields in pod to mimic another pod from the same replicaset. 383 // The list fields here has been generated by picking two pods in the same replicaset 384 // and checking diff of their jsons. 385 func randPod(b *testing.B, pod *v1.Pod, r *rand.Rand) { 386 pod.Name = fmt.Sprintf("%s-%x", pod.GenerateName, r.Int63n(1000)) 387 pod.UID = uuid.NewUUID() 388 pod.ResourceVersion = strconv.Itoa(r.Int()) 389 pod.Spec.NodeName = fmt.Sprintf("some-node-prefix-%x", r.Int63n(1000)) 390 391 randTime(&pod.CreationTimestamp.Time, r) 392 randTime(&pod.Status.StartTime.Time, r) 393 for i := range pod.Status.Conditions { 394 randTime(&pod.Status.Conditions[i].LastTransitionTime.Time, r) 395 } 396 for i := range pod.Status.ContainerStatuses { 397 containerStatus := &pod.Status.ContainerStatuses[i] 398 state := &containerStatus.State 399 if state.Running != nil { 400 randTime(&state.Running.StartedAt.Time, r) 401 } 402 containerStatus.ContainerID = fmt.Sprintf("docker://%x%x%x%x", r.Int63(), r.Int63(), r.Int63(), r.Int63()) 403 } 404 for i := range pod.ManagedFields { 405 randTime(&pod.ManagedFields[i].Time.Time, r) 406 } 407 408 randIP(&pod.Status.HostIP, r) 409 randIP(&pod.Status.PodIP, r) 410 } 411 412 func benchmarkItems(b *testing.B, file string, n int) *v1.PodList { 413 pod := v1.Pod{} 414 f, err := os.Open(file) 415 if err != nil { 416 b.Fatalf("Failed to open %q: %v", file, err) 417 } 418 defer f.Close() 419 err = json.NewDecoder(f).Decode(&pod) 420 if err != nil { 421 b.Fatalf("Failed to decode %q: %v", file, err) 422 } 423 424 list := &v1.PodList{ 425 Items: make([]v1.Pod, n), 426 } 427 428 r := rand.New(rand.NewSource(benchmarkSeed)) 429 for i := 0; i < n; i++ { 430 list.Items[i] = *pod.DeepCopy() 431 randPod(b, &list.Items[i], r) 432 } 433 return list 434 } 435 436 func toProtoBuf(b *testing.B, list *v1.PodList) []byte { 437 out, err := list.Marshal() 438 if err != nil { 439 b.Fatalf("Failed to marshal list to protobuf: %v", err) 440 } 441 return out 442 } 443 444 func toJSON(b *testing.B, list *v1.PodList) []byte { 445 out, err := json.Marshal(list) 446 if err != nil { 447 b.Fatalf("Failed to marshal list to json: %v", err) 448 } 449 return out 450 } 451 452 func benchmarkSerializeObject(b *testing.B, payload []byte) { 453 input, output := len(payload), len(gzipContent(payload, defaultGzipContentEncodingLevel)) 454 b.Logf("Payload size: %d, expected output size: %d, ratio: %.2f", input, output, float64(output)/float64(input)) 455 456 req := &http.Request{ 457 Header: http.Header{ 458 "Accept-Encoding": []string{"gzip"}, 459 }, 460 URL: &url.URL{Path: "/path"}, 461 } 462 featuregatetesting.SetFeatureGateDuringTest(b, utilfeature.DefaultFeatureGate, features.APIResponseCompression, true) 463 464 encoder := &fakeEncoder{ 465 buf: payload, 466 } 467 468 b.ResetTimer() 469 for i := 0; i < b.N; i++ { 470 recorder := httptest.NewRecorder() 471 SerializeObject("application/json", encoder, recorder, req, http.StatusOK, nil /* object */) 472 result := recorder.Result() 473 if result.StatusCode != http.StatusOK { 474 b.Fatalf("incorrect status code: got %v; want: %v", result.StatusCode, http.StatusOK) 475 } 476 } 477 } 478 479 func BenchmarkSerializeObject1000PodsPB(b *testing.B) { 480 benchmarkSerializeObject(b, toProtoBuf(b, benchmarkItems(b, "testdata/pod.json", 1000))) 481 } 482 func BenchmarkSerializeObject10000PodsPB(b *testing.B) { 483 benchmarkSerializeObject(b, toProtoBuf(b, benchmarkItems(b, "testdata/pod.json", 10000))) 484 } 485 func BenchmarkSerializeObject100000PodsPB(b *testing.B) { 486 benchmarkSerializeObject(b, toProtoBuf(b, benchmarkItems(b, "testdata/pod.json", 100000))) 487 } 488 489 func BenchmarkSerializeObject1000PodsJSON(b *testing.B) { 490 benchmarkSerializeObject(b, toJSON(b, benchmarkItems(b, "testdata/pod.json", 1000))) 491 } 492 func BenchmarkSerializeObject10000PodsJSON(b *testing.B) { 493 benchmarkSerializeObject(b, toJSON(b, benchmarkItems(b, "testdata/pod.json", 10000))) 494 } 495 func BenchmarkSerializeObject100000PodsJSON(b *testing.B) { 496 benchmarkSerializeObject(b, toJSON(b, benchmarkItems(b, "testdata/pod.json", 100000))) 497 } 498 499 type fakeResponseRecorder struct { 500 *httptest.ResponseRecorder 501 fe *fakeEncoder 502 errorAfterEncoding bool 503 } 504 505 func (frw *fakeResponseRecorder) Write(buf []byte) (int, error) { 506 if frw.errorAfterEncoding && frw.fe.encodeCalled { 507 return 0, errors.New("returning a requested error") 508 } 509 return frw.ResponseRecorder.Write(buf) 510 } 511 512 type fakeEncoder struct { 513 obj runtime.Object 514 buf []byte 515 errs []error 516 517 encodeCalled bool 518 } 519 520 func (e *fakeEncoder) Encode(obj runtime.Object, w io.Writer) error { 521 e.obj = obj 522 if len(e.errs) > 0 { 523 err := e.errs[0] 524 e.errs = e.errs[1:] 525 return err 526 } 527 _, err := w.Write(e.buf) 528 e.encodeCalled = true 529 return err 530 } 531 532 func (e *fakeEncoder) Identifier() runtime.Identifier { 533 return runtime.Identifier("fake") 534 } 535 536 func gzipContent(data []byte, level int) []byte { 537 buf := &bytes.Buffer{} 538 gw, err := gzip.NewWriterLevel(buf, level) 539 if err != nil { 540 panic(err) 541 } 542 if _, err := gw.Write(data); err != nil { 543 panic(err) 544 } 545 if err := gw.Close(); err != nil { 546 panic(err) 547 } 548 return buf.Bytes() 549 }