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
This commit is contained in:
Kostiantyn Kalynovskyi 2020-06-16 14:59:02 -05:00
parent 19c37fef7f
commit cd7e99ac15
4 changed files with 213 additions and 92 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}