diff --git a/CLAUDE.md b/CLAUDE.md index ebf54f05..50e93e88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,7 @@ The default `lstk az ` mode mirrors `lstk aws`: the Azure CLI has no `--en 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_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). # Infrastructure as Code Commands diff --git a/README.md b/README.md index cd3d53ba..a8ff4f87 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ lstk --non-interactive |---|---| | `LOCALSTACK_AUTH_TOKEN` | Auth token used for non-interactive runs or to skip browser login | | `LOCALSTACK_DISABLE_EVENTS=1` | Disables telemetry event reporting | +| `LSTK_STARTUP_TIMEOUT` | How long `lstk start` waits for the emulator to become healthy before acting (Go duration, e.g. `90s`, `5m`). Defaults: 20s interactively (shows a keep-waiting/stop prompt; "keep waiting" re-arms the deadline), 60s non-interactively (fails with the last container logs, leaving the emulator running for inspection). | | `LSTK_OTEL=1` | Enables OpenTelemetry trace export (disabled by default). When enabled, standard `OTEL_EXPORTER_OTLP_*` env vars are respected by the SDK (e.g. `OTEL_EXPORTER_OTLP_ENDPOINT` defaults to `http://localhost:4318`). Requires an OTLP-compatible backend to receive and visualize telemetry — for local development, `make otel` starts one (UI at http://localhost:16686). | | `DOCKER_HOST` | Override the Docker daemon socket (e.g. `unix:///home/user/.colima/default/docker.sock`). When unset, lstk tries the default socket and then probes common alternatives (Colima, OrbStack). | diff --git a/cmd/root.go b/cmd/root.go index 410e4ab4..a4703b69 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -285,6 +285,7 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger Containers: appConfig.Containers, Env: appConfig.Env, Persist: persist, + StartupTimeout: cfg.StartupTimeout, Logger: logger, Telemetry: tel, } diff --git a/internal/container/start.go b/internal/container/start.go index 16b067aa..6d246d79 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -44,6 +44,7 @@ type StartOptions struct { Containers []config.ContainerConfig Env map[string]map[string]string Persist bool + StartupTimeout time.Duration // zero uses the per-mode default (resolveStartupTimeout) Logger log.Logger Telemetry *telemetry.Client } @@ -254,7 +255,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, interactive); err != nil { return "", err } @@ -520,11 +521,12 @@ 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, interactive bool) error { + monitor := newStartupMonitor(rt, sink, tel, startupTimeout, interactive) for _, c := range containers { startTime := time.Now() sink.Emit(output.SpinnerStart("Starting LocalStack")) - containerID, err := rt.Start(ctx, c) + containerID, exitCh, err := rt.Start(ctx, c) if err != nil { sink.Emit(output.SpinnerStop()) tel.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ @@ -550,7 +552,7 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, }() healthURL := fmt.Sprintf("http://localhost:%s%s", c.Port, c.HealthPath) - err = awaitStartup(ctx, rt, sink, containerID, "LocalStack", healthURL, startupLogs) + err = monitor.await(ctx, containerID, healthURL, exitCh) // Stop following and let the goroutine return before continuing, so it does // not outlive the start. Bounded so a slow stream teardown can't hang start. stopLogTail() @@ -560,27 +562,31 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, } if err != nil { sink.Emit(output.SpinnerStop()) - errCode := telemetry.ErrCodeStartFailed - var licErr *licenseNotCoveredError - if 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()), - Actions: []output.ErrorAction{ - {Label: "Sign up for a free trial:", Value: "https://app.localstack.cloud/sign-up"}, - {Label: "Contact our team:", Value: "https://www.localstack.cloud/demo"}, - }, + // A cancelled context (e.g. Ctrl+C) is a deliberate abort, not a + // startup failure: propagate it without a styled error. The container + // is detached and stays running. It is still tracked as a start_error + // lifecycle event (matching the behavior before startup monitoring + // was introduced) so interrupted starts stay visible in telemetry. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + tel.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ + EventType: telemetry.LifecycleStartError, + Emulator: c.EmulatorType, + Image: c.Image, + ErrorCode: telemetry.ErrCodeStartFailed, + ErrorMsg: err.Error(), }) - err = output.NewSilentError(err) + return err } - tel.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ - EventType: telemetry.LifecycleStartError, - Emulator: c.EmulatorType, - Image: c.Image, - ErrorCode: errCode, - ErrorMsg: err.Error(), - }) - return err + // Read the logs only now, after the follow-goroutine's final flush, so + // the tail is complete. Fall back to a direct fetch if nothing was + // streamed (unlikely once the container ran). + logs := startupLogs.String() + if logs == "" { + if direct, derr := rt.Logs(ctx, containerID, 20); derr == nil { + logs = direct + } + } + return monitor.handleFailure(ctx, c, err, logs) } sink.Emit(output.SpinnerStop()) @@ -600,6 +606,83 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, return nil } +// handleFailure classifies an await failure for container c, emits the +// matching ErrorEvent + lifecycle telemetry, and returns a silent error (so the +// top-level handler does not re-print it). logs is the container's buffered +// startup output, read after the follow-goroutine's final flush. +func (m *startupMonitor) handleFailure(ctx context.Context, c runtime.ContainerConfig, err error, logs string) error { + errCode := telemetry.ErrCodeStartFailed + + switch { + // A self-validating emulator (Snowflake/Azure) whose license does not cover + // it exits with a distinctive log line. Preserve the dedicated messaging. + case c.EmulatorType.SelfValidatesLicense() && strings.Contains(logs, "not covered by your license"): + errCode = telemetry.ErrCodeLicenseInvalid + m.sink.Emit(output.ErrorEvent{ + Title: fmt.Sprintf("Your license does not include the %s emulator.", c.EmulatorType.ShortName()), + Actions: []output.ErrorAction{ + {Label: "Sign up for a free trial:", Value: "https://app.localstack.cloud/sign-up"}, + {Label: "Contact our team:", Value: "https://www.localstack.cloud/demo"}, + }, + }) + err = &licenseNotCoveredError{} + + case isStartupTimeout(err): + errCode = telemetry.ErrCodeStartTimeout + var timeoutErr *startupTimeoutError + errors.As(err, &timeoutErr) + summary := "LocalStack is still running so you can inspect it, or stop it." + actions := []output.ErrorAction{ + {Label: "View the logs:", Value: "lstk logs"}, + {Label: "Stop LocalStack:", Value: "lstk stop"}, + {Label: "Allow more time on a slow machine:", Value: "LSTK_STARTUP_TIMEOUT=5m lstk start"}, + } + if timeoutErr != nil && timeoutErr.stopped { + summary = "LocalStack has been stopped." + actions = []output.ErrorAction{ + {Label: "Try again:", Value: "lstk start"}, + {Label: "Allow more time on a slow machine:", Value: "LSTK_STARTUP_TIMEOUT=5m lstk start"}, + } + } + if tail := lastLogLines(logs, 15); tail != "" { + summary += "\nLast container output:\n" + tail + } + m.sink.Emit(output.ErrorEvent{ + Title: err.Error(), + Summary: summary, + Actions: actions, + }) + + default: + // Container exited before becoming healthy (crash during startup). + summary := "" + if tail := lastLogLines(logs, 15); tail != "" { + summary = "Last container output:\n" + tail + } + m.sink.Emit(output.ErrorEvent{ + Title: err.Error(), + Summary: summary, + Actions: []output.ErrorAction{ + {Label: "Check your configuration and try again:", Value: "lstk start"}, + }, + }) + } + + m.tel.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ + EventType: telemetry.LifecycleStartError, + Emulator: c.EmulatorType, + Image: c.Image, + ErrorCode: errCode, + ErrorMsg: err.Error(), + }) + return output.NewSilentError(err) +} + +func isStartupTimeout(err error) bool { + var timeoutErr *startupTimeoutError + return errors.As(err, &timeoutErr) +} + func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, localStackHost, webAppURL string) ([]runtime.ContainerConfig, error) { var filtered []runtime.ContainerConfig for _, c := range containers { @@ -814,7 +897,7 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c return nil } -// licenseNotCoveredError is returned by awaitStartup when the container exits +// licenseNotCoveredError is returned by startupMonitor.handleFailure when the container exits // because the license does not include the emulator (Snowflake or Azure). type licenseNotCoveredError struct{} @@ -822,63 +905,237 @@ func (e *licenseNotCoveredError) Error() string { return "license does not include this emulator" } +// containerExitedError is returned by startupMonitor.await when the container stops +// running before becoming healthy (e.g. it crashed during startup). +type containerExitedError struct { + exitCode int // -1 when unknown +} + +func (e *containerExitedError) Error() string { + if e.exitCode < 0 { + return "LocalStack exited unexpectedly" + } + return fmt.Sprintf("LocalStack exited unexpectedly (exit code %d)", e.exitCode) +} + +// exitedError builds the containerExitedError for an exit detected by the +// IsRunning poll, waiting briefly for the runtime's exit wait to deliver the +// exit code (the wait response is already in flight once the container is seen +// stopped). Falls back to an unknown code (-1). +func exitedError(exitCh <-chan runtime.ExitResult) error { + if exitCh != nil { + select { + case res := <-exitCh: + if res.Err == nil { + return &containerExitedError{exitCode: res.ExitCode} + } + case <-time.After(2 * time.Second): + } + } + return &containerExitedError{exitCode: -1} +} + +// startupTimeoutError is returned by startupMonitor.await when the container stays +// running but does not become healthy within the deadline. stopped records +// whether the container was stopped on the way out (the user chose "stop" at +// the interactive prompt) so the error messaging can reflect its actual state. +type startupTimeoutError struct { + timeout time.Duration + stopped bool +} + +func (e *startupTimeoutError) Error() string { + return fmt.Sprintf("LocalStack did not become ready within %s", e.timeout) +} + // maxStartupLogBytes bounds how much of a failing container's log tail is buffered // to explain a crash during startup. const maxStartupLogBytes = 64 * 1024 -// 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 -// -// startupLogs holds the container's logs streamed while it ran, so they survive -// the container being auto-removed (--rm) on exit. +// The startup deadline plays a different role per mode: interactively it only +// shows a recoverable "keep waiting?" prompt, so it fires early; non- +// interactively it fails the start, so it stays conservative. LocalStack should +// not take this long either way, so a slower start is itself unusual and worth +// surfacing (LSTK_STARTUP_TIMEOUT overrides both). +const ( + defaultStartupTimeoutInteractive = 20 * time.Second + defaultStartupTimeoutNonInteractive = 60 * time.Second +) + +// resolveStartupTimeout applies the per-mode default when no explicit timeout +// is configured. +func resolveStartupTimeout(timeout time.Duration, interactive bool) time.Duration { + if timeout > 0 { + return timeout + } + if interactive { + return defaultStartupTimeoutInteractive + } + return defaultStartupTimeoutNonInteractive +} + +// startupMonitor watches a just-started container until it becomes healthy, +// exits, or times out, and classifies/renders the failure when it doesn't. It +// bundles the collaborators and mode that stay constant across a Start +// invocation; per-container data flows through the method arguments. +type startupMonitor struct { + rt runtime.Runtime + sink output.Sink + tel *telemetry.Client + timeout time.Duration + interactive bool +} + +func newStartupMonitor(rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, timeout time.Duration, interactive bool) *startupMonitor { + return &startupMonitor{ + rt: rt, + sink: sink, + tel: tel, + timeout: resolveStartupTimeout(timeout, interactive), + interactive: interactive, + } +} + +// await polls until one of these outcomes: +// - Success: health endpoint returns 200 (LocalStack is ready) → nil. +// - Exit: the container stops running before becoming healthy → +// *containerExitedError (exit code from exitCh when available, else -1). +// - Timeout: the container stays running but is not healthy within the +// deadline → *startupTimeoutError. In interactive mode the user is prompted +// first (keep waiting re-arms the deadline; stop stops the container). // -// 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, startupLogs *logTail) error { +// exitCh delivers the container's exit as observed by the runtime. It may be nil +// if no wait could be registered; the IsRunning poll then still detects an exit +// (with an unknown exit code). +func (m *startupMonitor) await(ctx context.Context, containerID, healthURL string, exitCh <-chan runtime.ExitResult) error { client := &http.Client{Timeout: 2 * time.Second} - for { - running, err := rt.IsRunning(ctx, containerID) + deadline := time.NewTimer(m.timeout) + defer deadline.Stop() + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + check := func() (ready bool, err error) { + running, err := m.rt.IsRunning(ctx, containerID) if err != nil { - return fmt.Errorf("failed to check container status: %w", err) + return false, fmt.Errorf("failed to check container status: %w", err) } if !running { - // Prefer the logs streamed while the container ran: with --rm the - // container is already gone, so a direct fetch would return nothing. - logs := startupLogs.String() - if logs == "" { - if direct, derr := rt.Logs(ctx, containerID, 20); derr == nil { - logs = direct - } - } - if strings.Contains(logs, "not covered by your license") { - return &licenseNotCoveredError{} - } - if logs == "" { - return fmt.Errorf("%s exited unexpectedly", name) - } - return fmt.Errorf("%s exited unexpectedly:\n%s", name, logs) + // The poll can observe the exit before the runtime's wait response + // arrives; give exitCh a moment to deliver the exit code rather than + // reporting it unknown. + return false, exitedError(exitCh) } - resp, err := client.Get(healthURL) - if err == nil && resp.StatusCode == http.StatusOK { - if err := resp.Body.Close(); err != nil { - sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("failed to close response body: %v", err)}) + resp, gerr := client.Get(healthURL) + if gerr == nil && resp.StatusCode == http.StatusOK { + if cerr := resp.Body.Close(); cerr != nil { + m.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("failed to close response body: %v", cerr)}) } - return nil + return true, nil } if resp != nil { - if err := resp.Body.Close(); err != nil { - sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("failed to close response body: %v", err)}) + if cerr := resp.Body.Close(); cerr != nil { + m.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("failed to close response body: %v", cerr)}) } } + return false, nil + } + // Probe once before the first tick so a fast start is caught promptly. + if ready, err := check(); err != nil || ready { + return err + } + + for { select { case <-ctx.Done(): return ctx.Err() - case <-time.After(1 * time.Second): + case res := <-exitCh: + if res.Err != nil { + // The wait itself failed (e.g. an exit+removal race before the + // wait registered). Stop trusting exitCh and let the IsRunning + // poll detect the exit with an unknown code. + exitCh = nil + continue + } + return &containerExitedError{exitCode: res.ExitCode} + case <-deadline.C: + surface, stopped, err := m.handleTimeout(ctx, containerID) + if err != nil { + return err + } + if surface { + return &startupTimeoutError{timeout: m.timeout, stopped: stopped} + } + // Keep waiting: re-arm the deadline and restore the spinner. + deadline.Reset(m.timeout) + m.sink.Emit(output.SpinnerStart("Starting LocalStack")) + case <-ticker.C: + if ready, err := check(); err != nil || ready { + return err + } + } + } +} + +// handleTimeout decides what to do when the startup deadline elapses. In +// non-interactive mode it always surfaces the timeout, leaving the container +// running so the user can inspect it. In interactive mode it prompts the user to +// keep waiting or stop; "keep waiting" returns surface=false so the caller +// re-arms the deadline. stopped reports whether the container was stopped (the +// user chose "stop"), so the timeout error can describe its actual state. +func (m *startupMonitor) handleTimeout(ctx context.Context, containerID string) (surface, stopped bool, err error) { + if !m.interactive { + return true, false, nil + } + + m.sink.Emit(output.SpinnerStop()) + responseCh := make(chan output.InputResponse, 1) + m.sink.Emit(output.UserInputRequestEvent{ + Prompt: "LocalStack is taking longer than expected to start. Check logs with 'lstk logs'", + Options: []output.InputOption{ + {Key: "w", Label: "Keep waiting [W]"}, + {Key: "s", Label: "Stop LocalStack and exit [S]"}, + }, + ResponseCh: responseCh, + }) + + select { + case resp := <-responseCh: + if resp.Cancelled { + // Ctrl+C: leave the container running (it is detached). + if ctx.Err() != nil { + return false, false, ctx.Err() + } + return false, false, context.Canceled + } + if resp.SelectedKey == "s" { + // Stopping takes a few seconds; show progress so the CLI does not + // look hung after the keypress. The caller's SpinnerStop closes it. + m.sink.Emit(output.SpinnerStart("Stopping LocalStack...")) + // Best-effort stop; the timeout error is authoritative either way. + _ = m.rt.Stop(ctx, containerID) + return true, true, nil } + return false, false, nil + case <-ctx.Done(): + return false, false, ctx.Err() + } +} + +// lastLogLines returns the last n non-empty lines of logs, for including in an +// error summary. Returns "" when logs is empty. +func lastLogLines(logs string, n int) string { + logs = strings.TrimRight(logs, "\n") + if logs == "" { + return "" + } + lines := strings.Split(logs, "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] } + return strings.Join(lines, "\n") } // droppedHostEnv is a host variable filterHostEnv refused to forward, so the diff --git a/internal/container/start_test.go b/internal/container/start_test.go index e64215a6..f7ac0a0e 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -14,6 +14,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/localstack/lstk/internal/api" "github.com/localstack/lstk/internal/caller" @@ -658,7 +659,7 @@ func TestStartContainers_SnowflakeLicenseError(t *testing.T) { } const containerID = "abc123" licenseLog := "⚠️ The Snowflake emulator is currently not covered by your license. ❄️" - mockRT.EXPECT().Start(gomock.Any(), c).Return(containerID, nil) + mockRT.EXPECT().Start(gomock.Any(), c).Return(containerID, exitResultChan(runtime.ExitResult{ExitCode: 1}), nil) mockRT.EXPECT().StreamLogs(gomock.Any(), containerID, gomock.Any(), true).Return(nil) mockRT.EXPECT().IsRunning(gomock.Any(), containerID).Return(false, nil) mockRT.EXPECT().Logs(gomock.Any(), containerID, 20).Return(licenseLog, nil) @@ -668,7 +669,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, false) tel.Close() require.Error(t, err) @@ -705,7 +706,7 @@ func TestStartContainers_AzureLicenseError(t *testing.T) { } const containerID = "abc123" licenseLog := "The Azure emulator is currently not covered by your license." - mockRT.EXPECT().Start(gomock.Any(), c).Return(containerID, nil) + mockRT.EXPECT().Start(gomock.Any(), c).Return(containerID, exitResultChan(runtime.ExitResult{ExitCode: 1}), nil) mockRT.EXPECT().StreamLogs(gomock.Any(), containerID, gomock.Any(), true).Return(nil) mockRT.EXPECT().IsRunning(gomock.Any(), containerID).Return(false, nil) mockRT.EXPECT().Logs(gomock.Any(), containerID, 20).Return(licenseLog, nil) @@ -715,7 +716,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, false) tel.Close() require.Error(t, err) @@ -737,6 +738,231 @@ func TestStartContainers_AzureLicenseError(t *testing.T) { } } +// exitResultChan returns a buffered channel already holding res, matching the +// contract of the exit channel returned by runtime.Runtime.Start. +func exitResultChan(res runtime.ExitResult) <-chan runtime.ExitResult { + ch := make(chan runtime.ExitResult, 1) + ch <- res + return ch +} + +// unreachableHealthURL returns a URL pointing at a closed local port, so the +// health GET in startupMonitor.await always fails to connect. +func unreachableHealthURL(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + return "http://" + addr + "/_localstack/health" +} + +func TestStartupMonitorAwait_TimesOutWhenNeverHealthy(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + // Container stays running the whole time but never serves health. + mockRT.EXPECT().IsRunning(gomock.Any(), "cid").Return(true, nil).AnyTimes() + + sink := output.NewPlainSink(io.Discard) + // exitCh never fires; a tiny non-interactive timeout should surface promptly. + exitCh := make(chan runtime.ExitResult) + + monitor := newStartupMonitor(mockRT, sink, nil, 50*time.Millisecond, false) + err := monitor.await(context.Background(), "cid", unreachableHealthURL(t), exitCh) + + var timeoutErr *startupTimeoutError + require.ErrorAs(t, err, &timeoutErr) + assert.False(t, timeoutErr.stopped, "non-interactive timeout leaves the container running") +} + +// The interactive deadline shows a recoverable prompt instead of failing: +// "keep waiting" re-arms the deadline (the prompt returns), and "stop" stops +// the container and surfaces the timeout. +func TestStartupMonitorAwait_InteractivePromptKeepWaitingThenStop(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsRunning(gomock.Any(), "cid").Return(true, nil).AnyTimes() + mockRT.EXPECT().Stop(gomock.Any(), "cid").Return(nil) + + prompts := make(chan output.UserInputRequestEvent, 2) + sink := output.SinkFunc(func(event output.Event) { + if req, ok := event.(output.UserInputRequestEvent); ok { + prompts <- req + } + }) + + go func() { + for i, key := range []string{"w", "s"} { + select { + case req := <-prompts: + req.ResponseCh <- output.InputResponse{SelectedKey: key} + case <-time.After(5 * time.Second): + t.Errorf("prompt %d never appeared", i+1) + return + } + } + }() + + exitCh := make(chan runtime.ExitResult) + monitor := newStartupMonitor(mockRT, sink, nil, 50*time.Millisecond, true) + err := monitor.await(context.Background(), "cid", unreachableHealthURL(t), exitCh) + + var timeoutErr *startupTimeoutError + require.ErrorAs(t, err, &timeoutErr) + assert.True(t, timeoutErr.stopped, "choosing stop at the prompt must be recorded on the error") +} + +func TestStartupMonitorAwait_ReturnsExitCodeFromExitCh(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + // Running on the first probe; the exit arrives via exitCh. + mockRT.EXPECT().IsRunning(gomock.Any(), "cid").Return(true, nil).AnyTimes() + + sink := output.NewPlainSink(io.Discard) + + monitor := newStartupMonitor(mockRT, sink, nil, time.Minute, false) + err := monitor.await(context.Background(), "cid", unreachableHealthURL(t), exitResultChan(runtime.ExitResult{ExitCode: 42})) + + var exitErr *containerExitedError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 42, exitErr.exitCode) +} + +func TestStartupMonitorAwait_WaitErrorFallsBackToPoll(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + // First probe: running. After the exitCh error nils the channel, the poll + // sees the container gone and reports an unknown exit code. + gomock.InOrder( + mockRT.EXPECT().IsRunning(gomock.Any(), "cid").Return(true, nil), + mockRT.EXPECT().IsRunning(gomock.Any(), "cid").Return(false, nil), + ) + + sink := output.NewPlainSink(io.Discard) + + monitor := newStartupMonitor(mockRT, sink, nil, time.Minute, false) + err := monitor.await(context.Background(), "cid", unreachableHealthURL(t), exitResultChan(runtime.ExitResult{ExitCode: -1, Err: errors.New("wait failed")})) + + var exitErr *containerExitedError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, -1, exitErr.exitCode, "an exit detected only by polling has an unknown code") +} + +func TestResolveStartupTimeout(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + interactive bool + want time.Duration + }{ + {"zero interactive uses short default", 0, true, defaultStartupTimeoutInteractive}, + {"zero non-interactive uses long default", 0, false, defaultStartupTimeoutNonInteractive}, + {"explicit wins interactive", 5 * time.Second, true, 5 * time.Second}, + {"explicit wins non-interactive", 5 * time.Second, false, 5 * time.Second}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, resolveStartupTimeout(tt.timeout, tt.interactive)) + }) + } +} + +func TestStartContainers_ExitedEmitsErrorAndTelemetry(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: "1", // unreachable port so the health GET never connects + ContainerPort: "4566/tcp", + HealthPath: "/_localstack/health", + } + const containerID = "abc123" + mockRT.EXPECT().Start(gomock.Any(), c).Return(containerID, exitResultChan(runtime.ExitResult{ExitCode: 3}), nil) + mockRT.EXPECT().StreamLogs(gomock.Any(), containerID, gomock.Any(), true).Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), containerID).Return(true, nil).AnyTimes() + mockRT.EXPECT().Logs(gomock.Any(), containerID, 20).Return("boom: fatal error\n", 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{}, time.Minute, false) + tel.Close() + + require.Error(t, err) + assert.True(t, output.IsSilent(err), "error should be silent since an ErrorEvent was emitted") + got := out.String() + assert.Contains(t, got, "exited unexpectedly (exit code 3)") + assert.Contains(t, got, "boom: fatal error", "the log tail should be surfaced in the summary") + + select { + case ev := <-capturedEvents: + payload, ok := ev["payload"].(map[string]any) + require.True(t, ok) + assert.Equal(t, telemetry.LifecycleStartError, payload["event_type"]) + assert.Equal(t, telemetry.ErrCodeStartFailed, payload["error_code"]) + default: + t.Fatal("no telemetry event received") + } +} + +func TestStartContainers_TimeoutEmitsErrorAndTelemetry(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: "1", // unreachable port so the health GET never connects + ContainerPort: "4566/tcp", + HealthPath: "/_localstack/health", + } + const containerID = "abc123" + // exitCh never fires; the container stays running but never becomes healthy. + mockRT.EXPECT().Start(gomock.Any(), c).Return(containerID, (<-chan runtime.ExitResult)(make(chan runtime.ExitResult)), nil) + mockRT.EXPECT().StreamLogs(gomock.Any(), containerID, gomock.Any(), true).Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), containerID).Return(true, nil).AnyTimes() + mockRT.EXPECT().Logs(gomock.Any(), containerID, 20).Return("still booting...\n", 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, false) + tel.Close() + + require.Error(t, err) + assert.True(t, output.IsSilent(err)) + got := out.String() + assert.Contains(t, got, "did not become ready within 50ms") + assert.Contains(t, got, "lstk logs") + assert.Contains(t, got, "lstk stop") + + select { + case ev := <-capturedEvents: + payload, ok := ev["payload"].(map[string]any) + require.True(t, ok) + assert.Equal(t, telemetry.ErrCodeStartTimeout, payload["error_code"]) + default: + t.Fatal("no telemetry event received") + } +} + +func TestLastLogLines(t *testing.T) { + assert.Equal(t, "", lastLogLines("", 5)) + assert.Equal(t, "a\nb", lastLogLines("a\nb\n", 5)) + assert.Equal(t, "d\ne", lastLogLines("a\nb\nc\nd\ne", 2)) + assert.Equal(t, "only", lastLogLines("only", 3)) +} + func TestPullImages_ReusesLocalImageWhenPresent(t *testing.T) { ctrl := gomock.NewController(t) mockRT := runtime.NewMockRuntime(ctrl) diff --git a/internal/env/env.go b/internal/env/env.go index f328bee9..30b244fa 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -3,6 +3,7 @@ package env import ( "os" "strings" + "time" "github.com/spf13/viper" ) @@ -13,6 +14,7 @@ type Env struct { DockerHost string DisableEvents bool TracesEnabled bool + StartupTimeout time.Duration APIEndpoint string WebAppURL string @@ -41,6 +43,7 @@ func Init() *Env { DockerHost: os.Getenv("DOCKER_HOST"), DisableEvents: os.Getenv("LOCALSTACK_DISABLE_EVENTS") == "1", TracesEnabled: viper.GetBool("otel"), + StartupTimeout: viper.GetDuration("startup_timeout"), APIEndpoint: viper.GetString("api_endpoint"), WebAppURL: viper.GetString("web_app_url"), ForceFileKeyring: viper.GetString("keyring") == "file", diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index 10c9206c..a7ef4012 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -232,19 +232,19 @@ func (d *DockerRuntime) PullImage(ctx context.Context, imageName string, progres return nil } -func (d *DockerRuntime) Start(ctx context.Context, config ContainerConfig) (string, error) { +func (d *DockerRuntime) Start(ctx context.Context, config ContainerConfig) (string, <-chan ExitResult, error) { bindHostStr := config.BindHost if bindHostStr == "" { bindHostStr = "127.0.0.1" } bindHost, err := netip.ParseAddr(bindHostStr) if err != nil { - return "", fmt.Errorf("invalid bind host %q: %w", bindHostStr, err) + return "", nil, fmt.Errorf("invalid bind host %q: %w", bindHostStr, err) } containerPort, err := network.ParsePort(config.ContainerPort) if err != nil { - return "", fmt.Errorf("invalid container port %q: %w", config.ContainerPort, err) + return "", nil, fmt.Errorf("invalid container port %q: %w", config.ContainerPort, err) } exposedPorts := network.PortSet{containerPort: struct{}{}} portBindings := network.PortMap{containerPort: []network.PortBinding{{HostIP: bindHost, HostPort: config.Port}}} @@ -256,7 +256,7 @@ func (d *DockerRuntime) Start(ctx context.Context, config ContainerConfig) (stri } p, err := network.ParsePort(ep.ContainerPort + "/" + proto) if err != nil { - return "", fmt.Errorf("invalid extra port %q: %w", ep.ContainerPort, err) + return "", nil, fmt.Errorf("invalid extra port %q: %w", ep.ContainerPort, err) } exposedPorts[p] = struct{}{} portBindings[p] = []network.PortBinding{{HostIP: bindHost, HostPort: ep.HostPort}} @@ -285,14 +285,20 @@ func (d *DockerRuntime) Start(ctx context.Context, config ContainerConfig) (stri Name: config.Name, }) if err != nil { - return "", err + return "", nil, err } + // Register the exit wait before starting: an instantly-exiting container is + // auto-removed (--rm) so fast that a wait registered after start can miss it + // and lose the exit code. "next-exit" cannot fire for a created but + // not-yet-started container, so this never observes a stale exit. + exitCh := d.waitForExit(ctx, resp.ID) + if _, err := d.client.ContainerStart(ctx, resp.ID, client.ContainerStartOptions{}); err != nil { - return "", err + return "", nil, err } - return resp.ID, nil + return resp.ID, exitCh, nil } func (d *DockerRuntime) Stop(ctx context.Context, containerName string) error { @@ -353,6 +359,27 @@ func (d *DockerRuntime) IsRunning(ctx context.Context, containerID string) (bool return inspect.Container.State.Running, nil } +// waitForExit returns a channel that receives exactly one ExitResult for the +// container's next exit. ContainerWait blocks until the server acknowledges the +// wait registration, so the wait is armed by the time this returns. +func (d *DockerRuntime) waitForExit(ctx context.Context, containerID string) <-chan ExitResult { + wait := d.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNextExit}) + + // Buffered so the goroutine never leaks if the caller stops reading. + out := make(chan ExitResult, 1) + go func() { + select { + case resp := <-wait.Result: + out <- ExitResult{ExitCode: int(resp.StatusCode)} + case err := <-wait.Error: + out <- ExitResult{ExitCode: -1, Err: err} + case <-ctx.Done(): + out <- ExitResult{ExitCode: -1, Err: ctx.Err()} + } + }() + return out +} + func (d *DockerRuntime) ContainerStartedAt(ctx context.Context, containerName string) (time.Time, error) { inspect, err := d.client.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{}) if err != nil { diff --git a/internal/runtime/mock_runtime.go b/internal/runtime/mock_runtime.go index 0fbe8e62..22293e94 100644 --- a/internal/runtime/mock_runtime.go +++ b/internal/runtime/mock_runtime.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: internal/runtime/runtime.go +// Source: runtime.go // // Generated by this command: // -// mockgen -source=internal/runtime/runtime.go -destination=internal/runtime/mock_runtime.go -package=runtime +// mockgen -source=runtime.go -destination=mock_runtime.go -package=runtime // // Package runtime is a generated GoMock package. @@ -232,12 +232,13 @@ func (mr *MockRuntimeMockRecorder) SocketPath() *gomock.Call { } // Start mocks base method. -func (m *MockRuntime) Start(ctx context.Context, config ContainerConfig) (string, error) { +func (m *MockRuntime) Start(ctx context.Context, config ContainerConfig) (string, <-chan ExitResult, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Start", ctx, config) ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret1, _ := ret[1].(<-chan ExitResult) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // Start indicates an expected call of Start. diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 44669028..a3d39f2b 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -1,5 +1,7 @@ package runtime +//go:generate mockgen -source=runtime.go -destination=mock_runtime.go -package=runtime + import ( "context" "io" @@ -51,12 +53,25 @@ type RunningContainer struct { BoundPort string // host port bound to the queried container port } +// ExitResult reports a container's exit as observed by the exit wait that +// Start registers. +type ExitResult struct { + ExitCode int // -1 when unknown + Err error // wait itself failed (exit code unknown) +} + // Runtime abstracts container runtime operations (Docker, Podman, Kubernetes, etc.) type Runtime interface { IsHealthy(ctx context.Context) error EmitUnhealthyError(sink output.Sink, err error) PullImage(ctx context.Context, image string, progress chan<- PullProgress) error - Start(ctx context.Context, config ContainerConfig) (string, error) + // Start creates and starts the container. The returned channel receives + // exactly one ExitResult when the container exits. The exit wait is + // registered between create and start so that even an instantly-exiting + // container's exit code is observed — with AutoRemove the container is + // removed the moment it exits, after which a wait can no longer be + // registered. + Start(ctx context.Context, config ContainerConfig) (string, <-chan ExitResult, error) Stop(ctx context.Context, containerName string) error Remove(ctx context.Context, containerName string) error IsRunning(ctx context.Context, containerID string) (bool, error) diff --git a/internal/telemetry/events.go b/internal/telemetry/events.go index 20b1d6a3..09888bfb 100644 --- a/internal/telemetry/events.go +++ b/internal/telemetry/events.go @@ -78,6 +78,7 @@ const ( ErrCodeImagePullFailed = "image_pull_failed" ErrCodeLicenseInvalid = "license_invalid" ErrCodeStartFailed = "start_failed" + ErrCodeStartTimeout = "start_timeout" ErrCodeEmulatorMismatch = "emulator_mismatch" ) diff --git a/test/integration/env/env.go b/test/integration/env/env.go index 9f2772a5..ae940114 100644 --- a/test/integration/env/env.go +++ b/test/integration/env/env.go @@ -23,6 +23,7 @@ const ( Persistence Key = "LOCALSTACK_PERSISTENCE" Otel Key = "LSTK_OTEL" OtelEndpoint Key = "OTEL_EXPORTER_OTLP_ENDPOINT" + StartupTimeout Key = "LSTK_STARTUP_TIMEOUT" AWSAccessKeyID Key = "AWS_ACCESS_KEY_ID" AWSSecretAccessKey Key = "AWS_SECRET_ACCESS_KEY" // AzureCollectTelemetry controls the Azure CLI's usage telemetry. Defaulted to diff --git a/test/integration/start_test.go b/test/integration/start_test.go index d282b180..95987a67 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -95,6 +95,186 @@ func TestStartCommandReusesLocalImageWhenPresent(t *testing.T) { assert.NotContains(t, stdout, "Pulling", "lstk must not re-pull an image that is already present") } +// A container that exits during startup must fail with a styled +// ErrorEvent that reports the exit code, not the unstyled top-level fallback. +// The stand-in alpine image runs its default /bin/sh which exits immediately +// (exit code 0) without a TTY, so the container is gone almost at once. +func TestStartFailsWhenContainerExitsDuringStartup(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + + const pinnedTag = "exit-during-startup-test" + const pinnedImage = "localstack/localstack-pro:" + pinnedTag + reader, err := dockerClient.ImagePull(ctx, testImage, client.ImagePullOptions{}) + require.NoError(t, err, "failed to pull test image") + _, _ = io.Copy(io.Discard, reader) + _ = reader.Close() + _, err = dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: pinnedImage}) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), pinnedImage, client.ImageRemoveOptions{}) + }) + + // A pinned tag with the image present locally skips the license pre-flight. + wantContainer := "localstack-aws-" + pinnedTag + t.Cleanup(func() { + _, _ = dockerClient.ContainerRemove(context.Background(), wantContainer, 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\"\ntag = %q\nport = \"4599\"\n", pinnedTag)), 0644)) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + e := append(testEnvWithHome(home, ""), + string(env.APIEndpoint)+"="+mockServer.URL, + string(env.AuthToken)+"=fake-token") + stdout, stderr, err := runLstk(t, ctx, "", e, "--config", configFile, "--non-interactive", "start") + + require.Error(t, err, "expected start to fail when the container exits during startup") + requireExitCode(t, 1, err) + combined := stdout + stderr + assert.Contains(t, combined, "exited unexpectedly", "the failure must be surfaced as a styled error") + assert.Contains(t, combined, "exit code 0", "the captured exit code must be reported") +} + +// A container that stays running but never serves /_localstack/health must not wait forever. +// With a short LSTK_STARTUP_TIMEOUT in non-interactive mode, +// lstk fails with a bounded timeout error and guidance, +// leaving the container running. The stand-in nginx image keeps its entrypoint +// alive but nothing listens on 4566, so the health check never connects. +func TestStartFailsWhenContainerNeverBecomesHealthy(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + + const standInImage = "nginx:alpine" + const pinnedTag = "never-healthy-test" + const pinnedImage = "localstack/localstack-pro:" + pinnedTag + reader, err := dockerClient.ImagePull(ctx, standInImage, client.ImagePullOptions{}) + require.NoError(t, err, "failed to pull nginx test image") + _, _ = io.Copy(io.Discard, reader) + _ = reader.Close() + _, err = dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: standInImage, Target: pinnedImage}) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), pinnedImage, client.ImageRemoveOptions{}) + }) + + // The container stays running on timeout (it is not auto-removed until it + // exits), so force-remove it explicitly. Its name is localstack-aws-. + wantContainer := "localstack-aws-" + pinnedTag + t.Cleanup(func() { + _, _ = dockerClient.ContainerRemove(context.Background(), wantContainer, 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\"\ntag = %q\nport = \"4599\"\n", pinnedTag)), 0644)) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + e := append(testEnvWithHome(home, ""), + string(env.APIEndpoint)+"="+mockServer.URL, + string(env.AuthToken)+"=fake-token", + string(env.StartupTimeout)+"=5s") + stdout, stderr, err := runLstk(t, ctx, "", e, "--config", configFile, "--non-interactive", "start") + + require.Error(t, err, "expected start to fail when the container never becomes healthy") + requireExitCode(t, 1, err) + combined := stdout + stderr + assert.Contains(t, combined, "did not become ready within 5s", "the bounded timeout must be surfaced") + assert.Contains(t, combined, "lstk logs", "the guidance must point at lstk logs") + assert.Contains(t, combined, "lstk stop", "the guidance must point at lstk stop") +} + +// Interrupting a start while it awaits readiness (Ctrl+C / SIGINT) renders no +// styled error — it is a deliberate abort — but must still be tracked as a +// start_error lifecycle telemetry event, as it was before startup monitoring +// was introduced. Decided in the PR #390 review; this test guards the decision. +func TestStartEmitsTelemetryWhenInterruptedDuringStartup(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("sending os.Interrupt to a child process is not supported on Windows") + } + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + + // Same stand-in as TestStartFailsWhenContainerNeverBecomesHealthy: nginx + // keeps running but never serves the health endpoint, so the start stays in + // the readiness wait until interrupted. + const standInImage = "nginx:alpine" + const pinnedTag = "interrupted-startup-test" + const pinnedImage = "localstack/localstack-pro:" + pinnedTag + reader, err := dockerClient.ImagePull(ctx, standInImage, client.ImagePullOptions{}) + require.NoError(t, err, "failed to pull nginx test image") + _, _ = io.Copy(io.Discard, reader) + _ = reader.Close() + _, err = dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: standInImage, Target: pinnedImage}) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), pinnedImage, client.ImageRemoveOptions{}) + }) + + // The container stays running after the abort (it is detached), so + // force-remove it explicitly. + wantContainer := "localstack-aws-" + pinnedTag + t.Cleanup(func() { + _, _ = dockerClient.ContainerRemove(context.Background(), wantContainer, 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\"\ntag = %q\nport = \"4599\"\n", pinnedTag)), 0644)) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + analyticsSrv, events := mockAnalyticsServer(t) + + binPath, err := filepath.Abs(binaryPath()) + require.NoError(t, err) + cmd := exec.CommandContext(ctx, binPath, "--config", configFile, "--non-interactive", "start") + cmd.Env = append(testEnvWithHome(home, ""), + string(env.APIEndpoint)+"="+mockServer.URL, + string(env.AuthToken)+"=fake-token", + string(env.AnalyticsEndpoint)+"="+analyticsSrv.URL) + out := &syncBuffer{} + cmd.Stdout = out + cmd.Stderr = out + require.NoError(t, cmd.Start()) + + // Interrupt only once the container is running, so the SIGINT lands in the + // readiness wait rather than during container creation. + require.Eventually(t, func() bool { + inspect, ierr := dockerClient.ContainerInspect(ctx, wantContainer, client.ContainerInspectOptions{}) + return ierr == nil && inspect.Container.State.Running + }, 60*time.Second, 100*time.Millisecond, "container never started; output:\n%s", out.String()) + require.NoError(t, cmd.Process.Signal(os.Interrupt)) + _ = cmd.Wait() + + byName := collectTelemetryByName(t, events, 2) + lifecycle, ok := byName["lstk_lifecycle"] + require.True(t, ok, "an interrupted start must still emit a lifecycle telemetry event") + lifePayload, _ := lifecycle["payload"].(map[string]any) + assert.Equal(t, "start_error", lifePayload["event_type"]) + assert.Equal(t, "start_failed", lifePayload["error_code"]) + assert.Contains(t, lifePayload["error_msg"], "context canceled") + assert.Contains(t, byName, "lstk_command") +} + func TestStartCommandSucceedsWithKeyringToken(t *testing.T) { requireDocker(t) cleanup()