You're Invited:Meet the Socket Team at RSAC and BSidesSF 2026, March 23–26.RSVP
Socket
Book a DemoSign in
Socket

mygithub.libinneed.workers.dev/stackitcloud/stackit-cli

Package Overview
Dependencies
Versions
174
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

mygithub.libinneed.workers.dev/stackitcloud/stackit-cli - go Package Compare versions

Comparing version
v0.7.0
to
v0.8.0
.github/images/stackit-logo.png

Sorry, the diff of this file is not supported yet

+48
## stackit config profile create
Creates a CLI configuration profile
### Synopsis
Creates a CLI configuration profile based on the currently active profile and sets it as active.
The profile name can be provided via the STACKIT_CLI_PROFILE environment variable or as an argument in this command.
The environment variable takes precedence over the argument.
If you do not want to set the profile as active, use the --no-set flag.
If you want to create the new profile with the initial default configurations, use the --empty flag.
```
stackit config profile create PROFILE [flags]
```
### Examples
```
Create a new configuration profile "my-profile" with the current configuration, setting it as the active profile
$ stackit config profile create my-profile
Create a new configuration profile "my-profile" with a default initial configuration and don't set it as the active profile
$ stackit config profile create my-profile --empty --no-set
```
### Options
```
--empty Create the profile with the initial default configurations
-h, --help Help for "stackit config profile create"
--no-set Do not set the profile as the active profile
```
### 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
## stackit config profile delete
Delete a CLI configuration profile
### Synopsis
Delete a CLI configuration profile.
If the deleted profile is the active profile, the default profile will be set to active.
```
stackit config profile delete PROFILE [flags]
```
### Examples
```
Delete the configuration profile "my-profile"
$ stackit config profile delete my-profile
```
### Options
```
-h, --help Help for "stackit config profile delete"
```
### 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
## stackit config profile list
Lists all CLI configuration profiles
### Synopsis
Lists all CLI configuration profiles.
```
stackit config profile list [flags]
```
### Examples
```
List the configuration profiles
$ stackit config profile list
List the configuration profiles in a json format
$ stackit config profile list --output-format json
```
### Options
```
-h, --help Help for "stackit config profile list"
```
### 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
## stackit config profile set
Set a CLI configuration profile
### Synopsis
Set a CLI configuration profile as the active profile.
The profile to be used can be managed via the STACKIT_CLI_PROFILE environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands.
The environment variable takes precedence over what is set via the commands.
When no profile is set, the default profile is used.
```
stackit config profile set PROFILE [flags]
```
### Examples
```
Set the configuration profile "my-profile" as the active profile
$ stackit config profile set my-profile
```
### Options
```
-h, --help Help for "stackit config profile set"
```
### 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
## stackit config profile unset
Unset the current active CLI configuration profile
### Synopsis
Unset the current active CLI configuration profile.
When no profile is set, the default profile will be used.
```
stackit config profile unset [flags]
```
### Examples
```
Unset the currently active configuration profile. The default profile will be used.
$ stackit config profile unset
```
### Options
```
-h, --help Help for "stackit config profile unset"
```
### 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
## stackit config profile
Manage the CLI configuration profiles
### Synopsis
Manage the CLI configuration profiles.
The profile to be used can be managed via the "STACKIT_CLI_PROFILE" environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands.
The environment variable takes precedence over what is set via the commands.
When no profile is set, the default profile is used.
```
stackit config profile [flags]
```
### Options
```
-h, --help Help for "stackit config profile"
```
### 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](./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 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
* [stackit config profile unset](./stackit_config_profile_unset.md) - Unset the current active CLI configuration profile
package create
import (
"testing"
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/google/go-cmp/cmp"
)
const testProfile = "test-profile"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
testProfile,
}
for _, mod := range mods {
mod(argValues)
}
return argValues
}
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
Verbosity: globalflags.VerbosityDefault,
},
Profile: testProfile,
FromEmptyProfile: false,
NoSet: false,
}
for _, mod := range mods {
mod(model)
}
return model
}
func TestParseInput(t *testing.T) {
tests := []struct {
description string
argValues []string
flagValues map[string]string
isValid bool
expectedModel *inputModel
}{
{
description: "base",
argValues: fixtureArgValues(),
isValid: true,
expectedModel: fixtureInputModel(),
},
{
description: "no values",
argValues: []string{},
flagValues: map[string]string{},
isValid: false,
},
{
description: "no arg values",
argValues: []string{},
isValid: false,
},
{
description: "some global flag",
argValues: fixtureArgValues(),
flagValues: map[string]string{
globalflags.VerbosityFlag: globalflags.DebugVerbosity,
},
isValid: true,
expectedModel: fixtureInputModel(func(model *inputModel) {
model.GlobalFlagModel.Verbosity = globalflags.DebugVerbosity
}),
},
{
description: "invalid profile",
argValues: []string{"invalid-profile-&"},
isValid: false,
},
{
description: "use default given",
argValues: fixtureArgValues(),
flagValues: map[string]string{
fromEmptyProfile: "true",
},
isValid: true,
expectedModel: fixtureInputModel(func(model *inputModel) {
model.FromEmptyProfile = true
}),
},
{
description: "no set given",
argValues: fixtureArgValues(),
flagValues: map[string]string{
noSetFlag: "true",
},
isValid: true,
expectedModel: fixtureInputModel(func(model *inputModel) {
model.NoSet = true
}),
},
}
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.argValues)
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.argValues)
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)
}
})
}
}
package create
import (
"fmt"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/auth"
"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 (
profileArg = "PROFILE"
noSetFlag = "no-set"
fromEmptyProfile = "empty"
)
type inputModel struct {
*globalflags.GlobalFlagModel
NoSet bool
FromEmptyProfile bool
Profile string
}
func NewCmd(p *print.Printer) *cobra.Command {
cmd := &cobra.Command{
Use: fmt.Sprintf("create %s", profileArg),
Short: "Creates a CLI configuration profile",
Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s",
"Creates a CLI configuration profile based on the currently active profile and sets it as active.",
`The profile name can be provided via the STACKIT_CLI_PROFILE environment variable or as an argument in this command.`,
"The environment variable takes precedence over the argument.",
"If you do not want to set the profile as active, use the --no-set flag.",
"If you want to create the new profile with the initial default configurations, use the --empty flag.",
),
Args: args.SingleArg(profileArg, nil),
Example: examples.Build(
examples.NewExample(
`Create a new configuration profile "my-profile" with the current configuration, setting it as the active profile`,
"$ stackit config profile create my-profile"),
examples.NewExample(
`Create a new configuration profile "my-profile" with a default initial configuration and don't set it as the active profile`,
"$ stackit config profile create my-profile --empty --no-set"),
),
RunE: func(cmd *cobra.Command, args []string) error {
model, err := parseInput(p, cmd, args)
if err != nil {
return err
}
err = config.CreateProfile(p, model.Profile, !model.NoSet, model.FromEmptyProfile)
if err != nil {
return fmt.Errorf("create profile: %w", err)
}
if model.NoSet {
p.Info("Successfully created profile %q\n", model.Profile)
return nil
}
p.Info("Successfully created and set active profile to %q\n", model.Profile)
flow, err := auth.GetAuthFlow()
if err != nil {
p.Debug(print.WarningLevel, "both keyring and text file storage failed to find a valid authentication flow for the active profile")
p.Warn("The active profile %q is not authenticated, please login using the 'stackit auth login' command.\n", model.Profile)
return nil
}
p.Debug(print.DebugLevel, "found valid authentication flow for active profile: %s", flow)
return nil
},
}
configureFlags(cmd)
return cmd
}
func configureFlags(cmd *cobra.Command) {
cmd.Flags().Bool(noSetFlag, false, "Do not set the profile as the active profile")
cmd.Flags().Bool(fromEmptyProfile, false, "Create the profile with the initial default configurations")
}
func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
profile := inputArgs[0]
err := config.ValidateProfile(profile)
if err != nil {
return nil, err
}
globalFlags := globalflags.Parse(p, cmd)
model := inputModel{
GlobalFlagModel: globalFlags,
Profile: profile,
FromEmptyProfile: flags.FlagToBoolValue(p, cmd, fromEmptyProfile),
NoSet: flags.FlagToBoolValue(p, cmd, noSetFlag),
}
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
}
package delete
import (
"testing"
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/google/go-cmp/cmp"
)
const testProfile = "test-profile"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
testProfile,
}
for _, mod := range mods {
mod(argValues)
}
return argValues
}
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
Verbosity: globalflags.VerbosityDefault,
},
Profile: testProfile,
}
for _, mod := range mods {
mod(model)
}
return model
}
func TestParseInput(t *testing.T) {
tests := []struct {
description string
argValues []string
flagValues map[string]string
isValid bool
expectedModel *inputModel
}{
{
description: "base",
argValues: fixtureArgValues(),
isValid: true,
expectedModel: fixtureInputModel(),
},
{
description: "no values",
argValues: []string{},
flagValues: map[string]string{},
isValid: false,
},
{
description: "no arg values",
argValues: []string{},
isValid: false,
},
{
description: "some global flag",
argValues: fixtureArgValues(),
flagValues: map[string]string{
globalflags.VerbosityFlag: globalflags.DebugVerbosity,
},
isValid: true,
expectedModel: fixtureInputModel(func(model *inputModel) {
model.GlobalFlagModel.Verbosity = globalflags.DebugVerbosity
}),
},
{
description: "invalid profile",
argValues: []string{"invalid-profile-&"},
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.argValues)
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.argValues)
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)
}
})
}
}
package delete
import (
"fmt"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/auth"
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/spf13/cobra"
)
const (
profileArg = "PROFILE"
)
type inputModel struct {
*globalflags.GlobalFlagModel
Profile string
}
func NewCmd(p *print.Printer) *cobra.Command {
cmd := &cobra.Command{
Use: fmt.Sprintf("delete %s", profileArg),
Short: "Delete a CLI configuration profile",
Long: fmt.Sprintf("%s\n%s",
"Delete a CLI configuration profile.",
"If the deleted profile is the active profile, the default profile will be set to active.",
),
Args: args.SingleArg(profileArg, nil),
Example: examples.Build(
examples.NewExample(
`Delete the configuration profile "my-profile"`,
"$ stackit config profile delete my-profile"),
),
RunE: func(cmd *cobra.Command, args []string) error {
model, err := parseInput(p, cmd, args)
if err != nil {
return err
}
profileExists, err := config.ProfileExists(model.Profile)
if err != nil {
return fmt.Errorf("check if profile exists: %w", err)
}
if !profileExists {
return &errors.DeleteInexistentProfile{Profile: model.Profile}
}
if model.Profile == config.DefaultProfileName {
return &errors.DeleteDefaultProfile{DefaultProfile: config.DefaultProfileName}
}
activeProfile, err := config.GetProfile()
if err != nil {
return fmt.Errorf("get profile: %w", err)
}
if activeProfile == model.Profile {
p.Warn("The profile you are trying to delete is the active profile. The default profile will be set to active.\n")
}
if !model.AssumeYes {
prompt := fmt.Sprintf("Are you sure you want to delete profile %q? (This cannot be undone)", model.Profile)
err = p.PromptForConfirmation(prompt)
if err != nil {
return err
}
}
err = config.DeleteProfile(p, model.Profile)
if err != nil {
return fmt.Errorf("delete profile: %w", err)
}
err = auth.DeleteProfileFromKeyring(model.Profile)
if err != nil {
return fmt.Errorf("delete profile from keyring: %w", err)
}
p.Info("Successfully deleted profile %q\n", model.Profile)
return nil
},
}
return cmd
}
func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
profile := inputArgs[0]
err := config.ValidateProfile(profile)
if err != nil {
return nil, err
}
globalFlags := globalflags.Parse(p, cmd)
model := inputModel{
GlobalFlagModel: globalFlags,
Profile: profile,
}
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
}
package list
import (
"encoding/json"
"fmt"
"github.com/goccy/go-yaml"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/auth"
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
"github.com/spf13/cobra"
)
type inputModel struct {
*globalflags.GlobalFlagModel
}
func NewCmd(p *print.Printer) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "Lists all CLI configuration profiles",
Long: "Lists all CLI configuration profiles.",
Args: args.NoArgs,
Example: examples.Build(
examples.NewExample(
`List the configuration profiles`,
"$ stackit config profile list"),
examples.NewExample(
`List the configuration profiles in a json format`,
"$ stackit config profile list --output-format json"),
),
RunE: func(cmd *cobra.Command, args []string) error {
model := parseInput(p, cmd)
profiles, err := config.ListProfiles()
if err != nil {
return fmt.Errorf("get profile: %w", err)
}
activeProfile, err := config.GetProfile()
if err != nil {
return fmt.Errorf("get profile: %w", err)
}
outputProfiles := buildOutput(profiles, activeProfile)
return outputResult(p, model.OutputFormat, outputProfiles)
},
}
return cmd
}
func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel {
globalFlags := globalflags.Parse(p, cmd)
return &inputModel{
GlobalFlagModel: globalFlags,
}
}
type profileInfo struct {
Name string
Active bool
Email string
}
func buildOutput(profiles []string, activeProfile string) []profileInfo {
var configData []profileInfo
// Add default profile first
configData = append(configData, profileInfo{
Name: config.DefaultProfileName,
Active: activeProfile == config.DefaultProfileName,
Email: auth.GetProfileEmail(config.DefaultProfileName),
})
for _, profile := range profiles {
configData = append(configData, profileInfo{
Name: profile,
Active: profile == activeProfile,
Email: auth.GetProfileEmail(profile),
})
}
return configData
}
func outputResult(p *print.Printer, outputFormat string, profiles []profileInfo) error {
switch outputFormat {
case print.JSONOutputFormat:
details, err := json.MarshalIndent(profiles, "", " ")
if err != nil {
return fmt.Errorf("marshal config list: %w", err)
}
p.Outputln(string(details))
return nil
case print.YAMLOutputFormat:
details, err := yaml.MarshalWithOptions(profiles, yaml.IndentSequence(true))
if err != nil {
return fmt.Errorf("marshal config list: %w", err)
}
p.Outputln(string(details))
return nil
default:
table := tables.NewTable()
table.SetHeader("NAME", "ACTIVE", "EMAIL")
for _, profile := range profiles {
// Prettify the output
email := profile.Email
active := ""
if profile.Email == "" {
email = "Not authenticated"
}
if profile.Active {
active = "*"
}
table.AddRow(profile.Name, active, email)
table.AddSeparator()
}
err := table.Display(p)
if err != nil {
return fmt.Errorf("render table: %w", err)
}
return nil
}
}
package profile
import (
"fmt"
"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/list"
"github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/set"
"github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/unset"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
)
func NewCmd(p *print.Printer) *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage the CLI configuration profiles",
Long: fmt.Sprintf("%s\n%s\n%s\n%s",
"Manage the CLI configuration profiles.",
`The profile to be used can be managed via the "STACKIT_CLI_PROFILE" environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands.`,
"The environment variable takes precedence over what is set via the commands.",
"When no profile is set, the default profile is used.",
),
Args: args.NoArgs,
Run: utils.CmdHelp,
}
addSubcommands(cmd, p)
return cmd
}
func addSubcommands(cmd *cobra.Command, p *print.Printer) {
cmd.AddCommand(set.NewCmd(p))
cmd.AddCommand(unset.NewCmd(p))
cmd.AddCommand(create.NewCmd(p))
cmd.AddCommand(list.NewCmd(p))
cmd.AddCommand(delete.NewCmd(p))
}
package set
import (
"testing"
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/google/go-cmp/cmp"
)
const testProfile = "test-profile"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
testProfile,
}
for _, mod := range mods {
mod(argValues)
}
return argValues
}
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
Verbosity: globalflags.VerbosityDefault,
},
Profile: testProfile,
}
for _, mod := range mods {
mod(model)
}
return model
}
func TestParseInput(t *testing.T) {
tests := []struct {
description string
argValues []string
flagValues map[string]string
isValid bool
expectedModel *inputModel
}{
{
description: "base",
argValues: fixtureArgValues(),
isValid: true,
expectedModel: fixtureInputModel(),
},
{
description: "no values",
argValues: []string{},
flagValues: map[string]string{},
isValid: false,
},
{
description: "no arg values",
argValues: []string{},
isValid: false,
},
{
description: "some global flag",
argValues: fixtureArgValues(),
flagValues: map[string]string{
globalflags.VerbosityFlag: globalflags.DebugVerbosity,
},
isValid: true,
expectedModel: fixtureInputModel(func(model *inputModel) {
model.GlobalFlagModel.Verbosity = globalflags.DebugVerbosity
}),
},
{
description: "invalid profile",
argValues: []string{"invalid-profile-&"},
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.argValues)
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.argValues)
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)
}
})
}
}
package set
import (
"fmt"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/auth"
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/spf13/cobra"
)
const (
profileArg = "PROFILE"
)
type inputModel struct {
*globalflags.GlobalFlagModel
Profile string
}
func NewCmd(p *print.Printer) *cobra.Command {
cmd := &cobra.Command{
Use: fmt.Sprintf("set %s", profileArg),
Short: "Set a CLI configuration profile",
Long: fmt.Sprintf("%s\n%s\n%s\n%s",
"Set a CLI configuration profile as the active profile.",
`The profile to be used can be managed via the STACKIT_CLI_PROFILE environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands.`,
"The environment variable takes precedence over what is set via the commands.",
"When no profile is set, the default profile is used.",
),
Args: args.SingleArg(profileArg, nil),
Example: examples.Build(
examples.NewExample(
`Set the configuration profile "my-profile" as the active profile`,
"$ stackit config profile set my-profile"),
),
RunE: func(cmd *cobra.Command, args []string) error {
model, err := parseInput(p, cmd, args)
if err != nil {
return err
}
profileExists, err := config.ProfileExists(model.Profile)
if err != nil {
return fmt.Errorf("check if profile exists: %w", err)
}
if !profileExists {
return &errors.SetInexistentProfile{Profile: model.Profile}
}
err = config.SetProfile(p, model.Profile)
if err != nil {
return fmt.Errorf("set profile: %w", err)
}
p.Info("Successfully set active profile to %q\n", model.Profile)
flow, err := auth.GetAuthFlow()
if err != nil {
p.Debug(print.WarningLevel, "both keyring and text file storage failed to find a valid authentication flow for the active profile")
p.Warn("The active profile %q is not authenticated, please login using the 'stackit auth login' command.\n", model.Profile)
return nil
}
p.Debug(print.DebugLevel, "found valid authentication flow for active profile: %s", flow)
return nil
},
}
return cmd
}
func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
profile := inputArgs[0]
err := config.ValidateProfile(profile)
if err != nil {
return nil, err
}
globalFlags := globalflags.Parse(p, cmd)
model := inputModel{
GlobalFlagModel: globalFlags,
Profile: profile,
}
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
}
package unset
import (
"fmt"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/auth"
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/spf13/cobra"
)
func NewCmd(p *print.Printer) *cobra.Command {
cmd := &cobra.Command{
Use: "unset",
Short: "Unset the current active CLI configuration profile",
Long: fmt.Sprintf("%s\n%s",
"Unset the current active CLI configuration profile.",
"When no profile is set, the default profile will be used.",
),
Args: args.NoArgs,
Example: examples.Build(
examples.NewExample(
`Unset the currently active configuration profile. The default profile will be used.`,
"$ stackit config profile unset"),
),
RunE: func(cmd *cobra.Command, args []string) error {
err := config.UnsetProfile(p)
if err != nil {
return fmt.Errorf("unset profile: %w", err)
}
p.Info("Profile unset successfully. The default profile will be used.\n")
flow, err := auth.GetAuthFlow()
if err != nil {
p.Debug(print.WarningLevel, "both keyring and text file storage failed to find a valid authentication flow for the active profile")
p.Warn("The default profile is not authenticated, please login using the 'stackit auth login' command.\n")
return nil
}
p.Debug(print.DebugLevel, "found valid authentication flow for active profile: %s", flow)
return nil
},
}
return cmd
}
package config
import (
"path/filepath"
"testing"
)
func TestValidateProfile(t *testing.T) {
tests := []struct {
description string
profile string
isValid bool
}{
{
description: "valid profile with letters",
profile: "myprofile",
isValid: true,
},
{
description: "valid with letters and hyphen",
profile: "my-profile",
isValid: true,
},
{
description: "valid with letters, numbers, and hyphen",
profile: "my-profile-123",
isValid: true,
},
{
description: "valid with letters, numbers, and ending with hyphen",
profile: "my-profile123-",
isValid: true,
},
{
description: "invalid empty",
profile: "",
isValid: false,
},
{
description: "invalid with special characters",
profile: "my_profile",
isValid: false,
},
{
description: "invalid with spaces",
profile: "my profile",
isValid: false,
},
{
description: "invalid starting with -",
profile: "-my-profile",
isValid: false,
},
{
description: "invalid profile with uppercase letters",
profile: "myProfile",
isValid: false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := ValidateProfile(tt.profile)
if tt.isValid && err != nil {
t.Errorf("expected profile to be valid but got error: %v", err)
}
if !tt.isValid && err == nil {
t.Errorf("expected profile to be invalid but got no error")
}
})
}
}
func TestGetProfileFolderPath(t *testing.T) {
tests := []struct {
description string
defaultConfigFolderNotSet bool
profile string
expected string
}{
{
description: "default profile",
profile: DefaultProfileName,
expected: getInitialConfigDir(),
},
{
description: "default profile, default config folder not set",
defaultConfigFolderNotSet: true,
profile: DefaultProfileName,
expected: getInitialConfigDir(),
},
{
description: "custom profile",
profile: "my-profile",
expected: filepath.Join(getInitialConfigDir(), profileRootFolder, "my-profile"),
},
{
description: "custom profile, default config folder not set",
defaultConfigFolderNotSet: true,
profile: "my-profile",
expected: filepath.Join(getInitialConfigDir(), profileRootFolder, "my-profile"),
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
defaultConfigFolderPath = getInitialConfigDir()
if tt.defaultConfigFolderNotSet {
defaultConfigFolderPath = ""
}
actual := GetProfileFolderPath(tt.profile)
if actual != tt.expected {
t.Errorf("expected profile folder path to be %q but got %q", tt.expected, actual)
}
})
}
}
package config
import (
"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"
)
const ProfileEnvVar = "STACKIT_CLI_PROFILE"
// GetProfile returns the current profile to be used by the CLI.
// The profile is determined by the value of the STACKIT_CLI_PROFILE environment variable, or, if not set,
// by the contents of the profile file in the CLI config folder.
// If the profile is not set (env var or profile file) or is set but does not exist, it falls back to the default profile.
// If the profile is not valid, it returns an error.
func GetProfile() (string, error) {
_, profile, _, err := GetConfiguredProfile()
if err != nil {
return "", err
}
return profile, nil
}
// GetConfiguredProfile returns the profile configured by the user, the profile to be used by the CLI and the method used to configure the profile.
// The profile is determined by the value of the STACKIT_CLI_PROFILE environment variable, or, if not set,
// by the contents of the profile file in the CLI config folder.
// If the configured profile is not set (env var or profile file) or is set but does not exist, it falls back to the default profile.
// The configuration method can be environment variable, profile file or empty if profile is not configured.
// If the profile is not valid, it returns an error.
func GetConfiguredProfile() (configuredProfile, activeProfile, configurationMethod string, err error) {
var configMethod string
profile, profileSetInEnv := GetProfileFromEnv()
if !profileSetInEnv {
contents, exists, err := fileutils.ReadFileIfExists(profileFilePath)
if err != nil {
return "", "", "", fmt.Errorf("read profile from file: %w", err)
}
if !exists {
// No profile set in env or file
return DefaultProfileName, DefaultProfileName, "", nil
}
profile = contents
configMethod = "profile file"
} else {
configMethod = "environment variable"
}
// Make sure the profile exists
profileExists, err := ProfileExists(profile)
if err != nil {
return "", "", "", fmt.Errorf("check if profile exists: %w", err)
}
if !profileExists {
// Profile is configured but does not exist
return profile, DefaultProfileName, configMethod, nil
}
err = ValidateProfile(profile)
if err != nil {
return "", "", "", fmt.Errorf("validate profile: %w", err)
}
return profile, profile, configMethod, nil
}
// GetProfileFromEnv returns the profile from the environment variable.
// If the environment variable is not set, it returns an empty string.
// If the profile is not valid, it returns an error.
func GetProfileFromEnv() (string, bool) {
return os.LookupEnv(ProfileEnvVar)
}
// CreateProfile creates a new profile.
// If emptyProfile is true, it creates an empty profile. Otherwise, copies the config from the current profile to the new profile.
// If setProfile is true, it sets the new profile as the active profile.
// If the profile already exists, it returns an error.
func CreateProfile(p *print.Printer, profile string, setProfile, emptyProfile bool) error {
err := ValidateProfile(profile)
if err != nil {
return fmt.Errorf("validate profile: %w", err)
}
// Cannot create a profile with the default name
if profile == DefaultProfileName {
return &errors.InvalidProfileNameError{
Profile: profile,
}
}
configFolderPath = GetProfileFolderPath(profile)
// Error if the profile already exists
_, err = os.Stat(configFolderPath)
if err == nil {
return fmt.Errorf("profile %q already exists", profile)
}
err = os.MkdirAll(configFolderPath, os.ModePerm)
if err != nil {
return fmt.Errorf("create config folder: %w", err)
}
p.Debug(print.DebugLevel, "created folder for the new profile: %s", configFolderPath)
if !emptyProfile {
currentProfile, err := GetProfile()
if err != nil {
// Cleanup created directory
cleanupErr := os.RemoveAll(configFolderPath)
if cleanupErr != nil {
return fmt.Errorf("get active profile: %w, cleanup directories: %w", err, cleanupErr)
}
return fmt.Errorf("get active profile: %w", err)
}
p.Debug(print.DebugLevel, "current active profile: %q", currentProfile)
p.Debug(print.DebugLevel, "duplicating profile configuration from %q to new profile %q", currentProfile, profile)
err = DuplicateProfileConfiguration(p, currentProfile, profile)
if err != nil {
// Cleanup created directory
cleanupErr := os.RemoveAll(configFolderPath)
if cleanupErr != nil {
return fmt.Errorf("get active profile: %w, cleanup directories: %w", err, cleanupErr)
}
return fmt.Errorf("duplicate profile configuration: %w", err)
}
}
if setProfile {
err = SetProfile(p, profile)
if err != nil {
return fmt.Errorf("set profile: %w", err)
}
}
return nil
}
// DuplicateProfileConfiguration duplicates the current profile configuration to a new profile.
// It copies the config file from the current profile to the new profile.
// If the current profile does not exist, it does nothing.
// If the new profile already exists, it will be overwritten.
func DuplicateProfileConfiguration(p *print.Printer, currentProfile, newProfile string) error {
currentProfileFolder := GetProfileFolderPath(currentProfile)
currentConfigFilePath := getConfigFilePath(currentProfileFolder)
newConfigFilePath := getConfigFilePath(configFolderPath)
// If the source profile configuration does not exist, do nothing
_, err := os.Stat(currentConfigFilePath)
if err != nil {
if os.IsNotExist(err) {
p.Debug(print.DebugLevel, "current profile %q has no configuration, nothing to duplicate", currentProfile)
return nil
}
return fmt.Errorf("get current profile configuration: %w", err)
}
err = fileutils.CopyFile(currentConfigFilePath, newConfigFilePath)
if err != nil {
return fmt.Errorf("copy config file: %w", err)
}
p.Debug(print.DebugLevel, "created new configuration for profile %q based on %q in: %s", newProfile, currentProfile, newConfigFilePath)
return nil
}
// SetProfile sets the profile to be used by the CLI.
func SetProfile(p *print.Printer, profile string) error {
err := ValidateProfile(profile)
if err != nil {
return fmt.Errorf("validate profile: %w", err)
}
profileExists, err := ProfileExists(profile)
if err != nil {
return fmt.Errorf("check if profile exists: %w", err)
}
if !profileExists {
return &errors.SetInexistentProfile{Profile: profile}
}
if profileFilePath == "" {
profileFilePath = getInitialProfileFilePath()
}
err = os.WriteFile(profileFilePath, []byte(profile), os.ModePerm)
if err != nil {
return fmt.Errorf("write profile to file: %w", err)
}
p.Debug(print.DebugLevel, "persisted new active profile in: %s", profileFilePath)
configFolderPath = GetProfileFolderPath(profile)
p.Debug(print.DebugLevel, "profile %q is now active", profile)
return nil
}
// UnsetProfile removes the profile file.
// If the profile file does not exist, it does nothing.
func UnsetProfile(p *print.Printer) error {
err := os.Remove(profileFilePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove profile file: %w", err)
}
if p != nil {
p.Debug(print.DebugLevel, "removed active profile file: %s", profileFilePath)
}
return nil
}
// ValidateProfile validates the profile name.
// It can only use lowercase letters, numbers, or "-" and cannot be empty.
// It can't start with a "-".
// If the profile is invalid, it returns an error.
func ValidateProfile(profile string) error {
match, err := regexp.MatchString("^[a-z0-9][a-z0-9-]+$", profile)
if err != nil {
return fmt.Errorf("match string regex: %w", err)
}
if !match {
return &errors.InvalidProfileNameError{
Profile: profile,
}
}
return nil
}
func ProfileExists(profile string) (bool, error) {
_, err := os.Stat(GetProfileFolderPath(profile))
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("get profile folder: %w", err)
}
return true, nil
}
// GetProfileFolderPath returns the path to the folder where the profile configuration is stored.
// If the profile is the default profile, it returns the default config folder path.
func GetProfileFolderPath(profile string) string {
if defaultConfigFolderPath == "" {
defaultConfigFolderPath = getInitialConfigDir()
}
if profile == DefaultProfileName {
return defaultConfigFolderPath
}
return filepath.Join(defaultConfigFolderPath, profileRootFolder, profile)
}
// ListProfiles returns a list of all non-default profiles.
// If there are no profiles, it returns an empty list.
func ListProfiles() ([]string, error) {
profiles := []string{}
// Check if the profile root folder exists
_, err := os.Stat(filepath.Join(defaultConfigFolderPath, profileRootFolder))
if err != nil {
if os.IsNotExist(err) {
return profiles, nil
}
return nil, fmt.Errorf("get profile root folder: %w", err)
}
profileFolders, err := os.ReadDir(filepath.Join(defaultConfigFolderPath, profileRootFolder))
if err != nil {
return nil, fmt.Errorf("read profile folders: %w", err)
}
for _, profileFolder := range profileFolders {
if profileFolder.IsDir() {
profiles = append(profiles, profileFolder.Name())
}
}
return profiles, nil
}
// DeleteProfile deletes a profile.
// If the profile does not exist or is the default profile, it returns an error.
// If the profile is the active profile, it sets the active profile to the default profile.
func DeleteProfile(p *print.Printer, profile string) error {
err := ValidateProfile(profile)
if err != nil {
return fmt.Errorf("validate profile: %w", err)
}
// Default profile cannot be deleted
if profile == DefaultProfileName {
return &errors.DeleteDefaultProfile{DefaultProfile: DefaultProfileName}
}
activeProfile, err := GetProfile()
if err != nil {
return fmt.Errorf("get active profile: %w", err)
}
profileExists, err := ProfileExists(profile)
if err != nil {
return fmt.Errorf("check if profile exists: %w", err)
}
if !profileExists {
return &errors.DeleteInexistentProfile{Profile: profile}
}
err = os.RemoveAll(filepath.Join(defaultConfigFolderPath, profileRootFolder, profile))
if err != nil {
return fmt.Errorf("remove profile folder: %w", err)
}
if activeProfile == profile {
err = UnsetProfile(p)
if err != nil {
return fmt.Errorf("unset profile: %w", err)
}
}
if p != nil {
p.Debug(print.DebugLevel, "deleted profile %q", profile)
}
return nil
}
my-content
+7
-6

