mirror of https://github.com/fluxcd/cli-utils.git
				
				
				
			
		
			
				
	
	
		
			480 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			480 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
| // Copyright 2020 The Kubernetes Authors.
 | |
| // SPDX-License-Identifier: Apache-2.0
 | |
| 
 | |
| package apply
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"context"
 | |
| 	"fmt"
 | |
| 	"io/ioutil"
 | |
| 	"net/http"
 | |
| 	"regexp"
 | |
| 	"testing"
 | |
| 
 | |
| 	"github.com/stretchr/testify/assert"
 | |
| 	"github.com/stretchr/testify/require"
 | |
| 	v1 "k8s.io/api/core/v1"
 | |
| 	"k8s.io/apimachinery/pkg/api/meta"
 | |
| 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 | |
| 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 | |
| 	"k8s.io/apimachinery/pkg/runtime"
 | |
| 	"k8s.io/apimachinery/pkg/runtime/schema"
 | |
| 	"k8s.io/cli-runtime/pkg/resource"
 | |
| 	dynamicfake "k8s.io/client-go/dynamic/fake"
 | |
| 	"k8s.io/client-go/rest/fake"
 | |
| 	clienttesting "k8s.io/client-go/testing"
 | |
| 	"k8s.io/klog/v2"
 | |
| 	cmdtesting "k8s.io/kubectl/pkg/cmd/testing"
 | |
| 	"k8s.io/kubectl/pkg/scheme"
 | |
| 	"sigs.k8s.io/cli-utils/pkg/common"
 | |
| 	"sigs.k8s.io/cli-utils/pkg/inventory"
 | |
| 	"sigs.k8s.io/cli-utils/pkg/jsonpath"
 | |
| 	pollevent "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
 | |
| 	"sigs.k8s.io/cli-utils/pkg/kstatus/watcher"
 | |
| 	"sigs.k8s.io/cli-utils/pkg/object"
 | |
| )
 | |
| 
 | |
| type inventoryInfo struct {
 | |
| 	name      string
 | |
| 	namespace string
 | |
| 	id        string
 | |
| 	set       object.ObjMetadataSet
 | |
| }
 | |
| 
 | |
