Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ There is no `--offline` flag. Instead `container.Start` degrades gracefully when

Emulator type (aws/azure/snowflake) is always auto-detected by probing `/_localstack/health` (falling back to `/_localstack/info` for Azure, whose health response omits `version`) — there is no manual override flag or config setting; an inconclusive result is a hard failure. `terraform`/`cdk`/`sam` (AWS-only) reject a detected non-AWS type with the same error shape used for a wrong locally-running emulator.

# Already-Running / From-Source Instances

The proxies (`aws`, `az`, `terraform`/`cdk`/`sam`) plus `reset` and `snapshot save/load` work against a LocalStack instance lstk did not start — a from-source run, a hand-started container with an unknown image, or a remote host via `LOCALSTACK_HOST`. When Docker discovery finds nothing (or Docker is down), they probe `GET /_localstack/info` on the resolved host and attach silently on a LocalStack-shaped answer. `stop`/`logs`/`restart`/`status` remain Docker-only. Discovery semantics, the wrong-type guard, and the test-pinning rule (`deadLocalStackHost`) are documented in `internal/container/CLAUDE.md`.

# Emulator Setup Commands

Use `lstk setup <emulator>` to set up CLI integration for an emulator type:
Expand Down
19 changes: 5 additions & 14 deletions cmd/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import (
"github.com/localstack/lstk/internal/endpoint"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/terminal"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -130,11 +129,6 @@ Examples:
}
endpointURL = target.URL
} else {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
}

appCfg, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
Expand All @@ -148,23 +142,20 @@ Examples:
}
}

if err := rt.IsHealthy(cmd.Context()); err != nil {
rt.EmitUnhealthyError(sink, err)
return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err))
}
host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)

runningName, err := container.ResolveRunningContainerName(cmd.Context(), rt, awsContainer)
resolved, _, err := resolveReachableEmulator(cmd.Context(), cfg.DockerHost, sink, awsContainer, host)
if err != nil {
return fmt.Errorf("checking emulator status: %w", err)
return err
}
if runningName == "" {
if !resolved.Found() {
return container.HandleNoRunningContainer(sink, awsContainer)
}

host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)
endpointURL = "http://" + host
}


profileExists, _ := awsconfig.ProfileExists(cmd.Context())
if !profileExists {
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "No AWS profile found, run 'lstk setup aws'"})
Expand Down
22 changes: 7 additions & 15 deletions cmd/az.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import (
"github.com/localstack/lstk/internal/endpoint"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/terminal"
"github.com/localstack/lstk/internal/ui"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -173,8 +172,9 @@ func newAzStopInterceptionCmd(cfg *env.Env) *cobra.Command {
}

// azPreflight runs the checks shared by 'lstk az' passthrough and 'start-interception':
// the Azure CLI is installed, the Docker runtime is healthy, the Azure emulator is
// running, and *.localhost.localstack.cloud resolves. On failure it emits the matching
// the Azure CLI is installed, the Azure emulator is reachable (a managed container, or
// an already-running instance found via the HTTP probe when Docker discovery comes up
// empty), and *.localhost.localstack.cloud resolves. On failure it emits the matching
// ErrorEvent and returns a silent error. On success it returns the resolved LocalStack
// Azure endpoint URL.
//
Expand Down Expand Up @@ -213,24 +213,16 @@ func azPreflight(ctx context.Context, cfg *env.Env, sink output.Sink, target *en
}
}

rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return "", err
}
if err := rt.IsHealthy(ctx); err != nil {
rt.EmitUnhealthyError(sink, err)
return "", output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err))
}
resolvedHost, dnsOK := endpoint.ResolveHost(ctx, azureContainer.Port, cfg.LocalStackHost)

runningName, err := container.ResolveRunningContainerName(ctx, rt, azureContainer)
resolved, _, err := resolveReachableEmulator(ctx, cfg.DockerHost, sink, azureContainer, resolvedHost)
if err != nil {
return "", fmt.Errorf("checking emulator status: %w", err)
return "", err
}
if runningName == "" {
if !resolved.Found() {
return "", container.HandleNoRunningContainer(sink, azureContainer)
}

resolvedHost, dnsOK := endpoint.ResolveHost(ctx, azureContainer.Port, cfg.LocalStackHost)
if !dnsOK {
sink.Emit(output.ErrorEvent{
Title: "DNS resolution required for 'lstk az'",
Expand Down
14 changes: 2 additions & 12 deletions cmd/cdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
cdkcli "github.com/localstack/lstk/internal/iac/cdk/cli"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -119,21 +118,12 @@ Examples:
return cdkcli.Run(cmd.Context(), target.URL, region, sink, logger, cdkArgs)
}

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))
}
host, dnsOK := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)

if err := requireRunningAWSEmulator(cmd.Context(), rt, sink, awsContainer, "cdk"); err != nil {
if err := requireRunningAWSEmulator(cmd.Context(), cfg.DockerHost, sink, awsContainer, host, "cdk"); err != nil {
return err
}

host, dnsOK := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)
if !dnsOK {
// CDK has no env-only lever to force S3 path style, so on the
// loopback fallback its S3 asset operations (bootstrap, asset
Expand Down
51 changes: 51 additions & 0 deletions cmd/emulator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cmd

// Command-boundary emulator reachability shared by the proxy commands (aws,
// az, terraform, cdk, sam). Lives in cmd/ (not a domain package) because it
// constructs the runtime from env config and renders errors through the sink.

import (
"context"
"fmt"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
)

// resolveReachableEmulator constructs the Docker runtime, checks its health,
// and resolves the emulator via container.ResolveEmulator: Docker discovery
// first, then an HTTP probe of host, which also finds instances lstk did not
// start (e.g. LocalStack running from source). Docker being unavailable is
// fatal only when the probe finds nothing either, preserving today's errors
// (raw construction error, or the standard unhealthy ErrorEvent as a silent
// error). err == nil with !resolved.Found() means Docker is healthy but
// nothing answered — the caller picks its own not-running message. The
// returned runtime is non-nil only when Docker is healthy.
func resolveReachableEmulator(ctx context.Context, dockerHost string, sink output.Sink, c config.ContainerConfig, host string) (container.ResolvedEmulator, runtime.Runtime, error) {
var healthyRT runtime.Runtime
var healthErr error
rt, rtErr := runtime.NewDockerRuntime(dockerHost)
if rtErr == nil {
if healthErr = rt.IsHealthy(ctx); healthErr == nil {
healthyRT = rt
}
}

resolved, err := container.ResolveEmulator(ctx, healthyRT, c, host)
if err != nil {
return container.ResolvedEmulator{}, healthyRT, fmt.Errorf("checking emulator status: %w", err)
}
if resolved.Found() {
return resolved, healthyRT, nil
}
if rtErr != nil {
return container.ResolvedEmulator{}, nil, rtErr
}
if healthErr != nil {
rt.EmitUnhealthyError(sink, healthErr)
return container.ResolvedEmulator{}, nil, output.NewSilentError(fmt.Errorf("runtime not healthy: %w", healthErr))
}
return container.ResolvedEmulator{}, healthyRT, nil
}
38 changes: 21 additions & 17 deletions cmd/iac.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,32 +22,36 @@ import (

var accountIDRe = regexp.MustCompile(`^\d{12}$`)

// requireRunningAWSEmulator verifies the AWS emulator is running before an IaC
// proxy command (terraform/cdk) that contacts AWS proceeds. When it is not
// running it emits an actionable error through the sink — an AWS-specific
// requireRunningAWSEmulator verifies the AWS emulator is reachable before an
// IaC proxy command (terraform/cdk/sam) that contacts AWS proceeds — a managed
// container found via Docker, or an already-running instance answering the
// HTTP probe on host (e.g. LocalStack running from source). When nothing is
// reachable it emits an actionable error through the sink — an AWS-specific
// message naming the other emulator when a non-AWS one is up, otherwise the
// generic "not running" error — and returns a silent error. cmdLabel is the
// lstk command name used in the message (e.g. "terraform"/"cdk"). It returns nil
// when the AWS emulator is running.
func requireRunningAWSEmulator(ctx context.Context, rt runtime.Runtime, sink output.Sink, awsContainer config.ContainerConfig, cmdLabel string) error {
runningName, err := container.ResolveRunningContainerName(ctx, rt, awsContainer)
// when the AWS emulator is reachable.
func requireRunningAWSEmulator(ctx context.Context, dockerHost string, sink output.Sink, awsContainer config.ContainerConfig, host, cmdLabel string) error {
resolved, rt, err := resolveReachableEmulator(ctx, dockerHost, sink, awsContainer, host)
if err != nil {
return fmt.Errorf("checking emulator status: %w", err)
return err
}
if runningName != "" {
if resolved.Found() {
return nil
}
// These commands only work with the AWS emulator. If a different emulator
// is running, say so specifically rather than reporting a misleading
// "AWS not running".
if other := runningNonAWSEmulator(ctx, rt); other != "" {
sink.Emit(output.ErrorEvent{
Title: fmt.Sprintf("lstk %s requires the %s, but the %s is running", cmdLabel, awsContainer.DisplayName(), other),
Actions: []output.ErrorAction{
{Label: "Start the AWS emulator:", Value: "lstk"},
},
})
return output.NewSilentError(fmt.Errorf("lstk %s requires the AWS emulator, but the %s is running", cmdLabel, other))
// "AWS not running". Skipped when Docker is unavailable (rt == nil).
if rt != nil {
if other := runningNonAWSEmulator(ctx, rt); other != "" {
sink.Emit(output.ErrorEvent{
Title: fmt.Sprintf("lstk %s requires the %s, but the %s is running", cmdLabel, awsContainer.DisplayName(), other),
Actions: []output.ErrorAction{
{Label: "Start the AWS emulator:", Value: "lstk"},
},
})
return output.NewSilentError(fmt.Errorf("lstk %s requires the AWS emulator, but the %s is running", cmdLabel, other))
}
}
return container.HandleNoRunningContainer(sink, awsContainer)
}
Expand Down
15 changes: 2 additions & 13 deletions cmd/sam.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
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"
)

Expand Down Expand Up @@ -116,22 +115,12 @@ Examples:
return samcli.Run(cmd.Context(), target.URL, 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))
}
host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)

if err := requireRunningAWSEmulator(cmd.Context(), rt, sink, awsContainer, "sam"); err != nil {
if err := requireRunningAWSEmulator(cmd.Context(), cfg.DockerHost, sink, awsContainer, host, "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)
},
}
Expand Down
14 changes: 2 additions & 12 deletions cmd/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
tfcli "github.com/localstack/lstk/internal/iac/terraform/cli"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -120,23 +119,14 @@ Examples:
}
endpointURL = target.URL
} else {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
}

