...

Source file src/sigs.k8s.io/cli-utils/pkg/apply/common_test.go

Documentation: sigs.k8s.io/cli-utils/pkg/apply

     1  // Copyright 2020 The Kubernetes Authors.
     2  // SPDX-License-Identifier: Apache-2.0
     3  
     4  package apply
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  	"regexp"
    13  	"testing"
    14  
    15  	"github.com/stretchr/testify/assert"
    16  	"github.com/stretchr/testify/require"
    17  	v1 "k8s.io/api/core/v1"
    18  	"k8s.io/apimachinery/pkg/api/meta"
    19  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    20  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    21  	"k8s.io/apimachinery/pkg/runtime"
    22  	"k8s.io/apimachinery/pkg/runtime/schema"
    23  	"k8s.io/cli-runtime/pkg/resource"
    24  	dynamicfake "k8s.io/client-go/dynamic/fake"
    25  	"k8s.io/client-go/rest/fake"
    26  	clienttesting "k8s.io/client-go/testing"
    27  	"k8s.io/klog/v2"
    28  	cmdtesting "k8s.io/kubectl/pkg/cmd/testing"
    29  	"k8s.io/kubectl/pkg/scheme"
    30  	"sigs.k8s.io/cli-utils/pkg/common"
    31  	"sigs.k8s.io/cli-utils/pkg/inventory"
    32  	"sigs.k8s.io/cli-utils/pkg/jsonpath"
    33  	pollevent "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
    34  	"sigs.k8s.io/cli-utils/pkg/kstatus/watcher"
    35  	"sigs.k8s.io/cli-utils/pkg/object"
    36  )
    37  
    38  type inventoryInfo struct {
    39  	name      string
    40  	namespace string
    41  	id        string
    42  	set       object.ObjMetadataSet
    43  }
    44  
    45  func (i inventoryInfo) toUnstructured() *unstructured.Unstructured {
    46  	invMap := make(map[string]interface{})
    47  	for _, objMeta := range i.set {
    48  		invMap[objMeta.String()] = ""
    49  	}
    50  
    51  	return &unstructured.Unstructured{
    52  		Object: map[string]interface{}{
    53  			"apiVersion": "v1",
    54  			"kind":       "ConfigMap",
    55  			"metadata": map[string]interface{}{
    56  				"name":      i.name,
    57  				"namespace": i.namespace,
    58  				"labels": map[string]interface{}{
    59  					common.InventoryLabel: i.id,
    60  				},
    61  			},
    62  			"data": invMap,
    63  		},
    64  	}
    65  }
    66  
    67  func (i inventoryInfo) toWrapped() inventory.Info {
    68  	return inventory.WrapInventoryInfoObj(i.toUnstructured())
    69  }
    70  
    71  func newTestApplier(
    72  	t *testing.T,
    73  	invInfo inventoryInfo,
    74  	resources object.UnstructuredSet,
    75  	clusterObjs object.UnstructuredSet,
    76  	statusWatcher watcher.StatusWatcher,
    77  ) *Applier {
    78  	tf := newTestFactory(t, invInfo, resources, clusterObjs)
    79  	defer tf.Cleanup()
    80  
    81  	infoHelper := &fakeInfoHelper{
    82  		factory: tf,
    83  	}
    84  
    85  	invClient := newTestInventory(t, tf)
    86  
    87  	applier, err := NewApplierBuilder().
    88  		WithFactory(tf).
    89  		WithInventoryClient(invClient).
    90  		WithStatusWatcher(statusWatcher).
    91  		Build()
    92  	require.NoError(t, err)
    93  
    94  	// Inject the fakeInfoHelper to allow generating Info
    95  	// objects that use the FakeRESTClient as the UnstructuredClient.
    96  	applier.infoHelper = infoHelper
    97  
    98  	return applier
    99  }
   100  
   101  func newTestDestroyer(
   102  	t *testing.T,
   103  	invInfo inventoryInfo,
   104  	clusterObjs object.UnstructuredSet,
   105  	statusWatcher watcher.StatusWatcher,
   106  ) *Destroyer {
   107  	tf := newTestFactory(t, invInfo, object.UnstructuredSet{}, clusterObjs)
   108  	defer tf.Cleanup()
   109  
   110  	invClient := newTestInventory(t, tf)
   111  
   112  	destroyer, err := NewDestroyerBuilder().
   113  		WithFactory(tf).
   114  		WithInventoryClient(invClient).
   115  		Build()
   116  	require.NoError(t, err)
   117  	destroyer.statusWatcher = statusWatcher
   118  
   119  	return destroyer
   120  }
   121  
   122  func newTestInventory(
   123  	t *testing.T,
   124  	tf *cmdtesting.TestFactory,
   125  ) inventory.Client {
   126  	// Use an Client with a fakeInfoHelper to allow generating Info
   127  	// objects that use the FakeRESTClient as the UnstructuredClient.
   128  	invClient, err := inventory.ClusterClientFactory{StatusPolicy: inventory.StatusPolicyAll}.NewClient(tf)
   129  	require.NoError(t, err)
   130  	return invClient
   131  }
   132  
   133  func newTestFactory(
   134  	t *testing.T,
   135  	invInfo inventoryInfo,
   136  	resourceSet object.UnstructuredSet,
   137  	clusterObjs object.UnstructuredSet,
   138  ) *cmdtesting.TestFactory {
   139  	tf := cmdtesting.NewTestFactory().WithNamespace(invInfo.namespace)
   140  
   141  	mapper, err := tf.ToRESTMapper()
   142  	require.NoError(t, err)
   143  
   144  	objMap := make(map[object.ObjMetadata]resourceInfo)
   145  	for _, r := range resourceSet {
   146  		objMeta := object.UnstructuredToObjMetadata(r)
   147  		objMap[objMeta] = resourceInfo{
   148  			resource: r,
   149  			exists:   false,
   150  		}
   151  	}
   152  	for _, r := range clusterObjs {
   153  		objMeta := object.UnstructuredToObjMetadata(r)
   154  		objMap[objMeta] = resourceInfo{
   155  			resource: r,
   156  			exists:   true,
   157  		}
   158  	}
   159  	var objs []resourceInfo
   160  	for _, obj := range objMap {
   161  		objs = append(objs, obj)
   162  	}
   163  
   164  	handlers := []handler{
   165  		&nsHandler{},
   166  		&genericHandler{
   167  			resources: objs,
   168  			mapper:    mapper,
   169  		},
   170  	}
   171  
   172  	tf.UnstructuredClient = newFakeRESTClient(t, handlers)
   173  	tf.FakeDynamicClient = fakeDynamicClient(t, mapper, invInfo, objs...)
   174  
   175  	return tf
   176  }
   177  
   178  type resourceInfo struct {
   179  	resource *unstructured.Unstructured
   180  	exists   bool
   181  }
   182  
   183  // newFakeRESTClient creates a new client that uses a set of handlers to
   184  // determine how to handle requests. For every request it will iterate through
   185  // the handlers until it can find one that knows how to handle the request.
   186  // This is to keep the main structure of the fake client manageable while still
   187  // allowing different behavior for different testcases.
   188  func newFakeRESTClient(t *testing.T, handlers []handler) *fake.RESTClient {
   189  	return &fake.RESTClient{
   190  		NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
   191  		Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
   192  			klog.V(5).Infof("FakeRESTClient: handling %s request for %q", req.Method, req.URL)
   193  			for _, h := range handlers {
   194  				resp, handled, err := h.handle(t, req)
   195  				if err != nil {
   196  					t.Fatalf("unexpected error: %v", err)
   197  					return nil, nil
   198  				}
   199  				if handled {
   200  					return resp, nil
   201  				}
   202  			}
   203  			t.Fatalf("unexpected request: %#v\n%#v", req.URL, req)
   204  			return nil, nil
   205  		}),
   206  	}
   207  }
   208  
   209  // The handler interface allows different testcases to provide
   210  // special handling of requests. It also allows a single handler
   211  // to keep state between a set of related requests instead of keeping
   212  // a single large event handler.
   213  type handler interface {
   214  	handle(t *testing.T, req *http.Request) (*http.Response, bool, error)
   215  }
   216  
   217  // genericHandler provides a simple handler for resources that can
   218  // be fetched and updated. It will simply return the given resource
   219  // when asked for and accept patch requests.
   220  type genericHandler struct {
   221  	resources []resourceInfo
   222  	mapper    meta.RESTMapper
   223  }
   224  
   225  func (g *genericHandler) handle(t *testing.T, req *http.Request) (*http.Response, bool, error) {
   226  	klog.V(5).Infof("genericHandler: handling %s request for %q", req.Method, req.URL)
   227  	for _, r := range g.resources {
   228  		gvk := r.resource.GroupVersionKind()
   229  		mapping, err := g.mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
   230  		if err != nil {
   231  			return nil, false, err
   232  		}
   233  		var allPath string
   234  		if mapping.Scope == meta.RESTScopeNamespace {
   235  			allPath = fmt.Sprintf("/namespaces/%s/%s", r.resource.GetNamespace(), mapping.Resource.Resource)
   236  		} else {
   237  			allPath = fmt.Sprintf("/%s", mapping.Resource.Resource)
   238  		}
   239  		singlePath := allPath + "/" + r.resource.GetName()
   240  
   241  		if req.URL.Path == singlePath && req.Method == http.MethodGet {
   242  			if r.exists {
   243  				bodyRC := io.NopCloser(bytes.NewReader(toJSONBytes(t, r.resource)))
   244  				return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
   245  			}
   246  			return &http.Response{StatusCode: http.StatusNotFound, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.StringBody("")}, true, nil
   247  		}
   248  
   249  		if req.URL.Path == singlePath && req.Method == http.MethodPatch {
   250  			bodyRC := io.NopCloser(bytes.NewReader(toJSONBytes(t, r.resource)))
   251  			return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
   252  		}
   253  
   254  		if req.URL.Path == singlePath && req.Method == http.MethodDelete {
   255  			if r.exists {
   256  				bodyRC := io.NopCloser(bytes.NewReader(toJSONBytes(t, r.resource)))
   257  				return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
   258  			}
   259  
   260  			// We're not testing DeletePropagationOrphan, so StatusOK should be
   261  			// safe. Otherwise, the status might be StatusAccepted.
   262  			// https://github.com/kubernetes/apiserver/blob/v0.22.2/pkg/endpoints/handlers/delete.go#L140
   263  			status := http.StatusOK
   264  
   265  			// Return Status object, if resource doesn't exist.
   266  			result := &metav1.Status{
   267  				Status: metav1.StatusSuccess,
   268  				Code:   int32(status),
   269  				Details: &metav1.StatusDetails{
   270  					Name: r.resource.GetName(),
   271  					Kind: r.resource.GetKind(),
   272  				},
   273  			}
   274  			bodyRC := io.NopCloser(bytes.NewReader(toJSONBytes(t, result)))
   275  			return &http.Response{StatusCode: status, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
   276  		}
   277  
   278  		if req.URL.Path == allPath && req.Method == http.MethodPost {
   279  			bodyRC := io.NopCloser(bytes.NewReader(toJSONBytes(t, r.resource)))
   280  			return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
   281  		}
   282  	}
   283  	return nil, false, nil
   284  }
   285  
   286  func newInventoryReactor(invInfo inventoryInfo) *inventoryReactor {
   287  	return &inventoryReactor{
   288  		inventoryObj: invInfo.toUnstructured(),
   289  	}
   290  }
   291  
   292  type inventoryReactor struct {
   293  	inventoryObj *unstructured.Unstructured
   294  }
   295  
   296  func (ir *inventoryReactor) updateFakeDynamicClient(fdc *dynamicfake.FakeDynamicClient) {
   297  	fdc.PrependReactor("create", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) {
   298  		obj := *action.(clienttesting.CreateAction).GetObject().(*unstructured.Unstructured)
   299  		ir.inventoryObj = &obj
   300  		return true, ir.inventoryObj.DeepCopy(), nil
   301  	})
   302  	fdc.PrependReactor("list", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) {
   303  		uList := &unstructured.UnstructuredList{
   304  			Items: []unstructured.Unstructured{},
   305  		}
   306  		if ir.inventoryObj != nil {
   307  			uList.Items = append(uList.Items, *ir.inventoryObj.DeepCopy())
   308  		}
   309  		return true, uList, nil
   310  	})
   311  	fdc.PrependReactor("get", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) {
   312  		return true, ir.inventoryObj.DeepCopy(), nil
   313  	})
   314  	fdc.PrependReactor("update", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) {
   315  		obj := *action.(clienttesting.UpdateAction).GetObject().(*unstructured.Unstructured)
   316  		ir.inventoryObj = &obj
   317  		return true, ir.inventoryObj.DeepCopy(), nil
   318  	})
   319  }
   320  
   321  // nsHandler can handle requests for a namespace. It will behave as if
   322  // every requested namespace exists. It simply fetches the name of the requested
   323  // namespace from the url and creates a new namespace type with the provided
   324  // name for the response.
   325  type nsHandler struct{}
   326  
   327  var (
   328  	nsPathRegex = regexp.MustCompile(`/api/v1/namespaces/([^/]+)`)
   329  )
   330  
   331  func (n *nsHandler) handle(t *testing.T, req *http.Request) (*http.Response, bool, error) {
   332  	match := nsPathRegex.FindStringSubmatch(req.URL.Path)
   333  	if req.Method == http.MethodGet && match != nil {
   334  		nsName := match[1]
   335  		ns := v1.Namespace{
   336  			TypeMeta: metav1.TypeMeta{
   337  				APIVersion: "v1",
   338  				Kind:       "Namespace",
   339  			},
   340  			ObjectMeta: metav1.ObjectMeta{
   341  				Name: nsName,
   342  			},
   343  		}
   344  		bodyRC := io.NopCloser(bytes.NewReader(toJSONBytes(t, &ns)))
   345  		return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
   346  	}
   347  	return nil, false, nil
   348  }
   349  
   350  type fakeWatcher struct {
   351  	start  chan struct{}
   352  	events []pollevent.Event
   353  }
   354  
   355  func newFakeWatcher(statusEvents []pollevent.Event) *fakeWatcher {
   356  	return &fakeWatcher{
   357  		events: statusEvents,
   358  		start:  make(chan struct{}),
   359  	}
   360  }
   361  
   362  // Start events being sent on the status channel
   363  func (f *fakeWatcher) Start() {
   364  	close(f.start)
   365  }
   366  
   367  func (f *fakeWatcher) Watch(ctx context.Context, _ object.ObjMetadataSet, _ watcher.Options) <-chan pollevent.Event {
   368  	eventChannel := make(chan pollevent.Event)
   369  	go func() {
   370  		defer close(eventChannel)
   371  		// send sync event immediately
   372  		eventChannel <- pollevent.Event{Type: pollevent.SyncEvent}
   373  		// wait until started to send the events
   374  		<-f.start
   375  		for _, f := range f.events {
   376  			eventChannel <- f
   377  		}
   378  		// wait until cancelled to close the event channel and exit
   379  		<-ctx.Done()
   380  	}()
   381  	return eventChannel
   382  }
   383  
   384  type fakeInfoHelper struct {
   385  	factory *cmdtesting.TestFactory
   386  }
   387  
   388  // TODO(mortent): This has too much code in common with the
   389  // infoHelper implementation. We need to find a better way to structure
   390  // this.
   391  func (f *fakeInfoHelper) UpdateInfo(info *resource.Info) error {
   392  	mapper, err := f.factory.ToRESTMapper()
   393  	if err != nil {
   394  		return err
   395  	}
   396  	gvk := info.Object.GetObjectKind().GroupVersionKind()
   397  	mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
   398  	if err != nil {
   399  		return err
   400  	}
   401  	info.Mapping = mapping
   402  
   403  	c, err := f.getClient(gvk.GroupVersion())
   404  	if err != nil {
   405  		return err
   406  	}
   407  	info.Client = c
   408  	return nil
   409  }
   410  
   411  func (f *fakeInfoHelper) BuildInfo(obj *unstructured.Unstructured) (*resource.Info, error) {
   412  	info := &resource.Info{
   413  		Name:      obj.GetName(),
   414  		Namespace: obj.GetNamespace(),
   415  		Source:    "unstructured",
   416  		Object:    obj,
   417  	}
   418  	err := f.UpdateInfo(info)
   419  	return info, err
   420  }
   421  
   422  func (f *fakeInfoHelper) getClient(gv schema.GroupVersion) (resource.RESTClient, error) {
   423  	if f.factory.UnstructuredClientForMappingFunc != nil {
   424  		return f.factory.UnstructuredClientForMappingFunc(gv)
   425  	}
   426  	if f.factory.UnstructuredClient != nil {
   427  		return f.factory.UnstructuredClient, nil
   428  	}
   429  	return f.factory.Client, nil
   430  }
   431  
   432  // fakeDynamicClient returns a fake dynamic client.
   433  func fakeDynamicClient(t *testing.T, mapper meta.RESTMapper, invInfo inventoryInfo, objs ...resourceInfo) *dynamicfake.FakeDynamicClient {
   434  	fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme)
   435  
   436  	invReactor := newInventoryReactor(invInfo)
   437  	invReactor.updateFakeDynamicClient(fakeClient)
   438  
   439  	for i := range objs {
   440  		obj := objs[i]
   441  		gvk := obj.resource.GroupVersionKind()
   442  		mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
   443  		if !assert.NoError(t, err) {
   444  			t.FailNow()
   445  		}
   446  		r := mapping.Resource.Resource
   447  		fakeClient.PrependReactor("get", r, func(clienttesting.Action) (bool, runtime.Object, error) {
   448  			if obj.exists {
   449  				return true, obj.resource, nil
   450  			}
   451  			return false, nil, nil
   452  		})
   453  		fakeClient.PrependReactor("delete", r, func(clienttesting.Action) (bool, runtime.Object, error) {
   454  			return true, nil, nil
   455  		})
   456  	}
   457  
   458  	return fakeClient
   459  }
   460  
   461  func toJSONBytes(t *testing.T, obj runtime.Object) []byte {
   462  	objBytes, err := runtime.Encode(unstructured.NewJSONFallbackEncoder(codec), obj)
   463  	if !assert.NoError(t, err) {
   464  		t.Fatal(err)
   465  	}
   466  	return objBytes
   467  }
   468  
   469  type JSONPathSetter struct {
   470  	Path  string
   471  	Value interface{}
   472  }
   473  
   474  func (jps JSONPathSetter) Mutate(u *unstructured.Unstructured) {
   475  	_, err := jsonpath.Set(u.Object, jps.Path, jps.Value)
   476  	if err != nil {
   477  		panic(fmt.Sprintf("failed to mutate unstructured object: %v", err))
   478  	}
   479  }
   480  

View as plain text