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 @@ -142,6 +142,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 and the wrong-type guard are documented on `container.ResolveEmulator`/`container.FirstReachableEmulator` (`internal/container/running.go`) and `resolveReachableEmulator` (`cmd/emulator.go`); the test-pinning rule is on `deadLocalStackHost` (`test/integration/external_instance_test.go`).

# 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 @@ -163,11 +162,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 @@ -181,23 +175,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 @@ -20,32 +20,36 @@ import (
// The leading-flag parsing and account resolution that used to live here now
// sit in proxy.go: `lstk aws` shares them, so they are no longer IaC-specific.

// 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, regionSelected, 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, regionSelected, 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
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