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
19 changes: 14 additions & 5 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: CI

on:
pull_request:
workflow_dispatch:

jobs:
test-e2e-without-api:
Expand All @@ -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
Expand All @@ -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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 39 additions & 26 deletions cmd/config/config_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"os/signal"
"strings"
"syscall"

"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
120 changes: 120 additions & 0 deletions cmd/config_resolution.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading