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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 14 additions & 10 deletions cmd/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
}
18 changes: 17 additions & 1 deletion cmd/config/config_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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'
28 changes: 28 additions & 0 deletions tests/e2e/scenarios/local/config/show_when_valid_config_file.txtar
Original file line number Diff line number Diff line change
@@ -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"
29 changes: 0 additions & 29 deletions tests/e2e/scenarios/local/config_isolated.txtar

This file was deleted.

25 changes: 0 additions & 25 deletions tests/e2e/testscript_api_test.go

This file was deleted.

23 changes: 22 additions & 1 deletion tests/e2e/testscript_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 3 additions & 5 deletions tests/integ/blockstorage_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
package integ_test
package integ

import (
"fmt"
"math/rand"
"testing"

"github.com/exoscale/cli/internal/integ/test"
)

type blockStorageShowOutput struct {
Expand Down Expand Up @@ -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}}" +
Expand Down
71 changes: 71 additions & 0 deletions tests/integ/config_panic_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
Loading