Skip to content

Commit 8d43fc8

Browse files
authored
feat: Support for config file reading (#55)
1 parent 9120804 commit 8d43fc8

7 files changed

Lines changed: 212 additions & 15 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@ Use "cloudctl [command] --help" for more information about a command.
4141
## Requirements and Setup
4242
Download the latest release from [here](https://github.com/cloudoperators/cloudctl/releases), move to a location in PATH and update file permissions.
4343

44+
## Passing configuration options
45+
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.
46+
Configuration file location is searched in that order (first found takes precedence):
47+
* what is provided as a value for `--config` parameter
48+
* what is provided as a value for `$CLOUDCTL_CONFIG` environment variable
49+
* file paths:
50+
* `./.cloudctl.yaml`
51+
* `$HOME/.cloudctl.yaml`
52+
* `./cloudctl.yaml`
53+
* `$HOME/cloudctl.yaml`
54+
* if `$XDG_CONFIG_HOME` is set:
55+
* `$XDG_CONFIG_HOME/cloudctl/cloudctl.yaml`
56+
* `$XDG_CONFIG_HOME/cloudctl.yaml`
57+
* if not, finally falling back to:
58+
* `$HOME/.config/cloudctl/cloudctl.yaml`
59+
* `$HOME/.config/cloudctl.yaml`
4460

4561
## Support, Feedback, Contributing
4662

cmd/cluster-version.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strings"
1414

1515
"github.com/spf13/cobra"
16+
"github.com/spf13/viper"
1617
"k8s.io/apimachinery/pkg/version"
1718
"k8s.io/client-go/kubernetes"
1819
"k8s.io/client-go/rest"
@@ -31,6 +32,9 @@ var (
3132
)
3233

3334
func runClusterVersion(cmd *cobra.Command, args []string) error {
35+
// Use viper as a source of configuration
36+
kubeconfig = viper.GetString("kubeconfig")
37+
kubecontext = viper.GetString("context")
3438

3539
cfg, err := configWithContext(kubecontext, kubeconfig)
3640
if err != nil {
@@ -138,4 +142,9 @@ func getUnauthenticatedVersion(cfg *rest.Config) (*version.Info, error) {
138142
func init() {
139143
clusterVersionCmd.Flags().StringVarP(&kubeconfig, "kubeconfig", "k", clientcmd.RecommendedHomeFile, "kubeconfig file path")
140144
clusterVersionCmd.Flags().StringVarP(&kubecontext, "context", "c", "", "cluster version of the specified context in kubeconfig")
145+
146+
// BindPFlags can theroretically return an error if called with `nil` as an argument
147+
// which should never happened after at least one flag was defined. That's why the output
148+
// there is ignored.
149+
viper.BindPFlags(clusterVersionCmd.Flags())
141150
}

cmd/root.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ package cmd
55

66
import (
77
"context"
8+
"os"
9+
"path/filepath"
810

911
"github.com/spf13/cobra"
12+
"github.com/spf13/viper"
13+
1014
"k8s.io/client-go/rest"
1115
"k8s.io/client-go/tools/clientcmd"
1216
)
@@ -32,12 +36,26 @@ Examples:
3236
cloudctl version`,
3337
}
3438

39+
var (
40+
configFilePath string
41+
)
42+
3543
// Execute runs the CLI with the provided context.
3644
func Execute(ctx context.Context) error {
3745
return rootCmd.ExecuteContext(ctx)
3846
}
3947

4048
func init() {
49+
cobra.OnInitialize(func() {
50+
cobra.CheckErr(setupConfig())
51+
})
52+
rootCmd.PersistentFlags().StringVar(&configFilePath, "config", "", "Path to configuration file")
53+
54+
// BindPFlags can theroretically return an error if called with `nil` as an argument
55+
// which should never happened after at least one flag was defined. That's why the output
56+
// there is ignored.
57+
viper.BindPFlags(rootCmd.PersistentFlags())
58+
4159
// Add subcommands here
4260
rootCmd.AddCommand(syncCmd)
4361
rootCmd.AddCommand(clusterVersionCmd)
@@ -55,3 +73,64 @@ func configWithContext(contextName, kubeconfigPath string) (*rest.Config, error)
5573
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides)
5674
return cc.ClientConfig()
5775
}
76+
77+
func setupConfig() error {
78+
home, err := os.UserHomeDir()
79+
if err != nil {
80+
return err
81+
}
82+
83+
// Optionally read environment variables, config files, etc.
84+
viper.SetEnvPrefix("CLOUDCTL")
85+
viper.AutomaticEnv()
86+
87+
viper.SetConfigType("yaml")
88+
89+
configFilePath = viper.GetString("config")
90+
if len(configFilePath) > 0 {
91+
// Phase 1
92+
// First we are trying config provided as a command line parameter. Fail if there was an error
93+
// during reading configuration from this specified path.
94+
viper.SetConfigFile(configFilePath)
95+
return viper.ReadInConfig()
96+
} else {
97+
// Phase 2
98+
// Then we are searching for ".cloudctl.yaml" in current or home directory
99+
viper.AddConfigPath(".")
100+
viper.AddConfigPath(home)
101+
// NOTE: viper is automatically adding a file extension basing on the value of called above `SetConfigType`
102+
viper.SetConfigName(".cloudctl")
103+
}
104+
105+
err = viper.ReadInConfig()
106+
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
107+
// Phase 3
108+
// If reading config in above described locations failed, we are looking for configuration
109+
// in these locations:
110+
// locations set in PHASE 2:
111+
// ./cloudctl.yaml
112+
// $HOME/cloudctl.yaml
113+
// if $XDG_CONFIG_HOME is set:
114+
// $XDG_CONFIG_HOME/cloudctl/cloudctl.yaml
115+
// $XDG_CONFIG_HOME/cloudctl.yaml
116+
// else:
117+
// $HOME/.config/cloudctl/cloudctl.yaml
118+
// $HOME/.config/cloudctl.yaml
119+
// NOTE: viper is automatically adding a file extension basing on the value of called above `SetConfigType`
120+
viper.SetConfigName("cloudctl")
121+
if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); len(xdgConfig) > 0 {
122+
viper.AddConfigPath(filepath.Join(xdgConfig, "cloudctl"))
123+
viper.AddConfigPath(xdgConfig)
124+
} else {
125+
viper.AddConfigPath(filepath.Join(home, ".config", "cloudctl"))
126+
viper.AddConfigPath(filepath.Join(home, ".config"))
127+
}
128+
err = viper.ReadInConfig()
129+
// If configuration was not found in any of above listed locations - that's ok.
130+
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
131+
err = nil
132+
}
133+
}
134+
135+
return err
136+
}

cmd/root_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cmd
5+
6+
import (
7+
"os"
8+
"testing"
9+
10+
"github.com/spf13/viper"
11+
12+
. "github.com/onsi/gomega"
13+
)
14+
15+
var ConfigA = []byte(`kubeconfig: A
16+
config: B
17+
`)
18+
19+
func TestNotSetConfigPath(t *testing.T) {
20+
g := NewWithT(t)
21+
22+
t.Cleanup(func() { viper.Reset() })
23+
24+
// Ensure default config file location is not set
25+
config := rootCmd.PersistentFlags().Lookup("config")
26+
g.Expect(config).NotTo(BeNil())
27+
g.Expect(config.Value.String()).To(BeEmpty())
28+
29+
// ... and that does not lead to an error during configuration setup
30+
g.Expect(setupConfig()).To(BeNil())
31+
}
32+
33+
func TestConfigurationLoad(t *testing.T) {
34+
g := NewWithT(t)
35+
36+
f, err := os.CreateTemp("", "test_cloudctl_config")
37+
g.Expect(err).To(BeNil())
38+
t.Cleanup(func() { os.Remove(f.Name()) })
39+
40+
_, err = f.Write(ConfigA)
41+
g.Expect(err).To(BeNil())
42+
err = f.Close()
43+
g.Expect(err).To(BeNil())
44+
45+
// Set config file location env variable
46+
orig := os.Getenv("CLOUDCTL_CONFIG")
47+
os.Setenv("CLOUDCTL_CONFIG", f.Name())
48+
49+
t.Cleanup(func() {
50+
viper.Reset()
51+
os.Setenv("CLOUDCTL_CONFIG", orig)
52+
})
53+
54+
// Do the setup
55+
g.Expect(setupConfig()).To(BeNil())
56+
57+
// Check if config file variable was not overwriten with data from config file
58+
g.Expect(viper.GetString("config")).To(Equal(f.Name()))
59+
60+
// Check if `kubeconfig` variable was set to the value from temporary file
61+
g.Expect(viper.GetString("kubeconfig")).To(Equal("A"))
62+
}
63+
64+
func TestMissingConfigurationFile(t *testing.T) {
65+
g := NewWithT(t)
66+
67+
// Set config file location env variable
68+
orig := os.Getenv("CLOUDCTL_CONFIG")
69+
os.Setenv("CLOUDCTL_CONFIG", "A")
70+
71+
t.Cleanup(func() {
72+
viper.Reset()
73+
os.Setenv("CLOUDCTL_CONFIG", orig)
74+
})
75+
76+
// Do the setup
77+
err := setupConfig()
78+
g.Expect(err).NotTo(BeNil())
79+
}

cmd/sync.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
greenhousemetav1alpha1 "github.com/cloudoperators/greenhouse/api/meta/v1alpha1"
1919
"github.com/cloudoperators/greenhouse/api/v1alpha1"
2020
"github.com/spf13/cobra"
21+
"github.com/spf13/viper"
2122
"k8s.io/apimachinery/pkg/runtime"
2223
"k8s.io/client-go/rest"
2324
"k8s.io/client-go/tools/clientcmd"
@@ -56,6 +57,11 @@ func init() {
5657
syncCmd.Flags().StringVar(&kubeloginPath, "kubelogin-path", "kubelogin", "path to kubelogin command when using exec-plugin auth-type")
5758
syncCmd.Flags().StringSliceVar(&kubeloginExtraArgs, "kubelogin-extra-args", nil, "extra arguments to pass to kubelogin exec plugin")
5859
syncCmd.Flags().StringVar(&kubeloginTokenCacheDir, "kubelogin-token-cache-dir", "$(HOME)/.kube/cache/oidc-login", "token cache directory for kubelogin")
60+
61+
// BindPFlags can theroretically return an error if called with `nil` as an argument
62+
// which should never happened after at least one flag was defined. That's why the output
63+
// there is ignored.
64+
viper.BindPFlags(syncCmd.Flags())
5965
}
6066

6167
var syncCmd = &cobra.Command{
@@ -65,6 +71,19 @@ var syncCmd = &cobra.Command{
6571
}
6672

6773
func runSync(cmd *cobra.Command, args []string) error {
74+
// Use viper as a source of configuration
75+
greenhouseClusterKubeconfig = viper.GetString("greenhouse-cluster-kubeconfig")
76+
greenhouseClusterContext = viper.GetString("greenhouse-cluster-context")
77+
greenhouseClusterNamespace = viper.GetString("greenhouse-cluster-namespace")
78+
remoteClusterKubeconfig = viper.GetString("remote-cluster-kubeconfig")
79+
remoteClusterName = viper.GetString("remote-cluster-name")
80+
prefix = viper.GetString("prefix")
81+
mergeIdenticalUsers = viper.GetBool("merge-identical-users")
82+
authType = viper.GetString("auth-type")
83+
kubeloginPath = viper.GetString("kubelogin-path")
84+
kubeloginExtraArgs = viper.GetStringSlice("kubelogin-extra-args")
85+
kubeloginTokenCacheDir = viper.GetString("kubelogin-token-cache-dir")
86+
6887
if greenhouseClusterKubeconfig == "" {
6988
return fmt.Errorf("greenhouse cluster kubeconfig path is empty")
7089
}

cmd/version.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"runtime"
1010

1111
"github.com/spf13/cobra"
12+
"github.com/spf13/viper"
1213
)
1314

1415
var (
@@ -28,11 +29,6 @@ type versionInfo struct {
2829
Platform string `json:"platform"`
2930
}
3031

31-
var (
32-
versionShort bool
33-
versionJSON bool
34-
)
35-
3632
var versionCmd = &cobra.Command{
3733
Use: "version",
3834
Short: "Print the cloudctl version information",
@@ -46,7 +42,7 @@ var versionCmd = &cobra.Command{
4642
Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
4743
}
4844

49-
if versionJSON {
45+
if viper.GetBool("json") {
5046
b, err := json.MarshalIndent(info, "", " ")
5147
if err != nil {
5248
return err
@@ -55,7 +51,7 @@ var versionCmd = &cobra.Command{
5551
return nil
5652
}
5753

58-
if versionShort {
54+
if viper.GetBool("short") {
5955
fmt.Println(info.Version)
6056
return nil
6157
}
@@ -69,6 +65,11 @@ var versionCmd = &cobra.Command{
6965
}
7066

7167
func init() {
72-
versionCmd.Flags().BoolVar(&versionShort, "short", false, "print only the version number")
73-
versionCmd.Flags().BoolVar(&versionJSON, "json", false, "print version information as JSON")
68+
versionCmd.Flags().Bool("short", false, "print only the version number")
69+
versionCmd.Flags().Bool("json", false, "print version information as JSON")
70+
71+
// BindPFlags can theroretically return an error if called with `nil` as an argument
72+
// which should never happened after at least one flag was defined. That's why the output
73+
// there is ignored.
74+
viper.BindPFlags(versionCmd.Flags())
7475
}

main.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,12 @@ import (
1010
"os/signal"
1111
"syscall"
1212

13-
"github.com/spf13/viper"
14-
1513
"github.com/cloudoperators/cloudctl/cmd"
1614

1715
_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
1816
)
1917

2018
func main() {
21-
// Optionally read environment variables, config files, etc.
22-
viper.SetEnvPrefix("CLOUDCTL")
23-
viper.AutomaticEnv()
24-
2519
// Graceful cancellation on SIGINT/SIGTERM
2620
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
2721
defer stop()

0 commit comments

Comments
 (0)