@@ -7,9 +7,9 @@ ## stackit config

Provides functionality for CLI configuration options
The configuration is stored in a file in the user's config directory, which is OS dependent.
Windows: %APPDATA%\stackit
Linux: $XDG_CONFIG_HOME/stackit
macOS: $HOME/Library/Application Support/stackit
The configuration file is named `cli-config.json` and is created automatically in your first CLI run.
Provides functionality for CLI configuration options.
You can set and unset different configuration options via the "stackit config set" and "stackit config unset" commands.
Additionally, you can configure the CLI to use different profiles, each with its own configuration.
Additional profiles can be configured via the "STACKIT_CLI_PROFILE" environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands.
The environment variable takes precedence over what is set via the commands.
```

@@ -39,4 +39,5 @@ stackit config [flags]

* [stackit config list](./stackit_config_list.md) - Lists the current CLI configuration values
* [stackit config profile](./stackit_config_profile.md) - Manage the CLI configuration profiles
* [stackit config set](./stackit_config_set.md) - Sets CLI configuration options
* [stackit config unset](./stackit_config_unset.md) - Unsets CLI configuration options
+10
-10

@@ -6,3 +6,3 @@ module github.com/stackitcloud/stackit-cli

require (
github.com/fatih/color v1.14.1
github.com/fatih/color v1.17.0
github.com/goccy/go-yaml v1.11.3

@@ -18,3 +18,3 @@ github.com/golang-jwt/jwt/v5 v5.2.1

github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.18.2
github.com/spf13/viper v1.19.0
github.com/stackitcloud/stackit-sdk-go/core v0.12.0

@@ -29,3 +29,3 @@ github.com/stackitcloud/stackit-sdk-go/services/authorization v0.3.0

github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.4.0
github.com/stackitcloud/stackit-sdk-go/services/ske v0.15.0
github.com/stackitcloud/stackit-sdk-go/services/ske v0.16.0
github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.2.0

@@ -48,3 +48,3 @@ github.com/zalando/go-keyring v0.2.4

require (
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect

@@ -59,3 +59,3 @@ )

github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-logr/logr v1.3.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect

@@ -73,3 +73,3 @@ github.com/gogo/protobuf v1.3.2 // indirect

github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rivo/uniseg v0.4.4 // indirect

@@ -84,7 +84,7 @@ github.com/russross/blackfriday/v2 v2.1.0 // indirect

github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.12.0
github.com/stackitcloud/stackit-sdk-go/services/logme v0.14.0
github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.14.0
github.com/stackitcloud/stackit-sdk-go/services/logme v0.15.0
github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.15.0
github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.9.0
github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.14.0
github.com/stackitcloud/stackit-sdk-go/services/redis v0.14.0
github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.15.0
github.com/stackitcloud/stackit-sdk-go/services/redis v0.15.0
github.com/subosito/gotenv v1.6.0 // indirect

@@ -91,0 +91,0 @@ go.uber.org/multierr v1.11.0 // indirect

+27
-23

@@ -13,4 +13,4 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0=

github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=

@@ -20,4 +20,5 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=

github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=

@@ -86,4 +87,4 @@ github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=

github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=

@@ -100,4 +101,4 @@ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=

github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

@@ -127,4 +128,4 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=

github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stackitcloud/stackit-sdk-go/core v0.12.0 h1:auIzUUNRuydKOScvpICP4MifGgvOajiDQd+ncGmBL0U=

@@ -140,6 +141,6 @@ github.com/stackitcloud/stackit-sdk-go/core v0.12.0/go.mod h1:mDX1mSTsB3mP+tNBGcFNx6gH1mGBN4T+dVt+lcw7nlw=

github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.12.0/go.mod h1:wsO3+vXe1XiKLeCIctWAptaHQZ07Un7kmLTQ+drbj7w=
github.com/stackitcloud/stackit-sdk-go/services/logme v0.14.0 h1:vvQFCN5sKZA9tdzrbDnAVMsaTijX8lvTYnPaKQHmkoI=
github.com/stackitcloud/stackit-sdk-go/services/logme v0.14.0/go.mod h1:bj9cn1treNSxKTRCEmESwqfENN8vCYn60HUnEA0P83c=
github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.14.0 h1:tK6imWrbZ5TgQJbukWCUz7yDgcvvFMX8wamxkPTLuDo=
github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.14.0/go.mod h1:kPetkX9hNm9HkRyiKQL/tlgdi8frZdMP8afg0mEvQ9s=
github.com/stackitcloud/stackit-sdk-go/services/logme v0.15.0 h1:7gii3PZshOesHPCYlPycilXglk28imITIqjewySZwZ4=
github.com/stackitcloud/stackit-sdk-go/services/logme v0.15.0/go.mod h1:bj9cn1treNSxKTRCEmESwqfENN8vCYn60HUnEA0P83c=
github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.15.0 h1:eYYyVUTS9Gjovg3z9+r6ctvsm1p1J4fHLa5QJbWHi0A=
github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.15.0/go.mod h1:kPetkX9hNm9HkRyiKQL/tlgdi8frZdMP8afg0mEvQ9s=
github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.14.0 h1:FaJYVfha+atvPfFIf3h3+BFjOjeux9OBHukG1J98kq0=

@@ -153,6 +154,6 @@ github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.14.0/go.mod h1:iFerEzGmkg6R13ldFUyHUWHm0ac9cS4ftTDLhP0k/dU=

github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.14.0/go.mod h1:SdrqGLCkilL6wl1+jcxmLtks2IocgIg+bsyeyYUIzR4=
github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.14.0 h1:wJ+LSMrRol4wlm/ML4wvVPGwIw51VHMFwMCOtwluvKQ=
github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.14.0/go.mod h1:eSgnPBknTJh7t+jVKN+xzeAh+Cg1USOlH3QCyfvG20g=
github.com/stackitcloud/stackit-sdk-go/services/redis v0.14.0 h1:wcfA/3mTI7UmTFmKX09EKIVsEqflfkiuEoWL/j5cMvg=
github.com/stackitcloud/stackit-sdk-go/services/redis v0.14.0/go.mod h1:3LhiTR/DMbKR2HuleTzlFHltR1MT1KD0DeW46X6K2GE=
github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.15.0 h1:Q7JxjVwb+9ugAX71AXdbfPL87HHmIIwb9LNahn6H/2o=
github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.15.0/go.mod h1:eSgnPBknTJh7t+jVKN+xzeAh+Cg1USOlH3QCyfvG20g=
github.com/stackitcloud/stackit-sdk-go/services/redis v0.15.0 h1:/S+LOl94FqGk5Qdi5ehsiSCh6cCPEYJDctNOD0c2dmw=
github.com/stackitcloud/stackit-sdk-go/services/redis v0.15.0/go.mod h1:3LhiTR/DMbKR2HuleTzlFHltR1MT1KD0DeW46X6K2GE=
github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.8.0 h1:7AIvLkB7JZ5lYKtYLwI0rgJ0185hwQC1PFiUrjcinDM=

@@ -164,4 +165,4 @@ github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.8.0/go.mod h1:p16qz/pAW8b1gEhqMpIgJfutRPeDPqQLlbVGyCo3f8o=

github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.4.0/go.mod h1:Ni9RBJvcaXRIrDIuQBpJcuQvCQSj27crQSyc+WM4p0c=
github.com/stackitcloud/stackit-sdk-go/services/ske v0.15.0 h1:7iTzdiglvJmKMaHlr4JUPvNOmA730rAniry74cnZ8zI=
github.com/stackitcloud/stackit-sdk-go/services/ske v0.15.0/go.mod h1:0fFs4R7kg+gU7FNAIzzFvlCZJz6gyZ8CFhbK3eSrAwQ=
github.com/stackitcloud/stackit-sdk-go/services/ske v0.16.0 h1:trrJuRMzgXu6fiiMZiUx6+A1FNKEFhA1vGq5cr5Qn3U=
github.com/stackitcloud/stackit-sdk-go/services/ske v0.16.0/go.mod h1:0fFs4R7kg+gU7FNAIzzFvlCZJz6gyZ8CFhbK3eSrAwQ=
github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.2.0 h1:aIXxXx6u4+6C02MPb+hdItigeKeen7m+hEEG+Ej9sNs=

@@ -171,9 +172,11 @@ github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.2.0/go.mod h1:fQJOQMfasStZ8J9iGX0vTjyJoQtLqMXJ5Npb03QJk84=

github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=

@@ -213,2 +216,3 @@ github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=

golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=

@@ -234,4 +238,4 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

@@ -238,0 +242,0 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

@@ -7,2 +7,3 @@ package config

"github.com/stackitcloud/stackit-cli/internal/cmd/config/list"
"github.com/stackitcloud/stackit-cli/internal/cmd/config/profile"
"github.com/stackitcloud/stackit-cli/internal/cmd/config/set"

@@ -21,8 +22,8 @@ "github.com/stackitcloud/stackit-cli/internal/cmd/config/unset"

Short: "Provides functionality for CLI configuration options",
Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", "Provides functionality for CLI configuration options",
"The configuration is stored in a file in the user's config directory, which is OS dependent.",
"Windows: %APPDATA%\\stackit",
"Linux: $XDG_CONFIG_HOME/stackit",
"macOS: $HOME/Library/Application Support/stackit",
"The configuration file is named `cli-config.json` and is created automatically in your first CLI run.",
Long: fmt.Sprintf("%s\n%s\n\n%s\n%s\n%s",
"Provides functionality for CLI configuration options.",
`You can set and unset different configuration options via the "stackit config set" and "stackit config unset" commands.`,
"Additionally, you can configure the CLI to use different profiles, each with its own configuration.",
`Additional profiles can be configured via the "STACKIT_CLI_PROFILE" environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands.`,
"The environment variable takes precedence over what is set via the commands.",
),

@@ -40,2 +41,3 @@ Args: args.NoArgs,

cmd.AddCommand(unset.NewCmd(p))
cmd.AddCommand(profile.NewCmd(p))
}

@@ -54,3 +54,9 @@ package list

model := parseInput(p, cmd)
return outputResult(p, model.OutputFormat, configData)
activeProfile, err := config.GetProfile()
if err != nil {
return fmt.Errorf("get profile: %w", err)
}
return outputResult(p, model.OutputFormat, configData, activeProfile)
},

@@ -69,5 +75,8 @@ }

func outputResult(p *print.Printer, outputFormat string, configData map[string]any) error {
func outputResult(p *print.Printer, outputFormat string, configData map[string]any, activeProfile string) error {
switch outputFormat {
case print.JSONOutputFormat:
if activeProfile != "" {
configData["profile"] = activeProfile
}
details, err := json.MarshalIndent(configData, "", " ")

@@ -88,2 +97,3 @@ if err != nil {

default:
// Sort the config options by key

@@ -97,2 +107,5 @@ configKeys := make([]string, 0, len(configData))

table := tables.NewTable()
if activeProfile != "" {
table.SetTitle(fmt.Sprintf("Profile: %q", activeProfile))
}
table.SetHeader("NAME", "VALUE")

@@ -99,0 +112,0 @@ for _, key := range configKeys {

@@ -12,3 +12,3 @@ package cmd

"github.com/stackitcloud/stackit-cli/internal/cmd/beta"
"github.com/stackitcloud/stackit-cli/internal/cmd/config"
configCmd "github.com/stackitcloud/stackit-cli/internal/cmd/config"
"github.com/stackitcloud/stackit-cli/internal/cmd/curl"

@@ -31,2 +31,3 @@ "github.com/stackitcloud/stackit-cli/internal/cmd/dns"

"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"

@@ -37,3 +38,2 @@ "github.com/stackitcloud/stackit-cli/internal/pkg/flags"

"github.com/fatih/color"
"github.com/spf13/cobra"

@@ -52,3 +52,3 @@ "github.com/spf13/viper"

DisableAutoGenTag: true,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
p.Cmd = cmd

@@ -61,7 +61,29 @@ p.Verbosity = print.Level(globalflags.Parse(p, cmd).Verbosity)

configFilePath := viper.ConfigFileUsed()
p.Debug(print.DebugLevel, "using config file: %s", configFilePath)
p.Debug(print.DebugLevel, "configuration is persisted and read from: %s", configFilePath)
profileSet, activeProfile, configMethod, err := config.GetConfiguredProfile()
if err != nil {
return fmt.Errorf("get configured profile: %w", err)
}
p.Debug(print.DebugLevel, "read configuration profile %q via %s", profileSet, configMethod)
if activeProfile != profileSet {
if configMethod == "" {
p.Debug(print.DebugLevel, "no profile is configured in env var or profile file")
} else {
p.Debug(print.DebugLevel, "the configured profile %q does not exist: folder %q is missing", profileSet, config.GetProfileFolderPath(profileSet))
}
p.Debug(print.DebugLevel, "the %q profile will be used", activeProfile)
p.Warn("configured profile %q does not exist, the %q profile configuration will be used\n", profileSet, activeProfile)
}
p.Debug(print.DebugLevel, "active configuration profile: %s", activeProfile)
configKeys := viper.AllSettings()
configKeysStr := print.BuildDebugStrFromMap(configKeys)
p.Debug(print.DebugLevel, "config keys: %s", configKeysStr)
p.Debug(print.DebugLevel, "configuration keys: %s", configKeysStr)
return nil
},

@@ -104,3 +126,3 @@ RunE: func(cmd *cobra.Command, args []string) error {

func beautifyUsageTemplate(cmd *cobra.Command) {
cobra.AddTemplateFunc("WhiteBold", color.New(color.FgHiWhite, color.Bold).SprintFunc())
cobra.AddTemplateFunc("WhiteBold", print.WhiteBold)
usageTemplate := cmd.UsageTemplate()

@@ -133,4 +155,4 @@ usageTemplate = strings.NewReplacer(

cmd.AddCommand(auth.NewCmd(p))
cmd.AddCommand(configCmd.NewCmd(p))
cmd.AddCommand(beta.NewCmd(p))
cmd.AddCommand(config.NewCmd(p))
cmd.AddCommand(curl.NewCmd(p))

@@ -137,0 +159,0 @@ cmd.AddCommand(dns.NewCmd(p))

@@ -92,3 +92,3 @@ package create

respKubeconfig *ske.Kubeconfig
respLogin *ske.V1LoginKubeconfig
respLogin *ske.LoginKubeconfig
)

@@ -214,3 +214,3 @@

func outputResult(p *print.Printer, model *inputModel, kubeconfigPath string, respKubeconfig *ske.Kubeconfig, respLogin *ske.V1LoginKubeconfig) error {
func outputResult(p *print.Printer, model *inputModel, kubeconfigPath string, respKubeconfig *ske.Kubeconfig, respLogin *ske.LoginKubeconfig) error {
switch model.OutputFormat {

@@ -217,0 +217,0 @@ case print.JSONOutputFormat:

@@ -13,2 +13,4 @@ package auth

"github.com/zalando/go-keyring"
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
)

@@ -117,2 +119,7 @@

t.Run(tt.description, func(t *testing.T) {
activeProfile, err := config.GetProfile()
if err != nil {
t.Errorf("get profile: %v", err)
}
if !tt.keyringFails {

@@ -145,3 +152,3 @@ keyring.MockInit()

if !tt.keyringFails {
err = deleteAuthFieldInKeyring(key)
err = deleteAuthFieldInKeyring(activeProfile, key)
if err != nil {

@@ -151,3 +158,3 @@ t.Errorf("Post-test cleanup failed: remove field \"%s\" from keyring: %v. Please remove it manually", key, err)

} else {
err = deleteAuthFieldInEncodedTextFile(key)
err = deleteAuthFieldInEncodedTextFile(activeProfile, key)
if err != nil {

@@ -162,2 +169,165 @@ t.Errorf("Post-test cleanup failed: remove field \"%s\" from text file: %v. Please remove it manually", key, err)

func TestSetGetAuthFieldWithProfile(t *testing.T) {
var testField1 authFieldKey = "test-field-1"
var testField2 authFieldKey = "test-field-2"
testValue1 := fmt.Sprintf("value-1-%s", time.Now().Format(time.RFC3339))
testValue2 := fmt.Sprintf("value-2-%s", time.Now().Format(time.RFC3339))
testValue3 := fmt.Sprintf("value-3-%s", time.Now().Format(time.RFC3339))
type valueAssignment struct {
key authFieldKey
value string
}
tests := []struct {
description string
keyringFails bool
valueAssignments []valueAssignment
activeProfile string
expectedValues map[authFieldKey]string
}{
{
description: "simple assignments",
valueAssignments: []valueAssignment{
{
key: testField1,
value: testValue1,
},
{
key: testField2,
value: testValue2,
},
},
activeProfile: "test-profile",
expectedValues: map[authFieldKey]string{
testField1: testValue1,
testField2: testValue2,
},
},
{
description: "simple assignments w/ keyring failing",
keyringFails: true,
valueAssignments: []valueAssignment{
{
key: testField1,
value: testValue1,
},
{
key: testField2,
value: testValue2,
},
},
activeProfile: "test-profile",
expectedValues: map[authFieldKey]string{
testField1: testValue1,
testField2: testValue2,
},
},
{
description: "overlapping assignments",
keyringFails: true,
valueAssignments: []valueAssignment{
{
key: testField1,
value: testValue1,
},
{
key: testField2,
value: testValue2,
},
{
key: testField1,
value: testValue3,
},
},
activeProfile: "test-profile",
expectedValues: map[authFieldKey]string{
testField1: testValue3,
testField2: testValue2,
},
},
{
description: "overlapping assignments w/ keyring failing",
keyringFails: true,
valueAssignments: []valueAssignment{
{
key: testField1,
value: testValue1,
},
{
key: testField2,
value: testValue2,
},
{
key: testField1,
value: testValue3,
},
},
activeProfile: "test-profile",
expectedValues: map[authFieldKey]string{
testField1: testValue3,
testField2: testValue2,
},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
// Apppend random string to profile name to avoid conflicts
tt.activeProfile = makeProfileNameUnique(tt.activeProfile)
// Make sure profile name is valid
err := config.ValidateProfile(tt.activeProfile)
if err != nil {
t.Fatalf("Profile name \"%s\" is invalid: %v", tt.activeProfile, err)
}
if !tt.keyringFails {
keyring.MockInit()
} else {
keyring.MockInitWithError(fmt.Errorf("keyring unavailable for testing"))
}
for _, assignment := range tt.valueAssignments {
err := setAuthFieldWithProfile(tt.activeProfile, assignment.key, assignment.value)
if err != nil {
t.Fatalf("Failed to set \"%s\" as \"%s\": %v", assignment.key, assignment.value, err)
}
// Check that this value will be checked
if _, ok := tt.expectedValues[assignment.key]; !ok {
t.Fatalf("Value \"%s\" set but not checked. Please add it to 'expectedValues'", assignment.key)
}
}
for key, valueExpected := range tt.expectedValues {
value, err := getAuthFieldWithProfile(tt.activeProfile, key)
if err != nil {
t.Errorf("Failed to get value of \"%s\": %v", key, err)
continue
} else if value != valueExpected {
t.Errorf("Value of field \"%s\" is wrong: expected \"%s\", got \"%s\"", key, valueExpected, value)
}
if !tt.keyringFails {
err = deleteAuthFieldInKeyring(tt.activeProfile, key)
if err != nil {
t.Errorf("Post-test cleanup failed: remove field \"%s\" from keyring: %v. Please remove it manually", key, err)
}
} else {
err = deleteAuthFieldInEncodedTextFile(tt.activeProfile, key)
if err != nil {
t.Errorf("Post-test cleanup failed: remove field \"%s\" from text file: %v. Please remove it manually", key, err)
}
}
}
err = deleteAuthFieldProfile(tt.activeProfile)
if err != nil {
t.Errorf("Post-test cleanup failed: remove profile \"%s\": %v. Please remove it manually", tt.activeProfile, err)
}
})
}
}
func TestSetGetAuthFieldKeyring(t *testing.T) {

@@ -180,5 +350,7 @@ var testField1 authFieldKey = "test-field-1"

expectedValues map[authFieldKey]string
activeProfile string
}{
{
description: "simple assignments",
description: "simple assignments with default profile",
activeProfile: config.DefaultProfileName,
valueAssignments: []valueAssignment{

@@ -200,3 +372,4 @@ {

{
description: "overlapping assignments",
description: "overlapping assignments with default profile",
activeProfile: config.DefaultProfileName,
valueAssignments: []valueAssignment{

@@ -221,2 +394,42 @@ {

},
{
description: "simple assignments with test-profile",
activeProfile: "test-profile",
valueAssignments: []valueAssignment{
{
key: testField1,
value: testValue1,
},
{
key: testField2,
value: testValue2,
},
},
expectedValues: map[authFieldKey]string{
testField1: testValue1,
testField2: testValue2,
},
},
{
description: "overlapping assignments with test-profile",
activeProfile: "test-profile",
valueAssignments: []valueAssignment{
{
key: testField1,
value: testValue1,
},
{
key: testField2,
value: testValue2,
},
{
key: testField1,
value: testValue3,
},
},
expectedValues: map[authFieldKey]string{
testField1: testValue3,
testField2: testValue2,
},
},
}

@@ -228,4 +441,13 @@

// Apppend random string to profile name to avoid conflicts
tt.activeProfile = makeProfileNameUnique(tt.activeProfile)
// Make sure profile name is valid
err := config.ValidateProfile(tt.activeProfile)
if err != nil {
t.Fatalf("Profile name \"%s\" is invalid: %v", tt.activeProfile, err)
}
for _, assignment := range tt.valueAssignments {
err := setAuthFieldInKeyring(assignment.key, assignment.value)
err := setAuthFieldInKeyring(tt.activeProfile, assignment.key, assignment.value)
if err != nil {

@@ -241,3 +463,3 @@ t.Fatalf("Failed to set \"%s\" as \"%s\": %v", assignment.key, assignment.value, err)

for key, valueExpected := range tt.expectedValues {
value, err := getAuthFieldFromKeyring(key)
value, err := getAuthFieldFromKeyring(tt.activeProfile, key)
if err != nil {

@@ -250,3 +472,3 @@ t.Errorf("Failed to get value of \"%s\": %v", key, err)

err = deleteAuthFieldInKeyring(key)
err = deleteAuthFieldInKeyring(tt.activeProfile, key)
if err != nil {

@@ -260,2 +482,163 @@ t.Errorf("Post-test cleanup failed: remove field \"%s\" from keyring: %v. Please remove it manually", key, err)

func TestDeleteAuthFieldKeyring(t *testing.T) {
tests := []struct {
description string
activeProfile string
noKey bool
isValid bool
}{
{
description: "base, default profile",
activeProfile: config.DefaultProfileName,
isValid: true,
},
{
description: "key doesnt exist, default profile",
activeProfile: config.DefaultProfileName,
noKey: true,
isValid: false,
},
{
description: "base, custom profile",
activeProfile: "test-profile",
isValid: true,
},
{
description: "key doesnt exist, custom profile",
activeProfile: "test-profile",
noKey: true,
isValid: false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
keyring.MockInit()
// Append random string to auth field key and value to avoid conflicts
testField1 := authFieldKey(fmt.Sprintf("test-field-1-%s", time.Now().Format(time.RFC3339)))
testValue1 := fmt.Sprintf("value-1-keyring-%s", time.Now().Format(time.RFC3339))
// Append random string to profile name to avoid conflicts
tt.activeProfile = makeProfileNameUnique(tt.activeProfile)
// Make sure profile name is valid
err := config.ValidateProfile(tt.activeProfile)
if err != nil {
t.Fatalf("Profile name \"%s\" is invalid: %v", tt.activeProfile, err)
}
if !tt.noKey {
err := setAuthFieldInKeyring(tt.activeProfile, testField1, testValue1)
if err != nil {
t.Fatalf("Failed to set \"%s\" as \"%s\": %v", testField1, testValue1, err)
}
}
err = deleteAuthFieldInKeyring(tt.activeProfile, testField1)
if err != nil {
if tt.isValid {
t.Fatalf("Failed to delete field \"%s\" from keyring: %v", testField1, err)
}
return
}
if !tt.isValid {
t.Fatalf("Expected error when deleting field \"%s\" from keyring, got none", testField1)
}
// Check if key still exists
_, err = getAuthFieldFromKeyring(tt.activeProfile, testField1)
if err == nil {
t.Fatalf("Key \"%s\" still exists in keyring after deletion", testField1)
}
})
}
}
func TestDeleteProfileFromKeyring(t *testing.T) {
tests := []struct {
description string
keyringFails bool
keys []authFieldKey
activeProfile string
isValid bool
}{
{
description: "base",
keys: authFieldKeys,
activeProfile: "test-profile",
isValid: true,
},
{
description: "missing keys",
keys: []authFieldKey{
ACCESS_TOKEN,
SERVICE_ACCOUNT_EMAIL,
},
activeProfile: "test-profile",
isValid: true,
},
{
description: "invalid profile",
activeProfile: "INVALID",
isValid: false,
},
{
description: "keyring fails",
keyringFails: true,
isValid: false,
},
{
description: "default profile",
activeProfile: config.DefaultProfileName,
isValid: false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
if !tt.keyringFails {
keyring.MockInit()
} else {
keyring.MockInitWithError(fmt.Errorf("keyring unavailable for testing"))
}
// Append random string to auth field key and value to avoid conflicts
testValue1 := fmt.Sprintf("value-1-keyring-%s", time.Now().Format(time.RFC3339))
// Append random string to profile name to avoid conflicts
tt.activeProfile = makeProfileNameUnique(tt.activeProfile)
for _, key := range tt.keys {
err := setAuthFieldInKeyring(tt.activeProfile, key, testValue1)
if err != nil {
t.Fatalf("Failed to set \"%s\" as \"%s\": %v", key, testValue1, err)
}
}
err := DeleteProfileFromKeyring(tt.activeProfile)
if err != nil {
if tt.isValid {
t.Fatalf("Failed to delete profile \"%s\" from keyring: %v", tt.activeProfile, err)
}
return
}
if !tt.isValid {
t.Fatalf("Expected error when deleting profile \"%s\" from keyring, got none", tt.activeProfile)
}
for _, key := range tt.keys {
// Check if key still exists
_, err = getAuthFieldFromKeyring(tt.activeProfile, key)
if err == nil {
t.Fatalf("Key \"%s\" still exists in keyring after profile deletion", key)
}
}
})
}
}
func TestSetGetAuthFieldEncodedTextFile(t *testing.T) {

@@ -276,2 +659,3 @@ var testField1 authFieldKey = "test-field-1"

description string
activeProfile string
valueAssignments []valueAssignment

@@ -281,3 +665,4 @@ expectedValues map[authFieldKey]string

{
description: "simple assignments",
description: "simple assignments with default profile",
activeProfile: config.DefaultProfileName,
valueAssignments: []valueAssignment{

@@ -299,3 +684,4 @@ {

{
description: "overlapping assignments",
description: "overlapping assignments with default profile",
activeProfile: config.DefaultProfileName,
valueAssignments: []valueAssignment{

@@ -320,2 +706,42 @@ {

},
{
description: "simple assignments with test-profile",
activeProfile: "test-profile",
valueAssignments: []valueAssignment{
{
key: testField1,
value: testValue1,
},
{
key: testField2,
value: testValue2,
},
},
expectedValues: map[authFieldKey]string{
testField1: testValue1,
testField2: testValue2,
},
},
{
description: "overlapping assignments with test-profile",
activeProfile: "test-profile",
valueAssignments: []valueAssignment{
{
key: testField1,
value: testValue1,
},
{
key: testField2,
value: testValue2,
},
{
key: testField1,
value: testValue3,
},
},
expectedValues: map[authFieldKey]string{
testField1: testValue3,
testField2: testValue2,
},
},
}

@@ -325,4 +751,13 @@

t.Run(tt.description, func(t *testing.T) {
// Append random string to profile name to avoid conflicts
tt.activeProfile = makeProfileNameUnique(tt.activeProfile)
// Make sure profile name is valid
err := config.ValidateProfile(tt.activeProfile)
if err != nil {
t.Fatalf("Profile name \"%s\" is invalid: %v", tt.activeProfile, err)
}
for _, assignment := range tt.valueAssignments {
err := setAuthFieldInEncodedTextFile(assignment.key, assignment.value)
err := setAuthFieldInEncodedTextFile(tt.activeProfile, assignment.key, assignment.value)
if err != nil {

@@ -338,3 +773,3 @@ t.Fatalf("Failed to set \"%s\" as \"%s\": %v", assignment.key, assignment.value, err)

for key, valueExpected := range tt.expectedValues {
value, err := getAuthFieldFromEncodedTextFile(key)
value, err := getAuthFieldFromEncodedTextFile(tt.activeProfile, key)
if err != nil {

@@ -347,3 +782,3 @@ t.Errorf("Failed to get value of \"%s\": %v", key, err)

err = deleteAuthFieldInEncodedTextFile(key)
err = deleteAuthFieldInEncodedTextFile(tt.activeProfile, key)
if err != nil {

@@ -353,2 +788,7 @@ t.Errorf("Post-test cleanup failed: remove field \"%s\" from text file: %v. Please remove it manually", key, err)

}
err = deleteAuthFieldProfile(tt.activeProfile)
if err != nil {
t.Errorf("Post-test cleanup failed: remove profile \"%s\": %v. Please remove it manually", tt.activeProfile, err)
}
})

@@ -358,8 +798,162 @@ }

func deleteAuthFieldInKeyring(key authFieldKey) error {
return keyring.Delete(keyringService, string(key))
func TestGetProfileEmail(t *testing.T) {
tests := []struct {
description string
activeProfile string
userEmail string
authFlow AuthFlow
serviceAccEmail string
expectedEmail string
}{
{
description: "default profile, user token",
activeProfile: config.DefaultProfileName,
userEmail: "test@test.com",
authFlow: AUTH_FLOW_USER_TOKEN,
expectedEmail: "test@test.com",
},
{
description: "default profile, service acc token",
activeProfile: config.DefaultProfileName,
serviceAccEmail: "test@test.com",
authFlow: AUTH_FLOW_SERVICE_ACCOUNT_TOKEN,
expectedEmail: "test@test.com",
},
{
description: "default profile, service acc key",
activeProfile: config.DefaultProfileName,
serviceAccEmail: "test@test.com",
authFlow: AUTH_FLOW_SERVICE_ACCOUNT_KEY,
expectedEmail: "test@test.com",
},
{
description: "custom profile, user token",
activeProfile: "test-profile",
userEmail: "test@test.com",
authFlow: AUTH_FLOW_USER_TOKEN,
expectedEmail: "test@test.com",
},
{
description: "custom profile, service acc token",
activeProfile: "test-profile",
serviceAccEmail: "test@test.com",
authFlow: AUTH_FLOW_SERVICE_ACCOUNT_TOKEN,
expectedEmail: "test@test.com",
},
{
description: "custom profile, service acc key",
activeProfile: "test-profile",
serviceAccEmail: "test@test.com",
authFlow: AUTH_FLOW_SERVICE_ACCOUNT_KEY,
expectedEmail: "test@test.com",
},
{
description: "no email, user token",
activeProfile: "test-profile",
authFlow: AUTH_FLOW_USER_TOKEN,
expectedEmail: "",
},
{
description: "no email, service acc token",
activeProfile: "test-profile",
authFlow: AUTH_FLOW_SERVICE_ACCOUNT_TOKEN,
expectedEmail: "",
},
{
description: "no email, service acc key",
activeProfile: "test-profile",
authFlow: AUTH_FLOW_SERVICE_ACCOUNT_KEY,
expectedEmail: "",
},
{
description: "user not authenticated",
activeProfile: "test-profile",
expectedEmail: "",
},
{
description: "both emails, user not authenticated",
activeProfile: "test-profile",
userEmail: "test@test.com",
serviceAccEmail: "test2@test.com",
expectedEmail: "",
},
{
description: "both emails, user token",
activeProfile: "test-profile",
userEmail: "test@test.com",
serviceAccEmail: "test2@test.com",
authFlow: AUTH_FLOW_USER_TOKEN,
expectedEmail: "test@test.com",
},
{
description: "both emails, service account token",
activeProfile: "test-profile",
userEmail: "test@test.com",
serviceAccEmail: "test2@test.com",
authFlow: AUTH_FLOW_SERVICE_ACCOUNT_TOKEN,
expectedEmail: "test2@test.com",
},
{
description: "both emails, service account key",
activeProfile: "test-profile",
userEmail: "test@test.com",
serviceAccEmail: "test2@test.com",
authFlow: AUTH_FLOW_SERVICE_ACCOUNT_KEY,
expectedEmail: "test2@test.com",
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
keyring.MockInit()
// Append random string to profile name to avoid conflicts
tt.activeProfile = makeProfileNameUnique(tt.activeProfile)
// Make sure profile name is valid
err := config.ValidateProfile(tt.activeProfile)
if err != nil {
t.Fatalf("Profile name \"%s\" is invalid: %v", tt.activeProfile, err)
}
err = setAuthFieldInKeyring(tt.activeProfile, USER_EMAIL, tt.userEmail)
if err != nil {
t.Errorf("Failed to set user email: %v", err)
}
err = setAuthFieldInKeyring(tt.activeProfile, SERVICE_ACCOUNT_EMAIL, tt.serviceAccEmail)
if err != nil {
t.Errorf("Failed to set service account email: %v", err)
}
err = setAuthFieldWithProfile(tt.activeProfile, authFlowType, string(tt.authFlow))
if err != nil {
t.Errorf("Failed to set auth flow: %v", err)
}
email := GetProfileEmail(tt.activeProfile)
if email != tt.expectedEmail {
t.Errorf("Expected email \"%s\", got \"%s\"", tt.expectedEmail, email)
}
err = deleteAuthFieldInKeyring(tt.activeProfile, USER_EMAIL)
if err != nil {
t.Fatalf("Failed to remove user email: %v", err)
}
err = deleteAuthFieldInKeyring(tt.activeProfile, SERVICE_ACCOUNT_EMAIL)
if err != nil {
t.Fatalf("Failed to remove service account email: %v", err)
}
err = deleteAuthFieldProfile(tt.activeProfile)
if err != nil {
t.Fatalf("Failed to remove profile: %v", err)
}
})
}
}
func deleteAuthFieldInEncodedTextFile(key authFieldKey) error {
err := createEncodedTextFile()
func deleteAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey) error {
err := createEncodedTextFile(activeProfile)
if err != nil {

@@ -369,7 +963,3 @@ return err

configDir, err := os.UserConfigDir()
if err != nil {
return fmt.Errorf("get config dir: %w", err)
}
textFileDir := filepath.Join(configDir, textFileFolderName)
textFileDir := config.GetProfileFolderPath(activeProfile)
textFilePath := filepath.Join(textFileDir, textFileName)

@@ -404,1 +994,22 @@

}
func deleteAuthFieldProfile(activeProfile string) error {
if activeProfile == config.DefaultProfileName {
// Do not delete the default profile
return nil
}
textFileDir := config.GetProfileFolderPath(activeProfile)
// Remove the entire directory if the profile does not exist
err := os.RemoveAll(textFileDir)
if err != nil {
return fmt.Errorf("remove directory: %w", err)
}
return nil
}
func makeProfileNameUnique(profile string) string {
if profile == config.DefaultProfileName {
return profile
}
return fmt.Sprintf("%s-%s", profile, time.Now().Format("20060102150405"))
}

@@ -6,6 +6,11 @@ package auth

"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
pkgErrors "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/zalando/go-keyring"

@@ -46,2 +51,17 @@ )

// Returns all auth field keys managed by the auth storage
var authFieldKeys = []authFieldKey{
SESSION_EXPIRES_AT_UNIX,
ACCESS_TOKEN,
REFRESH_TOKEN,
SERVICE_ACCOUNT_TOKEN,
SERVICE_ACCOUNT_EMAIL,
USER_EMAIL,
SERVICE_ACCOUNT_KEY,
PRIVATE_KEY,
TOKEN_CUSTOM_ENDPOINT,
JWKS_CUSTOM_ENDPOINT,
authFlowType,
}
func SetAuthFlow(value AuthFlow) error {

@@ -63,5 +83,14 @@ return SetAuthField(authFlowType, string(value))

func SetAuthField(key authFieldKey, value string) error {
err := setAuthFieldInKeyring(key, value)
activeProfile, err := config.GetProfile()
if err != nil {
errFallback := setAuthFieldInEncodedTextFile(key, value)
return fmt.Errorf("get profile: %w", err)
}
return setAuthFieldWithProfile(activeProfile, key, value)
}
func setAuthFieldWithProfile(profile string, key authFieldKey, value string) error {
err := setAuthFieldInKeyring(profile, key, value)
if err != nil {
errFallback := setAuthFieldInEncodedTextFile(profile, key, value)
if errFallback != nil {

@@ -74,17 +103,25 @@ return fmt.Errorf("write to keyring failed (%w), try writing to encoded text file: %w", err, errFallback)

func setAuthFieldInKeyring(key authFieldKey, value string) error {
func setAuthFieldInKeyring(activeProfile string, key authFieldKey, value string) error {
if activeProfile != config.DefaultProfileName {
activeProfileKeyring := filepath.Join(keyringService, activeProfile)
return keyring.Set(activeProfileKeyring, string(key), value)
}
return keyring.Set(keyringService, string(key), value)
}
func setAuthFieldInEncodedTextFile(key authFieldKey, value string) error {
err := createEncodedTextFile()
if err != nil {
return err
func deleteAuthFieldInKeyring(activeProfile string, key authFieldKey) error {
keyringServiceLocal := keyringService
if activeProfile != config.DefaultProfileName {
keyringServiceLocal = filepath.Join(keyringService, activeProfile)
}
configDir, err := os.UserConfigDir()
return keyring.Delete(keyringServiceLocal, string(key))
}
func setAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey, value string) error {
err := createEncodedTextFile(activeProfile)
if err != nil {
return fmt.Errorf("get config dir: %w", err)
return err
}
textFileDir := filepath.Join(configDir, textFileFolderName)
textFileDir := config.GetProfileFolderPath(activeProfile)
textFilePath := filepath.Join(textFileDir, textFileName)

@@ -138,6 +175,14 @@

func GetAuthField(key authFieldKey) (string, error) {
value, err := getAuthFieldFromKeyring(key)
activeProfile, err := config.GetProfile()
if err != nil {
return "", fmt.Errorf("get profile: %w", err)
}
return getAuthFieldWithProfile(activeProfile, key)
}
func getAuthFieldWithProfile(profile string, key authFieldKey) (string, error) {
value, err := getAuthFieldFromKeyring(profile, key)
if err != nil {
var errFallback error
value, errFallback = getAuthFieldFromEncodedTextFile(key)
value, errFallback = getAuthFieldFromEncodedTextFile(profile, key)
if errFallback != nil {

@@ -150,8 +195,12 @@ return "", fmt.Errorf("read from keyring: %w, read from encoded file as fallback: %w", err, errFallback)

func getAuthFieldFromKeyring(key authFieldKey) (string, error) {
func getAuthFieldFromKeyring(activeProfile string, key authFieldKey) (string, error) {
if activeProfile != config.DefaultProfileName {
activeProfileKeyring := filepath.Join(keyringService, activeProfile)
return keyring.Get(activeProfileKeyring, string(key))
}
return keyring.Get(keyringService, string(key))
}
func getAuthFieldFromEncodedTextFile(key authFieldKey) (string, error) {
err := createEncodedTextFile()
func getAuthFieldFromEncodedTextFile(activeProfile string, key authFieldKey) (string, error) {
err := createEncodedTextFile(activeProfile)
if err != nil {

@@ -161,7 +210,3 @@ return "", err

configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("get config dir: %w", err)
}
textFileDir := filepath.Join(configDir, textFileFolderName)
textFileDir := config.GetProfileFolderPath(activeProfile)
textFilePath := filepath.Join(textFileDir, textFileName)

@@ -192,11 +237,7 @@

// If it does, does nothing (and returns nil).
func createEncodedTextFile() error {
configDir, err := os.UserConfigDir()
if err != nil {
return fmt.Errorf("get config dir: %w", err)
}
textFileDir := filepath.Join(configDir, textFileFolderName)
func createEncodedTextFile(activeProfile string) error {
textFileDir := config.GetProfileFolderPath(activeProfile)
textFilePath := filepath.Join(textFileDir, textFileName)
err = os.MkdirAll(textFileDir, os.ModePerm)
err := os.MkdirAll(textFileDir, os.ModePerm)
if err != nil {

@@ -218,1 +259,48 @@ return fmt.Errorf("create file dir: %w", err)

}
// GetProfileEmail returns the email of the user or service account associated with the given profile.
// If the profile is not authenticated or the email can't be obtained, it returns an empty string.
func GetProfileEmail(profile string) string {
value, err := getAuthFieldWithProfile(profile, authFlowType)
if err != nil {
return ""
}
var email string
switch AuthFlow(value) {
case AUTH_FLOW_USER_TOKEN:
email, err = getAuthFieldWithProfile(profile, USER_EMAIL)
if err != nil {
email = ""
}
case AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, AUTH_FLOW_SERVICE_ACCOUNT_KEY:
email, err = getAuthFieldWithProfile(profile, SERVICE_ACCOUNT_EMAIL)
if err != nil {
email = ""
}
}
return email
}
func DeleteProfileFromKeyring(profile string) error {
err := config.ValidateProfile(profile)
if err != nil {
return fmt.Errorf("validate profile: %w", err)
}
if profile == config.DefaultProfileName {
return &pkgErrors.DeleteDefaultProfile{DefaultProfile: config.DefaultProfileName}
}
for _, key := range authFieldKeys {
err := deleteAuthFieldInKeyring(profile, key)
if err != nil {
// if the key is not found, we can ignore the error
if !errors.Is(err, keyring.ErrNotFound) {
return fmt.Errorf("delete auth field \"%s\" from keyring: %w", key, err)
}
}
}
return nil
}

@@ -49,3 +49,3 @@ package cache

if tt.expectFile {
err := createFolderIfNotExists(cacheFolderPath)
err := os.MkdirAll(cacheFolderPath, os.ModePerm)
if err != nil {

@@ -52,0 +52,0 @@ t.Fatalf("create cache folder: %s", err.Error())

@@ -46,3 +46,3 @@ package cache

err := createFolderIfNotExists(cacheFolderPath)
err := os.MkdirAll(cacheFolderPath, os.ModePerm)
if err != nil {

@@ -69,15 +69,2 @@ return err

func createFolderIfNotExists(folderPath string) error {
_, err := os.Stat(folderPath)
if os.IsNotExist(err) {
err := os.MkdirAll(folderPath, os.ModePerm)
if err != nil {
return err
}
} else if err != nil {
return err
}
return nil
}
func validateCacheFolderPath() error {

@@ -84,0 +71,0 @@ if cacheFolderPath == "" {

package config
import (
"fmt"
"os"

@@ -36,6 +37,6 @@ "path/filepath"

viper.SetConfigFile(configPath)
folderPath = filepath.Dir(configPath)
configFolderPath = filepath.Dir(configPath)
if tt.folderExists {
err := os.MkdirAll(folderPath, os.ModePerm)
err := os.MkdirAll(configFolderPath, os.ModePerm)
if err != nil {

@@ -65,3 +66,3 @@ t.Fatalf("expected error to be nil, got %v", err)

if tt.folderName != "" {
err = os.Remove(folderPath)
err = os.Remove(configFolderPath)
if err != nil {

@@ -74,1 +75,56 @@ t.Fatalf("expected error to be nil, got %v", err)

}
func TestGetInitialConfigDir(t *testing.T) {
tests := []struct {
description string
}{
{
description: "base",
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
actual := getInitialConfigDir()
userConfig, err := os.UserConfigDir()
if err != nil {
t.Fatalf("expected error to be nil, got %v", err)
}
expected := filepath.Join(userConfig, "stackit")
if actual != expected {
t.Fatalf("expected %s, got %s", expected, actual)
}
})
}
}
func TestGetInitialProfileFilePath(t *testing.T) {
tests := []struct {
description string
configFolderPath string
}{
{
description: "base",
configFolderPath: getInitialConfigDir(),
},
{
description: "empty config folder path",
configFolderPath: "",
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
configFolderPath = getInitialConfigDir()
actual := getInitialProfileFilePath()
expected := filepath.Join(configFolderPath, fmt.Sprintf("%s.%s", profileFileName, profileFileExtension))
if actual != expected {
t.Fatalf("expected %s, got %s", expected, actual)
}
})
}
}

@@ -38,2 +38,5 @@ package config

ProjectNameKey = "project_name"
DefaultProfileName = "default"
AsyncDefault = false

@@ -43,8 +46,11 @@ SessionTimeLimitDefault = "2h"

// Backend config keys
const (
configFolder = "stackit"
configFolder = "stackit"
configFileName = "cli-config"
configFileExtension = "json"
ProjectNameKey = "project_name"
profileRootFolder = "profiles"
profileFileName = "cli-profile"
profileFileExtension = "txt"
)

@@ -79,13 +85,17 @@

var folderPath string
var defaultConfigFolderPath string
var configFolderPath string
var profileFilePath string
func InitConfig() {
configDir, err := os.UserConfigDir()
defaultConfigFolderPath = getInitialConfigDir()
profileFilePath = getInitialProfileFilePath() // Profile file path is in the default config folder
configProfile, err := GetProfile()
cobra.CheckErr(err)
configFolderPath := filepath.Join(configDir, configFolder)
configFilePath := filepath.Join(configFolderPath, fmt.Sprintf("%s.%s", configFileName, configFileExtension))
// Write config dir path to global variable
folderPath = configFolderPath
configFolderPath = GetProfileFolderPath(configProfile)
configFilePath := getConfigFilePath(configFolderPath)
// This hack is required to allow creating the config file with `viper.WriteConfig`

@@ -115,18 +125,6 @@ // see https://github.com/spf13/viper/issues/851#issuecomment-789393451

func createFolderIfNotExists() error {
_, err := os.Stat(folderPath)
if os.IsNotExist(err) {
err := os.MkdirAll(folderPath, os.ModePerm)
if err != nil {
return err
}
} else if err != nil {
return err
}
return nil
}
// Write saves the config file (wrapping `viper.WriteConfig`) and ensures that its directory exists
func Write() error {
if err := createFolderIfNotExists(); err != nil {
err := os.MkdirAll(configFolderPath, os.ModePerm)
if err != nil {
return fmt.Errorf("create config directory: %w", err)

@@ -157,1 +155,20 @@ }

}
func getConfigFilePath(configFolder string) string {
return filepath.Join(configFolder, fmt.Sprintf("%s.%s", configFileName, configFileExtension))
}
func getInitialConfigDir() string {
configDir, err := os.UserConfigDir()
cobra.CheckErr(err)
return filepath.Join(configDir, configFolder)
}
func getInitialProfileFilePath() string {
configFolderPath := defaultConfigFolderPath
if configFolderPath == "" {
configFolderPath = getInitialConfigDir()
}
return filepath.Join(configFolderPath, fmt.Sprintf("%s.%s", profileFileName, profileFileExtension))
}

@@ -126,2 +126,28 @@ package errors

func TestSetInexistentProfile(t *testing.T) {
tests := []struct {
description string
profile string
expectedMsg string
}{
{
description: "base",
profile: "profile",
expectedMsg: fmt.Sprintf(SET_INEXISTENT_PROFILE, "profile", "profile"),
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := &SetInexistentProfile{
Profile: tt.profile,
}
if err.Error() != tt.expectedMsg {
t.Fatalf("expected error to be %s, got %s", tt.expectedMsg, err.Error())
}
})
}
}
func TestArgusInvalidPlanError(t *testing.T) {

@@ -128,0 +154,0 @@ tests := []struct {

@@ -41,2 +41,14 @@ package errors

SET_INEXISTENT_PROFILE = `the configuration profile %[1]q you are trying to set doesn't exist.
To create it, run:
$ stackit config profile create %[1]q`
DELETE_INEXISTENT_PROFILE = `the configuration profile %q does not exist.
To list all profiles, run:
$ stackit config profile list`
DELETE_DEFAULT_PROFILE = `the default configuration profile %q cannot be deleted.`
ARGUS_INVALID_INPUT_PLAN = `the instance plan was not correctly provided.

