diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fae8a3af..5b4b347f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,18 @@ jobs: if: matrix.os == 'ubuntu-latest' run: npm install -g aws-cdk + # Install the AWS SAM CLI so the `lstk sam` end-to-end tests + # (sam_e2e_test.go) run on the Docker-capable Linux shards. They skip + # automatically wherever sam is absent (macOS/Windows). The ubuntu-latest + # image currently ships sam, but we install it explicitly so the tests + # don't silently lose coverage if a future image drops it. lstk requires + # SAM >= 1.95.0; the latest release satisfies that. + - name: Install AWS SAM CLI + if: matrix.os == 'ubuntu-latest' + uses: aws-actions/setup-sam@v2 + with: + use-installer: true + - name: Run integration tests run: make test-integration env: diff --git a/CLAUDE.md b/CLAUDE.md index 5d31dda2..66b838bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Note: Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for - `ui/` - Bubble Tea views for interactive output - `update/` - Self-update logic: version check via GitHub API, binary/Homebrew/npm update paths, archive extraction - `log/` - Internal diagnostic logging (not for user-facing output — use `output/` for that) - - `iac/` - Wrappers for third-party infrastructure as code tools, such as Terraform and CDK. + - `iac/` - Wrappers for third-party infrastructure as code tools (Terraform, AWS CDK, AWS SAM CLI). # Logging @@ -82,6 +82,11 @@ Environment variables: - `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set) - `LSTK_OTEL=1` - Enables OpenTelemetry trace export (disabled by default); when enabled, standard `OTEL_EXPORTER_OTLP_*` env vars are respected by the SDK. Requires an OTLP-compatible backend to receive and visualize telemetry — for local development, `make otel` starts one (UI at http://localhost:16686). +# Infrastructure as Code Commands + +lstk proxies third-party IaC tools at the AWS emulator so they run against LocalStack with no `*local` wrapper installed. Each command forwards its args to the real tool after configuring the environment; domain logic lives under `internal/iac//cli/`, wiring in `cmd/.go`, with shared command-boundary helpers in `cmd/iac.go`. Siblings: `lstk terraform` (alias `tf`), `lstk cdk`, `lstk sam`. + + # Snapshots `lstk snapshot` captures and restores the running emulator's state (AWS emulator only). Domain logic lives in `internal/snapshot/`; `cmd/snapshot.go` is wiring + output-mode selection. diff --git a/cmd/root.go b/cmd/root.go index 15da84c5..069d2745 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -85,6 +85,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newAWSCmd(cfg), newTerraformCmd(cfg, logger), newCDKCmd(cfg, logger), + newSamCmd(cfg, logger), newSnapshotCmd(cfg, tel, logger), newAzCmd(cfg), newResetCmd(cfg), diff --git a/cmd/sam.go b/cmd/sam.go new file mode 100644 index 00000000..18f204df --- /dev/null +++ b/cmd/sam.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/localstack/lstk/internal/endpoint" + "github.com/localstack/lstk/internal/env" + samcli "github.com/localstack/lstk/internal/iac/sam/cli" + "github.com/localstack/lstk/internal/log" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" + "github.com/spf13/cobra" +) + +func newSamCmd(cfg *env.Env, logger log.Logger) *cobra.Command { + // DisableFlagParsing means Cobra won't strip lstk's own flags; PreRunE does + // that and stashes the remaining args here for RunE to forward to sam. + var passthrough []string + return &cobra.Command{ + Use: "sam [args...]", + Short: "Run the AWS SAM CLI against LocalStack", + Long: `Proxy AWS SAM CLI commands to the running LocalStack emulator. + +Requires the AWS SAM CLI version 1.95.0 or newer on your PATH (older versions ignore AWS_ENDPOINT_URL and would target real AWS). + +lstk-specific flags (must appear before the sam action): + --region Deployment region (default us-east-1) + --account Target AWS account id, 12 digits (default 000000000000) + +Supported environment variables: + AWS_ENDPOINT_URL Override the auto-resolved LocalStack endpoint + AWS_ENDPOINT_URL_S3 Override S3 endpoint + LSTK_SAM_CMD SAM binary to invoke (default sam) + AWS_REGION Fallback for --region + AWS_ACCESS_KEY_ID Fallback for --account + +Known limitations versus samlocal: image/container-based Lambda (ECR) deploys and nested CloudFormation stacks are not supported; use samlocal for those workflows. + +Examples: + lstk sam build + lstk sam --region us-west-2 deploy + lstk sam validate`, + DisableFlagParsing: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + var gf globalFlags + passthrough, gf = stripGlobalFlags(args) + if gf.nonInteractive { + cfg.NonInteractive = true + } + if gf.configPath != "" { + if err := cmd.Flags().Set("config", gf.configPath); err != nil { + return err + } + } + return initConfig(nil)(cmd, args) + }, + RunE: func(cmd *cobra.Command, _ []string) error { + sink := output.NewPlainSink(os.Stdout) + + if err := rejectPreSubcommandFlags(cmd.CalledAs()); err != nil { + return emitValidationError(sink, err) + } + + samArgs, regionFlag, accountFlag, _, err := stripLeadingIaCFlags(passthrough, false) + if err != nil { + return emitValidationError(sink, err) + } + + region := resolveRegion(regionFlag) + account, err := resolveAccount(accountFlag) + if err != nil { + return emitValidationError(sink, err) + } + + awsContainer := resolveAWSContainer() + + // Offline subcommands never contact AWS, so they run without a + // running emulator. We still resolve the endpoint (DNS only) and + // inject it, so any incidental API call routes to LocalStack. + if samcli.IsOffline(samArgs) { + host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost) + return samcli.Run(cmd.Context(), "http://"+host, account, region, sink, logger, samArgs) + } + + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) + if err != nil { + return err + } + + if err := rt.IsHealthy(cmd.Context()); err != nil { + rt.EmitUnhealthyError(sink, err) + return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) + } + + if err := requireRunningAWSEmulator(cmd.Context(), rt, sink, awsContainer, "sam"); err != nil { + return err + } + + host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost) + + return samcli.Run(cmd.Context(), "http://"+host, account, region, sink, logger, samArgs) + }, + } +} diff --git a/internal/iac/sam/cli/defaults.go b/internal/iac/sam/cli/defaults.go new file mode 100644 index 00000000..fdf1a5c5 --- /dev/null +++ b/internal/iac/sam/cli/defaults.go @@ -0,0 +1,60 @@ +package cli + +// minSAMVersion is the lowest AWS SAM CLI version lstk supports. From this +// version SAM honors AWS_ENDPOINT_URL (via its bundled botocore), which is how +// lstk points SAM at LocalStack. Older versions ignore it and would silently +// target real AWS, so lstk refuses to run against them. See the sam-proxy design +// doc for the full rationale. +const ( + minSAMMajor = 1 + minSAMMinor = 95 + minSAMPatch = 0 +) + +// minSAMVersionString is the human-facing form used in error messages. +const minSAMVersionString = "1.95.0" + +// offlineCommands are the SAM subcommands that never contact AWS APIs and so do +// not require a running emulator. Everything else (deploy, sync, package, +// delete, logs, traces, list, remote, publish, …) is treated as AWS-contacting +// and is gated on a running AWS emulator. +var offlineCommands = map[string]bool{ + "docs": true, + "init": true, + "build": true, + "validate": true, + "local": true, + "pipeline": true, +} + +// valueFlags are SAM global options that consume the following token as their +// value, so the subcommand scan must skip both the flag and its value. SAM's +// global options before the subcommand (`--debug`, `--beta-features`, `--info`, +// `--version`, `-h`) are all boolean, so this is currently empty; it is kept for +// structural parity with the cdk proxy and to make adding one a one-line change. +var valueFlags = map[string]bool{} + +// IsOffline reports whether the SAM invocation described by args is one of the +// offline subcommands that need no running emulator. +func IsOffline(args []string) bool { + return offlineCommands[subcommand(args)] +} + +// subcommand returns the first non-flag token in args that is not consumed as a +// global option's value, or "" if there is none. +func subcommand(args []string) string { + for i := 0; i < len(args); i++ { + a := args[i] + if len(a) == 0 { + continue + } + if a[0] == '-' { + if valueFlags[a] && i+1 < len(args) { + i++ // skip this flag's value + } + continue + } + return a + } + return "" +} diff --git a/internal/iac/sam/cli/defaults_test.go b/internal/iac/sam/cli/defaults_test.go new file mode 100644 index 00000000..f5a61215 --- /dev/null +++ b/internal/iac/sam/cli/defaults_test.go @@ -0,0 +1,54 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsOffline(t *testing.T) { + offline := [][]string{ + {"docs"}, + {"init", "--name", "demo"}, + {"build"}, + {"validate", "--lint"}, + {"local", "generate-event", "s3", "put"}, + {"local", "invoke"}, + {"pipeline", "init"}, + } + for _, args := range offline { + assert.Truef(t, IsOffline(args), "expected %v offline", args) + } + + awsContacting := [][]string{ + {"deploy", "--stack-name", "demo"}, + {"sync"}, + {"package"}, + {"delete"}, + {"logs"}, + {"traces"}, + {"list", "resources", "--stack-name", "demo"}, + {"remote", "invoke"}, + {"publish"}, + {}, // no subcommand → not offline (gate on emulator) + } + for _, args := range awsContacting { + assert.Falsef(t, IsOffline(args), "expected %v not offline", args) + } +} + +// The classifier keys on the first non-flag (top-level) token; leading flags are +// skipped. +func TestSubcommandSkipsLeadingFlags(t *testing.T) { + assert.Equal(t, "deploy", subcommand([]string{"--debug", "deploy", "--stack-name", "demo"})) + assert.Equal(t, "build", subcommand([]string{"--beta-features", "build"})) + assert.Equal(t, "", subcommand([]string{"--debug"})) + assert.Equal(t, "", subcommand(nil)) +} + +// Two-level commands resolve to their top-level token: `local generate-event` is +// offline (under `local`), `list resources` is AWS-contacting (under `list`). +func TestTwoLevelCommandsKeyOnTopLevelToken(t *testing.T) { + assert.True(t, IsOffline([]string{"local", "generate-event"})) + assert.False(t, IsOffline([]string{"list", "resources"})) +} diff --git a/internal/iac/sam/cli/env.go b/internal/iac/sam/cli/env.go new file mode 100644 index 00000000..d5966e27 --- /dev/null +++ b/internal/iac/sam/cli/env.go @@ -0,0 +1,90 @@ +package cli + +import ( + "os" + "strings" +) + +// Environment variables this package reads. They are process environment, not +// lstk config, so reading them here (the domain boundary for sam) is consistent +// with the rule that domain code must not call config.Get(). + +// samCmd returns the SAM CLI binary name to invoke, honoring LSTK_SAM_CMD and +// defaulting to "sam". +func samCmd() string { + if v := os.Getenv("LSTK_SAM_CMD"); v != "" { + return v + } + return "sam" +} + +// endpointURLOverride returns AWS_ENDPOINT_URL, which takes precedence over the +// auto-resolved LocalStack endpoint. +func endpointURLOverride() string { + return os.Getenv("AWS_ENDPOINT_URL") +} + +// strippedKeys are ambient AWS configuration variables removed from the SAM +// subprocess environment. A named profile, default profile, or stale session +// token could otherwise resolve real credentials/region and silently redirect a +// deploy at real AWS. (samlocal itself does not strip these — it relies on its +// in-process boto3 endpoint patch — but lstk has no such patch, so it isolates +// the environment instead.) +var strippedKeys = map[string]bool{ + "AWS_PROFILE": true, + "AWS_DEFAULT_PROFILE": true, + "AWS_SESSION_TOKEN": true, +} + +// BuildEnv returns the environment for the sam subprocess: base with ambient AWS +// configuration stripped and the LocalStack-pointing values set (overriding any +// pre-existing entries). Empty endpoint values are not set, so they never +// clobber a meaningful inherited value with "". +// +// account is written to AWS_ACCESS_KEY_ID: SAM passes it through and LocalStack +// derives the account id from it (the Terraform model). The region is written to +// both AWS_REGION and AWS_DEFAULT_REGION because SAM reads AWS_DEFAULT_REGION +// (not AWS_REGION); setting both is harmless and overwrites any stale ambient +// AWS_REGION. +// +// Unlike the CDK proxy, no S3-specific endpoint is set: SAM's botocore +// auto-selects path-style addressing against a localhost/IP endpoint, so plain +// AWS_ENDPOINT_URL suffices. AWS_ENDPOINT_URL_S3 is deliberately neither set nor +// stripped — a user who needs to override S3 addressing for an exotic case can +// set it and botocore honors it. +func BuildEnv(base []string, endpointURL, account, region string) []string { + // Ordered so the produced environment is deterministic. Empty-valued + // entries are skipped below. + managed := []struct{ key, value string }{ + {"AWS_ENDPOINT_URL", endpointURL}, + {"AWS_ACCESS_KEY_ID", account}, + {"AWS_SECRET_ACCESS_KEY", "test"}, + {"AWS_REGION", region}, + {"AWS_DEFAULT_REGION", region}, + } + + managedKeys := make(map[string]bool, len(managed)) + for _, m := range managed { + managedKeys[m.key] = true + } + + env := make([]string, 0, len(base)+len(managed)) + for _, e := range base { + key, _, ok := strings.Cut(e, "=") + if !ok { + env = append(env, e) + continue + } + if strippedKeys[key] || managedKeys[key] { + continue + } + env = append(env, e) + } + for _, m := range managed { + if m.value == "" { + continue + } + env = append(env, m.key+"="+m.value) + } + return env +} diff --git a/internal/iac/sam/cli/env_test.go b/internal/iac/sam/cli/env_test.go new file mode 100644 index 00000000..4be325c7 --- /dev/null +++ b/internal/iac/sam/cli/env_test.go @@ -0,0 +1,96 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// envMap parses an env slice ("K=V") into a map for assertions. +func envMap(env []string) map[string]string { + m := make(map[string]string, len(env)) + for _, e := range env { + k, v, ok := strings.Cut(e, "=") + if ok { + m[k] = v + } + } + return m +} + +func TestBuildEnvSetsLocalStackValues(t *testing.T) { + env := envMap(BuildEnv(nil, "http://localhost.localstack.cloud:4566", "111111111111", "eu-west-1")) + + assert.Equal(t, "http://localhost.localstack.cloud:4566", env["AWS_ENDPOINT_URL"]) + assert.Equal(t, "111111111111", env["AWS_ACCESS_KEY_ID"]) + assert.Equal(t, "test", env["AWS_SECRET_ACCESS_KEY"]) + // SAM reads AWS_DEFAULT_REGION, not AWS_REGION; lstk sets both. + assert.Equal(t, "eu-west-1", env["AWS_REGION"]) + assert.Equal(t, "eu-west-1", env["AWS_DEFAULT_REGION"]) + + // lstk leaves the user's SAM telemetry preference untouched. + _, hasTelemetry := env["SAM_CLI_TELEMETRY"] + assert.False(t, hasTelemetry, "SAM_CLI_TELEMETRY must not be set by lstk") + + // Unlike cdk, lstk never sets an S3-specific endpoint. + _, hasS3 := env["AWS_ENDPOINT_URL_S3"] + assert.False(t, hasS3, "AWS_ENDPOINT_URL_S3 must not be set by lstk") +} + +// The resolved account is written to AWS_ACCESS_KEY_ID, overriding any ambient +// value, so SAM (and LocalStack) use the account lstk decided on. +func TestBuildEnvWritesResolvedAccount(t *testing.T) { + base := []string{"AWS_ACCESS_KEY_ID=123456789012", "AWS_SECRET_ACCESS_KEY=somesecret"} + env := envMap(BuildEnv(base, "http://127.0.0.1:4566", "test", "us-east-1")) + + assert.Equal(t, "test", env["AWS_ACCESS_KEY_ID"]) + assert.Equal(t, "test", env["AWS_SECRET_ACCESS_KEY"]) +} + +func TestBuildEnvStripsAmbientAWSConfig(t *testing.T) { + base := []string{ + "AWS_PROFILE=my-real-profile", + "AWS_DEFAULT_PROFILE=other", + "AWS_SESSION_TOKEN=realtoken", + "AWS_ACCESS_KEY_ID=AKIAREALKEY", + "AWS_SECRET_ACCESS_KEY=realsecret", + "PATH=/usr/bin", + "HOME=/home/user", + } + env := envMap(BuildEnv(base, "http://127.0.0.1:4566", "test", "us-east-1")) + + _, hasProfile := env["AWS_PROFILE"] + _, hasDefaultProfile := env["AWS_DEFAULT_PROFILE"] + _, hasSessionToken := env["AWS_SESSION_TOKEN"] + assert.False(t, hasProfile, "AWS_PROFILE must be stripped") + assert.False(t, hasDefaultProfile, "AWS_DEFAULT_PROFILE must be stripped") + assert.False(t, hasSessionToken, "AWS_SESSION_TOKEN must be stripped") + + // Real creds in base are overridden with the resolved values, not preserved. + assert.Equal(t, "test", env["AWS_ACCESS_KEY_ID"]) + assert.Equal(t, "test", env["AWS_SECRET_ACCESS_KEY"]) + + // Unrelated entries are preserved. + assert.Equal(t, "/usr/bin", env["PATH"]) + assert.Equal(t, "/home/user", env["HOME"]) +} + +// A user-set AWS_ENDPOINT_URL_S3 is neither set nor stripped by lstk; it passes +// through untouched as an escape hatch for exotic S3 addressing cases. +func TestBuildEnvPassesThroughUserS3Endpoint(t *testing.T) { + base := []string{"AWS_ENDPOINT_URL_S3=http://s3.example.test:4566"} + env := envMap(BuildEnv(base, "http://127.0.0.1:4566", "test", "us-east-1")) + + assert.Equal(t, "http://s3.example.test:4566", env["AWS_ENDPOINT_URL_S3"]) +} + +func TestBuildEnvSkipsEmptyEndpoint(t *testing.T) { + env := envMap(BuildEnv(nil, "", "test", "us-east-1")) + _, hasEndpoint := env["AWS_ENDPOINT_URL"] + assert.False(t, hasEndpoint, "empty AWS_ENDPOINT_URL must not be set") + // Region/creds are still set even with no endpoint. + assert.Equal(t, "us-east-1", env["AWS_REGION"]) + assert.Equal(t, "us-east-1", env["AWS_DEFAULT_REGION"]) + assert.Equal(t, "test", env["AWS_ACCESS_KEY_ID"]) +} diff --git a/internal/iac/sam/cli/exec.go b/internal/iac/sam/cli/exec.go new file mode 100644 index 00000000..38bde0a2 --- /dev/null +++ b/internal/iac/sam/cli/exec.go @@ -0,0 +1,84 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + + "github.com/localstack/lstk/internal/log" + "github.com/localstack/lstk/internal/output" +) + +const installDocsURL = "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html" + +// Run proxies an AWS SAM CLI invocation against LocalStack. It locates the sam +// binary, verifies its version, builds a subprocess environment that points SAM +// at the resolved LocalStack endpoint (and strips ambient AWS config that could +// redirect it at real AWS), then runs sam with stdio wired through. +// +// endpointURL is the resolved LocalStack endpoint (http://host:port). account is +// written to AWS_ACCESS_KEY_ID (LocalStack derives the account from it) and +// region to AWS_REGION/AWS_DEFAULT_REGION. SAM output is streamed unobstructed +// (no spinner); a non-zero exit is wrapped as a silent error so lstk does not +// reprint it. +func Run(ctx context.Context, endpointURL, account, region string, sink output.Sink, logger log.Logger, args []string) error { + ctx, span := otel.Tracer("github.com/localstack/lstk/internal/iac/sam/cli").Start(ctx, "sam cli") + defer span.End() + + samBin, err := exec.LookPath(samCmd()) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + sink.Emit(output.ErrorEvent{ + Title: fmt.Sprintf("%s not found in PATH", samCmd()), + Actions: []output.ErrorAction{{Label: "Install AWS SAM CLI:", Value: installDocsURL}}, + }) + return output.NewSilentError(fmt.Errorf("%s not found in PATH", samCmd())) + } + + if err := CheckVersion(ctx, samBin); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + sink.Emit(output.ErrorEvent{ + Title: err.Error(), + Actions: []output.ErrorAction{{Label: "Upgrade AWS SAM CLI:", Value: installDocsURL}}, + }) + return output.NewSilentError(err) + } + + effectiveEndpoint := endpointURL + if override := endpointURLOverride(); override != "" { + effectiveEndpoint = override + logger.Info("sam: using AWS_ENDPOINT_URL override %s", override) + } + + span.SetAttributes( + attribute.StringSlice("sam.args", args), + attribute.Bool("sam.offline", IsOffline(args)), + ) + + cmd := exec.CommandContext(ctx, samBin, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = BuildEnv(os.Environ(), effectiveEndpoint, account, region) + + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + span.SetAttributes(attribute.Int("sam.exit_code", exitErr.ExitCode())) + span.SetStatus(codes.Error, "sam exited non-zero") + return output.NewSilentError(err) + } + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err + } + return nil +} diff --git a/internal/iac/sam/cli/version.go b/internal/iac/sam/cli/version.go new file mode 100644 index 00000000..65e8a394 --- /dev/null +++ b/internal/iac/sam/cli/version.go @@ -0,0 +1,51 @@ +package cli + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "strconv" +) + +// versionRe matches the leading MAJOR.MINOR.PATCH of a `sam --version` line +// (e.g. "SAM CLI, version 1.151.0"). +var versionRe = regexp.MustCompile(`(\d+)\.(\d+)\.(\d+)`) + +// CheckVersion runs ` --version` and returns an error if the reported +// version is below the minimum lstk supports, or if the output cannot be parsed. +// lstk points SAM at LocalStack purely through environment variables, which only +// SAM >= minSAMVersionString honors; on an older (or unparseable) version lstk +// must refuse to run so it cannot silently target real AWS. +func CheckVersion(ctx context.Context, samBin string) error { + out, err := exec.CommandContext(ctx, samBin, "--version").Output() + if err != nil { + return fmt.Errorf("could not determine sam version (run `%s --version`): %w", samBin, err) + } + return checkVersionString(string(out)) +} + +func checkVersionString(out string) error { + m := versionRe.FindStringSubmatch(out) + if m == nil { + return fmt.Errorf("could not parse sam version from %q; lstk requires AWS SAM CLI %s or newer", out, minSAMVersionString) + } + major, _ := strconv.Atoi(m[1]) + minor, _ := strconv.Atoi(m[2]) + patch, _ := strconv.Atoi(m[3]) + if !atLeastMinVersion(major, minor, patch) { + return fmt.Errorf("AWS SAM CLI %d.%d.%d is too old; lstk requires %s or newer (it points SAM at LocalStack via AWS_ENDPOINT_URL, which older versions ignore)", major, minor, patch, minSAMVersionString) + } + return nil +} + +func atLeastMinVersion(major, minor, patch int) bool { + switch { + case major != minSAMMajor: + return major > minSAMMajor + case minor != minSAMMinor: + return minor > minSAMMinor + default: + return patch >= minSAMPatch + } +} diff --git a/internal/iac/sam/cli/version_test.go b/internal/iac/sam/cli/version_test.go new file mode 100644 index 00000000..844d03df --- /dev/null +++ b/internal/iac/sam/cli/version_test.go @@ -0,0 +1,32 @@ +package cli + +import "testing" + +func TestCheckVersionString(t *testing.T) { + tests := []struct { + name string + out string + wantErr bool + }{ + {"exact minimum", "SAM CLI, version 1.95.0", false}, + {"newer patch", "SAM CLI, version 1.95.5", false}, + {"newer minor", "SAM CLI, version 1.151.0", false}, + {"newer major", "SAM CLI, version 2.0.0", false}, + {"older patch boundary", "SAM CLI, version 1.94.99", true}, + {"older minor", "SAM CLI, version 1.90.0", true}, + {"older major", "SAM CLI, version 0.999.0", true}, + {"unparseable", "sam: command behaving oddly", true}, + {"empty", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkVersionString(tt.out) + if tt.wantErr && err == nil { + t.Fatalf("expected error for %q, got nil", tt.out) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error for %q: %v", tt.out, err) + } + }) + } +} diff --git a/openspec/changes/add-samlocal-command/.openspec.yaml b/openspec/changes/add-samlocal-command/.openspec.yaml new file mode 100644 index 00000000..c86b1d7f --- /dev/null +++ b/openspec/changes/add-samlocal-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-14 diff --git a/openspec/changes/add-samlocal-command/design.md b/openspec/changes/add-samlocal-command/design.md new file mode 100644 index 00000000..7439b2c5 --- /dev/null +++ b/openspec/changes/add-samlocal-command/design.md @@ -0,0 +1,155 @@ +## Context + +lstk already ships two first-party IaC proxy commands — `lstk terraform` and `lstk cdk` — that let users run the real tool against LocalStack without the third-party `tflocal`/`cdklocal` wrappers. The AWS SAM ecosystem has an equivalent third-party wrapper, `samlocal` (the `aws-sam-cli-local` pip package), which configures the SAM CLI to talk to LocalStack. This change adds a first-party `lstk sam` proxy so SAM users get the same experience. + +The two existing proxies sit at opposite ends of a spectrum: +- **terraform** generates a provider-override `.tf` file (because the AWS Terraform provider needs per-service endpoint blocks discovered from the provider schema). +- **cdk** is purely environment-variable driven: it sets `AWS_ENDPOINT_URL`/`AWS_ENDPOINT_URL_S3`, mock credentials, strips ambient AWS config, version-gates the binary, and execs the tool with stdio wired through. + +The SAM CLI runs its cloud operations (`deploy`, `package`, `sync`, `delete`, `logs`, `traces`, parts of `list`) through boto3/botocore, which honors `AWS_ENDPOINT_URL`. So SAM fits the **environment-variable model like CDK**, not the file-generation model of Terraform. + +Hands-on testing (see the comparison below) showed SAM is *not* a straight CDK clone, however. It lands as a third point in the design space: **CDK's env-var mechanism + Terraform's account model + the simplest endpoint of the three**. Specifically, testing confirmed: +- Only `AWS_ENDPOINT_URL` is needed — `http://localhost:4566` works with no S3-specific endpoint and no path-style fix, because SAM's botocore auto-selects path-style addressing against a `localhost`/IP host. (CDK needs an `s3.`-prefixed `AWS_ENDPOINT_URL_S3` because the JS SDK defaults to virtual-host addressing.) +- SAM honors `AWS_DEFAULT_REGION`, **not** `AWS_REGION` (it resolves `--region` itself and passes it explicitly into the boto3 session, with its env default reading `AWS_DEFAULT_REGION`). +- SAM passes `AWS_ACCESS_KEY_ID` straight through and LocalStack uses it as the account id — confirmed with a custom 12-digit key — so `--account` should be supported, exactly like Terraform (and unlike CDK, which rejects it). +- The minimum SAM CLI version that honors `AWS_ENDPOINT_URL` is `1.95.0`. + +`lstk sam` will reuse the shared `cmd/iac.go` command-boundary helpers and `internal/endpoint.ResolveHost`. It does **not** need `internal/endpoint.S3Addressing`. + +| Dimension | terraform | cdk | sam (tested) | +|---|---|---|---| +| Mechanism | override `.tf` file | env vars | env vars | +| Endpoint | per-service in file | `AWS_ENDPOINT_URL` + `AWS_ENDPOINT_URL_S3` (s3. prefix) | just `AWS_ENDPOINT_URL` | +| S3 path-style | `s3_use_path_style` | s3.-host + DNS warning | not needed (botocore auto path-style) | +| Region env | per-provider in file | `AWS_REGION` + `AWS_DEFAULT_REGION` | `AWS_DEFAULT_REGION` (load-bearing) | +| Account | `--account` ✅ (key = account) | `--account` ❌ rejected | `--account` ✅ (key = account) | +| Credentials | `access_key`=account | `access_key`=`test` fixed | `access_key`=account | +| Min version | — | 2.177.0 | 1.95.0 | + +Relevant existing code: +- `cmd/cdk.go` — Cobra wiring to mirror. +- `internal/iac/cdk/cli/{exec,env,version,defaults}.go` — domain logic to mirror under `internal/iac/sam/cli/`. +- `cmd/iac.go` — shared helpers (`stripLeadingIaCFlags`, `resolveRegion`, `resolveAccount`, `requireRunningAWSEmulator`, `rejectPreSubcommandFlags`, `emitValidationError`, `resolveAWSContainer`) — reused as-is. +- `internal/endpoint/endpoint.go` — `ResolveHost` — reused as-is. `S3Addressing` is not needed (see Decision 2). + +## Goals / Non-Goals + +**Goals:** +- Provide `lstk sam` that transparently forwards arguments to the real `sam` binary with the subprocess environment configured for LocalStack. +- Reuse the existing IaC command-boundary helpers and endpoint resolution so behavior (region parsing, emulator gating, error rendering) is consistent with `lstk cdk`. +- Be functionally equivalent to `samlocal` for the common case: ZIP-based Lambda functions and standard CloudFormation/S3/IAM/STS operations (`deploy`, `sync`, `package`, `delete`, `logs`, …). +- Support `--account` (like Terraform), since SAM passes the access-key id through as the account. +- Gate AWS-contacting subcommands on a running AWS emulator; let offline/local subcommands run without one. +- Version-gate the SAM CLI (minimum `1.95.0`) so lstk never silently targets real AWS via an old binary that ignores `AWS_ENDPOINT_URL`. +- No new Go module dependencies. + +**Non-Goals:** +- Reimplementing or vendoring `samlocal`/`aws-sam-cli-local`. +- **Full parity with `samlocal`'s in-process monkeypatches** — see "Parity with samlocal" below. Specifically out of scope for v1: image/container-based Lambda (ECR) deploys and nested CloudFormation stack template export. Both require patching SAM's Python internals, which a subprocess wrapper structurally cannot do (Open Questions track whether/how to address them). +- Solving in-container networking for `sam local invoke`/`start-api`/`start-lambda` so a Lambda's *runtime* AWS calls reach LocalStack from inside the SAM-spawned Docker container. That requires a container-reachable endpoint (e.g. `host.docker.internal`) and is out of scope for the first version (see Risks / Open Questions). +- Terraform-style file generation; SAM needs none. +- Setting `AWS_ENDPOINT_URL_S3` or any S3 path-style configuration; SAM does not need it (it is only honored as a user override). +- Reading `AWS_DEFAULT_REGION` to *decide* the region — lstk's region resolution stays consistent with tf/cdk (`--region` → `AWS_REGION` → `us-east-1`); SAM's `AWS_DEFAULT_REGION` dependency only affects what lstk *writes* to the subprocess. + +## Decisions + +### Decision 1: Mirror the CDK proxy (env-based), not the Terraform proxy (file-based) +SAM's cloud operations use botocore, which reads `AWS_ENDPOINT_URL`/`AWS_ENDPOINT_URL_S3`. So the entire LocalStack redirection is achievable through the subprocess environment with no generated files. + +- **Approach**: Create `internal/iac/sam/cli/` as a structural copy of `internal/iac/cdk/cli/` — `exec.go` (locate binary, version-check, build env, exec with stdio wired, wrap non-zero exit as silent error), `env.go` (`samCmd()`/`LSTK_SAM_CMD`, endpoint overrides), `version.go`, `defaults.go` (offline set + subcommand scanner). `cmd/sam.go` mirrors `cmd/cdk.go`. +- **Alternatives considered**: Generalizing the CDK package into a shared "env proxy" abstraction parameterized by binary name/min version/offline set. Rejected for now — the two would share ~90% code, but the existing codebase keeps terraform and cdk as separate sibling packages rather than abstracting them, and premature generalization would obscure SAM-specific divergences (offline set, `sam local`, telemetry env). Following the established sibling-package pattern keeps the change consistent and reviewable. A future refactor could extract the shared core once a third env-based proxy exists. + +### Parity with samlocal + +`samlocal` (the `aws-sam-cli-local` Python script) and `lstk sam` use fundamentally different mechanisms, which sets a hard ceiling on parity: + +``` + samlocal (Python) lstk sam (Go) + ───────────────── ───────────── + imports samcli, monkeypatches it sets env vars, then exec()s sam + in-process, then calls main.cli() as a separate black-box subprocess + → can rewrite ANY SAM internal → can only influence via env + flags +``` + +What `samlocal` does, and how `lstk sam` compares: + +| samlocal action | lstk sam | Parity | +|---|---|---| +| Patch `boto3.Session.client` to inject `endpoint_url` | `AWS_ENDPOINT_URL` | ✅ equivalent for SAM ≥ 1.95.0 | +| Set `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` = `test` (if unset) | sets them (account-derived; `--account` supported) | ✅ richer | +| Honor `AWS_ENDPOINT_URL` / `EDGE_PORT` / `LOCALSTACK_HOSTNAME` | `AWS_ENDPOINT_URL` (lstk-resolved); the deprecated `EDGE_PORT`/`LOCALSTACK_HOSTNAME` are not honored | ✅ (deprecated knobs dropped) | +| Patch `is_ecr_url` + `prompt_image_repository` + `ECRUploader.upload` to make **image-based Lambda (ECR)** deploys work | — | ❌ **gap** (Open Question) | +| Patch `do_export` to bypass **nested CFN stack** template export ([localstack#4965](https://github.com/localstack/localstack/issues/4965)) | — | ❌ **gap** (Open Question) | + +Notable places where `lstk sam` is *more* robust than `samlocal`: it strips `AWS_PROFILE`/`AWS_DEFAULT_PROFILE`/`AWS_SESSION_TOKEN` (samlocal leaves them, relying on its endpoint patch to override), and it sets the region deterministically (samlocal does not set a region at all). The cost of the cleaner env-only approach is the SAM ≥ 1.95.0 floor (Decision 3) — samlocal needs no version floor because its monkeypatches are version-independent. + +**Net:** functionally equivalent to `samlocal` for ZIP-based Lambdas + standard CloudFormation/S3/IAM/STS operations (the common case). The two ECR/nested-stack gaps are SAM-internal behaviors no env var or flag can reach on stock SAM, so they are deferred to the Open Questions rather than scoped into v1. + +### Decision 2: Environment variables set for the `sam` subprocess +A pared-down version of CDK's `BuildEnv`. Set (overriding ambient): +- `AWS_ENDPOINT_URL` = resolved LocalStack endpoint (or the user's `AWS_ENDPOINT_URL` override). No S3-specific endpoint is derived or set — testing confirmed plain `AWS_ENDPOINT_URL` covers `sam deploy --resolve-s3` and managed-bucket upload, because botocore auto-uses path-style addressing for a `localhost`/IP host. +- `AWS_ACCESS_KEY_ID` = resolved account (see Decision 6), `AWS_SECRET_ACCESS_KEY=test`. +- `AWS_REGION` **and** `AWS_DEFAULT_REGION` = resolved region. Both are set even though SAM only reads `AWS_DEFAULT_REGION`: it's free, matches CDK, overwrites any stale ambient `AWS_REGION`, and keeps the load-bearing `AWS_DEFAULT_REGION` correct. +- **Not** `SAM_CLI_TELEMETRY`. lstk deliberately leaves the user's SAM telemetry preference untouched. It was initially considered (analogous to CDK's `CDK_DISABLE_LEGACY_EXPORT_WARNING`) but the two are different in kind: the CDK var suppresses a cosmetic console warning, whereas `SAM_CLI_TELEMETRY=0` silently opts the user out of data collection — a privacy choice that belongs to the user (via SAM's first-run prompt, their `~/.aws-sam` config, or their own `SAM_CLI_TELEMETRY` export), not to a transparent proxy. It is also orthogonal to the package's stated rule of setting/stripping only what could *misroute a deploy* (telemetry cannot), and samlocal does not touch it either. The Azure-CLI precedent (lstk disables telemetry under `AZURE_CONFIG_DIR`) does not transfer: that is an lstk-owned throwaway config dir, whereas `lstk sam` runs in the user's own environment over their real `sam` binary. A user who has set `SAM_CLI_TELEMETRY` keeps it — the value passes through untouched. + +Strip from the subprocess env (mirror CDK `strippedKeys`): `AWS_PROFILE`, `AWS_DEFAULT_PROFILE`, `AWS_SESSION_TOKEN`. `AWS_ENDPOINT_URL_S3` is deliberately **not** stripped and **not** set — if a user sets it, it flows through as an escape hatch for an exotic S3 addressing case. Empty-valued managed entries are skipped so they never clobber inherited values with `""`. + +- **Alternatives considered**: Mirroring CDK exactly (set `AWS_ENDPOINT_URL_S3` with an `s3.` host + path-style). Rejected — testing showed SAM needs neither, and the extra machinery (`S3Addressing`, the DNS-fallback warning) would be dead weight. Passing `--region`/endpoints as SAM CLI flags was also rejected — SAM has no global `--endpoint-url`, so the env approach is uniform and is what `samlocal` relies on. + +### Decision 3: Minimum SAM CLI version gate +Older SAM CLIs bundle a botocore that ignores `AWS_ENDPOINT_URL`, which would silently target real AWS. Mirror CDK: run `sam --version`, parse `MAJOR.MINOR.PATCH`, and refuse to proceed below the minimum with an actionable upgrade message. + +- **Decision**: Minimum **SAM CLI `1.95.0`** — the version from which `AWS_ENDPOINT_URL` is honored, established by testing. Recorded in `version.go` and the help text. +- **Alternatives considered**: No version gate (rely on the user). Rejected — silently hitting real AWS is the exact failure mode the CDK gate exists to prevent. + +### Decision 4: Offline vs AWS-contacting subcommand classification +Reuse the CDK `IsOffline`/`subcommand` scanner pattern (skip flags, skip value-taking global flags, return first bare token), with a SAM-specific offline set. + +**The gate is UX-only, not a safety mechanism.** Unlike CDK (where the gate/version check exist to prevent a silent *real-AWS* call), lstk always sets `AWS_ENDPOINT_URL` to LocalStack for `sam`, so *every* command targets LocalStack regardless of classification. Misclassification therefore can never cause a wrong-target call — the only consequence is whether the user sees lstk's friendly "LocalStack is not running" message or SAM's raw botocore "Could not connect to the endpoint URL" error. This means we classify by **top-level token only** and pick the common-case behavior for the few mixed commands rather than chasing second-level precision. + +Classification was established empirically against SAM CLI 1.151.0 (ambiguous commands tested against a dead endpoint): + +- **Offline (no running emulator required, env still applied):** `docs`, `init`, `build`, `validate` (validates locally — modern SAM no longer calls CloudFormation `ValidateTemplate`, confirmed including `--lint`), `local` (all children: `invoke`, `start-api`, `start-lambda`, `generate-event`, `callback`, `execution`), `pipeline` (favors the common `pipeline init`), plus the no-subcommand forms `--version`, `--info`, `-h`/`--help`. +- **AWS-contacting (require running AWS emulator):** `deploy`, `sync`, `package` (uploads to S3), `delete`, `logs`, `traces`, `list` (favors the stack-querying `endpoints`/`stack-outputs`/`list resources --stack-name`), `remote` (all children), `publish`. +- **`sam local invoke`/`start-api`/`start-lambda`:** offline for the gate; the LocalStack env is still applied. Known limitation: the function's own runtime calls won't reach LocalStack without container-network endpoint configuration (Non-Goal / Risk). + +Two commands are mixed at the top-level-token granularity; both are classified by their common case, with the niche case degrading only to a raw connection error (never a wrong target): +- `list` → AWS-contacting. `list resources` *without* `--stack-name` is actually offline, so it is needlessly gated (fixed by starting the emulator). +- `pipeline` → offline. `pipeline bootstrap` actually contacts AWS, so against a stopped emulator it yields a connection error instead of lstk's message. + +- **Decision**: Encode the two sets above in `defaults.go` as the top-level offline set `{docs, init, build, validate, local, pipeline}` (everything else AWS-contacting). No second-level classification. + +### Decision 5: Command naming — `lstk sam` +Per the project convention (`lstk terraform`, `lstk cdk` name the underlying tool, not the `tflocal`/`cdklocal` wrapper), the command is `lstk sam`, not `lstk samlocal`. No alias is added initially (CDK has none; Terraform's `tf` alias is the lone exception). The help text references SAM and that no `samlocal` install is needed. + +### Decision 6: Region/account handling — reuse helpers, support `--account` (Terraform model) +Reuse `stripLeadingIaCFlags(passthrough, false)` (no `-chdir` for SAM), `resolveRegion`, `resolveAccount`, `rejectPreSubcommandFlags`, `emitValidationError`. + +- **Region**: `resolveRegion` is used unchanged — reading precedence `--region` → `AWS_REGION` → `us-east-1`, consistent with tf/cdk and deliberately not consulting `AWS_DEFAULT_REGION` for the *decision*. The resolved value is then written to both `AWS_REGION` and `AWS_DEFAULT_REGION` for the subprocess (Decision 2). +- **Account**: Support `--account` via `resolveAccount` (12-digit validation, `DeactivateAccessKey` for a stray real `AKIA…`/`ASIA…` key, fallback to `test`). Testing confirmed SAM passes `AWS_ACCESS_KEY_ID` through and LocalStack maps it to the account, including a custom 12-digit id — so unlike CDK (which rejects `--account` due to an inconsistent STS round-trip), SAM follows the Terraform model. The resolved account is written to `AWS_ACCESS_KEY_ID`. +- **Alternatives considered**: Rejecting `--account` like CDK. Rejected — testing showed a custom account id works end-to-end, so there's no reason to deny it. + +## Risks / Trade-offs + +- **`sam local` runtime calls don't reach LocalStack** → The env we set affects the `sam` process (and its build/deploy boto3 calls), but a function executed by `sam local invoke` runs in a separate Docker container where `AWS_ENDPOINT_URL=http://127.0.0.1:4566`/`localhost.localstack.cloud` may not resolve to the host's LocalStack. Mitigation: document as a known limitation for v1; treat `local` as offline for gating; a follow-up can inject a container-reachable endpoint (e.g. `http://host.docker.internal:4566`) into the function environment. This matches `samlocal`'s own historical limitations. +- **S3 addressing edge cases beyond what was tested** → Testing covered a `sam deploy` round-trip on a plain `localhost` endpoint (botocore auto path-style). An exotic case (e.g. an unusual `--s3-bucket` name or a virtual-host-only setup) could in theory still need explicit S3 configuration. Mitigation: lstk leaves `AWS_ENDPOINT_URL_S3` as a pass-through escape hatch — a user can set it and botocore honors it — without lstk having to derive or warn. +- **Offline-set misclassification** → If an AWS-contacting command is wrongly marked offline, it could run without the emulator and fail confusingly; if an offline command is wrongly gated, it needlessly requires a running emulator. Mitigation: the safe default is to require the emulator when unsure; finalize the set from `sam --help` (Decision 4). +- **Coupling to SAM CLI behavior** → SAM could change how it consumes endpoint env vars across versions. Mitigation: the version gate documents the supported floor; integration tests use a fake `sam` to lock the contract (args forwarded, env injected) independent of the real CLI. +- **Silent parity gaps vs samlocal** → A user migrating from `samlocal` to `lstk sam` for an **image-based Lambda** or **nested-stack** project will hit a failure (likely an ECR-URL validation/push error, or a nested-template export error), with no obvious signal that the cause is `lstk sam`'s missing monkeypatches rather than their setup. Mitigation: state the limitation explicitly in `lstk sam --help` and `CLAUDE.md`, and point affected users to `samlocal` as the fallback until the Open Questions are resolved. + +## Migration Plan + +Additive change — a new command with no effect on existing behavior. No data migration, no config changes, no breaking changes. Rollback is removing the command registration; the deprecated third-party `samlocal` continues to work for users who prefer it. Ship behind no flag; document in `CLAUDE.md` and command help. + +## Open Questions + +- **Image/container-based Lambda (ECR) support** — `samlocal` makes this work via three monkeypatches (broaden `is_ecr_url`, rewrite the ECR repo host in `prompt_image_repository`/`ECRUploader.upload`). A subprocess wrapper can't patch SAM internals. A source spike against stock SAM 1.151.0 found the gap has *partly* closed upstream but is not gone: + - `is_ecr_url` now natively accepts a bare `localhost[:port]/repo` and `127.0.0.1[:port]/repo`, but **rejects** `localhost.localstack.cloud:4566/repo`, any `http://`-prefixed form, and the virtual-host `.dkr.ecr..localhost.localstack.cloud:4566/repo` form (verified empirically). + - `ECRUploader.upload`'s rewrite is a no-op for an explicitly-provided localhost URL (it only rewrites repos containing `amazonaws.com`); `prompt_image_repository` only fires in guided (`--guided`) mode. + - **Net:** image-based Lambda is reachable on stock SAM *only* if the user passes `--image-repository` with a bare `127.0.0.1`/`localhost` host (no scheme, not `localhost.localstack.cloud`, not the virtual-host ECR URL). This conflicts with lstk's `endpoint.ResolveHost`, which prefers `localhost.localstack.cloud` — so the obvious image-repo value is the rejected one. + - **Sub-question raised:** should `lstk sam` force the loopback host (`127.0.0.1`/`localhost`) for `AWS_ENDPOINT_URL` instead of `localhost.localstack.cloud`? It would make image repos validate cleanly and costs nothing for non-ECR ops (SAM uses path-style S3 on any host), at the price of diverging from how `lstk cdk` resolves the host. Undecided. + - **Remaining avenues:** (a) document the narrow `127.0.0.1`-host recipe and defer broader support to `samlocal`; (b) force the loopback host (sub-question above); (c) a larger future change (upstream a SAM fix, or ship a thin Python shim). Still needs an end-to-end image deploy to confirm the `127.0.0.1`-host path actually completes (login + docker push + Lambda pull). +- **Nested CloudFormation stack export** — `samlocal` patches `do_export` to bypass re-exporting a nested template whose path points at LocalStack S3 ([localstack#4965](https://github.com/localstack/localstack/issues/4965)). A source spike found stock SAM's `is_s3_url` still requires `amazonaws.com`/`s3://` and does **not** recognize LocalStack S3 URLs — but `do_export` only re-examines a path when it already contains a localhost S3 URL (the multi-pass #4965 scenario); a normal single-pass `sam deploy` from local nested-template *files* uploads them to LocalStack S3 without the patch. Open question: does the #4965 edge still bite on current LocalStack + SAM, or is single-pass deploy sufficient for v1? Needs an end-to-end nested-stack deploy to confirm before deciding document-as-limitation vs. pursue a fix. +- **`sam local` networking** — out of scope for v1, but decide whether to document a manual `AWS_ENDPOINT_URL` override recipe for users who need it now. + +_Resolved by testing/source review:_ `SAM_CLI_TELEMETRY` is deliberately not set (Decision 2); minimum SAM version is `1.95.0`; only `AWS_ENDPOINT_URL` is needed (no S3 variant / path-style); `--account` works (Terraform model); SAM honors `AWS_DEFAULT_REGION`, not `AWS_REGION`; the offline subcommand set is finalized (Decision 4); `lstk sam` is functionally equivalent to `samlocal` for the common case, with two known gaps (above) inherent to the subprocess approach. diff --git a/openspec/changes/add-samlocal-command/proposal.md b/openspec/changes/add-samlocal-command/proposal.md new file mode 100644 index 00000000..49000fe7 --- /dev/null +++ b/openspec/changes/add-samlocal-command/proposal.md @@ -0,0 +1,35 @@ +## Why + +LocalStack users who build serverless applications with the AWS SAM CLI (`sam`) currently have to use the third-party `samlocal` wrapper (the `aws-sam-cli-local` pip package) to point SAM at LocalStack. lstk already provides first-party `lstk terraform` and `lstk cdk` proxies that remove the need for `tflocal`/`cdklocal`; adding `lstk sam` closes the gap for the SAM ecosystem so SAM users get the same zero-config, no-extra-dependency experience from the lstk CLI itself. + +## What Changes + +- Add a new `lstk sam` command that forwards all of its arguments to the real AWS SAM CLI (`sam`) and, before invoking it, configures the subprocess environment to target the running LocalStack instance — using CDK's env-var mechanism but Terraform's account model (see design.md). +- Inject `AWS_ENDPOINT_URL` so SAM's underlying botocore clients route CloudFormation/S3/Lambda/STS/IAM calls (`sam deploy`, `package`, `sync`, `delete`, `logs`, …) to LocalStack. Testing confirmed SAM needs no S3-specific endpoint — botocore auto-selects path-style addressing against a `localhost`/IP endpoint — so `AWS_ENDPOINT_URL_S3` is only honored as an override, never set by lstk. +- Provide LocalStack-compatible mock credentials and strip ambient AWS configuration (`AWS_PROFILE`, `AWS_DEFAULT_PROFILE`, `AWS_SESSION_TOKEN`, real keys) so a SAM deploy can never silently target real AWS. `AWS_SECRET_ACCESS_KEY` is fixed to `test`. +- Accept the lstk-specific `--region` flag in leading position with the same parsing/precedence as `lstk terraform`/`lstk cdk` (`--region` → `AWS_REGION` → `us-east-1`), and write the resolved region to both `AWS_REGION` and `AWS_DEFAULT_REGION` in the subprocess (SAM honors `AWS_DEFAULT_REGION`, not `AWS_REGION`). +- Support the lstk-specific `--account` flag, mirroring `lstk terraform`: SAM passes `AWS_ACCESS_KEY_ID` straight through and LocalStack maps it to the account (confirmed by testing with a custom account id). The resolved account is written to `AWS_ACCESS_KEY_ID`; absent a flag it falls back to a deactivated ambient `AWS_ACCESS_KEY_ID`, then `test` (default account `000000000000`). +- Require a running AWS emulator for AWS-contacting subcommands; run a fixed set of offline/local subcommands (`init`, `build`, `validate`, `local …`, `local generate-event`, `version`, …) without that requirement. +- Require AWS SAM CLI `1.95.0` or newer (the version from which SAM honors `AWS_ENDPOINT_URL`), refusing to run older versions so they cannot silently target real AWS (mirrors the CDK version gate). +- Resolve the `sam` binary from `PATH`, configurable via `LSTK_SAM_CMD` (default `sam`); emit an actionable error when it is missing. Do **not** require or invoke the third-party `samlocal`/`aws-sam-cli-local`. +- Stream the SAM subprocess's stdin/stdout/stderr through unobstructed and propagate its exit code. +- Register the command with the root command and document it in `CLAUDE.md`. + +**Scope note — parity with `samlocal`:** `lstk sam` is a Go subprocess wrapper, whereas `samlocal` is a Python script that monkeypatches SAM's internals in-process. `lstk sam` is functionally equivalent for the common case (ZIP-based Lambdas + standard CloudFormation/S3/IAM/STS operations), but two `samlocal` behaviors are **not covered in v1** because they require in-process patches no env var or flag can reach: **image/container-based Lambda (ECR) deploys** and **nested CloudFormation stack template export**. These are documented as known limitations (with `samlocal` as the fallback) and tracked as Open Questions in design.md for a possible follow-up. + +## Capabilities + +### New Capabilities +- `sam-proxy`: An `lstk sam` command that proxies the AWS SAM CLI against the running LocalStack emulator by configuring the subprocess environment (endpoint, mock credentials, AWS-config isolation, region), gating AWS-contacting subcommands on a running emulator, and enforcing a minimum SAM CLI version. + +### Modified Capabilities + + +## Impact + +- **New code**: `cmd/sam.go` (Cobra wiring) and `internal/iac/sam/cli/` (domain logic: `exec.go`, `env.go`, `version.go`, `defaults.go`), mirroring `internal/iac/cdk/cli/`. +- **Reused code**: `cmd/iac.go` shared helpers (`stripLeadingIaCFlags`, `resolveRegion`, `resolveAccount`, `requireRunningAWSEmulator`, `rejectPreSubcommandFlags`, `emitValidationError`, `resolveAWSContainer`), `internal/iac/terraform/cli` (`DeactivateAccessKey`, via `resolveAccount`), `internal/endpoint` (`ResolveHost`), `internal/output`, `internal/runtime`. SAM does not need `endpoint.S3Addressing`. +- **Modified code**: `cmd/root.go` (register `newSamCmd`), `CLAUDE.md` (document the command). +- **New env vars** (public interface): `LSTK_SAM_CMD` (binary override); `AWS_ENDPOINT_URL` and `AWS_ENDPOINT_URL_S3` honored as overrides (only `AWS_ENDPOINT_URL` is set by lstk); `AWS_REGION` as `--region` fallback. +- **Tests**: unit tests for SAM env building and version parsing; integration tests with a fake `sam` binary (arg forwarding, env injection, version gate, offline gating); optional e2e tests against a real `sam` + LocalStack. +- **External dependency**: relies on a sufficiently recent AWS SAM CLI on the user's `PATH`; no new Go module dependencies. diff --git a/openspec/changes/add-samlocal-command/specs/sam-proxy/spec.md b/openspec/changes/add-samlocal-command/specs/sam-proxy/spec.md new file mode 100644 index 00000000..6ae043ef --- /dev/null +++ b/openspec/changes/add-samlocal-command/specs/sam-proxy/spec.md @@ -0,0 +1,130 @@ +## ADDED Requirements + +### Requirement: SAM CLI proxy command +The system SHALL provide an `lstk sam` command that forwards all of its arguments to the real AWS SAM CLI (`sam`) and, before invoking it, configures the subprocess environment to target the running LocalStack instance. + +#### Scenario: Pass through SAM arguments +- **WHEN** the user runs `lstk sam deploy --stack-name my-stack --no-confirm-changeset` +- **THEN** lstk invokes the `sam` binary with `deploy --stack-name my-stack --no-confirm-changeset` intact and propagates its exit code + +#### Scenario: Inject LocalStack endpoint into the SAM environment +- **WHEN** lstk runs a SAM command +- **THEN** the `sam` subprocess receives `AWS_ENDPOINT_URL` set to the resolved LocalStack endpoint, and lstk does not set `AWS_ENDPOINT_URL_S3` or any S3 path-style configuration (SAM's botocore auto-selects path-style addressing against a `localhost`/IP endpoint) + +#### Scenario: Honor an explicit endpoint override +- **WHEN** `AWS_ENDPOINT_URL` is already set in the environment +- **THEN** lstk uses that value instead of the auto-resolved endpoint + +#### Scenario: Honor an explicit S3 endpoint override +- **WHEN** `AWS_ENDPOINT_URL_S3` is already set in the environment +- **THEN** lstk passes it through to the `sam` subprocess unchanged (it is neither set nor stripped by lstk), so a user can override S3 addressing for an exotic case + +### Requirement: Direct sam invocation with no samlocal dependency +The system SHALL invoke the real `sam` binary directly and SHALL NOT require or invoke `samlocal`/`aws-sam-cli-local`. The binary name SHALL be configurable via `LSTK_SAM_CMD`, defaulting to `sam`. + +#### Scenario: Resolve the sam binary from PATH +- **WHEN** `lstk sam` runs and `sam` is on `PATH` +- **THEN** lstk locates and executes it + +#### Scenario: Override the binary name +- **WHEN** `LSTK_SAM_CMD` is set to an alternative binary name +- **THEN** lstk invokes that binary instead of `sam` + +#### Scenario: Missing SAM binary +- **WHEN** the configured SAM binary is not found on `PATH` +- **THEN** lstk emits an actionable error directing the user to install the AWS SAM CLI and does not attempt to run anything + +### Requirement: Minimum SAM CLI version +The system SHALL require AWS SAM CLI version `1.95.0` or newer, the version from which SAM honors `AWS_ENDPOINT_URL`, because lstk points SAM at LocalStack purely through environment variables, which older SAM versions do not honor. + +#### Scenario: SAM version is too old +- **WHEN** the resolved `sam` binary reports a version older than `1.95.0` +- **THEN** lstk fails with an actionable error explaining the minimum version, and does not run the command (so it cannot silently target real AWS) + +#### Scenario: SAM version is supported +- **WHEN** the resolved `sam` binary reports a version at or above `1.95.0` +- **THEN** lstk proceeds to run the command + +### Requirement: Mock credentials and AWS environment isolation +The system SHALL provide LocalStack-compatible credentials to the `sam` subprocess and SHALL strip ambient AWS configuration that could redirect SAM to real AWS. lstk SHALL NOT require, read, or inject the LocalStack auth token for SAM-to-LocalStack API calls; the auth token only activates the emulator container. + +SAM passes `AWS_ACCESS_KEY_ID` straight through and LocalStack derives the account from it; lstk SHALL set `AWS_ACCESS_KEY_ID` to the resolved account (see "Account selection") and SHALL fix `AWS_SECRET_ACCESS_KEY=test`. + +#### Scenario: Provide credentials and region +- **WHEN** lstk runs a SAM command +- **THEN** the subprocess environment contains `AWS_ACCESS_KEY_ID` set to the resolved account, `AWS_SECRET_ACCESS_KEY=test`, and the resolved region in both `AWS_REGION` and `AWS_DEFAULT_REGION` + +#### Scenario: Strip ambient AWS configuration +- **WHEN** the user's environment contains `AWS_PROFILE`, `AWS_DEFAULT_PROFILE`, `AWS_SESSION_TOKEN`, or a real `AWS_SECRET_ACCESS_KEY` value +- **THEN** lstk removes or overrides them in the subprocess environment so SAM cannot resolve credentials or a profile that point at real AWS + +### Requirement: Region selection +The system SHALL accept the lstk-specific `--region` flag in leading position (before the SAM subcommand) and encode it into the subprocess environment, with the same parsing and precedence as `lstk terraform` and `lstk cdk`. Because SAM honors `AWS_DEFAULT_REGION` (and not `AWS_REGION`), lstk SHALL write the resolved region to both `AWS_REGION` and `AWS_DEFAULT_REGION`. + +#### Scenario: Region precedence +- **WHEN** `--region` is omitted +- **THEN** lstk resolves the region from `AWS_REGION`, falling back to `us-east-1` + +#### Scenario: Region reaches SAM via AWS_DEFAULT_REGION +- **WHEN** lstk runs a SAM command with a resolved region +- **THEN** the subprocess environment sets both `AWS_REGION` and `AWS_DEFAULT_REGION` to that region, so SAM (which reads `AWS_DEFAULT_REGION`) uses it + +#### Scenario: Flags only in leading position +- **WHEN** `--region` appears after the SAM subcommand (e.g. `lstk sam deploy --region us-west-2`) +- **THEN** lstk forwards it to `sam` unchanged rather than consuming it + +#### Scenario: Reject a leading flag before the subcommand at the lstk boundary +- **WHEN** `--region` or `--account` appears before the `sam` subcommand on the lstk command line (e.g. `lstk --region us-west-2 sam deploy`) +- **THEN** lstk fails with an error explaining the flag must appear after the `sam` subcommand, and does not invoke `sam` + +### Requirement: Account selection +The system SHALL accept the lstk-specific `--account` flag in leading position (before the SAM subcommand), mirroring `lstk terraform`, because SAM passes `AWS_ACCESS_KEY_ID` through to LocalStack which uses it as the account id. The resolved account SHALL be written to `AWS_ACCESS_KEY_ID` in the subprocess environment. + +#### Scenario: Explicit account +- **WHEN** `--account 111111111111` is provided in leading position +- **THEN** lstk validates it as a 12-digit account id and sets `AWS_ACCESS_KEY_ID=111111111111` for the `sam` subprocess, so resources are created under that LocalStack account + +#### Scenario: Invalid account value +- **WHEN** `--account` is provided with a value that is not exactly 12 digits +- **THEN** lstk fails at the command boundary with an error and does not invoke `sam` + +#### Scenario: Account precedence and real-key deactivation +- **WHEN** `--account` is omitted +- **THEN** lstk resolves the account from the ambient `AWS_ACCESS_KEY_ID` (deactivating a real-looking `AKIA…`/`ASIA…` key so it never reaches LocalStack), falling back to `test` (default account `000000000000`) + +### Requirement: Emulator gating for AWS-contacting commands +The system SHALL require a running AWS emulator for SAM subcommands that contact AWS APIs and SHALL run a fixed set of offline subcommands without that requirement. + +#### Scenario: AWS-contacting command without a running emulator +- **WHEN** the user runs an AWS-contacting subcommand (e.g. `lstk sam deploy`) and the AWS emulator is not running +- **THEN** lstk emits an actionable "LocalStack is not running" error (with a command to start it) and does not invoke `sam` + +#### Scenario: A different emulator is running +- **WHEN** an AWS-contacting SAM command is run while a non-AWS emulator (e.g. Snowflake or Azure) is running but the AWS emulator is not +- **THEN** lstk fails with an AWS-specific error naming the running emulator rather than a misleading generic "not running" message + +#### Scenario: Offline command without a running emulator +- **WHEN** the user runs an offline subcommand (e.g. `lstk sam init`, `lstk sam build`, `lstk sam validate`, `lstk sam local generate-event`) +- **THEN** lstk runs it without requiring a running emulator + +### Requirement: Streamed passthrough output +The system SHALL stream the SAM subprocess's stdin, stdout, and stderr through unobstructed and SHALL NOT display a spinner or capture SAM output into lifecycle events. + +#### Scenario: Unobstructed streaming +- **WHEN** a long-running SAM command (e.g. `lstk sam deploy`) produces incremental output +- **THEN** lstk streams that output directly to the terminal without a spinner or reformatting, and forwards interactive prompts via stdin + +#### Scenario: Propagate failure +- **WHEN** the SAM command exits non-zero +- **THEN** lstk returns a silent error carrying that exit status so the top-level handler does not reprint it + +### Requirement: Known limitations versus samlocal +Because `lstk sam` runs the real `sam` as a subprocess and configures it only through environment variables (rather than monkeypatching SAM internals like the Python `samlocal` wrapper), two `samlocal` behaviors are not guaranteed in this version: image/container-based Lambda (ECR) deploys and nested CloudFormation stack template export. The system SHALL surface these limitations to the user in the command help text and SHALL point users to `samlocal` as the fallback for those workflows. The system SHALL NOT silently alter or block these commands beyond the normal emulator gating — they are forwarded to `sam` like any other command. + +#### Scenario: Limitations documented in help +- **WHEN** the user runs `lstk sam --help` +- **THEN** the help text notes that image/container-based Lambda (ECR) deploys and nested CloudFormation stacks are not fully supported and that `samlocal` is the fallback for those workflows + +#### Scenario: Unsupported workflow is still forwarded, not blocked +- **WHEN** the user runs an image-based Lambda deploy (e.g. `lstk sam deploy` for a template with `PackageType: Image`) +- **THEN** lstk forwards the command to `sam` unchanged (subject to the normal emulator gate) rather than rejecting it, so the user sees SAM's own behavior/errors diff --git a/openspec/changes/add-samlocal-command/tasks.md b/openspec/changes/add-samlocal-command/tasks.md new file mode 100644 index 00000000..b1345096 --- /dev/null +++ b/openspec/changes/add-samlocal-command/tasks.md @@ -0,0 +1,36 @@ +## 1. Domain logic — `internal/iac/sam/cli/` + +(Pre-implementation verification is done: minimum version `1.95.0`; offline classification established empirically against SAM CLI 1.151.0 — see design Decision 4. The gate is UX-only since the env always targets LocalStack.) + +- [x] 1.1 Create `env.go`: `samCmd()` reading `LSTK_SAM_CMD` (default `sam`), `endpointURLOverride()` reading `AWS_ENDPOINT_URL`, and `s3EndpointOverride()` reading `AWS_ENDPOINT_URL_S3` (only honored as a user override — never set by lstk; see Decision 2). +- [x] 1.2 Create `defaults.go`: minimum-version constants (`minSAMMajor=1`, `minSAMMinor=95`, `minSAMPatch=0`, `minSAMVersionString="1.95.0"`); the top-level `offlineCommands` set `{docs, init, build, validate, local, pipeline}` (everything else is AWS-contacting; no second-level classification — see Decision 4); the `valueFlags` set for value-taking SAM global flags; and the `IsOffline`/`subcommand` scanner (mirror `internal/iac/cdk/cli/defaults.go`). +- [x] 1.3 Create `version.go`: `CheckVersion(ctx, samBin)` running `sam --version`, parsing `MAJOR.MINOR.PATCH`, and returning an actionable error below `1.95.0` (mirror `internal/iac/cdk/cli/version.go`). +- [x] 1.4 Create `env.go` `BuildEnv(base, endpointURL, account, region)`: set `AWS_ENDPOINT_URL`, `AWS_ACCESS_KEY_ID=account`, `AWS_SECRET_ACCESS_KEY=test`, and **both** `AWS_REGION` and `AWS_DEFAULT_REGION` to the resolved region (do **not** set `SAM_CLI_TELEMETRY` — leave the user's telemetry preference untouched, per design Decision 2); strip `AWS_PROFILE`, `AWS_DEFAULT_PROFILE`, `AWS_SESSION_TOKEN`; do **not** set or strip `AWS_ENDPOINT_URL_S3` (pass-through escape hatch); skip empty managed values. No S3 endpoint or path-style handling. +- [x] 1.5 Create `exec.go` `Run(ctx, endpointURL, account, region, sink, logger, args)`: locate binary (emit actionable `ErrorEvent` directing to install the AWS SAM CLI + silent error if missing), `CheckVersion`, apply the `AWS_ENDPOINT_URL` override if set, build env via `BuildEnv`, exec `sam` with stdin/stdout/stderr wired through, wrap non-zero exit as `output.NewSilentError`. Add the OTEL span/attributes like CDK. Does not import `endpoint.S3Addressing`. + +## 2. Command wiring — `cmd/sam.go` + +- [x] 2.1 Add `newSamCmd(cfg *env.Env, logger log.Logger)` with `Use: "sam [args...]"`, `DisableFlagParsing: true`, and `PreRunE` that calls `stripGlobalFlags` + `initConfig` (mirror `cmd/cdk.go`). +- [x] 2.2 In `RunE`: `rejectPreSubcommandFlags(cmd.CalledAs())`, `stripLeadingIaCFlags(passthrough, false)`, `resolveRegion(regionFlag)`, `resolveAccount(accountFlag)` (mirror `cmd/terraform.go` — `--account` is supported; surface a validation error via `emitValidationError` on an invalid account), `resolveAWSContainer`. +- [x] 2.3 For offline subcommands (`samcli.IsOffline`), resolve host (DNS only) and call `samcli.Run` without requiring a running emulator. +- [x] 2.4 For AWS-contacting subcommands: create the Docker runtime, check `IsHealthy`, `requireRunningAWSEmulator(..., "sam")`, resolve host, then call `samcli.Run`. No DNS-fallback warning is needed (SAM uses path-style addressing on any resolved host). +- [x] 2.5 Write the `Short`/`Long` help text as unbroken paragraphs (per CLAUDE.md), covering the leading `--region` and `--account` flags, the minimum SAM version (`1.95.0`), the supported env vars (`AWS_ENDPOINT_URL`, `LSTK_SAM_CMD`, `AWS_REGION`; note `AWS_ENDPOINT_URL_S3` is honored only as an override), and a brief known-limitations note that image/container-based Lambda (ECR) deploys and nested CloudFormation stacks are not supported — use `samlocal` for those. +- [x] 2.6 Register `newSamCmd(cfg, logger)` in `cmd/root.go` alongside `newCDKCmd`. + +## 3. Tests + +- [x] 3.1 Unit test `BuildEnv` (`internal/iac/sam/cli/env_test.go`): `AWS_ENDPOINT_URL` set, no `AWS_ENDPOINT_URL_S3` set, `AWS_ACCESS_KEY_ID` = the passed account, `AWS_SECRET_ACCESS_KEY=test`, both region vars set, AWS-config stripping, user-set `AWS_ENDPOINT_URL_S3` passed through untouched, empty-value skipping. +- [x] 3.2 Unit test `CheckVersion` (`version_test.go`): parsing and comparison against `1.95.0` (e.g. `1.94.x` fails, `1.95.0`/`1.96.x` pass). +- [x] 3.3 Unit test `IsOffline`/`subcommand` (`defaults_test.go`): offline tokens (`docs`, `init`, `build`, `validate`, `local`, `pipeline`) vs AWS-contacting (`deploy`, `sync`, `package`, `delete`, `logs`, `traces`, `list`, `remote`, `publish`); flag and value-flag skipping; two-level commands resolve to their top-level token (e.g. `local generate-event` → `local` offline, `list resources` → `list` AWS-contacting). +- [x] 3.4 Integration test with a fake `sam` binary (`test/integration/sam_cmd_test.go`, mirror `cdk_cmd_test.go`): arg forwarding, env injection (incl. `AWS_DEFAULT_REGION` and a custom `--account` reaching `AWS_ACCESS_KEY_ID`), version gate, missing-binary error, offline command runs without a running emulator. Use `testEnvWithHome(t.TempDir(), "")`; mark `t.Parallel()` where no Docker state is shared. +- [x] 3.5 (Optional) e2e test against a real `sam` + LocalStack (`test/integration/sam_e2e_test.go`) with a minimal SAM sample under `test/integration/test-samples/iac/sam/`: `validate`/`build` offline, a `deploy` round-trip, and a `deploy` with a custom `--account` asserting resources land under that account. +- [x] 3.6 Explicitly install the AWS SAM CLI on the Linux integration shards in `.github/workflows/ci.yml` (via `aws-actions/setup-sam@v2` with `use-installer: true`), mirroring the Terraform/CDK install steps. The `ubuntu-latest` image already ships `sam` (1.161.1), but installing it explicitly keeps the SAM e2e tests from silently losing coverage if a future runner image drops it. + +## 4. Documentation + +- [x] 4.1 Document `lstk sam` in `CLAUDE.md` (alongside the IaC/terraform/cdk descriptions): purpose, that no `samlocal` install is needed, the minimum SAM version (`1.95.0`), `--region`/`--account` support, the supported env vars, and the known limitations vs `samlocal` (no image/container-based Lambda or nested-stack support — use `samlocal` for those). + +## 5. Verification + +- [x] 5.1 Run `make lint`, `make test`, and `make test-integration RUN=...` for the new SAM tests; confirm all pass. +- [x] 5.2 Manually verify `lstk sam --help`, `lstk sam validate` (offline), a `lstk sam deploy` against a running AWS emulator routing to LocalStack, and a `lstk sam deploy --account 111111111111` landing resources under that account. (Verified locally: `lstk help sam` renders the help — note `lstk sam --help` forwards under `DisableFlagParsing` exactly like `lstk cdk`, so `lstk help sam` is the way to read it — and `lstk sam validate` runs offline against the real sam 1.151.0. The `deploy`/`--account` paths are covered by the gated e2e tests `TestSAME2EDeployDelete`/`TestSAME2EDeployCustomAccount`, which skip locally without `LOCALSTACK_AUTH_TOKEN` and run in CI; the env-injection contract those rely on is asserted by `TestSAMInjectsCleanAWSEnv`/`TestSAMAccountSupported`.) diff --git a/test/integration/sam_cmd_test.go b/test/integration/sam_cmd_test.go new file mode 100644 index 00000000..7b0474bf --- /dev/null +++ b/test/integration/sam_cmd_test.go @@ -0,0 +1,263 @@ +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" +) + +// writeFakeSAM creates a stub `sam` that answers `--version` with the given +// version string and, for any other invocation, echoes its args and the AWS +// environment it was given so tests can assert what lstk injected/stripped. +func writeFakeSAM(t *testing.T, version string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake sam script not supported on Windows") + } + dir := t.TempDir() + script := fmt.Sprintf(`#!/bin/sh +if [ "$1" = "--version" ]; then + echo "SAM CLI, version %s" + exit 0 +fi +echo "ARGS:$*" +echo "ENV_AWS_ENDPOINT_URL=$AWS_ENDPOINT_URL" +echo "ENV_AWS_ENDPOINT_URL_S3=${AWS_ENDPOINT_URL_S3:-}" +echo "ENV_AWS_REGION=$AWS_REGION" +echo "ENV_AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION" +echo "ENV_AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" +echo "ENV_AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" +echo "ENV_AWS_PROFILE=${AWS_PROFILE:-}" +echo "ENV_AWS_DEFAULT_PROFILE=${AWS_DEFAULT_PROFILE:-}" +echo "ENV_AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}" +`, version) + require.NoError(t, os.WriteFile(filepath.Join(dir, "sam"), []byte(script), 0755)) + return dir +} + +// writeFakeSAMExit creates a stub `sam` reporting a supported version but exiting +// with the given code for any real subcommand. +func writeFakeSAMExit(t *testing.T, code int) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake sam script not supported on Windows") + } + dir := t.TempDir() + script := fmt.Sprintf(`#!/bin/sh +if [ "$1" = "--version" ]; then echo "SAM CLI, version 1.95.0"; exit 0; fi +echo "sam: simulated failure" >&2 +exit %d +`, code) + require.NoError(t, os.WriteFile(filepath.Join(dir, "sam"), []byte(script), 0755)) + return dir +} + +// forwards args. `build` is offline, so no emulator/Docker is required. +func TestSAMForwardsArgs(t *testing.T) { + t.Parallel() + fakeDir := writeFakeSAM(t, "1.95.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "sam", "build") + require.NoError(t, err, "stderr: %s", stderr) + assert.Contains(t, stdout, "ARGS:build") +} + +// propagates the sam exit code. +func TestSAMPropagatesExitCode(t *testing.T) { + t.Parallel() + fakeDir := writeFakeSAMExit(t, 7) + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "sam", "build") + require.Error(t, err) + assert.Contains(t, stderr, "simulated failure") + requireExitCode(t, 7, err) +} + +// the subprocess gets the LocalStack-pointing env (region in AWS_DEFAULT_REGION, +// custom --account in AWS_ACCESS_KEY_ID, no S3 endpoint), and ambient AWS config +// that could redirect at real AWS is stripped. +func TestSAMInjectsCleanAWSEnv(t *testing.T) { + t.Parallel() + fakeDir := writeFakeSAM(t, "1.95.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()). + With(env.Key("AWS_PROFILE"), "my-real-profile"). + With(env.Key("AWS_DEFAULT_PROFILE"), "other"). + With(env.Key("AWS_SESSION_TOKEN"), "realtoken") + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, + "sam", "--region", "eu-west-1", "--account", "111111111111", "build") + require.NoError(t, err, "stderr: %s", stderr) + + assert.Contains(t, stdout, "ENV_AWS_ENDPOINT_URL=http") + assert.Contains(t, stdout, ":4566") + // SAM reads AWS_DEFAULT_REGION; lstk sets both region vars. + assert.Contains(t, stdout, "ENV_AWS_REGION=eu-west-1") + assert.Contains(t, stdout, "ENV_AWS_DEFAULT_REGION=eu-west-1") + // A custom --account flows through to AWS_ACCESS_KEY_ID (Terraform model). + assert.Contains(t, stdout, "ENV_AWS_ACCESS_KEY_ID=111111111111") + assert.Contains(t, stdout, "ENV_AWS_SECRET_ACCESS_KEY=test") + // lstk never sets an S3-specific endpoint for SAM. + assert.Contains(t, stdout, "ENV_AWS_ENDPOINT_URL_S3=") + // Ambient AWS config is stripped. + assert.Contains(t, stdout, "ENV_AWS_PROFILE=") + assert.Contains(t, stdout, "ENV_AWS_DEFAULT_PROFILE=") + assert.Contains(t, stdout, "ENV_AWS_SESSION_TOKEN=") +} + +// offline subcommands run without a running emulator, even with a leading +// --region present (which is stripped from the forwarded args). +func TestSAMOfflineCommandsNoEmulator(t *testing.T) { + t.Parallel() + for _, sub := range []string{"init", "build", "validate", "docs", "pipeline"} { + sub := sub + t.Run(sub, func(t *testing.T) { + t.Parallel() + fakeDir := writeFakeSAM(t, "1.95.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, + "sam", "--region", "us-west-2", sub) + require.NoError(t, err, "stderr: %s", stderr) + + assert.Contains(t, stdout, "ARGS:"+sub) + assert.NotContains(t, stdout, "--region") + }) + } +} + +// a too-old sam fails before the command runs. +func TestSAMVersionTooOld(t *testing.T) { + t.Parallel() + fakeDir := writeFakeSAM(t, "1.94.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "sam", "build") + require.Error(t, err) + assert.Contains(t, stderr+stdout, "1.95.0") + // sam was never run for real. + assert.NotContains(t, stdout, "ARGS:build") +} + +// a missing sam binary yields the install error. +func TestSAMMissingBinary(t *testing.T) { + t.Parallel() + e := env.With(env.DisableEvents, "1").With("PATH", t.TempDir()).With(env.Home, t.TempDir()) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "sam", "build") + require.Error(t, err) + assert.Contains(t, stderr+stdout, "not found in PATH") +} + +// --account is supported for sam (unlike cdk): a valid 12-digit value is +// accepted and reaches the subprocess as AWS_ACCESS_KEY_ID. +func TestSAMAccountSupported(t *testing.T) { + t.Parallel() + fakeDir := writeFakeSAM(t, "1.95.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, + "sam", "--account", "123456789012", "build") + require.NoError(t, err, "stderr: %s", stderr) + assert.Contains(t, stdout, "ENV_AWS_ACCESS_KEY_ID=123456789012") +} + +// an invalid --account value is rejected at the command boundary before sam runs. +func TestSAMInvalidAccountRejected(t *testing.T) { + t.Parallel() + fakeDir := writeFakeSAM(t, "1.95.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, + "sam", "--account", "12345", "build") + require.Error(t, err) + assert.Contains(t, stderr+stdout, "12-digit") + assert.NotContains(t, stdout, "ARGS:build") +} + +// flags after the subcommand are forwarded to sam unchanged. +func TestSAMFlagsAfterActionAreForwarded(t *testing.T) { + t.Parallel() + fakeDir := writeFakeSAM(t, "1.95.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, + "sam", "build", "--region", "us-west-2") + require.NoError(t, err, "stderr: %s", stderr) + assert.Contains(t, stdout, "ARGS:build --region us-west-2") +} + +// a flag before the subcommand is rejected with a clear message. +func TestSAMFlagBeforeSubcommandRejected(t *testing.T) { + t.Parallel() + fakeDir := writeFakeSAM(t, "1.95.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, + "--account", "111111111111", "sam", "build") + require.Error(t, err) + assert.Contains(t, stderr+stdout, "must appear after the sam subcommand") +} + +// LSTK_SAM_CMD selects the binary to invoke. +func TestSAMHonorsLstkSamCmd(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("fake sam script not supported on Windows") + } + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "mysam"), + []byte("#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"SAM CLI, version 1.95.0\"; exit 0; fi\necho \"MYSAM:$*\"\n"), 0755)) + e := env.With(env.DisableEvents, "1").With("PATH", dir).With(env.Home, t.TempDir()). + With(env.Key("LSTK_SAM_CMD"), "mysam") + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "sam", "build") + require.NoError(t, err, "stderr: %s", stderr) + assert.Contains(t, stdout, "MYSAM:build") +} + +// an AWS-contacting command with no running emulator fails with "not running" +// and does not invoke sam. +func TestSAMFailsWhenEmulatorNotRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + fakeDir := writeFakeSAM(t, "1.95.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "sam", "deploy") + require.Error(t, err) + assert.Contains(t, stdout, "is not running") + assert.Contains(t, stdout, "Start LocalStack:") + assert.NotContains(t, stdout, "ARGS:deploy") +} + +// an AWS-contacting command fails with an AWS-specific error naming the running +// non-AWS emulator, and does not invoke sam. +func TestSAMRequiresAWSEmulator(t *testing.T) { + requireDocker(t) + cleanup() + cleanupSnowflake() + t.Cleanup(cleanup) + t.Cleanup(cleanupSnowflake) + + ctx := testContext(t) + startTestSnowflakeContainer(t, ctx) + + fakeDir := writeFakeSAM(t, "1.95.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + stdout, _, err := runLstk(t, ctx, t.TempDir(), e, "sam", "deploy") + require.Error(t, err) + assert.Contains(t, stdout, "requires the") + assert.Contains(t, stdout, "Snowflake") + assert.NotContains(t, stdout, "ARGS:deploy") +} diff --git a/test/integration/sam_e2e_test.go b/test/integration/sam_e2e_test.go new file mode 100644 index 00000000..05108e38 --- /dev/null +++ b/test/integration/sam_e2e_test.go @@ -0,0 +1,126 @@ +package integration_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// End-to-end tests for `lstk sam` that exercise the real AWS SAM CLI against a +// real LocalStack container (see localstack_test.go for the shared bring-up +// helpers) — unlike sam_cmd_test.go, which uses a stub sam. They are gated on +// Docker + a real sam binary + an auth token (CI installs sam on the Linux +// shards and provides the token; otherwise they skip). +// +// A successful `sam deploy` against LocalStack proves the full path: lstk +// injected AWS_ENDPOINT_URL + mock creds + AWS_DEFAULT_REGION, SAM routed +// CloudFormation and the S3 artifact upload (--resolve-s3) through them, and +// LocalStack served the calls. No `sam build` is run: deploy packages the +// CodeUri directly (zipping ./src), which needs no language toolchain. + +func requireSAM(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("sam"); err != nil { + t.Skip("sam binary not found on PATH") + } +} + +// copySAMSample copies a SAM sample project from test-samples/iac/sam into a +// fresh temp dir. Tests run there so .aws-sam/samconfig.toml never touch the +// tracked tree. +func copySAMSample(t *testing.T, name string) string { + t.Helper() + work := t.TempDir() + src := filepath.Join("test-samples", "iac", "sam", name) + require.NoError(t, os.CopyFS(work, os.DirFS(src))) + return work +} + +// runSAM runs `lstk sam ` and returns stdout, stderr, err. +func runSAM(t *testing.T, ctx context.Context, work string, e env.Environ, args ...string) (string, string, error) { + t.Helper() + return runLstk(t, ctx, work, e, append([]string{"sam"}, args...)...) +} + +// samE2EEnv inherits the real PATH (so the real sam is found) with an isolated +// HOME. +func samE2EEnv(t *testing.T) env.Environ { + t.Helper() + return env.With(env.DisableEvents, "1").With(env.Home, t.TempDir()) +} + +// `sam validate` succeeds offline (no running emulator required). +func TestSAME2EValidateOffline(t *testing.T) { + requireSAM(t) + + ctx := testContext(t) + work := copySAMSample(t, "hello") + e := samE2EEnv(t) + + _, stderr, err := runSAM(t, ctx, work, e, "validate", "--lint") + require.NoError(t, err, "sam validate stderr: %s", stderr) +} + +// `sam deploy` of a single function succeeds against LocalStack, and `sam delete` +// tears it down. Exercises the --resolve-s3 artifact upload through the injected +// endpoint. +func TestSAME2EDeployDelete(t *testing.T) { + requireDocker(t) + requireSAM(t) + token := requireAuthToken(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startRealLocalStack(t, ctx, token) + + work := copySAMSample(t, "hello") + e := samE2EEnv(t) + const stack = "lstk-sam-e2e" + + _, stderr, err := runSAM(t, ctx, work, e, "deploy", + "--stack-name", stack, "--resolve-s3", "--no-confirm-changeset", + "--no-fail-on-empty-changeset", "--capabilities", "CAPABILITY_IAM", "--region", "us-east-1") + require.NoError(t, err, "sam deploy stderr: %s", stderr) + + _, stderr, err = runSAM(t, ctx, work, e, "delete", + "--stack-name", stack, "--no-prompts", "--region", "us-east-1") + require.NoError(t, err, "sam delete stderr: %s", stderr) +} + +// `sam deploy --account ` lands the stack under that LocalStack account: the +// deployed function's ARN (read back via `sam list stack-outputs`) carries the +// custom account id. Both the deploy and the read use the same --account. +func TestSAME2EDeployCustomAccount(t *testing.T) { + requireDocker(t) + requireSAM(t) + token := requireAuthToken(t) + cleanup() + t.Cleanup(cleanup) + ctx := testContext(t) + startRealLocalStack(t, ctx, token) + + work := copySAMSample(t, "hello") + e := samE2EEnv(t) + const stack = "lstk-sam-e2e-acct" + const account = "111111111111" + + _, stderr, err := runSAM(t, ctx, work, e, "--account", account, "deploy", + "--stack-name", stack, "--resolve-s3", "--no-confirm-changeset", + "--no-fail-on-empty-changeset", "--capabilities", "CAPABILITY_IAM", "--region", "us-east-1") + require.NoError(t, err, "sam deploy stderr: %s", stderr) + + stdout, stderr, err := runSAM(t, ctx, work, e, "--account", account, "list", "stack-outputs", + "--stack-name", stack, "--output", "json", "--region", "us-east-1") + require.NoError(t, err, "sam list stack-outputs stderr: %s", stderr) + assert.Contains(t, stdout, account, "function ARN should carry the custom account id") + + _, stderr, err = runSAM(t, ctx, work, e, "--account", account, "delete", + "--stack-name", stack, "--no-prompts", "--region", "us-east-1") + require.NoError(t, err, "sam delete stderr: %s", stderr) +} diff --git a/test/integration/test-samples/iac/sam/hello/src/app.py b/test/integration/test-samples/iac/sam/hello/src/app.py new file mode 100644 index 00000000..fb26672c --- /dev/null +++ b/test/integration/test-samples/iac/sam/hello/src/app.py @@ -0,0 +1,2 @@ +def handler(event, context): + return {"statusCode": 200, "body": "ok from lstk sam"} diff --git a/test/integration/test-samples/iac/sam/hello/template.yaml b/test/integration/test-samples/iac/sam/hello/template.yaml new file mode 100644 index 00000000..0e858ee1 --- /dev/null +++ b/test/integration/test-samples/iac/sam/hello/template.yaml @@ -0,0 +1,18 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: lstk sam e2e hello + +Resources: + HelloFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: lstk-sam-e2e-fn + Handler: app.handler + Runtime: python3.12 + CodeUri: ./src + Timeout: 30 + +Outputs: + FunctionArn: + Description: ARN of the deployed function (its account segment proves which LocalStack account the stack landed in) + Value: !GetAtt HelloFunction.Arn