...

Source file src/sigs.k8s.io/cli-utils/pkg/kstatus/watcher/dynamic_informer_factory_test.go

Documentation: sigs.k8s.io/cli-utils/pkg/kstatus/watcher

     1  // Copyright 2022 The Kubernetes Authors.
     2  // SPDX-License-Identifier: Apache-2.0
     3  
     4  package watcher
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"testing"
    10  	"time"
    11  
    12  	"github.com/stretchr/testify/assert"
    13  	"github.com/stretchr/testify/require"
    14  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    15  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    16  	"k8s.io/apimachinery/pkg/apis/testapigroup"
    17  	"k8s.io/apimachinery/pkg/runtime"
    18  	"k8s.io/apimachinery/pkg/runtime/schema"
    19  	"k8s.io/apimachinery/pkg/test"
    20  	"k8s.io/apimachinery/pkg/watch"
    21  	dynamicfake "k8s.io/client-go/dynamic/fake"
    22  	clienttesting "k8s.io/client-go/testing"
    23  	"k8s.io/client-go/tools/cache"
    24  	"k8s.io/klog/v2"
    25  	"sigs.k8s.io/cli-utils/pkg/testutil"
    26  )
    27  
    28  func TestResourceNotFoundError(t *testing.T) {
    29  	carpGVK := schema.GroupVersionKind{
    30  		Group:   "foo",
    31  		Version: "v1",
    32  		Kind:    "Carp",
    33  	}
    34  	exampleGR := schema.GroupResource{
    35  		Group:    carpGVK.Group,
    36  		Resource: "carps",
    37  	}
    38  	namespace := "example-ns"
    39  
    40  	testCases := []struct {
    41  		name         string
    42  		setup        func(*dynamicfake.FakeDynamicClient)
    43  		errorHandler func(t *testing.T, err error)
    44  	}{
    45  		{
    46  			name: "List resource not found error",
    47  			setup: func(fakeClient *dynamicfake.FakeDynamicClient) {
    48  				fakeClient.PrependReactor("list", exampleGR.Resource, func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) {
    49  					listAction := action.(clienttesting.ListAction)
    50  					if listAction.GetNamespace() != namespace {
    51  						assert.Fail(t, "Received unexpected LIST namespace: %s", listAction.GetNamespace())
    52  						return false, nil, nil
    53  					}
    54  					// dynamicClient converts Status objects from the apiserver into errors.
    55  					// So we can just return the right error here to simulate an error from
    56  					// the apiserver.
    57  					err = newGenericServerResponse(action, newNotFoundResourceStatusError(action))
    58  					return true, nil, err
    59  				})
    60  			},
    61  			errorHandler: func(t *testing.T, err error) {
    62  				switch {
    63  				case apierrors.IsNotFound(err):
    64  					t.Logf("Received expected typed NotFound error: %v", err)
    65  				default:
    66  					// If we got this error, the test is probably broken.
    67  					t.Errorf("Expected typed NotFound error, but got a different error: %v", err)
    68  				}
    69  			},
    70  		},
    71  		{
    72  			name: "Watch resource not found error",
    73  			setup: func(fakeClient *dynamicfake.FakeDynamicClient) {
    74  				fakeClient.PrependWatchReactor(exampleGR.Resource, func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) {
    75  					// dynamicClient converts Status objects from the apiserver into errors.
    76  					// So we can just return the right error here to simulate an error from
    77  					// the apiserver.
    78  					err = newGenericServerResponse(action, newNotFoundResourceStatusError(action))
    79  					return true, nil, err
    80  				})
    81  			},
    82  			errorHandler: func(t *testing.T, err error) {
    83  				switch {
    84  				case apierrors.IsNotFound(err):
    85  					// This is the expected behavior, because the
    86  					// Informer/Reflector DOES wrap watch errors
    87  					t.Logf("Received expected typed NotFound error: %v", err)
    88  				default:
    89  					// If we got this error, the test is probably broken.
    90  					t.Errorf("Expected typed NotFound error, but got a different error: %v", err)
    91  				}
    92  			},
    93  		},
    94  		{
    95  			name: "List resource forbidden error",
    96  			setup: func(fakeClient *dynamicfake.FakeDynamicClient) {
    97  				fakeClient.PrependReactor("list", exampleGR.Resource, func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) {
    98  					listAction := action.(clienttesting.ListAction)
    99  					if listAction.GetNamespace() != namespace {
   100  						assert.Fail(t, "Received unexpected LIST namespace: %s", listAction.GetNamespace())
   101  						return false, nil, nil
   102  					}
   103  					// dynamicClient converts Status objects from the apiserver into errors.
   104  					// So we can just return the right error here to simulate an error from
   105  					// the apiserver.
   106  					err = newGenericServerResponse(action, newForbiddenResourceStatusError(action))
   107  					return true, nil, err
   108  				})
   109  			},
   110  			errorHandler: func(t *testing.T, err error) {
   111  				switch {
   112  				case apierrors.IsForbidden(err):
   113  					t.Logf("Received expected typed Forbidden error: %v", err)
   114  				default:
   115  					// If we got this error, the test is probably broken.
   116  					t.Errorf("Expected typed Forbidden error, but got a different error: %v", err)
   117  				}
   118  			},
   119  		},
   120  		{
   121  			name: "Watch resource forbidden error",
   122  			setup: func(fakeClient *dynamicfake.FakeDynamicClient) {
   123  				fakeClient.PrependWatchReactor(exampleGR.Resource, func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) {
   124  					// dynamicClient converts Status objects from the apiserver into errors.
   125  					// So we can just return the right error here to simulate an error from
   126  					// the apiserver.
   127  					err = newGenericServerResponse(action, newForbiddenResourceStatusError(action))
   128  					return true, nil, err
   129  				})
   130  			},
   131  			errorHandler: func(t *testing.T, err error) {
   132  				switch {
   133  				case apierrors.IsForbidden(err):
   134  					// This is the expected behavior, because the
   135  					// Informer/Reflector DOES wrap watch errors
   136  					t.Logf("Received expected typed Forbidden error: %v", err)
   137  				default:
   138  					// If we got this error, the test is probably broken.
   139  					t.Errorf("Expected typed Forbidden error, but got a different error: %v", err)
   140  				}
   141  			},
   142  		},
   143  	}
   144  
   145  	for _, tc := range testCases {
   146  		t.Run(tc.name, func(t *testing.T) {
   147  			scheme := runtime.NewScheme()
   148  			scheme.AddKnownTypes(metav1.SchemeGroupVersion, &metav1.Status{})
   149  
   150  			// Register foo/v1 Carp CRD
   151  			scheme.AddKnownTypes(carpGVK.GroupVersion(), &testapigroup.Carp{}, &testapigroup.CarpList{}, &test.List{})
   152  
   153  			// Fake client that only knows about the types registered to the scheme
   154  			fakeClient := dynamicfake.NewSimpleDynamicClient(scheme)
   155  
   156  			// log fakeClient calls
   157  			fakeClient.PrependReactor("*", "*", func(a clienttesting.Action) (bool, runtime.Object, error) {
   158  				klog.V(3).Infof("FakeDynamicClient: %T{ Verb: %q, Resource: %q, Namespace: %q }",
   159  					a, a.GetVerb(), a.GetResource().Resource, a.GetNamespace())
   160  				return false, nil, nil
   161  			})
   162  			fakeClient.PrependWatchReactor("*", func(a clienttesting.Action) (bool, watch.Interface, error) {
   163  				klog.V(3).Infof("FakeDynamicClient: %T{ Verb: %q, Resource: %q, Namespace: %q }",
   164  					a, a.GetVerb(), a.GetResource().Resource, a.GetNamespace())
   165  				return false, nil, nil
   166  			})
   167  
   168  			tc.setup(fakeClient)
   169  
   170  			informerFactory := NewDynamicInformerFactory(fakeClient, 0) // disable re-sync
   171  
   172  			ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   173  			defer cancel()
   174  
   175  			fakeMapper := testutil.NewFakeRESTMapper(carpGVK)
   176  			mapping, err := fakeMapper.RESTMapping(carpGVK.GroupKind())
   177  			require.NoError(t, err)
   178  
   179  			informer := informerFactory.NewInformer(ctx, mapping, namespace)
   180  
   181  			err = informer.SetWatchErrorHandler(func(_ *cache.Reflector, err error) {
   182  				tc.errorHandler(t, err)
   183  				// Stop the informer after the first error.
   184  				cancel()
   185  			})
   186  			require.NoError(t, err)
   187  
   188  			// Block until context cancel or timeout.
   189  			informer.Run(ctx.Done())
   190  		})
   191  	}
   192  }
   193  
   194  // newForbiddenResourceStatusError emulates a Forbidden error from the apiserver
   195  // for a namespace-scoped resource.
   196  // https://github.com/kubernetes/apiserver/blob/master/pkg/endpoints/handlers/responsewriters/errors.go#L36
   197  func newForbiddenResourceStatusError(action clienttesting.Action) *apierrors.StatusError {
   198  	username := "unused"
   199  	verb := action.GetVerb()
   200  	resource := action.GetResource().Resource
   201  	if subresource := action.GetSubresource(); len(subresource) > 0 {
   202  		resource = resource + "/" + subresource
   203  	}
   204  	apiGroup := action.GetResource().Group
   205  	namespace := action.GetNamespace()
   206  
   207  	// https://github.com/kubernetes/apiserver/blob/master/pkg/endpoints/handlers/responsewriters/errors.go#L51
   208  	err := fmt.Errorf("User %q cannot %s resource %q in API group %q in the namespace %q",
   209  		username, verb, resource, apiGroup, namespace)
   210  
   211  	qualifiedResource := action.GetResource().GroupResource()
   212  	name := "" // unused by ListAndWatch
   213  	return apierrors.NewForbidden(qualifiedResource, name, err)
   214  }
   215  
   216  // newNotFoundResourceStatusError emulates a NotFOund error from the apiserver
   217  // for a resource (not an object).
   218  func newNotFoundResourceStatusError(action clienttesting.Action) *apierrors.StatusError {
   219  	qualifiedResource := action.GetResource().GroupResource()
   220  	name := "" // unused by ListAndWatch
   221  	return apierrors.NewNotFound(qualifiedResource, name)
   222  }
   223  
   224  // newGenericServerResponse emulates a StatusError from the apiserver.
   225  func newGenericServerResponse(action clienttesting.Action, statusError *apierrors.StatusError) *apierrors.StatusError {
   226  	errorCode := int(statusError.ErrStatus.Code)
   227  	verb := action.GetVerb()
   228  	qualifiedResource := action.GetResource().GroupResource()
   229  	name := statusError.ErrStatus.Details.Name
   230  	// https://github.com/kubernetes/apimachinery/blob/v0.24.0/pkg/api/errors/errors.go#L435
   231  	return apierrors.NewGenericServerResponse(errorCode, verb, qualifiedResource, name, statusError.Error(), -1, false)
   232  }
   233  

View as plain text