From d588c73e38f02c71c2ab1e518bc21440fb47c976 Mon Sep 17 00:00:00 2001 From: Kostiantyn Kalynovskyi Date: Wed, 6 Nov 2019 09:44:10 -0600 Subject: [PATCH] [#20] Add kubectl apply wrapper package The wrapper is called ApplyAdapter and is a struct, that has Apply(..) method and some setters that allow to control kubectl apply behaviour Addapter is expected to be used through Apply(..) function, which takes slice of document.Document interface objects, writes them out to temporary file system, from where they are picked up by kubectl Apply module, and delivered to kubernetes cluster. The decision to use temporary file system is based on the fact, that in current state kubectl project currently only works with actual files, and ignores io.Streams object, that is part of ApplyOptions struct, so it is currently the only way to use it. Change-Id: Idc5d79794149c00198f420d76cf9aa3b5264946e --- go.mod | 3 + pkg/k8s/kubectl/apply_options.go | 78 +++++++ pkg/k8s/kubectl/apply_options_test.go | 73 ++++++ pkg/k8s/kubectl/filesystem.go | 29 +++ pkg/k8s/kubectl/interfaces.go | 11 + pkg/k8s/kubectl/kubectl.go | 76 +++++++ pkg/k8s/kubectl/kubectl_test.go | 109 +++++++++ pkg/k8s/kubectl/testdata/kubeconfig.yaml | 19 ++ pkg/k8s/kubectl/testdata/kustomization.yaml | 2 + .../testdata/replicationcontroller.yaml | 22 ++ pkg/k8s/utils/utils.go | 12 + testutil/k8sutils/mock_kubectl_factory.go | 215 ++++++++++++++++++ 12 files changed, 649 insertions(+) create mode 100644 pkg/k8s/kubectl/apply_options.go create mode 100644 pkg/k8s/kubectl/apply_options_test.go create mode 100644 pkg/k8s/kubectl/filesystem.go create mode 100644 pkg/k8s/kubectl/interfaces.go create mode 100644 pkg/k8s/kubectl/kubectl.go create mode 100644 pkg/k8s/kubectl/kubectl_test.go create mode 100644 pkg/k8s/kubectl/testdata/kubeconfig.yaml create mode 100644 pkg/k8s/kubectl/testdata/kustomization.yaml create mode 100644 pkg/k8s/kubectl/testdata/replicationcontroller.yaml create mode 100644 pkg/k8s/utils/utils.go create mode 100644 testutil/k8sutils/mock_kubectl_factory.go diff --git a/go.mod b/go.mod index 2bb7dbf68..7b5869bac 100644 --- a/go.mod +++ b/go.mod @@ -26,8 +26,11 @@ require ( gopkg.in/src-d/go-billy.v4 v4.3.2 gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 gopkg.in/src-d/go-git.v4 v4.13.1 + k8s.io/api v0.0.0 k8s.io/apimachinery v0.0.0 + k8s.io/cli-runtime v0.0.0 k8s.io/client-go v11.0.0+incompatible + k8s.io/kubectl v0.0.0 k8s.io/kubernetes v1.16.3 opendev.org/airship/go-redfish v0.0.0-20200110185254-3ab47e28bae8 opendev.org/airship/go-redfish/client v0.0.0-20200110185254-3ab47e28bae8 diff --git a/pkg/k8s/kubectl/apply_options.go b/pkg/k8s/kubectl/apply_options.go new file mode 100644 index 000000000..54fd885f6 --- /dev/null +++ b/pkg/k8s/kubectl/apply_options.go @@ -0,0 +1,78 @@ +package kubectl + +import ( + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/kubectl/pkg/cmd/apply" + cmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +// NewApplyOptions is a helper function that Creates ApplyOptions of kubectl apply module +// Values set here, are default, and do not conflict with each other, can be used if you +// need `kubectl apply` functionality without calling executing command in shell +// To function properly, you may need to specify files from where to read the resources: +// DeleteOptions.Filenames of returned object has to be set for that +func NewApplyOptions(f cmdutil.Factory, streams genericclioptions.IOStreams) (*apply.ApplyOptions, error) { + o := apply.NewApplyOptions(streams) + o.ServerSideApply = false + o.ForceConflicts = false + + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + if o.DryRun { + err := o.PrintFlags.Complete("%s (dry run)") + if err != nil { + return nil, err + } + } + if o.ServerDryRun { + err := o.PrintFlags.Complete("%s (server dry run)") + if err != nil { + return nil, err + } + } + return o.PrintFlags.ToPrinter() + } + + var err error + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return nil, err + } + + o.DiscoveryClient, err = f.ToDiscoveryClient() + if err != nil { + return nil, err + } + + dynamicClient, err := f.DynamicClient() + if err != nil { + return nil, err + } + + o.DeleteOptions = o.DeleteFlags.ToOptions(dynamicClient, o.IOStreams) + // This can only fail if ToDiscoverClient() function fails + o.OpenAPISchema, err = f.OpenAPISchema() + if err != nil { + return nil, err + } + + o.Validator, err = f.Validator(false) + if err != nil { + return nil, err + } + + o.Builder = f.NewBuilder() + o.Mapper, err = f.ToRESTMapper() + if err != nil { + return nil, err + } + + o.DynamicClient = dynamicClient + + o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return nil, err + } + return o, nil +} diff --git a/pkg/k8s/kubectl/apply_options_test.go b/pkg/k8s/kubectl/apply_options_test.go new file mode 100644 index 000000000..756f7d0e8 --- /dev/null +++ b/pkg/k8s/kubectl/apply_options_test.go @@ -0,0 +1,73 @@ +package kubectl_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + + "opendev.org/airship/airshipctl/pkg/k8s/kubectl" + k8stest "opendev.org/airship/airshipctl/testutil/k8sutils" +) + +var ( + filenameRC = "testdata/replicationcontroller.yaml" + + testStreams = genericclioptions.NewTestIOStreamsDiscard() + ToDiscoveryError = errors.New("ToDiscoveryError") + DynamicClientError = errors.New("DynamicClientError") + ValidateError = errors.New("ValidateError") + ToRESTMapperError = errors.New("ToRESTMapperError") + NamespaceError = errors.New("NamespaceError") +) + +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.DryRun = true + aa.DeleteOptions.Filenames = []string{filenameRC} + assert.NoError(t, aa.Run()) +} + +func TestNewApplyOptionsFactoryFailures(t *testing.T) { + tests := []struct { + f cmdutil.Factory + expectedError error + }{ + { + f: k8stest.NewMockKubectlFactory().WithToDiscoveryClientByError(nil, ToDiscoveryError), + expectedError: ToDiscoveryError, + }, + { + f: k8stest.NewMockKubectlFactory().WithDynamicClientByError(nil, DynamicClientError), + expectedError: DynamicClientError, + }, + { + f: k8stest.NewMockKubectlFactory().WithValidatorByError(nil, ValidateError), + expectedError: ValidateError, + }, + { + f: k8stest.NewMockKubectlFactory().WithToRESTMapperByError(nil, ToRESTMapperError), + expectedError: ToRESTMapperError, + }, + { + f: k8stest.NewMockKubectlFactory(). + WithToRawKubeConfigLoaderByError(k8stest. + NewMockClientConfig(). + WithNamespace("", false, NamespaceError)), + expectedError: NamespaceError, + }, + } + for _, test := range tests { + _, err := kubectl.NewApplyOptions(test.f, testStreams) + assert.Equal(t, err, test.expectedError) + } +} diff --git a/pkg/k8s/kubectl/filesystem.go b/pkg/k8s/kubectl/filesystem.go new file mode 100644 index 000000000..36e62c843 --- /dev/null +++ b/pkg/k8s/kubectl/filesystem.go @@ -0,0 +1,29 @@ +package kubectl + +import ( + "io/ioutil" + + "sigs.k8s.io/kustomize/v3/pkg/fs" +) + +// File extends kustomize File and provide abstraction to creating temporary files +type File interface { + fs.File + Name() string +} + +// FileSystem extends kustomize FileSystem and provide abstraction to creating temporary files +type FileSystem interface { + fs.FileSystem + TempFile(string, string) (File, error) +} + +// Buffer is adaptor to TempFile +type Buffer struct { + fs.FileSystem +} + +// TempFile creates file in temporary filesystem, at default os.TempDir +func (b Buffer) TempFile(tmpDir string, prefix string) (File, error) { + return ioutil.TempFile(tmpDir, prefix) +} diff --git a/pkg/k8s/kubectl/interfaces.go b/pkg/k8s/kubectl/interfaces.go new file mode 100644 index 000000000..a378614a5 --- /dev/null +++ b/pkg/k8s/kubectl/interfaces.go @@ -0,0 +1,11 @@ +package kubectl + +import ( + "k8s.io/kubectl/pkg/cmd/apply" + + "opendev.org/airship/airshipctl/pkg/document" +) + +type Interface interface { + Apply(docs []document.Document, ao *apply.ApplyOptions) error +} diff --git a/pkg/k8s/kubectl/kubectl.go b/pkg/k8s/kubectl/kubectl.go new file mode 100644 index 000000000..17d9a9d2f --- /dev/null +++ b/pkg/k8s/kubectl/kubectl.go @@ -0,0 +1,76 @@ +package kubectl + +import ( + "os" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/kubectl/pkg/cmd/apply" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "sigs.k8s.io/kustomize/v3/pkg/fs" + + "opendev.org/airship/airshipctl/pkg/document" + "opendev.org/airship/airshipctl/pkg/log" + utilyaml "opendev.org/airship/airshipctl/pkg/util/yaml" +) + +// Kubectl container holds Factory, Streams and FileSystem to +// interact with upstream kubectl objects and serves as abstraction to kubectl project +type Kubectl struct { + cmdutil.Factory + genericclioptions.IOStreams + FileSystem + // Directory to buffer documents before passing them to kubectl commands + // default is empty, this means that /tmp dir will be used + bufferDir string +} + +// NewKubectlFromKubeconfigPath builds an instance +// of Kubectl struct from Path to kubeconfig file +func NewKubectl(f cmdutil.Factory) *Kubectl { + return &Kubectl{ + Factory: f, + IOStreams: genericclioptions.IOStreams{ + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + }, + FileSystem: Buffer{FileSystem: fs.MakeRealFS()}, + } +} + +func (kubectl *Kubectl) WithBufferDir(bd string) *Kubectl { + kubectl.bufferDir = bd + return kubectl +} + +// Apply is abstraction to kubectl apply command +func (kubectl *Kubectl) Apply(docs []document.Document, ao *apply.ApplyOptions) error { + tf, err := kubectl.TempFile(kubectl.bufferDir, "initinfra") + if err != nil { + return err + } + + defer func(f File) { + fName := f.Name() + dErr := kubectl.RemoveAll(fName) + if dErr != nil { + log.Fatalf("Failed to cleanup temporary file %s during kubectl apply", fName) + } + }(tf) + defer tf.Close() + for _, doc := range docs { + // Write out documents to temporary file + err = utilyaml.WriteOut(tf, doc) + if err != nil { + return err + } + } + ao.DeleteOptions.Filenames = []string{tf.Name()} + return ao.Run() +} + +// ApplyOptions is a wrapper over kubectl ApplyOptions, used to build +// new options from the factory and iostreams defined in Kubectl container +func (kubectl *Kubectl) ApplyOptions() (*apply.ApplyOptions, error) { + return NewApplyOptions(kubectl.Factory, kubectl.IOStreams) +} diff --git a/pkg/k8s/kubectl/kubectl_test.go b/pkg/k8s/kubectl/kubectl_test.go new file mode 100644 index 000000000..07b81eda9 --- /dev/null +++ b/pkg/k8s/kubectl/kubectl_test.go @@ -0,0 +1,109 @@ +package kubectl_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/kustomize/v3/pkg/fs" + + "opendev.org/airship/airshipctl/pkg/k8s/kubectl" + k8sutils "opendev.org/airship/airshipctl/pkg/k8s/utils" + "opendev.org/airship/airshipctl/testutil" + k8stest "opendev.org/airship/airshipctl/testutil/k8sutils" +) + +var ( + kubeconfigPath = "testdata/kubeconfig.yaml" + fixtureDir = "testdata/" + + writeOutError = errors.New("writeOutError") + TempFileError = errors.New("TempFileError") +) + +type MockFileSystem struct { + MockRemoveAll func() error + MockTempFile func() (kubectl.File, error) + fs.FileSystem +} + +func (fsys MockFileSystem) RemoveAll(name string) error { return fsys.MockRemoveAll() } +func (fsys MockFileSystem) TempFile(bufferDir string, prefix string) (kubectl.File, error) { + return fsys.MockTempFile() +} + +type TestFile struct { + kubectl.File + MockName func() string + MockWrite func() (int, error) + MockClose func() error +} + +func (f TestFile) Name() string { return f.MockName() } +func (f TestFile) Write([]byte) (int, error) { return f.MockWrite() } +func (f TestFile) Close() error { return f.MockClose() } + +func TestNewKubectlFromKubeconfigPath(t *testing.T) { + f := k8sutils.FactoryFromKubeconfigPath(kubeconfigPath) + kctl := kubectl.NewKubectl(f).WithBufferDir("/tmp/.airship") + + assert.NotNil(t, kctl.Factory) + assert.NotNil(t, kctl.FileSystem) + assert.NotNil(t, kctl.IOStreams) +} + +func TestApply(t *testing.T) { + f := k8stest.NewFakeFactoryForRC(t, filenameRC) + 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.DryRun = 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 + fs kubectl.FileSystem + }{ + { + expectedErr: nil, + fs: MockFileSystem{ + MockRemoveAll: func() error { return nil }, + MockTempFile: func() (kubectl.File, error) { + return TestFile{ + MockName: func() string { return filenameRC }, + MockWrite: func() (int, error) { return 0, nil }, + MockClose: func() error { return nil }, + }, nil + }, + }, + }, + { + expectedErr: writeOutError, + fs: MockFileSystem{ + MockTempFile: func() (kubectl.File, error) { return nil, writeOutError }}, + }, + { + expectedErr: TempFileError, + fs: MockFileSystem{ + MockRemoveAll: func() error { return nil }, + MockTempFile: func() (kubectl.File, error) { + return TestFile{ + MockWrite: func() (int, error) { return 0, TempFileError }, + MockName: func() string { return filenameRC }, + MockClose: func() error { return nil }, + }, nil + }}, + }, + } + for _, test := range tests { + kctl.FileSystem = test.fs + assert.Equal(t, kctl.Apply(docs, ao), test.expectedErr) + } +} diff --git a/pkg/k8s/kubectl/testdata/kubeconfig.yaml b/pkg/k8s/kubectl/testdata/kubeconfig.yaml new file mode 100644 index 000000000..967864a76 --- /dev/null +++ b/pkg/k8s/kubectl/testdata/kubeconfig.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUN5RENDQWJDZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRFNU1Ea3lPVEUzTURNd09Wb1hEVEk1TURreU5qRTNNRE13T1Zvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTUZyCkdxM0kyb2dZci81Y01Udy9Na1pORTNWQURzdEdyU240WjU2TDhPUGhMcUhDN2t1dno2dVpES3dCSGtGeTBNK2MKRXIzd2piUGE1aTV5NmkyMGtxSHBVMjdPZTA0dzBXV2s4N0RSZVlWaGNoZVJHRXoraWt3SndIcGRmMjJVemZNKwpkSDBzaUhuMVd6UnovYk4za3hMUzJlMnZ2U1Y3bmNubk1YRUd4OXV0MUY0NThHeWxxdmxXTUlWMzg5Q2didXFDCkcwcFdiMTBLM0RVZWdiT25Xa1FmSm5sTWRRVVZDUVdZZEZaaklrcWtkWi9hVTRobkNEV01oZXNWRnFNaDN3VVAKczhQay9BNWh1ZFFPbnFRNDVIWXZLdjZ5RjJWcDUyWExBRUx3NDJ4aVRKZlh0V1h4eHR6cU4wY1lyL2VxeS9XMQp1YVVGSW5xQjFVM0JFL1oxbmFrQ0F3RUFBYU1qTUNFd0RnWURWUjBQQVFIL0JBUURBZ0trTUE4R0ExVWRFd0VCCi93UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFKUUVKQVBLSkFjVDVuK3dsWGJsdU9mS0J3c2gKZTI4R1c5R2QwM0N0NGF3RzhzMXE1ZHNua2tpZmVTUENHVFZ1SXF6UTZDNmJaSk9SMDMvVEl5ejh6NDJnaitDVApjWUZXZkltM2RKTnpRL08xWkdySXZZNWdtcWJtWDlpV0JaU24rRytEOGxubzd2aGMvY0tBRFR5OTMvVU92MThuCkdhMnIrRGJJcHcyTWVBVEl2elpxRS9RWlVSQ25DMmdjUFhTVzFqN2h4R3o1a3ZNcGVDZTdQYVUvdVFvblVHSWsKZ2t6ZzI4NHQvREhUUzc4N1V1SUg5cXBaV09yTFNMOGFBeUxQUHhWSXBteGZmbWRETE9TS2VUemRlTmxoSitUMwowQlBVaHBQTlJBNTNJN0hRQjhVUDR2elNONTkzZ1VFbVlFQ2Jic2RYSzB6ZVR6SDdWWHR2Zmd5WTVWWT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + server: https://10.0.1.7:6443 + name: kubernetes_target +contexts: +- context: + cluster: kubernetes_target + user: kubernetes-admin + name: kubernetes-admin@kubernetes +current-context: "" +kind: Config +preferences: {} +users: +- name: kubernetes-admin + user: + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUM4akNDQWRxZ0F3SUJBZ0lJQXhEdzk2RUY4SXN3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB4T1RBNU1qa3hOekF6TURsYUZ3MHlNREE1TWpneE56QXpNVEphTURReApGekFWQmdOVkJBb1REbk41YzNSbGJUcHRZWE4wWlhKek1Sa3dGd1lEVlFRREV4QnJkV0psY201bGRHVnpMV0ZrCmJXbHVNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXV6R0pZdlBaNkRvaTQyMUQKSzhXSmFaQ25OQWQycXo1cC8wNDJvRnpRUGJyQWd6RTJxWVZrek9MOHhBVmVSN1NONXdXb1RXRXlGOEVWN3JyLwo0K0hoSEdpcTVQbXF1SUZ5enpuNi9JWmM4alU5eEVmenZpa2NpckxmVTR2UlhKUXdWd2dBU05sMkFXQUloMmRECmRUcmpCQ2ZpS1dNSHlqMFJiSGFsc0J6T3BnVC9IVHYzR1F6blVRekZLdjJkajVWMU5rUy9ESGp5UlJKK0VMNlEKQlltR3NlZzVQNE5iQzllYnVpcG1NVEFxL0p1bU9vb2QrRmpMMm5acUw2Zkk2ZkJ0RjVPR2xwQ0IxWUo4ZnpDdApHUVFaN0hUSWJkYjJ0cDQzRlZPaHlRYlZjSHFUQTA0UEoxNSswV0F5bVVKVXo4WEE1NDRyL2J2NzRKY0pVUkZoCmFyWmlRd0lEQVFBQm95Y3dKVEFPQmdOVkhROEJBZjhFQkFNQ0JhQXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUgKQXdJd0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFMMmhIUmVibEl2VHJTMFNmUVg1RG9ueVVhNy84aTg1endVWApSd3dqdzFuS0U0NDJKbWZWRGZ5b0hRYUM4Ti9MQkxyUXM0U0lqU1JYdmFHU1dSQnRnT1RRV21Db1laMXdSbjdwCndDTXZQTERJdHNWWm90SEZpUFl2b1lHWFFUSXA3YlROMmg1OEJaaEZ3d25nWUovT04zeG1rd29IN1IxYmVxWEYKWHF1TTluekhESk41VlZub1lQR09yRHMwWlg1RnNxNGtWVU0wVExNQm9qN1ZIRDhmU0E5RjRYNU4yMldsZnNPMAo4aksrRFJDWTAyaHBrYTZQQ0pQS0lNOEJaMUFSMG9ZakZxT0plcXpPTjBqcnpYWHh4S2pHVFVUb1BldVA5dCtCCjJOMVA1TnI4a2oxM0lrend5Q1NZclFVN09ZM3ltZmJobHkrcXZxaFVFa014MlQ1SkpmQT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcFFJQkFBS0NBUUVBdXpHSll2UFo2RG9pNDIxREs4V0phWkNuTkFkMnF6NXAvMDQyb0Z6UVBickFnekUyCnFZVmt6T0w4eEFWZVI3U041d1dvVFdFeUY4RVY3cnIvNCtIaEhHaXE1UG1xdUlGeXp6bjYvSVpjOGpVOXhFZnoKdmlrY2lyTGZVNHZSWEpRd1Z3Z0FTTmwyQVdBSWgyZERkVHJqQkNmaUtXTUh5ajBSYkhhbHNCek9wZ1QvSFR2MwpHUXpuVVF6Rkt2MmRqNVYxTmtTL0RIanlSUkorRUw2UUJZbUdzZWc1UDROYkM5ZWJ1aXBtTVRBcS9KdW1Pb29kCitGakwyblpxTDZmSTZmQnRGNU9HbHBDQjFZSjhmekN0R1FRWjdIVEliZGIydHA0M0ZWT2h5UWJWY0hxVEEwNFAKSjE1KzBXQXltVUpVejhYQTU0NHIvYnY3NEpjSlVSRmhhclppUXdJREFRQUJBb0lCQVFDU0pycjlaeVpiQ2dqegpSL3VKMFZEWCt2aVF4c01BTUZyUjJsOE1GV3NBeHk1SFA4Vk4xYmc5djN0YUVGYnI1U3hsa3lVMFJRNjNQU25DCm1uM3ZqZ3dVQWlScllnTEl5MGk0UXF5VFBOU1V4cnpTNHRxTFBjM3EvSDBnM2FrNGZ2cSsrS0JBUUlqQnloamUKbnVFc1JpMjRzT3NESlM2UDE5NGlzUC9yNEpIM1M5bFZGbkVuOGxUR2c0M1kvMFZoMXl0cnkvdDljWjR5ZUNpNwpjMHFEaTZZcXJZaFZhSW9RRW1VQjdsbHRFZkZzb3l4VDR6RTE5U3pVbkRoMmxjYTF1TzhqcmI4d2xHTzBoQ2JyClB1R1l2WFFQa3Q0VlNmalhvdGJ3d2lBNFRCVERCRzU1bHp6MmNKeS9zSS8zSHlYbEMxcTdXUmRuQVhhZ1F0VzkKOE9DZGRkb0JBb0dCQU5NcUNtSW94REtyckhZZFRxT1M1ZFN4cVMxL0NUN3ZYZ0pScXBqd2Y4WHA2WHo0KzIvTAozVXFaVDBEL3dGTkZkc1Z4eFYxMnNYMUdwMHFWZVlKRld5OVlCaHVSWGpTZ0ZEWldSY1Z1Y01sNVpPTmJsbmZGCjVKQ0xnNXFMZ1g5VTNSRnJrR3A0R241UDQxamg4TnhKVlhzZG5xWE9xNTFUK1RRT1UzdkpGQjc1QW9HQkFPTHcKalp1cnZtVkZyTHdaVGgvRDNpWll5SVV0ZUljZ2NKLzlzbTh6L0pPRmRIbFd4dGRHUFVzYVd1MnBTNEhvckFtbgpqTm4vSTluUXd3enZ3MWUzVVFPbUhMRjVBczk4VU5hbk5TQ0xNMW1yaXZHRXJ1VHFnTDM1bU41eFZPdTUxQU5JCm4yNkFtODBJT2JDeEtLa0R0ZXJSaFhHd3g5c1pONVJCbG9VRThZNGJBb0dBQ3ZsdVhMZWRxcng5VkE0bDNoNXUKVDJXRVUxYjgxZ1orcmtRc1I1S0lNWEw4cllBTElUNUpHKzFuendyN3BkaEFXZmFWdVV2SDRhamdYT0h6MUs5aQpFODNSVTNGMG9ldUg0V01PY1RwU0prWm0xZUlXcWRiaEVCb1FGdUlWTXRib1BsV0d4ZUhFRHJoOEtreGp4aThSCmdEcUQyajRwY1IzQ0g5QjJ5a0lqQjVFQ2dZRUExc0xXLys2enE1c1lNSm14K1JXZThhTXJmL3pjQnVTSU1LQWgKY0dNK0wwMG9RSHdDaUU4TVNqcVN1ajV3R214YUFuanhMb3ZwSFlRV1VmUEVaUW95UE1YQ2VhRVBLOU4xbk8xMwp0V2lHRytIZkIxaU5PazFCc0lhNFNDbndOM1FRVTFzeXBaeEgxT3hueS9LYmkvYmEvWEZ5VzNqMGFUK2YvVWxrCmJGV1ZVdWtDZ1lFQTBaMmRTTFlmTjV5eFNtYk5xMWVqZXdWd1BjRzQxR2hQclNUZEJxdHFac1doWGE3aDdLTWEKeHdvamh5SXpnTXNyK2tXODdlajhDQ2h0d21sQ1p5QU92QmdOZytncnJ1cEZLM3FOSkpKeU9YREdHckdpbzZmTQp5aXB3Q2tZVGVxRThpZ1J6UkI5QkdFUGY4eVpjMUtwdmZhUDVhM0lRZmxiV0czbGpUemNNZVZjPQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo= diff --git a/pkg/k8s/kubectl/testdata/kustomization.yaml b/pkg/k8s/kubectl/testdata/kustomization.yaml new file mode 100644 index 000000000..72ab8548e --- /dev/null +++ b/pkg/k8s/kubectl/testdata/kustomization.yaml @@ -0,0 +1,2 @@ +resources: + - replicationcontroller.yaml diff --git a/pkg/k8s/kubectl/testdata/replicationcontroller.yaml b/pkg/k8s/kubectl/testdata/replicationcontroller.yaml new file mode 100644 index 000000000..1e39abdd8 --- /dev/null +++ b/pkg/k8s/kubectl/testdata/replicationcontroller.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ReplicationController +metadata: + name: test-rc + namespace: test + annotations: + airshipit.org/initinfra: "workflow" + labels: + name: test-rc + airshipit.org/initinfra: "workflow" +spec: + replicas: 1 + template: + metadata: + labels: + name: test-rc + spec: + containers: + - name: test-rc + image: nginx + ports: + - containerPort: 80 diff --git a/pkg/k8s/utils/utils.go b/pkg/k8s/utils/utils.go new file mode 100644 index 000000000..2977eede9 --- /dev/null +++ b/pkg/k8s/utils/utils.go @@ -0,0 +1,12 @@ +package utils + +import ( + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +func FactoryFromKubeconfigPath(kp string) cmdutil.Factory { + kf := genericclioptions.NewConfigFlags(false) + kf.KubeConfig = &kp + return cmdutil.NewFactory(kf) +} diff --git a/testutil/k8sutils/mock_kubectl_factory.go b/testutil/k8sutils/mock_kubectl_factory.go new file mode 100644 index 000000000..2bb13c8fc --- /dev/null +++ b/testutil/k8sutils/mock_kubectl_factory.go @@ -0,0 +1,215 @@ +package k8sutils + +import ( + "bytes" + "io/ioutil" + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "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" + "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() +} +func (f *MockKubectlFactory) DynamicClient() (dynamic.Interface, error) { return f.MockDynamicClient() } +func (f *MockKubectlFactory) OpenAPISchema() (openapi.Resources, error) { return f.MockOpenAPISchema() } +func (f *MockKubectlFactory) Validator(validate bool) (validation.Schema, error) { + return f.MockValidator() +} +func (f *MockKubectlFactory) ToRESTMapper() (meta.RESTMapper, error) { return f.MockToRESTMapper() } +func (f *MockKubectlFactory) ToRESTConfig() (*rest.Config, error) { return f.MockToRESTConfig() } +func (f *MockKubectlFactory) NewBuilder() *resource.Builder { return f.MockNewBuilder() } +func (f *MockKubectlFactory) ToRawKubeConfigLoader() clientcmd.ClientConfig { + return f.MockToRawKubeConfigLoader() +} +func (f *MockKubectlFactory) ClientForMapping(*meta.RESTMapping) (resource.RESTClient, error) { + return f.MockClientForMapping() +} + +func (f *MockKubectlFactory) WithToDiscoveryClientByError(d dynamic.Interface, err error) *MockKubectlFactory { + f.MockDynamicClient = func() (dynamic.Interface, error) { return d, err } + return f +} + +func (f *MockKubectlFactory) WithOpenAPISchemaByError(r openapi.Resources, err error) *MockKubectlFactory { + f.MockOpenAPISchema = func() (openapi.Resources, error) { return r, err } + return f +} + +func (f *MockKubectlFactory) WithDynamicClientByError(d discovery.CachedDiscoveryInterface, + err error) *MockKubectlFactory { + f.MockToDiscoveryClient = func() (discovery.CachedDiscoveryInterface, error) { return d, err } + return f +} + +func (f *MockKubectlFactory) WithValidatorByError(v validation.Schema, err error) *MockKubectlFactory { + f.MockValidator = func() (validation.Schema, error) { return v, err } + return f +} + +func (f *MockKubectlFactory) WithToRESTMapperByError(r meta.RESTMapper, err error) *MockKubectlFactory { + f.MockToRESTMapper = func() (meta.RESTMapper, error) { return r, err } + return f +} + +func (f *MockKubectlFactory) WithToRESTConfigByError(r *rest.Config, err error) *MockKubectlFactory { + f.MockToRESTConfig = func() (*rest.Config, error) { return r, err } + return f +} + +func (f *MockKubectlFactory) WithNewBuilderByError(r *resource.Builder) *MockKubectlFactory { + f.MockNewBuilder = func() *resource.Builder { return r } + return f +} + +func (f *MockKubectlFactory) WithToRawKubeConfigLoaderByError(c clientcmd.ClientConfig) *MockKubectlFactory { + f.MockToRawKubeConfigLoader = func() clientcmd.ClientConfig { return c } + return f +} + +func (f *MockKubectlFactory) WithClientForMappingByError(r resource.RESTClient, err error) *MockKubectlFactory { + f.MockClientForMapping = func() (resource.RESTClient, error) { return r, err } + return f +} + +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 }, + } +} + +type MockClientConfig struct { + clientcmd.DirectClientConfig + MockNamespace func() (string, bool, error) +} + +func (c MockClientConfig) Namespace() (string, bool, error) { return c.MockNamespace() } + +func (c *MockClientConfig) WithNamespace(s string, b bool, err error) *MockClientConfig { + c.MockNamespace = func() (string, bool, error) { return s, b, err } + return c +} + +func NewMockClientConfig() *MockClientConfig { + return &MockClientConfig{ + MockNamespace: func() (string, bool, error) { return "test", false, nil }, + } +} + +func NewFakeFactoryForRC(t *testing.T, filenameRC string) *cmdtesting.TestFactory { + c := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + 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"}, + 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 + } + }), + } + 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 +}