From cd7e99ac15c49f995597fa52cccc9bc278ec0fc2 Mon Sep 17 00:00:00 2001 From: Kostiantyn Kalynovskyi 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 -}