diff --git a/CHANGELOG.md b/CHANGELOG.md index d6b36630..a2bfe641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Bug fixes + +- Fix panic when config commands are used without a default account set #798 + ## 1.93.0 ### Features diff --git a/cmd/config/config.go b/cmd/config/config.go index 9e7aa578..c61471e7 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -63,15 +63,7 @@ func configCmdRun(cmd *cobra.Command, _ []string) error { return nil } - fmt.Println("No Exoscale CLI configuration found") - - fmt.Print(` -In order to set up your configuration profile, you will need to retrieve -Exoscale API credentials from your organization's IAM: - - https://portal.exoscale.com/iam/keys - -`) + printNoConfigMessage() return addConfigAccount(true) } @@ -157,7 +149,8 @@ func saveConfig(filePath string, newAccounts *account.Config) error { return err } - conf.DefaultAccount = exocmd.GConfig.Get("defaultAccount").(string) + // Safely get defaultAccount - may be empty/unset if user declined to set default + conf.DefaultAccount = exocmd.GConfig.GetString("defaultAccount") if conf.DefaultAccount == "" { fmt.Println("no default account set") } @@ -254,6 +247,17 @@ func chooseZone(client *v3.Client, zones []string) (string, error) { return result, nil } +func printNoConfigMessage() { + fmt.Print(`No Exoscale CLI configuration found + +In order to set up your configuration profile, you will need to retrieve +Exoscale API credentials from your organization's IAM: + + https://portal.exoscale.com/iam/keys + +`) +} + func init() { exocmd.RootCmd.AddCommand(configCmd) } diff --git a/cmd/config/config_add.go b/cmd/config/config_add.go index df9693ef..5497b49f 100644 --- a/cmd/config/config_add.go +++ b/cmd/config/config_add.go @@ -20,15 +20,31 @@ func init() { Use: "add", Short: "Add a new account to configuration", RunE: func(cmd *cobra.Command, args []string) error { + // Check if this is the first account + isFirstAccount := account.GAllAccount == nil || len(account.GAllAccount.Accounts) == 0 + + if isFirstAccount { + printNoConfigMessage() + } + newAccount, err := promptAccountInformation() if err != nil { return err } config := &account.Config{Accounts: []account.Account{*newAccount}} - if utils.AskQuestion(exocmd.GContext, "Set ["+newAccount.Name+"] as default account?") { + + if isFirstAccount { + // First account: automatically set as default config.DefaultAccount = newAccount.Name exocmd.GConfig.Set("defaultAccount", newAccount.Name) + fmt.Printf("Set [%s] as default account (first account)\n", newAccount.Name) + } else { + // Additional account: ask user if it should be the new default + if utils.AskQuestion(exocmd.GContext, "Set ["+newAccount.Name+"] as default account?") { + config.DefaultAccount = newAccount.Name + exocmd.GConfig.Set("defaultAccount", newAccount.Name) + } } return saveConfig(exocmd.GConfig.ConfigFileUsed(), config) diff --git a/tests/e2e/scenarios/local/config/commands_when_no_default_account.txtar b/tests/e2e/scenarios/local/config/commands_when_no_default_account.txtar new file mode 100644 index 00000000..dcd25072 --- /dev/null +++ b/tests/e2e/scenarios/local/config/commands_when_no_default_account.txtar @@ -0,0 +1,67 @@ +# Test: Commands handle gracefully when account exists but no default is set +# Previously this caused a panic at cmd/config/config.go:160 (unsafe type assertion) +# Bug fix: Changed from Get("defaultAccount").(string) to GetString("defaultAccount") +# +# Note: With the fix, `exo config add` automatically sets the first account as default, +# so this state should not occur through normal CLI usage. However, it can still happen if: +# - User manually edits config file and removes defaultAccount +# - Config file was created by an external tool +# +# This test compares behavior in two similar failure scenarios: +# 1. No config file at all (first-time use) - various errors depending on command +# 2. Config with accounts but no defaultAccount - "default account not defined" +# +# Cannot test `exo config add` interactively in testscript due to promptui. +# See tests/integ/config_panic_test.go for integration test coverage. + +# Scenario 1: First test with NO config at all +# The `exo config` command should show helpful setup message +! exec exo config +stdout 'No Exoscale CLI configuration found' +stdout 'In order to set up your configuration profile' +stdout 'https://portal.exoscale.com/iam/keys' + +# Other config commands should fail gracefully +! exec exo config show + +# Scenario 2: Create config with account but NO defaultAccount field +mkdir -p .config/exoscale +cp test-config.toml .config/exoscale/exoscale.toml + +# Now commands should fail with clear "default account not defined" error +! exec exo config show +stderr 'default account not defined' + +! exec exo config list +stderr 'default account not defined' + +! exec exo config set Exoscale-Test +stderr 'default account not defined' + +# Workaround: --use-account flag bypasses the default account requirement +exec exo --use-account Exoscale-Test config show +stdout 'Exoscale-Test' +stdout 'EXOtest123' + +# Test with explicit --config flag (same results) +! exec exo --config .config/exoscale/exoscale.toml config show +stderr 'default account not defined' + +! exec exo --config .config/exoscale/exoscale.toml config list +stderr 'default account not defined' + +! exec exo --config .config/exoscale/exoscale.toml config set Exoscale-Test +stderr 'default account not defined' + +exec exo --config .config/exoscale/exoscale.toml --use-account Exoscale-Test config show +stdout 'Exoscale-Test' +stdout 'EXOtest123' + +# Config file without defaultAccount field +# This state can occur from manual config editing or external tools +-- test-config.toml -- +[[accounts]] +name = "Exoscale-Test" +key = "EXOtest123" +secret = "testsecret123" +defaultZone = "ch-gva-2" diff --git a/tests/e2e/scenarios/local/config/show_when_missing_config_file.txtar b/tests/e2e/scenarios/local/config/show_when_missing_config_file.txtar new file mode 100644 index 00000000..839c3ca1 --- /dev/null +++ b/tests/e2e/scenarios/local/config/show_when_missing_config_file.txtar @@ -0,0 +1,7 @@ +# Test config show fails when config file does not exist + +! exec exo config show +stderr 'no accounts configured' + +! exec exo --config ./i-do-not-exist.toml config show +stderr 'no such file or directory' diff --git a/tests/e2e/scenarios/local/config/show_when_valid_config_file.txtar b/tests/e2e/scenarios/local/config/show_when_valid_config_file.txtar new file mode 100644 index 00000000..f8a38a31 --- /dev/null +++ b/tests/e2e/scenarios/local/config/show_when_valid_config_file.txtar @@ -0,0 +1,28 @@ +# Test config show displays account information from valid config file + +exec exo config show +stdout 'test-account' +stdout 'EXOtest1234' +stdout 'ch-gva-2' +stdout 'https://sos-{zone}.exo.io' +stdout '.config/exoscale/exoscale.toml' +stdout '×××××××××××' +! stdout 'testsecret1234' + +exec exo --config .config/exoscale/exoscale.toml config show +stdout 'test-account' +stdout 'EXOtest1234' +stdout 'ch-gva-2' +stdout 'https://sos-{zone}.exo.io' +stdout '.config/exoscale/exoscale.toml' +stdout '×××××××××××' +! stdout 'testsecret1234' + +-- .config/exoscale/exoscale.toml -- +defaultaccount = "test-account" + +[[accounts]] +name = "test-account" +key = "EXOtest1234" +secret = "testsecret1234" +defaultZone = "ch-gva-2" diff --git a/tests/e2e/scenarios/local/config_isolated.txtar b/tests/e2e/scenarios/local/config_isolated.txtar deleted file mode 100644 index 004aa184..00000000 --- a/tests/e2e/scenarios/local/config_isolated.txtar +++ /dev/null @@ -1,29 +0,0 @@ -# Test config command with isolated config file -# Each test gets its own temp directory, so config changes are isolated - -# Create a config directory structure (txtar file archives can include files) --- .config/exoscale/exoscale.toml -- -default_account = "test-account" - -[[accounts]] -name = "test-account" -key = "EXOtest1234" -secret = "testsecret1234" -default_zone = "ch-gva-2" --- END -- - -# Now test config commands that read this file -exec exo config show -stdout 'test-account' -stdout 'EXOtest1234' - -# Add a new account (modifies the isolated config, not your real one!) -# exec exo config add new-account --key EXOnew123 --secret newsecret -# -# # Verify it was added -# exec exo config show -# stdout 'new-account' - -# Read default account -# exec exo config default -# stdout 'test-account' diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go deleted file mode 100644 index dc6a4aac..00000000 --- a/tests/e2e/testscript_api_test.go +++ /dev/null @@ -1,25 +0,0 @@ -//go:build api - -package e2e_test - -import ( - "testing" - - "github.com/rogpeppe/go-internal/testscript" -) - -// TestScriptsAPI runs testscript scenarios that require API access. -// These tests only run when built with the 'api' build tag: -// -// go test -tags=api -// -// API tests require EXOSCALE_API_KEY and EXOSCALE_API_SECRET -// environment variables to be set with valid API credentials. -func TestScriptsAPI(t *testing.T) { - testscript.Run(t, testscript.Params{ - Dir: "scenarios/api", - Setup: func(e *testscript.Env) error { - return setupTestEnv(e, true) - }, - }) -} diff --git a/tests/e2e/testscript_local_test.go b/tests/e2e/testscript_local_test.go index 1dbfd19c..af08dba3 100644 --- a/tests/e2e/testscript_local_test.go +++ b/tests/e2e/testscript_local_test.go @@ -11,14 +11,35 @@ import ( // TestScriptsLocal runs testscript scenarios that don't require API access. // These tests run by default without any build tags. func TestScriptsLocal(t *testing.T) { + // Find all txtar files recursively in scenarios/local + files, err := findTestScripts("scenarios/local") + if err != nil { + t.Fatal(err) + } + testscript.Run(t, testscript.Params{ - Dir: "scenarios/local", + Files: files, Setup: func(e *testscript.Env) error { return setupTestEnv(e, false) }, }) } +// findTestScripts recursively finds all .txtar and .txt files in a directory +func findTestScripts(dir string) ([]string, error) { + var files []string + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && (filepath.Ext(path) == ".txtar" || filepath.Ext(path) == ".txt") { + files = append(files, path) + } + return nil + }) + return files, err +} + // setupTestEnv configures the test environment for testscript scenarios. // withAPI controls whether API credentials should be forwarded. func setupTestEnv(e *testscript.Env, withAPI bool) error { diff --git a/tests/integ/blockstorage_test.go b/tests/integ/blockstorage_test.go index 7b884d18..84d1a328 100644 --- a/tests/integ/blockstorage_test.go +++ b/tests/integ/blockstorage_test.go @@ -1,11 +1,9 @@ -package integ_test +package integ import ( "fmt" "math/rand" "testing" - - "github.com/exoscale/cli/internal/integ/test" ) type blockStorageShowOutput struct { @@ -36,10 +34,10 @@ func TestBlockStorage(t *testing.T) { NewSnapshotName: fmt.Sprintf("test-snap-name-%d-renamed", rand.Int()), } - s := test.Suite{ + s := Suite{ Zone: "ch-gva-2", Parameters: params, - Steps: []test.Step{ + Steps: []Step{ { Description: "create volume", Command: "exo compute block-storage create {{.VolumeName}}" + diff --git a/tests/integ/config_panic_test.go b/tests/integ/config_panic_test.go new file mode 100644 index 00000000..d58c9fbe --- /dev/null +++ b/tests/integ/config_panic_test.go @@ -0,0 +1,71 @@ +package integ_test + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +var Binary = "../../bin/exo" + +// TestConfigPanic tests that config commands handle gracefully when +// defaultAccount field is missing from the config file. +// +// Note: With the fix, `exo config add` now automatically sets the first account +// as default, so this "no default" state should not occur through normal CLI usage. +// However, this state can still occur if: +// - User manually edits the config file and removes defaultAccount +// - Config file was created by an external tool +// +// This test ensures commands fail gracefully (not panic) when defaultAccount is missing. +// +// Note: This is an integration test rather than a testscript (e2e) test because +// `exo config add` uses interactive prompts (promptui) for zone selection and +// account information, which cannot be properly simulated in testscript's +// non-interactive environment. +func TestConfigPanic(t *testing.T) { + tmpHome := t.TempDir() + tmpConfigDir := filepath.Join(tmpHome, ".config", "exoscale") + err := os.MkdirAll(tmpConfigDir, 0755) + require.NoError(t, err) + + // Create config: account without defaultAccount field + // This state can occur from manual config editing or external tools + configWithoutDefault := `[[accounts]] +name = "test-account" +key = "EXOtest123" +secret = "testsecret" +defaultZone = "ch-gva-2" +` + configPath := filepath.Join(tmpConfigDir, "exoscale.toml") + err = os.WriteFile(configPath, []byte(configWithoutDefault), 0644) + require.NoError(t, err) + + t.Run("commands handle missing default account gracefully", func(t *testing.T) { + cmd := exec.Command(Binary, "config", "show") + cmd.Env = append(os.Environ(), "HOME="+tmpHome) + output, err := cmd.CombinedOutput() + + // Should fail gracefully with clear error message, not panic + require.Error(t, err) + require.Contains(t, string(output), "default account not defined") + t.Logf("Output: %s", output) + }) + + t.Run("use-account flag bypasses default account requirement", func(t *testing.T) { + cmd := exec.Command(Binary, "--use-account", "test-account", "config", "show") + cmd.Env = append(os.Environ(), "HOME="+tmpHome) + output, err := cmd.CombinedOutput() + + // Should work with --use-account flag + if err != nil { + // May fail due to invalid credentials, but shouldn't panic + t.Logf("Command failed (expected with test credentials): %s", output) + } else { + require.Contains(t, string(output), "test-account") + } + }) +} diff --git a/tests/integ/go.mod b/tests/integ/go.mod index 33006f4f..75ab676b 100644 --- a/tests/integ/go.mod +++ b/tests/integ/go.mod @@ -2,15 +2,10 @@ module github.com/exoscale/cli/internal/integ go 1.23 -require ( - github.com/rogpeppe/go-internal v1.14.1 - github.com/stretchr/testify v1.9.0 -) +require github.com/stretchr/testify v1.9.0 require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/tools v0.26.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/tests/integ/go.sum b/tests/integ/go.sum index bb1a653f..60ce688a 100644 --- a/tests/integ/go.sum +++ b/tests/integ/go.sum @@ -2,14 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= -golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/tests/integ/suite.go b/tests/integ/suite.go index 695bb2fc..e0839117 100644 --- a/tests/integ/suite.go +++ b/tests/integ/suite.go @@ -1,4 +1,4 @@ -package integ_test +package integ import ( "bytes"