From 32a3ef2f5369e51a1d942fb1fdd01113c7ed7659 Mon Sep 17 00:00:00 2001 From: Irvin Lim Date: Sat, 11 Feb 2023 14:44:49 +0800 Subject: [PATCH] feat(cli): Fix --kubeconfig and support --cluster and --context flags (#130) --- pkg/cli/cmd/cmd.go | 22 ++-- pkg/cli/common/common.go | 75 +------------ pkg/cli/common/config.go | 112 ++++++++++++++++++++ pkg/cli/common/flags.go | 58 ++++++++++ pkg/cli/completion/completion.go | 23 +++- pkg/cli/completion/kubeconfig_completion.go | 66 ++++++++++++ 6 files changed, 270 insertions(+), 86 deletions(-) create mode 100644 pkg/cli/common/flags.go create mode 100644 pkg/cli/completion/kubeconfig_completion.go diff --git a/pkg/cli/cmd/cmd.go b/pkg/cli/cmd/cmd.go index 29bafe8..0bb28b6 100644 --- a/pkg/cli/cmd/cmd.go +++ b/pkg/cli/cmd/cmd.go @@ -28,13 +28,9 @@ import ( ) type RootCommand struct { - kubeconfig string - namespace string - verbosity int - dynConfigName string - dynConfigNamespace string - streams *streams.Streams + + verbosity int } // NewRootCommand returns a new root command for the command-line utility. @@ -56,18 +52,24 @@ func NewRootCommand(streams *streams.Streams) *cobra.Command { streams.SetCmdOutput(cmd) flags := cmd.PersistentFlags() - flags.StringVar(&c.kubeconfig, "kubeconfig", "", + flags.String("kubeconfig", "", "Path to the kubeconfig file to use for CLI requests.") - flags.StringVarP(&c.namespace, "namespace", "n", "", + flags.String("cluster", "", + "The name of the kubeconfig cluster to use.") + flags.String("context", "", + "The name of the kubeconfig context to use.") + flags.StringP("namespace", "n", "", "If present, the namespace scope for this CLI request.") - flags.StringVar(&c.dynConfigName, "dynamic-config-name", "execution-dynamic-config", + flags.String("dynamic-config-name", "execution-dynamic-config", "Overrides the name of the dynamic cluster config.") - flags.StringVar(&c.dynConfigNamespace, "dynamic-config-namespace", "furiko-system", + flags.String("dynamic-config-namespace", "furiko-system", "Overrides the namespace of the dynamic cluster config.") flags.IntVarP(&c.verbosity, "v", "v", 0, "Sets the log level verbosity.") if err := completion.RegisterFlagCompletions(cmd, []completion.FlagCompletion{ {FlagName: "namespace", Completer: &completion.ListNamespacesCompleter{}}, + {FlagName: "cluster", CmdCompletionFunc: completion.ListKubeconfigClusterCompletionFunc}, + {FlagName: "context", CmdCompletionFunc: completion.ListKubeconfigContextCompletionFunc}, }); err != nil { common.Fatal(err, common.DefaultErrorExitCode) } diff --git a/pkg/cli/common/common.go b/pkg/cli/common/common.go index 278518c..54aef79 100644 --- a/pkg/cli/common/common.go +++ b/pkg/cli/common/common.go @@ -17,7 +17,6 @@ package common import ( - "context" "fmt" "os" "strings" @@ -27,12 +26,10 @@ import ( "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/klog/v2" - ctrl "sigs.k8s.io/controller-runtime" configv1alpha1 "github.com/furiko-io/furiko/apis/config/v1alpha1" "github.com/furiko-io/furiko/pkg/cli/printer" "github.com/furiko-io/furiko/pkg/runtime/controllercontext" - "github.com/furiko-io/furiko/pkg/utils/jsonyaml" ) const ( @@ -58,19 +55,14 @@ func RunAllE(funcs ...RunEFunc) RunEFunc { } // NewContext returns a common context from the cobra command. -func NewContext(_ *cobra.Command) (controllercontext.Context, error) { - kubeconfig, err := ctrl.GetConfig() +func NewContext(cmd *cobra.Command) (controllercontext.Context, error) { + kubeconfig, err := GetKubeConfig(cmd) if err != nil { return nil, errors.Wrapf(err, "cannot get kubeconfig") } return controllercontext.NewForConfig(kubeconfig, &configv1alpha1.BootstrapConfigSpec{}) } -// PrerunWithKubeconfig is a pre-run function that will set up the common context when kubeconfig is needed. -func PrerunWithKubeconfig(cmd *cobra.Command, _ []string) error { - return SetupCtrlContext(cmd) -} - // SetupCtrlContext sets up the common context. // TODO(irvinlim): We currently reuse controllercontext, but most of it is unusable for CLI interfaces. // We should create a new common context as needed. @@ -119,33 +111,6 @@ func GetNamespace(cmd *cobra.Command) (string, error) { return namespace, nil } -// GetDynamicConfig loads the dynamic config by name and unmarshals to out. -// TODO(irvinlim): If the current user does not have permissions to read the -// ConfigMap, or the ConfigMap uses a different name/namespace, we should -// gracefully handle this case. -func GetDynamicConfig(ctx context.Context, cmd *cobra.Command, name configv1alpha1.ConfigName, out interface{}) error { - cfgNamespace, err := cmd.Flags().GetString("dynamic-config-namespace") - if err != nil { - return err - } - cfgName, err := cmd.Flags().GetString("dynamic-config-name") - if err != nil { - return err - } - - klog.V(2).InfoS("fetching dynamic config", "namespace", cfgNamespace, "name", cfgName) - cm, err := ctrlContext.Clientsets().Kubernetes().CoreV1().ConfigMaps(cfgNamespace). - Get(ctx, cfgName, metav1.GetOptions{}) - if err != nil { - return errors.Wrapf(err, "cannot load dynamic config") - } - - data := cm.Data[string(name)] - klog.V(2).InfoS("fetched dynamic config", "data", data) - - return jsonyaml.UnmarshalString(data, out) -} - // GetOutputFormat returns the output format as parsed by the flag. func GetOutputFormat(cmd *cobra.Command) printer.OutputFormat { v := GetFlagString(cmd, "output") @@ -154,42 +119,6 @@ func GetOutputFormat(cmd *cobra.Command) printer.OutputFormat { return output } -// GetFlagBool gets the boolean value of a flag. -func GetFlagBool(cmd *cobra.Command, flag string) bool { - b, err := cmd.Flags().GetBool(flag) - if err != nil { - klog.Fatalf("error accessing flag %s for command %s: %v", flag, cmd.Name(), err) - } - return b -} - -// GetFlagBoolIfExists gets the boolean value of a flag if it exists. -func GetFlagBoolIfExists(cmd *cobra.Command, flag string) (val bool, ok bool) { - if cmd.Flags().Lookup(flag) != nil { - val := GetFlagBool(cmd, flag) - return val, true - } - return false, false -} - -// GetFlagString gets the string value of a flag. -func GetFlagString(cmd *cobra.Command, flag string) string { - v, err := cmd.Flags().GetString(flag) - if err != nil { - klog.Fatalf("error accessing flag %s for command %s: %v", flag, cmd.Name(), err) - } - return v -} - -// GetFlagInt64 gets the int64 value of a flag. -func GetFlagInt64(cmd *cobra.Command, flag string) int64 { - v, err := cmd.Flags().GetInt64(flag) - if err != nil { - klog.Fatalf("error accessing flag %s for command %s: %v", flag, cmd.Name(), err) - } - return v -} - // PrepareExample replaces the root command name and indents all lines. func PrepareExample(example string) string { example = strings.TrimPrefix(example, "\n") diff --git a/pkg/cli/common/config.go b/pkg/cli/common/config.go index 3c494b5..2719f44 100644 --- a/pkg/cli/common/config.go +++ b/pkg/cli/common/config.go @@ -17,13 +17,125 @@ package common import ( + "context" + "fmt" + "os" + "os/user" + "path/filepath" + "strings" + + "github.com/pkg/errors" "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/clientcmd/api" "k8s.io/klog/v2" configv1alpha1 "github.com/furiko-io/furiko/apis/config/v1alpha1" "github.com/furiko-io/furiko/pkg/config" + "github.com/furiko-io/furiko/pkg/utils/jsonyaml" ) +// GetKubeConfig returns the desired kubeconfig. +func GetKubeConfig(cmd *cobra.Command) (*rest.Config, error) { + // Attempt to use in-cluster config if both --kubeconfig and $KUBECONFIG is not set. + // If it cannot be loaded, fall through to the default loader behaviour. + if len(GetFlagString(cmd, "kubeconfig")) == 0 && len(os.Getenv(clientcmd.RecommendedConfigPathEnvVar)) == 0 { + if c, err := rest.InClusterConfig(); err == nil { + klog.V(1).InfoS("successfully loaded in-cluster kubeconfig") + return c, nil + } + } + + // Load the kubeconfig in the following order of precedence: + // * --kubeconfig + // * $KUBECONFIG + // * $HOME/.kube/config + clientConfig, err := GetClientConfig(cmd) + if err != nil { + return nil, errors.Wrapf(err, "cannot get client config") + } + return clientConfig.ClientConfig() +} + +// GetClientConfig loads the ClientConfig after parsing relevant flags. +func GetClientConfig(cmd *cobra.Command) (clientcmd.ClientConfig, error) { + // Get the --context and --cluster flags. + kubeconfigContext := GetFlagString(cmd, "context") + kubeconfigCluster := GetFlagString(cmd, "cluster") + + // Read the --kubeconfig flag. + if kubeconfig := GetFlagString(cmd, "kubeconfig"); kubeconfig != "" { + klog.V(1).InfoS("loading kubeconfig from --kubeconfig", + "path", kubeconfig, + ) + + loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig} + return makeClientConfig(loadingRules, kubeconfigContext, kubeconfigCluster), nil + } + + // Load from $KUBECONFIG or other default locations. + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + if _, ok := os.LookupEnv("HOME"); !ok { + u, err := user.Current() + if err != nil { + return nil, fmt.Errorf("could not get current user: %v", err) + } + loadingRules.Precedence = append(loadingRules.Precedence, filepath.Join(u.HomeDir, clientcmd.RecommendedHomeDir, clientcmd.RecommendedFileName)) + } + + klog.V(1).InfoS("loading default kubeconfig from recommended locations", + "pathPrecedence", strings.Join(loadingRules.Precedence, ":"), + ) + + return makeClientConfig(loadingRules, kubeconfigContext, kubeconfigCluster), nil +} + +func makeClientConfig(loadingRules *clientcmd.ClientConfigLoadingRules, context, cluster string) clientcmd.ClientConfig { + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, + &clientcmd.ConfigOverrides{ + CurrentContext: context, + Context: api.Context{ + Cluster: cluster, + }, + }, + ) +} + +// PrerunWithKubeconfig is a pre-run function that will set up the common context when kubeconfig is needed. +func PrerunWithKubeconfig(cmd *cobra.Command, _ []string) error { + return SetupCtrlContext(cmd) +} + +// GetDynamicConfig loads the dynamic config by name and unmarshals to out. +// TODO(irvinlim): If the current user does not have permissions to read the +// ConfigMap, or the ConfigMap uses a different name/namespace, we should +// gracefully handle this case. +func GetDynamicConfig(ctx context.Context, cmd *cobra.Command, name configv1alpha1.ConfigName, out interface{}) error { + cfgNamespace, err := cmd.Flags().GetString("dynamic-config-namespace") + if err != nil { + return err + } + cfgName, err := cmd.Flags().GetString("dynamic-config-name") + if err != nil { + return err + } + + klog.V(2).InfoS("fetching dynamic config", "namespace", cfgNamespace, "name", cfgName) + cm, err := ctrlContext.Clientsets().Kubernetes().CoreV1().ConfigMaps(cfgNamespace). + Get(ctx, cfgName, metav1.GetOptions{}) + if err != nil { + return errors.Wrapf(err, "cannot load dynamic config") + } + + data := cm.Data[string(name)] + klog.V(2).InfoS("fetched dynamic config", "data", data) + + return jsonyaml.UnmarshalString(data, out) +} + // GetCronDynamicConfig returns the cron dynamic config. func GetCronDynamicConfig(cmd *cobra.Command) *configv1alpha1.CronExecutionConfig { ctx := cmd.Context() diff --git a/pkg/cli/common/flags.go b/pkg/cli/common/flags.go new file mode 100644 index 0000000..8d82738 --- /dev/null +++ b/pkg/cli/common/flags.go @@ -0,0 +1,58 @@ +/* + * Copyright 2022 The Furiko Authors. + * + * 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 + * + * http://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 common + +import ( + "github.com/spf13/cobra" + "k8s.io/klog/v2" +) + +// GetFlagBool gets the boolean value of a flag. +func GetFlagBool(cmd *cobra.Command, flag string) bool { + b, err := cmd.Flags().GetBool(flag) + if err != nil { + klog.Fatalf("error accessing flag %s for command %s: %v", flag, cmd.Name(), err) + } + return b +} + +// GetFlagBoolIfExists gets the boolean value of a flag if it exists. +func GetFlagBoolIfExists(cmd *cobra.Command, flag string) (val bool, ok bool) { + if cmd.Flags().Lookup(flag) != nil { + val := GetFlagBool(cmd, flag) + return val, true + } + return false, false +} + +// GetFlagString gets the string value of a flag. +func GetFlagString(cmd *cobra.Command, flag string) string { + v, err := cmd.Flags().GetString(flag) + if err != nil { + klog.Fatalf("error accessing flag %s for command %s: %v", flag, cmd.Name(), err) + } + return v +} + +// GetFlagInt64 gets the int64 value of a flag. +func GetFlagInt64(cmd *cobra.Command, flag string) int64 { + v, err := cmd.Flags().GetInt64(flag) + if err != nil { + klog.Fatalf("error accessing flag %s for command %s: %v", flag, cmd.Name(), err) + } + return v +} diff --git a/pkg/cli/completion/completion.go b/pkg/cli/completion/completion.go index 0f522a8..86e6fc5 100644 --- a/pkg/cli/completion/completion.go +++ b/pkg/cli/completion/completion.go @@ -30,6 +30,9 @@ import ( // Func is a completion func, that knows how to return completions. type Func func(ctx context.Context, ctrlContext controllercontext.Context, namespace string) ([]string, error) +// CmdCompletionFunc is a completion func that only needs a *cobra.Command. +type CmdCompletionFunc func(cmd *cobra.Command) ([]string, error) + // Completer is the interface of Func. type Completer interface { Complete(ctx context.Context, ctrlContext controllercontext.Context, namespace string) ([]string, error) @@ -37,9 +40,10 @@ type Completer interface { // FlagCompletion defines a single flag completion entry. type FlagCompletion struct { - FlagName string - CompletionFunc Func - Completer Completer + FlagName string + CompletionFunc Func + CmdCompletionFunc CmdCompletionFunc + Completer Completer } type cobraCompletionFunc func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) @@ -54,6 +58,8 @@ func RegisterFlagCompletions(cmd *cobra.Command, completions []FlagCompletion) e var completionFunc cobraCompletionFunc if completion.CompletionFunc != nil { completionFunc = MakeCobraCompletionFunc(completion.CompletionFunc) + } else if completion.CmdCompletionFunc != nil { + completionFunc = CmdCompletionFuncToCobraCompletionFunc(completion.CmdCompletionFunc) } else if completion.Completer != nil { completionFunc = CompleterToCobraCompletionFunc(completion.Completer) } @@ -88,6 +94,17 @@ func MakeCobraCompletionFunc(f Func) func(cmd *cobra.Command, args []string, toC } } +// CmdCompletionFuncToCobraCompletionFunc converts a CmdCompletionFunc into a cobra completion function. +func CmdCompletionFuncToCobraCompletionFunc(c CmdCompletionFunc) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + comps, err := c(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + return comps, cobra.ShellCompDirectiveNoFileComp + } +} + // CompleterToCobraCompletionFunc converts a Completer into a cobra completion function. func CompleterToCobraCompletionFunc(c Completer) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return MakeCobraCompletionFunc(c.Complete) diff --git a/pkg/cli/completion/kubeconfig_completion.go b/pkg/cli/completion/kubeconfig_completion.go new file mode 100644 index 0000000..15867cf --- /dev/null +++ b/pkg/cli/completion/kubeconfig_completion.go @@ -0,0 +1,66 @@ +/* + * Copyright 2022 The Furiko Authors. + * + * 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 + * + * http://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 completion + +import ( + "sort" + + "github.com/spf13/cobra" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/furiko-io/furiko/pkg/cli/common" +) + +// ListKubeconfigClusterCompletionFunc knows how to list clusters from the kubeconfig. +func ListKubeconfigClusterCompletionFunc(cmd *cobra.Command) ([]string, error) { + clientConfig, err := getClientConfig(cmd) + if err != nil { + return nil, err + } + clusters := make([]string, 0, len(clientConfig.Clusters)) + for name := range clientConfig.Clusters { + clusters = append(clusters, name) + } + sort.Strings(clusters) + return clusters, nil +} + +// ListKubeconfigContextCompletionFunc knows how to list contexts from the kubeconfig. +func ListKubeconfigContextCompletionFunc(cmd *cobra.Command) ([]string, error) { + clientConfig, err := getClientConfig(cmd) + if err != nil { + return nil, err + } + contexts := make([]string, 0, len(clientConfig.Contexts)) + for name := range clientConfig.Contexts { + contexts = append(contexts, name) + } + sort.Strings(contexts) + return contexts, nil +} + +func getClientConfig(cmd *cobra.Command) (*clientcmdapi.Config, error) { + clientConfig, err := common.GetClientConfig(cmd) + if err != nil { + return nil, err + } + rawConfig, err := clientConfig.RawConfig() + if err != nil { + return nil, err + } + return &rawConfig, nil +}