github.com/tilt-dev/tilt@v0.36.0/internal/k8s/client_test.go (about)

     1  package k8s
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"strings"
    11  	"testing"
    12  	"time"
    13  
    14  	"github.com/stretchr/testify/assert"
    15  	"github.com/stretchr/testify/require"
    16  	"helm.sh/helm/v3/pkg/kube"
    17  	v1 "k8s.io/api/core/v1"
    18  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    19  	"k8s.io/apimachinery/pkg/api/meta"
    20  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    21  	"k8s.io/apimachinery/pkg/util/intstr"
    22  	"k8s.io/apimachinery/pkg/watch"
    23  	"k8s.io/cli-runtime/pkg/resource"
    24  	dynfake "k8s.io/client-go/dynamic/fake"
    25  	"k8s.io/client-go/kubernetes/fake"
    26  	"k8s.io/client-go/kubernetes/scheme"
    27  	restfake "k8s.io/client-go/rest/fake"
    28  	ktesting "k8s.io/client-go/testing"
    29  
    30  	"github.com/tilt-dev/clusterid"
    31  	"github.com/tilt-dev/tilt/internal/k8s/testyaml"
    32  	"github.com/tilt-dev/tilt/internal/testutils"
    33  )
    34  
    35  func TestEmptyNamespace(t *testing.T) {
    36  	var emptyNamespace Namespace
    37  	assert.True(t, emptyNamespace.Empty())
    38  	assert.True(t, emptyNamespace == "")
    39  	assert.Equal(t, "default", emptyNamespace.String())
    40  }
    41  
    42  func TestNotEmptyNamespace(t *testing.T) {
    43  	var ns Namespace = "x"
    44  	assert.False(t, ns.Empty())
    45  	assert.False(t, ns == "")
    46  	assert.Equal(t, "x", ns.String())
    47  }
    48  
    49  func TestUpsert(t *testing.T) {
    50  	f := newClientTestFixture(t)
    51  	postgres, err := ParseYAMLFromString(testyaml.PostgresYAML)
    52  	assert.Nil(t, err)
    53  	_, err = f.k8sUpsert(f.ctx, postgres)
    54  	assert.Nil(t, err)
    55  	assert.Equal(t, 5, len(f.resourceClient.updates))
    56  }
    57  
    58  func TestDelete(t *testing.T) {
    59  	f := newClientTestFixture(t)
    60  	postgres, err := ParseYAMLFromString(testyaml.PostgresYAML)
    61  	assert.Nil(t, err)
    62  	err = f.client.Delete(f.ctx, postgres, time.Minute)
    63  	assert.Nil(t, err)
    64  	assert.Equal(t, 5, len(f.resourceClient.deletes))
    65  }
    66  
    67  func TestDeleteMissingKind(t *testing.T) {
    68  	f := newClientTestFixture(t)
    69  	f.resourceClient.buildErrFn = func(e K8sEntity) error {
    70  		if e.GVK().Kind == "StatefulSet" {
    71  			return fmt.Errorf(`no matches for kind "StatefulSet" in version "apps/v1"`)
    72  		}
    73  		return nil
    74  	}
    75  
    76  	postgres, err := ParseYAMLFromString(testyaml.PostgresYAML)
    77  	assert.Nil(t, err)
    78  	err = f.client.Delete(f.ctx, postgres, time.Minute)
    79  	assert.Nil(t, err)
    80  	assert.Equal(t, 4, len(f.resourceClient.deletes))
    81  
    82  	kinds := []string{}
    83  	for _, r := range f.resourceClient.deletes {
    84  		kinds = append(kinds, r.Object.GetObjectKind().GroupVersionKind().Kind)
    85  	}
    86  	assert.Equal(t,
    87  		[]string{"ConfigMap", "PersistentVolume", "PersistentVolumeClaim", "Service"},
    88  		kinds)
    89  }
    90  
    91  func TestUpsertAnnotationTooLong(t *testing.T) {
    92  	postgres := MustParseYAMLFromString(t, testyaml.PostgresYAML)
    93  
    94  	// These error strings are from the Kubernetes API server which can have
    95  	// different error messages depending on the version.
    96  	//
    97  	// The error string was changed in K8S v1.32:
    98  	// See: https://github.com/kubernetes/kubernetes/pull/128553
    99  	errorMsgs := []string{
   100  		`The ConfigMap "postgres-config" is invalid: metadata.annotations: Too long: must have at most 262144 bytes`,
   101  		`The ConfigMap "postgres-config" is invalid: metadata.annotations: Too long: may not be more than 262144 bytes`,
   102  	}
   103  
   104  	for _, errorMsg := range errorMsgs {
   105  		t.Run(errorMsg, func(t *testing.T) {
   106  			f := newClientTestFixture(t)
   107  
   108  			f.resourceClient.updateErr = errors.New(errorMsg)
   109  			_, err := f.k8sUpsert(f.ctx, postgres)
   110  			assert.Nil(t, err)
   111  			assert.Equal(t, 0, len(f.resourceClient.creates))
   112  			assert.Equal(t, 1, len(f.resourceClient.createOrReplaces))
   113  			assert.Equal(t, 4, len(f.resourceClient.updates))
   114  		})
   115  	}
   116  }
   117  
   118  func TestUpsert413(t *testing.T) {
   119  	f := newClientTestFixture(t)
   120  	postgres := MustParseYAMLFromString(t, testyaml.PostgresYAML)
   121  
   122  	f.resourceClient.updateErr = fmt.Errorf(`the server responded with the status code 413 but did not return more information (post customresourcedefinitions.apiextensions.k8s.io)`)
   123  	_, err := f.k8sUpsert(f.ctx, postgres)
   124  	assert.Nil(t, err)
   125  	assert.Equal(t, 0, len(f.resourceClient.creates))
   126  	assert.Equal(t, 1, len(f.resourceClient.createOrReplaces))
   127  	assert.Equal(t, 4, len(f.resourceClient.updates))
   128  }
   129  
   130  func TestUpsert413Structured(t *testing.T) {
   131  	f := newClientTestFixture(t)
   132  	postgres := MustParseYAMLFromString(t, testyaml.PostgresYAML)
   133  
   134  	f.resourceClient.updateErr = &apierrors.StatusError{
   135  		ErrStatus: metav1.Status{
   136  			Message: "too large",
   137  			Reason:  "TooLarge",
   138  			Code:    http.StatusRequestEntityTooLarge,
   139  		},
   140  	}
   141  	_, err := f.k8sUpsert(f.ctx, postgres)
   142  	assert.Nil(t, err)
   143  	assert.Equal(t, 0, len(f.resourceClient.creates))
   144  	assert.Equal(t, 1, len(f.resourceClient.createOrReplaces))
   145  	assert.Equal(t, 4, len(f.resourceClient.updates))
   146  }
   147  
   148  func TestUpsertStatefulsetForbidden(t *testing.T) {
   149  	f := newClientTestFixture(t)
   150  	postgres, err := ParseYAMLFromString(testyaml.PostgresYAML)
   151  	assert.Nil(t, err)
   152  
   153  	f.resourceClient.updateErr = fmt.Errorf(`The StatefulSet "postgres" is invalid: spec: Forbidden: updates to statefulset spec for fields other than 'replicas', 'template', and 'updateStrategy' are forbidden.`)
   154  	_, err = f.k8sUpsert(f.ctx, postgres)
   155  	assert.Nil(t, err)
   156  	assert.Equal(t, 1, len(f.resourceClient.creates))
   157  	assert.Equal(t, 4, len(f.resourceClient.updates))
   158  }
   159  
   160  func TestUpsertToTerminatingNamespaceForbidden(t *testing.T) {
   161  	f := newClientTestFixture(t)
   162  	postgres, err := ParseYAMLFromString(testyaml.SanchoYAML)
   163  	assert.Nil(t, err)
   164  
   165  	// Bad error parsing used to result in us treating this error as an immutable
   166  	// field error. Make sure we treat it as what it is and bail out of `kubectl apply`
   167  	// rather than trying to --force
   168  	errStr := `Error from server (Forbidden): error when creating "STDIN": deployments.apps "sancho" is forbidden: unable to create new content in namespace sancho-ns because it is being terminated`
   169  	f.resourceClient.updateErr = errors.New(errStr)
   170  
   171  	_, err = f.k8sUpsert(f.ctx, postgres)
   172  	if assert.NotNil(t, err) {
   173  		assert.Contains(t, err.Error(), errStr)
   174  	}
   175  	assert.Equal(t, 0, len(f.resourceClient.updates))
   176  	assert.Equal(t, 0, len(f.resourceClient.creates))
   177  }
   178  
   179  func TestNodePortServiceURL(t *testing.T) {
   180  	// Taken from a Docker Desktop NodePort service.
   181  	s := &v1.Service{
   182  		Spec: v1.ServiceSpec{
   183  			Type: v1.ServiceTypeNodePort,
   184  			Ports: []v1.ServicePort{
   185  				{
   186  					NodePort:   31074,
   187  					Port:       9000,
   188  					TargetPort: intstr.FromInt(80),
   189  				},
   190  			},
   191  		},
   192  		Status: v1.ServiceStatus{
   193  			LoadBalancer: v1.LoadBalancerStatus{
   194  				Ingress: []v1.LoadBalancerIngress{
   195  					{Hostname: "localhost"},
   196  				},
   197  			},
   198  		},
   199  	}
   200  
   201  	url, err := ServiceURL(s, NodeIP("127.0.0.1"))
   202  	assert.NoError(t, err)
   203  	assert.Equal(t, "http://localhost:31074/", url.String())
   204  }
   205  
   206  func TestGetGroup(t *testing.T) {
   207  	for _, test := range []struct {
   208  		name          string
   209  		apiVersion    string
   210  		expectedGroup string
   211  	}{
   212  		{"normal", "apps/v1", "apps"},
   213  		// core types have an empty group
   214  		{"core", "/v1", ""},
   215  		// on some versions of k8s, deployment is buggy and doesn't have a version in its apiVersion
   216  		{"no version", "extensions", "extensions"},
   217  		{"alpha version", "apps/v1alpha1", "apps"},
   218  		{"beta version", "apps/v1beta1", "apps"},
   219  		// I've never seen this in the wild, but the docs say it's legal
   220  		{"alpha version, no second number", "apps/v1alpha", "apps"},
   221  	} {
   222  		t.Run(test.name, func(t *testing.T) {
   223  			obj := v1.ObjectReference{APIVersion: test.apiVersion}
   224  			assert.Equal(t, test.expectedGroup, ReferenceGVK(obj).Group)
   225  		})
   226  	}
   227  }
   228  
   229  func TestServerHealth(t *testing.T) {
   230  	// NOTE: the health endpoint contract only specifies that 200 is healthy
   231  	// 	and any other status code indicates not-healthy; in practice, apiserver
   232  	// 	seems to always use 500, but we throw a couple different status codes
   233  	// 	in here in the spirit of the contract
   234  	// 	see https://github.com/kubernetes/kubernetes/blob/9918aa1e035a00bc7c0f16a05e1b222650b3eabc/staging/src/k8s.io/apiserver/pkg/server/healthz/healthz.go#L258
   235  	for _, tc := range []struct {
   236  		name            string
   237  		liveErr         error
   238  		liveStatusCode  int
   239  		readyErr        error
   240  		readyStatusCode int
   241  	}{
   242  		{name: "Healthy", liveStatusCode: http.StatusOK, readyStatusCode: http.StatusOK},
   243  		{name: "NotLive", liveStatusCode: http.StatusServiceUnavailable, readyStatusCode: http.StatusServiceUnavailable},
   244  		{name: "NotReady", liveStatusCode: http.StatusOK, readyStatusCode: http.StatusInternalServerError},
   245  		{name: "ErrorLivez", liveErr: errors.New("fake livez network error")},
   246  		{name: "ErrorReadyz", liveStatusCode: http.StatusOK, readyErr: errors.New("fake readyz network error")},
   247  	} {
   248  		t.Run(tc.name, func(t *testing.T) {
   249  			f := newClientTestFixture(t)
   250  			f.restClient.Client = restfake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
   251  				switch req.URL.Path {
   252  				case "/livez":
   253  					if tc.liveErr != nil {
   254  						return nil, tc.liveErr
   255  					}
   256  					return &http.Response{
   257  						StatusCode: tc.liveStatusCode,
   258  						Body:       io.NopCloser(strings.NewReader("fake livez response")),
   259  					}, nil
   260  				case "/readyz":
   261  					if tc.readyErr != nil {
   262  						return nil, tc.readyErr
   263  					}
   264  					return &http.Response{
   265  						StatusCode: tc.readyStatusCode,
   266  						Body:       io.NopCloser(strings.NewReader("fake readyz response")),
   267  					}, nil
   268  				}
   269  				err := fmt.Errorf("unsupported request: %s", req.URL.Path)
   270  				t.Fatal(err.Error())
   271  				return nil, err
   272  			})
   273  
   274  			health, err := f.client.ClusterHealth(f.ctx, true)
   275  			if tc.liveErr != nil {
   276  				require.Error(t, err)
   277  				require.Contains(t, err.Error(), tc.liveErr.Error(),
   278  					"livez error did not match")
   279  				return
   280  			} else if tc.readyErr != nil {
   281  				require.Error(t, err)
   282  				require.Contains(t, err.Error(), tc.readyErr.Error(),
   283  					"readyz error did not match")
   284  				return
   285  			} else {
   286  				require.NoError(t, err)
   287  			}
   288  
   289  			// verbose output is only checked on success - some of the standard
   290  			// error handling in the K8s helpers massages non-200 requests, so
   291  			// it's too brittle to check against
   292  			isLive := tc.liveStatusCode == http.StatusOK
   293  			if assert.Equal(t, isLive, health.Live, "livez") && isLive {
   294  				assert.Equal(t, "fake livez response", health.LiveOutput)
   295  			}
   296  			isReady := tc.readyStatusCode == http.StatusOK
   297  			if assert.Equal(t, isReady, health.Ready, "readyz") && isReady {
   298  				assert.Equal(t, "fake readyz response", health.ReadyOutput)
   299  			}
   300  		})
   301  	}
   302  
   303  }
   304  
   305  type fakeResourceClient struct {
   306  	updates          kube.ResourceList
   307  	creates          kube.ResourceList
   308  	deletes          kube.ResourceList
   309  	createOrReplaces kube.ResourceList
   310  	updateErr        error
   311  	buildErrFn       func(e K8sEntity) error
   312  }
   313  
   314  func (c *fakeResourceClient) Apply(target kube.ResourceList) (*kube.Result, error) {
   315  	defer func() {
   316  		c.updateErr = nil
   317  	}()
   318  
   319  	if c.updateErr != nil {
   320  		return nil, c.updateErr
   321  	}
   322  	c.updates = append(c.updates, target...)
   323  	return &kube.Result{Updated: target}, nil
   324  }
   325  func (c *fakeResourceClient) Delete(l kube.ResourceList) (*kube.Result, []error) {
   326  	c.deletes = append(c.deletes, l...)
   327  	return &kube.Result{Deleted: l}, nil
   328  }
   329  func (c *fakeResourceClient) Create(l kube.ResourceList) (*kube.Result, error) {
   330  	c.creates = append(c.creates, l...)
   331  	return &kube.Result{Created: l}, nil
   332  }
   333  func (c *fakeResourceClient) CreateOrReplace(l kube.ResourceList) (*kube.Result, error) {
   334  	c.createOrReplaces = append(c.createOrReplaces, l...)
   335  	return &kube.Result{Updated: l}, nil
   336  }
   337  func (c *fakeResourceClient) Build(r io.Reader, validate bool) (kube.ResourceList, error) {
   338  	entities, err := ParseYAML(r)
   339  	if err != nil {
   340  		return nil, err
   341  	}
   342  	list := kube.ResourceList{}
   343  	for _, e := range entities {
   344  		if c.buildErrFn != nil {
   345  			err := c.buildErrFn(e)
   346  			if err != nil {
   347  				// Stop processing further resources.
   348  				//
   349  				// NOTE(nick): The real client behavior is more complex than this,
   350  				// where sometimes it seems to continue and other times it doesn't,
   351  				// but we want our code to handle "worst" case conditions.
   352  				return list, err
   353  			}
   354  		}
   355  
   356  		list = append(list, &resource.Info{
   357  			// Create a fake HTTP client that returns 404 for every request.
   358  			Client: &restfake.RESTClient{
   359  				NegotiatedSerializer: scheme.Codecs,
   360  				Resp:                 &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(&bytes.Buffer{})},
   361  			},
   362  			Mapping:   &meta.RESTMapping{Scope: meta.RESTScopeNamespace},
   363  			Object:    e.Obj,
   364  			Name:      e.Name(),
   365  			Namespace: string(e.Namespace()),
   366  		})
   367  	}
   368  
   369  	return list, nil
   370  }
   371  
   372  type clientTestFixture struct {
   373  	t              *testing.T
   374  	ctx            context.Context
   375  	client         K8sClient
   376  	tracker        ktesting.ObjectTracker
   377  	watchNotify    chan watch.Interface
   378  	resourceClient *fakeResourceClient
   379  	restClient     *restfake.RESTClient
   380  }
   381  
   382  func newClientTestFixture(t *testing.T) *clientTestFixture {
   383  	ret := &clientTestFixture{}
   384  	ret.t = t
   385  	ctx, _, _ := testutils.CtxAndAnalyticsForTest()
   386  	ret.ctx = ctx
   387  
   388  	tracker := ktesting.NewObjectTracker(scheme.Scheme, scheme.Codecs.UniversalDecoder())
   389  	watchNotify := make(chan watch.Interface, 100)
   390  	ret.watchNotify = watchNotify
   391  
   392  	cs := &fake.Clientset{}
   393  	cs.AddReactor("*", "*", ktesting.ObjectReaction(tracker))
   394  	cs.AddWatchReactor("*", func(action ktesting.Action) (handled bool, ret watch.Interface, err error) {
   395  		gvr := action.GetResource()
   396  		ns := action.GetNamespace()
   397  		watch, err := tracker.Watch(gvr, ns)
   398  		if err != nil {
   399  			return false, nil, err
   400  		}
   401  
   402  		watchNotify <- watch
   403  		return true, watch, nil
   404  	})
   405  
   406  	ret.tracker = tracker
   407  
   408  	core := cs.CoreV1()
   409  	dc := dynfake.NewSimpleDynamicClient(scheme.Scheme)
   410  	runtimeAsync := newRuntimeAsync(core)
   411  	registryAsync := newRegistryAsync(clusterid.ProductUnknown, core, runtimeAsync)
   412  	resourceClient := &fakeResourceClient{}
   413  	ret.resourceClient = resourceClient
   414  
   415  	ret.restClient = &restfake.RESTClient{}
   416  
   417  	ret.client = K8sClient{
   418  		product:           clusterid.ProductUnknown,
   419  		core:              core,
   420  		portForwardClient: NewFakePortForwardClient(),
   421  		discovery:         fakeDiscovery{restClient: ret.restClient},
   422  		dynamic:           dc,
   423  		runtimeAsync:      runtimeAsync,
   424  		registryAsync:     registryAsync,
   425  		resourceClient:    resourceClient,
   426  		drm:               fakeRESTMapper{},
   427  	}
   428  
   429  	return ret
   430  }
   431  
   432  func (c clientTestFixture) k8sUpsert(ctx context.Context, entities []K8sEntity) ([]K8sEntity, error) {
   433  	return c.client.Upsert(ctx, entities, time.Minute)
   434  }