Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ This naming avoids AWS-specific "profile" terminology and uses a clear verb for

Environment variables:
- `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set)
- `LSTK_STARTUP_TIMEOUT` - Startup readiness deadline for `lstk start` (Go duration). Zero/unset uses the per-mode default resolved in `resolveStartupTimeout` (`internal/container/start.go`): 20s interactive (deadline only shows a recoverable keep-waiting/stop prompt, re-armed by "keep waiting"), 60s non-interactive (fatal; the container is left running for inspection). Container exits are detected separately — and instantly, with the exit code — via the exit wait `runtime.Runtime.Start` registers between create and start.
- `LSTK_STARTUP_TIMEOUT` - Startup readiness deadline for `lstk start` (Go duration). Zero/unset uses the per-mode default resolved in `resolveStartupTimeout` (`internal/container/start.go`): 20s interactive (deadline only shows a recoverable keep-waiting/stop prompt, re-armed by "keep waiting"), 60s non-interactive (fatal; the container is left running for inspection). Container exits are detected separately — and instantly, with the exit code — via the exit wait `runtime.Runtime.Start` registers between create and start. `lstk start --timeout <duration>` (also on the bare root) overrides this for a single run; the flag wins over the env var when explicitly set, and `--timeout 0` falls back to the per-mode default (`addTimeoutFlag`/`applyTimeoutFlag` in `cmd/root.go`). `restart` and the snapshot auto-start path do not expose the flag.
- `LSTK_OTEL=1` - Enables OpenTelemetry trace export (disabled by default); when enabled, standard `OTEL_EXPORTER_OTLP_*` env vars are respected by the SDK. Requires an OTLP-compatible backend to receive and visualize telemetry — for local development, `make otel` starts one (UI at http://localhost:16686).
- `LSTK_MERGE_STRATEGY` - Default merge strategy for `snapshot load` / `load` (`account-region-merge`, `overwrite`, or `service-merge`) when `--merge` is not passed; an explicit `--merge` always wins. Resolved in `resolveMergeStrategy` (`cmd/snapshot.go`).

Expand Down
28 changes: 28 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
if err != nil {
return err
}
if err := applyTimeoutFlag(cmd, cfg); err != nil {
return err
}
return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot, emulatorType)
},
}
Expand Down Expand Up @@ -129,6 +132,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
root.Flags().Bool("persist", false, "Persist emulator state across restarts")
addEmulatorTypeFlag(root)
addSnapshotStartFlags(root)
addTimeoutFlag(root)

// Parse lstk's global flags only when they precede the command name: with
// interspersing disabled, Cobra consumes leading flags and hands everything
Expand Down Expand Up @@ -402,6 +406,30 @@ func resolveEmulatorTypeFlag(cmd *cobra.Command) (config.EmulatorType, error) {
return config.ParseEmulatorType(flagVal)
}

// addTimeoutFlag registers the --timeout flag on a start-capable command. It is
// a per-run override of LSTK_STARTUP_TIMEOUT / the startup_timeout config; 0
// (the default) leaves the env/config value in place, which in turn falls back
// to the per-mode default in resolveStartupTimeout. restart and the snapshot
// auto-start path deliberately do not expose this flag.
func addTimeoutFlag(cmd *cobra.Command) {
cmd.Flags().Duration("timeout", 0, "Maximum time to wait for the emulator to become ready (overrides LSTK_STARTUP_TIMEOUT; 0 uses the default)")
}

// applyTimeoutFlag lets --timeout override the env/config-derived
// cfg.StartupTimeout, but only when the flag was explicitly set, so an unset
// flag preserves the LSTK_STARTUP_TIMEOUT value.
func applyTimeoutFlag(cmd *cobra.Command, cfg *env.Env) error {
if !cmd.Flags().Changed("timeout") {
return nil
}
timeout, err := cmd.Flags().GetDuration("timeout")
if err != nil {
return err
}
cfg.StartupTimeout = timeout
return nil
}

// walkCommandsWithRunE walks the Cobra command tree rooted at cmd, calling wrap
// on every command that has a RunE so callers can layer cross-cutting behavior
// (telemetry, JSON gating, tracing) onto it.
Expand Down
4 changes: 4 additions & 0 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,15 @@ If a snapshot is configured for the AWS emulator (the snapshot field in [[contai
if err != nil {
return err
}
if err := applyTimeoutFlag(c, cfg); err != nil {
return err
}
return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot, emulatorType)
},
}
cmd.Flags().Bool("persist", false, "Persist emulator state across restarts")
addEmulatorTypeFlag(cmd)
addSnapshotStartFlags(cmd)
addTimeoutFlag(cmd)
return cmd
}
37 changes: 37 additions & 0 deletions test/integration/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,43 @@ func startExternalContainer(t *testing.T, ctx context.Context, imgName, name, ho
})
}

// commitNeverHealthyImage builds a local-only image whose default command stays
// running (sleep infinity) but never serves /_localstack/health. Starting it via
// lstk exercises the failure path where the emulator comes up but never reports
// healthy. The tag must stay pinned (non-latest): a pinned locally-present image
// skips both the pull and the license checks, while a "latest" tag is validated
// post-pull via GetImageVersion, which fails on this image (no
// LOCALSTACK_BUILD_VERSION) before the health wait is ever reached. Returns the
// image reference; the image and its source container are removed on test cleanup.
func commitNeverHealthyImage(t *testing.T, ctx context.Context) string {
t.Helper()

reader, err := dockerClient.ImagePull(ctx, testImage, client.ImagePullOptions{})
require.NoError(t, err, "failed to pull test image")
_, _ = io.Copy(io.Discard, reader)
_ = reader.Close()

resp, err := dockerClient.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: &container.Config{Image: testImage},
Name: "lstk-never-healthy-src",
})
require.NoError(t, err, "failed to create source container")
t.Cleanup(func() {
_, _ = dockerClient.ContainerRemove(context.Background(), resp.ID, client.ContainerRemoveOptions{Force: true})
})

const imageRef = "lstk-never-healthy:test"
_, err = dockerClient.ContainerCommit(ctx, resp.ID, client.ContainerCommitOptions{
Reference: imageRef,
Changes: []string{`CMD ["sleep", "infinity"]`},
})
require.NoError(t, err, "failed to commit never-healthy image")
t.Cleanup(func() {
_, _ = dockerClient.ImageRemove(context.Background(), imageRef, client.ImageRemoveOptions{Force: true})
})
return imageRef
}

func startTestSnowflakeContainer(t *testing.T, ctx context.Context) {
t.Helper()
startNamedTestContainer(t, ctx, snowflakeContainerName, "snowflake")
Expand Down
40 changes: 40 additions & 0 deletions test/integration/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,46 @@ image = "lstk-nonexistent-custom-image"
assert.Contains(t, combined, "Failed to pull lstk-nonexistent-custom-image:latest")
}

// TestStartTimesOutWhenEmulatorNeverBecomesHealthy verifies that --timeout bounds
// the health-check wait (PRO-357): a container that stays running but never serves
// /_localstack/health must fail fast with a clear error and a non-zero exit,
// instead of hanging indefinitely.
func TestStartTimesOutWhenEmulatorNeverBecomesHealthy(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)

ctx := testContext(t)
imageRef := commitNeverHealthyImage(t, ctx)
imageName, imageTag, ok := strings.Cut(imageRef, ":")
require.True(t, ok, "never-healthy image reference must carry a pinned tag")

// A non-interactive startup timeout deliberately leaves the container running
// for inspection, and the pinned tag names it "localstack-aws-<tag>" — a name
// the shared cleanup() (which only removes "localstack-aws") never touches.
// Remove it explicitly, or it keeps holding port 4566 and breaks every later
// test that starts an emulator. Registered after commitNeverHealthyImage's
// cleanups so it runs before them (LIFO): the container goes first, then its image.
t.Cleanup(func() {
_, _ = dockerClient.ContainerRemove(context.Background(), "localstack-aws-"+imageTag, client.ContainerRemoveOptions{Force: true})
})

home := t.TempDir()
configFile := filepath.Join(home, "config.toml")
require.NoError(t, os.WriteFile(configFile,
[]byte(fmt.Sprintf("[[containers]]\ntype = \"aws\"\nport = \"4566\"\nimage = %q\ntag = %q\n", imageName, imageTag)), 0644))

// A pinned tag already present locally skips both the pull and the license
// checks (see commitNeverHealthyImage), so a dummy token is enough to reach
// the health wait.
e := append(testEnvWithHome(home, ""), string(env.AuthToken)+"=fake-token")
stdout, stderr, err := runLstk(t, ctx, "", e, "--config", configFile, "--non-interactive", "start", "--timeout", "3s")

require.Error(t, err, "expected start to fail when the emulator never becomes healthy")
requireExitCode(t, 1, err)
assert.Contains(t, stdout+stderr, "LocalStack did not become ready within 3s")
}

// TestStartFallsBackToLocalImageWhenPullFails verifies the offline degradation
// path for image pulls: when the configured image cannot be pulled (registry
// unreachable, or the image was never published) but is already present locally,
Expand Down
Loading