mygithub.libinneed.workers.dev/stackitcloud/stackit-cli
Advanced tools
Sorry, the diff of this file is not supported yet
| ## 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 | ||
| ## 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 | ||
| ## 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 | ||
| ## 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 | ||
| ## 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 | ||
| ## 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 | ||
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
| 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 | ||
| } |
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
| 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 | ||
| } |
| 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 | ||
| } | ||
| } |
| 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)) | ||
| } |
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
| 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 | ||
| } |
| 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 | ||
| } |
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
| 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 | ||
| } |
@@ -7,9 +7,9 @@ ## stackit config | ||
| 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. | ||
| ``` | ||
@@ -39,4 +39,5 @@ stackit config [flags] | ||
| * [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 | ||
+10
-10
@@ -6,3 +6,3 @@ module github.com/stackitcloud/stackit-cli | ||
| require ( | ||
| github.com/fatih/color v1.14.1 | ||
| github.com/fatih/color v1.17.0 | ||
| github.com/goccy/go-yaml v1.11.3 | ||
@@ -18,3 +18,3 @@ github.com/golang-jwt/jwt/v5 v5.2.1 | ||
| github.com/spf13/pflag v1.0.5 | ||
| github.com/spf13/viper v1.18.2 | ||
| github.com/spf13/viper v1.19.0 | ||
| github.com/stackitcloud/stackit-sdk-go/core v0.12.0 | ||
@@ -29,3 +29,3 @@ github.com/stackitcloud/stackit-sdk-go/services/authorization v0.3.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.4.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/ske v0.15.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/ske v0.16.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.2.0 | ||
@@ -48,3 +48,3 @@ github.com/zalando/go-keyring v0.2.4 | ||
| require ( | ||
| github.com/mattn/go-isatty v0.0.17 // indirect | ||
| github.com/mattn/go-isatty v0.0.20 // indirect | ||
| golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect | ||
@@ -59,3 +59,3 @@ ) | ||
| github.com/fsnotify/fsnotify v1.7.0 // indirect | ||
| github.com/go-logr/logr v1.3.0 // indirect | ||
| github.com/go-logr/logr v1.4.1 // indirect | ||
| github.com/godbus/dbus/v5 v5.1.0 // indirect | ||
@@ -73,3 +73,3 @@ github.com/gogo/protobuf v1.3.2 // indirect | ||
| github.com/modern-go/reflect2 v1.0.2 // indirect | ||
| github.com/pelletier/go-toml/v2 v2.1.1 // indirect | ||
| github.com/pelletier/go-toml/v2 v2.2.2 // indirect | ||
| github.com/rivo/uniseg v0.4.4 // indirect | ||
@@ -84,7 +84,7 @@ github.com/russross/blackfriday/v2 v2.1.0 // indirect | ||
| github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.12.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/logme v0.14.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.14.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/logme v0.15.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.15.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.9.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.14.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/redis v0.14.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.15.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/redis v0.15.0 | ||
| github.com/subosito/gotenv v1.6.0 // indirect | ||
@@ -91,0 +91,0 @@ go.uber.org/multierr v1.11.0 // indirect |
+27
-23
@@ -13,4 +13,4 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= | ||
| github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= | ||
| github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= | ||
| github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= | ||
| github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= | ||
| github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= | ||
| github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= | ||
@@ -20,4 +20,5 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= | ||
| github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= | ||
| github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= | ||
| github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | ||
| github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= | ||
| github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | ||
| github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= | ||
@@ -86,4 +87,4 @@ github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= | ||
| github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= | ||
| github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= | ||
| github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= | ||
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | ||
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||
| github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= | ||
@@ -100,4 +101,4 @@ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= | ||
| github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= | ||
| github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= | ||
| github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= | ||
| github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= | ||
| github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= | ||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||
@@ -127,4 +128,4 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= | ||
| github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | ||
| github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= | ||
| github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= | ||
| github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= | ||
| github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= | ||
| github.com/stackitcloud/stackit-sdk-go/core v0.12.0 h1:auIzUUNRuydKOScvpICP4MifGgvOajiDQd+ncGmBL0U= | ||
@@ -140,6 +141,6 @@ github.com/stackitcloud/stackit-sdk-go/core v0.12.0/go.mod h1:mDX1mSTsB3mP+tNBGcFNx6gH1mGBN4T+dVt+lcw7nlw= | ||
| github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.12.0/go.mod h1:wsO3+vXe1XiKLeCIctWAptaHQZ07Un7kmLTQ+drbj7w= | ||
| github.com/stackitcloud/stackit-sdk-go/services/logme v0.14.0 h1:vvQFCN5sKZA9tdzrbDnAVMsaTijX8lvTYnPaKQHmkoI= | ||
| github.com/stackitcloud/stackit-sdk-go/services/logme v0.14.0/go.mod h1:bj9cn1treNSxKTRCEmESwqfENN8vCYn60HUnEA0P83c= | ||
| github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.14.0 h1:tK6imWrbZ5TgQJbukWCUz7yDgcvvFMX8wamxkPTLuDo= | ||
| github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.14.0/go.mod h1:kPetkX9hNm9HkRyiKQL/tlgdi8frZdMP8afg0mEvQ9s= | ||
| github.com/stackitcloud/stackit-sdk-go/services/logme v0.15.0 h1:7gii3PZshOesHPCYlPycilXglk28imITIqjewySZwZ4= | ||
| github.com/stackitcloud/stackit-sdk-go/services/logme v0.15.0/go.mod h1:bj9cn1treNSxKTRCEmESwqfENN8vCYn60HUnEA0P83c= | ||
| github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.15.0 h1:eYYyVUTS9Gjovg3z9+r6ctvsm1p1J4fHLa5QJbWHi0A= | ||
| github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.15.0/go.mod h1:kPetkX9hNm9HkRyiKQL/tlgdi8frZdMP8afg0mEvQ9s= | ||
| github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.14.0 h1:FaJYVfha+atvPfFIf3h3+BFjOjeux9OBHukG1J98kq0= | ||
@@ -153,6 +154,6 @@ github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.14.0/go.mod h1:iFerEzGmkg6R13ldFUyHUWHm0ac9cS4ftTDLhP0k/dU= | ||
| github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.14.0/go.mod h1:SdrqGLCkilL6wl1+jcxmLtks2IocgIg+bsyeyYUIzR4= | ||
| github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.14.0 h1:wJ+LSMrRol4wlm/ML4wvVPGwIw51VHMFwMCOtwluvKQ= | ||
| github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.14.0/go.mod h1:eSgnPBknTJh7t+jVKN+xzeAh+Cg1USOlH3QCyfvG20g= | ||
| github.com/stackitcloud/stackit-sdk-go/services/redis v0.14.0 h1:wcfA/3mTI7UmTFmKX09EKIVsEqflfkiuEoWL/j5cMvg= | ||
| github.com/stackitcloud/stackit-sdk-go/services/redis v0.14.0/go.mod h1:3LhiTR/DMbKR2HuleTzlFHltR1MT1KD0DeW46X6K2GE= | ||
| github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.15.0 h1:Q7JxjVwb+9ugAX71AXdbfPL87HHmIIwb9LNahn6H/2o= | ||
| github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.15.0/go.mod h1:eSgnPBknTJh7t+jVKN+xzeAh+Cg1USOlH3QCyfvG20g= | ||
| github.com/stackitcloud/stackit-sdk-go/services/redis v0.15.0 h1:/S+LOl94FqGk5Qdi5ehsiSCh6cCPEYJDctNOD0c2dmw= | ||
| github.com/stackitcloud/stackit-sdk-go/services/redis v0.15.0/go.mod h1:3LhiTR/DMbKR2HuleTzlFHltR1MT1KD0DeW46X6K2GE= | ||
| github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.8.0 h1:7AIvLkB7JZ5lYKtYLwI0rgJ0185hwQC1PFiUrjcinDM= | ||
@@ -164,4 +165,4 @@ github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.8.0/go.mod h1:p16qz/pAW8b1gEhqMpIgJfutRPeDPqQLlbVGyCo3f8o= | ||
| github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.4.0/go.mod h1:Ni9RBJvcaXRIrDIuQBpJcuQvCQSj27crQSyc+WM4p0c= | ||
| github.com/stackitcloud/stackit-sdk-go/services/ske v0.15.0 h1:7iTzdiglvJmKMaHlr4JUPvNOmA730rAniry74cnZ8zI= | ||
| github.com/stackitcloud/stackit-sdk-go/services/ske v0.15.0/go.mod h1:0fFs4R7kg+gU7FNAIzzFvlCZJz6gyZ8CFhbK3eSrAwQ= | ||
| github.com/stackitcloud/stackit-sdk-go/services/ske v0.16.0 h1:trrJuRMzgXu6fiiMZiUx6+A1FNKEFhA1vGq5cr5Qn3U= | ||
| github.com/stackitcloud/stackit-sdk-go/services/ske v0.16.0/go.mod h1:0fFs4R7kg+gU7FNAIzzFvlCZJz6gyZ8CFhbK3eSrAwQ= | ||
| github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.2.0 h1:aIXxXx6u4+6C02MPb+hdItigeKeen7m+hEEG+Ej9sNs= | ||
@@ -171,9 +172,11 @@ github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.2.0/go.mod h1:fQJOQMfasStZ8J9iGX0vTjyJoQtLqMXJ5Npb03QJk84= | ||
| github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= | ||
| github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= | ||
| github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= | ||
| github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= | ||
| github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= | ||
| github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= | ||
| github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||
| github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= | ||
| github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= | ||
| github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= | ||
| github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= | ||
| github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | ||
| github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= | ||
@@ -213,2 +216,3 @@ github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= | ||
| golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= | ||
@@ -234,4 +238,4 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||
| golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= | ||
| google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= | ||
| google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= | ||
| google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= | ||
| google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= | ||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||
@@ -238,0 +242,0 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= |
@@ -7,2 +7,3 @@ package config | ||
| "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" | ||
@@ -21,8 +22,8 @@ "github.com/stackitcloud/stackit-cli/internal/cmd/config/unset" | ||
| 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.", | ||
| ), | ||
@@ -40,2 +41,3 @@ Args: args.NoArgs, | ||
| cmd.AddCommand(unset.NewCmd(p)) | ||
| cmd.AddCommand(profile.NewCmd(p)) | ||
| } |
@@ -54,3 +54,9 @@ package list | ||
| 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) | ||
| }, | ||
@@ -69,5 +75,8 @@ } | ||
| 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, "", " ") | ||
@@ -88,2 +97,3 @@ if err != nil { | ||
| default: | ||
| // Sort the config options by key | ||
@@ -97,2 +107,5 @@ configKeys := make([]string, 0, len(configData)) | ||
| table := tables.NewTable() | ||
| if activeProfile != "" { | ||
| table.SetTitle(fmt.Sprintf("Profile: %q", activeProfile)) | ||
| } | ||
| table.SetHeader("NAME", "VALUE") | ||
@@ -99,0 +112,0 @@ for _, key := range configKeys { |
+29
-7
@@ -12,3 +12,3 @@ package cmd | ||
| "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" | ||
@@ -31,2 +31,3 @@ "github.com/stackitcloud/stackit-cli/internal/cmd/dns" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/args" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/config" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/errors" | ||
@@ -37,3 +38,2 @@ "github.com/stackitcloud/stackit-cli/internal/pkg/flags" | ||
| "github.com/fatih/color" | ||
| "github.com/spf13/cobra" | ||
@@ -52,3 +52,3 @@ "github.com/spf13/viper" | ||
| DisableAutoGenTag: true, | ||
| PersistentPreRun: func(cmd *cobra.Command, args []string) { | ||
| PersistentPreRunE: func(cmd *cobra.Command, args []string) error { | ||
| p.Cmd = cmd | ||
@@ -61,7 +61,29 @@ p.Verbosity = print.Level(globalflags.Parse(p, cmd).Verbosity) | ||
| 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 | ||
| }, | ||
@@ -104,3 +126,3 @@ RunE: func(cmd *cobra.Command, args []string) error { | ||
| func beautifyUsageTemplate(cmd *cobra.Command) { | ||
| cobra.AddTemplateFunc("WhiteBold", color.New(color.FgHiWhite, color.Bold).SprintFunc()) | ||
| cobra.AddTemplateFunc("WhiteBold", print.WhiteBold) | ||
| usageTemplate := cmd.UsageTemplate() | ||
@@ -133,4 +155,4 @@ usageTemplate = strings.NewReplacer( | ||
| 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)) | ||
@@ -137,0 +159,0 @@ cmd.AddCommand(dns.NewCmd(p)) |
@@ -92,3 +92,3 @@ package create | ||
| respKubeconfig *ske.Kubeconfig | ||
| respLogin *ske.V1LoginKubeconfig | ||
| respLogin *ske.LoginKubeconfig | ||
| ) | ||
@@ -214,3 +214,3 @@ | ||
| func outputResult(p *print.Printer, model *inputModel, kubeconfigPath string, respKubeconfig *ske.Kubeconfig, respLogin *ske.V1LoginKubeconfig) error { | ||
| func outputResult(p *print.Printer, model *inputModel, kubeconfigPath string, respKubeconfig *ske.Kubeconfig, respLogin *ske.LoginKubeconfig) error { | ||
| switch model.OutputFormat { | ||
@@ -217,0 +217,0 @@ case print.JSONOutputFormat: |
@@ -13,2 +13,4 @@ package auth | ||
| "github.com/zalando/go-keyring" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/config" | ||
| ) | ||
@@ -117,2 +119,7 @@ | ||
| t.Run(tt.description, func(t *testing.T) { | ||
| activeProfile, err := config.GetProfile() | ||
| if err != nil { | ||
| t.Errorf("get profile: %v", err) | ||
| } | ||
| if !tt.keyringFails { | ||
@@ -145,3 +152,3 @@ keyring.MockInit() | ||
| if !tt.keyringFails { | ||
| err = deleteAuthFieldInKeyring(key) | ||
| err = deleteAuthFieldInKeyring(activeProfile, key) | ||
| if err != nil { | ||
@@ -151,3 +158,3 @@ t.Errorf("Post-test cleanup failed: remove field \"%s\" from keyring: %v. Please remove it manually", key, err) | ||
| } else { | ||
| err = deleteAuthFieldInEncodedTextFile(key) | ||
| err = deleteAuthFieldInEncodedTextFile(activeProfile, key) | ||
| if err != nil { | ||
@@ -162,2 +169,165 @@ 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(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 TestSetGetAuthFieldKeyring(t *testing.T) { | ||
@@ -180,5 +350,7 @@ var testField1 authFieldKey = "test-field-1" | ||
| expectedValues map[authFieldKey]string | ||
| activeProfile string | ||
| }{ | ||
| { | ||
| description: "simple assignments", | ||
| description: "simple assignments with default profile", | ||
| activeProfile: config.DefaultProfileName, | ||
| valueAssignments: []valueAssignment{ | ||
@@ -200,3 +372,4 @@ { | ||
| { | ||
| description: "overlapping assignments", | ||
| description: "overlapping assignments with default profile", | ||
| activeProfile: config.DefaultProfileName, | ||
| valueAssignments: []valueAssignment{ | ||
@@ -221,2 +394,42 @@ { | ||
| }, | ||
| { | ||
| 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, | ||
| value: testValue1, | ||
| }, | ||
| { | ||
| key: testField2, | ||
| value: testValue2, | ||
| }, | ||
| { | ||
| key: testField1, | ||
| value: testValue3, | ||
| }, | ||
| }, | ||
| expectedValues: map[authFieldKey]string{ | ||
| testField1: testValue3, | ||
| testField2: testValue2, | ||
| }, | ||
| }, | ||
| } | ||
@@ -228,4 +441,13 @@ | ||
| // 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 { | ||
@@ -241,3 +463,3 @@ t.Fatalf("Failed to set \"%s\" as \"%s\": %v", assignment.key, assignment.value, err) | ||
| for key, valueExpected := range tt.expectedValues { | ||
| value, err := getAuthFieldFromKeyring(key) | ||
| value, err := getAuthFieldFromKeyring(tt.activeProfile, key) | ||
| if err != nil { | ||
@@ -250,3 +472,3 @@ t.Errorf("Failed to get value of \"%s\": %v", key, err) | ||
| err = deleteAuthFieldInKeyring(key) | ||
| err = deleteAuthFieldInKeyring(tt.activeProfile, key) | ||
| if err != nil { | ||
@@ -260,2 +482,163 @@ t.Errorf("Post-test cleanup failed: remove field \"%s\" from keyring: %v. Please remove it manually", key, err) | ||
| 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) { | ||
@@ -276,2 +659,3 @@ var testField1 authFieldKey = "test-field-1" | ||
| description string | ||
| activeProfile string | ||
| valueAssignments []valueAssignment | ||
@@ -281,3 +665,4 @@ expectedValues map[authFieldKey]string | ||
| { | ||
| description: "simple assignments", | ||
| description: "simple assignments with default profile", | ||
| activeProfile: config.DefaultProfileName, | ||
| valueAssignments: []valueAssignment{ | ||
@@ -299,3 +684,4 @@ { | ||
| { | ||
| description: "overlapping assignments", | ||
| description: "overlapping assignments with default profile", | ||
| activeProfile: config.DefaultProfileName, | ||
| valueAssignments: []valueAssignment{ | ||
@@ -320,2 +706,42 @@ { | ||
| }, | ||
| { | ||
| 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, | ||
| value: testValue1, | ||
| }, | ||
| { | ||
| key: testField2, | ||
| value: testValue2, | ||
| }, | ||
| { | ||
| key: testField1, | ||
| value: testValue3, | ||
| }, | ||
| }, | ||
| expectedValues: map[authFieldKey]string{ | ||
| testField1: testValue3, | ||
| testField2: testValue2, | ||
| }, | ||
| }, | ||
| } | ||
@@ -325,4 +751,13 @@ | ||
| 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 { | ||
@@ -338,3 +773,3 @@ t.Fatalf("Failed to set \"%s\" as \"%s\": %v", assignment.key, assignment.value, err) | ||
| for key, valueExpected := range tt.expectedValues { | ||
| value, err := getAuthFieldFromEncodedTextFile(key) | ||
| value, err := getAuthFieldFromEncodedTextFile(tt.activeProfile, key) | ||
| if err != nil { | ||
@@ -347,3 +782,3 @@ t.Errorf("Failed to get value of \"%s\": %v", key, err) | ||
| err = deleteAuthFieldInEncodedTextFile(key) | ||
| err = deleteAuthFieldInEncodedTextFile(tt.activeProfile, key) | ||
| if err != nil { | ||
@@ -353,2 +788,7 @@ 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) | ||
| } | ||
| }) | ||
@@ -358,8 +798,162 @@ } | ||
| 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 { | ||
@@ -369,7 +963,3 @@ 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) | ||
@@ -404,1 +994,22 @@ | ||
| } | ||
| 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")) | ||
| } |
+115
-27
@@ -6,6 +6,11 @@ package auth | ||
| "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" | ||
@@ -46,2 +51,17 @@ ) | ||
| // 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 { | ||
@@ -63,5 +83,14 @@ return SetAuthField(authFlowType, string(value)) | ||
| func SetAuthField(key authFieldKey, value string) error { | ||
| err := setAuthFieldInKeyring(key, value) | ||
| activeProfile, err := config.GetProfile() | ||
| if err != nil { | ||
| errFallback := setAuthFieldInEncodedTextFile(key, value) | ||
| return fmt.Errorf("get profile: %w", err) | ||
| } | ||
| return setAuthFieldWithProfile(activeProfile, key, value) | ||
| } | ||
| func setAuthFieldWithProfile(profile string, key authFieldKey, value string) error { | ||
| err := setAuthFieldInKeyring(profile, key, value) | ||
| if err != nil { | ||
| errFallback := setAuthFieldInEncodedTextFile(profile, key, value) | ||
| if errFallback != nil { | ||
@@ -74,17 +103,25 @@ return fmt.Errorf("write to keyring failed (%w), try writing to encoded text file: %w", err, errFallback) | ||
| 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) | ||
@@ -138,6 +175,14 @@ | ||
| 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 { | ||
@@ -150,8 +195,12 @@ return "", fmt.Errorf("read from keyring: %w, read from encoded file as fallback: %w", err, errFallback) | ||
| 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 { | ||
@@ -161,7 +210,3 @@ 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) | ||
@@ -192,11 +237,7 @@ | ||
| // 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 { | ||
@@ -218,1 +259,48 @@ return fmt.Errorf("create file dir: %w", err) | ||
| } | ||
| // 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 | ||
| } |
@@ -49,3 +49,3 @@ package cache | ||
| if tt.expectFile { | ||
| err := createFolderIfNotExists(cacheFolderPath) | ||
| err := os.MkdirAll(cacheFolderPath, os.ModePerm) | ||
| if err != nil { | ||
@@ -52,0 +52,0 @@ t.Fatalf("create cache folder: %s", err.Error()) |
@@ -46,3 +46,3 @@ package cache | ||
| err := createFolderIfNotExists(cacheFolderPath) | ||
| err := os.MkdirAll(cacheFolderPath, os.ModePerm) | ||
| if err != nil { | ||
@@ -69,15 +69,2 @@ return err | ||
| 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 { | ||
@@ -84,0 +71,0 @@ if cacheFolderPath == "" { |
| package config | ||
| import ( | ||
| "fmt" | ||
| "os" | ||
@@ -36,6 +37,6 @@ "path/filepath" | ||
| 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 { | ||
@@ -65,3 +66,3 @@ t.Fatalf("expected error to be nil, got %v", err) | ||
| if tt.folderName != "" { | ||
| err = os.Remove(folderPath) | ||
| err = os.Remove(configFolderPath) | ||
| if err != nil { | ||
@@ -74,1 +75,56 @@ t.Fatalf("expected error to be nil, got %v", err) | ||
| } | ||
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
@@ -38,2 +38,5 @@ package config | ||
| ProjectNameKey = "project_name" | ||
| DefaultProfileName = "default" | ||
| AsyncDefault = false | ||
@@ -43,8 +46,11 @@ 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" | ||
| ) | ||
@@ -79,13 +85,17 @@ | ||
| 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` | ||
@@ -115,18 +125,6 @@ // see https://github.com/spf13/viper/issues/851#issuecomment-789393451 | ||
| 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) | ||
@@ -157,1 +155,20 @@ } | ||
| } | ||
| 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)) | ||
| } |
@@ -126,2 +126,28 @@ package errors | ||
| 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) { | ||
@@ -128,0 +154,0 @@ tests := []struct { |
@@ -41,2 +41,14 @@ package errors | ||
| 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. | ||
@@ -119,2 +131,6 @@ | ||
| 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: | ||
@@ -148,2 +164,26 @@ $ %s --help` | ||
| 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 { | ||
@@ -322,1 +362,9 @@ Cmd *cobra.Command | ||
| } | ||
| type InvalidProfileNameError struct { | ||
| Profile string | ||
| } | ||
| func (e *InvalidProfileNameError) Error() string { | ||
| return fmt.Sprintf(INVALID_PROFILE_NAME, e.Profile) | ||
| } |
@@ -5,2 +5,3 @@ package fileutils | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
@@ -35,3 +36,3 @@ ) | ||
| 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) | ||
| } | ||
@@ -43,4 +44,138 @@ }) | ||
| 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") | ||
| } | ||
| }) | ||
| } | ||
| } |
@@ -8,2 +8,4 @@ package fileutils | ||
| // WriteToFile writes the given content to a file. | ||
| // If the file already exists, it will be overwritten. | ||
| func WriteToFile(outputFileName, content string) (err error) { | ||
@@ -30,1 +32,37 @@ fo, err := os.Create(outputFileName) | ||
| } | ||
| // 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 | ||
| } |
@@ -13,3 +13,2 @@ package print | ||
| "github.com/spf13/viper" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/config" | ||
| ) | ||
@@ -70,3 +69,3 @@ | ||
| if tt.outputFormatNone { | ||
| viper.Set(config.OutputFormatKey, NoneOutputFormat) | ||
| viper.Set(outputFormatKey, NoneOutputFormat) | ||
| } | ||
@@ -142,3 +141,3 @@ | ||
| if tt.outputFormatNone { | ||
| viper.Set(config.OutputFormatKey, NoneOutputFormat) | ||
| viper.Set(outputFormatKey, NoneOutputFormat) | ||
| } | ||
@@ -207,3 +206,3 @@ | ||
| if tt.outputFormatNone { | ||
| viper.Set(config.OutputFormatKey, NoneOutputFormat) | ||
| viper.Set(outputFormatKey, NoneOutputFormat) | ||
| } | ||
@@ -210,0 +209,0 @@ |
@@ -14,2 +14,3 @@ package print | ||
| "github.com/fatih/color" | ||
| "github.com/lmittmann/tint" | ||
@@ -19,3 +20,3 @@ "github.com/mattn/go-colorable" | ||
| "github.com/spf13/viper" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/config" | ||
| "golang.org/x/term" | ||
@@ -32,2 +33,6 @@ ) | ||
| // Needed to avoid import cycle | ||
| // Originally defined in "internal/pkg/config/config.go" | ||
| outputFormatKey = "output-format" | ||
| JSONOutputFormat = "json" | ||
@@ -39,4 +44,10 @@ PrettyOutputFormat = "pretty" | ||
| var errAborted = errors.New("operation aborted") | ||
| var ( | ||
| errAborted = errors.New("operation aborted") | ||
| WhiteBold = color.New(color.FgHiWhite, color.Bold).SprintFunc() | ||
| RedBold = color.New(color.FgHiRed, color.Bold).SprintFunc() | ||
| YellowBold = color.New(color.FgHiYellow, color.Bold).SprintFunc() | ||
| ) | ||
| type Printer struct { | ||
@@ -61,3 +72,3 @@ Cmd *cobra.Command | ||
| func (p *Printer) Outputf(msg string, args ...any) { | ||
| outputFormat := viper.GetString(config.OutputFormatKey) | ||
| outputFormat := viper.GetString(outputFormatKey) | ||
| if outputFormat == NoneOutputFormat { | ||
@@ -72,3 +83,3 @@ return | ||
| func (p *Printer) Outputln(msg string) { | ||
| outputFormat := viper.GetString(config.OutputFormatKey) | ||
| outputFormat := viper.GetString(outputFormatKey) | ||
| if outputFormat == NoneOutputFormat { | ||
@@ -115,3 +126,3 @@ return | ||
| warning := fmt.Sprintf(msg, args...) | ||
| p.Cmd.PrintErrf("Warning: %s", warning) | ||
| p.Cmd.PrintErrf("%s %s", YellowBold("Warning:"), warning) | ||
| } | ||
@@ -122,3 +133,3 @@ | ||
| err := fmt.Sprintf(msg, args...) | ||
| p.Cmd.PrintErrln(p.Cmd.ErrPrefix(), err) | ||
| p.Cmd.PrintErrln(RedBold(p.Cmd.ErrPrefix()), err) | ||
| } | ||
@@ -179,3 +190,3 @@ | ||
| func (p *Printer) PagerDisplay(content string) error { | ||
| outputFormat := viper.GetString(config.OutputFormatKey) | ||
| outputFormat := viper.GetString(outputFormatKey) | ||
| if outputFormat == NoneOutputFormat { | ||
@@ -182,0 +193,0 @@ return nil |
+1
-1
@@ -8,3 +8,3 @@ package main | ||
| // These values are dynamically overridden by GoReleaser | ||
| // These values are overwritten by GoReleaser at build time | ||
| var ( | ||
@@ -11,0 +11,0 @@ version = "DEV" |
+6
-0
| [](https://goreportcard.com/report/github.com/stackitcloud/stackit-cli)  [](https://www.apache.org/licenses/LICENSE-2.0) | ||
| <br> | ||
| <p align="center"> | ||
| <img src=".github/images/stackit-logo.png" alt="STACKIT logo" width="50%"/> | ||
| </p> | ||
| <br> | ||
| # STACKIT CLI (BETA) | ||
@@ -4,0 +10,0 @@ |