airshipctl/testutil/k8sutils/mock_kubectl_factory.go
Kostiantyn Kalynovskyi cd7e99ac15 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
2020-06-17 17:30:14 -05:00

381 lines
15 KiB
Go

/*
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package k8sutils
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"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/cli-runtime/pkg/genericclioptions"
"k8s.io/cli-runtime/pkg/resource"
"k8s.io/client-go/discovery"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
"k8s.io/client-go/rest/fake"
"k8s.io/client-go/tools/clientcmd"
kubeconfig "k8s.io/client-go/tools/clientcmd/api"
cmdtesting "k8s.io/kubectl/pkg/cmd/testing"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/scheme"
"k8s.io/kubectl/pkg/util/openapi"
"k8s.io/kubectl/pkg/validation"
)
// MockKubectlFactory implements Factory interface for testing purposes.
type MockKubectlFactory struct {
MockToDiscoveryClient func() (discovery.CachedDiscoveryInterface, error)
MockDynamicClient func() (dynamic.Interface, error)
MockOpenAPISchema func() (openapi.Resources, error)
MockValidator func() (validation.Schema, error)
MockToRESTMapper func() (meta.RESTMapper, error)
MockToRESTConfig func() (*rest.Config, error)
MockNewBuilder func() *resource.Builder
MockToRawKubeConfigLoader func() clientcmd.ClientConfig
MockClientForMapping func() (resource.RESTClient, error)
KubeConfig kubeconfig.Config
genericclioptions.ConfigFlags
cmdutil.Factory
}
// ToDiscoveryClient implements Factory interface
func (f *MockKubectlFactory) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
return f.MockToDiscoveryClient()
}
// DynamicClient implements Factory interface
// Returns a mock dynamic client ready for use
func (f *MockKubectlFactory) DynamicClient() (dynamic.Interface, error) { return f.MockDynamicClient() }
// OpenAPISchema implements Factory interface
// Returns a mock openapi schema definition. Schema definition includes metadata and structural information about
// Kubernetes object definitions
func (f *MockKubectlFactory) OpenAPISchema() (openapi.Resources, error) { return f.MockOpenAPISchema() }
// Validator implements Factory interface
// Returns a mock schema that can validate objects stored on disk
func (f *MockKubectlFactory) Validator(bool) (validation.Schema, error) {
return f.MockValidator()
}
// ToRESTMapper implements Factory interface
// Returns a mock RESTMapper
// RESTMapper allows clients to map resources to kind, and map kind and version to interfaces for manipulating
// those objects. It is primarily intended for consumers of Kubernetes compatible REST APIs
func (f *MockKubectlFactory) ToRESTMapper() (meta.RESTMapper, error) { return f.MockToRESTMapper() }
// ToRESTConfig implements Factory interface
// Returns a mock Config
// Config holds the common attributes that can be passed to a Kubernetes client on initialization
func (f *MockKubectlFactory) ToRESTConfig() (*rest.Config, error) { return f.MockToRESTConfig() }
// NewBuilder implements Factory interface
// Returns a mock object that assists in loading objects from both disk and the server
func (f *MockKubectlFactory) NewBuilder() *resource.Builder { return f.MockNewBuilder() }
// ToRawKubeConfigLoader implements Factory interface
func (f *MockKubectlFactory) ToRawKubeConfigLoader() clientcmd.ClientConfig {
return f.MockToRawKubeConfigLoader()
}
// ClientForMapping implements Factory interface
// Returns a mock RESTClient for working with the specified RESTMapping or an error
func (f *MockKubectlFactory) ClientForMapping(*meta.RESTMapping) (resource.RESTClient, error) {
return f.MockClientForMapping()
}
// WithToDiscoveryClientByError returns mock discovery client with its respective error
func (f *MockKubectlFactory) WithToDiscoveryClientByError(d discovery.CachedDiscoveryInterface,
err error) *MockKubectlFactory {
f.MockToDiscoveryClient = func() (discovery.CachedDiscoveryInterface, error) { return d, err }
return f
}
// WithOpenAPISchemaByError returns mock openAPISchema with its respective error
func (f *MockKubectlFactory) WithOpenAPISchemaByError(r openapi.Resources, err error) *MockKubectlFactory {
f.MockOpenAPISchema = func() (openapi.Resources, error) { return r, err }
return f
}
// WithDynamicClientByError returns mock dynamic client with its respective error
func (f *MockKubectlFactory) WithDynamicClientByError(d dynamic.Interface, err error) *MockKubectlFactory {
f.MockDynamicClient = func() (dynamic.Interface, error) { return d, err }
return f
}
// WithValidatorByError returns mock validator with its respective error
func (f *MockKubectlFactory) WithValidatorByError(v validation.Schema, err error) *MockKubectlFactory {
f.MockValidator = func() (validation.Schema, error) { return v, err }
return f
}
// WithToRESTMapperByError returns mock RESTMapper with its respective error
func (f *MockKubectlFactory) WithToRESTMapperByError(r meta.RESTMapper, err error) *MockKubectlFactory {
f.MockToRESTMapper = func() (meta.RESTMapper, error) { return r, err }
return f
}
// WithToRESTConfigByError returns mock RESTConfig with its respective error
func (f *MockKubectlFactory) WithToRESTConfigByError(r *rest.Config, err error) *MockKubectlFactory {
f.MockToRESTConfig = func() (*rest.Config, error) { return r, err }
return f
}
// WithNewBuilderByError returns mock resource builder with its respective error
func (f *MockKubectlFactory) WithNewBuilderByError(r *resource.Builder) *MockKubectlFactory {
f.MockNewBuilder = func() *resource.Builder { return r }
return f
}
// WithToRawKubeConfigLoaderByError returns mock raw kubeconfig loader with its respective error
func (f *MockKubectlFactory) WithToRawKubeConfigLoaderByError(c clientcmd.ClientConfig) *MockKubectlFactory {
f.MockToRawKubeConfigLoader = func() clientcmd.ClientConfig { return c }
return f
}
// WithClientForMappingByError returns mock client mapping with its respective error
func (f *MockKubectlFactory) WithClientForMappingByError(r resource.RESTClient, err error) *MockKubectlFactory {
f.MockClientForMapping = func() (resource.RESTClient, error) { return r, err }
return f
}
// NewMockKubectlFactory defines the functions of MockKubectlFactory with nil values for testing purpose
func NewMockKubectlFactory() *MockKubectlFactory {
return &MockKubectlFactory{MockDynamicClient: func() (dynamic.Interface, error) { return nil, nil },
MockToDiscoveryClient: func() (discovery.CachedDiscoveryInterface, error) { return nil, nil },
MockOpenAPISchema: func() (openapi.Resources, error) { return nil, nil },
MockValidator: func() (validation.Schema, error) { return nil, nil },
MockToRESTMapper: func() (meta.RESTMapper, error) { return nil, nil },
MockToRESTConfig: func() (*rest.Config, error) { return nil, nil },
MockNewBuilder: func() *resource.Builder { return nil },
MockToRawKubeConfigLoader: func() clientcmd.ClientConfig { return nil },
MockClientForMapping: func() (resource.RESTClient, error) { return nil, nil },
}
}
// MockClientConfig implements DirectClientConfig interface
// Returns mock client config for testing
type MockClientConfig struct {
clientcmd.DirectClientConfig
MockNamespace func() (string, bool, error)
}
// Namespace returns mock namespace for testing
func (c MockClientConfig) Namespace() (string, bool, error) { return c.MockNamespace() }
// WithNamespace returns mock namespace with its respective error
func (c *MockClientConfig) WithNamespace(s string, b bool, err error) *MockClientConfig {
c.MockNamespace = func() (string, bool, error) { return s, b, err }
return c
}
// NewMockClientConfig returns mock client config for testing
func NewMockClientConfig() *MockClientConfig {
return &MockClientConfig{
MockNamespace: func() (string, bool, error) { return "test", false, nil },
}
}
// 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")
defer f.Cleanup()
testRESTClient := &fake.RESTClient{
NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
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
}