@@ -119,2 +131,6 @@

INVALID_PROFILE_NAME = `the profile name %q is invalid.
The profile name can only contain lowercase letters, numbers, and "-" and cannot be empty or "default". It can't start with a "-".`
USAGE_TIP = `For usage help, run:

@@ -148,2 +164,26 @@ $ %s --help`

type SetInexistentProfile struct {
Profile string
}
func (e *SetInexistentProfile) Error() string {
return fmt.Sprintf(SET_INEXISTENT_PROFILE, e.Profile)
}
type DeleteInexistentProfile struct {
Profile string
}
func (e *DeleteInexistentProfile) Error() string {
return fmt.Sprintf(DELETE_INEXISTENT_PROFILE, e.Profile)
}
type DeleteDefaultProfile struct {
DefaultProfile string
}
func (e *DeleteDefaultProfile) Error() string {
return fmt.Sprintf(DELETE_DEFAULT_PROFILE, e.DefaultProfile)
}
type ArgusInputPlanError struct {

@@ -322,1 +362,9 @@ Cmd *cobra.Command

}
type InvalidProfileNameError struct {
Profile string
}
func (e *InvalidProfileNameError) Error() string {
return fmt.Sprintf(INVALID_PROFILE_NAME, e.Profile)
}

@@ -5,2 +5,3 @@ package fileutils

