From abcbfc3f3036bbc842d46bfb4bb1ce60173ab729 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Fri, 27 Mar 2026 15:56:35 +0200 Subject: [PATCH 1/7] Replace profile option with loading spinner --- cmd/aws.go | 50 ++++++++++++ cmd/root.go | 1 + internal/awscli/exec.go | 90 +++++++++++++++++++++ internal/awscli/exec_test.go | 53 ++++++++++++ internal/awscli/spinner.go | 84 +++++++++++++++++++ internal/output/errors.go | 29 +++++++ main.go | 3 +- test/integration/aws_cmd_test.go | 134 +++++++++++++++++++++++++++++++ 8 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 cmd/aws.go create mode 100644 internal/awscli/exec.go create mode 100644 internal/awscli/exec_test.go create mode 100644 internal/awscli/spinner.go create mode 100644 test/integration/aws_cmd_test.go diff --git a/cmd/aws.go b/cmd/aws.go new file mode 100644 index 00000000..1e7acc3c --- /dev/null +++ b/cmd/aws.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "github.com/localstack/lstk/internal/awscli" + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/endpoint" + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/telemetry" + "github.com/spf13/cobra" +) + +func newAWSCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { + return &cobra.Command{ + Use: "aws [args...]", + Short: "Run AWS CLI commands against LocalStack", + Long: `Proxy AWS CLI commands to LocalStack with endpoint, credentials, and region pre-configured. + +Equivalent to running: + aws --endpoint-url http://localhost:4566 +with AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_DEFAULT_REGION set automatically. + +Examples: + lstk aws s3 ls + lstk aws sqs list-queues + lstk aws s3 mb s3://my-bucket`, + DisableFlagParsing: true, + RunE: commandWithTelemetry("aws", tel, func(cmd *cobra.Command, args []string) error { + port := resolveAWSPort() + host, _ := endpoint.ResolveHost(port, cfg.LocalStackHost) + return awscli.Exec(cmd.Context(), "http://"+host, args) + }), + } +} + +func resolveAWSPort() string { + const defaultPort = "4566" + if err := config.Init(); err != nil { + return defaultPort + } + appCfg, err := config.Get() + if err != nil { + return defaultPort + } + for _, c := range appCfg.Containers { + if c.Type == config.EmulatorAWS { + return c.Port + } + } + return defaultPort +} diff --git a/cmd/root.go b/cmd/root.go index 8d54e919..e2dd2732 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -64,6 +64,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newVolumeCmd(cfg, tel), newUpdateCmd(cfg, tel), newDocsCmd(), + newAWSCmd(cfg, tel), ) return root diff --git a/internal/awscli/exec.go b/internal/awscli/exec.go new file mode 100644 index 00000000..88447136 --- /dev/null +++ b/internal/awscli/exec.go @@ -0,0 +1,90 @@ +package awscli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + + "github.com/localstack/lstk/internal/output" +) + +// stopOnWriteWriter wraps a writer and stops the spinner on first write +type stopOnWriteWriter struct { + w io.Writer + spinner *spinner + once sync.Once +} + +func (s *stopOnWriteWriter) Write(p []byte) (int, error) { + s.once.Do(func() { + s.spinner.Stop() + }) + return s.w.Write(p) +} + +func Exec(ctx context.Context, endpointURL string, args []string) error { + awsBin, err := exec.LookPath("aws") + if err != nil { + return fmt.Errorf("aws CLI not found in PATH — install it from https://aws.amazon.com/cli/") + } + + cmdArgs := make([]string, 0, len(args)+2) + cmdArgs = append(cmdArgs, "--endpoint-url", endpointURL) + cmdArgs = append(cmdArgs, args...) + + cmd := exec.CommandContext(ctx, awsBin, cmdArgs...) + cmd.Stdin = os.Stdin + cmd.Env = BuildEnv(os.Environ()) + + var s *spinner + if isTerminal(os.Stderr) { + s = newSpinner(os.Stderr, "Loading...") + s.Start() + + // Wrap stdout/stderr to stop spinner on first output + stopWriter := &stopOnWriteWriter{w: os.Stdout, spinner: s} + cmd.Stdout = stopWriter + cmd.Stderr = &stopOnWriteWriter{w: os.Stderr, spinner: s} + } else { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + + err = cmd.Run() + + if err == nil { + return nil + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return output.NewSilentError(output.NewExitCodeError(exitErr.ExitCode(), err)) + } + return err +} + +func BuildEnv(base []string) []string { + env := make([]string, len(base), len(base)+3) + copy(env, base) + + setIfAbsent(&env, "AWS_ACCESS_KEY_ID", "test") + setIfAbsent(&env, "AWS_SECRET_ACCESS_KEY", "test") + setIfAbsent(&env, "AWS_DEFAULT_REGION", "us-east-1") + + return env +} + +func setIfAbsent(env *[]string, key, value string) { + prefix := key + "=" + for _, e := range *env { + if strings.HasPrefix(e, prefix) { + return + } + } + *env = append(*env, prefix+value) +} diff --git a/internal/awscli/exec_test.go b/internal/awscli/exec_test.go new file mode 100644 index 00000000..8a28bb97 --- /dev/null +++ b/internal/awscli/exec_test.go @@ -0,0 +1,53 @@ +package awscli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildEnvSetsDefaultsWhenAbsent(t *testing.T) { + base := []string{"PATH=/usr/bin", "HOME=/home/user"} + env := BuildEnv(base) + + assert.Contains(t, env, "AWS_ACCESS_KEY_ID=test") + assert.Contains(t, env, "AWS_SECRET_ACCESS_KEY=test") + assert.Contains(t, env, "AWS_DEFAULT_REGION=us-east-1") + assert.Contains(t, env, "PATH=/usr/bin") + assert.Contains(t, env, "HOME=/home/user") +} + +func TestBuildEnvPreservesExistingValues(t *testing.T) { + base := []string{ + "AWS_ACCESS_KEY_ID=custom-key", + "AWS_SECRET_ACCESS_KEY=custom-secret", + "AWS_DEFAULT_REGION=eu-west-1", + } + env := BuildEnv(base) + + assert.Contains(t, env, "AWS_ACCESS_KEY_ID=custom-key") + assert.Contains(t, env, "AWS_SECRET_ACCESS_KEY=custom-secret") + assert.Contains(t, env, "AWS_DEFAULT_REGION=eu-west-1") + assert.NotContains(t, env, "AWS_ACCESS_KEY_ID=test") + assert.NotContains(t, env, "AWS_SECRET_ACCESS_KEY=test") + assert.NotContains(t, env, "AWS_DEFAULT_REGION=us-east-1") +} + +func TestBuildEnvDoesNotMutateInput(t *testing.T) { + base := []string{"PATH=/usr/bin"} + original := make([]string, len(base)) + copy(original, base) + + BuildEnv(base) + + assert.Equal(t, original, base) +} + +func TestBuildEnvPartialOverride(t *testing.T) { + base := []string{"AWS_ACCESS_KEY_ID=custom-key"} + env := BuildEnv(base) + + assert.Contains(t, env, "AWS_ACCESS_KEY_ID=custom-key") + assert.Contains(t, env, "AWS_SECRET_ACCESS_KEY=test") + assert.Contains(t, env, "AWS_DEFAULT_REGION=us-east-1") +} diff --git a/internal/awscli/spinner.go b/internal/awscli/spinner.go new file mode 100644 index 00000000..d34869e3 --- /dev/null +++ b/internal/awscli/spinner.go @@ -0,0 +1,84 @@ +package awscli + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "time" +) + +var dotFrames = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} + +// ANSI color codes matching lstk's spinner style (color 69 blue) and secondary (color 241 gray) +const ( + spinnerColor = "\033[38;5;69m" + secondaryColor = "\033[38;5;241m" + resetColor = "\033[0m" +) + +type spinner struct { + out io.Writer + label string + stop chan struct{} + done chan struct{} + mu sync.Mutex +} + +func newSpinner(out io.Writer, label string) *spinner { + return &spinner{ + out: out, + label: label, + stop: make(chan struct{}), + done: make(chan struct{}), + } +} + +func (s *spinner) Start() { + go func() { + defer close(s.done) + tick := time.NewTicker(100 * time.Millisecond) + defer tick.Stop() + + i := 0 + for { + s.mu.Lock() + _, _ = fmt.Fprintf(s.out, "\r%s%s%s %s%s%s", spinnerColor, dotFrames[i%len(dotFrames)], resetColor, secondaryColor, s.label, resetColor) + s.mu.Unlock() + + select { + case <-s.stop: + s.clearLine() + return + case <-tick.C: + i++ + } + } + }() +} + +func (s *spinner) Stop() { + close(s.stop) + <-s.done +} + +func (s *spinner) clearLine() { + s.mu.Lock() + defer s.mu.Unlock() + width := len(s.label) + 10 + _, _ = fmt.Fprintf(s.out, "\r%s\r", strings.Repeat(" ", width)) +} + +// isTerminal returns true if the writer is a terminal +func isTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + stat, err := f.Stat() + if err != nil { + return false + } + return (stat.Mode() & os.ModeCharDevice) != 0 +} diff --git a/internal/output/errors.go b/internal/output/errors.go index 27c831a0..4b47cc3a 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -26,3 +26,32 @@ func IsSilent(err error) bool { var silent *SilentError return errors.As(err, &silent) } + +// ExitCodeError carries a specific process exit code through the error chain. +// Used by passthrough commands (e.g. lstk aws) to propagate the subprocess exit code. +type ExitCodeError struct { + Code int + Err error +} + +func (e *ExitCodeError) Error() string { + return e.Err.Error() +} + +func (e *ExitCodeError) Unwrap() error { + return e.Err +} + +func NewExitCodeError(code int, err error) *ExitCodeError { + return &ExitCodeError{Code: code, Err: err} +} + +// ExitCode returns the exit code if err (or any error in its chain) is an ExitCodeError, +// or 1 as a default. +func ExitCode(err error) int { + var exitErr *ExitCodeError + if errors.As(err, &exitErr) { + return exitErr.Code + } + return 1 +} diff --git a/main.go b/main.go index a2d1285f..aceab22f 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "syscall" "github.com/localstack/lstk/cmd" + "github.com/localstack/lstk/internal/output" ) func main() { @@ -14,6 +15,6 @@ func main() { defer cancel() if err := cmd.Execute(ctx); err != nil { - os.Exit(1) + os.Exit(output.ExitCode(err)) } } diff --git a/test/integration/aws_cmd_test.go b/test/integration/aws_cmd_test.go new file mode 100644 index 00000000..fd133c93 --- /dev/null +++ b/test/integration/aws_cmd_test.go @@ -0,0 +1,134 @@ +package integration_test + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeFakeAWS creates a shell script that mimics `aws` by printing its args and env vars. +// Returns the directory containing the script (to prepend to PATH). +func writeFakeAWS(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + if runtime.GOOS == "windows" { + t.Skip("fake aws script not supported on Windows") + } + + script := `#!/bin/sh +echo "ENDPOINT:$2" +shift 2 +echo "ARGS:$@" +echo "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" +echo "AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" +echo "AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION" +` + path := filepath.Join(dir, "aws") + require.NoError(t, os.WriteFile(path, []byte(script), 0755)) + return dir +} + +func TestAWSCommandInjectsEndpointAndArgs(t *testing.T) { + fakeDir := writeFakeAWS(t) + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + require.NoError(t, err, "lstk aws failed: %s", stderr) + + assert.Contains(t, stdout, "ENDPOINT:http://") + assert.Contains(t, stdout, "ARGS:s3 ls") +} + +func TestAWSCommandInjectsCredentials(t *testing.T) { + fakeDir := writeFakeAWS(t) + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "sts", "get-caller-identity") + require.NoError(t, err, "lstk aws failed: %s", stderr) + + assert.Contains(t, stdout, "AWS_ACCESS_KEY_ID=test") + assert.Contains(t, stdout, "AWS_SECRET_ACCESS_KEY=test") + assert.Contains(t, stdout, "AWS_DEFAULT_REGION=us-east-1") +} + +func TestAWSCommandRespectsExistingCredentials(t *testing.T) { + fakeDir := writeFakeAWS(t) + e := env.With(env.DisableEvents, "1"). + With("PATH", fakeDir). + With("AWS_ACCESS_KEY_ID", "custom-key"). + With("AWS_SECRET_ACCESS_KEY", "custom-secret"). + With("AWS_DEFAULT_REGION", "eu-west-1") + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + require.NoError(t, err, "lstk aws failed: %s", stderr) + + assert.Contains(t, stdout, "AWS_ACCESS_KEY_ID=custom-key") + assert.Contains(t, stdout, "AWS_SECRET_ACCESS_KEY=custom-secret") + assert.Contains(t, stdout, "AWS_DEFAULT_REGION=eu-west-1") +} + +func TestAWSCommandFailsWhenAWSCLINotInstalled(t *testing.T) { + e := env.With(env.DisableEvents, "1").With("PATH", t.TempDir()) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + require.Error(t, err) + assert.Contains(t, stderr, "aws CLI not found") +} + +func TestAWSCommandUsesPortFromConfig(t *testing.T) { + fakeDir := writeFakeAWS(t) + workDir := t.TempDir() + + configContent := ` +[[containers]] +type = "aws" +tag = "latest" +port = "4599" +` + lstkDir := filepath.Join(workDir, ".lstk") + require.NoError(t, os.MkdirAll(lstkDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(lstkDir, "config.toml"), []byte(configContent), 0644)) + + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir) + + stdout, stderr, err := runLstk(t, testContext(t), workDir, e, "aws", "s3", "ls") + require.NoError(t, err, "lstk aws failed: %s", stderr) + + assert.Contains(t, stdout, ":4599") +} + +// writeFakeAWSFailing creates a shell script that mimics a failing `aws` command. +// Returns the directory containing the script (to prepend to PATH). +func writeFakeAWSFailing(t *testing.T, exitCode int) string { + t.Helper() + dir := t.TempDir() + + if runtime.GOOS == "windows" { + t.Skip("fake aws script not supported on Windows") + } + + script := fmt.Sprintf(`#!/bin/sh +echo "aws: error: simulated failure" >&2 +exit %d +`, exitCode) + path := filepath.Join(dir, "aws") + require.NoError(t, os.WriteFile(path, []byte(script), 0755)) + return dir +} + +func TestAWSCommandPropagatesExitCode(t *testing.T) { + fakeDir := writeFakeAWSFailing(t, 42) + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + require.Error(t, err, "lstk aws should fail when aws command fails") + assert.Contains(t, stderr, "simulated failure") + requireExitCode(t, 42, err) +} From bf0694618a6f6c7eb80d1d2caf3819f95f832cd3 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Wed, 22 Apr 2026 11:52:33 +0300 Subject: [PATCH 2/7] Address review comments --- cmd/aws.go | 7 ++-- internal/awscli/exec.go | 34 +++++++++++++---- internal/awsconfig/awsconfig.go | 6 +-- internal/awsconfig/awsconfig_test.go | 4 +- internal/config/containers.go | 1 + internal/output/errors.go | 28 -------------- internal/{awscli => terminal}/spinner.go | 35 ++++++++++-------- main.go | 9 ++++- test/integration/aws_cmd_test.go | 47 +++++++++++++++++++++++- 9 files changed, 107 insertions(+), 64 deletions(-) rename internal/{awscli => terminal}/spinner.go (64%) diff --git a/cmd/aws.go b/cmd/aws.go index 1e7acc3c..c88942ad 100644 --- a/cmd/aws.go +++ b/cmd/aws.go @@ -33,18 +33,17 @@ Examples: } func resolveAWSPort() string { - const defaultPort = "4566" if err := config.Init(); err != nil { - return defaultPort + return config.DefaultAWSPort } appCfg, err := config.Get() if err != nil { - return defaultPort + return config.DefaultAWSPort } for _, c := range appCfg.Containers { if c.Type == config.EmulatorAWS { return c.Port } } - return defaultPort + return config.DefaultAWSPort } diff --git a/internal/awscli/exec.go b/internal/awscli/exec.go index 88447136..efa2c347 100644 --- a/internal/awscli/exec.go +++ b/internal/awscli/exec.go @@ -10,13 +10,15 @@ import ( "strings" "sync" + "github.com/localstack/lstk/internal/awsconfig" "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/terminal" ) // stopOnWriteWriter wraps a writer and stops the spinner on first write type stopOnWriteWriter struct { w io.Writer - spinner *spinner + spinner *terminal.Spinner once sync.Once } @@ -33,17 +35,31 @@ func Exec(ctx context.Context, endpointURL string, args []string) error { return fmt.Errorf("aws CLI not found in PATH — install it from https://aws.amazon.com/cli/") } - cmdArgs := make([]string, 0, len(args)+2) + // Use the localstack AWS profile when it exists (written by lstk start/setup aws), + // so credentials and region come from ~/.aws rather than being re-injected here. + // Fall back to env var injection when the profile hasn't been set up yet. + profileExists, _ := awsconfig.ProfileExists() + + capacity := len(args) + 2 + if profileExists { + capacity += 2 + } + cmdArgs := make([]string, 0, capacity) cmdArgs = append(cmdArgs, "--endpoint-url", endpointURL) + if profileExists { + cmdArgs = append(cmdArgs, "--profile", awsconfig.ProfileName) + } cmdArgs = append(cmdArgs, args...) cmd := exec.CommandContext(ctx, awsBin, cmdArgs...) cmd.Stdin = os.Stdin - cmd.Env = BuildEnv(os.Environ()) + if !profileExists { + cmd.Env = BuildEnv(os.Environ()) + } - var s *spinner - if isTerminal(os.Stderr) { - s = newSpinner(os.Stderr, "Loading...") + var s *terminal.Spinner + if terminal.IsTerminal(os.Stderr) { + s = terminal.NewSpinner(os.Stderr, "Loading...") s.Start() // Wrap stdout/stderr to stop spinner on first output @@ -57,13 +73,17 @@ func Exec(ctx context.Context, endpointURL string, args []string) error { err = cmd.Run() + if s != nil { + s.Stop() + } + if err == nil { return nil } var exitErr *exec.ExitError if errors.As(err, &exitErr) { - return output.NewSilentError(output.NewExitCodeError(exitErr.ExitCode(), err)) + return output.NewSilentError(err) } return err } diff --git a/internal/awsconfig/awsconfig.go b/internal/awsconfig/awsconfig.go index e3d3c061..40f9d784 100644 --- a/internal/awsconfig/awsconfig.go +++ b/internal/awsconfig/awsconfig.go @@ -16,7 +16,7 @@ import ( ) const ( - profileName = "localstack" + ProfileName = "localstack" configSectionName = "profile localstack" // ~/.aws/config uses "profile " as section header credsSectionName = "localstack" // ~/.aws/credentials uses just the profile name // TODO: make region configurable (e.g. from container env or lstk config) @@ -136,9 +136,9 @@ func credsNeedWrite(path string) (bool, error) { return false, nil } -// profileExists reports whether the localstack profile section is present in both +// ProfileExists reports whether the localstack profile section is present in both // ~/.aws/config and ~/.aws/credentials. -func profileExists() (bool, error) { +func ProfileExists() (bool, error) { configPath, credsPath, err := awsPaths() if err != nil { return false, err diff --git a/internal/awsconfig/awsconfig_test.go b/internal/awsconfig/awsconfig_test.go index 8d5fca67..a8af47c7 100644 --- a/internal/awsconfig/awsconfig_test.go +++ b/internal/awsconfig/awsconfig_test.go @@ -50,7 +50,7 @@ func TestProfileExists(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) tc.setup(t, dir) - ok, err := profileExists() + ok, err := ProfileExists() if err != nil { t.Fatal(err) } @@ -71,7 +71,7 @@ func TestWriteProfile(t *testing.T) { name: "creates files when absent", setup: func(t *testing.T, dir string) {}, check: func(t *testing.T, dir string) { - ok, err := profileExists() + ok, err := ProfileExists() if err != nil { t.Fatal(err) } diff --git a/internal/config/containers.go b/internal/config/containers.go index 951b517b..3288155b 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -15,6 +15,7 @@ const ( EmulatorSnowflake EmulatorType = "snowflake" EmulatorAzure EmulatorType = "azure" + DefaultAWSPort = "4566" dockerRegistry = "localstack" ) diff --git a/internal/output/errors.go b/internal/output/errors.go index 4b47cc3a..d88f1f63 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -27,31 +27,3 @@ func IsSilent(err error) bool { return errors.As(err, &silent) } -// ExitCodeError carries a specific process exit code through the error chain. -// Used by passthrough commands (e.g. lstk aws) to propagate the subprocess exit code. -type ExitCodeError struct { - Code int - Err error -} - -func (e *ExitCodeError) Error() string { - return e.Err.Error() -} - -func (e *ExitCodeError) Unwrap() error { - return e.Err -} - -func NewExitCodeError(code int, err error) *ExitCodeError { - return &ExitCodeError{Code: code, Err: err} -} - -// ExitCode returns the exit code if err (or any error in its chain) is an ExitCodeError, -// or 1 as a default. -func ExitCode(err error) int { - var exitErr *ExitCodeError - if errors.As(err, &exitErr) { - return exitErr.Code - } - return 1 -} diff --git a/internal/awscli/spinner.go b/internal/terminal/spinner.go similarity index 64% rename from internal/awscli/spinner.go rename to internal/terminal/spinner.go index d34869e3..0b5219cd 100644 --- a/internal/awscli/spinner.go +++ b/internal/terminal/spinner.go @@ -1,4 +1,4 @@ -package awscli +package terminal import ( "fmt" @@ -11,23 +11,24 @@ import ( var dotFrames = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} -// ANSI color codes matching lstk's spinner style (color 69 blue) and secondary (color 241 gray) +// ANSI color codes matching the lstk style palette (color 69 = Nimbo blue, color 241 = secondary gray). const ( spinnerColor = "\033[38;5;69m" secondaryColor = "\033[38;5;241m" resetColor = "\033[0m" ) -type spinner struct { - out io.Writer - label string - stop chan struct{} - done chan struct{} - mu sync.Mutex +type Spinner struct { + out io.Writer + label string + stop chan struct{} + done chan struct{} + mu sync.Mutex + stopOnce sync.Once } -func newSpinner(out io.Writer, label string) *spinner { - return &spinner{ +func NewSpinner(out io.Writer, label string) *Spinner { + return &Spinner{ out: out, label: label, stop: make(chan struct{}), @@ -35,7 +36,7 @@ func newSpinner(out io.Writer, label string) *spinner { } } -func (s *spinner) Start() { +func (s *Spinner) Start() { go func() { defer close(s.done) tick := time.NewTicker(100 * time.Millisecond) @@ -58,20 +59,22 @@ func (s *spinner) Start() { }() } -func (s *spinner) Stop() { - close(s.stop) +func (s *Spinner) Stop() { + s.stopOnce.Do(func() { + close(s.stop) + }) <-s.done } -func (s *spinner) clearLine() { +func (s *Spinner) clearLine() { s.mu.Lock() defer s.mu.Unlock() width := len(s.label) + 10 _, _ = fmt.Fprintf(s.out, "\r%s\r", strings.Repeat(" ", width)) } -// isTerminal returns true if the writer is a terminal -func isTerminal(w io.Writer) bool { +// IsTerminal reports whether w is a character device (i.e. a terminal). +func IsTerminal(w io.Writer) bool { f, ok := w.(*os.File) if !ok { return false diff --git a/main.go b/main.go index aceab22f..655da7b0 100644 --- a/main.go +++ b/main.go @@ -2,12 +2,13 @@ package main import ( "context" + "errors" "os" + "os/exec" "os/signal" "syscall" "github.com/localstack/lstk/cmd" - "github.com/localstack/lstk/internal/output" ) func main() { @@ -15,6 +16,10 @@ func main() { defer cancel() if err := cmd.Execute(ctx); err != nil { - os.Exit(output.ExitCode(err)) + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) + } + os.Exit(1) } } diff --git a/test/integration/aws_cmd_test.go b/test/integration/aws_cmd_test.go index fd133c93..275f6f2b 100644 --- a/test/integration/aws_cmd_test.go +++ b/test/integration/aws_cmd_test.go @@ -35,9 +35,21 @@ echo "AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION" return dir } +// writeAWSProfile writes a minimal localstack AWS profile to dir/.aws/{config,credentials}. +func writeAWSProfile(t *testing.T, homeDir string) { + t.Helper() + awsDir := filepath.Join(homeDir, ".aws") + require.NoError(t, os.MkdirAll(awsDir, 0700)) + require.NoError(t, os.WriteFile(filepath.Join(awsDir, "config"), + []byte("[profile localstack]\nregion = us-east-1\noutput = json\nendpoint_url = http://localhost.localstack.cloud:4566\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(awsDir, "credentials"), + []byte("[localstack]\naws_access_key_id = test\naws_secret_access_key = test\n"), 0600)) +} + func TestAWSCommandInjectsEndpointAndArgs(t *testing.T) { fakeDir := writeFakeAWS(t) - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir) + // Use a fresh HOME so a real localstack profile doesn't affect the args output. + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") require.NoError(t, err, "lstk aws failed: %s", stderr) @@ -48,7 +60,8 @@ func TestAWSCommandInjectsEndpointAndArgs(t *testing.T) { func TestAWSCommandInjectsCredentials(t *testing.T) { fakeDir := writeFakeAWS(t) - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir) + // Use a fresh HOME so no localstack profile exists; credentials are injected via env vars. + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "sts", "get-caller-identity") require.NoError(t, err, "lstk aws failed: %s", stderr) @@ -60,8 +73,10 @@ func TestAWSCommandInjectsCredentials(t *testing.T) { func TestAWSCommandRespectsExistingCredentials(t *testing.T) { fakeDir := writeFakeAWS(t) + // Use a fresh HOME so no localstack profile exists; the user-provided env vars are preserved. e := env.With(env.DisableEvents, "1"). With("PATH", fakeDir). + With(env.Home, t.TempDir()). With("AWS_ACCESS_KEY_ID", "custom-key"). With("AWS_SECRET_ACCESS_KEY", "custom-secret"). With("AWS_DEFAULT_REGION", "eu-west-1") @@ -74,6 +89,21 @@ func TestAWSCommandRespectsExistingCredentials(t *testing.T) { assert.Contains(t, stdout, "AWS_DEFAULT_REGION=eu-west-1") } +func TestAWSCommandUsesProfileWhenAvailable(t *testing.T) { + fakeDir := writeFakeAWS(t) + homeDir := t.TempDir() + writeAWSProfile(t, homeDir) + + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, homeDir) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + require.NoError(t, err, "lstk aws failed: %s", stderr) + + assert.Contains(t, stdout, "--profile localstack") + // Credentials must not be injected via env when the profile is in use. + assert.NotContains(t, stdout, "AWS_ACCESS_KEY_ID=test") +} + func TestAWSCommandFailsWhenAWSCLINotInstalled(t *testing.T) { e := env.With(env.DisableEvents, "1").With("PATH", t.TempDir()) @@ -82,6 +112,19 @@ func TestAWSCommandFailsWhenAWSCLINotInstalled(t *testing.T) { assert.Contains(t, stderr, "aws CLI not found") } +func TestAWSCommandUsesDefaultPortWithoutConfig(t *testing.T) { + fakeDir := writeFakeAWS(t) + workDir := t.TempDir() + e := env.With(env.DisableEvents, "1"). + With("PATH", fakeDir). + With(env.Home, t.TempDir()) // isolate from any real config file + + stdout, stderr, err := runLstk(t, testContext(t), workDir, e, "aws", "s3", "ls") + require.NoError(t, err, "lstk aws failed: %s", stderr) + + assert.Contains(t, stdout, ":4566") +} + func TestAWSCommandUsesPortFromConfig(t *testing.T) { fakeDir := writeFakeAWS(t) workDir := t.TempDir() From 973e12cb657843378cb5a44198c75cd73f4aac6c Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Wed, 22 Apr 2026 14:18:07 +0300 Subject: [PATCH 3/7] Remove some unecassary code --- internal/awscli/exec.go | 1 - internal/terminal/spinner.go | 10 ++++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/awscli/exec.go b/internal/awscli/exec.go index efa2c347..773a9690 100644 --- a/internal/awscli/exec.go +++ b/internal/awscli/exec.go @@ -62,7 +62,6 @@ func Exec(ctx context.Context, endpointURL string, args []string) error { s = terminal.NewSpinner(os.Stderr, "Loading...") s.Start() - // Wrap stdout/stderr to stop spinner on first output stopWriter := &stopOnWriteWriter{w: os.Stdout, spinner: s} cmd.Stdout = stopWriter cmd.Stderr = &stopOnWriteWriter{w: os.Stderr, spinner: s} diff --git a/internal/terminal/spinner.go b/internal/terminal/spinner.go index 0b5219cd..a4c913ab 100644 --- a/internal/terminal/spinner.go +++ b/internal/terminal/spinner.go @@ -7,6 +7,8 @@ import ( "strings" "sync" "time" + + "golang.org/x/term" ) var dotFrames = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} @@ -73,15 +75,11 @@ func (s *Spinner) clearLine() { _, _ = fmt.Fprintf(s.out, "\r%s\r", strings.Repeat(" ", width)) } -// IsTerminal reports whether w is a character device (i.e. a terminal). +// IsTerminal reports whether w is a terminal. func IsTerminal(w io.Writer) bool { f, ok := w.(*os.File) if !ok { return false } - stat, err := f.Stat() - if err != nil { - return false - } - return (stat.Mode() & os.ModeCharDevice) != 0 + return term.IsTerminal(int(f.Fd())) } From 53f5010fae8291c27e2f9cce59d3dcec92b6629f Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Wed, 22 Apr 2026 16:09:04 +0200 Subject: [PATCH 4/7] Wire DefaultAWSPort constant to existing usages --- internal/config/config.go | 2 +- internal/config/containers.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 18fd8551..a30cfee0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,7 +24,7 @@ func setDefaults() { { "type": "aws", "tag": "latest", - "port": "4566", + "port": DefaultAWSPort, }, }) viper.SetDefault("update_prompt", true) diff --git a/internal/config/containers.go b/internal/config/containers.go index 3288155b..896cc4a5 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -118,7 +118,7 @@ func (c *ContainerConfig) HealthPath() (string, error) { func (c *ContainerConfig) ContainerPort() (string, error) { switch c.Type { case EmulatorAWS: - return "4566/tcp", nil + return DefaultAWSPort + "/tcp", nil default: return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type) } From a55c89bc3f727d6ef02d6de4b121d854a0be622f Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Thu, 23 Apr 2026 09:48:12 +0200 Subject: [PATCH 5/7] Refactor aws cmd: move spinner and profile check to command boundary - Move terminal detection and spinner setup from awscli.Exec to cmd/aws.go - Move StopOnWriteWriter from awscli to internal/terminal - Pass useProfile bool and injected writers into Exec instead of deciding internally - Emit note hint when AWS profile is missing - Add integration tests for hint suppression - Use \033[2K for clean line clearing in spinner --- cmd/aws.go | 23 ++++++++++- internal/awscli/exec.go | 65 +++++++------------------------- internal/terminal/spinner.go | 20 ++++++++-- test/integration/aws_cmd_test.go | 22 ++++++++++- 4 files changed, 72 insertions(+), 58 deletions(-) diff --git a/cmd/aws.go b/cmd/aws.go index c88942ad..4ffe5343 100644 --- a/cmd/aws.go +++ b/cmd/aws.go @@ -1,11 +1,17 @@ package cmd import ( + "io" + "os" + "github.com/localstack/lstk/internal/awscli" + "github.com/localstack/lstk/internal/awsconfig" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/endpoint" "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/telemetry" + "github.com/localstack/lstk/internal/terminal" "github.com/spf13/cobra" ) @@ -27,7 +33,22 @@ Examples: RunE: commandWithTelemetry("aws", tel, func(cmd *cobra.Command, args []string) error { port := resolveAWSPort() host, _ := endpoint.ResolveHost(port, cfg.LocalStackHost) - return awscli.Exec(cmd.Context(), "http://"+host, args) + + profileExists, _ := awsconfig.ProfileExists() + if !profileExists { + output.EmitNote(output.NewPlainSink(os.Stdout), "No AWS profile found, run 'lstk setup aws'") + } + + stdout, stderr := io.Writer(os.Stdout), io.Writer(os.Stderr) + if terminal.IsTerminal(os.Stderr) { + s := terminal.NewSpinner(os.Stderr, "Loading...") + s.Start() + defer s.Stop() + stdout = &terminal.StopOnWriteWriter{W: os.Stdout, Spinner: s} + stderr = &terminal.StopOnWriteWriter{W: os.Stderr, Spinner: s} + } + + return awscli.Exec(cmd.Context(), "http://"+host, profileExists, stdout, stderr, args) }), } } diff --git a/internal/awscli/exec.go b/internal/awscli/exec.go index 773a9690..1768ab3c 100644 --- a/internal/awscli/exec.go +++ b/internal/awscli/exec.go @@ -8,83 +8,44 @@ import ( "os" "os/exec" "strings" - "sync" "github.com/localstack/lstk/internal/awsconfig" "github.com/localstack/lstk/internal/output" - "github.com/localstack/lstk/internal/terminal" ) -// stopOnWriteWriter wraps a writer and stops the spinner on first write -type stopOnWriteWriter struct { - w io.Writer - spinner *terminal.Spinner - once sync.Once -} - -func (s *stopOnWriteWriter) Write(p []byte) (int, error) { - s.once.Do(func() { - s.spinner.Stop() - }) - return s.w.Write(p) -} - -func Exec(ctx context.Context, endpointURL string, args []string) error { +func Exec(ctx context.Context, endpointURL string, useProfile bool, stdout, stderr io.Writer, args []string) error { awsBin, err := exec.LookPath("aws") if err != nil { return fmt.Errorf("aws CLI not found in PATH — install it from https://aws.amazon.com/cli/") } - // Use the localstack AWS profile when it exists (written by lstk start/setup aws), - // so credentials and region come from ~/.aws rather than being re-injected here. - // Fall back to env var injection when the profile hasn't been set up yet. - profileExists, _ := awsconfig.ProfileExists() - capacity := len(args) + 2 - if profileExists { + if useProfile { capacity += 2 } cmdArgs := make([]string, 0, capacity) cmdArgs = append(cmdArgs, "--endpoint-url", endpointURL) - if profileExists { + if useProfile { cmdArgs = append(cmdArgs, "--profile", awsconfig.ProfileName) } cmdArgs = append(cmdArgs, args...) cmd := exec.CommandContext(ctx, awsBin, cmdArgs...) cmd.Stdin = os.Stdin - if !profileExists { + cmd.Stdout = stdout + cmd.Stderr = stderr + if !useProfile { cmd.Env = BuildEnv(os.Environ()) } - var s *terminal.Spinner - if terminal.IsTerminal(os.Stderr) { - s = terminal.NewSpinner(os.Stderr, "Loading...") - s.Start() - - stopWriter := &stopOnWriteWriter{w: os.Stdout, spinner: s} - cmd.Stdout = stopWriter - cmd.Stderr = &stopOnWriteWriter{w: os.Stderr, spinner: s} - } else { - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - } - - err = cmd.Run() - - if s != nil { - s.Stop() - } - - if err == nil { - return nil - } - - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return output.NewSilentError(err) + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return output.NewSilentError(err) + } + return err } - return err + return nil } func BuildEnv(base []string) []string { diff --git a/internal/terminal/spinner.go b/internal/terminal/spinner.go index a4c913ab..76417b28 100644 --- a/internal/terminal/spinner.go +++ b/internal/terminal/spinner.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "os" - "strings" "sync" "time" @@ -47,7 +46,7 @@ func (s *Spinner) Start() { i := 0 for { s.mu.Lock() - _, _ = fmt.Fprintf(s.out, "\r%s%s%s %s%s%s", spinnerColor, dotFrames[i%len(dotFrames)], resetColor, secondaryColor, s.label, resetColor) + _, _ = fmt.Fprintf(s.out, "\r\033[2K%s%s%s %s%s%s", spinnerColor, dotFrames[i%len(dotFrames)], resetColor, secondaryColor, s.label, resetColor) s.mu.Unlock() select { @@ -71,8 +70,7 @@ func (s *Spinner) Stop() { func (s *Spinner) clearLine() { s.mu.Lock() defer s.mu.Unlock() - width := len(s.label) + 10 - _, _ = fmt.Fprintf(s.out, "\r%s\r", strings.Repeat(" ", width)) + _, _ = fmt.Fprint(s.out, "\r\033[2K") } // IsTerminal reports whether w is a terminal. @@ -83,3 +81,17 @@ func IsTerminal(w io.Writer) bool { } return term.IsTerminal(int(f.Fd())) } + +// StopOnWriteWriter wraps a writer and stops the spinner on the first write. +type StopOnWriteWriter struct { + W io.Writer + Spinner *Spinner + once sync.Once +} + +func (s *StopOnWriteWriter) Write(p []byte) (int, error) { + s.once.Do(func() { + s.Spinner.Stop() + }) + return s.W.Write(p) +} diff --git a/test/integration/aws_cmd_test.go b/test/integration/aws_cmd_test.go index 275f6f2b..ccb41a26 100644 --- a/test/integration/aws_cmd_test.go +++ b/test/integration/aws_cmd_test.go @@ -2,7 +2,6 @@ package integration_test import ( "fmt" - "os" "path/filepath" "runtime" "testing" @@ -175,3 +174,24 @@ func TestAWSCommandPropagatesExitCode(t *testing.T) { assert.Contains(t, stderr, "simulated failure") requireExitCode(t, 42, err) } + +func TestAWSCommandHintsSetupCommandWhenProfileMissing(t *testing.T) { + fakeDir := writeFakeAWS(t) + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + require.NoError(t, err) + assert.Contains(t, stdout, "lstk setup aws") +} + +func TestAWSCommandSuppressesHintWhenProfileExists(t *testing.T) { + fakeDir := writeFakeAWS(t) + homeDir := t.TempDir() + writeAWSProfile(t, homeDir) + + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, homeDir) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + require.NoError(t, err) + assert.NotContains(t, stdout, "lstk setup aws") +} From ef6c2779821e2cc4e0c2c1beba354980e8160eae Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Thu, 23 Apr 2026 10:23:24 +0200 Subject: [PATCH 6/7] Check emulator and Docker status before running aws CLI wrapper - Add IsHealthy check with EmitUnhealthyError for Docker not running - Add IsRunning check with ErrorEvent for emulator not running - Remove resolveAWSPort in favour of config.Get() after initConfig - Add integration tests for both failure cases --- cmd/aws.go | 66 ++++++++++++------ test/integration/aws_cmd_test.go | 111 ++++++++++++++++++++++++++++--- 2 files changed, 148 insertions(+), 29 deletions(-) diff --git a/cmd/aws.go b/cmd/aws.go index 4ffe5343..e9b12f6f 100644 --- a/cmd/aws.go +++ b/cmd/aws.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "io" "os" @@ -10,6 +11,7 @@ import ( "github.com/localstack/lstk/internal/endpoint" "github.com/localstack/lstk/internal/env" "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" "github.com/localstack/lstk/internal/telemetry" "github.com/localstack/lstk/internal/terminal" "github.com/spf13/cobra" @@ -25,18 +27,60 @@ Equivalent to running: aws --endpoint-url http://localhost:4566 with AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_DEFAULT_REGION set automatically. +Run 'lstk setup aws' to configure the LocalStack AWS profile for use with CLI and SDKs. + Examples: lstk aws s3 ls lstk aws sqs list-queues lstk aws s3 mb s3://my-bucket`, DisableFlagParsing: true, + PreRunE: initConfig, RunE: commandWithTelemetry("aws", tel, func(cmd *cobra.Command, args []string) error { - port := resolveAWSPort() - host, _ := endpoint.ResolveHost(port, cfg.LocalStackHost) + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) + if err != nil { + return err + } + + appCfg, err := config.Get() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + awsContainer := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultAWSPort} + for _, c := range appCfg.Containers { + if c.Type == config.EmulatorAWS { + awsContainer = c + break + } + } + + sink := output.NewPlainSink(os.Stdout) + + if err := rt.IsHealthy(cmd.Context()); err != nil { + rt.EmitUnhealthyError(sink, err) + return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) + } + + running, err := rt.IsRunning(cmd.Context(), awsContainer.Name()) + if err != nil { + return fmt.Errorf("checking emulator status: %w", err) + } + if !running { + output.EmitError(sink, output.ErrorEvent{ + Title: fmt.Sprintf("%s is not running", awsContainer.DisplayName()), + Actions: []output.ErrorAction{ + {Label: "Start LocalStack:", Value: "lstk"}, + {Label: "See help:", Value: "lstk -h"}, + }, + }) + return output.NewSilentError(fmt.Errorf("%s is not running", awsContainer.Name())) + } + + host, _ := endpoint.ResolveHost(awsContainer.Port, cfg.LocalStackHost) profileExists, _ := awsconfig.ProfileExists() if !profileExists { - output.EmitNote(output.NewPlainSink(os.Stdout), "No AWS profile found, run 'lstk setup aws'") + output.EmitNote(sink, "No AWS profile found, run 'lstk setup aws'") } stdout, stderr := io.Writer(os.Stdout), io.Writer(os.Stderr) @@ -52,19 +96,3 @@ Examples: }), } } - -func resolveAWSPort() string { - if err := config.Init(); err != nil { - return config.DefaultAWSPort - } - appCfg, err := config.Get() - if err != nil { - return config.DefaultAWSPort - } - for _, c := range appCfg.Containers { - if c.Type == config.EmulatorAWS { - return c.Port - } - } - return config.DefaultAWSPort -} diff --git a/test/integration/aws_cmd_test.go b/test/integration/aws_cmd_test.go index ccb41a26..bcfdcfd9 100644 --- a/test/integration/aws_cmd_test.go +++ b/test/integration/aws_cmd_test.go @@ -2,6 +2,7 @@ package integration_test import ( "fmt" + "os" "path/filepath" "runtime" "testing" @@ -46,11 +47,17 @@ func writeAWSProfile(t *testing.T, homeDir string) { } func TestAWSCommandInjectsEndpointAndArgs(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startTestContainer(t, ctx) + fakeDir := writeFakeAWS(t) // Use a fresh HOME so a real localstack profile doesn't affect the args output. e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) - stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), e, "aws", "s3", "ls") require.NoError(t, err, "lstk aws failed: %s", stderr) assert.Contains(t, stdout, "ENDPOINT:http://") @@ -58,11 +65,17 @@ func TestAWSCommandInjectsEndpointAndArgs(t *testing.T) { } func TestAWSCommandInjectsCredentials(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startTestContainer(t, ctx) + fakeDir := writeFakeAWS(t) // Use a fresh HOME so no localstack profile exists; credentials are injected via env vars. e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) - stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "sts", "get-caller-identity") + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), e, "aws", "sts", "get-caller-identity") require.NoError(t, err, "lstk aws failed: %s", stderr) assert.Contains(t, stdout, "AWS_ACCESS_KEY_ID=test") @@ -71,6 +84,12 @@ func TestAWSCommandInjectsCredentials(t *testing.T) { } func TestAWSCommandRespectsExistingCredentials(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startTestContainer(t, ctx) + fakeDir := writeFakeAWS(t) // Use a fresh HOME so no localstack profile exists; the user-provided env vars are preserved. e := env.With(env.DisableEvents, "1"). @@ -80,7 +99,7 @@ func TestAWSCommandRespectsExistingCredentials(t *testing.T) { With("AWS_SECRET_ACCESS_KEY", "custom-secret"). With("AWS_DEFAULT_REGION", "eu-west-1") - stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), e, "aws", "s3", "ls") require.NoError(t, err, "lstk aws failed: %s", stderr) assert.Contains(t, stdout, "AWS_ACCESS_KEY_ID=custom-key") @@ -89,13 +108,19 @@ func TestAWSCommandRespectsExistingCredentials(t *testing.T) { } func TestAWSCommandUsesProfileWhenAvailable(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startTestContainer(t, ctx) + fakeDir := writeFakeAWS(t) homeDir := t.TempDir() writeAWSProfile(t, homeDir) e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, homeDir) - stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), e, "aws", "s3", "ls") require.NoError(t, err, "lstk aws failed: %s", stderr) assert.Contains(t, stdout, "--profile localstack") @@ -104,27 +129,45 @@ func TestAWSCommandUsesProfileWhenAvailable(t *testing.T) { } func TestAWSCommandFailsWhenAWSCLINotInstalled(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startTestContainer(t, ctx) + e := env.With(env.DisableEvents, "1").With("PATH", t.TempDir()) - _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + _, stderr, err := runLstk(t, ctx, t.TempDir(), e, "aws", "s3", "ls") require.Error(t, err) assert.Contains(t, stderr, "aws CLI not found") } func TestAWSCommandUsesDefaultPortWithoutConfig(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startTestContainer(t, ctx) + fakeDir := writeFakeAWS(t) workDir := t.TempDir() e := env.With(env.DisableEvents, "1"). With("PATH", fakeDir). With(env.Home, t.TempDir()) // isolate from any real config file - stdout, stderr, err := runLstk(t, testContext(t), workDir, e, "aws", "s3", "ls") + stdout, stderr, err := runLstk(t, ctx, workDir, e, "aws", "s3", "ls") require.NoError(t, err, "lstk aws failed: %s", stderr) assert.Contains(t, stdout, ":4566") } func TestAWSCommandUsesPortFromConfig(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startTestContainer(t, ctx) + fakeDir := writeFakeAWS(t) workDir := t.TempDir() @@ -140,7 +183,7 @@ port = "4599" e := env.With(env.DisableEvents, "1").With("PATH", fakeDir) - stdout, stderr, err := runLstk(t, testContext(t), workDir, e, "aws", "s3", "ls") + stdout, stderr, err := runLstk(t, ctx, workDir, e, "aws", "s3", "ls") require.NoError(t, err, "lstk aws failed: %s", stderr) assert.Contains(t, stdout, ":4599") @@ -166,32 +209,80 @@ exit %d } func TestAWSCommandPropagatesExitCode(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startTestContainer(t, ctx) + fakeDir := writeFakeAWSFailing(t, 42) e := env.With(env.DisableEvents, "1").With("PATH", fakeDir) - _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + _, stderr, err := runLstk(t, ctx, t.TempDir(), e, "aws", "s3", "ls") require.Error(t, err, "lstk aws should fail when aws command fails") assert.Contains(t, stderr, "simulated failure") requireExitCode(t, 42, err) } +func TestAWSCommandFailsWhenDockerNotRunning(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows Docker error tested separately via windowsDockerErrorEnv") + } + + fakeDir := writeFakeAWS(t) + e := env.With(env.DisableEvents, "1"). + With("PATH", fakeDir). + With(env.Key("DOCKER_HOST"), "tcp://localhost:1") + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + require.Error(t, err) + assert.Contains(t, stdout, "Docker is not available") +} + +func TestAWSCommandFailsWhenEmulatorNotRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + fakeDir := writeFakeAWS(t) + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + require.Error(t, err) + assert.Contains(t, stdout, "is not running") + assert.Contains(t, stdout, "Start LocalStack:") + assert.Contains(t, stdout, "lstk") +} + func TestAWSCommandHintsSetupCommandWhenProfileMissing(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startTestContainer(t, ctx) + fakeDir := writeFakeAWS(t) e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) - stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + stdout, _, err := runLstk(t, ctx, t.TempDir(), e, "aws", "s3", "ls") require.NoError(t, err) assert.Contains(t, stdout, "lstk setup aws") } func TestAWSCommandSuppressesHintWhenProfileExists(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startTestContainer(t, ctx) + fakeDir := writeFakeAWS(t) homeDir := t.TempDir() writeAWSProfile(t, homeDir) e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, homeDir) - stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + stdout, _, err := runLstk(t, ctx, t.TempDir(), e, "aws", "s3", "ls") require.NoError(t, err) assert.NotContains(t, stdout, "lstk setup aws") } From d0ad100ca845e4fe3af29b29766f32e9f86cb381 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Thu, 23 Apr 2026 11:03:07 +0200 Subject: [PATCH 7/7] Add telemetry assertions to aws command integration tests --- test/integration/aws_cmd_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/integration/aws_cmd_test.go b/test/integration/aws_cmd_test.go index bcfdcfd9..e2492c3c 100644 --- a/test/integration/aws_cmd_test.go +++ b/test/integration/aws_cmd_test.go @@ -54,14 +54,17 @@ func TestAWSCommandInjectsEndpointAndArgs(t *testing.T) { startTestContainer(t, ctx) fakeDir := writeFakeAWS(t) + analyticsSrv, events := mockAnalyticsServer(t) // Use a fresh HOME so a real localstack profile doesn't affect the args output. - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + e := env.With("PATH", fakeDir).With(env.Home, t.TempDir()). + With(env.AnalyticsEndpoint, analyticsSrv.URL) stdout, stderr, err := runLstk(t, ctx, t.TempDir(), e, "aws", "s3", "ls") require.NoError(t, err, "lstk aws failed: %s", stderr) assert.Contains(t, stdout, "ENDPOINT:http://") assert.Contains(t, stdout, "ARGS:s3 ls") + assertCommandTelemetry(t, events, "aws", 0) } func TestAWSCommandInjectsCredentials(t *testing.T) { @@ -245,13 +248,16 @@ func TestAWSCommandFailsWhenEmulatorNotRunning(t *testing.T) { t.Cleanup(cleanup) fakeDir := writeFakeAWS(t) - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir) + analyticsSrv, events := mockAnalyticsServer(t) + e := env.With("PATH", fakeDir). + With(env.AnalyticsEndpoint, analyticsSrv.URL) stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") require.Error(t, err) assert.Contains(t, stdout, "is not running") assert.Contains(t, stdout, "Start LocalStack:") assert.Contains(t, stdout, "lstk") + assertCommandTelemetry(t, events, "aws", 1) } func TestAWSCommandHintsSetupCommandWhenProfileMissing(t *testing.T) {