From 26d67cd45293eaff0140aadc3370c0b8c021d2ef Mon Sep 17 00:00:00 2001 From: Nicolas Hedger Date: Wed, 25 Mar 2026 12:32:57 +0100 Subject: [PATCH 1/3] feat(config): improve config override behavior --- cmd/root.go | 209 ++++++++++-------- ...nv_credentials_keeps_config_defaults.txtar | 22 ++ ..._with_env_credentials_without_config.txtar | 9 + tests/e2e/testscript_api_test.go | 6 +- 4 files changed, 156 insertions(+), 90 deletions(-) create mode 100644 tests/e2e/scenarios/without-api/config/show/show_with_env_credentials_keeps_config_defaults.txtar create mode 100644 tests/e2e/scenarios/without-api/config/show/show_with_env_credentials_without_config.txtar diff --git a/cmd/root.go b/cmd/root.go index 37027313b..bce4b8d18 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -107,6 +107,120 @@ 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 + } + + if globalstate.OutputFormat == "" { + if account.CurrentAccount.DefaultOutputFormat != "" { + globalstate.OutputFormat = account.CurrentAccount.DefaultOutputFormat + } else { + 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{ @@ -127,63 +241,7 @@ func initConfig() { //nolint:gocyclo } } - sosEndpointFromEnv := readFromEnv( - "EXOSCALE_STORAGE_API_ENDPOINT", - "EXOSCALE_SOS_ENDPOINT", - ) - - apiEndpoint := os.Getenv("EXOSCALE_API_ENDPOINT") - - apiKeyFromEnv := readFromEnv( - "EXOSCALE_API_KEY", - "EXOSCALE_KEY", - "CLOUDSTACK_KEY", - "CLOUDSTACK_API_KEY", - ) - - apiSecretFromEnv := readFromEnv( - "EXOSCALE_API_SECRET", - "EXOSCALE_SECRET", - "EXOSCALE_SECRET_KEY", - "CLOUDSTACK_SECRET", - "CLOUDSTACK_SECRET_KEY", - ) - - apiEnvironmentFromEnv := readFromEnv("EXOSCALE_API_ENVIRONMENT") - - if apiKeyFromEnv != "" && apiSecretFromEnv != "" { - account.CurrentAccount.Name = "" - GConfigFilePath = "" - account.CurrentAccount.Key = apiKeyFromEnv - account.CurrentAccount.Secret = apiSecretFromEnv - - if apiEndpoint != "" { - account.CurrentAccount.Endpoint = apiEndpoint - } - - if apiEnvironmentFromEnv != "" { - account.CurrentAccount.Environment = apiEnvironmentFromEnv - } - if sosEndpointFromEnv != "" { - account.CurrentAccount.SosEndpoint = sosEndpointFromEnv - } - - clientTimeoutFromEnv := readFromEnv("EXOSCALE_API_TIMEOUT") - if clientTimeoutFromEnv != "" { - if t, err := strconv.Atoi(clientTimeoutFromEnv); err == nil { - account.CurrentAccount.ClientTimeout = t - } - } - - account.GAllAccount = &account.Config{ - DefaultAccount: account.CurrentAccount.Name, - Accounts: []account.Account{*account.CurrentAccount}, - } - - buildClient() - - return - } + overrides := readEnvAccountOverrides() config := &account.Config{} @@ -235,6 +293,12 @@ 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) + return + } + if isNonCredentialCmd(nonCredentialCmds...) { ignoreClientBuild = true // Set GAllAccount with empty config so config commands can handle gracefully @@ -327,36 +391,7 @@ func initConfig() { //nolint:gocyclo log.Fatalf("error: could't find any configured account named %q", gAccountName) } - if account.CurrentAccount.Environment == "" { - account.CurrentAccount.Environment = DefaultEnvironment - } - - if account.CurrentAccount.DefaultZone == "" { - account.CurrentAccount.DefaultZone = DefaultZone - } - - // if an output format isn't specified via cli argument, use - // the current account default format - if globalstate.OutputFormat == "" { - if account.CurrentAccount.DefaultOutputFormat != "" { - globalstate.OutputFormat = account.CurrentAccount.DefaultOutputFormat - } else { - globalstate.OutputFormat = DefaultOutputFormat - } - } - - if account.CurrentAccount.SosEndpoint == "" { - account.CurrentAccount.SosEndpoint = DefaultSosEndpoint - } - - clientTimeoutFromEnv := readFromEnv("EXOSCALE_API_TIMEOUT") - if clientTimeoutFromEnv != "" { - if t, err := strconv.Atoi(clientTimeoutFromEnv); err == nil { - account.CurrentAccount.ClientTimeout = t - } - } - - account.CurrentAccount.SosEndpoint = strings.TrimRight(account.CurrentAccount.SosEndpoint, "/") + finalizeCurrentAccount(overrides) } func isNonCredentialCmd(cmds ...string) bool { diff --git a/tests/e2e/scenarios/without-api/config/show/show_with_env_credentials_keeps_config_defaults.txtar b/tests/e2e/scenarios/without-api/config/show/show_with_env_credentials_keeps_config_defaults.txtar new file mode 100644 index 000000000..a5dd50936 --- /dev/null +++ b/tests/e2e/scenarios/without-api/config/show/show_with_env_credentials_keeps_config_defaults.txtar @@ -0,0 +1,22 @@ +# Test env credentials override auth without discarding profile defaults + +env EXOSCALE_API_KEY=EXOenv1234 +env EXOSCALE_API_SECRET=envsecret5678 +env EXOSCALE_CONFIG=test-config.toml + +exec exo config show +stdout 'test-account' +stdout 'EXOenv1234' +stdout 'de-fra-1' +stdout 'test-config.toml' +! stdout '' +! stdout 'EXOconfig1234' + +-- test-config.toml -- +defaultaccount = "test-account" + +[[accounts]] +name = "test-account" +key = "EXOconfig1234" +secret = "configsecret1234" +defaultZone = "de-fra-1" diff --git a/tests/e2e/scenarios/without-api/config/show/show_with_env_credentials_without_config.txtar b/tests/e2e/scenarios/without-api/config/show/show_with_env_credentials_without_config.txtar new file mode 100644 index 000000000..e9cb75375 --- /dev/null +++ b/tests/e2e/scenarios/without-api/config/show/show_with_env_credentials_without_config.txtar @@ -0,0 +1,9 @@ +# Test env credentials still work without a config file + +env EXOSCALE_API_KEY=EXOenv1234 +env EXOSCALE_API_SECRET=envsecret5678 + +exec exo config show +stdout '' +stdout 'EXOenv1234' +stdout 'ch-dk-2' diff --git a/tests/e2e/testscript_api_test.go b/tests/e2e/testscript_api_test.go index 1e875eb70..3d170fb37 100644 --- a/tests/e2e/testscript_api_test.go +++ b/tests/e2e/testscript_api_test.go @@ -75,14 +75,14 @@ func TestScriptsAPI(t *testing.T) { // setupAPITestEnv configures the testscript environment with API credentials // and run metadata. Each scenario creates and deletes its own resources. // Credentials are passed via env vars (EXOSCALE_API_KEY / EXOSCALE_API_SECRET) -// so the CLI never reads or writes a config file during API tests, keeping -// secrets off disk. +// so the CLI can run during API tests without writing secrets to disk. func setupAPITestEnv(e *testscript.Env, suite *APITestSuite) error { // Isolate HOME so no real config file is accidentally read. e.Setenv("XDG_CONFIG_HOME", e.WorkDir+"/.config") e.Setenv("HOME", e.WorkDir) - // API credentials — the CLI reads these directly, ignoring any config file. + // API credentials — the CLI reads these directly, allowing tests to run + // without a config file. e.Setenv("EXOSCALE_API_KEY", os.Getenv("EXOSCALE_API_KEY")) e.Setenv("EXOSCALE_API_SECRET", os.Getenv("EXOSCALE_API_SECRET")) From 1b5d895c060adf1920655951c234753880e132f2 Mon Sep 17 00:00:00 2001 From: Nicolas Hedger Date: Wed, 25 Mar 2026 12:54:52 +0100 Subject: [PATCH 2/3] add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20bc9e6c2..eb92a7d53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - fix(nlb): API error swallowed on load-balancer update (e.g. duplicate name conflict reported as "operation is nil") #806 - fix(config): panic when used without a default account set #798 - Fix bad flag ref in `dns add NS` #812 +- fix(config): preserve profile defaults such as default zone when API credentials are provided via environment variables ### Documentation From 23699d8e4a02765a73fcc562bea61f4075c5e965 Mon Sep 17 00:00:00 2001 From: Nicolas Hedger Date: Wed, 25 Mar 2026 12:55:21 +0100 Subject: [PATCH 3/3] add pr number --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb92a7d53..f8544f4ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ - fix(nlb): API error swallowed on load-balancer update (e.g. duplicate name conflict reported as "operation is nil") #806 - fix(config): panic when used without a default account set #798 - Fix bad flag ref in `dns add NS` #812 -- fix(config): preserve profile defaults such as default zone when API credentials are provided via environment variables +- fix(config): preserve profile defaults such as default zone when API credentials are provided via environment variables #817 ### Documentation