"os"
"path/filepath"
"testing"

@@ -35,3 +36,3 @@ )

if string(output) != tt.content {
t.Errorf("unexpected output: got %q, want %q", output, tt.content)
t.Fatalf("unexpected output: got %q, want %q", output, tt.content)
}

@@ -43,4 +44,138 @@ })

if err != nil {
t.Errorf("failed cleaning test data")
t.Fatalf("failed cleaning test data")
}
}
func TestReadFileIfExists(t *testing.T) {
tests := []struct {
description string
filePath string
exists bool
content string
}{
{
description: "file exists",
filePath: "test-data/file-with-content.txt",
exists: true,
content: "my-content",
},
{
description: "file does not exist",
filePath: "test-data/file-does-not-exist.txt",
content: "",
},
{
description: "empty file",
filePath: "test-data/empty-file.txt",
exists: true,
content: "",
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
content, exists, err := ReadFileIfExists(tt.filePath)
if err != nil {
t.Fatalf("read file: %v", err)
}
if exists != tt.exists {
t.Fatalf("expected exists to be %t but got %t", tt.exists, exists)
}
if content != tt.content {
t.Fatalf("expected content to be %q but got %q", tt.content, content)
}
})
}
}
func TestCopyFile(t *testing.T) {
tests := []struct {
description string
srcExists bool
destExists bool
content string
isValid bool
}{
{
description: "copy file",
srcExists: true,
content: "my-content",
isValid: true,
},
{
description: "copy empty file",
srcExists: true,
content: "",
isValid: true,
},
{
description: "copy non-existent file",
srcExists: false,
content: "",
isValid: false,
},
{
description: "copy file to existing file",
srcExists: true,
destExists: true,
content: "my-content",
isValid: true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
basePath := filepath.Join(os.TempDir(), "test-data")
src := filepath.Join(basePath, "file-with-content.txt")
dst := filepath.Join(basePath, "file-with-content-copy.txt")
err := os.MkdirAll(basePath, os.ModePerm)
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}
if tt.srcExists {
err := WriteToFile(src, tt.content)
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}
}
if tt.destExists {
err := WriteToFile(dst, "existing-content")
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}
}
err = CopyFile(src, dst)
if err != nil {
if tt.isValid {
t.Fatalf("unexpected error: %s", err.Error())
}
return
}
if !tt.isValid {
t.Fatalf("expected error but got none")
}
content, exists, err := ReadFileIfExists(dst)
if err != nil {
t.Fatalf("read file: %v", err)
}
if !exists {
t.Fatalf("expected file to exist but it does not")
}
if content != tt.content {
t.Fatalf("expected content to be %q but got %q", tt.content, content)
}
// Cleanup
err = os.RemoveAll(basePath)
if err != nil {
t.Fatalf("failed cleaning test data")
}
})
}
}

