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 @@ -78,6 +78,10 @@ Each `[[containers]]` block may set an optional `image` to override the default
- The **host publish IP** for *all* published ports (gateway ports + the 4510-4559 service range) is the host part of the first entry, defaulting to `127.0.0.1`. So `GATEWAY_LISTEN = "0.0.0.0:4566,0.0.0.0:443"` exposes the emulator beyond loopback (e.g. on an EC2/MicroVM host). This is threaded through as `runtime.ContainerConfig.BindHost` and applied in `internal/runtime/docker.go`.
- Gateway ports beyond the primary edge port (4566, which is published on the configured `port`) are published host-port == container-port, so listing an extra port like `:8443` publishes it. `servicePortRange()` covers only 4510-4559 now — 443 comes from the default `GATEWAY_LISTEN`.

## Startup timeout

`lstk start --timeout` bounds how long lstk waits for the emulator to report healthy (`awaitStartup` in `internal/container/start.go`), so a container that never comes up fails fast with a clear error and non-zero exit instead of hanging (e.g. in CI). It defaults to `defaultStartupTimeout` (5m, defined in `cmd/start.go`); `--timeout 0` disables it (wait indefinitely). The value is threaded as `StartOptions.StartupTimeout` and applied by wrapping the wait context with a deadline; a deadline hit is surfaced as `startupTimeoutError` (telemetry `ErrCodeStartTimeout`). `restart` and the snapshot auto-start path reuse the same default but do not expose the flag.

## Volume Mounts

Each `[[containers]]` block accepts a `volumes` list of Docker-style `"host:container[:ro]"` bind specs (e.g. for Snowflake init hooks mounted into `/etc/localstack/init/{boot,start,ready,shutdown}.d`). The persistence/cache mount to `/var/lib/localstack` is folded into this list: the entry whose container target is `/var/lib/localstack` (`persistenceTarget` in `internal/config/containers.go`) defines the host dir backing it, and that path is what `VolumeDir()`, `lstk volume path`, and `lstk volume clear` resolve. Resolution precedence in `VolumeDir()`: a `volumes` entry targeting `/var/lib/localstack` → the legacy singular `volume = "..."` field (still honored for backward compatibility) → the default OS cache dir. Setting the persistence dir via both `volume` and a `volumes` entry with differing sources is a validation error.
Expand Down
2 changes: 1 addition & 1 deletion cmd/restart.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func newRestartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobr
stopOpts := container.StopOptions{
Telemetry: tel,
}
startOpts := buildStartOptions(cfg, appConfig, logger, tel, persist)
startOpts := buildStartOptions(cfg, appConfig, logger, tel, persist, defaultStartupTimeout)

if isInteractiveMode(cfg) {
return ui.RunRestart(cmd.Context(), rt, stopOpts, startOpts)
Expand Down
18 changes: 7 additions & 11 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,11 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
if err != nil {
return err
}
persist, err := cmd.Flags().GetBool("persist")
persist, timeout, snapshotFlag, noSnapshot, err := startFlags(cmd)
if err != nil {
return err
}
snapshotFlag, noSnapshot, err := snapshotFlags(cmd)
if err != nil {
return err
}
return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot)
return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, timeout, firstRun, snapshotFlag, noSnapshot)
},
}

Expand All @@ -82,8 +78,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C

root.PersistentFlags().String("config", "", "Path to config file")
root.PersistentFlags().BoolVar(&cfg.NonInteractive, "non-interactive", false, "Disable interactive mode")
root.Flags().Bool("persist", false, "Persist emulator state across restarts")
addSnapshotStartFlags(root)
addStartFlags(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 @@ -234,7 +229,7 @@ func Execute(ctx context.Context) error {
return nil
}

func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger, tel *telemetry.Client, persist bool) container.StartOptions {
func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger, tel *telemetry.Client, persist bool, startupTimeout time.Duration) container.StartOptions {
return container.StartOptions{
PlatformClient: api.NewPlatformClient(cfg.APIEndpoint, logger),
AuthToken: cfg.AuthToken,
Expand All @@ -244,12 +239,13 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger
Containers: appConfig.Containers,
Env: appConfig.Env,
Persist: persist,
StartupTimeout: startupTimeout,
Logger: logger,
Telemetry: tel,
}
}

func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool, firstRun bool, snapshotFlag string, noSnapshot bool) error {
func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool, startupTimeout time.Duration, firstRun bool, snapshotFlag string, noSnapshot bool) error {
appConfig, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
Expand All @@ -265,7 +261,7 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
return err
}

opts := buildStartOptions(cfg, appConfig, logger, tel, persist)
opts := buildStartOptions(cfg, appConfig, logger, tel, persist, startupTimeout)

