diff --git a/docs/stackit_config.md b/docs/stackit_config.md index a20ee0235..7a5d45296 100644 --- a/docs/stackit_config.md +++ b/docs/stackit_config.md @@ -4,12 +4,12 @@ Provides functionality for CLI configuration options ### Synopsis -Provides functionality for CLI configuration options -The configuration is stored in a file in the user's config directory, which is OS dependent. -Windows: %APPDATA%\stackit -Linux: $XDG_CONFIG_HOME/stackit -macOS: $HOME/Library/Application Support/stackit -The configuration file is named `cli-config.json` and is created automatically in your first CLI run. +Provides functionality for CLI configuration options. +You can set and unset different configuration options via the "stackit config set" and "stackit config unset" commands. + +Additionally, you can configure the CLI to use different profiles, each with its own configuration. +Additional profiles can be configured via the "STACKIT_CLI_PROFILE" environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands. +The environment variable takes precedence over what is set via the commands. ``` stackit config [flags] @@ -35,6 +35,7 @@ stackit config [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit config list](./stackit_config_list.md) - Lists the current CLI configuration values +* [stackit config profile](./stackit_config_profile.md) - Manage the CLI configuration profiles * [stackit config set](./stackit_config_set.md) - Sets CLI configuration options * [stackit config unset](./stackit_config_unset.md) - Unsets CLI configuration options diff --git a/docs/stackit_config_profile.md b/docs/stackit_config_profile.md new file mode 100644 index 000000000..1c46aeb73 --- /dev/null +++ b/docs/stackit_config_profile.md @@ -0,0 +1,37 @@ +## stackit config profile + +Manage the CLI configuration profiles + +### Synopsis + +Manage the CLI configuration profiles. +The profile to be used can be managed via the "STACKIT_CLI_PROFILE" environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands. +The environment variable takes precedence over what is set via the commands. +When no profile is set, the default profile is used. + +``` +stackit config profile [flags] +``` + +### Options + +``` + -h, --help Help for "stackit config profile" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit config](./stackit_config.md) - Provides functionality for CLI configuration options +* [stackit config profile set](./stackit_config_profile_set.md) - Set a CLI configuration profile +* [stackit config profile unset](./stackit_config_profile_unset.md) - Unset the current active CLI configuration profile + diff --git a/docs/stackit_config_profile_set.md b/docs/stackit_config_profile_set.md new file mode 100644 index 000000000..bcc9725a6 --- /dev/null +++ b/docs/stackit_config_profile_set.md @@ -0,0 +1,43 @@ +## stackit config profile set + +Set a CLI configuration profile + +### Synopsis + +Set a CLI configuration profile as the active profile. +The profile to be used can be managed via the STACKIT_CLI_PROFILE environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands. +The environment variable takes precedence over what is set via the commands. +A new profile is created automatically if it does not exist. +When no profile is set, the default profile is used. + +``` +stackit config profile set PROFILE [flags] +``` + +### Examples + +``` + Set the configuration profile "my-profile" as the active profile + $ stackit config profile set my-profile +``` + +### Options + +``` + -h, --help Help for "stackit config profile set" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit config profile](./stackit_config_profile.md) - Manage the CLI configuration profiles + diff --git a/docs/stackit_config_profile_unset.md b/docs/stackit_config_profile_unset.md new file mode 100644 index 000000000..0beda0f67 --- /dev/null +++ b/docs/stackit_config_profile_unset.md @@ -0,0 +1,40 @@ +## stackit config profile unset + +Unset the current active CLI configuration profile + +### Synopsis + +Unset the current active CLI configuration profile. +When no profile is set, the default profile will be used. + +``` +stackit config profile unset [flags] +``` + +### Examples + +``` + Unset the currently active configuration profile. The default profile will be used. + $ stackit config profile unset +``` + +### Options + +``` + -h, --help Help for "stackit config profile unset" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit config profile](./stackit_config_profile.md) - Manage the CLI configuration profiles + diff --git a/docs/stackit_load-balancer_observability-credentials_cleanup.md b/docs/stackit_load-balancer_observability-credentials_cleanup.md index f22f88994..6f72a76c2 100644 --- a/docs/stackit_load-balancer_observability-credentials_cleanup.md +++ b/docs/stackit_load-balancer_observability-credentials_cleanup.md @@ -35,5 +35,4 @@ stackit load-balancer observability-credentials cleanup [flags] ### SEE ALSO -* [stackit load-balancer observability-credentials](./stackit_load-balancer_observability-credentials.md) - Provides functionality for Load Balancer observability credentials - +- [stackit load-balancer observability-credentials](./stackit_load-balancer_observability-credentials.md) - Provides functionality for Load Balancer observability credentials diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 7c3fea56f..a96355a54 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stackitcloud/stackit-cli/internal/cmd/config/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile" "github.com/stackitcloud/stackit-cli/internal/cmd/config/set" "github.com/stackitcloud/stackit-cli/internal/cmd/config/unset" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -17,12 +18,12 @@ func NewCmd(p *print.Printer) *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "Provides functionality for CLI configuration options", - Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", "Provides functionality for CLI configuration options", - "The configuration is stored in a file in the user's config directory, which is OS dependent.", - "Windows: %APPDATA%\\stackit", - "Linux: $XDG_CONFIG_HOME/stackit", - "macOS: $HOME/Library/Application Support/stackit", - "The configuration file is named `cli-config.json` and is created automatically in your first CLI run.", + Long: fmt.Sprintf("%s\n%s\n\n%s\n%s\n%s", + "Provides functionality for CLI configuration options.", + `You can set and unset different configuration options via the "stackit config set" and "stackit config unset" commands.`, + "Additionally, you can configure the CLI to use different profiles, each with its own configuration.", + `Additional profiles can be configured via the "STACKIT_CLI_PROFILE" environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands.`, + "The environment variable takes precedence over what is set via the commands.", ), Args: args.NoArgs, Run: utils.CmdHelp, @@ -35,4 +36,5 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(list.NewCmd(p)) cmd.AddCommand(set.NewCmd(p)) cmd.AddCommand(unset.NewCmd(p)) + cmd.AddCommand(profile.NewCmd(p)) } diff --git a/internal/cmd/config/list/list.go b/internal/cmd/config/list/list.go index 560e1dd89..8ba6160f4 100644 --- a/internal/cmd/config/list/list.go +++ b/internal/cmd/config/list/list.go @@ -51,7 +51,13 @@ func NewCmd(p *print.Printer) *cobra.Command { configData := viper.AllSettings() model := parseInput(p, cmd) - return outputResult(p, model.OutputFormat, configData) + + activeProfile, err := config.GetProfile() + if err != nil { + return fmt.Errorf("get profile: %w", err) + } + + return outputResult(p, model.OutputFormat, configData, activeProfile) }, } return cmd @@ -65,9 +71,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { } } -func outputResult(p *print.Printer, outputFormat string, configData map[string]any) error { +func outputResult(p *print.Printer, outputFormat string, configData map[string]any, activeProfile string) error { switch outputFormat { case print.JSONOutputFormat: + if activeProfile != "" { + configData["profile"] = activeProfile + } details, err := json.MarshalIndent(configData, "", " ") if err != nil { return fmt.Errorf("marshal config list: %w", err) @@ -83,6 +92,7 @@ func outputResult(p *print.Printer, outputFormat string, configData map[string]a return nil default: + // Sort the config options by key configKeys := make([]string, 0, len(configData)) for k := range configData { @@ -91,6 +101,9 @@ func outputResult(p *print.Printer, outputFormat string, configData map[string]a sort.Strings(configKeys) table := tables.NewTable() + if activeProfile != "" { + table.SetTitle(fmt.Sprintf("Profile: %q", activeProfile)) + } table.SetHeader("NAME", "VALUE") for _, key := range configKeys { value := configData[key] diff --git a/internal/cmd/config/profile/profile.go b/internal/cmd/config/profile/profile.go new file mode 100644 index 000000000..42bea8e58 --- /dev/null +++ b/internal/cmd/config/profile/profile.go @@ -0,0 +1,35 @@ +package profile + +import ( + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/set" + "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/unset" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "profile", + Short: "Manage the CLI configuration profiles", + Long: fmt.Sprintf("%s\n%s\n%s\n%s", + "Manage the CLI configuration profiles.", + `The profile to be used can be managed via the "STACKIT_CLI_PROFILE" environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands.`, + "The environment variable takes precedence over what is set via the commands.", + "When no profile is set, the default profile is used.", + ), + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(set.NewCmd(p)) + cmd.AddCommand(unset.NewCmd(p)) +} diff --git a/internal/cmd/config/profile/set/set.go b/internal/cmd/config/profile/set/set.go new file mode 100644 index 000000000..3594883fe --- /dev/null +++ b/internal/cmd/config/profile/set/set.go @@ -0,0 +1,94 @@ +package set + +import ( + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/cobra" +) + +const ( + profileArg = "PROFILE" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Profile string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("set %s", profileArg), + Short: "Set a CLI configuration profile", + Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s", + "Set a CLI configuration profile as the active profile.", + `The profile to be used can be managed via the STACKIT_CLI_PROFILE environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands.`, + "The environment variable takes precedence over what is set via the commands.", + "A new profile is created automatically if it does not exist.", + "When no profile is set, the default profile is used.", + ), + Args: args.SingleArg(profileArg, nil), + Example: examples.Build( + examples.NewExample( + `Set the configuration profile "my-profile" as the active profile`, + "$ stackit config profile set my-profile"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + err = config.SetProfile(p, model.Profile) + if err != nil { + return fmt.Errorf("set profile: %w", err) + } + + p.Info("Successfully set active profile to %q\n", model.Profile) + + flow, err := auth.GetAuthFlow() + if err != nil { + p.Debug(print.WarningLevel, "both keyring and text file storage failed to find a valid authentication flow for the active profile") + p.Warn("The active profile %q is not authenticated, please login using the 'stackit auth login' command.\n", model.Profile) + return nil + } + p.Debug(print.DebugLevel, "found valid authentication flow for active profile: %s", flow) + + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + profile := inputArgs[0] + + err := config.ValidateProfile(profile) + if err != nil { + return nil, err + } + + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Profile: profile, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} diff --git a/internal/cmd/config/profile/set/set_test.go b/internal/cmd/config/profile/set/set_test.go new file mode 100644 index 000000000..47f56ca0b --- /dev/null +++ b/internal/cmd/config/profile/set/set_test.go @@ -0,0 +1,132 @@ +package set + +import ( + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" +) + +const testProfile = "test-profile" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testProfile, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + Profile: testProfile, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + isValid: false, + }, + { + description: "some global flag", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.VerbosityFlag: globalflags.DebugVerbosity, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.GlobalFlagModel.Verbosity = globalflags.DebugVerbosity + }), + }, + { + description: "invalid profile", + argValues: []string{"invalid-profile-&"}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/config/profile/unset/unset.go b/internal/cmd/config/profile/unset/unset.go new file mode 100644 index 000000000..f767cbfe4 --- /dev/null +++ b/internal/cmd/config/profile/unset/unset.go @@ -0,0 +1,49 @@ +package unset + +import ( + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/cobra" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "unset", + Short: "Unset the current active CLI configuration profile", + Long: fmt.Sprintf("%s\n%s", + "Unset the current active CLI configuration profile.", + "When no profile is set, the default profile will be used.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Unset the currently active configuration profile. The default profile will be used.`, + "$ stackit config profile unset"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + err := config.UnsetProfile(p) + if err != nil { + return fmt.Errorf("unset profile: %w", err) + } + + p.Info("Profile unset successfully. The default profile will be used.\n") + + flow, err := auth.GetAuthFlow() + if err != nil { + p.Debug(print.WarningLevel, "both keyring and text file storage failed to find a valid authentication flow for the active profile") + p.Warn("The default profile is not authenticated, please login using the 'stackit auth login' command.\n") + return nil + } + p.Debug(print.DebugLevel, "found valid authentication flow for active profile: %s", flow) + + return nil + }, + } + return cmd +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 5845d0c80..8f4193142 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -8,7 +8,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/argus" "github.com/stackitcloud/stackit-cli/internal/cmd/auth" - "github.com/stackitcloud/stackit-cli/internal/cmd/config" + configCmd "github.com/stackitcloud/stackit-cli/internal/cmd/config" "github.com/stackitcloud/stackit-cli/internal/cmd/curl" "github.com/stackitcloud/stackit-cli/internal/cmd/dns" loadbalancer "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer" @@ -26,6 +26,7 @@ import ( serviceaccount "github.com/stackitcloud/stackit-cli/internal/cmd/service-account" "github.com/stackitcloud/stackit-cli/internal/cmd/ske" "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -44,7 +45,7 @@ func NewRootCmd(version, date string, p *print.Printer) *cobra.Command { SilenceErrors: true, // Error is beautified in a custom way before being printed SilenceUsage: true, DisableAutoGenTag: true, - PersistentPreRun: func(cmd *cobra.Command, args []string) { + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { p.Cmd = cmd p.Verbosity = print.Level(globalflags.Parse(p, cmd).Verbosity) @@ -52,11 +53,22 @@ func NewRootCmd(version, date string, p *print.Printer) *cobra.Command { p.Debug(print.DebugLevel, "arguments: %s", argsString) configFilePath := viper.ConfigFileUsed() - p.Debug(print.DebugLevel, "using config file: %s", configFilePath) + p.Debug(print.DebugLevel, "configuration is persisted and read from: %s", configFilePath) + + activeProfile, err := config.GetProfile() + if err != nil { + return fmt.Errorf("get profile: %w", err) + } + if activeProfile == "" { + activeProfile = "(no active profile, the default profile configuration will be used)" + } + p.Debug(print.DebugLevel, "active configuration profile: %s", activeProfile) configKeys := viper.AllSettings() configKeysStr := print.BuildDebugStrFromMap(configKeys) - p.Debug(print.DebugLevel, "config keys: %s", configKeysStr) + p.Debug(print.DebugLevel, "configuration keys: %s", configKeysStr) + + return nil }, RunE: func(cmd *cobra.Command, args []string) error { if flags.FlagToBoolValue(p, cmd, "version") { @@ -104,7 +116,7 @@ func configureFlags(cmd *cobra.Command) error { func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(argus.NewCmd(p)) cmd.AddCommand(auth.NewCmd(p)) - cmd.AddCommand(config.NewCmd(p)) + cmd.AddCommand(configCmd.NewCmd(p)) cmd.AddCommand(curl.NewCmd(p)) cmd.AddCommand(dns.NewCmd(p)) cmd.AddCommand(loadbalancer.NewCmd(p)) diff --git a/internal/pkg/auth/storage.go b/internal/pkg/auth/storage.go index 9d08ca070..73bef1189 100644 --- a/internal/pkg/auth/storage.go +++ b/internal/pkg/auth/storage.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/zalando/go-keyring" ) @@ -58,9 +60,14 @@ func SetAuthFieldMap(keyMap map[authFieldKey]string) error { } func SetAuthField(key authFieldKey, value string) error { - err := setAuthFieldInKeyring(key, value) + activeProfile, err := config.GetProfile() if err != nil { - errFallback := setAuthFieldInEncodedTextFile(key, value) + return fmt.Errorf("get profile: %w", err) + } + + err = setAuthFieldInKeyring(activeProfile, key, value) + if err != nil { + errFallback := setAuthFieldInEncodedTextFile(activeProfile, key, value) if errFallback != nil { return fmt.Errorf("write to keyring failed (%w), try writing to encoded text file: %w", err, errFallback) } @@ -68,12 +75,16 @@ func SetAuthField(key authFieldKey, value string) error { return nil } -func setAuthFieldInKeyring(key authFieldKey, value string) error { +func setAuthFieldInKeyring(activeProfile string, key authFieldKey, value string) error { + if activeProfile != "" { + activeProfileKeyring := filepath.Join(keyringService, activeProfile) + return keyring.Set(activeProfileKeyring, string(key), value) + } return keyring.Set(keyringService, string(key), value) } -func setAuthFieldInEncodedTextFile(key authFieldKey, value string) error { - err := createEncodedTextFile() +func setAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey, value string) error { + err := createEncodedTextFile(activeProfile) if err != nil { return err } @@ -82,7 +93,13 @@ func setAuthFieldInEncodedTextFile(key authFieldKey, value string) error { if err != nil { return fmt.Errorf("get config dir: %w", err) } - textFileDir := filepath.Join(configDir, textFileFolderName) + + profileTextFileFolderName := textFileFolderName + if activeProfile != "" { + profileTextFileFolderName = filepath.Join(textFileFolderName, activeProfile) + } + + textFileDir := filepath.Join(configDir, profileTextFileFolderName) textFilePath := filepath.Join(textFileDir, textFileName) contentEncoded, err := os.ReadFile(textFilePath) @@ -131,10 +148,15 @@ func GetAuthFlow() (AuthFlow, error) { } func GetAuthField(key authFieldKey) (string, error) { - value, err := getAuthFieldFromKeyring(key) + activeProfile, err := config.GetProfile() + if err != nil { + return "", fmt.Errorf("get profile: %w", err) + } + + value, err := getAuthFieldFromKeyring(activeProfile, key) if err != nil { var errFallback error - value, errFallback = getAuthFieldFromEncodedTextFile(key) + value, errFallback = getAuthFieldFromEncodedTextFile(activeProfile, key) if errFallback != nil { return "", fmt.Errorf("read from keyring: %w, read from encoded file as fallback: %w", err, errFallback) } @@ -142,12 +164,16 @@ func GetAuthField(key authFieldKey) (string, error) { return value, nil } -func getAuthFieldFromKeyring(key authFieldKey) (string, error) { +func getAuthFieldFromKeyring(activeProfile string, key authFieldKey) (string, error) { + if activeProfile != "" { + activeProfileKeyring := filepath.Join(keyringService, activeProfile) + return keyring.Get(activeProfileKeyring, string(key)) + } return keyring.Get(keyringService, string(key)) } -func getAuthFieldFromEncodedTextFile(key authFieldKey) (string, error) { - err := createEncodedTextFile() +func getAuthFieldFromEncodedTextFile(activeProfile string, key authFieldKey) (string, error) { + err := createEncodedTextFile(activeProfile) if err != nil { return "", err } @@ -156,7 +182,13 @@ func getAuthFieldFromEncodedTextFile(key authFieldKey) (string, error) { if err != nil { return "", fmt.Errorf("get config dir: %w", err) } - textFileDir := filepath.Join(configDir, textFileFolderName) + + profileTextFileFolderName := textFileFolderName + if activeProfile != "" { + profileTextFileFolderName = filepath.Join(textFileFolderName, activeProfile) + } + + textFileDir := filepath.Join(configDir, profileTextFileFolderName) textFilePath := filepath.Join(textFileDir, textFileName) contentEncoded, err := os.ReadFile(textFilePath) @@ -182,12 +214,18 @@ func getAuthFieldFromEncodedTextFile(key authFieldKey) (string, error) { // Checks if the encoded text file exist. // If it doesn't, creates it with the content "{}" encoded. // If it does, does nothing (and returns nil). -func createEncodedTextFile() error { +func createEncodedTextFile(activeProfile string) error { configDir, err := os.UserConfigDir() if err != nil { return fmt.Errorf("get config dir: %w", err) } - textFileDir := filepath.Join(configDir, textFileFolderName) + + profileTextFileFolderName := textFileFolderName + if activeProfile != "" { + profileTextFileFolderName = filepath.Join(textFileFolderName, activeProfile) + } + + textFileDir := filepath.Join(configDir, profileTextFileFolderName) textFilePath := filepath.Join(textFileDir, textFileName) err = os.MkdirAll(textFileDir, os.ModePerm) diff --git a/internal/pkg/auth/storage_test.go b/internal/pkg/auth/storage_test.go index 1d3d4dab9..4d1593020 100644 --- a/internal/pkg/auth/storage_test.go +++ b/internal/pkg/auth/storage_test.go @@ -10,6 +10,8 @@ import ( "time" "github.com/zalando/go-keyring" + + "github.com/stackitcloud/stackit-cli/internal/pkg/config" ) func TestSetGetAuthField(t *testing.T) { @@ -113,6 +115,11 @@ func TestSetGetAuthField(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { + activeProfile, err := config.GetProfile() + if err != nil { + t.Errorf("get profile: %v", err) + } + if !tt.keyringFails { keyring.MockInit() } else { @@ -140,12 +147,12 @@ func TestSetGetAuthField(t *testing.T) { } if !tt.keyringFails { - err = deleteAuthFieldInKeyring(key) + err = deleteAuthFieldInKeyring(activeProfile, key) if err != nil { t.Errorf("Post-test cleanup failed: remove field \"%s\" from keyring: %v. Please remove it manually", key, err) } } else { - err = deleteAuthFieldInEncodedTextFile(key) + err = deleteAuthFieldInEncodedTextFile(activeProfile, key) if err != nil { t.Errorf("Post-test cleanup failed: remove field \"%s\" from text file: %v. Please remove it manually", key, err) } @@ -172,9 +179,11 @@ func TestSetGetAuthFieldKeyring(t *testing.T) { description string valueAssignments []valueAssignment expectedValues map[authFieldKey]string + activeProfile string }{ { - description: "simple assignments", + description: "simple assignments with default profile", + activeProfile: "", valueAssignments: []valueAssignment{ { key: testField1, @@ -191,7 +200,48 @@ func TestSetGetAuthFieldKeyring(t *testing.T) { }, }, { - description: "overlapping assignments", + description: "overlapping assignments with default profile", + activeProfile: "", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + { + key: testField1, + value: testValue3, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue3, + testField2: testValue2, + }, + }, + { + description: "simple assignments with testProfile", + activeProfile: "testProfile", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue1, + testField2: testValue2, + }, + }, + { + description: "overlapping assignments with testProfile", + activeProfile: "testProfile", valueAssignments: []valueAssignment{ { key: testField1, @@ -218,7 +268,7 @@ func TestSetGetAuthFieldKeyring(t *testing.T) { keyring.MockInit() for _, assignment := range tt.valueAssignments { - err := setAuthFieldInKeyring(assignment.key, assignment.value) + err := setAuthFieldInKeyring(tt.activeProfile, assignment.key, assignment.value) if err != nil { t.Fatalf("Failed to set \"%s\" as \"%s\": %v", assignment.key, assignment.value, err) } @@ -229,7 +279,7 @@ func TestSetGetAuthFieldKeyring(t *testing.T) { } for key, valueExpected := range tt.expectedValues { - value, err := getAuthFieldFromKeyring(key) + value, err := getAuthFieldFromKeyring(tt.activeProfile, key) if err != nil { t.Errorf("Failed to get value of \"%s\": %v", key, err) continue @@ -237,7 +287,7 @@ func TestSetGetAuthFieldKeyring(t *testing.T) { t.Errorf("Value of field \"%s\" is wrong: expected \"%s\", got \"%s\"", key, valueExpected, value) } - err = deleteAuthFieldInKeyring(key) + err = deleteAuthFieldInKeyring(tt.activeProfile, key) if err != nil { t.Errorf("Post-test cleanup failed: remove field \"%s\" from keyring: %v. Please remove it manually", key, err) } @@ -261,11 +311,13 @@ func TestSetGetAuthFieldEncodedTextFile(t *testing.T) { tests := []struct { description string + activeProfile string valueAssignments []valueAssignment expectedValues map[authFieldKey]string }{ { - description: "simple assignments", + description: "simple assignments with default profile", + activeProfile: "", valueAssignments: []valueAssignment{ { key: testField1, @@ -282,7 +334,48 @@ func TestSetGetAuthFieldEncodedTextFile(t *testing.T) { }, }, { - description: "overlapping assignments", + description: "overlapping assignments with default profile", + activeProfile: "", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + { + key: testField1, + value: testValue3, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue3, + testField2: testValue2, + }, + }, + { + description: "simple assignments with testProfile", + activeProfile: "testProfile", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue1, + testField2: testValue2, + }, + }, + { + description: "overlapping assignments with testProfile", + activeProfile: "testProfile", valueAssignments: []valueAssignment{ { key: testField1, @@ -307,7 +400,7 @@ func TestSetGetAuthFieldEncodedTextFile(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { for _, assignment := range tt.valueAssignments { - err := setAuthFieldInEncodedTextFile(assignment.key, assignment.value) + err := setAuthFieldInEncodedTextFile(tt.activeProfile, assignment.key, assignment.value) if err != nil { t.Fatalf("Failed to set \"%s\" as \"%s\": %v", assignment.key, assignment.value, err) } @@ -318,7 +411,7 @@ func TestSetGetAuthFieldEncodedTextFile(t *testing.T) { } for key, valueExpected := range tt.expectedValues { - value, err := getAuthFieldFromEncodedTextFile(key) + value, err := getAuthFieldFromEncodedTextFile(tt.activeProfile, key) if err != nil { t.Errorf("Failed to get value of \"%s\": %v", key, err) continue @@ -326,7 +419,7 @@ func TestSetGetAuthFieldEncodedTextFile(t *testing.T) { t.Errorf("Value of field \"%s\" is wrong: expected \"%s\", got \"%s\"", key, valueExpected, value) } - err = deleteAuthFieldInEncodedTextFile(key) + err = deleteAuthFieldInEncodedTextFile(tt.activeProfile, key) if err != nil { t.Errorf("Post-test cleanup failed: remove field \"%s\" from text file: %v. Please remove it manually", key, err) } @@ -335,12 +428,17 @@ func TestSetGetAuthFieldEncodedTextFile(t *testing.T) { } } -func deleteAuthFieldInKeyring(key authFieldKey) error { +func deleteAuthFieldInKeyring(activeProfile string, key authFieldKey) error { + if activeProfile != "" { + activeProfileKeyring := filepath.Join(keyringService, activeProfile) + return keyring.Delete(activeProfileKeyring, string(key)) + } + return keyring.Delete(keyringService, string(key)) } -func deleteAuthFieldInEncodedTextFile(key authFieldKey) error { - err := createEncodedTextFile() +func deleteAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey) error { + err := createEncodedTextFile(activeProfile) if err != nil { return err } @@ -349,7 +447,13 @@ func deleteAuthFieldInEncodedTextFile(key authFieldKey) error { if err != nil { return fmt.Errorf("get config dir: %w", err) } - textFileDir := filepath.Join(configDir, textFileFolderName) + + profileTextFileFolderName := textFileFolderName + if activeProfile != "" { + profileTextFileFolderName = filepath.Join(textFileFolderName, activeProfile) + } + + textFileDir := filepath.Join(configDir, profileTextFileFolderName) textFilePath := filepath.Join(textFileDir, textFileName) contentEncoded, err := os.ReadFile(textFilePath) diff --git a/internal/pkg/cache/cache.go b/internal/pkg/cache/cache.go index abc81787d..d52670fd4 100644 --- a/internal/pkg/cache/cache.go +++ b/internal/pkg/cache/cache.go @@ -43,7 +43,7 @@ func PutObject(identifier string, data []byte) error { return ErrorInvalidCacheIdentifier } - err := createFolderIfNotExists(cacheFolderPath) + err := os.MkdirAll(cacheFolderPath, os.ModePerm) if err != nil { return err } @@ -65,19 +65,6 @@ func DeleteObject(identifier string) error { return nil } -func createFolderIfNotExists(folderPath string) error { - _, err := os.Stat(folderPath) - if os.IsNotExist(err) { - err := os.MkdirAll(folderPath, os.ModePerm) - if err != nil { - return err - } - } else if err != nil { - return err - } - return nil -} - func validateCacheFolderPath() error { if cacheFolderPath == "" { return errors.New("cacheFolderPath not set. Forgot to call Init()?") diff --git a/internal/pkg/cache/cache_test.go b/internal/pkg/cache/cache_test.go index 9cdf34ce4..4ea003116 100644 --- a/internal/pkg/cache/cache_test.go +++ b/internal/pkg/cache/cache_test.go @@ -46,7 +46,7 @@ func TestGetObject(t *testing.T) { // setup if tt.expectFile { - err := createFolderIfNotExists(cacheFolderPath) + err := os.MkdirAll(cacheFolderPath, os.ModePerm) if err != nil { t.Fatalf("create cache folder: %s", err.Error()) } diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index fa367857d..b9abf9b31 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -34,16 +34,20 @@ const ( ServiceAccountCustomEndpointKey = "service_account_custom_endpoint" SKECustomEndpointKey = "ske_custom_endpoint" + ProjectNameKey = "project_name" + AsyncDefault = false SessionTimeLimitDefault = "2h" ) -// Backend config keys const ( - configFolder = "stackit" + configFolder = "stackit" + configFileName = "cli-config" configFileExtension = "json" - ProjectNameKey = "project_name" + + profileFileName = "cli-profile" + profileFileExtension = "txt" ) var ConfigKeys = []string{ @@ -72,16 +76,26 @@ var ConfigKeys = []string{ SKECustomEndpointKey, } -var folderPath string +var defaultConfigFolderPath string +var configFolderPath string +var profileFilePath string func InitConfig() { configDir, err := os.UserConfigDir() cobra.CheckErr(err) - configFolderPath := filepath.Join(configDir, configFolder) - configFilePath := filepath.Join(configFolderPath, fmt.Sprintf("%s.%s", configFileName, configFileExtension)) - // Write config dir path to global variable - folderPath = configFolderPath + defaultConfigFolderPath = filepath.Join(configDir, configFolder) + profileFilePath = filepath.Join(defaultConfigFolderPath, fmt.Sprintf("%s.%s", profileFileName, profileFileExtension)) // Profile file path is in the default config folder + + configProfile, err := GetProfile() + cobra.CheckErr(err) + + configFolderPath = defaultConfigFolderPath + if configProfile != "" { + configFolderPath = filepath.Join(configFolderPath, configProfile) // If a profile is set, use the profile config folder + } + + configFilePath := filepath.Join(configFolderPath, fmt.Sprintf("%s.%s", configFileName, configFileExtension)) // This hack is required to allow creating the config file with `viper.WriteConfig` // see https://github.com/spf13/viper/issues/851#issuecomment-789393451 @@ -107,22 +121,10 @@ func InitConfig() { viper.SetEnvPrefix("stackit") } -func createFolderIfNotExists() error { - _, err := os.Stat(folderPath) - if os.IsNotExist(err) { - err := os.MkdirAll(folderPath, os.ModePerm) - if err != nil { - return err - } - } else if err != nil { - return err - } - return nil -} - // Write saves the config file (wrapping `viper.WriteConfig`) and ensures that its directory exists func Write() error { - if err := createFolderIfNotExists(); err != nil { + err := os.MkdirAll(configFolderPath, os.ModePerm) + if err != nil { return fmt.Errorf("create config directory: %w", err) } return viper.WriteConfig() diff --git a/internal/pkg/config/file_utils.go b/internal/pkg/config/file_utils.go new file mode 100644 index 000000000..0895a9dc5 --- /dev/null +++ b/internal/pkg/config/file_utils.go @@ -0,0 +1,26 @@ +package config + +import ( + "fmt" + "os" +) + +// readFileIfExists reads the contents of a file and returns it as a string, along with a boolean indicating if the file exists. +// If the file does not exist, it returns an empty string and no error. +// If the file exists but cannot be read, it returns an error. +func readFileIfExists(filePath string) (contents string, exists bool, err error) { + _, err = os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", true, err + } + + content, err := os.ReadFile(filePath) + if err != nil { + return "", true, fmt.Errorf("read file: %w", err) + } + + return string(content), true, nil +} diff --git a/internal/pkg/config/file_utils_test.go b/internal/pkg/config/file_utils_test.go new file mode 100644 index 000000000..d7f299cb6 --- /dev/null +++ b/internal/pkg/config/file_utils_test.go @@ -0,0 +1,47 @@ +package config + +import ( + "testing" +) + +func TestReadFileIfExists(t *testing.T) { + tests := []struct { + description string + filePath string + exists bool + content string + }{ + { + description: "file exists", + filePath: "test-data/file-with-content.txt", + exists: true, + content: "my-content", + }, + { + description: "file does not exist", + filePath: "test-data/file-does-not-exist.txt", + content: "", + }, + { + description: "empty file", + filePath: "test-data/empty-file.txt", + exists: true, + content: "", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + content, exists, err := readFileIfExists(tt.filePath) + if err != nil { + t.Errorf("read file: %v", err) + } + if exists != tt.exists { + t.Errorf("expected exists to be %t but got %t", tt.exists, exists) + } + if content != tt.content { + t.Errorf("expected content to be %q but got %q", tt.content, content) + } + }) + } +} diff --git a/internal/pkg/config/profiles.go b/internal/pkg/config/profiles.go new file mode 100644 index 000000000..ff07fbd7b --- /dev/null +++ b/internal/pkg/config/profiles.go @@ -0,0 +1,90 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +// GetProfile returns the current profile to be used by the CLI. +// +// The profile is determined by the value of the STACKIT_CLI_PROFILE environment variable, or, if not set, +// by the contents of the profile file in the CLI config folder. +// +// If the environment variable is not set and the profile file does not exist, it returns an empty string. +// +// If the profile is not valid, it returns an error. +func GetProfile() (string, error) { + profile, profileSet := os.LookupEnv("STACKIT_CLI_PROFILE") + if !profileSet { + contents, exists, err := readFileIfExists(profileFilePath) + if err != nil { + return "", fmt.Errorf("read profile from file: %w", err) + } + if !exists { + return "", nil + } + profile = contents + } + + err := ValidateProfile(profile) + if err != nil { + return "", fmt.Errorf("validate profile: %w", err) + } + return profile, nil +} + +// SetProfile sets the profile to be used by the CLI. +func SetProfile(p *print.Printer, profile string) error { + err := ValidateProfile(profile) + if err != nil { + return fmt.Errorf("validate profile: %w", err) + } + + err = os.WriteFile(profileFilePath, []byte(profile), os.ModePerm) + if err != nil { + return fmt.Errorf("write profile to file: %w", err) + } + p.Debug(print.DebugLevel, "persisted new active profile in: %s", profileFilePath) + + configFolderPath = filepath.Join(defaultConfigFolderPath, profile) + err = os.MkdirAll(configFolderPath, os.ModePerm) + if err != nil { + return fmt.Errorf("create config folder: %w", err) + } + p.Debug(print.DebugLevel, "created folder for the new profile: %s", configFolderPath) + p.Debug(print.DebugLevel, "profile %q is now active", profile) + + return nil +} + +// UnsetProfile removes the profile file. +// If the profile file does not exist, it does nothing. +func UnsetProfile(p *print.Printer) error { + err := os.Remove(profileFilePath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove profile file: %w", err) + } + p.Debug(print.DebugLevel, "removed active profile file: %s", profileFilePath) + return nil +} + +// ValidateProfile validates the profile name. +// It can only use letters, numbers, or "-" and cannot be empty. +// If the profile is invalid, it returns an error. +func ValidateProfile(profile string) error { + match, err := regexp.MatchString("^[a-zA-Z0-9-]+$", profile) + if err != nil { + return fmt.Errorf("match string regex: %w", err) + } + if !match { + return &errors.InvalidProfileNameError{ + Profile: profile, + } + } + return nil +} diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go new file mode 100644 index 000000000..e97451aeb --- /dev/null +++ b/internal/pkg/config/profiles_test.go @@ -0,0 +1,59 @@ +package config + +import "testing" + +func TestValidateProfile(t *testing.T) { + tests := []struct { + description string + profile string + isValid bool + }{ + { + description: "valid profile with letters", + profile: "myprofile", + isValid: true, + }, + { + description: "valid profile with uppercase letters", + profile: "myProfile", + isValid: true, + }, + { + description: "valid with letters and hyphen", + profile: "my-profile", + isValid: true, + }, + { + description: "valid with letters, numbers, and hyphen", + profile: "my-profile-123", + isValid: true, + }, + { + description: "invalid empty", + profile: "", + isValid: false, + }, + { + description: "invalid with special characters", + profile: "my_profile", + isValid: false, + }, + { + description: "invalid with spaces", + profile: "my profile", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := ValidateProfile(tt.profile) + if tt.isValid && err != nil { + t.Errorf("expected profile to be valid but got error: %v", err) + } + if !tt.isValid && err == nil { + t.Errorf("expected profile to be invalid but got no error") + } + }) + } +} diff --git a/internal/pkg/config/test-data/empty-file.txt b/internal/pkg/config/test-data/empty-file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/internal/pkg/config/test-data/file-with-content.txt b/internal/pkg/config/test-data/file-with-content.txt new file mode 100644 index 000000000..d89fbd214 --- /dev/null +++ b/internal/pkg/config/test-data/file-with-content.txt @@ -0,0 +1 @@ +my-content \ No newline at end of file diff --git a/internal/pkg/config/test-data/folder-exists/dummy.txt b/internal/pkg/config/test-data/folder-exists/dummy.txt new file mode 100644 index 000000000..e69de29bb diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index 6b127bf67..1cfe6bce8 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -115,6 +115,10 @@ For more details on the available storages for the configured flavor (%[3]s), ru SUBCOMMAND_MISSING = `missing subcommand` + INVALID_PROFILE_NAME = `the profile name %q is invalid. + +The profile name can only contain letters, numbers, and "-" and cannot be empty.` + USAGE_TIP = `For usage help, run: $ %s --help` ) @@ -314,3 +318,11 @@ func AppendUsageTip(err error, cmd *cobra.Command) error { tip := fmt.Sprintf(USAGE_TIP, cmd.CommandPath()) return fmt.Errorf("%w.\n\n%s", err, tip) } + +type InvalidProfileNameError struct { + Profile string +} + +func (e *InvalidProfileNameError) Error() string { + return fmt.Sprintf(INVALID_PROFILE_NAME, e.Profile) +} diff --git a/internal/pkg/print/print.go b/internal/pkg/print/print.go index 205836393..649590c7c 100644 --- a/internal/pkg/print/print.go +++ b/internal/pkg/print/print.go @@ -15,7 +15,7 @@ import ( "github.com/mattn/go-colorable" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "golang.org/x/term" ) @@ -27,6 +27,10 @@ const ( WarningLevel Level = "warning" ErrorLevel Level = "error" + // Needed to avoid import cycle + // Originally defined in "internal/pkg/config/config.go" + outputFormatKey = "output-format" + JSONOutputFormat = "json" PrettyOutputFormat = "pretty" NoneOutputFormat = "none" @@ -54,7 +58,7 @@ func NewPrinter() *Printer { // Print an output using Printf to the defined output (falling back to Stderr if not set). // If output format is set to none, it does nothing func (p *Printer) Outputf(msg string, args ...any) { - outputFormat := viper.GetString(config.OutputFormatKey) + outputFormat := viper.GetString(outputFormatKey) if outputFormat == NoneOutputFormat { return } @@ -64,7 +68,7 @@ func (p *Printer) Outputf(msg string, args ...any) { // Print an output using Println to the defined output (falling back to Stderr if not set). // If output format is set to none, it does nothing func (p *Printer) Outputln(msg string) { - outputFormat := viper.GetString(config.OutputFormatKey) + outputFormat := viper.GetString(outputFormatKey) if outputFormat == NoneOutputFormat { return } @@ -168,7 +172,7 @@ func (p *Printer) PromptForPassword(prompt string) (string, error) { // Shows the content in the command's stdout using the "less" command // If output format is set to none, it does nothing func (p *Printer) PagerDisplay(content string) error { - outputFormat := viper.GetString(config.OutputFormatKey) + outputFormat := viper.GetString(outputFormatKey) if outputFormat == NoneOutputFormat { return nil } diff --git a/internal/pkg/print/print_test.go b/internal/pkg/print/print_test.go index 712288639..f5841de37 100644 --- a/internal/pkg/print/print_test.go +++ b/internal/pkg/print/print_test.go @@ -10,7 +10,6 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/stackitcloud/stackit-cli/internal/pkg/config" ) func TestOutputf(t *testing.T) { @@ -66,7 +65,7 @@ func TestOutputf(t *testing.T) { viper.Reset() if tt.outputFormatNone { - viper.Set(config.OutputFormatKey, NoneOutputFormat) + viper.Set(outputFormatKey, NoneOutputFormat) } if len(tt.args) == 0 { @@ -137,7 +136,7 @@ func TestOutputln(t *testing.T) { viper.Reset() if tt.outputFormatNone { - viper.Set(config.OutputFormatKey, NoneOutputFormat) + viper.Set(outputFormatKey, NoneOutputFormat) } p.Outputln(tt.message) @@ -201,7 +200,7 @@ func TestPagerDisplay(t *testing.T) { viper.Reset() if tt.outputFormatNone { - viper.Set(config.OutputFormatKey, NoneOutputFormat) + viper.Set(outputFormatKey, NoneOutputFormat) } err := p.PagerDisplay(tt.content) diff --git a/main.go b/main.go index 37689d889..5286783ac 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/config" ) -// These values are dynamically overridden by GoReleaser +// These values are overwritten by GoReleaser at build time var ( version = "DEV" date = "UNKNOWN"