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 }