| func (i inventoryInfo) toUnstructured() *unstructured.Unstructured {
 | |
| 	invMap := make(map[string]interface{})
 | |
| 	for _, objMeta := range i.set {
 | |
| 		invMap[objMeta.String()] = ""
 | |
| 	}
 | |
| 
 | |
| 	return &unstructured.Unstructured{
 | |
| 		Object: map[string]interface{}{
 | |
| 			"apiVersion": "v1",
 | |
| 			"kind":       "ConfigMap",
 | |
| 			"metadata": map[string]interface{}{
 | |
| 				"name":      i.name,
 | |
| 				"namespace": i.namespace,
 | |
| 				"labels": map[string]interface{}{
 | |
| 					common.InventoryLabel: i.id,
 | |
| 				},
 | |
| 			},
 | |
| 			"data": invMap,
 | |
| 		},
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (i inventoryInfo) toWrapped() inventory.Info {
 | |
| 	return inventory.WrapInventoryInfoObj(i.toUnstructured())
 | |
| }
 | |
| 
 | |
| func newTestApplier(
 | |
| 	t *testing.T,
 | |
| 	invInfo inventoryInfo,
 | |
| 	resources object.UnstructuredSet,
 | |
| 	clusterObjs object.UnstructuredSet,
 | |
| 	statusWatcher watcher.StatusWatcher,
 | |
| ) *Applier {
 | |
| 	tf := newTestFactory(t, invInfo, resources, clusterObjs)
 | |
| 	defer tf.Cleanup()
 | |
| 
 | |
| 	infoHelper := &fakeInfoHelper{
 | |
| 		factory: tf,
 | |
| 	}
 | |
| 
 | |
| 	invClient := newTestInventory(t, tf)
 | |
| 
 | |
| 	applier, err := NewApplierBuilder().
 | |
| 		WithFactory(tf).
 | |
| 		WithInventoryClient(invClient).
 | |
| 		WithStatusWatcher(statusWatcher).
 | |
| 		Build()
 | |
| 	require.NoError(t, err)
 | |
| 
 | |
| 	// Inject the fakeInfoHelper to allow generating Info
 | |
| 	// objects that use the FakeRESTClient as the UnstructuredClient.
 | |
| 	applier.infoHelper = infoHelper
 | |
| 
 | |
| 	return applier
 | |
| }
 | |
| 
 | |
| func newTestDestroyer(
 | |
| 	t *testing.T,
 | |
| 	invInfo inventoryInfo,
 | |
| 	clusterObjs object.UnstructuredSet,
 | |
| 	statusWatcher watcher.StatusWatcher,
 | |
| ) *Destroyer {
 | |
| 	tf := newTestFactory(t, invInfo, object.UnstructuredSet{}, clusterObjs)
 | |
| 	defer tf.Cleanup()
 | |
| 
 | |
| 	invClient := newTestInventory(t, tf)
 | |
| 
 | |
| 	destroyer, err := NewDestroyerBuilder().
 | |
| 		WithFactory(tf).
 | |
| 		WithInventoryClient(invClient).
 | |
| 		Build()
 | |
| 	require.NoError(t, err)
 | |
| 	destroyer.statusWatcher = statusWatcher
 | |
| 
 | |
| 	return destroyer
 | |
| }
 | |
| 
 | |
| func newTestInventory(
 | |
| 	t *testing.T,
 | |
| 	tf *cmdtesting.TestFactory,
 | |
| ) inventory.Client {
 | |
| 	// Use an Client with a fakeInfoHelper to allow generating Info
 | |
| 	// objects that use the FakeRESTClient as the UnstructuredClient.
 | |
| 	invClient, err := inventory.ClusterClientFactory{StatusPolicy: inventory.StatusPolicyAll}.NewClient(tf)
 | |
| 	require.NoError(t, err)
 | |
| 	return invClient
 | |
| }
 | |
| 
 | |
| func newTestFactory(
 | |
| 	t *testing.T,
 | |
| 	invInfo inventoryInfo,
 | |
| 	resourceSet object.UnstructuredSet,
 | |
| 	clusterObjs object.UnstructuredSet,
 | |
| ) *cmdtesting.TestFactory {
 | |
| 	tf := cmdtesting.NewTestFactory().WithNamespace(invInfo.namespace)
 | |
| 
 | |
| 	mapper, err := tf.ToRESTMapper()
 | |
| 	require.NoError(t, err)
 | |
| 
 | |
| 	objMap := make(map[object.ObjMetadata]resourceInfo)
 | |
| 	for _, r := range resourceSet {
 | |
| 		objMeta := object.UnstructuredToObjMetadata(r)
 | |
| 		objMap[objMeta] = resourceInfo{
 | |
| 			resource: r,
 | |
| 			exists:   false,
 | |
| 		}
 | |
| 	}
 | |
| 	for _, r := range clusterObjs {
 | |
| 		objMeta := object.UnstructuredToObjMetadata(r)
 | |
| 		objMap[objMeta] = resourceInfo{
 | |
| 			resource: r,
 | |
| 			exists:   true,
 | |
| 		}
 | |
| 	}
 | |
| 	var objs []resourceInfo
 | |
| 	for _, obj := range objMap {
 | |
| 		objs = append(objs, obj)
 | |
| 	}
 | |
| 
 | |
| 	handlers := []handler{
 | |
| 		&nsHandler{},
 | |
| 		&genericHandler{
 | |
| 			resources: objs,
 | |
| 			mapper:    mapper,
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	tf.UnstructuredClient = newFakeRESTClient(t, handlers)
 | |
| 	tf.FakeDynamicClient = fakeDynamicClient(t, mapper, invInfo, objs...)
 | |
| 
 | |
| 	return tf
 | |
| }
 | |
| 
 | |
| type resourceInfo struct {
 | |
| 	resource *unstructured.Unstructured
 | |
| 	exists   bool
 | |
| }
 | |
| 
 | |
| // newFakeRESTClient creates a new client that uses a set of handlers to
 | |
| // determine how to handle requests. For every request it will iterate through
 | |
| // the handlers until it can find one that knows how to handle the request.
 | |
| // This is to keep the main structure of the fake client manageable while still
 | |
| // allowing different behavior for different testcases.
 | |
| func newFakeRESTClient(t *testing.T, handlers []handler) *fake.RESTClient {
 | |
| 	return &fake.RESTClient{
 | |
| 		NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
 | |
| 		Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
 | |
| 			klog.V(5).Infof("FakeRESTClient: handling %s request for %q", req.Method, req.URL)
 | |
| 			for _, h := range handlers {
 | |
| 				resp, handled, err := h.handle(t, req)
 | |
| 				if err != nil {
 | |
| 					t.Fatalf("unexpected error: %v", err)
 | |
| 					return nil, nil
 | |
| 				}
 | |
| 				if handled {
 | |
| 					return resp, nil
 | |
| 				}
 | |
| 			}
 | |
| 			t.Fatalf("unexpected request: %#v\n%#v", req.URL, req)
 | |
| 			return nil, nil
 | |
| 		}),
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // The handler interface allows different testcases to provide
 | |
| // special handling of requests. It also allows a single handler
 | |
| // to keep state between a set of related requests instead of keeping
 | |
| // a single large event handler.
 | |
| type handler interface {
 | |
| 	handle(t *testing.T, req *http.Request) (*http.Response, bool, error)
 | |
| }
 | |
| 
 | |
| // genericHandler provides a simple handler for resources that can
 | |
| // be fetched and updated. It will simply return the given resource
 | |
| // when asked for and accept patch requests.
 | |
| type genericHandler struct {
 | |
| 	resources []resourceInfo
 | |
| 	mapper    meta.RESTMapper
 | |
| }
 | |
| 
 | |
| func (g *genericHandler) handle(t *testing.T, req *http.Request) (*http.Response, bool, error) {
 | |
| 	klog.V(5).Infof("genericHandler: handling %s request for %q", req.Method, req.URL)
 | |
| 	for _, r := range g.resources {
 | |
| 		gvk := r.resource.GroupVersionKind()
 | |
| 		mapping, err := g.mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
 | |
| 		if err != nil {
 | |
| 			return nil, false, err
 | |
| 		}
 | |
| 		var allPath string
 | |
| 		if mapping.Scope == meta.RESTScopeNamespace {
 | |
| 			allPath = fmt.Sprintf("/namespaces/%s/%s", r.resource.GetNamespace(), mapping.Resource.Resource)
 | |
| 		} else {
 | |
| 			allPath = fmt.Sprintf("/%s", mapping.Resource.Resource)
 | |
| 		}
 | |
| 		singlePath := allPath + "/" + r.resource.GetName()
 | |
| 
 | |
| 		if req.URL.Path == singlePath && req.Method == http.MethodGet {
 | |
| 			if r.exists {
 | |
| 				bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, r.resource)))
 | |
| 				return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
 | |
| 			}
 | |
| 			return &http.Response{StatusCode: http.StatusNotFound, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.StringBody("")}, true, nil
 | |
| 		}
 | |
| 
 | |
| 		if req.URL.Path == singlePath && req.Method == http.MethodPatch {
 | |
| 			bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, r.resource)))
 | |
| 			return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
 | |
| 		}
 | |
