From 62dede763f62a530ef1f5193327752cf4ff16eb7 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Tue, 5 May 2026 15:44:28 +0200 Subject: [PATCH 1/9] refactor(config): separate source loading from resolution #830 --- CHANGELOG.md | 6 + cmd/config_resolution.go | 143 +++++++++++ cmd/config_resolution_test.go | 124 ++++++++++ cmd/root.go | 222 +++++------------- .../show_config_flag_beats_env_config.txtar | 25 ++ ..._with_env_zone_overrides_config_zone.txtar | 20 ++ 6 files changed, 378 insertions(+), 162 deletions(-) create mode 100644 cmd/config_resolution.go create mode 100644 cmd/config_resolution_test.go create mode 100644 tests/e2e/scenarios/without-api/config/show/show_config_flag_beats_env_config.txtar create mode 100644 tests/e2e/scenarios/without-api/config/show/show_with_env_zone_overrides_config_zone.txtar diff --git a/CHANGELOG.md b/CHANGELOG.md index 20219fa4..e46f84b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,20 @@ ### Bug fixes +- fix(config): `--config` flag now takes precedence over `EXOSCALE_CONFIG` env var as expected #830 +- feat(config): `EXOSCALE_ZONE` env var overrides the default zone from the config profile #830 +- refactor(config): separate config source loading from resolution into an explicit, testable merge function #830 + ## 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_resolution.go b/cmd/config_resolution.go new file mode 100644 index 00000000..f4c6782a --- /dev/null +++ b/cmd/config_resolution.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "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 + } + + // Built-in defaults for any field not set by the profile. + if acc.Environment == "" { + acc.Environment = DefaultEnvironment + } + if acc.DefaultZone == "" { + acc.DefaultZone = DefaultZone + } + if acc.SosEndpoint == "" { + acc.SosEndpoint = DefaultSosEndpoint + } + + // Env overrides. + if env.zone != "" { + acc.DefaultZone = env.zone + } + if env.apiEndpoint != "" { + acc.Endpoint = env.apiEndpoint + } + if env.apiEnvironment != "" { + acc.Environment = env.apiEnvironment + } + if env.sosEndpoint != "" { + acc.SosEndpoint = env.sosEndpoint + } + if env.clientTimeout != nil { + acc.ClientTimeout = *env.clientTimeout + } + if env.hasCredentials() { + acc.Key = env.apiKey + acc.Secret = env.apiSecret + acc.SecretCommand = nil + } + + acc.SosEndpoint = strings.TrimRight(acc.SosEndpoint, "/") + + 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..14bf068d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,7 +13,6 @@ import ( "path/filepath" "regexp" "slices" - "strconv" "strings" "time" @@ -178,104 +177,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 +187,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 +246,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 +259,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 +318,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" From b5c6b59cb867744f848f580bd74f72000a94b81b Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Tue, 5 May 2026 16:38:45 +0200 Subject: [PATCH 2/9] fix(config): catch SIGINT directly in readInputWithContext --- cmd/config/config_add.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cmd/config/config_add.go b/cmd/config/config_add.go index 5bf6ee31..352d5ef1 100644 --- a/cmd/config/config_add.go +++ b/cmd/config/config_add.go @@ -166,6 +166,28 @@ func readPasswordInterruptible() ([]byte, error) { func readInputWithContext(ctx context.Context, reader *bufio.Reader, prompt string) (string, error) { fmt.Printf("[+] %s: ", prompt) + // Catch SIGINT directly so Ctrl+C during a plain-text prompt is handled + // synchronously. Without this, the handler relies on Execute()'s signal + // goroutine eventually calling cancel(), which can lag on loaded CI runners. + sigCh := make(chan os.Signal, 1) + doneCh := make(chan struct{}) + signal.Notify(sigCh, os.Interrupt) + defer func() { + signal.Stop(sigCh) + close(doneCh) + }() + go func() { + select { + case _, ok := <-sigCh: + if ok { + fmt.Println() + fmt.Fprintln(os.Stderr, "Error: Operation Cancelled") + os.Exit(exocmd.ExitCodeInterrupt) + } + case <-doneCh: + } + }() + inputCh := make(chan struct { value string err error From 9cc0242890bb5f6bfa33fc72e6907dc26189695e Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Tue, 5 May 2026 16:45:01 +0200 Subject: [PATCH 3/9] chore(config): trim comments in config_add.go --- cmd/config/config_add.go | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/cmd/config/config_add.go b/cmd/config/config_add.go index 352d5ef1..9d9ad6a1 100644 --- a/cmd/config/config_add.go +++ b/cmd/config/config_add.go @@ -130,11 +130,10 @@ 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. +// readPasswordInterruptible reads a password with no echo, intercepting SIGINT +// so the process can print "Error: Operation Cancelled" before exiting. +// (term.ReadPassword enables ISIG, which would otherwise deliver the signal +// directly and kill the process before anything is printed.) func readPasswordInterruptible() ([]byte, error) { fd := int(os.Stdin.Fd()) @@ -160,15 +159,13 @@ func readPasswordInterruptible() ([]byte, error) { return b, err } -// 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, exiting on Ctrl+C. +// SIGINT is caught directly rather than relying on Execute()'s cancel goroutine, +// which can lag on loaded runners. func readInputWithContext(ctx context.Context, reader *bufio.Reader, prompt string) (string, error) { fmt.Printf("[+] %s: ", prompt) - // Catch SIGINT directly so Ctrl+C during a plain-text prompt is handled - // synchronously. Without this, the handler relies on Execute()'s signal - // goroutine eventually calling cancel(), which can lag on loaded CI runners. + // Catch SIGINT directly so Ctrl+C is handled synchronously. sigCh := make(chan os.Signal, 1) doneCh := make(chan struct{}) signal.Notify(sigCh, os.Interrupt) From 45ca9074d6f01f5a2ac4ed46b2b989f38d17bab1 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Tue, 5 May 2026 17:28:31 +0200 Subject: [PATCH 4/9] refactor(config): use cmp.Or in resolve() --- cmd/config_resolution.go | 35 ++++++----------------------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/cmd/config_resolution.go b/cmd/config_resolution.go index f4c6782a..77810ca3 100644 --- a/cmd/config_resolution.go +++ b/cmd/config_resolution.go @@ -1,6 +1,7 @@ package cmd import ( + "cmp" "fmt" "os" "strconv" @@ -104,40 +105,16 @@ func resolve(env envSources, file fileSources) account.Account { acc = *file.profile } - // Built-in defaults for any field not set by the profile. - if acc.Environment == "" { - acc.Environment = DefaultEnvironment - } - if acc.DefaultZone == "" { - acc.DefaultZone = DefaultZone - } - if acc.SosEndpoint == "" { - acc.SosEndpoint = DefaultSosEndpoint - } - - // Env overrides. - if env.zone != "" { - acc.DefaultZone = env.zone - } - if env.apiEndpoint != "" { - acc.Endpoint = env.apiEndpoint - } - if env.apiEnvironment != "" { - acc.Environment = env.apiEnvironment - } - if env.sosEndpoint != "" { - acc.SosEndpoint = env.sosEndpoint - } + 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 = env.apiKey - acc.Secret = env.apiSecret - acc.SecretCommand = nil + acc.Key, acc.Secret, acc.SecretCommand = env.apiKey, env.apiSecret, nil } - acc.SosEndpoint = strings.TrimRight(acc.SosEndpoint, "/") - return acc } From 50317eb75f4b8fa4bd3bd5eefd3e82144a3a1517 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Wed, 6 May 2026 15:24:50 +0200 Subject: [PATCH 5/9] fix(config): fix SIGINT/SIGHUP race in interactive prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When exo runs inside a PTY spawned by testscript, the testscript wrapper binary (mainExo) is the PTY session leader. On Ctrl+C: 1. SIGINT goes to the whole foreground process group: wrapper + exo. 2. The wrapper has no signal.Notify, so Go's default SIGINT handler kills it immediately. 3. On session-leader exit, the kernel sends SIGHUP to the remaining process group members — including exo. With no SIGHUP handler, exo is killed before it can print "Error: Operation Cancelled". Fix: add syscall.SIGHUP to the signal.Notify in Execute() so SIGHUP cancels the context instead of killing the process. Also overhaul readPasswordInterruptible to cover all SIGINT timing paths: sigCh fires first, unix.Read returns EINTR first, or ctx.Done fires first (when cobra's goroutine cancels the context before signal.Notify runs). --- cmd/config/config_add.go | 84 +++++++++++++++++++--------------------- cmd/root.go | 6 ++- 2 files changed, 43 insertions(+), 47 deletions(-) diff --git a/cmd/config/config_add.go b/cmd/config/config_add.go index 9d9ad6a1..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,61 +131,54 @@ func addConfigAccount(firstRun bool) error { return saveConfig(filePath, &config) } -// readPasswordInterruptible reads a password with no echo, intercepting SIGINT -// so the process can print "Error: Operation Cancelled" before exiting. -// (term.ReadPassword enables ISIG, which would otherwise deliver the signal -// directly and kill the process before anything is printed.) -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, exiting on Ctrl+C. -// SIGINT is caught directly rather than relying on Execute()'s cancel goroutine, -// which can lag on loaded runners. +// 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) - // Catch SIGINT directly so Ctrl+C is handled synchronously. - sigCh := make(chan os.Signal, 1) - doneCh := make(chan struct{}) - signal.Notify(sigCh, os.Interrupt) - defer func() { - signal.Stop(sigCh) - close(doneCh) - }() - go func() { - select { - case _, ok := <-sigCh: - if ok { - fmt.Println() - fmt.Fprintln(os.Stderr, "Error: Operation Cancelled") - os.Exit(exocmd.ExitCodeInterrupt) - } - case <-doneCh: - } - }() - inputCh := make(chan struct { value string err error @@ -233,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 @@ -242,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/root.go b/cmd/root.go index 14bf068d..d1623e0e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( "regexp" "slices" "strings" + "syscall" "time" "github.com/spf13/cobra" @@ -65,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() From 965fb03a3c43395bdfc44eb08bba113d6094241a Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Wed, 6 May 2026 15:32:36 +0200 Subject: [PATCH 6/9] ci: trigger e2e tests From fb86722cdf549bacac90f43073b3d0ee49eb86fe Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Wed, 6 May 2026 15:39:12 +0200 Subject: [PATCH 7/9] ci: add workflow_dispatch to e2e workflow --- .github/workflows/e2e.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1dc011c2..70c135b4 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: From b5ddfc1947b092ddf7bd40404e4541b382aed446 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Wed, 6 May 2026 15:49:31 +0200 Subject: [PATCH 8/9] ci(e2e): split with-API tests into parallel compute/dbaas jobs --- .github/workflows/e2e.yml | 16 +++++++++++----- tests/e2e/testscript_api_test.go | 24 ++++++++++++++++++------ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 70c135b4..81383e58 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -14,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 @@ -25,24 +25,30 @@ 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: + suite: [compute, dbaas] 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 + suite=${{ matrix.suite }} + func="TestScriptsAPI${suite^}" + go test -v -tags=api -timeout 30m -run "$func" 2>&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 } From 3e3b764aad883357f030156de37f4119e5c76073 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Wed, 6 May 2026 15:55:56 +0200 Subject: [PATCH 9/9] fix(ci): use explicit run names in e2e matrix, fix CHANGELOG sections --- .github/workflows/e2e.yml | 10 ++++++---- CHANGELOG.md | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 81383e58..a23a5083 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -30,7 +30,11 @@ jobs: strategy: fail-fast: false matrix: - suite: [compute, dbaas] + include: + - suite: compute + run: TestScriptsAPICompute + - suite: dbaas + run: TestScriptsAPIDBaaS steps: - uses: actions/checkout@v4 @@ -49,6 +53,4 @@ jobs: EXOSCALE_ZONE: ch-gva-2 run: | cd tests/e2e - suite=${{ matrix.suite }} - func="TestScriptsAPI${suite^}" - go test -v -tags=api -timeout 30m -run "$func" 2>&1 + go test -v -tags=api -timeout 30m -run "${{ matrix.run }}" 2>&1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8850e300..8f682256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,12 @@ ### 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 -- fix(config): `--config` flag now takes precedence over `EXOSCALE_CONFIG` env var as expected #830 -- feat(config): `EXOSCALE_ZONE` env var overrides the default zone from the config profile #830 -- refactor(config): separate config source loading from resolution into an explicit, testable merge function #830 +- `--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