From deb660cb0041d5fe2edb1702fffb27d7a62957ff Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Tue, 17 Dec 2024 17:45:20 +0100 Subject: [PATCH 01/10] Add command to export config profiles --- docs/stackit_config_profile.md | 1 + docs/stackit_config_profile_export.md | 43 ++++++ internal/cmd/config/profile/export/export.go | 86 +++++++++++ .../cmd/config/profile/export/export_test.go | 136 ++++++++++++++++++ internal/cmd/config/profile/profile.go | 2 + internal/pkg/config/profiles.go | 56 +++++++- internal/pkg/config/profiles_test.go | 57 ++++++++ internal/pkg/errors/errors.go | 21 +++ 8 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 docs/stackit_config_profile_export.md create mode 100644 internal/cmd/config/profile/export/export.go create mode 100644 internal/cmd/config/profile/export/export_test.go 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..ec112509c --- /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 the current path + $ 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 + +``` + --file-path string Path where the config should be saved. E.g. '--file-path ~/config.json', '--file-path ~/' + -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..51f1906f7 --- /dev/null +++ b/internal/cmd/config/profile/export/export.go @@ -0,0 +1,86 @@ +package export + +import ( + "fmt" + "github.com/spf13/cobra" + "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" +) + +const ( + profileNameArg = "PROFILE_NAME" + + filePathFlag = "file-path" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ProfileName string + ExportPath 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 the current path`, + "$ 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.ExportPath) + if err != nil { + return fmt.Errorf("could not export profile: %w", err) + } + + p.Info("Exported profile %q\n", model.ProfileName) + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(filePathFlag, "", "Path where the config should be saved. E.g. '--file-path ~/config.json', '--file-path ~/'") +} + +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, + ExportPath: flags.FlagToStringValue(p, cmd, filePathFlag), + } + + 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..fc6d8c127 --- /dev/null +++ b/internal/cmd/config/profile/export/export_test.go @@ -0,0 +1,136 @@ +package export + +import ( + "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "testing" +) + +const ( + testProfileArg = "default" + testExportPath = "/tmp/stackit-profiles" +) + +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, + ExportPath: 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: 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.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/profiles.go b/internal/pkg/config/profiles.go index f7b6c5f70..5413a1c97 100644 --- a/internal/pkg/config/profiles.go +++ b/internal/pkg/config/profiles.go @@ -3,13 +3,12 @@ package config import ( "encoding/json" "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" + "os" + "path/filepath" + "regexp" ) const ProfileEnvVar = "STACKIT_CLI_PROFILE" @@ -397,3 +396,52 @@ func ImportProfile(p *print.Printer, profileName, config string, setAsActive boo return nil } + +// ExportProfile exports a profile configuration +// Is exports the profile to the filePath. +func ExportProfile(p *print.Printer, profile, filePath string) error { + err := ValidateProfile(profile) + if err != nil { + return fmt.Errorf("validate profile: %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) + exportFilePath := filePath + + // Handle if exportFilePath is a directory + stats, err := os.Stat(exportFilePath) + if err == nil { + // If exportFilePath exists, and it is not a directory, then return an error + if !stats.IsDir() { + return &errors.FileAlreadyExistsError{Filename: exportFilePath} + } + + exportFileName := fmt.Sprintf("%s.%s", profile, configFileExtension) + exportFilePath = filepath.Join(filePath, exportFileName) + + _, err = os.Stat(exportFilePath) + if err == nil { + return &errors.FileAlreadyExistsError{Filename: exportFilePath} + } + } + + err = fileutils.CopyFile(configFile, exportFilePath) + if err != nil { + return fmt.Errorf("export config file: %w", err) + } + + if p != nil { + p.Debug(print.DebugLevel, "exported profile %q", profile) + } + + return nil +} diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go index 9700ea978..d24de1257 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,59 @@ 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) + } + defer os.RemoveAll(testDir) + + tests := []struct { + description string + profile string + filePath string + isValid bool + }{ + { + description: "valid profile", + profile: "default", + filePath: testDir, + isValid: true, + }, + { + description: "invalid profile", + profile: "invalid-my-profile", + isValid: false, + }, + { + description: "custom file name", + profile: "default", + filePath: filepath.Join(testDir, fmt.Sprintf("custom-name.%s", configFileExtension)), + isValid: true, + }, + { + description: "not existing path", + profile: "default", + filePath: filepath.Join(testDir, "invalid", "path"), + isValid: false, + }, + } + + 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) } From 6baf1ae5ed7411c96aece7adf23aed71ab7fcec3 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Wed, 18 Dec 2024 09:08:35 +0100 Subject: [PATCH 02/10] Fix: Linter and tests --- internal/cmd/config/profile/export/export.go | 4 ++- .../cmd/config/profile/export/export_test.go | 11 +++++-- internal/pkg/config/profiles.go | 33 ++++++++----------- internal/pkg/config/profiles_test.go | 4 ++- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/internal/cmd/config/profile/export/export.go b/internal/cmd/config/profile/export/export.go index 51f1906f7..e2056e472 100644 --- a/internal/cmd/config/profile/export/export.go +++ b/internal/cmd/config/profile/export/export.go @@ -2,13 +2,15 @@ package export import ( "fmt" - "github.com/spf13/cobra" + "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 ( diff --git a/internal/cmd/config/profile/export/export_test.go b/internal/cmd/config/profile/export/export_test.go index fc6d8c127..927f720c3 100644 --- a/internal/cmd/config/profile/export/export_test.go +++ b/internal/cmd/config/profile/export/export_test.go @@ -1,10 +1,12 @@ package export import ( - "github.com/google/go-cmp/cmp" + "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "testing" + + "github.com/google/go-cmp/cmp" ) const ( @@ -77,7 +79,10 @@ func TestParseInput(t *testing.T) { description: "no flags", argsValues: fixtureArgValues(), flagValues: map[string]string{}, - isValid: false, + isValid: true, + expectedModel: fixtureInputModel(func(inputModel *inputModel) { + inputModel.ExportPath = "" + }), }, } diff --git a/internal/pkg/config/profiles.go b/internal/pkg/config/profiles.go index 5413a1c97..291c99af3 100644 --- a/internal/pkg/config/profiles.go +++ b/internal/pkg/config/profiles.go @@ -3,12 +3,14 @@ package config import ( "encoding/json" "fmt" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" - "github.com/stackitcloud/stackit-cli/internal/pkg/fileutils" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "os" "path/filepath" "regexp" + "strings" + + "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" @@ -415,32 +417,25 @@ func ExportProfile(p *print.Printer, profile, filePath string) error { profilePath := GetProfileFolderPath(profile) configFile := getConfigFilePath(profilePath) - exportFilePath := filePath - // Handle if exportFilePath is a directory - stats, err := os.Stat(exportFilePath) - if err == nil { - // If exportFilePath exists, and it is not a directory, then return an error - if !stats.IsDir() { - return &errors.FileAlreadyExistsError{Filename: exportFilePath} - } - - exportFileName := fmt.Sprintf("%s.%s", profile, configFileExtension) + exportFileName := fmt.Sprintf("%s.%s", profile, configFileExtension) + exportFilePath := filePath + if !strings.HasSuffix(exportFilePath, fmt.Sprintf(".%s", configFileExtension)) { exportFilePath = filepath.Join(filePath, exportFileName) + } - _, err = os.Stat(exportFilePath) - if err == nil { - return &errors.FileAlreadyExistsError{Filename: exportFilePath} - } + _, err = os.Stat(exportFilePath) + if err == nil { + return fmt.Errorf("file %q already exists in the export path. Delete the existing file or define a different export path", exportFileName) } err = fileutils.CopyFile(configFile, exportFilePath) if err != nil { - return fmt.Errorf("export config file: %w", err) + return fmt.Errorf("export config file to %q: %w", exportFilePath, err) } if p != nil { - p.Debug(print.DebugLevel, "exported profile %q", profile) + p.Debug(print.DebugLevel, "exported profile %q to %q", profile, exportFilePath) } return nil diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go index d24de1257..7b2f5d4a8 100644 --- a/internal/pkg/config/profiles_test.go +++ b/internal/pkg/config/profiles_test.go @@ -190,7 +190,9 @@ func TestExportProfile(t *testing.T) { if err != nil { t.Fatal(err) } - defer os.RemoveAll(testDir) + defer func(path string) { + _ = os.RemoveAll(path) + }(testDir) tests := []struct { description string From ec6c7c12cdb3ccb56b65519918fd615aa929c336 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Wed, 18 Dec 2024 09:22:55 +0100 Subject: [PATCH 03/10] Fix: Export test --- internal/pkg/config/profiles_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go index 7b2f5d4a8..4a8b439a7 100644 --- a/internal/pkg/config/profiles_test.go +++ b/internal/pkg/config/profiles_test.go @@ -186,7 +186,7 @@ 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") + testDir, err := os.MkdirTemp(".", "stackit-cli-test") if err != nil { t.Fatal(err) } From 7c8fb372d2d6672f11632e79437baf25a9ab7d10 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Wed, 18 Dec 2024 09:46:11 +0100 Subject: [PATCH 04/10] Fix: Export test --- internal/pkg/config/profiles_test.go | 35 ++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go index 4a8b439a7..3d26c085f 100644 --- a/internal/pkg/config/profiles_test.go +++ b/internal/pkg/config/profiles_test.go @@ -186,13 +186,34 @@ func TestImportProfile(t *testing.T) { func TestExportProfile(t *testing.T) { // Create directory where the export configs should be stored - testDir, err := os.MkdirTemp(".", "stackit-cli-test") + testDir, err := os.MkdirTemp(os.TempDir(), "stackit-cli-test") if err != nil { t.Fatal(err) } - defer func(path string) { - _ = os.RemoveAll(path) - }(testDir) + 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 prerequisite profile + p := print.NewPrinter() + profileName := "export-profile-test" + err = CreateProfile(p, profileName, false, 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 @@ -202,7 +223,7 @@ func TestExportProfile(t *testing.T) { }{ { description: "valid profile", - profile: "default", + profile: profileName, filePath: testDir, isValid: true, }, @@ -213,13 +234,13 @@ func TestExportProfile(t *testing.T) { }, { description: "custom file name", - profile: "default", + profile: profileName, filePath: filepath.Join(testDir, fmt.Sprintf("custom-name.%s", configFileExtension)), isValid: true, }, { description: "not existing path", - profile: "default", + profile: profileName, filePath: filepath.Join(testDir, "invalid", "path"), isValid: false, }, From 1310042265d34877565325a4039db135dbb65982 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Wed, 18 Dec 2024 11:24:48 +0100 Subject: [PATCH 05/10] Fix: CI pipeline - Export test --- internal/pkg/config/profiles_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go index 3d26c085f..1b2a5e1f0 100644 --- a/internal/pkg/config/profiles_test.go +++ b/internal/pkg/config/profiles_test.go @@ -202,6 +202,7 @@ func TestExportProfile(t *testing.T) { // Create prerequisite profile p := print.NewPrinter() profileName := "export-profile-test" + InitConfig() err = CreateProfile(p, profileName, false, false) if err != nil { t.Fatalf("could not create prerequisite profile, %v", err) From 76113cdf1d388b090ebfaa9d7864cccb4ae67909 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Wed, 18 Dec 2024 11:35:18 +0100 Subject: [PATCH 06/10] Fix: CI pipeline - Export test --- internal/pkg/config/profiles_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go index 1b2a5e1f0..65038ea77 100644 --- a/internal/pkg/config/profiles_test.go +++ b/internal/pkg/config/profiles_test.go @@ -202,11 +202,15 @@ func TestExportProfile(t *testing.T) { // Create prerequisite profile p := print.NewPrinter() profileName := "export-profile-test" - InitConfig() - err = CreateProfile(p, profileName, false, false) + err = CreateProfile(p, profileName, true, true) if err != nil { t.Fatalf("could not create prerequisite profile, %v", err) } + InitConfig() + err = Write() + if err != nil { + t.Fatalf("could not write profile, %v", err) + } t.Cleanup(func() { func(p *print.Printer, profile string) { err := DeleteProfile(p, profile) From 217af014381468612c1b17349587ea27ade2129b Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Wed, 18 Dec 2024 17:09:39 +0100 Subject: [PATCH 07/10] Refinements in tests and defining filePath --- internal/cmd/config/profile/export/export.go | 22 ++++++++++++++----- .../cmd/config/profile/export/export_test.go | 7 +++--- internal/pkg/config/config.go | 6 ++++- internal/pkg/config/profiles.go | 20 ++++++++--------- internal/pkg/config/profiles_test.go | 18 +++++++-------- 5 files changed, 43 insertions(+), 30 deletions(-) diff --git a/internal/cmd/config/profile/export/export.go b/internal/cmd/config/profile/export/export.go index e2056e472..8a178f302 100644 --- a/internal/cmd/config/profile/export/export.go +++ b/internal/cmd/config/profile/export/export.go @@ -2,6 +2,8 @@ package export import ( "fmt" + "path/filepath" + "strings" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/config" @@ -17,12 +19,14 @@ const ( profileNameArg = "PROFILE_NAME" filePathFlag = "file-path" + + configFileExtension = "json" ) type inputModel struct { *globalflags.GlobalFlagModel ProfileName string - ExportPath string + FilePath string } func NewCmd(p *print.Printer) *cobra.Command { @@ -32,7 +36,7 @@ func NewCmd(p *print.Printer) *cobra.Command { Long: "Exports a CLI configuration profile.", Example: examples.Build( examples.NewExample( - `Export a profile with name "PROFILE_NAME" to the current path`, + `Export a profile with name "PROFILE_NAME" to a file in your current directory`, "$ stackit config profile export PROFILE_NAME", ), examples.NewExample( @@ -47,12 +51,12 @@ func NewCmd(p *print.Printer) *cobra.Command { return err } - err = config.ExportProfile(p, model.ProfileName, model.ExportPath) + 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\n", model.ProfileName) + p.Info("Exported profile %q to %q\n", model.ProfileName, model.FilePath) return nil }, @@ -62,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - cmd.Flags().String(filePathFlag, "", "Path where the config should be saved. E.g. '--file-path ~/config.json', '--file-path ~/'") + cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the payload to the given. If unset, writes the payload to you current directory with the name of the profile. E.g. '--file-path ~/my-config.json', '--file-path ~/'") } func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { @@ -72,7 +76,13 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu model := inputModel{ GlobalFlagModel: globalFlags, ProfileName: profileName, - ExportPath: flags.FlagToStringValue(p, cmd, filePathFlag), + FilePath: flags.FlagToStringValue(p, cmd, filePathFlag), + } + + // If filePath contains does not contain a file name, then add a default name + if !strings.HasSuffix(model.FilePath, fmt.Sprintf(".%s", configFileExtension)) { + exportFileName := fmt.Sprintf("%s.%s", model.ProfileName, configFileExtension) + model.FilePath = filepath.Join(model.FilePath, exportFileName) } if p.IsVerbosityDebug() { diff --git a/internal/cmd/config/profile/export/export_test.go b/internal/cmd/config/profile/export/export_test.go index 927f720c3..fb4f3a7e4 100644 --- a/internal/cmd/config/profile/export/export_test.go +++ b/internal/cmd/config/profile/export/export_test.go @@ -1,6 +1,7 @@ package export import ( + "fmt" "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -11,7 +12,7 @@ import ( const ( testProfileArg = "default" - testExportPath = "/tmp/stackit-profiles" + testExportPath = "/tmp/stackit-profiles/" + testProfileArg + ".json" ) func fixtureArgValues(mods ...func(args []string)) []string { @@ -40,7 +41,7 @@ func fixtureInputModel(mods ...func(inputModel *inputModel)) *inputModel { Verbosity: globalflags.VerbosityDefault, }, ProfileName: testProfileArg, - ExportPath: testExportPath, + FilePath: testExportPath, } for _, mod := range mods { mod(model) @@ -81,7 +82,7 @@ func TestParseInput(t *testing.T) { flagValues: map[string]string{}, isValid: true, expectedModel: fixtureInputModel(func(inputModel *inputModel) { - inputModel.ExportPath = "" + inputModel.FilePath = fmt.Sprintf("%s.json", testProfileArg) }), }, } 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 291c99af3..facc1fc03 100644 --- a/internal/pkg/config/profiles.go +++ b/internal/pkg/config/profiles.go @@ -400,8 +400,8 @@ func ImportProfile(p *print.Printer, profileName, config string, setAsActive boo } // ExportProfile exports a profile configuration -// Is exports the profile to the filePath. -func ExportProfile(p *print.Printer, profile, filePath string) error { +// Is exports the profile to the exportPath. The exportPath must contain in the suffix `.json` as file extension +func ExportProfile(p *print.Printer, profile, exportPath string) error { err := ValidateProfile(profile) if err != nil { return fmt.Errorf("validate profile: %w", err) @@ -418,24 +418,22 @@ func ExportProfile(p *print.Printer, profile, filePath string) error { profilePath := GetProfileFolderPath(profile) configFile := getConfigFilePath(profilePath) - exportFileName := fmt.Sprintf("%s.%s", profile, configFileExtension) - exportFilePath := filePath - if !strings.HasSuffix(exportFilePath, fmt.Sprintf(".%s", configFileExtension)) { - exportFilePath = filepath.Join(filePath, exportFileName) + if !strings.HasSuffix(exportPath, fmt.Sprintf(".%s", configFileExtension)) { + return fmt.Errorf("export file name must end with '.%s'", configFileExtension) } - _, err = os.Stat(exportFilePath) + _, err = os.Stat(exportPath) if err == nil { - return fmt.Errorf("file %q already exists in the export path. Delete the existing file or define a different export path", exportFileName) + return &errors.FileAlreadyExistsError{Filename: exportPath} } - err = fileutils.CopyFile(configFile, exportFilePath) + err = fileutils.CopyFile(configFile, exportPath) if err != nil { - return fmt.Errorf("export config file to %q: %w", exportFilePath, err) + return fmt.Errorf("export config file to %q: %w", exportPath, err) } if p != nil { - p.Debug(print.DebugLevel, "exported profile %q to %q", profile, exportFilePath) + 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 65038ea77..1ce83f8fd 100644 --- a/internal/pkg/config/profiles_test.go +++ b/internal/pkg/config/profiles_test.go @@ -199,6 +199,12 @@ func TestExportProfile(t *testing.T) { }(testDir) }) + defaultConfigFolderPath = filepath.Join(testDir, "config") + err = os.Mkdir(defaultConfigFolderPath, 0o750) + if err != nil { + t.Fatal(err) + } + // Create prerequisite profile p := print.NewPrinter() profileName := "export-profile-test" @@ -206,7 +212,7 @@ func TestExportProfile(t *testing.T) { if err != nil { t.Fatalf("could not create prerequisite profile, %v", err) } - InitConfig() + initConfig(defaultConfigFolderPath) err = Write() if err != nil { t.Fatalf("could not write profile, %v", err) @@ -229,7 +235,7 @@ func TestExportProfile(t *testing.T) { { description: "valid profile", profile: profileName, - filePath: testDir, + filePath: filepath.Join(testDir, fmt.Sprintf("custom-name.%s", configFileExtension)), isValid: true, }, { @@ -237,16 +243,10 @@ func TestExportProfile(t *testing.T) { profile: "invalid-my-profile", isValid: false, }, - { - description: "custom file name", - profile: profileName, - filePath: filepath.Join(testDir, fmt.Sprintf("custom-name.%s", configFileExtension)), - isValid: true, - }, { description: "not existing path", profile: profileName, - filePath: filepath.Join(testDir, "invalid", "path"), + filePath: filepath.Join(testDir, "invalid", "path", fmt.Sprintf("custom-name.%s", configFileExtension)), isValid: false, }, } From 83a10c27fe735640c0b8342f39d286f9c4e309e8 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Thu, 19 Dec 2024 09:33:02 +0100 Subject: [PATCH 08/10] Changed export --file-path behaviour. - When --file-path is set, it does not add a default name and the user needs to provide the whole export path - When --file-path is empty, it adds a default name --- docs/stackit_config_profile_export.md | 4 ++-- internal/cmd/config/profile/export/export.go | 5 ++--- internal/cmd/config/profile/export/export_test.go | 12 ++++++++++++ internal/pkg/config/profiles.go | 7 +------ internal/pkg/config/profiles_test.go | 6 ++++++ 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/stackit_config_profile_export.md b/docs/stackit_config_profile_export.md index ec112509c..a553f8ede 100644 --- a/docs/stackit_config_profile_export.md +++ b/docs/stackit_config_profile_export.md @@ -13,7 +13,7 @@ stackit config profile export PROFILE_NAME [flags] ### Examples ``` - Export a profile with name "PROFILE_NAME" to the current path + 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 @@ -23,7 +23,7 @@ stackit config profile export PROFILE_NAME [flags] ### Options ``` - --file-path string Path where the config should be saved. E.g. '--file-path ~/config.json', '--file-path ~/' + -f, --file-path string If set, writes the config to the given. If unset, writes the config to you current directory with the name of the profile. E.g. '--file-path ~/my-config.json', '--file-path ~/' -h, --help Help for "stackit config profile export" ``` diff --git a/internal/cmd/config/profile/export/export.go b/internal/cmd/config/profile/export/export.go index 8a178f302..040c47a81 100644 --- a/internal/cmd/config/profile/export/export.go +++ b/internal/cmd/config/profile/export/export.go @@ -3,7 +3,6 @@ package export import ( "fmt" "path/filepath" - "strings" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/config" @@ -66,7 +65,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the payload to the given. If unset, writes the payload to you current directory with the name of the profile. E.g. '--file-path ~/my-config.json', '--file-path ~/'") + cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the config to the given. If unset, writes the config to you current directory with the name of the profile. E.g. '--file-path ~/my-config.json', '--file-path ~/'") } func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { @@ -80,7 +79,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu } // If filePath contains does not contain a file name, then add a default name - if !strings.HasSuffix(model.FilePath, fmt.Sprintf(".%s", configFileExtension)) { + if model.FilePath == "" { exportFileName := fmt.Sprintf("%s.%s", model.ProfileName, configFileExtension) model.FilePath = filepath.Join(model.FilePath, exportFileName) } diff --git a/internal/cmd/config/profile/export/export_test.go b/internal/cmd/config/profile/export/export_test.go index fb4f3a7e4..4dffd3a22 100644 --- a/internal/cmd/config/profile/export/export_test.go +++ b/internal/cmd/config/profile/export/export_test.go @@ -85,6 +85,18 @@ func TestParseInput(t *testing.T) { 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 { diff --git a/internal/pkg/config/profiles.go b/internal/pkg/config/profiles.go index facc1fc03..9528d5d7d 100644 --- a/internal/pkg/config/profiles.go +++ b/internal/pkg/config/profiles.go @@ -6,7 +6,6 @@ import ( "os" "path/filepath" "regexp" - "strings" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/fileutils" @@ -400,7 +399,7 @@ func ImportProfile(p *print.Printer, profileName, config string, setAsActive boo } // ExportProfile exports a profile configuration -// Is exports the profile to the exportPath. The exportPath must contain in the suffix `.json` as file extension +// 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 { @@ -418,10 +417,6 @@ func ExportProfile(p *print.Printer, profile, exportPath string) error { profilePath := GetProfileFolderPath(profile) configFile := getConfigFilePath(profilePath) - if !strings.HasSuffix(exportPath, fmt.Sprintf(".%s", configFileExtension)) { - return fmt.Errorf("export file name must end with '.%s'", configFileExtension) - } - _, err = os.Stat(exportPath) if err == nil { return &errors.FileAlreadyExistsError{Filename: exportPath} diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go index 1ce83f8fd..0216c97dd 100644 --- a/internal/pkg/config/profiles_test.go +++ b/internal/pkg/config/profiles_test.go @@ -249,6 +249,12 @@ func TestExportProfile(t *testing.T) { 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 { From caae8812a965077e3b1dc8b9ecb3ed296be82c72 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Thu, 19 Dec 2024 12:01:17 +0100 Subject: [PATCH 09/10] Refactor TestExportProfile --- internal/pkg/config/profiles_test.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go index 0216c97dd..327c9dcf8 100644 --- a/internal/pkg/config/profiles_test.go +++ b/internal/pkg/config/profiles_test.go @@ -199,24 +199,21 @@ func TestExportProfile(t *testing.T) { }(testDir) }) - defaultConfigFolderPath = filepath.Join(testDir, "config") - err = os.Mkdir(defaultConfigFolderPath, 0o750) + // Create test config directory + testConfigFolderPath := filepath.Join(testDir, "config") + initConfig(testConfigFolderPath) + err = Write() if err != nil { - t.Fatal(err) + t.Fatalf("could not write profile, %v", err) } // Create prerequisite profile p := print.NewPrinter() profileName := "export-profile-test" - err = CreateProfile(p, profileName, true, true) + err = CreateProfile(p, profileName, true, false) if err != nil { t.Fatalf("could not create prerequisite profile, %v", err) } - initConfig(defaultConfigFolderPath) - err = Write() - if err != nil { - t.Fatalf("could not write profile, %v", err) - } t.Cleanup(func() { func(p *print.Printer, profile string) { err := DeleteProfile(p, profile) From 6f603a5c29b30e98d6f195d32409036978e5cc7a Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Thu, 19 Dec 2024 14:54:59 +0100 Subject: [PATCH 10/10] Update export --file-path description and error handling --- docs/stackit_config_profile_export.md | 2 +- internal/cmd/config/profile/export/export.go | 2 +- internal/pkg/config/profiles.go | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/stackit_config_profile_export.md b/docs/stackit_config_profile_export.md index a553f8ede..86265e493 100644 --- a/docs/stackit_config_profile_export.md +++ b/docs/stackit_config_profile_export.md @@ -23,7 +23,7 @@ stackit config profile export PROFILE_NAME [flags] ### Options ``` - -f, --file-path string If set, writes the config to the given. If unset, writes the config to you current directory with the name of the profile. E.g. '--file-path ~/my-config.json', '--file-path ~/' + -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" ``` diff --git a/internal/cmd/config/profile/export/export.go b/internal/cmd/config/profile/export/export.go index 040c47a81..9aa585971 100644 --- a/internal/cmd/config/profile/export/export.go +++ b/internal/cmd/config/profile/export/export.go @@ -65,7 +65,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the config to the given. If unset, writes the config to you current directory with the name of the profile. E.g. '--file-path ~/my-config.json', '--file-path ~/'") + 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) { diff --git a/internal/pkg/config/profiles.go b/internal/pkg/config/profiles.go index 9528d5d7d..db47ce5d3 100644 --- a/internal/pkg/config/profiles.go +++ b/internal/pkg/config/profiles.go @@ -403,7 +403,7 @@ func ImportProfile(p *print.Printer, profileName, config string, setAsActive boo func ExportProfile(p *print.Printer, profile, exportPath string) error { err := ValidateProfile(profile) if err != nil { - return fmt.Errorf("validate profile: %w", err) + return fmt.Errorf("validate profile name: %w", err) } exists, err := ProfileExists(profile) @@ -417,8 +417,11 @@ func ExportProfile(p *print.Printer, profile, exportPath string) error { profilePath := GetProfileFolderPath(profile) configFile := getConfigFilePath(profilePath) - _, err = os.Stat(exportPath) + 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} }