Skip to content

Commit 7e7d6d3

Browse files
authored
Onboard command to export config profiles (#544)
* Add functionality and command to export config profiles
1 parent d117bb5 commit 7e7d6d3

File tree

9 files changed

+450
-1
lines changed

9 files changed

+450
-1
lines changed

docs/stackit_config_profile.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ stackit config profile [flags]
3434
* [stackit config](./stackit_config.md) - Provides functionality for CLI configuration options
3535
* [stackit config profile create](./stackit_config_profile_create.md) - Creates a CLI configuration profile
3636
* [stackit config profile delete](./stackit_config_profile_delete.md) - Delete a CLI configuration profile
37+
* [stackit config profile export](./stackit_config_profile_export.md) - Exports a CLI configuration profile
3738
* [stackit config profile import](./stackit_config_profile_import.md) - Imports a CLI configuration profile
3839
* [stackit config profile list](./stackit_config_profile_list.md) - Lists all CLI configuration profiles
3940
* [stackit config profile set](./stackit_config_profile_set.md) - Set a CLI configuration profile

docs/stackit_config_profile_export.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
## stackit config profile export
2+
3+
Exports a CLI configuration profile
4+
5+
### Synopsis
6+
7+
Exports a CLI configuration profile.
8+
9+
```
10+
stackit config profile export PROFILE_NAME [flags]
11+
```
12+
13+
### Examples
14+
15+
```
16+
Export a profile with name "PROFILE_NAME" to a file in your current directory
17+
$ stackit config profile export PROFILE_NAME
18+
19+
Export a profile with name "PROFILE_NAME"" to a specific file path FILE_PATH
20+
$ stackit config profile export PROFILE_NAME --file-path FILE_PATH
21+
```
22+
23+
### Options
24+
25+
```
26+
-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'
27+
-h, --help Help for "stackit config profile export"
28+
```
29+
30+
### Options inherited from parent commands
31+
32+
```
33+
-y, --assume-yes If set, skips all confirmation prompts
34+
--async If set, runs the command asynchronously
35+
-o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
36+
-p, --project-id string Project ID
37+
--verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
38+
```
39+
40+
### SEE ALSO
41+
42+
* [stackit config profile](./stackit_config_profile.md) - Manage the CLI configuration profiles
43+
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package export
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
7+
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
8+
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
9+
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
10+
"github.com/stackitcloud/stackit-cli/internal/pkg/flags"
11+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
12+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
13+
14+
"github.com/spf13/cobra"
15+
)
16+
17+
const (
18+
profileNameArg = "PROFILE_NAME"
19+
20+
filePathFlag = "file-path"
21+
22+
configFileExtension = "json"
23+
)
24+
25+
type inputModel struct {
26+
*globalflags.GlobalFlagModel
27+
ProfileName string
28+
FilePath string
29+
}
30+
31+
func NewCmd(p *print.Printer) *cobra.Command {
32+
cmd := &cobra.Command{
33+
Use: fmt.Sprintf("export %s", profileNameArg),
34+
Short: "Exports a CLI configuration profile",
35+
Long: "Exports a CLI configuration profile.",
36+
Example: examples.Build(
37+
examples.NewExample(
38+
`Export a profile with name "PROFILE_NAME" to a file in your current directory`,
39+
"$ stackit config profile export PROFILE_NAME",
40+
),
41+
examples.NewExample(
42+
`Export a profile with name "PROFILE_NAME"" to a specific file path FILE_PATH`,
43+
"$ stackit config profile export PROFILE_NAME --file-path FILE_PATH",
44+
),
45+
),
46+
Args: args.SingleArg(profileNameArg, nil),
47+
RunE: func(cmd *cobra.Command, args []string) error {
48+
model, err := parseInput(p, cmd, args)
49+
if err != nil {
50+
return err
51+
}
52+
53+
err = config.ExportProfile(p, model.ProfileName, model.FilePath)
54+
if err != nil {
55+
return fmt.Errorf("could not export profile: %w", err)
56+
}
57+
58+
p.Info("Exported profile %q to %q\n", model.ProfileName, model.FilePath)
59+
60+
return nil
61+
},
62+
}
63+
configureFlags(cmd)
64+
return cmd
65+
}
66+
67+
func configureFlags(cmd *cobra.Command) {
68+
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'")
69+
}
70+
71+
func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
72+
profileName := inputArgs[0]
73+
globalFlags := globalflags.Parse(p, cmd)
74+
75+
model := inputModel{
76+
GlobalFlagModel: globalFlags,
77+
ProfileName: profileName,
78+
FilePath: flags.FlagToStringValue(p, cmd, filePathFlag),
79+
}
80+
81+
// If filePath contains does not contain a file name, then add a default name
82+
if model.FilePath == "" {
83+
exportFileName := fmt.Sprintf("%s.%s", model.ProfileName, configFileExtension)
84+
model.FilePath = filepath.Join(model.FilePath, exportFileName)
85+
}
86+
87+
if p.IsVerbosityDebug() {
88+
modelStr, err := print.BuildDebugStrFromInputModel(model)
89+
if err != nil {
90+
p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
91+
} else {
92+
p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
93+
}
94+
}
95+
96+
return &model, nil
97+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package export
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
8+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
9+
10+
"github.com/google/go-cmp/cmp"
11+
)
12+
13+
const (
14+
testProfileArg = "default"
15+
testExportPath = "/tmp/stackit-profiles/" + testProfileArg + ".json"
16+
)
17+
18+
func fixtureArgValues(mods ...func(args []string)) []string {
19+
args := []string{
20+
testProfileArg,
21+
}
22+
for _, mod := range mods {
23+
mod(args)
24+
}
25+
return args
26+
}
27+
28+
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
29+
flagValues := map[string]string{
30+
filePathFlag: testExportPath,
31+
}
32+
for _, mod := range mods {
33+
mod(flagValues)
34+
}
35+
return flagValues
36+
}
37+
38+
func fixtureInputModel(mods ...func(inputModel *inputModel)) *inputModel {
39+
model := &inputModel{
40+
GlobalFlagModel: &globalflags.GlobalFlagModel{
41+
Verbosity: globalflags.VerbosityDefault,
42+
},
43+
ProfileName: testProfileArg,
44+
FilePath: testExportPath,
45+
}
46+
for _, mod := range mods {
47+
mod(model)
48+
}
49+
return model
50+
}
51+
52+
func TestParseInput(t *testing.T) {
53+
tests := []struct {
54+
description string
55+
argsValues []string
56+
flagValues map[string]string
57+
isValid bool
58+
expectedModel *inputModel
59+
}{
60+
{
61+
description: "base",
62+
argsValues: fixtureArgValues(),
63+
flagValues: fixtureFlagValues(),
64+
isValid: true,
65+
expectedModel: fixtureInputModel(),
66+
},
67+
{
68+
description: "no values",
69+
argsValues: []string{},
70+
flagValues: map[string]string{},
71+
isValid: false,
72+
},
73+
{
74+
description: "no args",
75+
argsValues: []string{},
76+
flagValues: fixtureFlagValues(),
77+
isValid: false,
78+
},
79+
{
80+
description: "no flags",
81+
argsValues: fixtureArgValues(),
82+
flagValues: map[string]string{},
83+
isValid: true,
84+
expectedModel: fixtureInputModel(func(inputModel *inputModel) {
85+
inputModel.FilePath = fmt.Sprintf("%s.json", testProfileArg)
86+
}),
87+
},
88+
{
89+
description: "custom file-path without file extension",
90+
argsValues: fixtureArgValues(),
91+
flagValues: fixtureFlagValues(
92+
func(flagValues map[string]string) {
93+
flagValues[filePathFlag] = "./my-exported-config"
94+
}),
95+
isValid: true,
96+
expectedModel: fixtureInputModel(func(inputModel *inputModel) {
97+
inputModel.FilePath = "./my-exported-config"
98+
}),
99+
},
100+
}
101+
102+
for _, tt := range tests {
103+
t.Run(tt.description, func(t *testing.T) {
104+
p := print.NewPrinter()
105+
cmd := NewCmd(p)
106+
err := globalflags.Configure(cmd.Flags())
107+
if err != nil {
108+
t.Fatalf("configure global flags: %v", err)
109+
}
110+
111+
for flag, value := range tt.flagValues {
112+
err = cmd.Flags().Set(flag, value)
113+
if err != nil {
114+
if !tt.isValid {
115+
return
116+
}
117+
t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
118+
}
119+
}
120+
121+
err = cmd.ValidateArgs(tt.argsValues)
122+
if err != nil {
123+
if !tt.isValid {
124+
return
125+
}
126+
t.Fatalf("error validating args: %v", err)
127+
}
128+
129+
err = cmd.ValidateRequiredFlags()
130+
if err != nil {
131+
if !tt.isValid {
132+
return
133+
}
134+
t.Fatalf("error validating flags: %v", err)
135+
}
136+
137+
model, err := parseInput(p, cmd, tt.argsValues)
138+
if err != nil {
139+
if !tt.isValid {
140+
return
141+
}
142+
t.Fatalf("error parsing input: %v", err)
143+
}
144+
145+
if !tt.isValid {
146+
t.Fatalf("did not fail on invalid input")
147+
}
148+
diff := cmp.Diff(model, tt.expectedModel)
149+
if diff != "" {
150+
t.Fatalf("Data does not match: %s", diff)
151+
}
152+
})
153+
}
154+
}

