diff --git a/CLAUDE.md b/CLAUDE.md index 26c7c3fb..3a39136b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` (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`). diff --git a/cmd/root.go b/cmd/root.go index d056bdd2..07651575 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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) }, } @@ -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 @@ -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. diff --git a/cmd/start.go b/cmd/start.go index 8271eeb1..5f687e0f 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -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 } diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 46ec3297..c356d1e2 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -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") diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 95987a67..d38b2395 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -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-" — 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,