awsContainer := resolveAWSContainer()

if err := rt.IsHealthy(cmd.Context()); err != nil {
rt.EmitUnhealthyError(sink, err)
return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err))
}
host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)

if err := requireRunningAWSEmulator(cmd.Context(), rt, sink, awsContainer, "terraform"); err != nil {
if err := requireRunningAWSEmulator(cmd.Context(), cfg.DockerHost, sink, awsContainer, host, "terraform"); err != nil {
return err
}

host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)
endpointURL = "http://" + host
}

Expand Down
10 changes: 10 additions & 0 deletions internal/container/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

Detail moved out of the root CLAUDE.md.

## Emulator discovery and external instances

Discovery is Docker-first with an HTTP fallback. `ResolveRunningContainerName` (running.go) is the Docker-only path: exact container-name match (`localstack-{type}`), then `FindRunningByImage` (known image repos + internal port). `ResolveEmulator` wraps it and, when Docker finds nothing — or Docker is unavailable (`rt == nil`) — probes `GET /_localstack/info` on the resolved host (`ProbeEmulatorInfo`, info.go: 2s timeout, requires 200 + JSON + non-empty `version` so an unrelated service can't false-positive). A successful probe yields an **external instance** (`ResolvedEmulator.External`): something lstk did not start, e.g. LocalStack running from source (`uv run -m localstack.runtime.main`) or reached via `LOCALSTACK_HOST`. The probe runs only on paths that previously errored, so container flows are unchanged and no latency is added to success paths.

Guard: `/_localstack/info` cannot identify the emulator product, so when Docker is healthy and a known LocalStack container of *any* type is running, `ResolveEmulator` treats the probe answer as that container and reports not-found — preserving the type-mismatch errors (e.g. `lstk terraform` with only Snowflake up). With Docker down the guard can't run; that looseness is accepted (from-source runs are overwhelmingly single-type).

Consumers: the proxies (`aws`, `az`, `terraform`/`cdk`/`sam`) go through `resolveReachableEmulator` in `cmd/emulator.go`; `reset` and `snapshot save/load` go through `FirstReachableEmulator` (running.go), which also demotes the Docker health check to lazy — "Docker is not available" is emitted only when the probe finds nothing either. `snapshot load`'s auto-starter requires Docker, so it runs only when Docker is healthy and nothing is reachable; an external instance is used as-is. `stop`, `logs`, `restart`, and `status` remain Docker-only (a non-container instance cannot be stopped or log-tailed by lstk; status/stop/logs messaging for external instances is a planned follow-up).

Integration tests for external instances live in `test/integration/external_instance_test.go`. **When adding a negative-path test** asserting "is not running"/"Docker is not available" on a probe-adopting command, pin `LOCALSTACK_HOST` to `deadLocalStackHost` (`127.0.0.1:1`) — otherwise the probe finds any real LocalStack on the developer's 4566 and the test flakes exactly on the machines this feature targets.

## GATEWAY_LISTEN and host exposure

`GATEWAY_LISTEN` is not hardcoded — it is read from the container's resolved env (set it via an `[env.*]` profile referenced by the container's `env` field). When unset it defaults to `:4566,:443`. Parsing/derivation lives in `internal/container/gateway.go` (`parseGatewayListen`), mirroring the v1 CLI:
Expand Down
16 changes: 14 additions & 2 deletions internal/container/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ import (
"github.com/localstack/lstk/internal/telemetry"
)

func fetchLocalStackInfo(ctx context.Context, port string) (*telemetry.LocalStackInfo, error) {
url := fmt.Sprintf("http://localhost:%s/_localstack/info", port)
// ProbeEmulatorInfo fetches /_localstack/info from host ("host:port", plain
// HTTP, 2s timeout). It errors when nothing LocalStack-like answers there:
// transport error, non-200, non-JSON, or a response without a version (any
// JSON object decodes into LocalStackInfo, so an unrelated service returning
// 200 JSON must not count as a LocalStack instance).
func ProbeEmulatorInfo(ctx context.Context, host string) (*telemetry.LocalStackInfo, error) {
url := fmt.Sprintf("http://%s/_localstack/info", host)
client := &http.Client{Timeout: 2 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
Expand All @@ -29,5 +34,12 @@ func fetchLocalStackInfo(ctx context.Context, port string) (*telemetry.LocalStac
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, err
}
if info.Version == "" {
return nil, fmt.Errorf("no LocalStack version in /_localstack/info response")
}
return &info, nil
}

func fetchLocalStackInfo(ctx context.Context, port string) (*telemetry.LocalStackInfo, error) {
return ProbeEmulatorInfo(ctx, "localhost:"+port)
}
Loading
Loading