internal/cmd/config/profile/profile.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/create"
77
"github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/delete"
8+
"github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/export"
89
importProfile "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/import"
910
"github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/list"
1011
"github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/set"
@@ -40,4 +41,5 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) {
4041
cmd.AddCommand(list.NewCmd(p))
4142
cmd.AddCommand(delete.NewCmd(p))
4243
cmd.AddCommand(importProfile.NewCmd(p))
44+
cmd.AddCommand(export.NewCmd(p))
4345
}

internal/pkg/config/config.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,11 @@ var configFolderPath string
105105
var profileFilePath string
106106

107107
func InitConfig() {
108-
defaultConfigFolderPath = getInitialConfigDir()
108+
initConfig(getInitialConfigDir())
109+
}
110+
111+
func initConfig(configPath string) {
112+
defaultConfigFolderPath = configPath
109113
profileFilePath = getInitialProfileFilePath() // Profile file path is in the default config folder
110114

111115
configProfile, err := GetProfile()

internal/pkg/config/profiles.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,3 +397,42 @@ func ImportProfile(p *print.Printer, profileName, config string, setAsActive boo
397397

398398
return nil
399399
}
400+
401+
// ExportProfile exports a profile configuration
402+
// Is exports the profile to the exportPath. The exportPath must contain the filename.
403+
func ExportProfile(p *print.Printer, profile, exportPath string) error {
404+
err := ValidateProfile(profile)
405+
if err != nil {
406+
return fmt.Errorf("validate profile name: %w", err)
407+
}
408+
409+
exists, err := ProfileExists(profile)
410+
if err != nil {
411+
return fmt.Errorf("check if profile exists: %w", err)
412+
}
413+
if !exists {
414+
return &errors.ProfileDoesNotExistError{Profile: profile}
415+
}
416+
417+
profilePath := GetProfileFolderPath(profile)
418+
configFile := getConfigFilePath(profilePath)
419+
420+
stats, err := os.Stat(exportPath)
421+
if err == nil {
422+
if stats.IsDir() {
423+
return fmt.Errorf("export path %q is a directory. Please specify a full path", exportPath)
424+
}
425+
return &errors.FileAlreadyExistsError{Filename: exportPath}
426+
}
427+
428+
err = fileutils.CopyFile(configFile, exportPath)
429+
if err != nil {
430+
return fmt.Errorf("export config file to %q: %w", exportPath, err)
431+
}
432+
433+
if p != nil {
434+
p.Debug(print.DebugLevel, "exported profile %q to %q", profile, exportPath)
435+
}
436+
437+
return nil
438+
}

0 commit comments

Comments
 (0)