From cd7e99ac15c49f995597fa52cccc9bc278ec0fc2 Mon Sep 17 00:00:00 2001
From: Kostiantyn Kalynovskyi <kkalynovskyi@mirantis.com>
Date: Tue, 16 Jun 2020 14:59:02 -0500
Subject: [PATCH] Add more genernic Kubectl Factory interface

Adds FakeFactory function, that allows to inject custom handlers
for HTTP and Unstructured client, which in turn enables better
testing for kubernetes api related packages

Relates-To: #276
Relates-To: #238

Change-Id: Ic27352bdc64bfccb91cc6a49afa6164e4624b1e1
Closes: #276
---
 pkg/k8s/kubectl/apply_options_test.go     |  14 --
 pkg/k8s/kubectl/kubectl_test.go           |  23 +-
 pkg/phase/apply/apply_test.go             |  25 ++-
 testutil/k8sutils/mock_kubectl_factory.go | 243 ++++++++++++++++------
 4 files changed, 213 insertions(+), 92 deletions(-)

diff --git a/pkg/k8s/kubectl/apply_options_test.go b/pkg/k8s/kubectl/apply_options_test.go
index 59302e212..227771c82 100644
--- a/pkg/k8s/kubectl/apply_options_test.go
+++ b/pkg/k8s/kubectl/apply_options_test.go
@@ -19,7 +19,6 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	cmdutil "k8s.io/kubectl/pkg/cmd/util"
 
@@ -38,19 +37,6 @@ var (
 	ErrNamespaceError     = errors.New("ErrNamespaceError")
 )
 
