Skip to content

Commit deb660c

Browse files
committed
Add command to export config profiles
1 parent c794a3d commit deb660c

File tree

8 files changed

+398
-4
lines changed

8 files changed

+398
-4
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
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 the current path
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+
--file-path string Path where the config should be saved. E.g. '--file-path ~/config.json', '--file-path ~/'
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: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package export
2+
3+
import (
4+
"fmt"
5+
"github.com/spf13/cobra"
6+
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
7+
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
8+
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
9+
"github.com/stackitcloud/stackit-cli/internal/pkg/flags"
10+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
11+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
12+
)
13+
14+
const (
15+
profileNameArg = "PROFILE_NAME"
16+
17+
filePathFlag = "file-path"
18+
)
19+
20+
type inputModel struct {
21+
*globalflags.GlobalFlagModel
22+
ProfileName string
23+
ExportPath string
24+
}
25+
26+
func NewCmd(p *print.Printer) *cobra.Command {
27+
cmd := &cobra.Command{
28+
Use: fmt.Sprintf("export %s", profileNameArg),
29+
Short: "Exports a CLI configuration profile",
30+
Long: "Exports a CLI configuration profile.",
31+
Example: examples.Build(
32+
examples.NewExample(
33+
`Export a profile with name "PROFILE_NAME" to the current path`,
34+
"$ stackit config profile export PROFILE_NAME",
35+
),
36+
examples.NewExample(
37+
`Export a profile with name "PROFILE_NAME"" to a specific file path FILE_PATH`,
38+
"$ stackit config profile export PROFILE_NAME --file-path FILE_PATH",
39+
),
40+
),
41+
Args: args.SingleArg(profileNameArg, nil),
42+
RunE: func(cmd *cobra.Command, args []string) error {
43+
model, err := parseInput(p, cmd, args)
44+
if err != nil {
45+
return err
46+
}
47+
48+
err = config.ExportProfile(p, model.ProfileName, model.ExportPath)
49+
if err != nil {
50+
return fmt.Errorf("could not export profile: %w", err)
51+
}
52+
53+
p.Info("Exported profile %q\n", model.ProfileName)
54+
55+
return nil
56+
},
57+
}
58+
configureFlags(cmd)
59+
return cmd
60+
}
61+
62+
func configureFlags(cmd *cobra.Command) {
63+
cmd.Flags().String(filePathFlag, "", "Path where the config should be saved. E.g. '--file-path ~/config.json', '--file-path ~/'")
64+
}
65+
66+
func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
67+
profileName := inputArgs[0]
68+
globalFlags := globalflags.Parse(p, cmd)
69+
70+
model := inputModel{
71+
GlobalFlagModel: globalFlags,
72+
ProfileName: profileName,
73+
ExportPath: flags.FlagToStringValue(p, cmd, filePathFlag),
74+
}
75+
76+
if p.IsVerbosityDebug() {
77+
modelStr, err := print.BuildDebugStrFromInputModel(model)
78+
if err != nil {
79+
p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
80+
} else {
81+
p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
82+
}
83+
}
84+
85+
return &model, nil
86+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package export
2+
3+
import (
4+
"github.com/google/go-cmp/cmp"
5+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
6+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
7+
"testing"
8+
)
9+
10+
const (
11+
testProfileArg = "default"
12+
testExportPath = "/tmp/stackit-profiles"
13+
)
14+
15+
func fixtureArgValues(mods ...func(args []string)) []string {
16+
args := []string{
17+
testProfileArg,
18+
}
19+
for _, mod := range mods {
20+
mod(args)
21+
}
22+
return args
23+
}
24+
25+
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
26+
flagValues := map[string]string{
27+
filePathFlag: testExportPath,
28+
}
29+
for _, mod := range mods {
30+
mod(flagValues)
31+
}
32+
return flagValues
33+
}
34+
35+
func fixtureInputModel(mods ...func(inputModel *inputModel)) *inputModel {
36+
model := &inputModel{
37+
GlobalFlagModel: &globalflags.GlobalFlagModel{
38+
Verbosity: globalflags.VerbosityDefault,
39+
},
40+
ProfileName: testProfileArg,
41+
ExportPath: testExportPath,
42+
}
43+
for _, mod := range mods {
44+
mod(model)
45+
}
46+
return model
47+
}
48+
49+
func TestParseInput(t *testing.T) {
50+
tests := []struct {
51+
description string
52+
argsValues []string
53+
flagValues map[string]string
54+
isValid bool
55+
expectedModel *inputModel
56+
}{
57+
{
58+
description: "base",
59+
argsValues: fixtureArgValues(),
60+
flagValues: fixtureFlagValues(),
61+
isValid: true,
62+
expectedModel: fixtureInputModel(),
63+
},
64+
{
65+
description: "no values",
66+
argsValues: []string{},
67+
flagValues: map[string]string{},
68+
isValid: false,
69+
},
70+
{
71+
description: "no args",
72+
argsValues: []string{},
73+
flagValues: fixtureFlagValues(),
74+
isValid: false,
75+
},
76+
{
77+
description: "no flags",
78+
argsValues: fixtureArgValues(),
79+
flagValues: map[string]string{},
80+
isValid: false,
81+
},
82+
}
83+
84+
for _, tt := range tests {
85+
t.Run(tt.description, func(t *testing.T) {
86+
p := print.NewPrinter()
87+
cmd := NewCmd(p)
88+
err := globalflags.Configure(cmd.Flags())
89+
if err != nil {
90+
t.Fatalf("configure global flags: %v", err)
91+
}
92+
93+
for flag, value := range tt.flagValues {
94+
err = cmd.Flags().Set(flag, value)
95+
if err != nil {
96+
if !tt.isValid {
97+
return
98+
}
99+
t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
100+
}
101+
}
102+
103+
err = cmd.ValidateArgs(tt.argsValues)
104+
if err != nil {
105+
if !tt.isValid {
106+
return
107+
}
108+
t.Fatalf("error validating args: %v", err)
109+
}
110+
111+
err = cmd.ValidateRequiredFlags()
112+
if err != nil {
113+
if !tt.isValid {
114+
return
115+
}
116+
t.Fatalf("error validating flags: %v", err)
117+
}
118+
119+
model, err := parseInput(p, cmd, tt.argsValues)
120+
if err != nil {
121+
if !tt.isValid {
122+
return
123+
}
124+
t.Fatalf("error parsing input: %v", err)
125+
}
126+
127+
if !tt.isValid {
128+
t.Fatalf("did not fail on invalid input")
129+
}
130+
diff := cmp.Diff(model, tt.expectedModel)
131+
if diff != "" {
132+
t.Fatalf("Data does not match: %s", diff)
133+
}
134+
})
135+
}
136+
}

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/profiles.go

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@ package config
33
import (
44
"encoding/json"
55
"fmt"
6-
"os"
7-
"path/filepath"
8-
"regexp"
9-
106
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
117
"github.com/stackitcloud/stackit-cli/internal/pkg/fileutils"
128
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
9+
"os"
10+
"path/filepath"
11+
"regexp"
1312
)
1413

