Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/stackit_config_profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions docs/stackit_config_profile_export.md
Original file line number Diff line number Diff line change
@@ -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

98 changes: 98 additions & 0 deletions internal/cmd/config/profile/export/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package export

import (
"fmt"
"path/filepath"
"strings"

"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 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 ~/'")
Comment thread
joaopalet marked this conversation as resolved.
Outdated
}

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 !strings.HasSuffix(model.FilePath, fmt.Sprintf(".%s", configFileExtension)) {
exportFileName := fmt.Sprintf("%s.%s", model.ProfileName, configFileExtension)
model.FilePath = filepath.Join(model.FilePath, exportFileName)
}
Comment thread
joaopalet marked this conversation as resolved.
Outdated

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
}
142 changes: 142 additions & 0 deletions internal/cmd/config/profile/export/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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)
}),
},
}

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)
}
})
}
}
2 changes: 2 additions & 0 deletions internal/cmd/config/profile/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}
6 changes: 5 additions & 1 deletion internal/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
41 changes: 41 additions & 0 deletions internal/pkg/config/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"regexp"
"strings"

"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/fileutils"
Expand Down Expand Up @@ -397,3 +398,43 @@ 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 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)
}

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)

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}
}

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
}
Loading