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
98 changes: 98 additions & 0 deletions cmd/aws.go
Original file line number Diff line number Diff line change
@@ -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 <args>
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)
}),
}
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions internal/awscli/exec.go
Original file line number Diff line number Diff line change
@@ -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)
}
53 changes: 53 additions & 0 deletions internal/awscli/exec_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
6 changes: 3 additions & 3 deletions internal/awsconfig/awsconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
)

const (
profileName = "localstack"
ProfileName = "localstack"
configSectionName = "profile localstack" // ~/.aws/config uses "profile <name>" as section header
credsSectionName = "localstack" // ~/.aws/credentials uses just the profile name
// TODO: make region configurable (e.g. from container env or lstk config)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/awsconfig/awsconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func setDefaults() {
{
"type": "aws",
"tag": "latest",
"port": "4566",
"port": DefaultAWSPort,
},
})
viper.SetDefault("update_prompt", true)
Expand Down
3 changes: 2 additions & 1 deletion internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const (
EmulatorSnowflake EmulatorType = "snowflake"
EmulatorAzure EmulatorType = "azure"

DefaultAWSPort = "4566"
dockerRegistry = "localstack"
)

Expand Down Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions internal/output/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ func IsSilent(err error) bool {
var silent *SilentError
return errors.As(err, &silent)
}

97 changes: 97 additions & 0 deletions internal/terminal/spinner.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading