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..947f3cc9c --- /dev/null +++ b/docs/stackit_config_profile.md @@ -0,0 +1,40 @@ +## 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" "yaml"] + -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 create](./stackit_config_profile_create.md) - Creates a CLI configuration profile +* [stackit config profile delete](./stackit_config_profile_delete.md) - Delete a CLI configuration profile +* [stackit config profile list](./stackit_config_profile_list.md) - Lists all CLI configuration profiles +* [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_create.md b/docs/stackit_config_profile_create.md new file mode 100644 index 000000000..5f9a45af3 --- /dev/null +++ b/docs/stackit_config_profile_create.md @@ -0,0 +1,48 @@ +## stackit config profile create + +Creates a CLI configuration profile + +### Synopsis + +Creates a CLI configuration profile based on the currently active profile and sets it as active. +The profile name can be provided via the STACKIT_CLI_PROFILE environment variable or as an argument in this command. +The environment variable takes precedence over the argument. +If you do not want to set the profile as active, use the --no-set flag. +If you want to create the new profile with the initial default configurations, use the --empty flag. + +``` +stackit config profile create PROFILE [flags] +``` + +### Examples + +``` + Create a new configuration profile "my-profile" with the current configuration, setting it as the active profile + $ stackit config profile create my-profile + + Create a new configuration profile "my-profile" with a default initial configuration and don't set it as the active profile + $ stackit config profile create my-profile --empty --no-set +``` + +### Options + +``` + --empty Create the profile with the initial default configurations + -h, --help Help for "stackit config profile create" + --no-set Do not set the profile as the active 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" "yaml"] + -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_delete.md b/docs/stackit_config_profile_delete.md new file mode 100644 index 000000000..4de5f7545 --- /dev/null +++ b/docs/stackit_config_profile_delete.md @@ -0,0 +1,40 @@ +## stackit config profile delete + +Delete a CLI configuration profile + +### Synopsis + +Delete a CLI configuration profile. +If the deleted profile is the active profile, the default profile will be set to active. + +``` +stackit config profile delete PROFILE [flags] +``` + +### Examples + +``` + Delete the configuration profile "my-profile" + $ stackit config profile delete my-profile +``` + +### Options + +``` + -h, --help Help for "stackit config profile delete" +``` + +### 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" "yaml"] + -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_list.md b/docs/stackit_config_profile_list.md new file mode 100644 index 000000000..9a8985341 --- /dev/null +++ b/docs/stackit_config_profile_list.md @@ -0,0 +1,42 @@ +## stackit config profile list + +Lists all CLI configuration profiles + +### Synopsis + +Lists all CLI configuration profiles. + +``` +stackit config profile list [flags] +``` + +### Examples + +``` + List the configuration profiles + $ stackit config profile list + + List the configuration profiles in a json format + $ stackit config profile list --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit config profile list" +``` + +### 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" "yaml"] + -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_set.md b/docs/stackit_config_profile_set.md new file mode 100644 index 000000000..a39604ff1 --- /dev/null +++ b/docs/stackit_config_profile_set.md @@ -0,0 +1,42 @@ +## 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. +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" "yaml"] + -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..410469005 --- /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" "yaml"] + -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/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/create/create.go b/internal/cmd/config/profile/create/create.go new file mode 100644 index 000000000..ceec4ee04 --- /dev/null +++ b/internal/cmd/config/profile/create/create.go @@ -0,0 +1,116 @@ +package create + +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/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/cobra" +) + +const ( + profileArg = "PROFILE" + + noSetFlag = "no-set" + fromEmptyProfile = "empty" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + NoSet bool + FromEmptyProfile bool + Profile string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("create %s", profileArg), + Short: "Creates a CLI configuration profile", + Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s", + "Creates a CLI configuration profile based on the currently active profile and sets it as active.", + `The profile name can be provided via the STACKIT_CLI_PROFILE environment variable or as an argument in this command.`, + "The environment variable takes precedence over the argument.", + "If you do not want to set the profile as active, use the --no-set flag.", + "If you want to create the new profile with the initial default configurations, use the --empty flag.", + ), + Args: args.SingleArg(profileArg, nil), + Example: examples.Build( + examples.NewExample( + `Create a new configuration profile "my-profile" with the current configuration, setting it as the active profile`, + "$ stackit config profile create my-profile"), + examples.NewExample( + `Create a new configuration profile "my-profile" with a default initial configuration and don't set it as the active profile`, + "$ stackit config profile create my-profile --empty --no-set"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + err = config.CreateProfile(p, model.Profile, !model.NoSet, model.FromEmptyProfile) + if err != nil { + return fmt.Errorf("create profile: %w", err) + } + + if model.NoSet { + p.Info("Successfully created profile %q\n", model.Profile) + return nil + } + + p.Info("Successfully created and 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 + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(noSetFlag, false, "Do not set the profile as the active profile") + cmd.Flags().Bool(fromEmptyProfile, false, "Create the profile with the initial default configurations") +} + +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, + FromEmptyProfile: flags.FlagToBoolValue(p, cmd, fromEmptyProfile), + NoSet: flags.FlagToBoolValue(p, cmd, noSetFlag), + } + + 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/create/create_test.go b/internal/cmd/config/profile/create/create_test.go new file mode 100644 index 000000000..0cc32cc9d --- /dev/null +++ b/internal/cmd/config/profile/create/create_test.go @@ -0,0 +1,156 @@ +package create + +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, + FromEmptyProfile: false, + NoSet: false, + } + 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, + }, + { + description: "use default given", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + fromEmptyProfile: "true", + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.FromEmptyProfile = true + }), + }, + { + description: "no set given", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + noSetFlag: "true", + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NoSet = true + }), + }, + } + + 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/delete/delete.go b/internal/cmd/config/profile/delete/delete.go new file mode 100644 index 000000000..f93a6163f --- /dev/null +++ b/internal/cmd/config/profile/delete/delete.go @@ -0,0 +1,117 @@ +package delete + +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/errors" + "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("delete %s", profileArg), + Short: "Delete a CLI configuration profile", + Long: fmt.Sprintf("%s\n%s", + "Delete a CLI configuration profile.", + "If the deleted profile is the active profile, the default profile will be set to active.", + ), + Args: args.SingleArg(profileArg, nil), + Example: examples.Build( + examples.NewExample( + `Delete the configuration profile "my-profile"`, + "$ stackit config profile delete my-profile"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + profileExists, err := config.ProfileExists(model.Profile) + if err != nil { + return fmt.Errorf("check if profile exists: %w", err) + } + if !profileExists { + return &errors.DeleteInexistentProfile{Profile: model.Profile} + } + + if model.Profile == config.DefaultProfileName { + return &errors.DeleteDefaultProfile{DefaultProfile: config.DefaultProfileName} + } + + activeProfile, err := config.GetProfile() + if err != nil { + return fmt.Errorf("get profile: %w", err) + } + if activeProfile == model.Profile { + p.Warn("The profile you are trying to delete is the active profile. The default profile will be set to active.\n") + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete profile %q? (This cannot be undone)", model.Profile) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + err = config.DeleteProfile(p, model.Profile) + if err != nil { + return fmt.Errorf("delete profile: %w", err) + } + + err = auth.DeleteProfileFromKeyring(model.Profile) + if err != nil { + return fmt.Errorf("delete profile from keyring: %w", err) + } + + p.Info("Successfully deleted profile %q\n", model.Profile) + + 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/delete/delete_test.go b/internal/cmd/config/profile/delete/delete_test.go new file mode 100644 index 000000000..3919460b7 --- /dev/null +++ b/internal/cmd/config/profile/delete/delete_test.go @@ -0,0 +1,132 @@ +package delete + +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/list/list.go b/internal/cmd/config/profile/list/list.go new file mode 100644 index 000000000..7f594d1e1 --- /dev/null +++ b/internal/cmd/config/profile/list/list.go @@ -0,0 +1,131 @@ +package list + +import ( + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "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/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all CLI configuration profiles", + Long: "Lists all CLI configuration profiles.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List the configuration profiles`, + "$ stackit config profile list"), + examples.NewExample( + `List the configuration profiles in a json format`, + "$ stackit config profile list --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + model := parseInput(p, cmd) + + profiles, err := config.ListProfiles() + if err != nil { + return fmt.Errorf("get profile: %w", err) + } + + activeProfile, err := config.GetProfile() + if err != nil { + return fmt.Errorf("get profile: %w", err) + } + + outputProfiles := buildOutput(profiles, activeProfile) + + return outputResult(p, model.OutputFormat, outputProfiles) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { + globalFlags := globalflags.Parse(p, cmd) + + return &inputModel{ + GlobalFlagModel: globalFlags, + } +} + +type profileInfo struct { + Name string + Active bool + Email string +} + +func buildOutput(profiles []string, activeProfile string) []profileInfo { + var configData []profileInfo + + // Add default profile first + configData = append(configData, profileInfo{ + Name: config.DefaultProfileName, + Active: activeProfile == config.DefaultProfileName, + Email: auth.GetProfileEmail(config.DefaultProfileName), + }) + + for _, profile := range profiles { + configData = append(configData, profileInfo{ + Name: profile, + Active: profile == activeProfile, + Email: auth.GetProfileEmail(profile), + }) + } + + return configData +} + +func outputResult(p *print.Printer, outputFormat string, profiles []profileInfo) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(profiles, "", " ") + if err != nil { + return fmt.Errorf("marshal config list: %w", err) + } + p.Outputln(string(details)) + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(profiles, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal config list: %w", err) + } + p.Outputln(string(details)) + return nil + default: + table := tables.NewTable() + table.SetHeader("NAME", "ACTIVE", "EMAIL") + for _, profile := range profiles { + // Prettify the output + email := profile.Email + active := "" + if profile.Email == "" { + email = "Not authenticated" + } + if profile.Active { + active = "*" + } + table.AddRow(profile.Name, active, email) + table.AddSeparator() + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + } +} diff --git a/internal/cmd/config/profile/profile.go b/internal/cmd/config/profile/profile.go new file mode 100644 index 000000000..3a4233ec4 --- /dev/null +++ b/internal/cmd/config/profile/profile.go @@ -0,0 +1,41 @@ +package profile + +import ( + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/list" + "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)) + cmd.AddCommand(create.NewCmd(p)) + cmd.AddCommand(list.NewCmd(p)) + cmd.AddCommand(delete.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..ac43977b3 --- /dev/null +++ b/internal/cmd/config/profile/set/set.go @@ -0,0 +1,102 @@ +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/errors" + "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", + "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.", + "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 + } + + profileExists, err := config.ProfileExists(model.Profile) + if err != nil { + return fmt.Errorf("check if profile exists: %w", err) + } + if !profileExists { + return &errors.SetInexistentProfile{Profile: model.Profile} + } + + 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 a641c73fe..97384911f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -9,7 +9,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/beta" - "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" @@ -27,6 +27,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" @@ -45,7 +46,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) @@ -53,11 +54,33 @@ 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) + + profileSet, activeProfile, configMethod, err := config.GetConfiguredProfile() + if err != nil { + return fmt.Errorf("get configured profile: %w", err) + } + + p.Debug(print.DebugLevel, "read configuration profile %q via %s", profileSet, configMethod) + + if activeProfile != profileSet { + if configMethod == "" { + p.Debug(print.DebugLevel, "no profile is configured in env var or profile file") + } else { + p.Debug(print.DebugLevel, "the configured profile %q does not exist: folder %q is missing", profileSet, config.GetProfileFolderPath(profileSet)) + } + p.Debug(print.DebugLevel, "the %q profile will be used", activeProfile) + + p.Warn("configured profile %q does not exist, the %q profile configuration will be used\n", profileSet, activeProfile) + } + + 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") { @@ -123,8 +146,8 @@ 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(configCmd.NewCmd(p)) cmd.AddCommand(beta.NewCmd(p)) - cmd.AddCommand(config.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..d44cc1133 100644 --- a/internal/pkg/auth/storage.go +++ b/internal/pkg/auth/storage.go @@ -3,10 +3,15 @@ package auth import ( "encoding/base64" "encoding/json" + "errors" "fmt" + "os" "path/filepath" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + pkgErrors "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/zalando/go-keyring" ) @@ -42,6 +47,21 @@ const ( AUTH_FLOW_SERVICE_ACCOUNT_KEY AuthFlow = "sa_key" ) +// Returns all auth field keys managed by the auth storage +var authFieldKeys = []authFieldKey{ + SESSION_EXPIRES_AT_UNIX, + ACCESS_TOKEN, + REFRESH_TOKEN, + SERVICE_ACCOUNT_TOKEN, + SERVICE_ACCOUNT_EMAIL, + USER_EMAIL, + SERVICE_ACCOUNT_KEY, + PRIVATE_KEY, + TOKEN_CUSTOM_ENDPOINT, + JWKS_CUSTOM_ENDPOINT, + authFlowType, +} + func SetAuthFlow(value AuthFlow) error { return SetAuthField(authFlowType, string(value)) } @@ -58,9 +78,18 @@ 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 { + return fmt.Errorf("get profile: %w", err) + } + + return setAuthFieldWithProfile(activeProfile, key, value) +} + +func setAuthFieldWithProfile(profile string, key authFieldKey, value string) error { + err := setAuthFieldInKeyring(profile, key, value) if err != nil { - errFallback := setAuthFieldInEncodedTextFile(key, value) + errFallback := setAuthFieldInEncodedTextFile(profile, key, value) if errFallback != nil { return fmt.Errorf("write to keyring failed (%w), try writing to encoded text file: %w", err, errFallback) } @@ -68,21 +97,29 @@ 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 != config.DefaultProfileName { + 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() - if err != nil { - return err +func deleteAuthFieldInKeyring(activeProfile string, key authFieldKey) error { + keyringServiceLocal := keyringService + if activeProfile != config.DefaultProfileName { + keyringServiceLocal = filepath.Join(keyringService, activeProfile) } - configDir, err := os.UserConfigDir() + return keyring.Delete(keyringServiceLocal, string(key)) +} + +func setAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey, value string) error { + err := createEncodedTextFile(activeProfile) if err != nil { - return fmt.Errorf("get config dir: %w", err) + return err } - textFileDir := filepath.Join(configDir, textFileFolderName) + textFileDir := config.GetProfileFolderPath(activeProfile) textFilePath := filepath.Join(textFileDir, textFileName) contentEncoded, err := os.ReadFile(textFilePath) @@ -131,10 +168,18 @@ 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) + } + return getAuthFieldWithProfile(activeProfile, key) +} + +func getAuthFieldWithProfile(profile string, key authFieldKey) (string, error) { + value, err := getAuthFieldFromKeyring(profile, key) if err != nil { var errFallback error - value, errFallback = getAuthFieldFromEncodedTextFile(key) + value, errFallback = getAuthFieldFromEncodedTextFile(profile, key) if errFallback != nil { return "", fmt.Errorf("read from keyring: %w, read from encoded file as fallback: %w", err, errFallback) } @@ -142,21 +187,21 @@ 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 != config.DefaultProfileName { + 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 } - configDir, err := os.UserConfigDir() - if err != nil { - return "", fmt.Errorf("get config dir: %w", err) - } - textFileDir := filepath.Join(configDir, textFileFolderName) + textFileDir := config.GetProfileFolderPath(activeProfile) textFilePath := filepath.Join(textFileDir, textFileName) contentEncoded, err := os.ReadFile(textFilePath) @@ -182,15 +227,11 @@ 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 { - configDir, err := os.UserConfigDir() - if err != nil { - return fmt.Errorf("get config dir: %w", err) - } - textFileDir := filepath.Join(configDir, textFileFolderName) +func createEncodedTextFile(activeProfile string) error { + textFileDir := config.GetProfileFolderPath(activeProfile) textFilePath := filepath.Join(textFileDir, textFileName) - err = os.MkdirAll(textFileDir, os.ModePerm) + err := os.MkdirAll(textFileDir, os.ModePerm) if err != nil { return fmt.Errorf("create file dir: %w", err) } @@ -207,3 +248,50 @@ func createEncodedTextFile() error { return nil } + +// GetProfileEmail returns the email of the user or service account associated with the given profile. +// If the profile is not authenticated or the email can't be obtained, it returns an empty string. +func GetProfileEmail(profile string) string { + value, err := getAuthFieldWithProfile(profile, authFlowType) + if err != nil { + return "" + } + + var email string + switch AuthFlow(value) { + case AUTH_FLOW_USER_TOKEN: + email, err = getAuthFieldWithProfile(profile, USER_EMAIL) + if err != nil { + email = "" + } + case AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, AUTH_FLOW_SERVICE_ACCOUNT_KEY: + email, err = getAuthFieldWithProfile(profile, SERVICE_ACCOUNT_EMAIL) + if err != nil { + email = "" + } + } + return email +} + +func DeleteProfileFromKeyring(profile string) error { + err := config.ValidateProfile(profile) + if err != nil { + return fmt.Errorf("validate profile: %w", err) + } + + if profile == config.DefaultProfileName { + return &pkgErrors.DeleteDefaultProfile{DefaultProfile: config.DefaultProfileName} + } + + for _, key := range authFieldKeys { + err := deleteAuthFieldInKeyring(profile, key) + if err != nil { + // if the key is not found, we can ignore the error + if !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("delete auth field \"%s\" from keyring: %w", key, err) + } + } + } + + return nil +} diff --git a/internal/pkg/auth/storage_test.go b/internal/pkg/auth/storage_test.go index 1d3d4dab9..1591a024f 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,17 +147,180 @@ 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(activeProfile, key) + if err != nil { + t.Errorf("Post-test cleanup failed: remove field \"%s\" from text file: %v. Please remove it manually", key, err) + } + } + } + }) + } +} + +func TestSetGetAuthFieldWithProfile(t *testing.T) { + var testField1 authFieldKey = "test-field-1" + var testField2 authFieldKey = "test-field-2" + + testValue1 := fmt.Sprintf("value-1-%s", time.Now().Format(time.RFC3339)) + testValue2 := fmt.Sprintf("value-2-%s", time.Now().Format(time.RFC3339)) + testValue3 := fmt.Sprintf("value-3-%s", time.Now().Format(time.RFC3339)) + + type valueAssignment struct { + key authFieldKey + value string + } + + tests := []struct { + description string + keyringFails bool + valueAssignments []valueAssignment + activeProfile string + expectedValues map[authFieldKey]string + }{ + { + description: "simple assignments", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + }, + activeProfile: "test-profile", + expectedValues: map[authFieldKey]string{ + testField1: testValue1, + testField2: testValue2, + }, + }, + { + description: "simple assignments w/ keyring failing", + keyringFails: true, + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + }, + activeProfile: "test-profile", + expectedValues: map[authFieldKey]string{ + testField1: testValue1, + testField2: testValue2, + }, + }, + { + description: "overlapping assignments", + keyringFails: true, + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + { + key: testField1, + value: testValue3, + }, + }, + activeProfile: "test-profile", + expectedValues: map[authFieldKey]string{ + testField1: testValue3, + testField2: testValue2, + }, + }, + { + description: "overlapping assignments w/ keyring failing", + keyringFails: true, + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + { + key: testField1, + value: testValue3, + }, + }, + activeProfile: "test-profile", + expectedValues: map[authFieldKey]string{ + testField1: testValue3, + testField2: testValue2, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + // Apppend random string to profile name to avoid conflicts + tt.activeProfile = makeProfileNameUnique(tt.activeProfile) + + // Make sure profile name is valid + err := config.ValidateProfile(tt.activeProfile) + if err != nil { + t.Fatalf("Profile name \"%s\" is invalid: %v", tt.activeProfile, err) + } + + if !tt.keyringFails { + keyring.MockInit() + } else { + keyring.MockInitWithError(fmt.Errorf("keyring unavailable for testing")) + } + + for _, assignment := range tt.valueAssignments { + err := setAuthFieldWithProfile(tt.activeProfile, assignment.key, assignment.value) + if err != nil { + t.Fatalf("Failed to set \"%s\" as \"%s\": %v", assignment.key, assignment.value, err) + } + // Check that this value will be checked + if _, ok := tt.expectedValues[assignment.key]; !ok { + t.Fatalf("Value \"%s\" set but not checked. Please add it to 'expectedValues'", assignment.key) + } + } + + for key, valueExpected := range tt.expectedValues { + value, err := getAuthFieldWithProfile(tt.activeProfile, key) + if err != nil { + t.Errorf("Failed to get value of \"%s\": %v", key, err) + continue + } else if value != valueExpected { + t.Errorf("Value of field \"%s\" is wrong: expected \"%s\", got \"%s\"", key, valueExpected, value) + } + + if !tt.keyringFails { + 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) } } else { - 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) } } } + + err = deleteAuthFieldProfile(tt.activeProfile) + if err != nil { + t.Errorf("Post-test cleanup failed: remove profile \"%s\": %v. Please remove it manually", tt.activeProfile, err) + } }) } } @@ -172,9 +342,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: config.DefaultProfileName, valueAssignments: []valueAssignment{ { key: testField1, @@ -191,7 +363,48 @@ func TestSetGetAuthFieldKeyring(t *testing.T) { }, }, { - description: "overlapping assignments", + description: "overlapping assignments with default profile", + activeProfile: config.DefaultProfileName, + 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 test-profile", + activeProfile: "test-profile", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue1, + testField2: testValue2, + }, + }, + { + description: "overlapping assignments with test-profile", + activeProfile: "test-profile", valueAssignments: []valueAssignment{ { key: testField1, @@ -217,8 +430,17 @@ func TestSetGetAuthFieldKeyring(t *testing.T) { t.Run(tt.description, func(t *testing.T) { keyring.MockInit() + // Apppend random string to profile name to avoid conflicts + tt.activeProfile = makeProfileNameUnique(tt.activeProfile) + + // Make sure profile name is valid + err := config.ValidateProfile(tt.activeProfile) + if err != nil { + t.Fatalf("Profile name \"%s\" is invalid: %v", tt.activeProfile, err) + } + 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 +451,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 +459,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) } @@ -246,6 +468,167 @@ func TestSetGetAuthFieldKeyring(t *testing.T) { } } +func TestDeleteAuthFieldKeyring(t *testing.T) { + tests := []struct { + description string + activeProfile string + noKey bool + isValid bool + }{ + { + description: "base, default profile", + activeProfile: config.DefaultProfileName, + isValid: true, + }, + { + description: "key doesnt exist, default profile", + activeProfile: config.DefaultProfileName, + noKey: true, + isValid: false, + }, + { + description: "base, custom profile", + activeProfile: "test-profile", + isValid: true, + }, + { + description: "key doesnt exist, custom profile", + activeProfile: "test-profile", + noKey: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + keyring.MockInit() + + // Append random string to auth field key and value to avoid conflicts + testField1 := authFieldKey(fmt.Sprintf("test-field-1-%s", time.Now().Format(time.RFC3339))) + testValue1 := fmt.Sprintf("value-1-keyring-%s", time.Now().Format(time.RFC3339)) + + // Append random string to profile name to avoid conflicts + tt.activeProfile = makeProfileNameUnique(tt.activeProfile) + + // Make sure profile name is valid + err := config.ValidateProfile(tt.activeProfile) + if err != nil { + t.Fatalf("Profile name \"%s\" is invalid: %v", tt.activeProfile, err) + } + + if !tt.noKey { + err := setAuthFieldInKeyring(tt.activeProfile, testField1, testValue1) + if err != nil { + t.Fatalf("Failed to set \"%s\" as \"%s\": %v", testField1, testValue1, err) + } + } + + err = deleteAuthFieldInKeyring(tt.activeProfile, testField1) + if err != nil { + if tt.isValid { + t.Fatalf("Failed to delete field \"%s\" from keyring: %v", testField1, err) + } + return + } + + if !tt.isValid { + t.Fatalf("Expected error when deleting field \"%s\" from keyring, got none", testField1) + } + + // Check if key still exists + _, err = getAuthFieldFromKeyring(tt.activeProfile, testField1) + if err == nil { + t.Fatalf("Key \"%s\" still exists in keyring after deletion", testField1) + } + }) + } +} + +func TestDeleteProfileFromKeyring(t *testing.T) { + tests := []struct { + description string + keyringFails bool + keys []authFieldKey + activeProfile string + isValid bool + }{ + + { + description: "base", + keys: authFieldKeys, + activeProfile: "test-profile", + isValid: true, + }, + { + description: "missing keys", + keys: []authFieldKey{ + ACCESS_TOKEN, + SERVICE_ACCOUNT_EMAIL, + }, + activeProfile: "test-profile", + isValid: true, + }, + { + description: "invalid profile", + activeProfile: "INVALID", + isValid: false, + }, + { + description: "keyring fails", + keyringFails: true, + isValid: false, + }, + { + description: "default profile", + activeProfile: config.DefaultProfileName, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + if !tt.keyringFails { + keyring.MockInit() + } else { + keyring.MockInitWithError(fmt.Errorf("keyring unavailable for testing")) + } + + // Append random string to auth field key and value to avoid conflicts + testValue1 := fmt.Sprintf("value-1-keyring-%s", time.Now().Format(time.RFC3339)) + + // Append random string to profile name to avoid conflicts + tt.activeProfile = makeProfileNameUnique(tt.activeProfile) + + for _, key := range tt.keys { + err := setAuthFieldInKeyring(tt.activeProfile, key, testValue1) + if err != nil { + t.Fatalf("Failed to set \"%s\" as \"%s\": %v", key, testValue1, err) + } + } + + err := DeleteProfileFromKeyring(tt.activeProfile) + if err != nil { + if tt.isValid { + t.Fatalf("Failed to delete profile \"%s\" from keyring: %v", tt.activeProfile, err) + } + return + } + + if !tt.isValid { + t.Fatalf("Expected error when deleting profile \"%s\" from keyring, got none", tt.activeProfile) + } + + for _, key := range tt.keys { + // Check if key still exists + _, err = getAuthFieldFromKeyring(tt.activeProfile, key) + if err == nil { + t.Fatalf("Key \"%s\" still exists in keyring after profile deletion", key) + } + } + }) + } +} + func TestSetGetAuthFieldEncodedTextFile(t *testing.T) { var testField1 authFieldKey = "test-field-1" var testField2 authFieldKey = "test-field-2" @@ -261,11 +644,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: config.DefaultProfileName, valueAssignments: []valueAssignment{ { key: testField1, @@ -282,7 +667,48 @@ func TestSetGetAuthFieldEncodedTextFile(t *testing.T) { }, }, { - description: "overlapping assignments", + description: "overlapping assignments with default profile", + activeProfile: config.DefaultProfileName, + 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 test-profile", + activeProfile: "test-profile", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue1, + testField2: testValue2, + }, + }, + { + description: "overlapping assignments with test-profile", + activeProfile: "test-profile", valueAssignments: []valueAssignment{ { key: testField1, @@ -306,8 +732,17 @@ func TestSetGetAuthFieldEncodedTextFile(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { + // Append random string to profile name to avoid conflicts + tt.activeProfile = makeProfileNameUnique(tt.activeProfile) + + // Make sure profile name is valid + err := config.ValidateProfile(tt.activeProfile) + if err != nil { + t.Fatalf("Profile name \"%s\" is invalid: %v", tt.activeProfile, err) + } + 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 +753,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,30 +761,185 @@ 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) } } + + err = deleteAuthFieldProfile(tt.activeProfile) + if err != nil { + t.Errorf("Post-test cleanup failed: remove profile \"%s\": %v. Please remove it manually", tt.activeProfile, err) + } }) } } -func deleteAuthFieldInKeyring(key authFieldKey) error { - return keyring.Delete(keyringService, string(key)) +func TestGetProfileEmail(t *testing.T) { + tests := []struct { + description string + activeProfile string + userEmail string + authFlow AuthFlow + serviceAccEmail string + expectedEmail string + }{ + { + description: "default profile, user token", + activeProfile: config.DefaultProfileName, + userEmail: "test@test.com", + authFlow: AUTH_FLOW_USER_TOKEN, + expectedEmail: "test@test.com", + }, + { + description: "default profile, service acc token", + activeProfile: config.DefaultProfileName, + serviceAccEmail: "test@test.com", + authFlow: AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, + expectedEmail: "test@test.com", + }, + { + description: "default profile, service acc key", + activeProfile: config.DefaultProfileName, + serviceAccEmail: "test@test.com", + authFlow: AUTH_FLOW_SERVICE_ACCOUNT_KEY, + expectedEmail: "test@test.com", + }, + { + description: "custom profile, user token", + activeProfile: "test-profile", + userEmail: "test@test.com", + authFlow: AUTH_FLOW_USER_TOKEN, + expectedEmail: "test@test.com", + }, + { + description: "custom profile, service acc token", + activeProfile: "test-profile", + serviceAccEmail: "test@test.com", + authFlow: AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, + expectedEmail: "test@test.com", + }, + { + description: "custom profile, service acc key", + activeProfile: "test-profile", + serviceAccEmail: "test@test.com", + authFlow: AUTH_FLOW_SERVICE_ACCOUNT_KEY, + expectedEmail: "test@test.com", + }, + { + description: "no email, user token", + activeProfile: "test-profile", + authFlow: AUTH_FLOW_USER_TOKEN, + expectedEmail: "", + }, + { + description: "no email, service acc token", + activeProfile: "test-profile", + authFlow: AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, + expectedEmail: "", + }, + { + description: "no email, service acc key", + activeProfile: "test-profile", + authFlow: AUTH_FLOW_SERVICE_ACCOUNT_KEY, + expectedEmail: "", + }, + { + description: "user not authenticated", + activeProfile: "test-profile", + expectedEmail: "", + }, + { + description: "both emails, user not authenticated", + activeProfile: "test-profile", + userEmail: "test@test.com", + serviceAccEmail: "test2@test.com", + expectedEmail: "", + }, + { + description: "both emails, user token", + activeProfile: "test-profile", + userEmail: "test@test.com", + serviceAccEmail: "test2@test.com", + authFlow: AUTH_FLOW_USER_TOKEN, + expectedEmail: "test@test.com", + }, + { + description: "both emails, service account token", + activeProfile: "test-profile", + userEmail: "test@test.com", + serviceAccEmail: "test2@test.com", + authFlow: AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, + expectedEmail: "test2@test.com", + }, + { + description: "both emails, service account key", + activeProfile: "test-profile", + userEmail: "test@test.com", + serviceAccEmail: "test2@test.com", + authFlow: AUTH_FLOW_SERVICE_ACCOUNT_KEY, + expectedEmail: "test2@test.com", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + keyring.MockInit() + + // Append random string to profile name to avoid conflicts + tt.activeProfile = makeProfileNameUnique(tt.activeProfile) + + // Make sure profile name is valid + err := config.ValidateProfile(tt.activeProfile) + if err != nil { + t.Fatalf("Profile name \"%s\" is invalid: %v", tt.activeProfile, err) + } + + err = setAuthFieldInKeyring(tt.activeProfile, USER_EMAIL, tt.userEmail) + if err != nil { + t.Errorf("Failed to set user email: %v", err) + } + + err = setAuthFieldInKeyring(tt.activeProfile, SERVICE_ACCOUNT_EMAIL, tt.serviceAccEmail) + if err != nil { + t.Errorf("Failed to set service account email: %v", err) + } + + err = setAuthFieldWithProfile(tt.activeProfile, authFlowType, string(tt.authFlow)) + if err != nil { + t.Errorf("Failed to set auth flow: %v", err) + } + + email := GetProfileEmail(tt.activeProfile) + if email != tt.expectedEmail { + t.Errorf("Expected email \"%s\", got \"%s\"", tt.expectedEmail, email) + } + + err = deleteAuthFieldInKeyring(tt.activeProfile, USER_EMAIL) + if err != nil { + t.Fatalf("Failed to remove user email: %v", err) + } + + err = deleteAuthFieldInKeyring(tt.activeProfile, SERVICE_ACCOUNT_EMAIL) + if err != nil { + t.Fatalf("Failed to remove service account email: %v", err) + } + + err = deleteAuthFieldProfile(tt.activeProfile) + if err != nil { + t.Fatalf("Failed to remove profile: %v", err) + } + }) + } } -func deleteAuthFieldInEncodedTextFile(key authFieldKey) error { - err := createEncodedTextFile() +func deleteAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey) error { + err := createEncodedTextFile(activeProfile) if err != nil { return err } - configDir, err := os.UserConfigDir() - if err != nil { - return fmt.Errorf("get config dir: %w", err) - } - textFileDir := filepath.Join(configDir, textFileFolderName) + textFileDir := config.GetProfileFolderPath(activeProfile) textFilePath := filepath.Join(textFileDir, textFileName) contentEncoded, err := os.ReadFile(textFilePath) @@ -379,3 +969,24 @@ func deleteAuthFieldInEncodedTextFile(key authFieldKey) error { } return nil } + +func deleteAuthFieldProfile(activeProfile string) error { + if activeProfile == config.DefaultProfileName { + // Do not delete the default profile + return nil + } + textFileDir := config.GetProfileFolderPath(activeProfile) + // Remove the entire directory if the profile does not exist + err := os.RemoveAll(textFileDir) + if err != nil { + return fmt.Errorf("remove directory: %w", err) + } + return nil +} + +func makeProfileNameUnique(profile string) string { + if profile == config.DefaultProfileName { + return profile + } + return fmt.Sprintf("%s-%s", profile, time.Now().Format("20060102150405")) +} 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 5d90f5a39..a27b13eff 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -35,16 +35,22 @@ const ( SKECustomEndpointKey = "ske_custom_endpoint" SQLServerFlexCustomEndpointKey = "sqlserverflex_custom_endpoint" + ProjectNameKey = "project_name" + DefaultProfileName = "default" + AsyncDefault = false SessionTimeLimitDefault = "2h" ) -// Backend config keys const ( - configFolder = "stackit" + configFolder = "stackit" + configFileName = "cli-config" configFileExtension = "json" - ProjectNameKey = "project_name" + + profileRootFolder = "profiles" + profileFileName = "cli-profile" + profileFileExtension = "txt" ) var ConfigKeys = []string{ @@ -74,16 +80,20 @@ var ConfigKeys = []string{ SQLServerFlexCustomEndpointKey, } -var folderPath string +var defaultConfigFolderPath string +var configFolderPath string +var profileFilePath string func InitConfig() { - configDir, err := os.UserConfigDir() + defaultConfigFolderPath = getInitialConfigDir() + profileFilePath = getInitialProfileFilePath() // Profile file path is in the default config folder + + configProfile, err := GetProfile() 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 + configFolderPath = GetProfileFolderPath(configProfile) + + configFilePath := getConfigFilePath(configFolderPath) // This hack is required to allow creating the config file with `viper.WriteConfig` // see https://github.com/spf13/viper/issues/851#issuecomment-789393451 @@ -109,22 +119,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() @@ -150,3 +148,22 @@ func setConfigDefaults() { viper.SetDefault(SKECustomEndpointKey, "") viper.SetDefault(SQLServerFlexCustomEndpointKey, "") } + +func getConfigFilePath(configFolder string) string { + return filepath.Join(configFolder, fmt.Sprintf("%s.%s", configFileName, configFileExtension)) +} + +func getInitialConfigDir() string { + configDir, err := os.UserConfigDir() + cobra.CheckErr(err) + + return filepath.Join(configDir, configFolder) +} + +func getInitialProfileFilePath() string { + configFolderPath := defaultConfigFolderPath + if configFolderPath == "" { + configFolderPath = getInitialConfigDir() + } + return filepath.Join(configFolderPath, fmt.Sprintf("%s.%s", profileFileName, profileFileExtension)) +} diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index 194ba8047..7c9954117 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "path/filepath" "testing" @@ -33,10 +34,10 @@ func TestWrite(t *testing.T) { t.Run(tt.description, func(t *testing.T) { configPath := filepath.Join(os.TempDir(), tt.folderName, "config.json") viper.SetConfigFile(configPath) - folderPath = filepath.Dir(configPath) + configFolderPath = filepath.Dir(configPath) if tt.folderExists { - err := os.MkdirAll(folderPath, os.ModePerm) + err := os.MkdirAll(configFolderPath, os.ModePerm) if err != nil { t.Fatalf("expected error to be nil, got %v", err) } @@ -61,7 +62,7 @@ func TestWrite(t *testing.T) { // Delete the folder if tt.folderName != "" { - err = os.Remove(folderPath) + err = os.Remove(configFolderPath) if err != nil { t.Fatalf("expected error to be nil, got %v", err) } @@ -69,3 +70,58 @@ func TestWrite(t *testing.T) { }) } } + +func TestGetInitialConfigDir(t *testing.T) { + tests := []struct { + description string + }{ + { + description: "base", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + actual := getInitialConfigDir() + + userConfig, err := os.UserConfigDir() + if err != nil { + t.Fatalf("expected error to be nil, got %v", err) + } + + expected := filepath.Join(userConfig, "stackit") + if actual != expected { + t.Fatalf("expected %s, got %s", expected, actual) + } + }) + } +} + +func TestGetInitialProfileFilePath(t *testing.T) { + tests := []struct { + description string + configFolderPath string + }{ + { + description: "base", + configFolderPath: getInitialConfigDir(), + }, + { + description: "empty config folder path", + configFolderPath: "", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + configFolderPath = getInitialConfigDir() + + actual := getInitialProfileFilePath() + + expected := filepath.Join(configFolderPath, fmt.Sprintf("%s.%s", profileFileName, profileFileExtension)) + if actual != expected { + t.Fatalf("expected %s, got %s", expected, actual) + } + }) + } +} diff --git a/internal/pkg/config/profiles.go b/internal/pkg/config/profiles.go new file mode 100644 index 000000000..f05ae9a7b --- /dev/null +++ b/internal/pkg/config/profiles.go @@ -0,0 +1,332 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/fileutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ProfileEnvVar = "STACKIT_CLI_PROFILE" + +// 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 profile is not set (env var or profile file) or is set but does not exist, it falls back to the default profile. +// If the profile is not valid, it returns an error. +func GetProfile() (string, error) { + _, profile, _, err := GetConfiguredProfile() + if err != nil { + return "", err + } + + return profile, nil +} + +// GetConfiguredProfile returns the profile configured by the user, the profile to be used by the CLI and the method used to configure the profile. +// 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 configured profile is not set (env var or profile file) or is set but does not exist, it falls back to the default profile. +// The configuration method can be environment variable, profile file or empty if profile is not configured. +// If the profile is not valid, it returns an error. +func GetConfiguredProfile() (configuredProfile, activeProfile, configurationMethod string, err error) { + var configMethod string + profile, profileSetInEnv := GetProfileFromEnv() + if !profileSetInEnv { + contents, exists, err := fileutils.ReadFileIfExists(profileFilePath) + if err != nil { + return "", "", "", fmt.Errorf("read profile from file: %w", err) + } + if !exists { + // No profile set in env or file + return DefaultProfileName, DefaultProfileName, "", nil + } + profile = contents + configMethod = "profile file" + } else { + configMethod = "environment variable" + } + + // Make sure the profile exists + profileExists, err := ProfileExists(profile) + if err != nil { + return "", "", "", fmt.Errorf("check if profile exists: %w", err) + } + if !profileExists { + // Profile is configured but does not exist + return profile, DefaultProfileName, configMethod, nil + } + + err = ValidateProfile(profile) + if err != nil { + return "", "", "", fmt.Errorf("validate profile: %w", err) + } + return profile, profile, configMethod, nil +} + +// GetProfileFromEnv returns the profile from the environment variable. +// If the environment variable is not set, it returns an empty string. +// If the profile is not valid, it returns an error. +func GetProfileFromEnv() (string, bool) { + return os.LookupEnv(ProfileEnvVar) +} + +// CreateProfile creates a new profile. +// If emptyProfile is true, it creates an empty profile. Otherwise, copies the config from the current profile to the new profile. +// If setProfile is true, it sets the new profile as the active profile. +// If the profile already exists, it returns an error. +func CreateProfile(p *print.Printer, profile string, setProfile, emptyProfile bool) error { + err := ValidateProfile(profile) + if err != nil { + return fmt.Errorf("validate profile: %w", err) + } + + // Cannot create a profile with the default name + if profile == DefaultProfileName { + return &errors.InvalidProfileNameError{ + Profile: profile, + } + } + + configFolderPath = GetProfileFolderPath(profile) + + // Error if the profile already exists + _, err = os.Stat(configFolderPath) + if err == nil { + return fmt.Errorf("profile %q already exists", 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) + + if !emptyProfile { + currentProfile, err := GetProfile() + if err != nil { + // Cleanup created directory + cleanupErr := os.RemoveAll(configFolderPath) + if cleanupErr != nil { + return fmt.Errorf("get active profile: %w, cleanup directories: %w", err, cleanupErr) + } + return fmt.Errorf("get active profile: %w", err) + } + + p.Debug(print.DebugLevel, "current active profile: %q", currentProfile) + p.Debug(print.DebugLevel, "duplicating profile configuration from %q to new profile %q", currentProfile, profile) + err = DuplicateProfileConfiguration(p, currentProfile, profile) + if err != nil { + // Cleanup created directory + cleanupErr := os.RemoveAll(configFolderPath) + if cleanupErr != nil { + return fmt.Errorf("get active profile: %w, cleanup directories: %w", err, cleanupErr) + } + return fmt.Errorf("duplicate profile configuration: %w", err) + } + } + + if setProfile { + err = SetProfile(p, profile) + if err != nil { + return fmt.Errorf("set profile: %w", err) + } + } + + return nil +} + +// DuplicateProfileConfiguration duplicates the current profile configuration to a new profile. +// It copies the config file from the current profile to the new profile. +// If the current profile does not exist, it does nothing. +// If the new profile already exists, it will be overwritten. +func DuplicateProfileConfiguration(p *print.Printer, currentProfile, newProfile string) error { + currentProfileFolder := GetProfileFolderPath(currentProfile) + currentConfigFilePath := getConfigFilePath(currentProfileFolder) + + newConfigFilePath := getConfigFilePath(configFolderPath) + + // If the source profile configuration does not exist, do nothing + _, err := os.Stat(currentConfigFilePath) + if err != nil { + if os.IsNotExist(err) { + p.Debug(print.DebugLevel, "current profile %q has no configuration, nothing to duplicate", currentProfile) + return nil + } + return fmt.Errorf("get current profile configuration: %w", err) + } + + err = fileutils.CopyFile(currentConfigFilePath, newConfigFilePath) + if err != nil { + return fmt.Errorf("copy config file: %w", err) + } + + p.Debug(print.DebugLevel, "created new configuration for profile %q based on %q in: %s", newProfile, currentProfile, newConfigFilePath) + + return 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) + } + + profileExists, err := ProfileExists(profile) + if err != nil { + return fmt.Errorf("check if profile exists: %w", err) + } + + if !profileExists { + return &errors.SetInexistentProfile{Profile: profile} + } + + if profileFilePath == "" { + profileFilePath = getInitialProfileFilePath() + } + + 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 = GetProfileFolderPath(profile) + 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) + } + if p != nil { + p.Debug(print.DebugLevel, "removed active profile file: %s", profileFilePath) + } + return nil +} + +// ValidateProfile validates the profile name. +// It can only use lowercase letters, numbers, or "-" and cannot be empty. +// It can't start with a "-". +// If the profile is invalid, it returns an error. +func ValidateProfile(profile string) error { + match, err := regexp.MatchString("^[a-z0-9][a-z0-9-]+$", profile) + if err != nil { + return fmt.Errorf("match string regex: %w", err) + } + if !match { + return &errors.InvalidProfileNameError{ + Profile: profile, + } + } + return nil +} + +func ProfileExists(profile string) (bool, error) { + _, err := os.Stat(GetProfileFolderPath(profile)) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("get profile folder: %w", err) + } + return true, nil +} + +// GetProfileFolderPath returns the path to the folder where the profile configuration is stored. +// If the profile is the default profile, it returns the default config folder path. +func GetProfileFolderPath(profile string) string { + if defaultConfigFolderPath == "" { + defaultConfigFolderPath = getInitialConfigDir() + } + + if profile == DefaultProfileName { + return defaultConfigFolderPath + } + return filepath.Join(defaultConfigFolderPath, profileRootFolder, profile) +} + +// ListProfiles returns a list of all non-default profiles. +// If there are no profiles, it returns an empty list. +func ListProfiles() ([]string, error) { + profiles := []string{} + + // Check if the profile root folder exists + _, err := os.Stat(filepath.Join(defaultConfigFolderPath, profileRootFolder)) + if err != nil { + if os.IsNotExist(err) { + return profiles, nil + } + return nil, fmt.Errorf("get profile root folder: %w", err) + } + + profileFolders, err := os.ReadDir(filepath.Join(defaultConfigFolderPath, profileRootFolder)) + if err != nil { + return nil, fmt.Errorf("read profile folders: %w", err) + } + + for _, profileFolder := range profileFolders { + if profileFolder.IsDir() { + profiles = append(profiles, profileFolder.Name()) + } + } + + return profiles, nil +} + +// DeleteProfile deletes a profile. +// If the profile does not exist or is the default profile, it returns an error. +// If the profile is the active profile, it sets the active profile to the default profile. +func DeleteProfile(p *print.Printer, profile string) error { + err := ValidateProfile(profile) + if err != nil { + return fmt.Errorf("validate profile: %w", err) + } + + // Default profile cannot be deleted + if profile == DefaultProfileName { + return &errors.DeleteDefaultProfile{DefaultProfile: DefaultProfileName} + } + + activeProfile, err := GetProfile() + if err != nil { + return fmt.Errorf("get active profile: %w", err) + } + + profileExists, err := ProfileExists(profile) + if err != nil { + return fmt.Errorf("check if profile exists: %w", err) + } + + if !profileExists { + return &errors.DeleteInexistentProfile{Profile: profile} + } + + err = os.RemoveAll(filepath.Join(defaultConfigFolderPath, profileRootFolder, profile)) + if err != nil { + return fmt.Errorf("remove profile folder: %w", err) + } + + if activeProfile == profile { + err = UnsetProfile(p) + if err != nil { + return fmt.Errorf("unset profile: %w", err) + } + } + + if p != nil { + p.Debug(print.DebugLevel, "deleted profile %q", 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..bb96918c3 --- /dev/null +++ b/internal/pkg/config/profiles_test.go @@ -0,0 +1,117 @@ +package config + +import ( + "path/filepath" + "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 with letters and hyphen", + profile: "my-profile", + isValid: true, + }, + { + description: "valid with letters, numbers, and hyphen", + profile: "my-profile-123", + isValid: true, + }, + { + description: "valid with letters, numbers, and ending with hyphen", + profile: "my-profile123-", + 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, + }, + { + description: "invalid starting with -", + profile: "-my-profile", + isValid: false, + }, + { + description: "invalid profile with uppercase letters", + profile: "myProfile", + 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") + } + }) + } +} + +func TestGetProfileFolderPath(t *testing.T) { + tests := []struct { + description string + defaultConfigFolderNotSet bool + profile string + expected string + }{ + { + description: "default profile", + profile: DefaultProfileName, + expected: getInitialConfigDir(), + }, + { + description: "default profile, default config folder not set", + defaultConfigFolderNotSet: true, + profile: DefaultProfileName, + expected: getInitialConfigDir(), + }, + { + description: "custom profile", + profile: "my-profile", + expected: filepath.Join(getInitialConfigDir(), profileRootFolder, "my-profile"), + }, + { + description: "custom profile, default config folder not set", + defaultConfigFolderNotSet: true, + profile: "my-profile", + expected: filepath.Join(getInitialConfigDir(), profileRootFolder, "my-profile"), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + defaultConfigFolderPath = getInitialConfigDir() + if tt.defaultConfigFolderNotSet { + defaultConfigFolderPath = "" + } + actual := GetProfileFolderPath(tt.profile) + if actual != tt.expected { + t.Errorf("expected profile folder path to be %q but got %q", tt.expected, actual) + } + }) + } +} diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index 0d7528461..7ebe01ad5 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -38,6 +38,18 @@ Please double check if they are correctly configured. For more details run: $ stackit auth activate-service-account -h` + SET_INEXISTENT_PROFILE = `the configuration profile %[1]q you are trying to set doesn't exist. + +To create it, run: + $ stackit config profile create %[1]q` + + DELETE_INEXISTENT_PROFILE = `the configuration profile %q does not exist. + +To list all profiles, run: + $ stackit config profile list` + + DELETE_DEFAULT_PROFILE = `the default configuration profile %q cannot be deleted.` + ARGUS_INVALID_INPUT_PLAN = `the instance plan was not correctly provided. Either provide the plan ID: @@ -115,6 +127,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 lowercase letters, numbers, and "-" and cannot be empty or "default". It can't start with a "-".` + USAGE_TIP = `For usage help, run: $ %s --help` ) @@ -143,6 +159,30 @@ func (e *ActivateServiceAccountError) Error() string { return FAILED_SERVICE_ACCOUNT_ACTIVATION } +type SetInexistentProfile struct { + Profile string +} + +func (e *SetInexistentProfile) Error() string { + return fmt.Sprintf(SET_INEXISTENT_PROFILE, e.Profile) +} + +type DeleteInexistentProfile struct { + Profile string +} + +func (e *DeleteInexistentProfile) Error() string { + return fmt.Sprintf(DELETE_INEXISTENT_PROFILE, e.Profile) +} + +type DeleteDefaultProfile struct { + DefaultProfile string +} + +func (e *DeleteDefaultProfile) Error() string { + return fmt.Sprintf(DELETE_DEFAULT_PROFILE, e.DefaultProfile) +} + type ArgusInputPlanError struct { Cmd *cobra.Command Args []string @@ -316,3 +356,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/errors/errors_test.go b/internal/pkg/errors/errors_test.go index fd050f2f7..67c37b775 100644 --- a/internal/pkg/errors/errors_test.go +++ b/internal/pkg/errors/errors_test.go @@ -123,6 +123,32 @@ func TestArgusInputPlanError(t *testing.T) { } } +func TestSetInexistentProfile(t *testing.T) { + tests := []struct { + description string + profile string + expectedMsg string + }{ + { + description: "base", + profile: "profile", + expectedMsg: fmt.Sprintf(SET_INEXISTENT_PROFILE, "profile", "profile"), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := &SetInexistentProfile{ + Profile: tt.profile, + } + + if err.Error() != tt.expectedMsg { + t.Fatalf("expected error to be %s, got %s", tt.expectedMsg, err.Error()) + } + }) + } +} + func TestArgusInvalidPlanError(t *testing.T) { tests := []struct { description string diff --git a/internal/pkg/fileutils/file_utils.go b/internal/pkg/fileutils/file_utils.go index 69a0234c6..b20bb7779 100644 --- a/internal/pkg/fileutils/file_utils.go +++ b/internal/pkg/fileutils/file_utils.go @@ -5,6 +5,8 @@ import ( "os" ) +// WriteToFile writes the given content to a file. +// If the file already exists, it will be overwritten. func WriteToFile(outputFileName, content string) (err error) { fo, err := os.Create(outputFileName) if err != nil { @@ -26,3 +28,39 @@ func WriteToFile(outputFileName, content string) (err error) { } return err } + +// 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, false 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 +} + +// CopyFile copies the contents of a file to another file. +// If the destination file already exists, it will be overwritten. +func CopyFile(src, dst string) (err error) { + contents, err := os.ReadFile(src) + if err != nil { + return fmt.Errorf("read source file: %w", err) + } + + err = WriteToFile(dst, string(contents)) + if err != nil { + return fmt.Errorf("write destination file: %w", err) + } + + return nil +} diff --git a/internal/pkg/fileutils/file_utils_test.go b/internal/pkg/fileutils/file_utils_test.go index 746249353..e979bef94 100644 --- a/internal/pkg/fileutils/file_utils_test.go +++ b/internal/pkg/fileutils/file_utils_test.go @@ -2,6 +2,7 @@ package fileutils import ( "os" + "path/filepath" "testing" ) @@ -31,13 +32,147 @@ func TestWriteToFile(t *testing.T) { t.Fatalf("unexpected error: %s", err.Error()) } if string(output) != tt.content { - t.Errorf("unexpected output: got %q, want %q", output, tt.content) + t.Fatalf("unexpected output: got %q, want %q", output, tt.content) } }) } // Cleanup err := os.RemoveAll(outputFilePath) if err != nil { - t.Errorf("failed cleaning test data") + t.Fatalf("failed cleaning test data") + } +} + +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.Fatalf("read file: %v", err) + } + if exists != tt.exists { + t.Fatalf("expected exists to be %t but got %t", tt.exists, exists) + } + if content != tt.content { + t.Fatalf("expected content to be %q but got %q", tt.content, content) + } + }) + } +} + +func TestCopyFile(t *testing.T) { + tests := []struct { + description string + srcExists bool + destExists bool + content string + isValid bool + }{ + { + description: "copy file", + srcExists: true, + content: "my-content", + isValid: true, + }, + { + description: "copy empty file", + srcExists: true, + content: "", + isValid: true, + }, + { + description: "copy non-existent file", + srcExists: false, + content: "", + isValid: false, + }, + { + description: "copy file to existing file", + srcExists: true, + destExists: true, + content: "my-content", + isValid: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + basePath := filepath.Join(os.TempDir(), "test-data") + src := filepath.Join(basePath, "file-with-content.txt") + dst := filepath.Join(basePath, "file-with-content-copy.txt") + + err := os.MkdirAll(basePath, os.ModePerm) + if err != nil { + t.Fatalf("unexpected error: %s", err.Error()) + } + + if tt.srcExists { + err := WriteToFile(src, tt.content) + if err != nil { + t.Fatalf("unexpected error: %s", err.Error()) + } + } + + if tt.destExists { + err := WriteToFile(dst, "existing-content") + if err != nil { + t.Fatalf("unexpected error: %s", err.Error()) + } + } + + err = CopyFile(src, dst) + if err != nil { + if tt.isValid { + t.Fatalf("unexpected error: %s", err.Error()) + } + return + } + if !tt.isValid { + t.Fatalf("expected error but got none") + } + + content, exists, err := ReadFileIfExists(dst) + if err != nil { + t.Fatalf("read file: %v", err) + } + + if !exists { + t.Fatalf("expected file to exist but it does not") + } + + if content != tt.content { + t.Fatalf("expected content to be %q but got %q", tt.content, content) + } + + // Cleanup + err = os.RemoveAll(basePath) + if err != nil { + t.Fatalf("failed cleaning test data") + } + }) } } diff --git a/internal/pkg/fileutils/test-data/empty-file.txt b/internal/pkg/fileutils/test-data/empty-file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/internal/pkg/fileutils/test-data/file-with-content.txt b/internal/pkg/fileutils/test-data/file-with-content.txt new file mode 100644 index 000000000..d89fbd214 --- /dev/null +++ b/internal/pkg/fileutils/test-data/file-with-content.txt @@ -0,0 +1 @@ +my-content \ No newline at end of file diff --git a/internal/pkg/fileutils/test-data/folder-exists/dummy.txt b/internal/pkg/fileutils/test-data/folder-exists/dummy.txt new file mode 100644 index 000000000..e69de29bb diff --git a/internal/pkg/print/print.go b/internal/pkg/print/print.go index 8e0ba53b5..470fb49ac 100644 --- a/internal/pkg/print/print.go +++ b/internal/pkg/print/print.go @@ -16,7 +16,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" ) @@ -28,6 +28,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" @@ -61,7 +65,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 } @@ -71,7 +75,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 } @@ -175,7 +179,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"