1514
const ProfileEnvVar = "STACKIT_CLI_PROFILE"
@@ -397,3 +396,52 @@ func ImportProfile(p *print.Printer, profileName, config string, setAsActive boo
397396

398397
return nil
399398
}
399+
400+
// ExportProfile exports a profile configuration
401+
// Is exports the profile to the filePath.
402+
func ExportProfile(p *print.Printer, profile, filePath string) error {
403+
err := ValidateProfile(profile)
404+
if err != nil {
405+
return fmt.Errorf("validate profile: %w", err)
406+
}
407+
408+
exists, err := ProfileExists(profile)
409+
if err != nil {
410+
return fmt.Errorf("check if profile exists: %w", err)
411+
}
412+
if !exists {
413+
return &errors.ProfileDoesNotExistError{Profile: profile}
414+
}
415+
416+
profilePath := GetProfileFolderPath(profile)
417+
configFile := getConfigFilePath(profilePath)
418+
exportFilePath := filePath
419+
420+
// Handle if exportFilePath is a directory
421+
stats, err := os.Stat(exportFilePath)
422+
if err == nil {
423+
// If exportFilePath exists, and it is not a directory, then return an error
424+
if !stats.IsDir() {
425+
return &errors.FileAlreadyExistsError{Filename: exportFilePath}
426+
}
427+
428+
exportFileName := fmt.Sprintf("%s.%s", profile, configFileExtension)
429+
exportFilePath = filepath.Join(filePath, exportFileName)
430+
431+
_, err = os.Stat(exportFilePath)
432+
if err == nil {
433+
return &errors.FileAlreadyExistsError{Filename: exportFilePath}
434+
}
435+
}
436+
437+
err = fileutils.CopyFile(configFile, exportFilePath)
438+
if err != nil {
439+
return fmt.Errorf("export config file: %w", err)
440+
}
441+
442+
if p != nil {
443+
p.Debug(print.DebugLevel, "exported profile %q", profile)
444+
}
445+
446+
return nil
447+
}

0 commit comments

Comments
 (0)