diff --git a/cmd/aws.go b/cmd/aws.go new file mode 100644 index 00000000..e9b12f6f --- /dev/null +++ b/cmd/aws.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "fmt" + "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/runtime" + "github.com/localstack/lstk/internal/telemetry" + "github.com/localstack/lstk/internal/terminal" + "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. + +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 { + 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(sink, "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/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..1768ab3c --- /dev/null +++ b/internal/awscli/exec.go @@ -0,0 +1,70 @@ +package awscli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/localstack/lstk/internal/awsconfig" + "github.com/localstack/lstk/internal/output" +) + +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/") + } + + capacity := len(args) + 2 + if useProfile { + capacity += 2 + } + cmdArgs := make([]string, 0, capacity) + cmdArgs = append(cmdArgs, "--endpoint-url", endpointURL) + if useProfile { + cmdArgs = append(cmdArgs, "--profile", awsconfig.ProfileName) + } + cmdArgs = append(cmdArgs, args...) + + cmd := exec.CommandContext(ctx, awsBin, cmdArgs...) + cmd.Stdin = os.Stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + if !useProfile { + cmd.Env = BuildEnv(os.Environ()) + } + + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return output.NewSilentError(err) + } + return err + } + return nil +} + +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/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/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 951b517b..896cc4a5 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" ) @@ -117,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) } diff --git a/internal/output/errors.go b/internal/output/errors.go index 27c831a0..d88f1f63 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -26,3 +26,4 @@ func IsSilent(err error) bool { var silent *SilentError return errors.As(err, &silent) } + diff --git a/internal/terminal/spinner.go b/internal/terminal/spinner.go new file mode 100644 index 00000000..76417b28 --- /dev/null +++ b/internal/terminal/spinner.go @@ -0,0 +1,97 @@ +package terminal + +import ( + "fmt" + "io" + "os" + "sync" + "time" + + "golang.org/x/term" +) + +var dotFrames = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} + +// 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 + stopOnce sync.Once +} + +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\033[2K%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() { + s.stopOnce.Do(func() { + close(s.stop) + }) + <-s.done +} + +func (s *Spinner) clearLine() { + s.mu.Lock() + defer s.mu.Unlock() + _, _ = fmt.Fprint(s.out, "\r\033[2K") +} + +// IsTerminal reports whether w is a terminal. +func IsTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + 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/main.go b/main.go index a2d1285f..655da7b0 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,9 @@ package main import ( "context" + "errors" "os" + "os/exec" "os/signal" "syscall" @@ -14,6 +16,10 @@ func main() { defer cancel() if err := cmd.Execute(ctx); err != nil { + 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 new file mode 100644 index 00000000..e2492c3c --- /dev/null +++ b/test/integration/aws_cmd_test.go @@ -0,0 +1,294 @@ +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 +} + +// 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) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(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("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) { + 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, 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") + assert.Contains(t, stdout, "AWS_SECRET_ACCESS_KEY=test") + assert.Contains(t, stdout, "AWS_DEFAULT_REGION=us-east-1") +} + +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"). + 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") + + 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") + assert.Contains(t, stdout, "AWS_SECRET_ACCESS_KEY=custom-secret") + assert.Contains(t, stdout, "AWS_DEFAULT_REGION=eu-west-1") +} + +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, ctx, 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) { + 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, 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, 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() + + 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, ctx, 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) { + 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, 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) + 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) { + 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, 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, ctx, t.TempDir(), e, "aws", "s3", "ls") + require.NoError(t, err) + assert.NotContains(t, stdout, "lstk setup aws") +}