diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1dc011c2..a23a5083 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -2,6 +2,7 @@ name: CI on: pull_request: + workflow_dispatch: jobs: test-e2e-without-api: @@ -13,7 +14,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" - name: Build binary run: make build @@ -24,24 +25,32 @@ jobs: go test -v test-e2e-with-api: - name: Run E2E (Testscript) Tests / With API + name: Run E2E (Testscript) Tests / With API / ${{ matrix.suite }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - suite: compute + run: TestScriptsAPICompute + - suite: dbaas + run: TestScriptsAPIDBaaS steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" - name: Build binary run: make build - - name: Run E2E (Testscript) Tests / With API + - name: Run E2E (Testscript) Tests / With API / ${{ matrix.suite }} env: EXOSCALE_API_KEY: ${{ secrets.EXOSCALE_API_KEY }} EXOSCALE_API_SECRET: ${{ secrets.EXOSCALE_API_SECRET }} EXOSCALE_ZONE: ch-gva-2 run: | cd tests/e2e - go test -v -tags=api -timeout 30m -run TestScriptsAPI 2>&1 + go test -v -tags=api -timeout 30m -run "${{ matrix.run }}" 2>&1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f8665e4a..8f682256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,24 @@ ### Improvements - Made cli more resilient to zone failures, and streams output while waiting for slow zones to responds #826 +- `EXOSCALE_ZONE` env var now overrides the default zone from the config profile #830 +- Config source loading separated from resolution into an explicit, testable merge function #830 ### Bug fixes +- `--config` flag now takes precedence over `EXOSCALE_CONFIG` env var as expected #830 - Adding forgotten field `Inference Engine Version` in `exo ai deployment show` command output #833 ## 1.94.2 ### Bug fixes + - fix: Improved error reporting for Dedicated Inference #825 ## 1.94.1 ### Bug fixes + - fix: prevent traversal path during sos download #823 ## 1.94.0 diff --git a/cmd/config/config_add.go b/cmd/config/config_add.go index 5bf6ee31..1e8796da 100644 --- a/cmd/config/config_add.go +++ b/cmd/config/config_add.go @@ -9,6 +9,7 @@ import ( "os" "os/signal" "strings" + "syscall" "github.com/manifoldco/promptui" "github.com/spf13/cobra" @@ -130,39 +131,51 @@ func addConfigAccount(firstRun bool) error { return saveConfig(filePath, &config) } -// readPasswordInterruptible reads a password from the terminal (no echo) while -// catching SIGINT (Ctrl+C). term.ReadPassword enables ISIG on the fd, which -// would otherwise deliver SIGINT directly to the process and kill it before any -// cancellation message can be printed. By intercepting the signal we can exit -// gracefully with the expected "Error: Operation Cancelled" output. -func readPasswordInterruptible() ([]byte, error) { - fd := int(os.Stdin.Fd()) +// readPasswordInterruptible reads a password with no echo, exiting gracefully +// on Ctrl+C. Three timing paths for SIGINT are covered: +// 1. sigCh fires first (Go runtime delivers signal before unix.Read returns). +// 2. unix.Read is interrupted first (returns syscall.EINTR). +// 3. ctx.Done fires first (cobra's handler cancelled the context first). +func readPasswordInterruptible(ctx context.Context) ([]byte, error) { + type result struct { + b []byte + err error + } sigCh := make(chan os.Signal, 1) - doneCh := make(chan struct{}) signal.Notify(sigCh, os.Interrupt) + defer signal.Stop(sigCh) + resultCh := make(chan result, 1) go func() { - select { - case _, ok := <-sigCh: - if ok { - fmt.Println() - fmt.Fprintln(os.Stderr, "Error: Operation Cancelled") - os.Exit(exocmd.ExitCodeInterrupt) - } - case <-doneCh: - } + b, err := term.ReadPassword(int(os.Stdin.Fd())) + resultCh <- result{b, err} }() - b, err := term.ReadPassword(fd) - signal.Stop(sigCh) - close(doneCh) - return b, err + select { + case r := <-resultCh: + // term.ReadPassword returns syscall.EINTR when unix.Read is interrupted by SIGINT. + if errors.Is(r.err, syscall.EINTR) { + fmt.Println() + fmt.Fprintln(os.Stderr, "Error: Operation Cancelled") + os.Exit(exocmd.ExitCodeInterrupt) + } + return r.b, r.err + case <-sigCh: + fmt.Println() + fmt.Fprintln(os.Stderr, "Error: Operation Cancelled") + os.Exit(exocmd.ExitCodeInterrupt) + case <-ctx.Done(): + fmt.Println() + fmt.Fprintln(os.Stderr, "Error: Operation Cancelled") + os.Exit(exocmd.ExitCodeInterrupt) + } + panic("unreachable") } -// readInputWithContext reads a line from stdin with context cancellation support. -// Returns io.EOF if Ctrl+C or Ctrl+D is pressed, allowing graceful cancellation. -// Silent exit behavior matches promptui.Select's interrupt handling. +// readInputWithContext reads a line from stdin with context cancellation +// support. Returns ctx.Err() when the context is done (e.g. on SIGINT); +// the caller is responsible for printing any message and exiting. func readInputWithContext(ctx context.Context, reader *bufio.Reader, prompt string) (string, error) { fmt.Printf("[+] %s: ", prompt) @@ -214,7 +227,7 @@ func promptAccountInformation() (*account.Account, error) { // Prompt for Secret Key with validation fmt.Printf("[+] Secret Key: ") //nolint:errcheck - secretKeyBytes, err := readPasswordInterruptible() + secretKeyBytes, err := readPasswordInterruptible(ctx) fmt.Println() //nolint:errcheck if err != nil { return nil, err @@ -223,7 +236,7 @@ func promptAccountInformation() (*account.Account, error) { for secretKey == "" { fmt.Println("Secret Key cannot be empty") fmt.Printf("[+] Secret Key: ") //nolint:errcheck - secretKeyBytes, err = readPasswordInterruptible() + secretKeyBytes, err = readPasswordInterruptible(ctx) fmt.Println() //nolint:errcheck if err != nil { return nil, err diff --git a/cmd/config_resolution.go b/cmd/config_resolution.go new file mode 100644 index 00000000..77810ca3 --- /dev/null +++ b/cmd/config_resolution.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "cmp" + "fmt" + "os" + "strconv" + "strings" + + "github.com/spf13/viper" + + "github.com/exoscale/cli/pkg/account" +) + +// envSources holds account-level values read from environment variables. +type envSources struct { + apiKey string + apiSecret string + apiEndpoint string + apiEnvironment string + sosEndpoint string + zone string + clientTimeout *int // nil when EXOSCALE_API_TIMEOUT is not set +} + +// fileSources holds the raw loaded config and the selected account profile. +// profile is nil when the config file is missing or the account was not found. +type fileSources struct { + config *account.Config + profile *account.Account +} + +// readEnvSources populates an envSources from the current environment. +func readEnvSources() envSources { + s := envSources{ + apiEndpoint: os.Getenv("EXOSCALE_API_ENDPOINT"), + apiEnvironment: readFromEnv("EXOSCALE_API_ENVIRONMENT"), + apiKey: readFromEnv( + "EXOSCALE_API_KEY", + "EXOSCALE_KEY", + "CLOUDSTACK_KEY", + "CLOUDSTACK_API_KEY", + ), + apiSecret: readFromEnv( + "EXOSCALE_API_SECRET", + "EXOSCALE_SECRET", + "EXOSCALE_SECRET_KEY", + "CLOUDSTACK_SECRET", + "CLOUDSTACK_SECRET_KEY", + ), + sosEndpoint: readFromEnv( + "EXOSCALE_STORAGE_API_ENDPOINT", + "EXOSCALE_SOS_ENDPOINT", + ), + zone: readFromEnv("EXOSCALE_ZONE"), + } + + if raw := readFromEnv("EXOSCALE_API_TIMEOUT"); raw != "" { + if n, err := strconv.Atoi(raw); err == nil { + s.clientTimeout = &n + } + } + + return s +} + +// hasCredentials reports whether both API key and secret are set. +func (s envSources) hasCredentials() bool { + return s.apiKey != "" && s.apiSecret != "" +} + +// loadFileSources reads the config file from v and selects the named account. +// Returns a zero fileSources when the file is not found. +func loadFileSources(v *viper.Viper, accountName string) (fileSources, error) { + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + return fileSources{}, nil + } + return fileSources{}, err + } + + cfg := &account.Config{} + if err := v.Unmarshal(cfg); err != nil { + return fileSources{}, fmt.Errorf("couldn't read config: %w", err) + } + + if accountName == "" { + accountName = cfg.DefaultAccount + } + + for i := range cfg.Accounts { + if cfg.Accounts[i].Name == accountName { + return fileSources{config: cfg, profile: &cfg.Accounts[i]}, nil + } + } + + // Keep config even without a matching profile so callers can list accounts. + return fileSources{config: cfg, profile: nil}, nil +} + +// resolve merges env, file profile, and built-in defaults in that order of precedence. +func resolve(env envSources, file fileSources) account.Account { + var acc account.Account + if file.profile != nil { + acc = *file.profile + } + + acc.Environment = cmp.Or(env.apiEnvironment, acc.Environment, DefaultEnvironment) + acc.DefaultZone = cmp.Or(env.zone, acc.DefaultZone, DefaultZone) + acc.SosEndpoint = strings.TrimRight(cmp.Or(env.sosEndpoint, acc.SosEndpoint, DefaultSosEndpoint), "/") + acc.Endpoint = cmp.Or(env.apiEndpoint, acc.Endpoint) + if env.clientTimeout != nil { + acc.ClientTimeout = *env.clientTimeout + } + if env.hasCredentials() { + acc.Key, acc.Secret, acc.SecretCommand = env.apiKey, env.apiSecret, nil + } + + return acc +} diff --git a/cmd/config_resolution_test.go b/cmd/config_resolution_test.go new file mode 100644 index 00000000..3201494f --- /dev/null +++ b/cmd/config_resolution_test.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "testing" + + "github.com/exoscale/cli/pkg/account" + "github.com/stretchr/testify/require" +) + +func ptr[T any](v T) *T { return &v } + +func TestResolve(t *testing.T) { + type want struct { + key string + secret string + zone string + endpoint string + environment string + sosEndpoint string + clientTimeout int + secretCmd []string + } + + tests := []struct { + name string + env envSources + file *account.Account + want want + }{ + { + name: "defaults when no sources set", + want: want{ + zone: DefaultZone, + environment: DefaultEnvironment, + sosEndpoint: DefaultSosEndpoint, + }, + }, + { + name: "file profile preserved", + file: &account.Account{Name: "prod", Key: "file-key", Secret: "file-secret", DefaultZone: "de-fra-1"}, + want: want{key: "file-key", secret: "file-secret", zone: "de-fra-1"}, + }, + { + name: "env credentials override file", + env: envSources{apiKey: "env-key", apiSecret: "env-secret"}, + file: &account.Account{Key: "file-key", Secret: "file-secret"}, + want: want{key: "env-key", secret: "env-secret"}, + }, + { + name: "env credentials clear secret command", + env: envSources{apiKey: "k", apiSecret: "s"}, + file: &account.Account{SecretCommand: []string{"gpg", "--decrypt", "secret.gpg"}}, + want: want{key: "k", secret: "s", secretCmd: nil}, + }, + { + name: "partial env credentials ignored", + env: envSources{apiKey: "env-key-only"}, + file: &account.Account{Key: "file-key", Secret: "file-secret"}, + want: want{key: "file-key", secret: "file-secret"}, + }, + { + name: "env zone overrides file", + env: envSources{zone: "ch-gva-2"}, + file: &account.Account{DefaultZone: "de-fra-1"}, + want: want{zone: "ch-gva-2"}, + }, + { + name: "file zone preserved when no env zone", + file: &account.Account{DefaultZone: "de-fra-1"}, + want: want{zone: "de-fra-1"}, + }, + { + name: "sos endpoint trailing slash stripped", + env: envSources{apiKey: "k", apiSecret: "s", sosEndpoint: "https://sos.example.com/"}, + want: want{key: "k", secret: "s", sosEndpoint: "https://sos.example.com"}, + }, + { + name: "client timeout from env", + env: envSources{clientTimeout: ptr(42)}, + want: want{clientTimeout: 42}, + }, + { + name: "env endpoint overrides file", + env: envSources{apiEndpoint: "https://env-endpoint.exo.io"}, + file: &account.Account{Endpoint: "https://file-endpoint.exo.io"}, + want: want{endpoint: "https://env-endpoint.exo.io"}, + }, + { + name: "env only no file", + env: envSources{apiKey: "env-key", apiSecret: "env-secret", zone: "at-vie-1"}, + want: want{key: "env-key", secret: "env-secret", zone: "at-vie-1", environment: DefaultEnvironment}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fs := fileSources{profile: tc.file} + acc := resolve(tc.env, fs) + + if tc.want.key != "" { + require.Equal(t, tc.want.key, acc.Key) + } + if tc.want.secret != "" { + require.Equal(t, tc.want.secret, acc.Secret) + } + if tc.want.zone != "" { + require.Equal(t, tc.want.zone, acc.DefaultZone) + } + if tc.want.endpoint != "" { + require.Equal(t, tc.want.endpoint, acc.Endpoint) + } + if tc.want.environment != "" { + require.Equal(t, tc.want.environment, acc.Environment) + } + if tc.want.sosEndpoint != "" { + require.Equal(t, tc.want.sosEndpoint, acc.SosEndpoint) + } + if tc.want.clientTimeout != 0 { + require.Equal(t, tc.want.clientTimeout, acc.ClientTimeout) + } + require.Equal(t, tc.want.secretCmd, acc.SecretCommand) + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index ba76ee50..d1623e0e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,8 +13,8 @@ import ( "path/filepath" "regexp" "slices" - "strconv" "strings" + "syscall" "time" "github.com/spf13/cobra" @@ -66,10 +66,11 @@ var versionCmd = &cobra.Command{ // This is called by main.main(). It only needs to happen once to the RootCmd. func Execute(version, commit string) { - // trap Ctrl+C and call cancel on the context + // Trap Ctrl+C (and SIGHUP, which the kernel delivers when the PTY session + // leader exits before we do) and cancel the context. ctx, cancel := context.WithCancel(context.Background()) c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt) + signal.Notify(c, os.Interrupt, syscall.SIGHUP) defer func() { signal.Stop(c) cancel() @@ -178,104 +179,9 @@ func init() { var ignoreClientBuild = false -type envAccountOverrides struct { - apiEndpoint string - apiEnvironment string - apiKey string - apiSecret string - sosEndpoint string - clientTimeout *int -} - -func readEnvAccountOverrides() envAccountOverrides { - clientTimeoutFromEnv := readFromEnv("EXOSCALE_API_TIMEOUT") - - overrides := envAccountOverrides{ - apiEndpoint: os.Getenv("EXOSCALE_API_ENDPOINT"), - apiEnvironment: readFromEnv("EXOSCALE_API_ENVIRONMENT"), - apiKey: readFromEnv( - "EXOSCALE_API_KEY", - "EXOSCALE_KEY", - "CLOUDSTACK_KEY", - "CLOUDSTACK_API_KEY", - ), - apiSecret: readFromEnv( - "EXOSCALE_API_SECRET", - "EXOSCALE_SECRET", - "EXOSCALE_SECRET_KEY", - "CLOUDSTACK_SECRET", - "CLOUDSTACK_SECRET_KEY", - ), - sosEndpoint: readFromEnv( - "EXOSCALE_STORAGE_API_ENDPOINT", - "EXOSCALE_SOS_ENDPOINT", - ), - } - - if clientTimeoutFromEnv != "" { - if t, err := strconv.Atoi(clientTimeoutFromEnv); err == nil { - overrides.clientTimeout = &t - } - } - - return overrides -} - -func (o envAccountOverrides) HasCredentials() bool { - return o.apiKey != "" && o.apiSecret != "" -} - -func (o envAccountOverrides) Apply(acc *account.Account) { - if o.clientTimeout != nil { - acc.ClientTimeout = *o.clientTimeout - } - - if !o.HasCredentials() { - return - } - - acc.Key = o.apiKey - acc.Secret = o.apiSecret - acc.SecretCommand = nil - - if o.apiEndpoint != "" { - acc.Endpoint = o.apiEndpoint - } - - if o.apiEnvironment != "" { - acc.Environment = o.apiEnvironment - } - - if o.sosEndpoint != "" { - acc.SosEndpoint = o.sosEndpoint - } -} - -func useEnvOnlyAccount(overrides envAccountOverrides) { - envAccount := account.Account{ - Name: "", - DefaultZone: DefaultZone, - Environment: DefaultEnvironment, - SosEndpoint: DefaultSosEndpoint, - } - GConfigFilePath = "" - overrides.Apply(&envAccount) - account.GAllAccount = &account.Config{ - DefaultAccount: envAccount.Name, - Accounts: []account.Account{envAccount}, - } - account.CurrentAccount = &account.GAllAccount.Accounts[0] -} - -func finalizeCurrentAccount(overrides envAccountOverrides) { - if account.CurrentAccount.Environment == "" { - account.CurrentAccount.Environment = DefaultEnvironment - } - - if account.CurrentAccount.DefaultZone == "" { - account.CurrentAccount.DefaultZone = DefaultZone - } - +// applyOutputFormat sets the output format from the account default when +// --output-format was not passed on the command line. +func applyOutputFormat() { if globalstate.OutputFormat == "" { if account.CurrentAccount.DefaultOutputFormat != "" { globalstate.OutputFormat = account.CurrentAccount.DefaultOutputFormat @@ -283,39 +189,31 @@ func finalizeCurrentAccount(overrides envAccountOverrides) { globalstate.OutputFormat = DefaultOutputFormat } } - - if account.CurrentAccount.SosEndpoint == "" { - account.CurrentAccount.SosEndpoint = DefaultSosEndpoint - } - - overrides.Apply(account.CurrentAccount) - account.CurrentAccount.SosEndpoint = strings.TrimRight(account.CurrentAccount.SosEndpoint, "/") } // initConfig reads in config file and ENV variables if set. func initConfig() { //nolint:gocyclo - envs := map[string]string{ + // Bind meta-config env vars to flags; CLI flags take precedence. + metaEnvFlags := map[string]string{ "EXOSCALE_CONFIG": "config", "EXOSCALE_ACCOUNT": "use-account", "EXOSCALE_TIMEOUT": "timeout", } - for env, flag := range envs { - pflag := RootCmd.Flags().Lookup(flag) + for envVar, flagName := range metaEnvFlags { + pflag := RootCmd.Flags().Lookup(flagName) if pflag == nil { - panic(fmt.Sprintf("unknown flag '%s'", flag)) + panic(fmt.Sprintf("unknown flag %q", flagName)) } - if value, ok := os.LookupEnv(env); ok { + if value, ok := os.LookupEnv(envVar); ok && !pflag.Changed { if err := pflag.Value.Set(value); err != nil { log.Fatal(err) } } } - overrides := readEnvAccountOverrides() - - config := &account.Config{} + env := readEnvSources() usr, err := user.Current() if err != nil { @@ -350,7 +248,6 @@ func initConfig() { //nolint:gocyclo log.Fatalf("%q is a directory but but should be configuration file", GConfigFilePath) } - // Use config file from the flag. GConfig.SetConfigFile(GConfigFilePath) } else { GConfig.SetConfigName("exoscale") @@ -364,55 +261,55 @@ func initConfig() { //nolint:gocyclo nonCredentialCmds := []string{"config", "version", "status"} - if err := GConfig.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); ok && overrides.HasCredentials() { - useEnvOnlyAccount(overrides) - finalizeCurrentAccount(overrides) + file, err := loadFileSources(GConfig, gAccountName) + if err != nil { + if isNonCredentialCmd(nonCredentialCmds...) { + ignoreClientBuild = true + account.GAllAccount = &account.Config{} return } + log.Fatal(err) + } + // No config file: require env credentials or bail. + if file.config == nil { + if env.hasCredentials() { + seed := account.Account{Name: ""} + GConfigFilePath = "" + resolved := resolve(env, fileSources{profile: &seed}) + account.GAllAccount = &account.Config{ + DefaultAccount: resolved.Name, + Accounts: []account.Account{resolved}, + } + account.CurrentAccount = &account.GAllAccount.Accounts[0] + applyOutputFormat() + return + } if isNonCredentialCmd(nonCredentialCmds...) { ignoreClientBuild = true - // Set GAllAccount with empty config so config commands can handle gracefully account.GAllAccount = &account.Config{} return } - - if _, ok := err.(viper.ConfigFileNotFoundError); ok { - log.Fatal(`error: the exo CLI must be configured before usage, please run "exo config"`) - } - - log.Fatal(err) + log.Fatal(`error: the exo CLI must be configured before usage, please run "exo config"`) } - // All the stored data (e.g. ssh keys) will be put next to the config file. + // Config file found: update paths. GConfigFilePath = GConfig.ConfigFileUsed() globalstate.ConfigFolder = filepath.Dir(GConfigFilePath) - if err := GConfig.Unmarshal(config); err != nil { - log.Fatal(fmt.Errorf("couldn't read config: %s", err)) - } - - if len(config.Accounts) == 0 { + if len(file.config.Accounts) == 0 { if isNonCredentialCmd(nonCredentialCmds...) { ignoreClientBuild = true - // Set GAllAccount so config commands can handle the empty state gracefully - account.GAllAccount = config + account.GAllAccount = file.config return } - log.Fatalf("no accounts were found into %q", GConfig.ConfigFileUsed()) - return } - // Allow config management commands to run without a default account - // This fixes the circular dependency where 'exo config set' couldn't run - // to set a default account because it required a default account to exist + // Allow config management commands to run without a default account. configManagementCmds := []string{"list", "set", "show"} isConfigManagementCmd := getCmdPosition("config") == 1 if isConfigManagementCmd && len(os.Args) > 2 { - // Check if the subcommand is a config management command - // Need to find the actual subcommand by skipping flags for i := 2; i < len(os.Args); i++ { if !strings.HasPrefix(os.Args[i], "-") { isConfigManagementCmd = slices.Contains(configManagementCmds, os.Args[i]) @@ -423,47 +320,50 @@ func initConfig() { //nolint:gocyclo isConfigManagementCmd = false } - if config.DefaultAccount == "" && gAccountName == "" { - // Allow config management commands to proceed without default account + if file.config.DefaultAccount == "" && gAccountName == "" { if isConfigManagementCmd { ignoreClientBuild = true - // Set GAllAccount so config commands can access the account list - account.GAllAccount = config + account.GAllAccount = file.config return } - // Provide helpful error message with available accounts - var availableAccounts []string - for _, acc := range config.Accounts { - availableAccounts = append(availableAccounts, acc.Name) + var names []string + for _, acc := range file.config.Accounts { + names = append(names, acc.Name) } - if len(availableAccounts) > 0 { + if len(names) > 0 { log.Fatalf("default account not defined\n\nSet a default account with: exo config set \nAvailable accounts: %s\n\nOr specify an account for this command with: --use-account ", - strings.Join(availableAccounts, ", ")) + strings.Join(names, ", ")) } else { log.Fatalf("default account not defined") } } + if file.profile == nil { + selectedName := gAccountName + if selectedName == "" { + selectedName = file.config.DefaultAccount + } + log.Fatalf("error: could't find any configured account named %q", selectedName) + } + if gAccountName == "" { - gAccountName = config.DefaultAccount + gAccountName = file.config.DefaultAccount } - account.GAllAccount = config + account.GAllAccount = file.config account.GAllAccount.DefaultAccount = gAccountName - for i, acc := range config.Accounts { - if acc.Name == gAccountName { - account.CurrentAccount = &config.Accounts[i] + resolved := resolve(env, file) + // Update in place so account.CurrentAccount pointer stays valid. + for i := range account.GAllAccount.Accounts { + if account.GAllAccount.Accounts[i].Name == resolved.Name { + account.GAllAccount.Accounts[i] = resolved + account.CurrentAccount = &account.GAllAccount.Accounts[i] break } } - - if account.CurrentAccount.Name == "" { - log.Fatalf("error: could't find any configured account named %q", gAccountName) - } - - finalizeCurrentAccount(overrides) + applyOutputFormat() } func isNonCredentialCmd(cmds ...string) bool { diff --git a/tests/e2e/scenarios/without-api/config/show/show_config_flag_beats_env_config.txtar b/tests/e2e/scenarios/without-api/config/show/show_config_flag_beats_env_config.txtar new file mode 100644 index 00000000..9d8ea39e --- /dev/null +++ b/tests/e2e/scenarios/without-api/config/show/show_config_flag_beats_env_config.txtar @@ -0,0 +1,25 @@ +# Test --config flag takes precedence over EXOSCALE_CONFIG env var + +env EXOSCALE_CONFIG=env-config.toml + +exec exo --config flag-config.toml config show +stdout 'flag-account' +! stdout 'env-account' + +-- flag-config.toml -- +defaultaccount = "flag-account" + +[[accounts]] +name = "flag-account" +key = "EXOflag1234" +secret = "flagsecret1234" +defaultZone = "ch-gva-2" + +-- env-config.toml -- +defaultaccount = "env-account" + +[[accounts]] +name = "env-account" +key = "EXOenv1234" +secret = "envsecret1234" +defaultZone = "de-fra-1" diff --git a/tests/e2e/scenarios/without-api/config/show/show_with_env_zone_overrides_config_zone.txtar b/tests/e2e/scenarios/without-api/config/show/show_with_env_zone_overrides_config_zone.txtar new file mode 100644 index 00000000..b5feb556 --- /dev/null +++ b/tests/e2e/scenarios/without-api/config/show/show_with_env_zone_overrides_config_zone.txtar @@ -0,0 +1,20 @@ +# Test EXOSCALE_ZONE overrides the default zone from the config profile + +env EXOSCALE_API_KEY=EXOenv1234 +env EXOSCALE_API_SECRET=envsecret5678 +env EXOSCALE_ZONE=ch-gva-2 +env EXOSCALE_CONFIG=test-config.toml + +exec exo config show +stdout 'test-account' +stdout 'ch-gva-2' +! stdout 'de-fra-1' + +-- test-config.toml -- +defaultaccount = "test-account" + +[[accounts]] +name = "test-account" +key = "EXOconfig1234" +secret = "configsecret1234" +defaultZone = "de-fra-1" diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go index 3d170fb3..64357a23 100644 --- a/tests/e2e/testscript_api_test.go +++ b/tests/e2e/testscript_api_test.go @@ -22,15 +22,28 @@ type APITestSuite struct { RunID string } -// TestScriptsAPI runs testscript scenarios that require real API access. -// Run with: go test -v -tags=api -timeout 30m +// TestScriptsAPICompute runs API e2e scenarios under scenarios/with-api/compute/. +// Run with: go test -v -tags=api -timeout 30m -run TestScriptsAPICompute +func TestScriptsAPICompute(t *testing.T) { + runAPITestSuite(t, "scenarios/with-api/compute") +} + +// TestScriptsAPIDBaaS runs API e2e scenarios under scenarios/with-api/dbaas/. +// Run with: go test -v -tags=api -timeout 30m -run TestScriptsAPIDBaaS +func TestScriptsAPIDBaaS(t *testing.T) { + runAPITestSuite(t, "scenarios/with-api/dbaas") +} + +// runAPITestSuite is the shared runner for per-suite API test functions. +// dir is the directory of .txtar scenarios to run (relative to the e2e package). // // Required environment variables: // // EXOSCALE_API_KEY - Exoscale API key // EXOSCALE_API_SECRET - Exoscale API secret // EXOSCALE_ZONE - Zone to run tests in (default: ch-gva-2) -func TestScriptsAPI(t *testing.T) { +func runAPITestSuite(t *testing.T, dir string) { + t.Helper() if os.Getenv("EXOSCALE_API_KEY") == "" || os.Getenv("EXOSCALE_API_SECRET") == "" { t.Skip("Skipping API tests: EXOSCALE_API_KEY and EXOSCALE_API_SECRET must be set") } @@ -48,13 +61,12 @@ func TestScriptsAPI(t *testing.T) { RunID: runID, } - // Run all scenarios under scenarios/with-api/ - files, err := findTestScripts("scenarios/with-api") + files, err := findTestScripts(dir) if err != nil { t.Fatal(err) } if len(files) == 0 { - t.Log("No API test scenarios found in scenarios/with-api/") + t.Logf("No API test scenarios found in %s/", dir) return }