notifyOpts := update.NotifyOptions{
GitHubToken: cfg.GitHubToken,
Expand Down
2 changes: 1 addition & 1 deletion cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ func newSnapshotAutoLoader(cfg *env.Env, rt runtime.Runtime, appConfig *config.C

func buildStarter(cfg *env.Env, rt runtime.Runtime, appConfig *config.Config, logger log.Logger, tel *telemetry.Client) snapshot.Starter {
return func(ctx context.Context, sink output.Sink) error {
opts := buildStartOptions(cfg, appConfig, logger, tel, false)
opts := buildStartOptions(cfg, appConfig, logger, tel, false, defaultStartupTimeout)
_, err := container.Start(ctx, rt, sink, opts, false)
return err
}
Expand Down
37 changes: 29 additions & 8 deletions cmd/start.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,39 @@
package cmd

import (
"time"

"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/telemetry"
"github.com/spf13/cobra"
)

// defaultStartupTimeout bounds how long `lstk start` waits for the emulator to
// become healthy before failing. `--timeout 0` disables it.
const defaultStartupTimeout = 5 * time.Minute

// addStartFlags registers the flags shared by the `start` command and the root
// command (which starts the emulator when invoked without a subcommand).
func addStartFlags(cmd *cobra.Command) {
cmd.Flags().Bool("persist", false, "Persist emulator state across restarts")
cmd.Flags().Duration("timeout", defaultStartupTimeout, "Maximum time to wait for the emulator to become ready (0 disables the timeout)")
addSnapshotStartFlags(cmd)
}

// startFlags parses the flags shared by the `start` and root commands.
func startFlags(cmd *cobra.Command) (persist bool, timeout time.Duration, snapshotFlag string, noSnapshot bool, err error) {
if persist, err = cmd.Flags().GetBool("persist"); err != nil {
return
}
if timeout, err = cmd.Flags().GetDuration("timeout"); err != nil {
return
}
snapshotFlag, noSnapshot, err = snapshotFlags(cmd)
return
}

func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
var firstRun bool
cmd := &cobra.Command{
Expand All @@ -24,18 +50,13 @@ If a snapshot is configured for the AWS emulator (the snapshot field in [[contai
if err != nil {
return err
}
persist, err := c.Flags().GetBool("persist")
persist, timeout, snapshotFlag, noSnapshot, err := startFlags(c)
if err != nil {
return err
}
snapshotFlag, noSnapshot, err := snapshotFlags(c)
if err != nil {
return err
}
return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot)
return startEmulator(c.Context(), rt, cfg, tel, logger, persist, timeout, firstRun, snapshotFlag, noSnapshot)
},
}
cmd.Flags().Bool("persist", false, "Persist emulator state across restarts")
addSnapshotStartFlags(cmd)
addStartFlags(cmd)
return cmd
}
50 changes: 43 additions & 7 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ type StartOptions struct {
Containers []config.ContainerConfig
Env map[string]map[string]string
Persist bool
Logger log.Logger
Telemetry *telemetry.Client
// StartupTimeout bounds how long to wait for the emulator to become
// healthy. A value <= 0 disables the timeout (wait indefinitely).
StartupTimeout time.Duration
Logger log.Logger
Telemetry *telemetry.Client
}

func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool) (string, error) {
Expand Down Expand Up @@ -239,7 +242,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start
}
}

if err := startContainers(ctx, rt, sink, tel, containers, pulled); err != nil {
if err := startContainers(ctx, rt, sink, tel, containers, pulled, opts.StartupTimeout); err != nil {
return "", err
}

Expand Down Expand Up @@ -504,7 +507,7 @@ func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink ou
return firstVersion, nil
}

func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, pulled map[string]bool) error {
func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, pulled map[string]bool, startupTimeout time.Duration) error {
for _, c := range containers {
startTime := time.Now()
sink.Emit(output.SpinnerStart("Starting LocalStack"))
Expand All @@ -522,11 +525,13 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink,
}

healthURL := fmt.Sprintf("http://localhost:%s%s", c.Port, c.HealthPath)
if err := awaitStartup(ctx, rt, sink, containerID, "LocalStack", healthURL); err != nil {
if err := awaitStartup(ctx, rt, sink, containerID, "LocalStack", healthURL, startupTimeout); err != nil {
sink.Emit(output.SpinnerStop())
errCode := telemetry.ErrCodeStartFailed
var licErr *licenseNotCoveredError
if errors.As(err, &licErr) && c.EmulatorType.SelfValidatesLicense() {
var timeoutErr *startupTimeoutError
switch {
case errors.As(err, &licErr) && c.EmulatorType.SelfValidatesLicense():
errCode = telemetry.ErrCodeLicenseInvalid
sink.Emit(output.ErrorEvent{
Title: fmt.Sprintf("Your license does not include the %s emulator.", c.EmulatorType.ShortName()),
Expand All @@ -536,6 +541,17 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink,
},
})
err = output.NewSilentError(err)
case errors.As(err, &timeoutErr):
errCode = telemetry.ErrCodeStartTimeout
sink.Emit(output.ErrorEvent{
Title: fmt.Sprintf("LocalStack did not become ready within %s.", timeoutErr.timeout),
Summary: "The emulator is still running but did not report healthy in time.",
Actions: []output.ErrorAction{
{Label: "Inspect the emulator logs:", Value: "lstk logs"},
{Label: "Raise or disable the timeout:", Value: "lstk start --timeout 0"},
},
})
err = output.NewSilentError(err)
}
tel.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{
EventType: telemetry.LifecycleStartError,
Expand Down Expand Up @@ -775,12 +791,29 @@ func (e *licenseNotCoveredError) Error() string {
return "license does not include this emulator"
}

// startupTimeoutError is returned by awaitStartup when the emulator does not
// become healthy within the configured startup timeout.
type startupTimeoutError struct {
name string
timeout time.Duration
}

func (e *startupTimeoutError) Error() string {
return fmt.Sprintf("%s did not become ready within %s", e.name, e.timeout)
}

// awaitStartup polls until one of two outcomes:
// - Success: health endpoint returns 200 (license is valid, LocalStack is ready)
// - Failure: container stops running (e.g., license activation failed), returns error with container logs
//
// TODO: move to Runtime interface if other runtimes (k8s?) need native readiness probes
func awaitStartup(ctx context.Context, rt runtime.Runtime, sink output.Sink, containerID, name, healthURL string) error {
func awaitStartup(ctx context.Context, rt runtime.Runtime, sink output.Sink, containerID, name, healthURL string, timeout time.Duration) error {
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}

client := &http.Client{Timeout: 2 * time.Second}

for {
Expand Down Expand Up @@ -814,6 +847,9 @@ func awaitStartup(ctx context.Context, rt runtime.Runtime, sink output.Sink, con

select {
case <-ctx.Done():
if timeout > 0 && errors.Is(ctx.Err(), context.DeadlineExceeded) {
return &startupTimeoutError{name: name, timeout: timeout}
}
return ctx.Err()
case <-time.After(1 * time.Second):
}
Expand Down
50 changes: 48 additions & 2 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"sync"
"sync/atomic"
"testing"
"time"

"github.com/localstack/lstk/internal/api"
"github.com/localstack/lstk/internal/caller"
Expand Down Expand Up @@ -561,7 +562,7 @@ func TestStartContainers_SnowflakeLicenseError(t *testing.T) {
var out bytes.Buffer
sink := output.NewPlainSink(&out)

err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{})
err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{}, 0)
tel.Close()

require.Error(t, err)
Expand Down Expand Up @@ -607,7 +608,7 @@ func TestStartContainers_AzureLicenseError(t *testing.T) {
var out bytes.Buffer
sink := output.NewPlainSink(&out)

err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{})
err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{}, 0)
tel.Close()

require.Error(t, err)
Expand All @@ -629,6 +630,51 @@ func TestStartContainers_AzureLicenseError(t *testing.T) {
}
}

func TestStartContainers_StartupTimeout(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)

c := runtime.ContainerConfig{
Image: "localstack/localstack-pro:latest",
Name: "localstack-aws",
EmulatorType: config.EmulatorAWS,
Tag: "latest",
Port: "59999", // nothing listens here, so the health poll never succeeds
ContainerPort: "4566/tcp",
HealthPath: "/_localstack/health",
}
const containerID = "abc123"
mockRT.EXPECT().Start(gomock.Any(), c).Return(containerID, nil)
// Container stays up but never becomes healthy, so awaitStartup loops until the timeout.
mockRT.EXPECT().IsRunning(gomock.Any(), containerID).Return(true, nil).AnyTimes()

tel, capturedEvents := newCapturingTelClient(t)

var out bytes.Buffer
sink := output.NewPlainSink(&out)

err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{}, 50*time.Millisecond)
tel.Close()

require.Error(t, err)
assert.True(t, output.IsSilent(err), "error should be silent since ErrorEvent was already emitted")
var timeoutErr *startupTimeoutError
assert.ErrorAs(t, err, &timeoutErr)
got := out.String()
assert.Contains(t, got, "LocalStack did not become ready within 50ms.")
assert.Contains(t, got, "lstk start --timeout 0")

select {
case ev := <-capturedEvents:
payload, ok := ev["payload"].(map[string]any)
require.True(t, ok, "telemetry event should have a payload map")
assert.Equal(t, telemetry.LifecycleStartError, payload["event_type"])
assert.Equal(t, telemetry.ErrCodeStartTimeout, payload["error_code"])
default:
t.Fatal("no telemetry event received")
}
}

func TestPullImages_ReusesLocalImageWhenPresent(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)
Expand Down
1 change: 1 addition & 0 deletions internal/telemetry/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const (
ErrCodeImagePullFailed = "image_pull_failed"
ErrCodeLicenseInvalid = "license_invalid"
ErrCodeStartFailed = "start_failed"
ErrCodeStartTimeout = "start_timeout"
ErrCodeEmulatorMismatch = "emulator_mismatch"
)

Expand Down
34 changes: 34 additions & 0 deletions test/integration/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,40 @@ 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. 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:latest"
_, 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
Loading
Loading