diff --git a/cmd/cluster/get_kubeconfig.go b/cmd/cluster/get_kubeconfig.go index 47079efd7..33f851f87 100644 --- a/cmd/cluster/get_kubeconfig.go +++ b/cmd/cluster/get_kubeconfig.go @@ -37,6 +37,14 @@ Retrieve target-cluster kubeconfig Retrieve kubeconfig for the entire site; the kubeconfig will have context for every cluster # airshipctl cluster get-kubeconfig + +Specify a file where kubeconfig should be written +# airshipctl cluster get-kubeconfig --file ~/my-kubeconfig + +Merge site kubeconfig with existing kubeconfig file. +Keep in mind that this can override a context if it has the same name +Airshipctl will overwrite the contents of the file, if you want merge with existing file, specify "--merge" flag +# airshipctl cluster get-kubeconfig --file ~/.airship/kubeconfig --merge ` ) @@ -53,6 +61,21 @@ func NewGetKubeconfigCommand(cfgFactory config.Factory) *cobra.Command { return opts.RunE(cfgFactory, cmd.OutOrStdout()) }, } + flags := cmd.Flags() + + flags.StringVarP( + &opts.File, + "file", + "f", + "", + "specify where to write kubeconfig file. If flag isn't specified, airshipctl will write it to stdout", + ) + flags.BoolVar( + &opts.Merge, + "merge", + false, + "specify if you want to merge kubeconfig with the one that exists at --file location", + ) return cmd } diff --git a/cmd/cluster/testdata/TestNewKubeConfigCommandCmdGoldenOutput/cluster-get-kubeconfig-cmd-with-help.golden b/cmd/cluster/testdata/TestNewKubeConfigCommandCmdGoldenOutput/cluster-get-kubeconfig-cmd-with-help.golden index 745206786..394b7cd8a 100644 --- a/cmd/cluster/testdata/TestNewKubeConfigCommandCmdGoldenOutput/cluster-get-kubeconfig-cmd-with-help.golden +++ b/cmd/cluster/testdata/TestNewKubeConfigCommandCmdGoldenOutput/cluster-get-kubeconfig-cmd-with-help.golden @@ -17,6 +17,16 @@ Retrieve target-cluster kubeconfig Retrieve kubeconfig for the entire site; the kubeconfig will have context for every cluster # airshipctl cluster get-kubeconfig +Specify a file where kubeconfig should be written +# airshipctl cluster get-kubeconfig --file ~/my-kubeconfig + +Merge site kubeconfig with existing kubeconfig file. +Keep in mind that this can override a context if it has the same name +Airshipctl will overwrite the contents of the file, if you want merge with existing file, specify "--merge" flag +# airshipctl cluster get-kubeconfig --file ~/.airship/kubeconfig --merge + Flags: - -h, --help help for get-kubeconfig + -f, --file string specify where to write kubeconfig file. If flag isn't specified, airshipctl will write it to stdout + -h, --help help for get-kubeconfig + --merge specify if you want to merge kubeconfig with the one that exists at --file location diff --git a/docs/source/cli/airshipctl_cluster_get-kubeconfig.md b/docs/source/cli/airshipctl_cluster_get-kubeconfig.md index 32817aef6..329917a0b 100644 --- a/docs/source/cli/airshipctl_cluster_get-kubeconfig.md +++ b/docs/source/cli/airshipctl_cluster_get-kubeconfig.md @@ -27,12 +27,22 @@ Retrieve target-cluster kubeconfig Retrieve kubeconfig for the entire site; the kubeconfig will have context for every cluster # airshipctl cluster get-kubeconfig +Specify a file where kubeconfig should be written +# airshipctl cluster get-kubeconfig --file ~/my-kubeconfig + +Merge site kubeconfig with existing kubeconfig file. +Keep in mind that this can override a context if it has the same name +Airshipctl will overwrite the contents of the file, if you want merge with existing file, specify "--merge" flag +# airshipctl cluster get-kubeconfig --file ~/.airship/kubeconfig --merge + ``` ### Options ``` - -h, --help help for get-kubeconfig + -f, --file string specify where to write kubeconfig file. If flag isn't specified, airshipctl will write it to stdout + -h, --help help for get-kubeconfig + --merge specify if you want to merge kubeconfig with the one that exists at --file location ``` ### Options inherited from parent commands diff --git a/pkg/cluster/command.go b/pkg/cluster/command.go index 773aa7173..30480b924 100755 --- a/pkg/cluster/command.go +++ b/pkg/cluster/command.go @@ -57,9 +57,12 @@ func StatusRunner(o StatusOptions, w io.Writer) error { // GetKubeconfigCommand holds options for get kubeconfig command type GetKubeconfigCommand struct { ClusterName string + File string + Merge bool } -// RunE creates new kubeconfig interface object from secret and prints its content to writer +// RunE creates new kubeconfig interface object from secret, options hold the writer and merge(bool) +// to merge the kubeconfig. Writer in options is ignored if a file is provided. func (cmd *GetKubeconfigCommand) RunE(cfgFactory config.Factory, writer io.Writer) error { cfg, err := cfgFactory() if err != nil { @@ -89,5 +92,8 @@ func (cmd *GetKubeconfigCommand) RunE(cfgFactory config.Factory, writer io.Write SiteWide(siteWide). Build() + if cmd.File != "" { + return kubeconf.WriteFile(cmd.File, kubeconfig.WriteOptions{Merge: cmd.Merge}) + } return kubeconf.Write(writer) } diff --git a/pkg/k8s/kubeconfig/builder_test.go b/pkg/k8s/kubeconfig/builder_test.go index 35f8ade6d..79fff08e9 100644 --- a/pkg/k8s/kubeconfig/builder_test.go +++ b/pkg/k8s/kubeconfig/builder_test.go @@ -215,7 +215,7 @@ func TestBuilderClusterctl(t *testing.T) { MockTempFile: func(s1, s2 string) (fs.File, error) { return testfs.TestFile{ MockName: func() string { return kubeconfigPath }, - MockWrite: func() (int, error) { return 0, nil }, + MockWrite: func([]byte) (int, error) { return 0, nil }, MockClose: func() error { return nil }, }, nil }, diff --git a/pkg/k8s/kubeconfig/kubeconfig.go b/pkg/k8s/kubeconfig/kubeconfig.go index 14dddf00e..a2e5df1b1 100644 --- a/pkg/k8s/kubeconfig/kubeconfig.go +++ b/pkg/k8s/kubeconfig/kubeconfig.go @@ -24,6 +24,7 @@ import ( corev1 "k8s.io/client-go/kubernetes/typed/core/v1" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" + "sigs.k8s.io/yaml" "opendev.org/airship/airshipctl/pkg/api/v1alpha1" @@ -48,7 +49,8 @@ type Interface interface { // 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 + // WriteOptions holds additional option when writing kubeconfig to file + WriteFile(path string, options WriteOptions) 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) @@ -65,6 +67,11 @@ type kubeConfig struct { sourceFunc KubeSourceFunc } +// WriteOptions holds additional option while writing kubeconfig to the file +type WriteOptions struct { + Merge bool +} + // 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 @@ -206,8 +213,14 @@ func InjectFilePath(path string, fSys fs.FileSystem) Option { } } -func (k *kubeConfig) WriteFile(path string) (err error) { - data, err := k.bytes() +func (k *kubeConfig) WriteFile(path string, options WriteOptions) error { + var data []byte + var err error + if options.Merge && path != "" { + data, err = k.mergedBytes(path) + } else { + data, err = k.bytes() + } if err != nil { return err } @@ -253,6 +266,24 @@ func (k *kubeConfig) bytes() ([]byte, error) { return k.savedByes, err } +// mergedBytes takes the file path and return byte data of the kubeconfig file to be written +func (k *kubeConfig) mergedBytes(path string) ([]byte, error) { + kFile, cleanup, err := k.WriteTempFile(k.dumpRoot) + if err != nil { + return []byte{}, err + } + defer cleanup() + + rules := clientcmd.ClientConfigLoadingRules{ + Precedence: []string{path, kFile}, + } + mergedConfig, err := rules.Load() + if err != nil { + return []byte{}, err + } + return clientcmd.Write(*mergedConfig) +} + // 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 diff --git a/pkg/k8s/kubeconfig/kubeconfig_test.go b/pkg/k8s/kubeconfig/kubeconfig_test.go index e269b2fb9..c183007dc 100644 --- a/pkg/k8s/kubeconfig/kubeconfig_test.go +++ b/pkg/k8s/kubeconfig/kubeconfig_test.go @@ -62,6 +62,52 @@ users: user: client-certificate-data: cert-data client-key-data: client-keydata +` + testValidKubeconfigTwo = ` +apiVersion: v1 +clusters: +- cluster: + 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: {} +` + //testMergedValidKubeconfig tests to confirm whether two kubeconfig got merged or not + testMergedValidKubeconfig = ` +apiVersion: v1 +clusters: +- cluster: + server: https://10.23.25.101:6443 + name: dummycluster_ephemeral +- cluster: + server: https://10.0.1.7:6443 + name: kubernetes_target +contexts: +- context: + cluster: dummycluster_ephemeral + user: kubernetes-admin + name: dummy_cluster +- context: + cluster: kubernetes_target + user: kubernetes-admin + name: kubernetes-admin@kubernetes +current-context: dummy_cluster +kind: Config +preferences: {} +users: +- name: kubernetes-admin + user: + client-certificate-data: dGVzdAo= + client-key-data: dGVzdAo= ` ) @@ -297,7 +343,7 @@ func TestNewKubeConfig(t *testing.T) { MockTempFile: func(root, pattern string) (fs.File, error) { return testfs.TestFile{ MockName: func() string { return "kubeconfig-142398" }, - MockWrite: func() (int, error) { return 0, nil }, + MockWrite: func([]byte) (int, error) { return 0, nil }, MockClose: func() error { return nil }, }, nil }, @@ -322,7 +368,7 @@ func TestNewKubeConfig(t *testing.T) { } return testfs.TestFile{ MockName: func() string { return "kubeconfig-142398" }, - MockWrite: func() (int, error) { return 0, nil }, + MockWrite: func([]byte) (int, error) { return 0, nil }, MockClose: func() error { return nil }, }, nil }, @@ -440,6 +486,7 @@ func TestKubeConfigWriteFile(t *testing.T) { expectedContent string path string expectedErrorContains string + merge bool fs fs.FileSystem src kubeconfig.KubeSourceFunc @@ -448,26 +495,52 @@ func TestKubeConfigWriteFile(t *testing.T) { name: "Basic write file", src: kubeconfig.FromByte([]byte(testValidKubeconfig)), expectedContent: testValidKubeconfig, + merge: false, fs: fsWithFile(t, "/test-path"), path: "/test-path", }, + { + name: "Basic merge write file", + src: kubeconfig.FromByte([]byte(testValidKubeconfigTwo)), + expectedContent: testMergedValidKubeconfig, + merge: true, + fs: fs.NewDocumentFs(), + path: "testdata/kubeconfig-test", + }, { name: "Source error", src: func() ([]byte, error) { return nil, errSourceFunc }, expectedErrorContains: errSourceFunc.Error(), + merge: false, }, } 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) + kubeconf := kubeconfig.NewKubeConfig(tt.src, + kubeconfig.InjectTempRoot("testdata/"), + kubeconfig.InjectFileSystem(tt.fs)) + options := kubeconfig.WriteOptions{ + Merge: tt.merge, + } + if tt.merge { + _, clean, err := kubeconf.GetFile() + require.NoError(t, err) + defer clean() + original, err := ioutil.ReadFile(tt.path) + require.NoError(t, err) + defer func() { + err = ioutil.WriteFile(tt.path, original, 0600) + require.NoError(t, err) + }() + } + err := kubeconf.WriteFile(tt.path, options) 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)) + assert.YAMLEq(t, tt.expectedContent, readFile(t, tt.path, tt.fs)) } }) } @@ -486,11 +559,19 @@ func read(t *testing.T, r io.Reader) string { } func fsWithFile(t *testing.T, path string) fs.FileSystem { + memFs := kustfs.MakeFsInMemory() fSys := testfs.MockFileSystem{ - FileSystem: kustfs.MakeFsInMemory(), + FileSystem: memFs, MockRemoveAll: func() error { return nil }, + MockTempFile: func(root, pattern string) (fs.File, error) { + return testfs.TestFile{ + MockName: func() string { return "kubeconfig-142398" }, + MockWrite: func(b []byte) (int, error) { return 0, memFs.WriteFile("kubeconfig-142398", b) }, + MockClose: func() error { return nil }, + }, nil + }, } err := fSys.WriteFile(path, []byte(testValidKubeconfig)) require.NoError(t, err) diff --git a/pkg/k8s/kubeconfig/testdata/kubeconfig-test b/pkg/k8s/kubeconfig/testdata/kubeconfig-test new file mode 100644 index 000000000..f956153eb --- /dev/null +++ b/pkg/k8s/kubeconfig/testdata/kubeconfig-test @@ -0,0 +1,18 @@ + apiVersion: v1 + kind: Config + clusters: + - cluster: + server: https://10.23.25.101:6443 + name: dummycluster_ephemeral + contexts: + - context: + cluster: dummycluster_ephemeral + user: kubernetes-admin + name: dummy_cluster + current-context: dummy_cluster + preferences: {} + users: + - name: kubernetes-admin + user: + client-certificate-data: dGVzdAo= + client-key-data: dGVzdAo= diff --git a/pkg/k8s/kubectl/kubectl_test.go b/pkg/k8s/kubectl/kubectl_test.go index dcee73c1d..bcd1ca57d 100644 --- a/pkg/k8s/kubectl/kubectl_test.go +++ b/pkg/k8s/kubectl/kubectl_test.go @@ -83,7 +83,7 @@ func TestApply(t *testing.T) { MockTempFile: func(string, string) (fs.File, error) { return testfs.TestFile{ MockName: func() string { return filenameRC }, - MockWrite: func() (int, error) { return 0, nil }, + MockWrite: func([]byte) (int, error) { return 0, nil }, MockClose: func() error { return nil }, }, nil }, @@ -100,7 +100,7 @@ func TestApply(t *testing.T) { MockRemoveAll: func() error { return nil }, MockTempFile: func(string, string) (fs.File, error) { return testfs.TestFile{ - MockWrite: func() (int, error) { return 0, ErrTempFileError }, + MockWrite: func([]byte) (int, error) { return 0, ErrTempFileError }, MockName: func() string { return filenameRC }, MockClose: func() error { return nil }, }, nil diff --git a/pkg/phase/executors/clusterctl_test.go b/pkg/phase/executors/clusterctl_test.go index 1d2617e4c..9146c8c37 100755 --- a/pkg/phase/executors/clusterctl_test.go +++ b/pkg/phase/executors/clusterctl_test.go @@ -154,7 +154,7 @@ func TestClusterctlExecutorRun(t *testing.T) { MockTempFile: func(string, string) (fs.File, error) { return testfs.TestFile{ MockName: func() string { return "filename" }, - MockWrite: func() (int, error) { return 0, nil }, + MockWrite: func([]byte) (int, error) { return 0, nil }, MockClose: func() error { return nil }, }, nil }, diff --git a/pkg/phase/executors/container_test.go b/pkg/phase/executors/container_test.go index f02b9f9bf..978f08490 100644 --- a/pkg/phase/executors/container_test.go +++ b/pkg/phase/executors/container_test.go @@ -344,9 +344,9 @@ type fakeKubeConfig struct { getFile func() (string, kubeconfig.Cleanup, error) } -func (k fakeKubeConfig) GetFile() (string, kubeconfig.Cleanup, error) { return k.getFile() } -func (k fakeKubeConfig) Write(_ io.Writer) error { return nil } -func (k fakeKubeConfig) WriteFile(_ string) error { return nil } +func (k fakeKubeConfig) GetFile() (string, kubeconfig.Cleanup, error) { return k.getFile() } +func (k fakeKubeConfig) Write(_ io.Writer) error { return nil } +func (k fakeKubeConfig) WriteFile(_ string, _ kubeconfig.WriteOptions) error { return nil } func (k fakeKubeConfig) WriteTempFile(_ string) (string, kubeconfig.Cleanup, error) { return k.getFile() } diff --git a/pkg/phase/executors/k8s_applier_test.go b/pkg/phase/executors/k8s_applier_test.go index fac1f856e..61e61dd11 100644 --- a/pkg/phase/executors/k8s_applier_test.go +++ b/pkg/phase/executors/k8s_applier_test.go @@ -290,7 +290,7 @@ func testKubeconfig(stringData string) kubeconfig.Interface { MockTempFile: func(root, pattern string) (fs.File, error) { return testfs.TestFile{ MockName: func() string { return "kubeconfig-142398" }, - MockWrite: func() (int, error) { return 0, nil }, + MockWrite: func([]byte) (int, error) { return 0, nil }, MockClose: func() error { return nil }, }, nil }, diff --git a/testutil/fs/fs.go b/testutil/fs/fs.go index 3e10b2772..34bffa976 100644 --- a/testutil/fs/fs.go +++ b/testutil/fs/fs.go @@ -62,7 +62,7 @@ func (fsys MockFileSystem) Dir(path string) string { type TestFile struct { fs.File MockName func() string - MockWrite func() (int, error) + MockWrite func([]byte) (int, error) MockClose func() error } @@ -70,7 +70,7 @@ type TestFile struct { func (f TestFile) Name() string { return f.MockName() } // Write File interface implementation -func (f TestFile) Write([]byte) (int, error) { return f.MockWrite() } +func (f TestFile) Write(b []byte) (int, error) { return f.MockWrite(b) } // Close File interface implementation func (f TestFile) Close() error { return f.MockClose() }