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