From 7aaf8c84006a57b0a3cbf77a582d9ef09d6b306f Mon Sep 17 00:00:00 2001 From: Piotr Skamruk Date: Thu, 21 May 2026 17:07:39 +0200 Subject: [PATCH 1/3] feat: Support for config file reading Add support for configuration files as described in #49 Signed-off-by: Piotr Skamruk --- cmd/cluster-version.go | 9 +++++ cmd/root.go | 79 ++++++++++++++++++++++++++++++++++++++++++ cmd/sync.go | 19 ++++++++++ cmd/version.go | 19 +++++----- main.go | 6 ---- 5 files changed, 117 insertions(+), 15 deletions(-) diff --git a/cmd/cluster-version.go b/cmd/cluster-version.go index f418cca..4ec0a8a 100644 --- a/cmd/cluster-version.go +++ b/cmd/cluster-version.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/spf13/viper" "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" @@ -31,6 +32,9 @@ var ( ) func runClusterVersion(cmd *cobra.Command, args []string) error { + // Use viper as a source of configuration + kubeconfig = viper.GetString("kubeconfig") + kubecontext = viper.GetString("context") cfg, err := configWithContext(kubecontext, kubeconfig) if err != nil { @@ -138,4 +142,9 @@ func getUnauthenticatedVersion(cfg *rest.Config) (*version.Info, error) { func init() { clusterVersionCmd.Flags().StringVarP(&kubeconfig, "kubeconfig", "k", clientcmd.RecommendedHomeFile, "kubeconfig file path") clusterVersionCmd.Flags().StringVarP(&kubecontext, "context", "c", "", "cluster version of the specified context in kubeconfig") + + // BindPFlags can theroretically return an error if called with `nil` as an argument + // which should never happened after at least one flag was defined. That's why the output + // there is ignored. + viper.BindPFlags(clusterVersionCmd.Flags()) } diff --git a/cmd/root.go b/cmd/root.go index f940a89..78908dc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,8 +5,12 @@ package cmd import ( "context" + "os" + "path/filepath" "github.com/spf13/cobra" + "github.com/spf13/viper" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" ) @@ -32,12 +36,26 @@ Examples: cloudctl version`, } +var ( + configFilePath string +) + // Execute runs the CLI with the provided context. func Execute(ctx context.Context) error { return rootCmd.ExecuteContext(ctx) } func init() { + cobra.OnInitialize(func() { + cobra.CheckErr(setupConfig()) + }) + rootCmd.PersistentFlags().StringVar(&configFilePath, "config", "", "Path to configuration file") + + // BindPFlags can theroretically return an error if called with `nil` as an argument + // which should never happened after at least one flag was defined. That's why the output + // there is ignored. + viper.BindPFlags(rootCmd.PersistentFlags()) + // Add subcommands here rootCmd.AddCommand(syncCmd) rootCmd.AddCommand(clusterVersionCmd) @@ -55,3 +73,64 @@ func configWithContext(contextName, kubeconfigPath string) (*rest.Config, error) cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) return cc.ClientConfig() } + +func setupConfig() error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + // Optionally read environment variables, config files, etc. + viper.SetEnvPrefix("CLOUDCTL") + viper.AutomaticEnv() + + viper.SetConfigType("yaml") + + configFilePath = viper.GetString("config") + if len(configFilePath) > 0 { + // Phase 1 + // First we are trying config provided as a command line parameter. Fail if there was an error + // during reading configuration from this specified path. + viper.SetConfigFile(configFilePath) + return viper.ReadInConfig() + } else { + // Phase 2 + // Then we are searching for ".cloudctl.yaml" in current or home directory + viper.AddConfigPath(".") + viper.AddConfigPath(home) + // NOTE: viper is automatically adding a file extension basing on the value of called above `SetConfigType` + viper.SetConfigName(".cloudctl") + } + + err = viper.ReadInConfig() + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + // Phase 3 + // If reading config in above described locations failed, we are looking for configuration + // in these locations: + // locations set in PHASE 2: + // ./cloudctl.yaml + // $HOME/cloudctl.yaml + // if $XDG_CONFIG_HOME is set: + // $XDG_CONFIG_HOME/cloudctl/cloudctl.yaml + // $XDG_CONFIG_HOME/cloudctl.yaml + // else: + // $HOME/.config/cloudctl/cloudctl.yaml + // $HOME/.config/cloudctl.yaml + // NOTE: viper is automatically adding a file extension basing on the value of called above `SetConfigType` + viper.SetConfigName("cloudctl") + if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); len(xdgConfig) > 0 { + viper.AddConfigPath(filepath.Join(xdgConfig, "cloudctl")) + viper.AddConfigPath(xdgConfig) + } else { + viper.AddConfigPath(filepath.Join(home, ".config", "cloudctl")) + viper.AddConfigPath(filepath.Join(home, ".config")) + } + err = viper.ReadInConfig() + // If configuration was not found in any of above listed locations - that's ok. + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + err = nil + } + } + + return err +} diff --git a/cmd/sync.go b/cmd/sync.go index cf206c8..ff8992f 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -18,6 +18,7 @@ import ( greenhousemetav1alpha1 "github.com/cloudoperators/greenhouse/api/meta/v1alpha1" "github.com/cloudoperators/greenhouse/api/v1alpha1" "github.com/spf13/cobra" + "github.com/spf13/viper" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" @@ -56,6 +57,11 @@ func init() { syncCmd.Flags().StringVar(&kubeloginPath, "kubelogin-path", "kubelogin", "path to kubelogin command when using exec-plugin auth-type") syncCmd.Flags().StringSliceVar(&kubeloginExtraArgs, "kubelogin-extra-args", nil, "extra arguments to pass to kubelogin exec plugin") syncCmd.Flags().StringVar(&kubeloginTokenCacheDir, "kubelogin-token-cache-dir", "$(HOME)/.kube/cache/oidc-login", "token cache directory for kubelogin") + + // BindPFlags can theroretically return an error if called with `nil` as an argument + // which should never happened after at least one flag was defined. That's why the output + // there is ignored. + viper.BindPFlags(syncCmd.Flags()) } var syncCmd = &cobra.Command{ @@ -65,6 +71,19 @@ var syncCmd = &cobra.Command{ } func runSync(cmd *cobra.Command, args []string) error { + // Use viper as a source of configuration + greenhouseClusterKubeconfig = viper.GetString("greenhouse-cluster-kubeconfig") + greenhouseClusterContext = viper.GetString("greenhouse-cluster-context") + greenhouseClusterNamespace = viper.GetString("greenhouse-cluster-namespace") + remoteClusterKubeconfig = viper.GetString("remote-cluster-kubeconfig") + remoteClusterName = viper.GetString("remote-cluster-name") + prefix = viper.GetString("prefix") + mergeIdenticalUsers = viper.GetBool("merge-identical-users") + authType = viper.GetString("auth-type") + kubeloginPath = viper.GetString("kubelogin-path") + kubeloginExtraArgs = viper.GetStringSlice("kubelogin-extra-args") + kubeloginTokenCacheDir = viper.GetString("kubelogin-token-cache-dir") + if greenhouseClusterKubeconfig == "" { return fmt.Errorf("greenhouse cluster kubeconfig path is empty") } diff --git a/cmd/version.go b/cmd/version.go index 34f3dd8..95216b6 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -9,6 +9,7 @@ import ( "runtime" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var ( @@ -28,11 +29,6 @@ type versionInfo struct { Platform string `json:"platform"` } -var ( - versionShort bool - versionJSON bool -) - var versionCmd = &cobra.Command{ Use: "version", Short: "Print the cloudctl version information", @@ -46,7 +42,7 @@ var versionCmd = &cobra.Command{ Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), } - if versionJSON { + if viper.GetBool("json") { b, err := json.MarshalIndent(info, "", " ") if err != nil { return err @@ -55,7 +51,7 @@ var versionCmd = &cobra.Command{ return nil } - if versionShort { + if viper.GetBool("short") { fmt.Println(info.Version) return nil } @@ -69,6 +65,11 @@ var versionCmd = &cobra.Command{ } func init() { - versionCmd.Flags().BoolVar(&versionShort, "short", false, "print only the version number") - versionCmd.Flags().BoolVar(&versionJSON, "json", false, "print version information as JSON") + versionCmd.Flags().Bool("short", false, "print only the version number") + versionCmd.Flags().Bool("json", false, "print version information as JSON") + + // BindPFlags can theroretically return an error if called with `nil` as an argument + // which should never happened after at least one flag was defined. That's why the output + // there is ignored. + viper.BindPFlags(versionCmd.Flags()) } diff --git a/main.go b/main.go index b145f3d..a254857 100644 --- a/main.go +++ b/main.go @@ -10,18 +10,12 @@ import ( "os/signal" "syscall" - "github.com/spf13/viper" - "github.com/cloudoperators/cloudctl/cmd" _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" ) func main() { - // Optionally read environment variables, config files, etc. - viper.SetEnvPrefix("CLOUDCTL") - viper.AutomaticEnv() - // Graceful cancellation on SIGINT/SIGTERM ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() From 4399234ad10e889e660d6472b6f73ba9f8970d70 Mon Sep 17 00:00:00 2001 From: Piotr Skamruk Date: Mon, 8 Jun 2026 11:13:06 +0200 Subject: [PATCH 2/3] test: Add tests for configuration loading Signed-off-by: Piotr Skamruk --- cmd/root_test.go | 79 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 cmd/root_test.go diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..68cf44e --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "os" + "testing" + + "github.com/spf13/viper" + + . "github.com/onsi/gomega" +) + +var ConfigA = []byte(`kubeconfig: A +config: B +`) + +func TestNotSetConfigPath(t *testing.T) { + g := NewWithT(t) + + t.Cleanup(func() { viper.Reset() }) + + // Ensure default config file location is not set + config := rootCmd.PersistentFlags().Lookup("config") + g.Expect(config).NotTo(BeNil()) + g.Expect(config.Value.String()).To(BeEmpty()) + + // ... and that does not lead to an error during configuration setup + g.Expect(setupConfig()).To(BeNil()) +} + +func TestConfigurationLoad(t *testing.T) { + g := NewWithT(t) + + f, err := os.CreateTemp("", "test_cloudctl_config") + g.Expect(err).To(BeNil()) + t.Cleanup(func() { os.Remove(f.Name()) }) + + _, err = f.Write(ConfigA) + g.Expect(err).To(BeNil()) + err = f.Close() + g.Expect(err).To(BeNil()) + + // Set config file location env variable + orig := os.Getenv("CLOUDCTL_CONFIG") + os.Setenv("CLOUDCTL_CONFIG", f.Name()) + + t.Cleanup(func() { + viper.Reset() + os.Setenv("CLOUDCTL_CONFIG", orig) + }) + + // Do the setup + g.Expect(setupConfig()).To(BeNil()) + + // Check if config file variable was not overwriten with data from config file + g.Expect(viper.GetString("config")).To(Equal(f.Name())) + + // Check if `kubeconfig` variable was set to the value from temporary file + g.Expect(viper.GetString("kubeconfig")).To(Equal("A")) +} + +func TestMissingConfigurationFile(t *testing.T) { + g := NewWithT(t) + + // Set config file location env variable + orig := os.Getenv("CLOUDCTL_CONFIG") + os.Setenv("CLOUDCTL_CONFIG", "A") + + t.Cleanup(func() { + viper.Reset() + os.Setenv("CLOUDCTL_CONFIG", orig) + }) + + // Do the setup + err := setupConfig() + g.Expect(err).NotTo(BeNil()) +} From 667eb4bd0ee0c45f389196633fd3a56380a89183 Mon Sep 17 00:00:00 2001 From: Piotr Skamruk Date: Mon, 8 Jun 2026 11:26:18 +0200 Subject: [PATCH 3/3] docs: Add info about configuration reading Signed-off-by: Piotr Skamruk --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 3352f43..77ece81 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,22 @@ Use "cloudctl [command] --help" for more information about a command. ## Requirements and Setup Download the latest release from [here](https://github.com/cloudoperators/cloudctl/releases), move to a location in PATH and update file permissions. +## Passing configuration options +Configuration options for each command can be provided through command line parameters, environment variables (using `CLOUDCTL_` as a variable name prefix), and/or through configuration file. +Configuration file location is searched in that order (first found takes precedence): +* what is provided as a value for `--config` parameter +* what is provided as a value for `$CLOUDCTL_CONFIG` environment variable +* file paths: + * `./.cloudctl.yaml` + * `$HOME/.cloudctl.yaml` + * `./cloudctl.yaml` + * `$HOME/cloudctl.yaml` + * if `$XDG_CONFIG_HOME` is set: + * `$XDG_CONFIG_HOME/cloudctl/cloudctl.yaml` + * `$XDG_CONFIG_HOME/cloudctl.yaml` + * if not, finally falling back to: + * `$HOME/.config/cloudctl/cloudctl.yaml` + * `$HOME/.config/cloudctl.yaml` ## Support, Feedback, Contributing