| 
 | |
| 		if req.URL.Path == singlePath && req.Method == http.MethodDelete {
 | |
| 			if r.exists {
 | |
| 				bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, r.resource)))
 | |
| 				return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
 | |
| 			}
 | |
| 
 | |
| 			// We're not testing DeletePropagationOrphan, so StatusOK should be
 | |
| 			// safe. Otherwise, the status might be StatusAccepted.
 | |
| 			// https://github.com/kubernetes/apiserver/blob/v0.22.2/pkg/endpoints/handlers/delete.go#L140
 | |
| 			status := http.StatusOK
 | |
| 
 | |
| 			// Return Status object, if resource doesn't exist.
 | |
| 			result := &metav1.Status{
 | |
| 				Status: metav1.StatusSuccess,
 | |
| 				Code:   int32(status),
 | |
| 				Details: &metav1.StatusDetails{
 | |
| 					Name: r.resource.GetName(),
 | |
| 					Kind: r.resource.GetKind(),
 | |
| 				},
 | |
| 			}
 | |
| 			bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, result)))
 | |
| 			return &http.Response{StatusCode: status, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
 | |
| 		}
 | |
| 
 | |
| 		if req.URL.Path == allPath && req.Method == http.MethodPost {
 | |
| 			bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, r.resource)))
 | |
| 			return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
 | |
| 		}
 | |
| 	}
 | |
| 	return nil, false, nil
 | |
| }
 | |
