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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/<tool>/cli/`, wiring in `cmd/<tool>.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.
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
105 changes: 105 additions & 0 deletions cmd/sam.go
Original file line number Diff line number Diff line change
@@ -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 <region> Deployment region (default us-east-1)
--account <id> 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)
},
}
}
60 changes: 60 additions & 0 deletions internal/iac/sam/cli/defaults.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
54 changes: 54 additions & 0 deletions internal/iac/sam/cli/defaults_test.go
Original file line number Diff line number Diff line change
@@ -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"}))
}
90 changes: 90 additions & 0 deletions internal/iac/sam/cli/env.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading