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  }