@@ -8,2 +8,4 @@ package fileutils

// WriteToFile writes the given content to a file.
// If the file already exists, it will be overwritten.
func WriteToFile(outputFileName, content string) (err error) {

@@ -30,1 +32,37 @@ fo, err := os.Create(outputFileName)

}
// ReadFileIfExists reads the contents of a file and returns it as a string, along with a boolean indicating if the file exists.
// If the file does not exist, it returns an empty string, false and no error.
// If the file exists but cannot be read, it returns an error.
func ReadFileIfExists(filePath string) (contents string, exists bool, err error) {
_, err = os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return "", false, nil
}
return "", true, err
}
content, err := os.ReadFile(filePath)
if err != nil {
return "", true, fmt.Errorf("read file: %w", err)
}
return string(content), true, nil
}
// CopyFile copies the contents of a file to another file.
// If the destination file already exists, it will be overwritten.
func CopyFile(src, dst string) (err error) {
contents, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("read source file: %w", err)
}
err = WriteToFile(dst, string(contents))
if err != nil {
return fmt.Errorf("write destination file: %w", err)
}
return nil
}

@@ -13,3 +13,2 @@ package print

"github.com/spf13/viper"
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
)

@@ -70,3 +69,3 @@

if tt.outputFormatNone {
viper.Set(config.OutputFormatKey, NoneOutputFormat)
viper.Set(outputFormatKey, NoneOutputFormat)
}

