diff --git a/pkg/k8s/kubeconfig/errors.go b/pkg/k8s/kubeconfig/errors.go new file mode 100644 index 000000000..8f65b9576 --- /dev/null +++ b/pkg/k8s/kubeconfig/errors.go @@ -0,0 +1,23 @@ +/* + 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 kubeconfig + +// ErrKubeConfigPathEmpty returned when kubeconfig path is not specified +type ErrKubeConfigPathEmpty struct { +} + +func (e *ErrKubeConfigPathEmpty) Error() string { + return "kubeconfig path is not defined" +} diff --git a/pkg/k8s/kubeconfig/kubeconfig.go b/pkg/k8s/kubeconfig/kubeconfig.go index 1dcef1854..0ff17c71a 100644 --- a/pkg/k8s/kubeconfig/kubeconfig.go +++ b/pkg/k8s/kubeconfig/kubeconfig.go @@ -14,18 +14,167 @@ package kubeconfig -// File determines where kubeconfig is located on file system and which context to use -type File struct { - Path string - Context string +import ( + "io" + "log" + + "sigs.k8s.io/yaml" + + "opendev.org/airship/airshipctl/pkg/api/v1alpha1" + "opendev.org/airship/airshipctl/pkg/document" +) + +// Interface provides a uniform way to interact with kubeconfig file +type Interface interface { + // GetFile returns path to kubeconfig file and a function to remove it + // if error is returned cleanup is not needed + GetFile() (string, Cleanup, error) + // Write will write kubeconfig to the provided writer + Write(w io.Writer) error + // WriteFile will write kubeconfig data to specified path + WriteFile(path string) error + // WriteTempFile writes a file a temporary file, returns path to it, cleanup function and error + // it is responsibility of the caller to use the cleanup function to make sure that there are no leftovers + WriteTempFile(dumpRoot string) (string, Cleanup, error) } -// Provider interface allows to get kubeconfig file path and context based on cluster type -type Provider interface { - // If clusterType is an empty string it means that caller is not aware then default cluster type will be used - // default cluster type maybe different for different provider implementations, for example if we are providing - // kubeconfig file for a phase then phase may be bound to ephemeral or target cluster type then defaults will be - // ephemeral or target respectively. - Get(clusterType string) (File, error) - Cleanup() error +var _ Interface = &kubeConfig{} + +type kubeConfig struct { + path string + dumpRoot string + + fileSystem document.FileSystem + sourceFunc KubeSourceFunc +} + +// NewKubeConfig serves as a constructor for kubeconfig Interface +// first argument is a function that should return bytes with kubeconfig and error +// see FromByte() FromAPIalphaV1() FromFile() functions or extend with your own +// second argument are options that can be used to inject various supported options into it +// see InjectTempRoot(), InjectFileSystem(), InjectFilePath() functions for more info +func NewKubeConfig(source KubeSourceFunc, options ...Option) Interface { + kf := &kubeConfig{} + for _, o := range options { + o(kf) + } + kf.sourceFunc = source + if kf.fileSystem == nil { + kf.fileSystem = document.NewDocumentFs() + } + return kf +} + +// Option is a function that allows to modify kubeConfig object +type Option func(*kubeConfig) + +// KubeSourceFunc is a function which returns bytes array to construct new kubeConfig object +type KubeSourceFunc func() ([]byte, error) + +// Cleanup is a function which cleans up kubeconfig file from filesystem +type Cleanup func() + +// FromByte returns KubeSource type, uses plain bytes array as source to construct kubeconfig object +func FromByte(b []byte) KubeSourceFunc { + return func() ([]byte, error) { + return b, nil + } +} + +// FromAPIalphaV1 returns KubeSource type, uses API Config array as source to construct kubeconfig object +func FromAPIalphaV1(apiObj *v1alpha1.KubeConfig) KubeSourceFunc { + return func() ([]byte, error) { + return yaml.Marshal(apiObj.Config) + } +} + +// FromFile returns KubeSource type, uses path to kubeconfig on FS as source to construct kubeconfig object +func FromFile(path string, fs document.FileSystem) KubeSourceFunc { + return func() ([]byte, error) { + return fs.ReadFile(path) + } +} + +// InjectFileSystem sets fileSystem to be used, mostly to be used for tests +func InjectFileSystem(fs document.FileSystem) Option { + return func(k *kubeConfig) { + k.fileSystem = fs + } +} + +// InjectTempRoot sets root for temporary file system, if not set default OS temp dir will be used +func InjectTempRoot(dumpRoot string) Option { + return func(k *kubeConfig) { + k.dumpRoot = dumpRoot + } +} + +// InjectFilePath enables setting kubeconfig path, useful when you have kubeconfig +// from the actual filesystem, if this option is used, please also make sure that +// FromFile option is also used as a first argument in NewKubeConfig function +func InjectFilePath(path string, fs document.FileSystem) Option { + return func(k *kubeConfig) { + k.path = path + k.fileSystem = fs + } +} + +func (k *kubeConfig) WriteFile(path string) (err error) { + data, err := k.sourceFunc() + if err != nil { + return err + } + return k.fileSystem.WriteFile(path, data) +} + +func (k *kubeConfig) Write(w io.Writer) (err error) { + data, err := k.sourceFunc() + if err != nil { + return err + } + _, err = w.Write(data) + return err +} + +// WriteTempFile implements kubeconfig Interface +func (k *kubeConfig) WriteTempFile(root string) (string, Cleanup, error) { + data, err := k.sourceFunc() + if err != nil { + return "", nil, err + } + file, err := k.fileSystem.TempFile(root, "kubeconfig-") + if err != nil { + return "", nil, err + } + defer file.Close() + fName := file.Name() + _, err = file.Write(data) + if err != nil { + // delete the temp file that was created and return write error + cleanup(fName, k.fileSystem)() + return "", nil, err + } + return fName, cleanup(fName, k.fileSystem), nil +} + +// GetFile checks if path to kubeconfig is already set and returns it no cleanup is necessary, +// and Cleanup() method will do nothing. +// If path is not set kubeconfig will be written to temporary file system, returned path will +// point to it and Cleanup() function will remove this file from the filesystem. +func (k *kubeConfig) GetFile() (string, Cleanup, error) { + if k.path != "" { + return k.path, func() {}, nil + } + return k.WriteTempFile(k.dumpRoot) +} + +func cleanup(path string, fs document.FileSystem) Cleanup { + if path == "" { + return func() {} + } + return func() { + if err := fs.RemoveAll(path); err != nil { + log.Fatalf("Failed to cleanup kubeconfig file %s, error: %v", path, err) + } + } } diff --git a/pkg/k8s/kubeconfig/kubeconfig_test.go b/pkg/k8s/kubeconfig/kubeconfig_test.go new file mode 100644 index 000000000..4b2275d25 --- /dev/null +++ b/pkg/k8s/kubeconfig/kubeconfig_test.go @@ -0,0 +1,344 @@ +/* + 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 kubeconfig_test + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "k8s.io/client-go/tools/clientcmd/api/v1" + kustfs "sigs.k8s.io/kustomize/api/filesys" + + "opendev.org/airship/airshipctl/pkg/api/v1alpha1" + "opendev.org/airship/airshipctl/pkg/document" + "opendev.org/airship/airshipctl/pkg/k8s/kubeconfig" + "opendev.org/airship/airshipctl/testutil/fs" +) + +const ( + testValidKubeconfig = `apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: ca-data + 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: cert-data + client-key-data: client-keydata +` +) + +var ( + errTempFile = fmt.Errorf("TempFile Error") + errSourceFunc = fmt.Errorf("Source func error") + errWriter = fmt.Errorf("Writer error") + testValidKubeconfigAPI = &v1alpha1.KubeConfig{ + Config: v1.Config{ + CurrentContext: "test", + Clusters: []v1.NamedCluster{ + { + Name: "some-cluster", + Cluster: v1.Cluster{ + CertificateAuthority: "ca", + Server: "https://10.0.1.7:6443", + }, + }, + }, + APIVersion: "v1", + Contexts: []v1.NamedContext{ + { + Name: "test", + Context: v1.Context{ + Cluster: "some-cluster", + AuthInfo: "some-user", + }, + }, + }, + AuthInfos: []v1.NamedAuthInfo{ + { + Name: "some-user", + AuthInfo: v1.AuthInfo{ + ClientCertificate: "cert-data", + ClientKey: "client-key", + }, + }, + }, + }, + } +) + +func TestKubeconfigContent(t *testing.T) { + expectedData := []byte(testValidKubeconfig) + fs := document.NewDocumentFs() + kubeconf := kubeconfig.NewKubeConfig( + kubeconfig.FromByte(expectedData), + kubeconfig.InjectFileSystem(fs), + kubeconfig.InjectTempRoot(".")) + path, clean, err := kubeconf.GetFile() + require.NoError(t, err) + defer clean() + actualData, err := fs.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, expectedData, actualData) +} + +func TestNewKubeConfig(t *testing.T) { + tests := []struct { + shouldPanic bool + name string + expectedPathContains string + expectedErrorContains string + src kubeconfig.KubeSourceFunc + options []kubeconfig.Option + }{ + { + name: "write to temp file", + src: kubeconfig.FromByte([]byte(testValidKubeconfig)), + options: []kubeconfig.Option{ + kubeconfig.InjectFileSystem( + fs.MockFileSystem{ + MockTempFile: func(root, pattern string) (document.File, error) { + return fs.TestFile{ + MockName: func() string { return "kubeconfig-142398" }, + MockWrite: func() (int, error) { return 0, nil }, + MockClose: func() error { return nil }, + }, nil + }, + MockRemoveAll: func() error { return nil }, + }, + ), + }, + expectedPathContains: "kubeconfig-142398", + }, + { + name: "cleanup with dump root", + expectedPathContains: "kubeconfig-142398", + src: kubeconfig.FromByte([]byte(testValidKubeconfig)), + options: []kubeconfig.Option{ + kubeconfig.InjectTempRoot("/my-unique-root"), + kubeconfig.InjectFileSystem( + fs.MockFileSystem{ + MockTempFile: func(root, _ string) (document.File, error) { + // check if root path is passed to the TempFile interface + if root != "/my-unique-root" { + return nil, errTempFile + } + return fs.TestFile{ + MockName: func() string { return "kubeconfig-142398" }, + MockWrite: func() (int, error) { return 0, nil }, + MockClose: func() error { return nil }, + }, nil + }, + MockRemoveAll: func() error { return nil }, + }, + ), + }, + }, + { + name: "from file, and fs option", + src: kubeconfig.FromFile("/my/kubeconfig", fsWithFile(t, "/my/kubeconfig")), + options: []kubeconfig.Option{ + kubeconfig.InjectFilePath("/my/kubeconfig", fsWithFile(t, "/my/kubeconfig")), + }, + expectedPathContains: "/my/kubeconfig", + }, + { + name: "write to real fs", + src: kubeconfig.FromAPIalphaV1(testValidKubeconfigAPI), + expectedPathContains: "kubeconfig-", + }, + { + name: "from file, use SourceFile", + src: kubeconfig.FromFile("/my/kubeconfig", fsWithFile(t, "/my/kubeconfig")), + expectedPathContains: "kubeconfig-", + }, + { + name: "temp file error", + src: kubeconfig.FromAPIalphaV1(testValidKubeconfigAPI), + expectedErrorContains: errTempFile.Error(), + options: []kubeconfig.Option{ + kubeconfig.InjectFileSystem( + fs.MockFileSystem{ + MockTempFile: func(string, string) (document.File, error) { + return nil, errTempFile + }, + MockRemoveAll: func() error { return nil }, + }, + ), + }, + }, + { + name: "source func error", + src: func() ([]byte, error) { return nil, errSourceFunc }, + expectedPathContains: "kubeconfig-", + expectedErrorContains: errSourceFunc.Error(), + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + kubeconf := kubeconfig.NewKubeConfig(tt.src, tt.options...) + path, clean, err := kubeconf.GetFile() + if tt.expectedErrorContains != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErrorContains) + } else { + require.NoError(t, err) + actualPath := path + assert.Contains(t, actualPath, tt.expectedPathContains) + clean() + } + }) + } +} + +func TestKubeConfigWrite(t *testing.T) { + tests := []struct { + name string + expectedContent string + expectedErrorContains string + + readWrite io.ReadWriter + options []kubeconfig.Option + src kubeconfig.KubeSourceFunc + }{ + { + name: "Basic write", + src: kubeconfig.FromByte([]byte(testValidKubeconfig)), + expectedContent: testValidKubeconfig, + readWrite: bytes.NewBuffer([]byte{}), + }, + { + name: "Source error", + src: func() ([]byte, error) { return nil, errSourceFunc }, + expectedErrorContains: errSourceFunc.Error(), + }, + { + name: "Writer error", + src: kubeconfig.FromByte([]byte(testValidKubeconfig)), + expectedErrorContains: errWriter.Error(), + readWrite: fakeReaderWriter{writeErr: errWriter}, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + kubeconf := kubeconfig.NewKubeConfig(tt.src, tt.options...) + err := kubeconf.Write(tt.readWrite) + if tt.expectedErrorContains != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErrorContains) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedContent, read(t, tt.readWrite)) + } + }) + } +} + +func TestKubeConfigWriteFile(t *testing.T) { + tests := []struct { + name string + expectedContent string + path string + expectedErrorContains string + + fs document.FileSystem + src kubeconfig.KubeSourceFunc + }{ + { + name: "Basic write file", + src: kubeconfig.FromByte([]byte(testValidKubeconfig)), + expectedContent: testValidKubeconfig, + fs: fsWithFile(t, "/test-path"), + path: "/test-path", + }, + { + name: "Source error", + src: func() ([]byte, error) { return nil, errSourceFunc }, + expectedErrorContains: errSourceFunc.Error(), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + kubeconf := kubeconfig.NewKubeConfig(tt.src, kubeconfig.InjectFileSystem(tt.fs)) + err := kubeconf.WriteFile(tt.path) + if tt.expectedErrorContains != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErrorContains) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedContent, readFile(t, tt.path, tt.fs)) + } + }) + } +} + +func readFile(t *testing.T, path string, fs document.FileSystem) string { + b, err := fs.ReadFile(path) + require.NoError(t, err) + return string(b) +} + +func read(t *testing.T, r io.Reader) string { + b, err := ioutil.ReadAll(r) + require.NoError(t, err) + return string(b) +} + +func fsWithFile(t *testing.T, path string) document.FileSystem { + fSys := fs.MockFileSystem{ + FileSystem: kustfs.MakeFsInMemory(), + MockRemoveAll: func() error { + return nil + }, + } + err := fSys.WriteFile(path, []byte(testValidKubeconfig)) + require.NoError(t, err) + return fSys +} + +type fakeReaderWriter struct { + readErr error + writeErr error +} + +var _ io.Reader = fakeReaderWriter{} +var _ io.Writer = fakeReaderWriter{} + +func (f fakeReaderWriter) Read(p []byte) (n int, err error) { + return 0, f.readErr +} + +func (f fakeReaderWriter) Write(p []byte) (n int, err error) { + return 0, f.writeErr +} diff --git a/pkg/k8s/kubectl/kubectl_test.go b/pkg/k8s/kubectl/kubectl_test.go index 8ed155674..63d3aae44 100644 --- a/pkg/k8s/kubectl/kubectl_test.go +++ b/pkg/k8s/kubectl/kubectl_test.go @@ -79,7 +79,7 @@ func TestApply(t *testing.T) { expectedErr: nil, fs: fs.MockFileSystem{ MockRemoveAll: func() error { return nil }, - MockTempFile: func() (document.File, error) { + MockTempFile: func(string, string) (document.File, error) { return fs.TestFile{ MockName: func() string { return filenameRC }, MockWrite: func() (int, error) { return 0, nil }, @@ -91,13 +91,13 @@ func TestApply(t *testing.T) { { expectedErr: ErrWriteOutError, fs: fs.MockFileSystem{ - MockTempFile: func() (document.File, error) { return nil, ErrWriteOutError }}, + MockTempFile: func(string, string) (document.File, error) { return nil, ErrWriteOutError }}, }, { expectedErr: ErrTempFileError, fs: fs.MockFileSystem{ MockRemoveAll: func() error { return nil }, - MockTempFile: func() (document.File, error) { + MockTempFile: func(string, string) (document.File, error) { return fs.TestFile{ MockWrite: func() (int, error) { return 0, ErrTempFileError }, MockName: func() string { return filenameRC }, diff --git a/pkg/k8s/utils/utils.go b/pkg/k8s/utils/utils.go index d2737f7b5..37ab539fd 100644 --- a/pkg/k8s/utils/utils.go +++ b/pkg/k8s/utils/utils.go @@ -25,9 +25,6 @@ import ( cmdutil "k8s.io/kubectl/pkg/cmd/util" "sigs.k8s.io/cli-utils/pkg/manifestreader" - "sigs.k8s.io/yaml" - - airshipv1 "opendev.org/airship/airshipctl/pkg/api/v1alpha1" "opendev.org/airship/airshipctl/pkg/document" ) @@ -99,27 +96,3 @@ func (mbr *ManifestBundleReader) Read() ([]*resource.Info, error) { } return mbr.StreamReader.Read() } - -// DumpKubeConfig to temporary directory -func DumpKubeConfig(kconf *airshipv1.KubeConfig, root string, fs document.FileSystem) (string, error) { - data, err := yaml.Marshal(kconf.Config) - if err != nil { - return "", err - } - - dir, err := fs.TempDir(root, "") - if err != nil { - return "", err - } - - file, err := fs.TempFile(dir, "") - if err != nil { - return "", err - } - - _, err = file.Write(data) - if err != nil { - return "", err - } - return file.Name(), nil -} diff --git a/pkg/k8s/utils/utils_test.go b/pkg/k8s/utils/utils_test.go index 49e5fde5e..b0fbf164a 100644 --- a/pkg/k8s/utils/utils_test.go +++ b/pkg/k8s/utils/utils_test.go @@ -15,7 +15,6 @@ package utils import ( - "errors" "fmt" "io" "testing" @@ -24,12 +23,7 @@ import ( "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime/schema" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - clientcmdapi "k8s.io/client-go/tools/clientcmd/api/v1" - - airshipv1 "opendev.org/airship/airshipctl/pkg/api/v1alpha1" "opendev.org/airship/airshipctl/pkg/document" - "opendev.org/airship/airshipctl/testutil/fs" ) func TestDefaultManifestFactory(t *testing.T) { @@ -95,87 +89,6 @@ func TestManifestBundleReader(t *testing.T) { } } -func TestDumpKubeConfig(t *testing.T) { - errTmpDir := errors.New("TmpDir error") - errTmpFile := errors.New("TmpFile error") - errWriteFile := errors.New("WriteFile error") - - sampleKubeConfig := &airshipv1.KubeConfig{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "airshipit.org/v1alpha1", - Kind: "KubeConfig", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "somename", - }, - Config: clientcmdapi.Config{ - APIVersion: "v1", - Kind: "Config", - }, - } - - testCases := []struct { - name string - fs document.FileSystem - expectedErr error - }{ - { - name: "Error temporary dir", - fs: fs.MockFileSystem{ - MockTempDir: func() (string, error) { - return "", errTmpDir - }, - }, - expectedErr: errTmpDir, - }, - { - name: "Error temporary file", - fs: fs.MockFileSystem{ - MockTempDir: func() (string, error) { return "someDir", nil }, - MockTempFile: func() (document.File, error) { return nil, errTmpFile }, - }, - expectedErr: errTmpFile, - }, - { - name: "Error write file", - fs: fs.MockFileSystem{ - MockTempDir: func() (string, error) { return "someDir", nil }, - MockTempFile: func() (document.File, error) { - return fs.TestFile{ - MockName: func() string { return "filename" }, - MockWrite: func() (int, error) { return 0, errWriteFile }, - MockClose: func() error { return nil }, - }, nil - }, - MockRemoveAll: func() error { return nil }, - }, - expectedErr: errWriteFile, - }, - { - name: "Dump without errors", - fs: fs.MockFileSystem{ - MockTempDir: func() (string, error) { return "someDir", nil }, - MockTempFile: func() (document.File, error) { - return fs.TestFile{ - MockName: func() string { return "filename" }, - MockWrite: func() (int, error) { return 0, nil }, - MockClose: func() error { return nil }, - }, nil - }, - MockRemoveAll: func() error { return nil }, - }, - }, - } - - for _, test := range testCases { - tt := test - t.Run(tt.name, func(t *testing.T) { - _, err := DumpKubeConfig(sampleKubeConfig, "ttt", tt.fs) - assert.Equal(t, tt.expectedErr, err) - }) - } -} - type fakeReaderWriter struct { readErr error writeErr error diff --git a/pkg/phase/ifc/executor.go b/pkg/phase/ifc/executor.go index 05efb70c5..3b946b11e 100644 --- a/pkg/phase/ifc/executor.go +++ b/pkg/phase/ifc/executor.go @@ -58,5 +58,5 @@ type ExecutorFactory func( document.Document, document.Bundle, *environment.AirshipCTLSettings, - kubeconfig.Provider, + kubeconfig.Interface, ) (Executor, error) diff --git a/testutil/fs/fs.go b/testutil/fs/fs.go index 3e7901bbb..9fd3da760 100644 --- a/testutil/fs/fs.go +++ b/testutil/fs/fs.go @@ -15,23 +15,28 @@ package fs import ( + fs "sigs.k8s.io/kustomize/api/filesys" + "opendev.org/airship/airshipctl/pkg/document" ) +var _ document.FileSystem = MockFileSystem{} + // MockFileSystem implements Filesystem type MockFileSystem struct { MockRemoveAll func() error MockTempDir func() (string, error) - MockTempFile func() (document.File, error) - document.FileSystem + // allow to check content of the incoming parameters, root and patter for temp file + MockTempFile func(string, string) (document.File, error) + fs.FileSystem } // RemoveAll Filesystem interface imlementation func (fsys MockFileSystem) RemoveAll(string) error { return fsys.MockRemoveAll() } // TempFile Filesystem interface imlementation -func (fsys MockFileSystem) TempFile(string, string) (document.File, error) { - return fsys.MockTempFile() +func (fsys MockFileSystem) TempFile(root, pattern string) (document.File, error) { + return fsys.MockTempFile(root, pattern) } // TempDir Filesystem interface imlementation