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 }