@@ -142,3 +141,3 @@

if tt.outputFormatNone {
viper.Set(config.OutputFormatKey, NoneOutputFormat)
viper.Set(outputFormatKey, NoneOutputFormat)
}

@@ -207,3 +206,3 @@

if tt.outputFormatNone {
viper.Set(config.OutputFormatKey, NoneOutputFormat)
viper.Set(outputFormatKey, NoneOutputFormat)
}

@@ -210,0 +209,0 @@

@@ -14,2 +14,3 @@ package print

"github.com/fatih/color"
"github.com/lmittmann/tint"

@@ -19,3 +20,3 @@ "github.com/mattn/go-colorable"

"github.com/spf13/viper"
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
"golang.org/x/term"

@@ -32,2 +33,6 @@ )

// Needed to avoid import cycle
// Originally defined in "internal/pkg/config/config.go"
outputFormatKey = "output-format"
JSONOutputFormat = "json"

@@ -39,4 +44,10 @@ PrettyOutputFormat = "pretty"

var errAborted = errors.New("operation aborted")
var (
errAborted = errors.New("operation aborted")
WhiteBold = color.New(color.FgHiWhite, color.Bold).SprintFunc()
RedBold = color.New(color.FgHiRed, color.Bold).SprintFunc()
YellowBold = color.New(color.FgHiYellow, color.Bold).SprintFunc()
)
type Printer struct {

@@ -61,3 +72,3 @@ Cmd *cobra.Command

func (p *Printer) Outputf(msg string, args ...any) {
outputFormat := viper.GetString(config.OutputFormatKey)
outputFormat := viper.GetString(outputFormatKey)
if outputFormat == NoneOutputFormat {

@@ -72,3 +83,3 @@ return

func (p *Printer) Outputln(msg string) {
outputFormat := viper.GetString(config.OutputFormatKey)
outputFormat := viper.GetString(outputFormatKey)
if outputFormat == NoneOutputFormat {

@@ -115,3 +126,3 @@ return

warning := fmt.Sprintf(msg, args...)
p.Cmd.PrintErrf("Warning: %s", warning)
p.Cmd.PrintErrf("%s %s", YellowBold("Warning:"), warning)
}

@@ -122,3 +133,3 @@

err := fmt.Sprintf(msg, args...)
p.Cmd.PrintErrln(p.Cmd.ErrPrefix(), err)
p.Cmd.PrintErrln(RedBold(p.Cmd.ErrPrefix()), err)
}

@@ -179,3 +190,3 @@

func (p *Printer) PagerDisplay(content string) error {
outputFormat := viper.GetString(config.OutputFormatKey)
outputFormat := viper.GetString(outputFormatKey)
if outputFormat == NoneOutputFormat {

@@ -182,0 +193,0 @@ return nil

@@ -8,3 +8,3 @@ package main

// These values are dynamically overridden by GoReleaser
// These values are overwritten by GoReleaser at build time
var (

@@ -11,0 +11,0 @@ version = "DEV"

[![Go Report Card](https://goreportcard.com/badge/github.com/stackitcloud/stackit-cli)](https://goreportcard.com/report/github.com/stackitcloud/stackit-cli) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/stackitcloud/stackit-cli) [![GitHub License](https://img.shields.io/github/license/stackitcloud/stackit-cli)](https://www.apache.org/licenses/LICENSE-2.0)
<br>
<p align="center">
<img src=".github/images/stackit-logo.png" alt="STACKIT logo" width="50%"/>
</p>
<br>
# STACKIT CLI (BETA)

@@ -4,0 +10,0 @@