From 16bc7494f87fe76d7364ecbb9ccc411862c12ace Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Fri, 24 Jul 2026 13:55:29 +0300 Subject: [PATCH 1/2] Use already-running LocalStack instances when Docker discovery finds nothing Co-Authored-By: Claude --- CLAUDE.md | 4 + cmd/aws.go | 19 +- cmd/az.go | 22 +- cmd/cdk.go | 14 +- cmd/emulator.go | 51 ++++ cmd/iac.go | 38 +-- cmd/sam.go | 15 +- cmd/terraform.go | 14 +- internal/container/CLAUDE.md | 10 + internal/container/info.go | 16 +- internal/container/info_test.go | 64 +++++ internal/container/running.go | 88 +++++++ internal/container/running_test.go | 181 ++++++++++++++ internal/reset/reset.go | 13 +- internal/snapshot/load.go | 25 +- internal/snapshot/remote.go | 4 +- internal/snapshot/save.go | 17 +- test/integration/aws_cmd_test.go | 6 +- test/integration/cdk_cmd_test.go | 3 +- test/integration/external_instance_test.go | 273 +++++++++++++++++++++ test/integration/reset_test.go | 7 +- test/integration/sam_cmd_test.go | 3 +- test/integration/setup_azure_test.go | 2 +- test/integration/snapshot_save_test.go | 2 +- test/integration/terraform_cmd_test.go | 3 +- 25 files changed, 765 insertions(+), 129 deletions(-) create mode 100644 cmd/emulator.go create mode 100644 internal/container/info_test.go create mode 100644 internal/container/running_test.go create mode 100644 test/integration/external_instance_test.go diff --git a/CLAUDE.md b/CLAUDE.md index dba1270a..baaacfe6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` to set up CLI integration for an emulator type: diff --git a/cmd/aws.go b/cmd/aws.go index 76d7064f..bc6d6e9e 100644 --- a/cmd/aws.go +++ b/cmd/aws.go @@ -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" ) @@ -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) @@ -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'"}) diff --git a/cmd/az.go b/cmd/az.go index 4c1c0ca2..34295bb0 100644 --- a/cmd/az.go +++ b/cmd/az.go @@ -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" @@ -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. // @@ -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'", diff --git a/cmd/cdk.go b/cmd/cdk.go index d5edd107..2aa190cd 100644 --- a/cmd/cdk.go +++ b/cmd/cdk.go @@ -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" ) @@ -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 diff --git a/cmd/emulator.go b/cmd/emulator.go new file mode 100644 index 00000000..4c91ef4f --- /dev/null +++ b/cmd/emulator.go @@ -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 +} diff --git a/cmd/iac.go b/cmd/iac.go index 97706fd9..1ec7dc17 100644 --- a/cmd/iac.go +++ b/cmd/iac.go @@ -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) } diff --git a/cmd/sam.go b/cmd/sam.go index b93142bd..40baec9d 100644 --- a/cmd/sam.go +++ b/cmd/sam.go @@ -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" ) @@ -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) }, } diff --git a/cmd/terraform.go b/cmd/terraform.go index bf94341b..1b3f0e13 100644 --- a/cmd/terraform.go +++ b/cmd/terraform.go @@ -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" ) @@ -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 } diff --git a/internal/container/CLAUDE.md b/internal/container/CLAUDE.md index eeb9df51..a2ddd4d6 100644 --- a/internal/container/CLAUDE.md +++ b/internal/container/CLAUDE.md @@ -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: diff --git a/internal/container/info.go b/internal/container/info.go index 070511f5..9a91a505 100644 --- a/internal/container/info.go +++ b/internal/container/info.go @@ -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 { @@ -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) +} diff --git a/internal/container/info_test.go b/internal/container/info_test.go new file mode 100644 index 00000000..751ca27b --- /dev/null +++ b/internal/container/info_test.go @@ -0,0 +1,64 @@ +package container + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func infoServer(t *testing.T, status int, body string) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/_localstack/info" { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return strings.TrimPrefix(srv.URL, "http://") +} + +func TestProbeEmulatorInfoReturnsInfo(t *testing.T) { + host := infoServer(t, http.StatusOK, `{"version":"4.16.0","edition":"pro","is_docker":false,"uptime":42}`) + + info, err := ProbeEmulatorInfo(context.Background(), host) + require.NoError(t, err) + assert.Equal(t, "4.16.0", info.Version) + assert.Equal(t, "pro", info.Edition) + assert.Equal(t, 42, info.Uptime) +} + +func TestProbeEmulatorInfoRejectsNon200(t *testing.T) { + host := infoServer(t, http.StatusServiceUnavailable, `{}`) + + _, err := ProbeEmulatorInfo(context.Background(), host) + assert.Error(t, err) +} + +func TestProbeEmulatorInfoRejectsNonJSON(t *testing.T) { + host := infoServer(t, http.StatusOK, `not localstack`) + + _, err := ProbeEmulatorInfo(context.Background(), host) + assert.Error(t, err) +} + +func TestProbeEmulatorInfoRejectsEmptyVersion(t *testing.T) { + // Any JSON object decodes into LocalStackInfo, so a 200 from an unrelated + // service must not count as a LocalStack instance. + host := infoServer(t, http.StatusOK, `{"status":"ok"}`) + + _, err := ProbeEmulatorInfo(context.Background(), host) + assert.Error(t, err) +} + +func TestProbeEmulatorInfoUnreachableHost(t *testing.T) { + _, err := ProbeEmulatorInfo(context.Background(), "127.0.0.1:1") + assert.Error(t, err) +} diff --git a/internal/container/running.go b/internal/container/running.go index cce1cb02..00dddabc 100644 --- a/internal/container/running.go +++ b/internal/container/running.go @@ -8,6 +8,7 @@ import ( "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" + "github.com/localstack/lstk/internal/telemetry" ) func StillRunningMessage(running []config.ContainerConfig) string { @@ -60,6 +61,93 @@ func ResolveRunningContainerName(ctx context.Context, rt runtime.Runtime, c conf return "", nil } +// ResolvedEmulator reports how the emulator can be reached. +type ResolvedEmulator struct { + ContainerName string // non-empty: managed container found via the runtime + External bool // reachable over HTTP only (e.g. running from source) + Info *telemetry.LocalStackInfo // /_localstack/info payload when External +} + +func (r ResolvedEmulator) Found() bool { + return r.ContainerName != "" || r.External +} + +// ResolveEmulator locates the emulator described by c: first via the container +// runtime (skipped when rt is nil, i.e. Docker is unavailable), then by probing +// host's /_localstack/info endpoint, which also finds instances lstk did not +// start (e.g. LocalStack running from source). It emits nothing; the returned +// error covers only runtime-API failures — an unreachable endpoint is a +// not-found result, not an error. +func ResolveEmulator(ctx context.Context, rt runtime.Runtime, c config.ContainerConfig, host string) (ResolvedEmulator, error) { + if rt != nil { + name, err := ResolveRunningContainerName(ctx, rt, c) + if err != nil { + return ResolvedEmulator{}, err + } + if name != "" { + return ResolvedEmulator{ContainerName: name}, nil + } + } + + info, err := ProbeEmulatorInfo(ctx, host) + if err != nil { + return ResolvedEmulator{}, nil + } + + // The probe cannot tell which emulator product answered. When a known + // LocalStack container of any type is running, the answer is that + // container, not an external instance — report not-found so callers keep + // today's type-mismatch errors. Without Docker the guard cannot run; the + // looseness is accepted (from-source runs are overwhelmingly single-type). + if rt != nil { + containerPort, err := c.ContainerPort() + if err != nil { + return ResolvedEmulator{}, err + } + found, err := rt.FindRunningByImage(ctx, config.KnownImageRepos(), containerPort) + if err != nil { + return ResolvedEmulator{}, fmt.Errorf("failed to scan for running containers: %w", err) + } + if found != nil { + return ResolvedEmulator{}, nil + } + } + + return ResolvedEmulator{External: true, Info: info}, nil +} + +// FirstReachableEmulator returns the first container from containers that is +// reachable: via Docker discovery when the runtime is healthy, else via the +// HTTP probe of host (which also finds instances lstk did not start, e.g. +// LocalStack running from source). Docker being unhealthy is fatal only when +// the probe finds nothing either — then the standard unhealthy error is +// emitted through sink and a silent error returned, preserving today's +// behavior. A zero result with a nil error means Docker is healthy but +// nothing answered; callers emit their own not-running message. +func FirstReachableEmulator(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []config.ContainerConfig, host string) (config.ContainerConfig, ResolvedEmulator, error) { + discoveryRT := rt + healthErr := rt.IsHealthy(ctx) + if healthErr != nil { + discoveryRT = nil + } + + for _, c := range containers { + resolved, err := ResolveEmulator(ctx, discoveryRT, c, host) + if err != nil { + return config.ContainerConfig{}, ResolvedEmulator{}, fmt.Errorf("checking emulator status: %w", err) + } + if resolved.Found() { + return c, resolved, nil + } + } + + if healthErr != nil { + rt.EmitUnhealthyError(sink, healthErr) + return config.ContainerConfig{}, ResolvedEmulator{}, output.NewSilentError(fmt.Errorf("runtime not healthy: %w", healthErr)) + } + return config.ContainerConfig{}, ResolvedEmulator{}, nil +} + // HandleNoRunningContainer emits the standard "not running" error for c // through sink (naming it and pointing the user at how to start it), then // returns a silent error for the caller to propagate. Callers that already diff --git a/internal/container/running_test.go b/internal/container/running_test.go new file mode 100644 index 00000000..c80eb503 --- /dev/null +++ b/internal/container/running_test.go @@ -0,0 +1,181 @@ +package container + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func nopSink() output.Sink { + return output.SinkFunc(func(output.Event) {}) +} + +// countingInfoServer serves /_localstack/info and counts requests, so tests can +// assert the HTTP probe did or did not run. +func countingInfoServer(t *testing.T) (host string, calls *atomic.Int32) { + t.Helper() + var n atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/_localstack/info" { + w.WriteHeader(http.StatusNotFound) + return + } + n.Add(1) + _, _ = w.Write([]byte(`{"version":"4.16.0","edition":"community"}`)) + })) + t.Cleanup(srv.Close) + return strings.TrimPrefix(srv.URL, "http://"), &n +} + +func awsTestContainer() config.ContainerConfig { + return config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultPort} +} + +func TestResolveEmulatorManagedContainerSkipsProbe(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + c := awsTestContainer() + mockRT.EXPECT().IsRunning(gomock.Any(), c.Name()).Return(true, nil) + + host, calls := countingInfoServer(t) + + resolved, err := ResolveEmulator(context.Background(), mockRT, c, host) + require.NoError(t, err) + assert.Equal(t, c.Name(), resolved.ContainerName) + assert.False(t, resolved.External) + assert.True(t, resolved.Found()) + assert.Equal(t, int32(0), calls.Load(), "probe must not run when a managed container is found") +} + +func TestResolveEmulatorFallsBackToProbe(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + c := awsTestContainer() + containerPort, err := c.ContainerPort() + require.NoError(t, err) + + mockRT.EXPECT().IsRunning(gomock.Any(), c.Name()).Return(false, nil) + mockRT.EXPECT().FindRunningByImage(gomock.Any(), config.KnownImageReposForType(c.Type), containerPort).Return(nil, nil) + // Wrong-type guard: no known LocalStack container of any type is running. + mockRT.EXPECT().FindRunningByImage(gomock.Any(), config.KnownImageRepos(), containerPort).Return(nil, nil) + + host, calls := countingInfoServer(t) + + resolved, err := ResolveEmulator(context.Background(), mockRT, c, host) + require.NoError(t, err) + assert.Empty(t, resolved.ContainerName) + assert.True(t, resolved.External) + assert.True(t, resolved.Found()) + require.NotNil(t, resolved.Info) + assert.Equal(t, "4.16.0", resolved.Info.Version) + assert.Equal(t, int32(1), calls.Load()) +} + +func TestResolveEmulatorNilRuntimeProbesDirectly(t *testing.T) { + host, _ := countingInfoServer(t) + + resolved, err := ResolveEmulator(context.Background(), nil, awsTestContainer(), host) + require.NoError(t, err) + assert.True(t, resolved.External) + assert.True(t, resolved.Found()) +} + +func TestResolveEmulatorNilRuntimeNothingListening(t *testing.T) { + resolved, err := ResolveEmulator(context.Background(), nil, awsTestContainer(), "127.0.0.1:1") + require.NoError(t, err) + assert.False(t, resolved.Found()) +} + +func TestResolveEmulatorProbeAnswerFromOtherEmulatorContainer(t *testing.T) { + // A known LocalStack container of a different type is running: the probe + // answer is that container, not an external instance, so resolution must + // report not-found and let callers keep today's type-mismatch errors. + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + c := awsTestContainer() + containerPort, err := c.ContainerPort() + require.NoError(t, err) + + mockRT.EXPECT().IsRunning(gomock.Any(), c.Name()).Return(false, nil) + mockRT.EXPECT().FindRunningByImage(gomock.Any(), config.KnownImageReposForType(c.Type), containerPort).Return(nil, nil) + mockRT.EXPECT().FindRunningByImage(gomock.Any(), config.KnownImageRepos(), containerPort). + Return(&runtime.RunningContainer{Name: "localstack-snowflake", Image: "localstack/snowflake:latest"}, nil) + + host, _ := countingInfoServer(t) + + resolved, err := ResolveEmulator(context.Background(), mockRT, c, host) + require.NoError(t, err) + assert.False(t, resolved.Found()) +} + +func TestFirstReachableEmulatorManagedContainer(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + c := awsTestContainer() + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), c.Name()).Return(true, nil) + + target, resolved, err := FirstReachableEmulator(context.Background(), mockRT, nopSink(), []config.ContainerConfig{c}, "127.0.0.1:1") + require.NoError(t, err) + assert.Equal(t, c.Type, target.Type) + assert.True(t, resolved.Found()) +} + +func TestFirstReachableEmulatorDockerDownProbeOK(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(assert.AnError) + + host, _ := countingInfoServer(t) + + target, resolved, err := FirstReachableEmulator(context.Background(), mockRT, nopSink(), []config.ContainerConfig{awsTestContainer()}, host) + require.NoError(t, err) + assert.Equal(t, config.EmulatorAWS, target.Type) + assert.True(t, resolved.External) +} + +func TestFirstReachableEmulatorDockerDownNothingListening(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(assert.AnError) + mockRT.EXPECT().EmitUnhealthyError(gomock.Any(), assert.AnError) + + _, _, err := FirstReachableEmulator(context.Background(), mockRT, nopSink(), []config.ContainerConfig{awsTestContainer()}, "127.0.0.1:1") + require.Error(t, err) + assert.True(t, output.IsSilent(err)) +} + +func TestFirstReachableEmulatorDockerHealthyNothingFound(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + c := awsTestContainer() + containerPort, err := c.ContainerPort() + require.NoError(t, err) + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), c.Name()).Return(false, nil) + mockRT.EXPECT().FindRunningByImage(gomock.Any(), config.KnownImageReposForType(c.Type), containerPort).Return(nil, nil) + + _, resolved, err := FirstReachableEmulator(context.Background(), mockRT, nopSink(), []config.ContainerConfig{c}, "127.0.0.1:1") + require.NoError(t, err) + assert.False(t, resolved.Found()) +} + +func TestResolveEmulatorRuntimeErrorPropagates(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + c := awsTestContainer() + mockRT.EXPECT().IsRunning(gomock.Any(), c.Name()).Return(false, assert.AnError) + + _, err := ResolveEmulator(context.Background(), mockRT, c, "127.0.0.1:1") + assert.Error(t, err) +} diff --git a/internal/reset/reset.go b/internal/reset/reset.go index 54b21e9d..025cc475 100644 --- a/internal/reset/reset.go +++ b/internal/reset/reset.go @@ -18,16 +18,11 @@ type StateResetter interface { } func Reset(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, resetter StateResetter, host string, force bool, sink output.Sink) (retErr error) { - if err := rt.IsHealthy(ctx); err != nil { - rt.EmitUnhealthyError(sink, err) - return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) - } - - runningContainers, err := container.RunningEmulators(ctx, rt, containers) + target, resolved, err := container.FirstReachableEmulator(ctx, rt, sink, containers, host) if err != nil { - return fmt.Errorf("checking emulator status: %w", err) + return err } - if len(runningContainers) == 0 { + if !resolved.Found() { sink.Emit(output.ErrorEvent{ Title: "LocalStack is not running", Actions: []output.ErrorAction{ @@ -65,7 +60,7 @@ func Reset(ctx context.Context, rt runtime.Runtime, containers []config.Containe defer func() { sink.Emit(output.SpinnerStop()) if retErr == nil { - sink.Emit(output.EmulatorResetEvent{Type: string(runningContainers[0].Type), Name: runningContainers[0].Name()}) + sink.Emit(output.EmulatorResetEvent{Type: string(target.Type), Name: target.Name()}) } }() diff --git a/internal/snapshot/load.go b/internal/snapshot/load.go index 0c592d9a..27638e0c 100644 --- a/internal/snapshot/load.go +++ b/internal/snapshot/load.go @@ -89,21 +89,20 @@ type PodLoader interface { } // load is the shared entry point for both LoadLocal and LoadPod. -// It checks runtime health, auto-starts the emulator if needed, then runs do(). -func load(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, sink output.Sink, starter Starter, spinnerText string, onSuccess func(), do func() error) (retErr error) { - if err := rt.IsHealthy(ctx); err != nil { - rt.EmitUnhealthyError(sink, err) - return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) +// It checks the emulator is reachable (a managed container, or an +// already-running instance answering the HTTP probe on host, e.g. LocalStack +// running from source), auto-starts the emulator if needed, then runs do(). +// The auto-starter requires Docker, so it only runs when Docker is healthy; +// an external instance found via the probe is used as-is. +func load(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, sink output.Sink, host string, starter Starter, spinnerText string, onSuccess func(), do func() error) (retErr error) { + _, resolved, err := container.FirstReachableEmulator(ctx, rt, sink, containers, host) + if err != nil { + return err } emitExperimentalWarning(containers, sink) - runningContainers, err := container.RunningEmulators(ctx, rt, containers) - if err != nil { - return fmt.Errorf("checking emulator status: %w", err) - } - - if len(runningContainers) == 0 { + if !resolved.Found() { if starter == nil { sink.Emit(output.ErrorEvent{ Title: "LocalStack is not running", @@ -162,7 +161,7 @@ func LoadLocal(ctx context.Context, rt runtime.Runtime, containers []config.Cont cwd, _ := os.Getwd() home, _ := os.UserHomeDir() - return load(ctx, rt, containers, sink, starter, + return load(ctx, rt, containers, sink, host, starter, "Loading snapshot...", func() { sink.Emit(output.SnapshotLoadedEvent{Source: displayPath(src, cwd, home)}) @@ -201,7 +200,7 @@ func LoadPod(ctx context.Context, rt runtime.Runtime, containers []config.Contai } var services []string - err := load(ctx, rt, containers, sink, starter, + err := load(ctx, rt, containers, sink, host, starter, spinnerText, func() { sink.Emit(output.SnapshotLoadedEvent{ diff --git a/internal/snapshot/remote.go b/internal/snapshot/remote.go index ffb67fa0..459a3bd1 100644 --- a/internal/snapshot/remote.go +++ b/internal/snapshot/remote.go @@ -144,7 +144,7 @@ func SaveRemoteS3(ctx context.Context, rt runtime.Runtime, containers []config.C name := remoteName(s3URL) remoteURL := templatedRemoteURL(s3URL, creds.SessionToken != "") var result PodSaveResult - return save(ctx, rt, containers, sink, + return save(ctx, rt, containers, sink, host, fmt.Sprintf("Saving snapshot to %s...", s3URL), func() { sink.Emit(output.RemoteSnapshotSavedEvent{ @@ -175,7 +175,7 @@ func LoadRemoteS3(ctx context.Context, rt runtime.Runtime, containers []config.C name := remoteName(s3URL) remoteURL := templatedRemoteURL(s3URL, creds.SessionToken != "") var services []string - return load(ctx, rt, containers, sink, starter, + return load(ctx, rt, containers, sink, host, starter, fmt.Sprintf("Loading snapshot %q from %s...", podName, s3URL), func() { sink.Emit(output.SnapshotLoadedEvent{ diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index 5252b5b2..000dbc84 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -35,17 +35,12 @@ type PodSaver interface { SavePodSnapshot(ctx context.Context, host, podName, authToken string, services []string) (PodSaveResult, error) } -func save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, sink output.Sink, spinnerText string, onSuccess func(), do func() error) (retErr error) { - if err := rt.IsHealthy(ctx); err != nil { - rt.EmitUnhealthyError(sink, err) - return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) - } - - runningContainers, err := container.RunningEmulators(ctx, rt, containers) +func save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, sink output.Sink, host, spinnerText string, onSuccess func(), do func() error) (retErr error) { + _, resolved, err := container.FirstReachableEmulator(ctx, rt, sink, containers, host) if err != nil { - return fmt.Errorf("checking emulator status: %w", err) + return err } - if len(runningContainers) == 0 { + if !resolved.Found() { sink.Emit(output.ErrorEvent{ Title: "LocalStack is not running", Actions: []output.ErrorAction{ @@ -81,7 +76,7 @@ func SaveLocal(ctx context.Context, rt runtime.Runtime, containers []config.Cont cwd, _ := os.Getwd() home, _ := os.UserHomeDir() var extracted []string - return save(ctx, rt, containers, sink, + return save(ctx, rt, containers, sink, host, "Saving snapshot...", func() { sink.Emit(output.LocalSnapshotSavedEvent{ @@ -124,7 +119,7 @@ func SavePod(ctx context.Context, rt runtime.Runtime, containers []config.Contai return fmt.Errorf("pod snapshots require authentication — set LOCALSTACK_AUTH_TOKEN or run %q", "lstk login") } var result PodSaveResult - return save(ctx, rt, containers, sink, + return save(ctx, rt, containers, sink, host, fmt.Sprintf("Saving snapshot to pod %q...", podName), func() { sink.Emit(output.PodSnapshotSavedEvent{ diff --git a/test/integration/aws_cmd_test.go b/test/integration/aws_cmd_test.go index 1fd94e28..b6dc19cc 100644 --- a/test/integration/aws_cmd_test.go +++ b/test/integration/aws_cmd_test.go @@ -292,7 +292,8 @@ func TestAWSCommandFailsWhenDockerNotRunning(t *testing.T) { fakeDir := writeFakeAWS(t) e := env.With(env.DisableEvents, "1"). With("PATH", fakeDir). - With(env.Key("DOCKER_HOST"), "tcp://localhost:1") + With(env.Key("DOCKER_HOST"), "tcp://localhost:1"). + With(env.LocalStackHost, deadLocalStackHost) stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") require.Error(t, err) @@ -307,7 +308,8 @@ func TestAWSCommandFailsWhenEmulatorNotRunning(t *testing.T) { fakeDir := writeFakeAWS(t) analyticsSrv, events := mockAnalyticsServer(t) e := env.With("PATH", fakeDir). - With(env.AnalyticsEndpoint, analyticsSrv.URL) + With(env.AnalyticsEndpoint, analyticsSrv.URL). + With(env.LocalStackHost, deadLocalStackHost) stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") require.Error(t, err) diff --git a/test/integration/cdk_cmd_test.go b/test/integration/cdk_cmd_test.go index 3c5050fa..882b0046 100644 --- a/test/integration/cdk_cmd_test.go +++ b/test/integration/cdk_cmd_test.go @@ -244,7 +244,8 @@ func TestCDKFailsWhenEmulatorNotRunning(t *testing.T) { t.Cleanup(cleanup) fakeDir := writeFakeCDK(t, "2.177.0") - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()). + With(env.LocalStackHost, deadLocalStackHost) stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "cdk", "deploy") require.Error(t, err) diff --git a/test/integration/external_instance_test.go b/test/integration/external_instance_test.go new file mode 100644 index 00000000..f51ad31e --- /dev/null +++ b/test/integration/external_instance_test.go @@ -0,0 +1,273 @@ +package integration_test + +import ( + "archive/zip" + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// deadLocalStackHost pins LOCALSTACK_HOST to a closed port so the emulator +// reachability probe deterministically fails. Negative-path tests ("is not +// running", "Docker is not available") must set this: without it they would +// probe 127.0.0.1:4566 and attach to a real LocalStack instance on the +// developer's machine (e.g. one running from source). +const deadLocalStackHost = "127.0.0.1:1" + +// mockLocalStackInfoServer serves /_localstack/info the way a running +// LocalStack instance (container or from-source) does, so tests can stand in +// for an emulator lstk did not start. Extra handlers extend the mux for +// endpoints a command calls after discovery (reset, snapshot, ...). +func mockLocalStackInfoServer(t *testing.T, extra map[string]http.HandlerFunc) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/_localstack/info", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"version":"4.16.0","edition":"community","is_docker":false,"uptime":42}`)) + }) + for pattern, handler := range extra { + mux.HandleFunc(pattern, handler) + } + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestAWSCommandUsesExternalInstance is the from-source regression test for +// the head-of-engineering report: `lstk aws` against a LocalStack that is a +// plain process (no container, Docker daemon down) must proxy to it instead of +// failing with "Docker is not available". +func TestAWSCommandUsesExternalInstance(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("fake aws script and unix-socket DOCKER_HOST not supported on Windows") + } + + srv := mockLocalStackInfoServer(t, nil) + fakeDir := writeFakeAWS(t) + e := unhealthyDockerEnv(). + With(env.DisableEvents, "1"). + With("PATH", fakeDir). + With(env.Home, t.TempDir()). + With(env.LocalStackHost, lsHost(srv)) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + require.NoError(t, err, "lstk aws should use the external instance: %s", stderr) + + assert.Contains(t, stdout, "ENDPOINT:http://"+lsHost(srv)) + assert.Contains(t, stdout, "ARGS:s3 ls") +} + +// Without LOCALSTACK_HOST, the probe targets the configured port — the +// zero-config from-source case (instance on the config/default port). +func TestAWSCommandUsesExternalInstanceFromConfigPort(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("fake aws script and unix-socket DOCKER_HOST not supported on Windows") + } + + srv := mockLocalStackInfoServer(t, nil) + port := srv.URL[strings.LastIndex(srv.URL, ":")+1:] + + workDir := t.TempDir() + lstkDir := filepath.Join(workDir, ".lstk") + require.NoError(t, os.MkdirAll(lstkDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(lstkDir, "config.toml"), + []byte(fmt.Sprintf("[[containers]]\ntype = \"aws\"\ntag = \"latest\"\nport = %q\n", port)), 0644)) + + fakeDir := writeFakeAWS(t) + e := unhealthyDockerEnv(). + With(env.DisableEvents, "1"). + With("PATH", fakeDir). + With(env.Home, t.TempDir()) + + stdout, stderr, err := runLstk(t, testContext(t), workDir, e, "aws", "s3", "ls") + require.NoError(t, err, "lstk aws should use the external instance on the configured port: %s", stderr) + + assert.Contains(t, stdout, ":"+port) + assert.Contains(t, stdout, "ARGS:s3 ls") +} + +// Docker healthy but no LocalStack container: discovery falls back to the +// probe instead of erroring. +func TestAWSCommandExternalInstanceWithDockerHealthy(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + srv := mockLocalStackInfoServer(t, nil) + fakeDir := writeFakeAWS(t) + e := env.With(env.DisableEvents, "1"). + With("PATH", fakeDir). + With(env.Home, t.TempDir()). + With(env.LocalStackHost, lsHost(srv)) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "aws", "s3", "ls") + require.NoError(t, err, "lstk aws should use the external instance: %s", stderr) + + assert.Contains(t, stdout, "ENDPOINT:http://"+lsHost(srv)) +} + +func TestTerraformUsesExternalInstance(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("fake terraform script and unix-socket DOCKER_HOST not supported on Windows") + } + + srv := mockLocalStackInfoServer(t, nil) + fakeDir := writeFakeTerraform(t) + e := unhealthyDockerEnv(). + With(env.DisableEvents, "1"). + With("PATH", fakeDir). + With(env.Home, t.TempDir()). + With(env.LocalStackHost, lsHost(srv)) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "terraform", "plan") + require.NoError(t, err, "lstk terraform should use the external instance: %s", stderr) + + assert.Contains(t, stdout, "ARGS:plan") + assert.Contains(t, stdout, lsHost(srv), "override should point endpoints at the external instance") +} + +func TestResetExternalInstance(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("unix-socket DOCKER_HOST not supported on Windows") + } + + var resetCalls atomic.Int32 + srv := mockLocalStackInfoServer(t, map[string]http.HandlerFunc{ + "/_localstack/state/reset": func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + resetCalls.Add(1) + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) + }, + }) + + e := unhealthyDockerEnv(). + With(env.DisableEvents, "1"). + With(env.Home, t.TempDir()). + With(env.LocalStackHost, lsHost(srv)) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "--non-interactive", "reset", "--force") + require.NoError(t, err, "lstk reset should work against the external instance: %s", stderr) + + assert.Contains(t, stdout, "Emulator state reset") + assert.Equal(t, int32(1), resetCalls.Load()) +} + +func TestSnapshotSaveExternalInstance(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("unix-socket DOCKER_HOST not supported on Windows") + } + + var zipBuf bytes.Buffer + zw := zip.NewWriter(&zipBuf) + f, err := zw.Create("state.json") + require.NoError(t, err) + _, err = f.Write([]byte(`{"services":{}}`)) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + srv := mockLocalStackInfoServer(t, map[string]http.HandlerFunc{ + "/_localstack/pods/state": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipBuf.Bytes()) + }, + }) + + dir := t.TempDir() + e := unhealthyDockerEnv(). + With(env.DisableEvents, "1"). + With(env.Home, t.TempDir()). + With(env.LocalStackHost, lsHost(srv)) + + stdout, stderr, err := runLstk(t, testContext(t), dir, e, "--non-interactive", "snapshot", "save", filepath.Join(dir, "ext.snapshot")) + require.NoError(t, err, "lstk snapshot save should work against the external instance: %s", stderr) + + assert.Contains(t, stdout, "Snapshot saved") + _, statErr := os.Stat(filepath.Join(dir, "ext.snapshot")) + assert.NoError(t, statErr) +} + +func TestSnapshotLoadExternalInstance(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("unix-socket DOCKER_HOST not supported on Windows") + } + + var imported atomic.Bool + srv := mockLocalStackInfoServer(t, map[string]http.HandlerFunc{ + "/_localstack/pods": func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + imported.Store(true) + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) + }, + }) + + // A minimal zip is a valid snapshot payload for the mock import endpoint. + dir := t.TempDir() + snapPath := filepath.Join(dir, "ext.snapshot") + var zipBuf bytes.Buffer + zw := zip.NewWriter(&zipBuf) + f, err := zw.Create("state.json") + require.NoError(t, err) + _, err = f.Write([]byte(`{"services":{}}`)) + require.NoError(t, err) + require.NoError(t, zw.Close()) + require.NoError(t, os.WriteFile(snapPath, zipBuf.Bytes(), 0644)) + + e := unhealthyDockerEnv(). + With(env.DisableEvents, "1"). + With(env.Home, t.TempDir()). + With(env.LocalStackHost, lsHost(srv)) + + stdout, stderr, err := runLstk(t, testContext(t), dir, e, "--non-interactive", "snapshot", "load", snapPath) + require.NoError(t, err, "lstk snapshot load should work against the external instance: %s", stderr) + + assert.Contains(t, stdout, "Snapshot loaded") + assert.True(t, imported.Load(), "import endpoint should be called") +} + +func TestAzCommandUsesExternalInstance(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("fake az script and unix-socket DOCKER_HOST not supported on Windows") + } + + srv := mockLocalStackInfoServer(t, nil) + workDir := azureWorkDir(t) + writeAzureSetupMarker(t, workDir) + + fakeDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(fakeDir, "az"), + []byte("#!/bin/sh\necho \"AZ-ARGS:$*\"\n"), 0755)) + + e := unhealthyDockerEnv(). + With(env.DisableEvents, "1"). + With("PATH", fakeDir). + With(env.Home, t.TempDir()). + With(env.LocalStackHost, lsHost(srv)) + + stdout, stderr, err := runLstk(t, testContext(t), workDir, e, "az", "group", "list") + require.NoError(t, err, "lstk az should use the external instance: %s", stderr) + + assert.Contains(t, stdout, "AZ-ARGS:group list") +} diff --git a/test/integration/reset_test.go b/test/integration/reset_test.go index 714139f9..c01b5e3f 100644 --- a/test/integration/reset_test.go +++ b/test/integration/reset_test.go @@ -82,7 +82,8 @@ func TestResetLocalStackNotRunning(t *testing.T) { ctx := testContext(t) // Intentionally no startTestContainer: the emulator is not running. - stdout, _, err := runLstk(t, ctx, t.TempDir(), testEnvWithHome(t.TempDir(), ""), + stdout, _, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, deadLocalStackHost), "--non-interactive", "reset", "--force", ) requireExitCode(t, 1, err) @@ -136,7 +137,9 @@ func TestResetTelemetryOnFailure(t *testing.T) { analyticsSrv, events := mockAnalyticsServer(t) _, _, err := runLstk(t, ctx, t.TempDir(), - env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.AnalyticsEndpoint, analyticsSrv.URL), + env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.AnalyticsEndpoint, analyticsSrv.URL). + With(env.LocalStackHost, deadLocalStackHost), "--non-interactive", "reset", "--force", ) requireExitCode(t, 1, err) diff --git a/test/integration/sam_cmd_test.go b/test/integration/sam_cmd_test.go index cd1e6d5b..b2d16520 100644 --- a/test/integration/sam_cmd_test.go +++ b/test/integration/sam_cmd_test.go @@ -251,7 +251,8 @@ func TestSAMFailsWhenEmulatorNotRunning(t *testing.T) { t.Cleanup(cleanup) fakeDir := writeFakeSAM(t, "1.95.0") - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()). + With(env.LocalStackHost, deadLocalStackHost) stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "sam", "deploy") require.Error(t, err) diff --git a/test/integration/setup_azure_test.go b/test/integration/setup_azure_test.go index d5dbf61a..cc86c296 100644 --- a/test/integration/setup_azure_test.go +++ b/test/integration/setup_azure_test.go @@ -136,7 +136,7 @@ func TestAzCommandErrorsWhenEmulatorNotRunning(t *testing.T) { writeAzureSetupMarker(t, workDir) stdout, _, err := runLstk(t, testContext(t), workDir, - env.With(env.Home, t.TempDir()), + env.With(env.Home, t.TempDir()).With(env.LocalStackHost, deadLocalStackHost), "az", "group", "list", ) require.Error(t, err) diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index 77975785..bfca669c 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -643,7 +643,7 @@ func TestSnapshotSaveEmulatorNotRunning(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Intentionally no startTestContainer: the emulator is not running. ctx := testContext(t) - e := env.Environ(testEnvWithHome(t.TempDir(), "")) + e := env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, deadLocalStackHost) if tc.authToken != "" { e = e.With(env.AuthToken, tc.authToken) } diff --git a/test/integration/terraform_cmd_test.go b/test/integration/terraform_cmd_test.go index 9f1ea076..d5b2ad32 100644 --- a/test/integration/terraform_cmd_test.go +++ b/test/integration/terraform_cmd_test.go @@ -247,7 +247,8 @@ func TestTerraformFailsWhenEmulatorNotRunning(t *testing.T) { t.Cleanup(cleanup) fakeDir := writeFakeTerraform(t) - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()). + With(env.LocalStackHost, deadLocalStackHost) stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "terraform", "plan") require.Error(t, err) From f8b3986458e3b5ed54a02293e018c1bf80fab217 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Fri, 24 Jul 2026 14:27:04 +0300 Subject: [PATCH 2/2] Test lstk az start-interception against an external instance Co-Authored-By: Claude --- test/integration/external_instance_test.go | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/integration/external_instance_test.go b/test/integration/external_instance_test.go index f51ad31e..f2cc92a5 100644 --- a/test/integration/external_instance_test.go +++ b/test/integration/external_instance_test.go @@ -271,3 +271,51 @@ func TestAzCommandUsesExternalInstance(t *testing.T) { assert.Contains(t, stdout, "AZ-ARGS:group list") } + +// TestAzStartInterceptionUsesExternalInstance pins the regression Paolo reported +// in the head-of-engineering thread: with the Azure emulator running as a host +// process (debug mode — no container, and often the Docker daemon down), `lstk +// az start-interception` failed its preflight with "LocalStack Azure Emulator is +// not running", because emulator discovery was Docker-only. +// +// start-interception routes through the same azPreflight as `lstk az `, so +// the HTTP-probe fallback now lets it find the external instance too. The +// interception step that follows registers the 'LocalStack' cloud against the +// emulator's TLS gateway at azure. and needs wildcard DNS, which a plain +// httptest mock can't provide — so this test asserts the fix at the point that +// regressed: the command gets past Docker-only discovery (no "is not running", +// no "Docker is not available") and reaches the reachability check against the +// resolved external endpoint. The full interception path is covered by +// TestSetupAzureAndAzCommandSucceed (Docker + real az + auth token). +func TestAzStartInterceptionUsesExternalInstance(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("fake az script and unix-socket DOCKER_HOST not supported on Windows") + } + + srv := mockLocalStackInfoServer(t, nil) + workDir := azureWorkDir(t) + + // azPreflight checks the az CLI is installed before discovery; a stub on PATH + // satisfies that. It is never executed here — IsHealthy fails first. + fakeDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(fakeDir, "az"), + []byte("#!/bin/sh\nexit 0\n"), 0755)) + + e := unhealthyDockerEnv(). + With(env.DisableEvents, "1"). + With("PATH", fakeDir). + With(env.Home, t.TempDir()). + With(env.LocalStackHost, lsHost(srv)) + + stdout, stderr, err := runLstk(t, testContext(t), workDir, e, "az", "start-interception") + requireExitCode(t, 1, err) + + combined := stdout + stderr + assert.NotContains(t, combined, "is not running", + "preflight must discover the external instance instead of reporting it missing") + assert.NotContains(t, combined, "Docker is not available", + "Docker being down must not block discovery of an already-running instance") + assert.Contains(t, combined, "not reachable at https://azure.", + "discovery succeeds, so interception proceeds to the reachability check against the resolved external endpoint") +}