diff --git a/docs/stackit_config_profile.md b/docs/stackit_config_profile.md index 415ac9d06..3e89ab27a 100644 --- a/docs/stackit_config_profile.md +++ b/docs/stackit_config_profile.md @@ -34,6 +34,7 @@ stackit config profile [flags] * [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 export](./stackit_config_profile_export.md) - Exports a CLI configuration profile * [stackit config profile import](./stackit_config_profile_import.md) - Imports 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 diff --git a/docs/stackit_config_profile_export.md b/docs/stackit_config_profile_export.md new file mode 100644 index 000000000..86265e493 --- /dev/null +++ b/docs/stackit_config_profile_export.md @@ -0,0 +1,43 @@ +## stackit config profile export + +Exports a CLI configuration profile + +### Synopsis + +Exports a CLI configuration profile. + +``` +stackit config profile export PROFILE_NAME [flags] +``` + +### Examples + +``` + Export a profile with name "PROFILE_NAME" to a file in your current directory + $ stackit config profile export PROFILE_NAME + + Export a profile with name "PROFILE_NAME"" to a specific file path FILE_PATH + $ stackit config profile export PROFILE_NAME --file-path FILE_PATH +``` + +### Options + +``` + -f, --file-path string If set, writes the config to the given file path. If unset, writes the config to you current directory with the name of the profile. E.g. '--file-path ~/my-config.json' + -h, --help Help for "stackit config profile export" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit config profile](./stackit_config_profile.md) - Manage the CLI configuration profiles + diff --git a/internal/cmd/config/profile/export/export.go b/internal/cmd/config/profile/export/export.go new file mode 100644 index 000000000..9aa585971 --- /dev/null +++ b/internal/cmd/config/profile/export/export.go @@ -0,0 +1,97 @@ +package export + +import ( + "fmt" + "path/filepath" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "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 ( + profileNameArg = "PROFILE_NAME" + + filePathFlag = "file-path" + + configFileExtension = "json" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ProfileName string + FilePath string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("export %s", profileNameArg), + Short: "Exports a CLI configuration profile", + Long: "Exports a CLI configuration profile.", + Example: examples.Build( + examples.NewExample( + `Export a profile with name "PROFILE_NAME" to a file in your current directory`, + "$ stackit config profile export PROFILE_NAME", + ), + examples.NewExample( + `Export a profile with name "PROFILE_NAME"" to a specific file path FILE_PATH`, + "$ stackit config profile export PROFILE_NAME --file-path FILE_PATH", + ), + ), + Args: args.SingleArg(profileNameArg, nil), + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + err = config.ExportProfile(p, model.ProfileName, model.FilePath) + if err != nil { + return fmt.Errorf("could not export profile: %w", err) + } + + p.Info("Exported profile %q to %q\n", model.ProfileName, model.FilePath) + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the config to the given file path. If unset, writes the config to you current directory with the name of the profile. E.g. '--file-path ~/my-config.json'") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + profileName := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: globalFlags, + ProfileName: profileName, + FilePath: flags.FlagToStringValue(p, cmd, filePathFlag), + } + + // If filePath contains does not contain a file name, then add a default name + if model.FilePath == "" { + exportFileName := fmt.Sprintf("%s.%s", model.ProfileName, configFileExtension) + model.FilePath = filepath.Join(model.FilePath, exportFileName) + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} diff --git a/internal/cmd/config/profile/export/export_test.go b/internal/cmd/config/profile/export/export_test.go new file mode 100644 index 000000000..4dffd3a22 --- /dev/null +++ b/internal/cmd/config/profile/export/export_test.go @@ -0,0 +1,154 @@ +package export + +import ( + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" +) + +const ( + testProfileArg = "default" + testExportPath = "/tmp/stackit-profiles/" + testProfileArg + ".json" +) + +func fixtureArgValues(mods ...func(args []string)) []string { + args := []string{ + testProfileArg, + } + for _, mod := range mods { + mod(args) + } + return args +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + filePathFlag: testExportPath, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(inputModel *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + ProfileName: testProfileArg, + FilePath: testExportPath, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argsValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argsValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no args", + argsValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flags", + argsValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: true, + expectedModel: fixtureInputModel(func(inputModel *inputModel) { + inputModel.FilePath = fmt.Sprintf("%s.json", testProfileArg) + }), + }, + { + description: "custom file-path without file extension", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + flagValues[filePathFlag] = "./my-exported-config" + }), + isValid: true, + expectedModel: fixtureInputModel(func(inputModel *inputModel) { + inputModel.FilePath = "./my-exported-config" + }), + }, + } + + 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.argsValues) + 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.argsValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/config/profile/profile.go b/internal/cmd/config/profile/profile.go index c24d3a921..848a60382 100644 --- a/internal/cmd/config/profile/profile.go +++ b/internal/cmd/config/profile/profile.go @@ -5,6 +5,7 @@ import ( "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/export" importProfile "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/import" "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/list" "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/set" @@ -40,4 +41,5 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(list.NewCmd(p)) cmd.AddCommand(delete.NewCmd(p)) cmd.AddCommand(importProfile.NewCmd(p)) + cmd.AddCommand(export.NewCmd(p)) } diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index b64762d83..91ce0bb5a 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -105,7 +105,11 @@ var configFolderPath string var profileFilePath string func InitConfig() { - defaultConfigFolderPath = getInitialConfigDir() + initConfig(getInitialConfigDir()) +} + +func initConfig(configPath string) { + defaultConfigFolderPath = configPath profileFilePath = getInitialProfileFilePath() // Profile file path is in the default config folder configProfile, err := GetProfile() diff --git a/internal/pkg/config/profiles.go b/internal/pkg/config/profiles.go index f7b6c5f70..db47ce5d3 100644 --- a/internal/pkg/config/profiles.go +++ b/internal/pkg/config/profiles.go @@ -397,3 +397,42 @@ func ImportProfile(p *print.Printer, profileName, config string, setAsActive boo return nil } + +// ExportProfile exports a profile configuration +// Is exports the profile to the exportPath. The exportPath must contain the filename. +func ExportProfile(p *print.Printer, profile, exportPath string) error { + err := ValidateProfile(profile) + if err != nil { + return fmt.Errorf("validate profile name: %w", err) + } + + exists, err := ProfileExists(profile) + if err != nil { + return fmt.Errorf("check if profile exists: %w", err) + } + if !exists { + return &errors.ProfileDoesNotExistError{Profile: profile} + } + + profilePath := GetProfileFolderPath(profile) + configFile := getConfigFilePath(profilePath) + + stats, err := os.Stat(exportPath) + if err == nil { + if stats.IsDir() { + return fmt.Errorf("export path %q is a directory. Please specify a full path", exportPath) + } + return &errors.FileAlreadyExistsError{Filename: exportPath} + } + + err = fileutils.CopyFile(configFile, exportPath) + if err != nil { + return fmt.Errorf("export config file to %q: %w", exportPath, err) + } + + if p != nil { + p.Debug(print.DebugLevel, "exported profile %q to %q", profile, exportPath) + } + + return nil +} diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go index 9700ea978..327c9dcf8 100644 --- a/internal/pkg/config/profiles_test.go +++ b/internal/pkg/config/profiles_test.go @@ -3,6 +3,7 @@ package config import ( _ "embed" "fmt" + "os" "path/filepath" "testing" @@ -182,3 +183,90 @@ func TestImportProfile(t *testing.T) { }) } } + +func TestExportProfile(t *testing.T) { + // Create directory where the export configs should be stored + testDir, err := os.MkdirTemp(os.TempDir(), "stackit-cli-test") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + func(path string) { + err := os.RemoveAll(path) + if err != nil { + fmt.Printf("could not clean up temp dir: %v\n", err) + } + }(testDir) + }) + + // Create test config directory + testConfigFolderPath := filepath.Join(testDir, "config") + initConfig(testConfigFolderPath) + err = Write() + if err != nil { + t.Fatalf("could not write profile, %v", err) + } + + // Create prerequisite profile + p := print.NewPrinter() + profileName := "export-profile-test" + err = CreateProfile(p, profileName, true, false) + if err != nil { + t.Fatalf("could not create prerequisite profile, %v", err) + } + t.Cleanup(func() { + func(p *print.Printer, profile string) { + err := DeleteProfile(p, profile) + if err != nil { + fmt.Printf("could not clean up prerequisite profile %q, %v", profileName, err) + } + }(p, profileName) + }) + + tests := []struct { + description string + profile string + filePath string + isValid bool + }{ + { + description: "valid profile", + profile: profileName, + filePath: filepath.Join(testDir, fmt.Sprintf("custom-name.%s", configFileExtension)), + isValid: true, + }, + { + description: "invalid profile", + profile: "invalid-my-profile", + isValid: false, + }, + { + description: "not existing path", + profile: profileName, + filePath: filepath.Join(testDir, "invalid", "path", fmt.Sprintf("custom-name.%s", configFileExtension)), + isValid: false, + }, + { + description: "export without file extension", + profile: profileName, + filePath: filepath.Join(testDir, "file-without-extension"), + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + err := ExportProfile(p, tt.profile, tt.filePath) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("export should be valid but got error: %v\n", err) + } + if !tt.isValid { + t.Fatalf("export should be invalid but got no error\n") + } + }) + } +} diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index d61a8d9da..e67efde5b 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -155,6 +155,13 @@ To enable it, run: To delete it, run: $ stackit config profile delete %[1]s` + + PROFILE_DOES_NOT_EXIST = `The profile %q does not exist. + +To list all profiles, run: + $ stackit config profile list` + + FILE_ALREADY_EXISTS = `file %q already exists in the export path. Delete the existing file or define a different export path` ) type ServerNicAttachMissingNicIdError struct { @@ -450,3 +457,17 @@ type ProfileAlreadyExistsError struct { func (e *ProfileAlreadyExistsError) Error() string { return fmt.Sprintf(PROFILE_ALREADY_EXISTS, e.Profile) } + +type ProfileDoesNotExistError struct { + Profile string +} + +func (e *ProfileDoesNotExistError) Error() string { + return fmt.Sprintf(PROFILE_DOES_NOT_EXIST, e.Profile) +} + +type FileAlreadyExistsError struct { + Filename string +} + +func (e *FileAlreadyExistsError) Error() string { return fmt.Sprintf(FILE_ALREADY_EXISTS, e.Filename) }