| 
 | |
| func newInventoryReactor(invInfo inventoryInfo) *inventoryReactor {
 | |
| 	return &inventoryReactor{
 | |
| 		inventoryObj: invInfo.toUnstructured(),
 | |
| 	}
 | |
| }
 | |
| 
 | |
| type inventoryReactor struct {
 | |
| 	inventoryObj *unstructured.Unstructured
 | |
| }
 | |
| 
 | |
| func (ir *inventoryReactor) updateFakeDynamicClient(fdc *dynamicfake.FakeDynamicClient) {
 | |
| 	fdc.PrependReactor("create", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) {
 | |
| 		obj := *action.(clienttesting.CreateAction).GetObject().(*unstructured.Unstructured)
 | |
| 		ir.inventoryObj = &obj
 | |
| 		return true, ir.inventoryObj.DeepCopy(), nil
 | |
| 	})
 | |
| 	fdc.PrependReactor("list", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) {
 | |
| 		uList := &unstructured.UnstructuredList{
 | |
| 			Items: []unstructured.Unstructured{},
 | |
| 		}
 | |
| 		if ir.inventoryObj != nil {
 | |
| 			uList.Items = append(uList.Items, *ir.inventoryObj.DeepCopy())
 | |
| 		}
 | |
| 		return true, uList, nil
 | |
| 	})
 | |
| 	fdc.PrependReactor("get", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) {
 | |
| 		return true, ir.inventoryObj.DeepCopy(), nil
 | |
| 	})
 | |
| 	fdc.PrependReactor("update", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) {
 | |
| 		obj := *action.(clienttesting.UpdateAction).GetObject().(*unstructured.Unstructured)
 | |
| 		ir.inventoryObj = &obj
 | |
| 		return true, ir.inventoryObj.DeepCopy(), nil
 | |
| 	})
 | |
| }
 | |
| 
 | |
| // nsHandler can handle requests for a namespace. It will behave as if
 | |
| // every requested namespace exists. It simply fetches the name of the requested
 | |
| // namespace from the url and creates a new namespace type with the provided
 | |
| // name for the response.
 | |
| type nsHandler struct{}
 | |
| 
 | |
| var (
 | |
| 	nsPathRegex = regexp.MustCompile(`/api/v1/namespaces/([^/]+)`)
 | |
| )
 | |
| 
 | |
| func (n *nsHandler) handle(t *testing.T, req *http.Request) (*http.Response, bool, error) {
 | |
| 	match := nsPathRegex.FindStringSubmatch(req.URL.Path)
 | |
| 	if req.Method == http.MethodGet && match != nil {
 | |
| 		nsName := match[1]
 | |
| 		ns := v1.Namespace{
 | |
| 			TypeMeta: metav1.TypeMeta{
 | |
| 				APIVersion: "v1",
 | |
| 				Kind:       "Namespace",
 | |
| 			},
 | |
| 			ObjectMeta: metav1.ObjectMeta{
 | |
| 				Name: nsName,
 | |
| 			},
 | |
| 		}
 | |
| 		bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, &ns)))
 | |
| 		return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
 | |
| 	}
 | |
| 	return nil, false, nil
 | |
| }
 | |
| 
 | |
| type fakeWatcher struct {
 | |
| 	start  chan struct{}
 | |
| 	events []pollevent.Event
 | |
| }
 | |
| 
 | |