-func TestApplyOptionsRun(t *testing.T) {
-	f := k8stest.NewFakeFactoryForRC(t, filenameRC)
-	defer f.Cleanup()
-
-	streams := genericclioptions.NewTestIOStreamsDiscard()
-
-	aa, err := kubectl.NewApplyOptions(f, streams)
-	require.NoError(t, err, "Could not build ApplyAdapter")
-	aa.SetDryRun(true)
-	aa.SetSourceFiles([]string{filenameRC})
-	assert.NoError(t, aa.Run())
-}
-
 func TestNewApplyOptionsFactoryFailures(t *testing.T) {
 	tests := []struct {
 		f             cmdutil.Factory
diff --git a/pkg/k8s/kubectl/kubectl_test.go b/pkg/k8s/kubectl/kubectl_test.go
index cfed67595..3db24948c 100644
--- a/pkg/k8s/kubectl/kubectl_test.go
+++ b/pkg/k8s/kubectl/kubectl_test.go
@@ -20,6 +20,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
 
 	"opendev.org/airship/airshipctl/pkg/document"
 	"opendev.org/airship/airshipctl/pkg/k8s/kubectl"
@@ -68,18 +69,28 @@ func TestNewKubectlFromKubeConfigPath(t *testing.T) {
 }
 
 func TestApply(t *testing.T) {
-	f := k8stest.NewFakeFactoryForRC(t, filenameRC)
+	b := testutil.NewTestBundle(t, fixtureDir)
+	docs, err := b.GetByAnnotation("airshipit.org/initinfra")
+	require.NoError(t, err, "failed to get documents from bundle")
+	replicationController, err := b.SelectOne(document.NewSelector().ByKind("ReplicationController"))
+	require.NoError(t, err)
+	rcBytes, err := replicationController.AsYAML()
+	require.NoError(t, err)
+	f := k8stest.FakeFactory(t,
+		[]k8stest.ClientHandler{
+			&k8stest.GenericHandler{
+				Obj:       &corev1.ReplicationController{},
+				Bytes:     rcBytes,
+				URLPath:   "/namespaces/%s/replicationcontrollers",
+				Namespace: replicationController.GetNamespace(),
+			},
+		})
 	defer f.Cleanup()
 	kctl := kubectl.NewKubectl(f).WithBufferDir("/tmp/.airship")
 	kctl.Factory = f
 	ao, err := kctl.ApplyOptions()
 	require.NoError(t, err, "failed to get documents from bundle")
 	ao.SetDryRun(true)
-
-	b := testutil.NewTestBundle(t, fixtureDir)
-	docs, err := b.GetByAnnotation("airshipit.org/initinfra")
-	require.NoError(t, err, "failed to get documents from bundle")
-
 	tests := []struct {
 		name        string
 		expectedErr error
diff --git a/pkg/phase/apply/apply_test.go b/pkg/phase/apply/apply_test.go
index ce92082c2..9e3b59ffe 100644
--- a/pkg/phase/apply/apply_test.go
+++ b/pkg/phase/apply/apply_test.go
@@ -21,18 +21,20 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
 
+	"opendev.org/airship/airshipctl/pkg/document"
 	"opendev.org/airship/airshipctl/pkg/environment"
 	"opendev.org/airship/airshipctl/pkg/k8s/client"
 	"opendev.org/airship/airshipctl/pkg/k8s/client/fake"
 	"opendev.org/airship/airshipctl/pkg/k8s/kubectl"
 	"opendev.org/airship/airshipctl/pkg/phase/apply"
+	"opendev.org/airship/airshipctl/testutil"
 	"opendev.org/airship/airshipctl/testutil/k8sutils"
 )
 
 const (
 	kubeconfigPath    = "testdata/kubeconfig.yaml"
-	filenameRC        = "testdata/primary/site/test-site/ephemeral/initinfra/replicationcontroller.yaml"
 	airshipConfigFile = "testdata/config.yaml"
 )
 
@@ -42,14 +44,29 @@ var (
 
 func TestDeploy(t *testing.T) {
 	rs := makeNewFakeRootSettings(t, kubeconfigPath, airshipConfigFile)
-	tf := k8sutils.NewFakeFactoryForRC(t, filenameRC)
-	defer tf.Cleanup()
+	bundle := testutil.NewTestBundle(t, "testdata/primary/site/test-site/ephemeral/initinfra")
+	replicationController, err := bundle.SelectOne(document.NewSelector().ByKind("ReplicationController"))
+	require.NoError(t, err)
+	b, err := replicationController.AsYAML()
+	require.NoError(t, err)
+	f := k8sutils.FakeFactory(t,
+		[]k8sutils.ClientHandler{
+			&k8sutils.InventoryObjectHandler{},
+			&k8sutils.NamespaceHandler{},
+			&k8sutils.GenericHandler{
+				Obj:       &corev1.ReplicationController{},
+				Bytes:     b,
+				URLPath:   "/namespaces/%s/replicationcontrollers",
+				Namespace: replicationController.GetNamespace(),
+			},
+		})
+	defer f.Cleanup()
 
 	ao := apply.NewOptions(rs)
 	ao.PhaseName = "initinfra"
 	ao.DryRun = true
 
-	kctl := kubectl.NewKubectl(tf)
+	kctl := kubectl.NewKubectl(f)
 
 	tests := []struct {
 		theApplyOptions *apply.Options
diff --git a/testutil/k8sutils/mock_kubectl_factory.go b/testutil/k8sutils/mock_kubectl_factory.go
index f6698d709..3b47f2511 100644
--- a/testutil/k8sutils/mock_kubectl_factory.go
+++ b/testutil/k8sutils/mock_kubectl_factory.go
@@ -16,16 +16,19 @@ package k8sutils
 
 import (
 	"bytes"
+	"fmt"
 	"io/ioutil"
 	"net/http"
-	"os"
+	"path"
+	"regexp"
 	"testing"
 
 	"github.com/stretchr/testify/require"
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/meta"
+	v1 "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/genericclioptions"
 	"k8s.io/cli-runtime/pkg/resource"
 	"k8s.io/client-go/discovery"
@@ -195,79 +198,183 @@ func NewMockClientConfig() *MockClientConfig {
 	}
 }
 
-// NewFakeFactoryForRC returns a fake Factory object for testing
-// It is used to mock network interactions via a rest.Request
-func NewFakeFactoryForRC(t *testing.T, filenameRC string) *cmdtesting.TestFactory {
+// ClientHandler is an interface that can be injected into FakeFactory
+// it's purpose to mock http request handling done by the Kubernetes Clients produced by cmdutils.Factory
+type ClientHandler interface {
+	Handle(t *testing.T, req *http.Request) (*http.Response, bool, error)
+}
+
+var (
+	nsNamedPathRegex = regexp.MustCompile(`/api/v1/namespaces/([^/]+)`)
+	nsPath           = "/api/v1/namespaces"
+)
+
+// NamespaceHandler implements ClientHandler, that is to be used to handle
+// Http Requests made by clients that are produced by cmdutils.Factory interface
+type NamespaceHandler struct {
+}
+
+var _ ClientHandler = &NamespaceHandler{}
+
+// Handle implements handler
+func (h *NamespaceHandler) Handle(_ *testing.T, req *http.Request) (*http.Response, bool, error) {
 	c := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...)
+	switch match, method := nsNamedPathRegex.FindStringSubmatch(req.URL.Path), req.Method; {
+	case match != nil && method == http.MethodGet:
+		ns := &corev1.Namespace{
+			TypeMeta: v1.TypeMeta{
+				Kind:       "Namespace",
+				APIVersion: "v1"},
+			ObjectMeta: v1.ObjectMeta{
+				// check that this index exists is performed at case statement match != nil
+				// this means that [0] and [1] exist
+				Name: match[1],
+			}}
+		response := &http.Response{
+			StatusCode: http.StatusOK,
+			Header:     cmdtesting.DefaultHeader(),
+			Body:       cmdtesting.ObjBody(c, ns)}
+		return response, true, nil
 
+	case req.URL.Path == nsPath && method == http.MethodPost:
+		ns := &corev1.Namespace{
+			TypeMeta: v1.TypeMeta{
+				Kind:       "Namespace",
+				APIVersion: "v1"},
+		}
+		response := &http.Response{StatusCode: http.StatusOK,
+			Header: cmdtesting.DefaultHeader(),
+			Body:   cmdtesting.ObjBody(c, ns)}
+		return response, true, nil
+	}
+
+	return nil, false, nil
+}
+
+// InventoryObjectHandler handles configmap inventory object from cli-utils by mocking
+// http calls made by clients produced by cmdutils.Factory interface
+type InventoryObjectHandler struct {
+	inventoryObj *corev1.ConfigMap
+}
+
+var _ ClientHandler = &InventoryObjectHandler{}
+
+var (
+	cmPathRegex              = regexp.MustCompile(`^/namespaces/([^/]+)/configmaps$`)
+	resourceNameRegexpString = `^[a-zA-Z]+-[a-z0-9]+$`
+	invObjNameRegex          = regexp.MustCompile(resourceNameRegexpString)
+	invObjPathRegex          = regexp.MustCompile(`^/namespaces/([^/]+)/configmaps/` + resourceNameRegexpString[:1])
+	codec                    = scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...)
+)
+
+// Handle implements handler
+func (i *InventoryObjectHandler) Handle(t *testing.T, req *http.Request) (*http.Response, bool, error) {
+	if req.Method == http.MethodPost && cmPathRegex.Match([]byte(req.URL.Path)) {
+		b, err := ioutil.ReadAll(req.Body)
+		if err != nil {
+			return nil, false, err
+		}
+		cm := corev1.ConfigMap{}
+		err = runtime.DecodeInto(codec, b, &cm)
+		if err != nil {
+			return nil, false, err
+		}
+		if invObjNameRegex.Match([]byte(cm.Name)) {
+			i.inventoryObj = &cm
+			bodyRC := ioutil.NopCloser(bytes.NewReader(b))
+			return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
+		}
+		return nil, false, nil
+	}
+
+	if req.Method == http.MethodGet && cmPathRegex.Match([]byte(req.URL.Path)) {
+		cmList := corev1.ConfigMapList{
+			TypeMeta: v1.TypeMeta{
+				APIVersion: "v1",
+				Kind:       "List",
+			},
+			Items: []corev1.ConfigMap{},
+		}
+		if i.inventoryObj != nil {
+			cmList.Items = append(cmList.Items, *i.inventoryObj)
+		}
+		bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, &cmList)))
+		return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
+	}
+
+	if req.Method == http.MethodGet && invObjPathRegex.Match([]byte(req.URL.Path)) {
+		if i.inventoryObj == nil {
+			return &http.Response{
+				StatusCode: http.StatusNotFound,
+				Header:     cmdtesting.DefaultHeader(),
+				Body:       cmdtesting.StringBody("")}, true, nil
+		}
+		bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, i.inventoryObj)))
+		return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
+	}
+	return nil, false, nil
+}
+
+// GenericHandler is a handler for generic objects
+type GenericHandler struct {
+	Obj       runtime.Object
+	Namespace string
+	// URLPath is a string for formater in which it should be defined how to inject a namespace into it
+	// example : /namespaces/%s/deployments
+	URLPath string
+	Bytes   []byte
+}
+
+var _ ClientHandler = &GenericHandler{}
+
+// Handle implements handler
+func (g *GenericHandler) Handle(t *testing.T, req *http.Request) (*http.Response, bool, error) {
+	err := runtime.DecodeInto(codec, g.Bytes, g.Obj)
+	if err != nil {
+		return nil, false, err
+	}
+	accessor, err := meta.Accessor(g.Obj)
+	if err != nil {
+		return nil, false, err
+	}
+	basePath := fmt.Sprintf(g.URLPath, g.Namespace)
+	resourcePath := path.Join(basePath, accessor.GetName())
+	if req.URL.Path == resourcePath && req.Method == http.MethodGet {
+		bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, g.Obj)))
+		return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
+	}
+	if req.URL.Path == resourcePath && req.Method == http.MethodPatch {
+		bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, g.Obj)))
+		return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
+	}
+	return nil, false, nil
+}
+
+func toJSONBytes(t *testing.T, obj runtime.Object) []byte {
+	objBytes, err := runtime.Encode(unstructured.NewJSONFallbackEncoder(codec), obj)
+	require.NoError(t, err)
+	return objBytes
+}
+
+// FakeFactory returns a fake factory based on provided handlers
+func FakeFactory(t *testing.T, handlers []ClientHandler) *cmdtesting.TestFactory {
 	f := cmdtesting.NewTestFactory().WithNamespace("test")
-
-	f.ClientConfigVal = cmdtesting.DefaultClientConfig()
-
-	pathRC := "/namespaces/test/replicationcontrollers/test-rc"
-	get := "GET"
-	_, rcBytes := readReplicationController(t, filenameRC, c)
-
-	f.UnstructuredClient = &fake.RESTClient{
-		GroupVersion:         schema.GroupVersion{Version: "v1"},
+	defer f.Cleanup()
+	testRESTClient := &fake.RESTClient{
 		NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
 		Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
-			switch p, m := req.URL.Path, req.Method; {
-			case p == pathRC && m == get:
-				bodyRC := ioutil.NopCloser(bytes.NewReader(rcBytes))
-				return &http.Response{StatusCode: http.StatusOK,
-					Header: cmdtesting.DefaultHeader(),
-					Body:   bodyRC}, nil
-			case p == "/namespaces/test/replicationcontrollers" && m == get:
-				bodyRC := ioutil.NopCloser(bytes.NewReader(rcBytes))
-				return &http.Response{StatusCode: http.StatusOK,
-					Header: cmdtesting.DefaultHeader(),
-					Body:   bodyRC}, nil
-			case p == "/namespaces/test/replicationcontrollers/no-match" && m == get:
-				return &http.Response{StatusCode: http.StatusNotFound,
-					Header: cmdtesting.DefaultHeader(),
-					Body:   cmdtesting.ObjBody(c, &corev1.Pod{})}, nil
-			case p == "/api/v1/namespaces/test" && m == get:
-				return &http.Response{StatusCode: http.StatusOK,
-					Header: cmdtesting.DefaultHeader(),
-					Body:   cmdtesting.ObjBody(c, &corev1.Namespace{})}, nil
-			default:
-				t.Fatalf("unexpected request: %#v\n%#v", req.URL, req)
-				return nil, nil
+			for _, h := range handlers {
+				resp, handled, err := h.Handle(t, req)
+				if handled {
+					return resp, err
+				}
 			}
+			t.Fatalf("unexpected request: %#v\n%#v", req.URL, req)
+			// dummy return
+			return nil, nil
 		}),
 	}
+	f.Client = testRESTClient
+	f.UnstructuredClient = testRESTClient
 	return f
 }
