From 3c73f94ad26c10c553e9f284f05a1bfc6eaad6dd Mon Sep 17 00:00:00 2001 From: Dinos Kousidis Date: Tue, 10 Mar 2020 18:56:37 +0100 Subject: [PATCH] Adds eksctl functions to write and merge kubeconfig files * Switches context to newly created cluster by default, except if if --use-context=false is passed. * If --artifact-directory is not passed, and ~/.kube/config file exists, the context is merged in the existing file. If ~/.kube/config doesn't exist, it creates it. * Sets SilenceUsage to true in kubeconfig root command, to avoid showing the usage message in the case of error. Relevant issue: https://github.com/spf13/cobra/issues/340 * Removes printing of error in Execute() of kubeconfig command to avoid printing twice an error message. Relevant issue: https://github.com/spf13/cobra/issues/304 * Updates docs in `wksctl kubeconfig` part Signed-off-by: Dinos Kousidis --- cmd/wksctl/kubeconfig/kubeconfig.go | 56 ++++++++++++----- cmd/wksctl/main.go | 2 - docs/wks-and-footloose.md | 4 +- pkg/kubernetes/config/kubeconfig.go | 77 ++++++++++++++++++++++++ pkg/kubernetes/config/kubeconfig_test.go | 46 ++++++++++++++ pkg/utilities/path/path.go | 11 +--- test/integration/test/apply_test.go | 14 ++--- 7 files changed, 172 insertions(+), 38 deletions(-) diff --git a/cmd/wksctl/kubeconfig/kubeconfig.go b/cmd/wksctl/kubeconfig/kubeconfig.go index 2f712268..0b9396f8 100644 --- a/cmd/wksctl/kubeconfig/kubeconfig.go +++ b/cmd/wksctl/kubeconfig/kubeconfig.go @@ -2,7 +2,6 @@ package kubeconfig import ( "fmt" - "io/ioutil" "path/filepath" "github.com/pkg/errors" @@ -12,6 +11,7 @@ import ( "github.com/weaveworks/wksctl/pkg/specs" "github.com/weaveworks/wksctl/pkg/utilities/manifest" "github.com/weaveworks/wksctl/pkg/utilities/path" + "k8s.io/client-go/tools/clientcmd" ) // A new version of the kubeconfig command that retrieves the config from @@ -19,9 +19,10 @@ import ( // Cmd represents the kubeconfig command var Cmd = &cobra.Command{ - Use: "kubeconfig", - Short: "Generate a kubeconfig file for the cluster", - RunE: kubeconfigRun, + Use: "kubeconfig", + Short: "Generate a kubeconfig file for the cluster", + RunE: kubeconfigRun, + SilenceUsage: true, } var kubeconfigOptions struct { @@ -34,6 +35,7 @@ var kubeconfigOptions struct { artifactDirectory string namespace string sshKeyPath string + useContext bool skipTLSVerify bool useLocalhost bool usePublicAddress bool @@ -56,6 +58,9 @@ func init() { &kubeconfigOptions.artifactDirectory, "artifact-directory", "", "Write output files in the specified directory") Cmd.Flags().StringVar( &kubeconfigOptions.namespace, "namespace", manifest.DefaultNamespace, "namespace portion of kubeconfig path") + Cmd.Flags().BoolVar( + &kubeconfigOptions.useContext, "use-context", true, + "Set current context to the newly created one") Cmd.Flags().BoolVar( &kubeconfigOptions.skipTLSVerify, "insecure-skip-tls-verify", false, "Enables kubectl to communicate with the API w/o verifying the certificate") @@ -93,29 +98,48 @@ func kubeconfigRun(cmd *cobra.Command, args []string) error { } func writeKubeconfig(cpath, mpath string) error { - wksHome, err := path.CreateDirectory( - path.WKSHome(kubeconfigOptions.artifactDirectory)) - if err != nil { - return errors.Wrapf(err, "failed to create WKS home directory") - } + var wksHome string + var err error + var configPath string + sp := specs.NewFromPaths(cpath, mpath) + if kubeconfigOptions.artifactDirectory != "" { + wksHome, err = path.CreateDirectory( + path.WKSHome(kubeconfigOptions.artifactDirectory)) + if err != nil { + return errors.Wrapf(err, "failed to create WKS home directory") + } + + _, err = path.CreateDirectory(filepath.Dir(configPath)) + if err != nil { + return errors.Wrapf(err, "failed to create configuration directory") + } + configPath = path.Kubeconfig(wksHome, kubeconfigOptions.namespace, sp.GetClusterName()) + } else { + configPath = clientcmd.RecommendedHomeFile + } + configStr, err := config.GetRemoteKubeconfig(sp, kubeconfigOptions.sshKeyPath, kubeconfigOptions.verbose, kubeconfigOptions.skipTLSVerify) if err != nil { - return errors.Wrapf(err, "GetRemoteKubeconfig") + return errors.Wrapf(err, "failed to get remote kubeconfig") } - configPath := path.Kubeconfig(wksHome, kubeconfigOptions.namespace, sp.GetClusterName()) - - _, err = path.CreateDirectory(filepath.Dir(configPath)) + remoteConfig, err := clientcmd.Load([]byte(configStr)) if err != nil { - return errors.Wrapf(err, "failed to create configuration directory") + return errors.Wrapf(err, "failed to load kubeconfig") } + config.RenameConfig(sp, remoteConfig) - err = ioutil.WriteFile(configPath, []byte(configStr), 0644) + configPath, err = config.Write(configPath, *remoteConfig, kubeconfigOptions.useContext) if err != nil { return errors.Wrapf(err, "failed to write Kubernetes configuration locally") } - fmt.Printf("To use kubectl with the %s cluster, enter:\n$ export KUBECONFIG=%s\n", sp.GetClusterName(), configPath) + if kubeconfigOptions.artifactDirectory != "" { + fmt.Printf("To use kubectl with the %s cluster, enter:\n$ export KUBECONFIG=%s\n", sp.GetClusterName(), configPath) + } else { + fmt.Printf("The kubeconfig file at %q has been updated\n", configPath) + } + return nil } diff --git a/cmd/wksctl/main.go b/cmd/wksctl/main.go index f7f6cce3..9cfae0e2 100644 --- a/cmd/wksctl/main.go +++ b/cmd/wksctl/main.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "os" log "github.com/sirupsen/logrus" @@ -66,7 +65,6 @@ func main() { } if err := rootCmd.Execute(); err != nil { - fmt.Println(err) os.Exit(1) } diff --git a/docs/wks-and-footloose.md b/docs/wks-and-footloose.md index 9a476df8..f53ad55b 100644 --- a/docs/wks-and-footloose.md +++ b/docs/wks-and-footloose.md @@ -74,9 +74,7 @@ These commands assume you are in the `examples/footloose` directory. ```console $ wksctl kubeconfig --cluster=cluster.yaml - To use kubectl with the example cluster, enter: - export KUBECONFIG=$HOME/.wks/weavek8sops/example/kubeconfig - $ export KUBECONFIG=/home/lucas/.wks/weavek8sops/example/kubeconfig + The kubeconfig file at "/home/dinos/.kube/config" has been updated $ kubectl get nodes NAME STATUS ROLES AGE VERSION b4fdde36eb122804 Ready master 77s v1.14.1 diff --git a/pkg/kubernetes/config/kubeconfig.go b/pkg/kubernetes/config/kubeconfig.go index c29c5d4f..7c0b7618 100644 --- a/pkg/kubernetes/config/kubeconfig.go +++ b/pkg/kubernetes/config/kubeconfig.go @@ -7,15 +7,24 @@ import ( yaml "github.com/ghodss/yaml" "github.com/pkg/errors" + log "github.com/sirupsen/logrus" "github.com/weaveworks/wksctl/pkg/cluster/machine" "github.com/weaveworks/wksctl/pkg/plan/runners/ssh" "github.com/weaveworks/wksctl/pkg/plan/runners/sudo" "github.com/weaveworks/wksctl/pkg/specs" "github.com/weaveworks/wksctl/pkg/utilities/path" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" clientcmdv1 "k8s.io/client-go/tools/clientcmd/api/v1" clusterv1 "sigs.k8s.io/cluster-api/pkg/apis/cluster/v1alpha1" ) +// DefaultPath defines the default path +var DefaultPath = clientcmd.RecommendedHomeFile +var DefaultClusterName = "kubernetes" +var DefaultClusterAdminName = "kubernetes-admin" +var DefaultContextName = fmt.Sprintf("%s@%s", DefaultClusterAdminName, DefaultClusterName) + // NewKubeConfig generates a Kubernetes configuration (e.g. for kubectl to use) // from the provided machines, and places it in the provided directory. func NewKubeConfig(artifactDirectory string, machines []*clusterv1.Machine) (string, error) { @@ -76,6 +85,7 @@ func Sanitize(configStr string, params Params) (string, error) { return configStr, nil } +// GetRemoteKubeconfig retrieves Kubernetes configuration from a master node of the cluster func GetRemoteKubeconfig(sp *specs.Specs, sshKeyPath string, verbose, skipTLSVerify bool) (string, error) { sshClient, err := ssh.NewClientForMachine(sp.MasterSpec, sp.ClusterSpec.User, sshKeyPath, verbose) if err != nil { @@ -99,3 +109,70 @@ func GetRemoteKubeconfig(sp *specs.Specs, sshKeyPath string, verbose, skipTLSVer SkipTLSVerify: skipTLSVerify, }) } + +// Write will write Kubernetes client configuration to a file. +// If path isn't specified then the path will be determined by client-go. +// If file pointed to by path doesn't exist it will be created. +// If the file already exists then the configuration will be merged with the existing file. +func Write(path string, newConfig clientcmdapi.Config, setContext bool) (string, error) { + configAccess := GetConfigAccess(path) + + existingConfig, err := configAccess.GetStartingConfig() + if err != nil { + return "", errors.Wrapf(err, "Unable to read existing kubeconfig file %q", path) + } + + log.Debug("Merging kubeconfig files") + mergedConfig := Merge(existingConfig, &newConfig) + + if setContext && newConfig.CurrentContext != "" { + log.Debugf("setting current-context to %s", newConfig.CurrentContext) + mergedConfig.CurrentContext = newConfig.CurrentContext + } + + if err := clientcmd.ModifyConfig(configAccess, *mergedConfig, true); err != nil { + return "", errors.Wrapf(err, "unable to modify kubeconfig %s", path) + } + + return configAccess.GetDefaultFilename(), nil +} + +func GetConfigAccess(explicitPath string) clientcmd.ConfigAccess { + pathOptions := clientcmd.NewDefaultPathOptions() + if explicitPath != "" && explicitPath != DefaultPath { + pathOptions.LoadingRules.ExplicitPath = explicitPath + } + + return interface{}(pathOptions).(clientcmd.ConfigAccess) +} + +// Merge two kubeconfig objects +func Merge(existing *clientcmdapi.Config, tomerge *clientcmdapi.Config) *clientcmdapi.Config { + for k, v := range tomerge.Clusters { + existing.Clusters[k] = v + } + for k, v := range tomerge.AuthInfos { + existing.AuthInfos[k] = v + } + for k, v := range tomerge.Contexts { + existing.Contexts[k] = v + } + + return existing +} + +// RenameConfig renames the default cluster and context names to the values from cluster.yaml +func RenameConfig(sp *specs.Specs, newConfig *clientcmdapi.Config) { + log.Debug("Renaming cluster") + newConfig.Clusters[sp.GetClusterName()] = newConfig.Clusters[DefaultClusterName] + delete(newConfig.Clusters, DefaultClusterName) + + log.Debug("Renaming context") + newContextName := fmt.Sprintf("%s@%s", DefaultClusterAdminName, sp.GetClusterName()) + newConfig.Contexts[newContextName] = newConfig.Contexts[DefaultContextName] + newConfig.Contexts[newContextName].Cluster = sp.GetClusterName() + delete(newConfig.Contexts, DefaultContextName) + + log.Debug("Renaming current context") + newConfig.CurrentContext = newContextName +} diff --git a/pkg/kubernetes/config/kubeconfig_test.go b/pkg/kubernetes/config/kubeconfig_test.go index 3c76fb0e..8ffc0e44 100644 --- a/pkg/kubernetes/config/kubeconfig_test.go +++ b/pkg/kubernetes/config/kubeconfig_test.go @@ -1,10 +1,13 @@ package config_test import ( + "io/ioutil" + "os" "testing" "github.com/stretchr/testify/assert" "github.com/weaveworks/wksctl/pkg/kubernetes/config" + clientcmd "k8s.io/client-go/tools/clientcmd" ) const validConfig = `apiVersion: v1 @@ -86,3 +89,46 @@ func TestSanitizeWithInvalidConfigContainingSSHBannerAndWithPublicIPChange(t *te assert.NoError(t, err) assert.Equal(t, validConfigWithPublicIP, actualConfig) } + +func TestWrite(t *testing.T) { + testDataDir := "./testdata/" + testDataPath := "./testdata/test_kubeconfig" + defer os.RemoveAll(testDataDir) + validConfigObject, err := clientcmd.Load([]byte(validConfig)) + assert.NoError(t, err) + _, err = config.Write(testDataPath, *validConfigObject, true) + assert.NoError(t, err) + loadedConfig, err := clientcmd.LoadFromFile(testDataPath) + assert.NoError(t, err) + err = clientcmd.Validate(*loadedConfig) + assert.NoError(t, err) +} + +func TestMerge(t *testing.T) { + // Create 2 test kubeconfig objects + validConfigA, err := clientcmd.Load([]byte(validConfig)) + assert.NoError(t, err) + validConfigB, err := clientcmd.Load([]byte(validConfigWithPublicIP)) + assert.NoError(t, err) + + mergedConfig := config.Merge(validConfigA, validConfigB) + err = clientcmd.Validate(*mergedConfig) + assert.NoError(t, err) +} + +func TestInvalidExistingConfig(t *testing.T) { + // Fail if the current kubeconfig, which will be merged with the newly created one, is invalid + testDataDir := "./testdata/" + testDataPath := "./testdata/test_kubeconfig" + defer os.RemoveAll(testDataDir) + os.Mkdir(testDataDir, 0777) + + err := ioutil.WriteFile(testDataPath, []byte(invalidConfigWithSSHBanner), 0777) + assert.NoError(t, err) + + validConfig, err := clientcmd.Load([]byte(validConfig)) + assert.NoError(t, err) + + _, err = config.Write(testDataPath, *validConfig, true) + assert.Errorf(t, err, "Unable to read existing kubeconfig file") +} diff --git a/pkg/utilities/path/path.go b/pkg/utilities/path/path.go index 812d23f5..51b42662 100644 --- a/pkg/utilities/path/path.go +++ b/pkg/utilities/path/path.go @@ -7,6 +7,8 @@ import ( "os/user" "path/filepath" "strings" + + "k8s.io/client-go/tools/clientcmd" ) // UserHomeDirectory returns the user directory. @@ -44,14 +46,7 @@ func WKSHome(artifactDirectory string) string { if artifactDirectory != "" { return expandHome(artifactDirectory) } - - userHome, err := UserHomeDirectory() - if err == nil { - return filepath.Join(userHome, ".wks") - } - - wd, _ := os.Getwd() - return wd + return clientcmd.RecommendedHomeFile } // WKSResourcePath joins the provided (optional) artifact directory and the diff --git a/test/integration/test/apply_test.go b/test/integration/test/apply_test.go index 0cf6e440..63fa5c6a 100644 --- a/test/integration/test/apply_test.go +++ b/test/integration/test/apply_test.go @@ -7,12 +7,12 @@ import ( "io/ioutil" "log" "os" - "os/user" - "path" "path/filepath" "testing" "time" + "k8s.io/client-go/tools/clientcmd" + "github.com/weaveworks/wksctl/pkg/cluster/machine" "github.com/weaveworks/wksctl/pkg/kubernetes" "github.com/weaveworks/wksctl/pkg/plan/runners/ssh" @@ -21,12 +21,11 @@ import ( baremetalspecv1 "github.com/weaveworks/wksctl/pkg/baremetalproviderspec/v1alpha1" spawn "github.com/weaveworks/wksctl/test/integration/spawn" + "github.com/stretchr/testify/assert" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" clusterv1 "sigs.k8s.io/cluster-api/pkg/apis/cluster/v1alpha1" - - "github.com/stretchr/testify/assert" ) // Runs a basic set of tests for apply. @@ -240,13 +239,10 @@ func getClusterNamespaceAndName(t *testing.T) (string, string) { // The installer names the kubeconfig file from the cluster namespace and name // ~/.wks func wksKubeconfig(t *testing.T, l *clusterv1.MachineList) string { - currentUser, err := user.Current() - assert.NoError(t, err) master := machine.FirstMasterInArray(l.Items) assert.NotNil(t, master) - namespace, name := getClusterNamespaceAndName(t) - kubeconfig := path.Join(currentUser.HomeDir, ".wks", namespace, name, "kubeconfig") - _, err = os.Stat(kubeconfig) + kubeconfig := clientcmd.RecommendedHomeFile + _, err := os.Stat(kubeconfig) assert.NoError(t, err) return kubeconfig