Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 #817

### Documentation

Expand Down
209 changes: 122 additions & 87 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<environment variables>",
DefaultZone: DefaultZone,
Environment: DefaultEnvironment,
SosEndpoint: DefaultSosEndpoint,
}
GConfigFilePath = "<environment variables>"
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{
Expand All @@ -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 = "<environment variables>"
GConfigFilePath = "<environment variables>"
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{}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 '<environment variables>'
! stdout 'EXOconfig1234'

-- test-config.toml --
defaultaccount = "test-account"

[[accounts]]
name = "test-account"
key = "EXOconfig1234"
secret = "configsecret1234"
defaultZone = "de-fra-1"
Original file line number Diff line number Diff line change
@@ -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 '<environment variables>'
stdout 'EXOenv1234'
stdout 'ch-dk-2'
6 changes: 3 additions & 3 deletions tests/e2e/testscript_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down