-
-// Below functions are taken from Kubectl library.
-// https://github.com/kubernetes/kubectl/blob/master/pkg/cmd/apply/apply_test.go
-func readReplicationController(t *testing.T, filenameRC string, c runtime.Codec) (string, []byte) {
-	t.Helper()
-	rcObj := readReplicationControllerFromFile(t, filenameRC, c)
-	metaAccessor, err := meta.Accessor(rcObj)
-	require.NoError(t, err, "Could not read replcation controller")
-	rcBytes, err := runtime.Encode(c, rcObj)
-	require.NoError(t, err, "Could not read replcation controller")
-	return metaAccessor.GetName(), rcBytes
-}
-
-func readReplicationControllerFromFile(t *testing.T,
-	filename string, c runtime.Decoder) *corev1.ReplicationController {
-	data := readBytesFromFile(t, filename)
-	rc := corev1.ReplicationController{}
-	require.NoError(t, runtime.DecodeInto(c, data, &rc), "Could not read replcation controller")
-
-	return &rc
-}
-
-func readBytesFromFile(t *testing.T, filename string) []byte {
-	file, err := os.Open(filename)
-	require.NoError(t, err, "Could not read file")
-	defer file.Close()
-
-	data, err := ioutil.ReadAll(file)
-	require.NoError(t, err, "Could not read file")
-
-	return data
-}