| func newFakeWatcher(statusEvents []pollevent.Event) *fakeWatcher {
 | |
| 	return &fakeWatcher{
 | |
| 		events: statusEvents,
 | |
| 		start:  make(chan struct{}),
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // Start events being sent on the status channel
 | |
| func (f *fakeWatcher) Start() {
 | |
| 	close(f.start)
 | |
| }
 | |
| 
 | |
| func (f *fakeWatcher) Watch(ctx context.Context, _ object.ObjMetadataSet, _ watcher.Options) <-chan pollevent.Event {
 | |
| 	eventChannel := make(chan pollevent.Event)
 | |
| 	go func() {
 | |
| 		defer close(eventChannel)
 | |
| 		// send sync event immediately
 | |
| 		eventChannel <- pollevent.Event{Type: pollevent.SyncEvent}
 | |
| 		// wait until started to send the events
 | |
| 		<-f.start
 | |
| 		for _, f := range f.events {
 | |
| 			eventChannel <- f
 | |
| 		}
 | |
| 		// wait until cancelled to close the event channel and exit
 | |
| 		<-ctx.Done()
 | |
| 	}()
 | |
| 	return eventChannel
 | |
| }
 | |
| 
 | |
| type fakeInfoHelper struct {
 | |
| 	factory *cmdtesting.TestFactory
 | |
| }
 | |
| 
 | |
| // TODO(mortent): This has too much code in common with the
 | |
| // infoHelper implementation. We need to find a better way to structure
 | |
| // this.
 | |
| func (f *fakeInfoHelper) UpdateInfo(info *resource.Info) error {
 | |
| 	mapper, err := f.factory.ToRESTMapper()
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	gvk := info.Object.GetObjectKind().GroupVersionKind()
 | |
| 	mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	info.Mapping = mapping
 | |
| 
 | |
| 	c, err := f.getClient(gvk.GroupVersion())
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	info.Client = c
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (f *fakeInfoHelper) BuildInfo(obj *unstructured.Unstructured) (*resource.Info, error) {
 | |
| 	info := &resource.Info{
 | |
| 		Name:      obj.GetName(),
 | |
| 		Namespace: obj.GetNamespace(),
 | |
| 		Source:    "unstructured",
 | |
| 		Object:    obj,
 | |
| 	}
 | |
| 	err := f.UpdateInfo(info)
 | |
| 	return info, err
 | |
| }
 | |
| 
 | |
| func (f *fakeInfoHelper) getClient(gv schema.GroupVersion) (resource.RESTClient, error) {
 | |
| 	if f.factory.UnstructuredClientForMappingFunc != nil {
 | |
| 		return f.factory.UnstructuredClientForMappingFunc(gv)
 | |
| 	}
 | |
| 	if f.factory.UnstructuredClient != nil {
 | |
| 		return f.factory.UnstructuredClient, nil
 | |
| 	}
 | |
| 	return f.factory.Client, nil
 | |
| }
 | |
| 
 | |
| // fakeDynamicClient returns a fake dynamic client.
 | |
| func fakeDynamicClient(t *testing.T, mapper meta.RESTMapper, invInfo inventoryInfo, objs ...resourceInfo) *dynamicfake.FakeDynamicClient {
 | |
| 	fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme)
 | |
| 
 | |
| 	invReactor := newInventoryReactor(invInfo)
 | |
| 	invReactor.updateFakeDynamicClient(fakeClient)
 | |
| 
 | |
| 	for i := range objs {
 | |
| 		obj := objs[i]
 | |
| 		gvk := obj.resource.GroupVersionKind()
 | |
| 		mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
 | |
| 		if !assert.NoError(t, err) {
 | |
| 			t.FailNow()
 | |
| 		}
 | |
| 		r := mapping.Resource.Resource
 | |
| 		fakeClient.PrependReactor("get", r, func(clienttesting.Action) (bool, runtime.Object, error) {
 | |
| 			if obj.exists {
 | |
| 				return true, obj.resource, nil
 | |
| 			}
 | |
| 			return false, nil, nil
 | |
| 		})
 | |
| 		fakeClient.PrependReactor("delete", r, func(clienttesting.Action) (bool, runtime.Object, error) {
 | |
| 			return true, nil, nil
 | |
| 		})
 | |
| 	}
 | |
| 
 | |
| 	return fakeClient
 | |
| }
 | |
| 
 | |
| func toJSONBytes(t *testing.T, obj runtime.Object) []byte {
 | |
| 	objBytes, err := runtime.Encode(unstructured.NewJSONFallbackEncoder(codec), obj)
 | |
| 	if !assert.NoError(t, err) {
 | |
| 		t.Fatal(err)
 | |
| 	}
 | |
| 	return objBytes
 | |
| }
 | |
| 
 | |
| type JSONPathSetter struct {
 | |
| 	Path  string
 | |
| 	Value interface{}
 | |
| }
 | |
| 
 | |
| func (jps JSONPathSetter) Mutate(u *unstructured.Unstructured) {
 | |
| 	_, err := jsonpath.Set(u.Object, jps.Path, jps.Value)
 | |
| 	if err != nil {
 | |
| 		panic(fmt.Sprintf("failed to mutate unstructured object: %v", err))
 | |
| 	}
 | |
| }
 |