diff --git a/cmd/logs.go b/cmd/logs.go index 823ebdec..07f2b89b 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "strconv" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/container" @@ -17,7 +18,7 @@ func newLogsCmd(cfg *env.Env) *cobra.Command { cmd := &cobra.Command{ Use: "logs", Short: "Show emulator logs", - Long: "Show logs from the emulator. Use --follow to stream in real-time.", + Long: "Show logs from the emulator. Use --follow to stream in real-time and --tail to limit output to the last N lines.", PreRunE: initConfigDeferCreate(nil), RunE: func(cmd *cobra.Command, args []string) error { follow, err := cmd.Flags().GetBool("follow") @@ -28,6 +29,13 @@ func newLogsCmd(cfg *env.Env) *cobra.Command { if err != nil { return err } + tail, err := cmd.Flags().GetString("tail") + if err != nil { + return err + } + if err := validateTail(tail); err != nil { + return err + } rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err @@ -37,12 +45,23 @@ func newLogsCmd(cfg *env.Env) *cobra.Command { return fmt.Errorf("failed to get config: %w", err) } if isInteractiveMode(cfg) { - return ui.RunLogs(cmd.Context(), rt, appConfig.Containers, follow, verbose) + return ui.RunLogs(cmd.Context(), rt, appConfig.Containers, follow, tail, verbose) } - return container.Logs(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers, follow, verbose) + return container.Logs(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers, follow, tail, verbose) }, } cmd.Flags().BoolP("follow", "f", false, "Follow log output") cmd.Flags().BoolP("verbose", "v", false, "Show all log output without filtering") + cmd.Flags().StringP("tail", "n", "all", "Number of lines to show from the end of the logs") return cmd } + +func validateTail(tail string) error { + if tail == "all" { + return nil + } + if n, err := strconv.Atoi(tail); err != nil || n < 0 { + return fmt.Errorf("invalid --tail value %q: expected a non-negative integer or \"all\"", tail) + } + return nil +} diff --git a/internal/container/logs.go b/internal/container/logs.go index 192dca7d..7f033f39 100644 --- a/internal/container/logs.go +++ b/internal/container/logs.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "strconv" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/output" @@ -26,7 +27,18 @@ const maxLogLineBytes = 8 * 1024 // bounds only what is kept and emitted). const logReaderBufferSize = 64 * 1024 -func Logs(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []config.ContainerConfig, follow bool, verbose bool) error { +// tailAll is the --tail value meaning "no limit". +const tailAll = "all" + +// backlogFetchFactor over-fetches raw container lines relative to the requested +// --tail, so the filter usually has enough survivors after a single pass. +const backlogFetchFactor = 8 + +// maxBacklogFetch bounds that over-fetch; beyond it lstk reads the container's +// whole history rather than growing the request further. +const maxBacklogFetch = 100_000 + +func Logs(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []config.ContainerConfig, follow bool, tail string, verbose bool) error { if err := rt.IsHealthy(ctx); err != nil { rt.EmitUnhealthyError(sink, err) return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) @@ -47,24 +59,107 @@ func Logs(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers return HandleNoRunningContainer(sink, c) } + emit := func(line string, level output.LogLevel) { + sink.Emit(output.LogLineEvent{Source: output.LogSourceEmulator, Line: line, Level: level}) + } + + // A --tail limit counts the lines lstk prints, not the raw container lines. + // Letting the runtime apply the limit would count lines that the filter + // then drops, so `--tail 1` prints nothing whenever the newest raw line + // happens to be a filtered one. Verbose mode prints every line, so there + // the runtime's own tail is already exact (and far cheaper). + limit, hasLimit := parseTailLimit(tail) + if !hasLimit || verbose { + _, err := forEachLogLine(ctx, rt, name, follow, tail, verbose, emit) + return err + } + + if err := emitFilteredBacklog(ctx, rt, name, limit, emit); err != nil { + return err + } + if !follow { + return nil + } + // The backlog is already printed, so stream only what arrives from here on. + // Lines written in the gap between the two calls are not shown. + _, err = forEachLogLine(ctx, rt, name, true, "0", verbose, emit) + return err +} + +// parseTailLimit reports the numeric --tail limit, if the value sets one. +func parseTailLimit(tail string) (int, bool) { + if tail == "" || tail == tailAll { + return 0, false + } + n, err := strconv.Atoi(tail) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + +// emitFilteredBacklog emits the last limit lines that survive filtering. +func emitFilteredBacklog(ctx context.Context, rt runtime.Runtime, name string, limit int, emit func(string, output.LogLevel)) error { + if limit == 0 { + return nil + } + + // Filtering is per-line and order-preserving, so the last limit survivors + // of a long enough raw suffix are the last limit survivors overall. Grow + // the suffix only when it turned out to be too heavily filtered, so a small + // --tail on a long-running emulator does not stream the whole history. + ring := newLineRing(limit) + for fetch := limit * backlogFetchFactor; ; fetch *= backlogFetchFactor { + unbounded := fetch <= 0 || fetch > maxBacklogFetch // also covers int overflow + tailArg := tailAll + if !unbounded { + tailArg = strconv.Itoa(fetch) + } + + ring.reset() + raw, err := forEachLogLine(ctx, rt, name, false, tailArg, false, ring.add) + if err != nil { + return err + } + // A short read means the suffix covered the whole history. + if unbounded || ring.len() == limit || raw < fetch { + break + } + } + ring.emit(emit) + return nil +} + +// forEachLogLine streams the container's logs, calling fn for every line that +// survives filtering, and reports how many raw lines it read. +func forEachLogLine(ctx context.Context, rt runtime.Runtime, name string, follow bool, tail string, verbose bool, fn func(string, output.LogLevel)) (int, error) { pr, pw := io.Pipe() errCh := make(chan error, 1) go func() { - err := rt.StreamLogs(ctx, name, pw, follow) - pw.CloseWithError(err) - errCh <- err + // Deferred so that a goroutine death which skips the normal return — + // a panic, or a test double calling t.Fatal — still closes the pipe and + // reports back. Otherwise the read loop below blocks on a writer that + // will never write, and the errCh receive never completes. + var err error + defer func() { + pw.CloseWithError(err) + errCh <- err + }() + err = rt.StreamLogs(ctx, name, pw, follow, tail) }() + raw := 0 reader := bufio.NewReaderSize(pr, logReaderBufferSize) for { line, truncated, ok, err := readBoundedLine(reader, maxLogLineBytes) if ok { + raw++ if verbose || !shouldFilter(line) { level, _ := parseLogLine(line) if truncated > 0 { line = fmt.Sprintf("%s … (%d more bytes truncated)", line, truncated) } - sink.Emit(output.LogLineEvent{Source: output.LogSourceEmulator, Line: line, Level: level}) + fn(line, level) } } if err != nil { @@ -74,10 +169,57 @@ func Logs(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers if ctx.Err() != nil { break } - return err + return raw, err } } - return <-errCh + return raw, <-errCh +} + +// lineRing keeps the most recent n log lines, overwriting the oldest. +type lineRing struct { + buf []logLine + next int + full bool +} + +type logLine struct { + text string + level output.LogLevel +} + +func newLineRing(n int) *lineRing { return &lineRing{buf: make([]logLine, n)} } + +func (r *lineRing) add(text string, level output.LogLevel) { + if len(r.buf) == 0 { + return + } + r.buf[r.next] = logLine{text: text, level: level} + r.next = (r.next + 1) % len(r.buf) + if r.next == 0 { + r.full = true + } +} + +func (r *lineRing) len() int { + if r.full { + return len(r.buf) + } + return r.next +} + +func (r *lineRing) reset() { + r.next, r.full = 0, false +} + +func (r *lineRing) emit(fn func(string, output.LogLevel)) { + start := 0 + if r.full { + start = r.next + } + for i := range r.len() { + l := r.buf[(start+i)%len(r.buf)] + fn(l.text, l.level) + } } // readBoundedLine reads one newline-delimited line from r, buffering at most max diff --git a/internal/container/logs_test.go b/internal/container/logs_test.go index 422cd21e..be2012fc 100644 --- a/internal/container/logs_test.go +++ b/internal/container/logs_test.go @@ -2,7 +2,9 @@ package container import ( "context" + "fmt" "io" + "strconv" "strings" "sync" "testing" @@ -36,15 +38,15 @@ func runLogs(t *testing.T, content string, verbose bool) *captureSink { mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil) mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil) mockRT.EXPECT(). - StreamLogs(gomock.Any(), "localstack-aws", gomock.Any(), false). - DoAndReturn(func(_ context.Context, _ string, out io.Writer, _ bool) error { + StreamLogs(gomock.Any(), "localstack-aws", gomock.Any(), false, "all"). + DoAndReturn(func(_ context.Context, _ string, out io.Writer, _ bool, _ string) error { _, err := io.WriteString(out, content) return err }) sink := &captureSink{} containers := []config.ContainerConfig{{Type: config.EmulatorAWS}} - err := Logs(context.Background(), mockRT, sink, containers, false, verbose) + err := Logs(context.Background(), mockRT, sink, containers, false, "all", verbose) require.NoError(t, err) return sink } @@ -99,6 +101,134 @@ func TestLogs_FilteredLinesDropped(t *testing.T) { assert.Contains(t, sink.lines[0].Line, "keep") } +// dockerTail mimics the runtime's Tail semantics — return the last n lines of +// the container's history — so tail tests exercise the same slicing order the +// real runtime applies. +func dockerTail(content, tail string) string { + if tail == tailAll { + return content + } + n, err := strconv.Atoi(tail) + if err != nil { + return content + } + lines := strings.Split(strings.TrimSuffix(content, "\n"), "\n") + if n > len(lines) { + n = len(lines) + } + if n == 0 { + return "" + } + return strings.Join(lines[len(lines)-n:], "\n") + "\n" +} + +func runLogsWithTail(t *testing.T, content, tail string) *captureSink { + t.Helper() + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil) + mockRT.EXPECT(). + StreamLogs(gomock.Any(), "localstack-aws", gomock.Any(), false, gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, out io.Writer, _ bool, tail string) error { + _, err := io.WriteString(out, dockerTail(content, tail)) + return err + }).AnyTimes() + + sink := &captureSink{} + containers := []config.ContainerConfig{{Type: config.EmulatorAWS}} + err := Logs(context.Background(), mockRT, sink, containers, false, tail, false) + require.NoError(t, err) + return sink +} + +func filteredLines(n int) string { + var b strings.Builder + for i := range n { + fmt.Fprintf(&b, "2026-07-07T10:05:%02d.240 INFO --- [et.reactor-0] localstack.request.http : GET /_localstack/health => 200\n", i) + } + return b.String() +} + +// --tail counts the lines lstk prints, not the raw container lines. Letting the +// runtime apply the limit made `--tail 1` print nothing whenever the newest raw +// lines were ones the filter drops. +func TestLogs_TailCountsVisibleLinesNotFilteredOnes(t *testing.T) { + t.Parallel() + content := "2026-07-07T10:05:11.240 INFO --- [ MainThread] l.foo : keep\n" + filteredLines(5) + + sink := runLogsWithTail(t, content, "1") + + require.Len(t, sink.lines, 1, "the newest visible line must survive a --tail smaller than the filtered burst") + assert.Contains(t, sink.lines[0].Line, "keep") +} + +// When the first over-fetched suffix is entirely filtered, the fetch must grow +// until it reaches a visible line rather than reporting an empty tail. +func TestLogs_TailGrowsFetchPastLongFilteredRun(t *testing.T) { + t.Parallel() + content := "2026-07-07T10:05:11.240 INFO --- [ MainThread] l.foo : keep\n" + filteredLines(20) + + sink := runLogsWithTail(t, content, "1") + + require.Len(t, sink.lines, 1) + assert.Contains(t, sink.lines[0].Line, "keep") +} + +// A --tail larger than the number of visible lines shows all of them, and the +// order stays oldest-first. +func TestLogs_TailKeepsOrderAndCapsAtVisibleCount(t *testing.T) { + t.Parallel() + content := "2026-07-07T10:05:11.240 INFO --- [ MainThread] l.foo : first\n" + + filteredLines(3) + + "2026-07-07T10:05:12.240 INFO --- [ MainThread] l.foo : second\n" + + "2026-07-07T10:05:13.240 INFO --- [ MainThread] l.foo : third\n" + + sink := runLogsWithTail(t, content, "2") + require.Len(t, sink.lines, 2) + assert.Contains(t, sink.lines[0].Line, "second") + assert.Contains(t, sink.lines[1].Line, "third") + + all := runLogsWithTail(t, content, "10") + require.Len(t, all.lines, 3, "a limit above the visible count shows every visible line") + assert.Contains(t, all.lines[0].Line, "first") +} + +// --tail 0 shows nothing and must not read the container's history to find out. +func TestLogs_TailZeroEmitsNothing(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil) + + sink := &captureSink{} + containers := []config.ContainerConfig{{Type: config.EmulatorAWS}} + require.NoError(t, Logs(context.Background(), mockRT, sink, containers, false, "0", false)) + assert.Empty(t, sink.lines) +} + +// Verbose mode prints every line, so the runtime's own tail is already exact +// and lstk must delegate to it instead of re-reading the history. +func TestLogs_VerboseDelegatesTailToRuntime(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil) + mockRT.EXPECT(). + StreamLogs(gomock.Any(), "localstack-aws", gomock.Any(), false, "2"). + DoAndReturn(func(_ context.Context, _ string, out io.Writer, _ bool, tail string) error { + _, err := io.WriteString(out, dockerTail(filteredLines(5), tail)) + return err + }) + + sink := &captureSink{} + containers := []config.ContainerConfig{{Type: config.EmulatorAWS}} + require.NoError(t, Logs(context.Background(), mockRT, sink, containers, false, "2", true)) + assert.Len(t, sink.lines, 2, "verbose keeps filtered lines, so the runtime tail is exact") +} + // When no matching emulator container is running, Logs must fail with a // silent error instead of trying to stream from a nonexistent container. func TestLogs_EmulatorNotRunning_ReturnsSilentError(t *testing.T) { @@ -113,7 +243,7 @@ func TestLogs_EmulatorNotRunning_ReturnsSilentError(t *testing.T) { sink := &captureSink{} containers := []config.ContainerConfig{{Type: config.EmulatorAWS}} - err := Logs(context.Background(), mockRT, sink, containers, false, false) + err := Logs(context.Background(), mockRT, sink, containers, false, "all", false) require.Error(t, err) assert.True(t, output.IsSilent(err), "error must be silent since the sink already surfaced a 'not running' message to the user") diff --git a/internal/container/start.go b/internal/container/start.go index a3a63493..f82e8721 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -548,7 +548,7 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, logDone := make(chan struct{}) go func() { defer close(logDone) - _ = rt.StreamLogs(logCtx, containerID, startupLogs, true) + _ = rt.StreamLogs(logCtx, containerID, startupLogs, true, "all") }() healthURL := fmt.Sprintf("http://localhost:%s%s", c.Port, c.HealthPath) diff --git a/internal/container/start_test.go b/internal/container/start_test.go index f7ac0a0e..660dc53f 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -660,7 +660,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, exitResultChan(runtime.ExitResult{ExitCode: 1}), nil) - mockRT.EXPECT().StreamLogs(gomock.Any(), containerID, gomock.Any(), true).Return(nil) + mockRT.EXPECT().StreamLogs(gomock.Any(), containerID, gomock.Any(), true, "all").Return(nil) mockRT.EXPECT().IsRunning(gomock.Any(), containerID).Return(false, nil) mockRT.EXPECT().Logs(gomock.Any(), containerID, 20).Return(licenseLog, nil) @@ -707,7 +707,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, exitResultChan(runtime.ExitResult{ExitCode: 1}), nil) - mockRT.EXPECT().StreamLogs(gomock.Any(), containerID, gomock.Any(), true).Return(nil) + mockRT.EXPECT().StreamLogs(gomock.Any(), containerID, gomock.Any(), true, "all").Return(nil) mockRT.EXPECT().IsRunning(gomock.Any(), containerID).Return(false, nil) mockRT.EXPECT().Logs(gomock.Any(), containerID, 20).Return(licenseLog, nil) @@ -882,7 +882,7 @@ func TestStartContainers_ExitedEmitsErrorAndTelemetry(t *testing.T) { } 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().StreamLogs(gomock.Any(), containerID, gomock.Any(), true, "all").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() @@ -927,7 +927,7 @@ func TestStartContainers_TimeoutEmitsErrorAndTelemetry(t *testing.T) { 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().StreamLogs(gomock.Any(), containerID, gomock.Any(), true, "all").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() diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index a7ef4012..a23cdc41 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -431,12 +431,15 @@ func (d *DockerRuntime) Logs(ctx context.Context, containerID string, tail int) return string(logs), nil } -func (d *DockerRuntime) StreamLogs(ctx context.Context, containerID string, out io.Writer, follow bool) error { +func (d *DockerRuntime) StreamLogs(ctx context.Context, containerID string, out io.Writer, follow bool, tail string) error { + if tail == "" { + tail = "all" + } reader, err := d.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ ShowStdout: true, ShowStderr: true, Follow: follow, - Tail: "all", + Tail: tail, }) if err != nil { if errdefs.IsNotFound(err) { diff --git a/internal/runtime/mock_runtime.go b/internal/runtime/mock_runtime.go index 22293e94..b014505b 100644 --- a/internal/runtime/mock_runtime.go +++ b/internal/runtime/mock_runtime.go @@ -262,15 +262,15 @@ func (mr *MockRuntimeMockRecorder) Stop(ctx, containerName any) *gomock.Call { } // StreamLogs mocks base method. -func (m *MockRuntime) StreamLogs(ctx context.Context, containerID string, out io.Writer, follow bool) error { +func (m *MockRuntime) StreamLogs(ctx context.Context, containerID string, out io.Writer, follow bool, tail string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "StreamLogs", ctx, containerID, out, follow) + ret := m.ctrl.Call(m, "StreamLogs", ctx, containerID, out, follow, tail) ret0, _ := ret[0].(error) return ret0 } // StreamLogs indicates an expected call of StreamLogs. -func (mr *MockRuntimeMockRecorder) StreamLogs(ctx, containerID, out, follow any) *gomock.Call { +func (mr *MockRuntimeMockRecorder) StreamLogs(ctx, containerID, out, follow, tail any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StreamLogs", reflect.TypeOf((*MockRuntime)(nil).StreamLogs), ctx, containerID, out, follow) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StreamLogs", reflect.TypeOf((*MockRuntime)(nil).StreamLogs), ctx, containerID, out, follow, tail) } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index a3d39f2b..9022f8c8 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -78,7 +78,7 @@ type Runtime interface { ContainerStartedAt(ctx context.Context, containerName string) (time.Time, error) ContainerEnv(ctx context.Context, containerName string) ([]string, error) Logs(ctx context.Context, containerID string, tail int) (string, error) - StreamLogs(ctx context.Context, containerID string, out io.Writer, follow bool) error + StreamLogs(ctx context.Context, containerID string, out io.Writer, follow bool, tail string) error GetImageVersion(ctx context.Context, imageName string) (string, error) // ImageExists reports whether the given image is already present locally. ImageExists(ctx context.Context, image string) (bool, error) diff --git a/internal/ui/run_logs.go b/internal/ui/run_logs.go index bc4db22b..613d19d6 100644 --- a/internal/ui/run_logs.go +++ b/internal/ui/run_logs.go @@ -12,7 +12,7 @@ import ( "github.com/localstack/lstk/internal/runtime" ) -func RunLogs(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, follow bool, verbose bool) error { +func RunLogs(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, follow bool, tail string, verbose bool) error { ctx, cancel := context.WithCancel(parentCtx) defer cancel() @@ -21,7 +21,7 @@ func RunLogs(parentCtx context.Context, rt runtime.Runtime, containers []config. runErrCh := make(chan error, 1) go func() { - err := container.Logs(ctx, rt, output.NewTUISink(programSender{p: p}), containers, follow, verbose) + err := container.Logs(ctx, rt, output.NewTUISink(programSender{p: p}), containers, follow, tail, verbose) runErrCh <- err if err != nil && !errors.Is(err, context.Canceled) { p.Send(runErrMsg{err: err}) diff --git a/test/integration/logs_test.go b/test/integration/logs_test.go index a9c802cd..a49103b4 100644 --- a/test/integration/logs_test.go +++ b/test/integration/logs_test.go @@ -3,6 +3,8 @@ package integration_test import ( "bufio" "context" + "fmt" + "io" "os" "os/exec" "path/filepath" @@ -88,6 +90,174 @@ func TestLogsWorksWithExternalContainer(t *testing.T) { assertCommandTelemetry(t, events, "logs", 0) } +// writeNumberedLogLines writes tail-marker-1..tail-marker- to PID 1's +// stdout inside the test container and waits until the last one is visible in +// docker logs, so tail assertions don't race the writes. +func writeNumberedLogLines(t *testing.T, ctx context.Context, count int) { + t.Helper() + + writeContainerLogs(t, ctx, fmt.Sprintf("for i in $(seq 1 %d); do echo tail-marker-$i; done", count), fmt.Sprintf("tail-marker-%d", count)) +} + +// writeLogLines writes the given lines verbatim to PID 1's stdout inside the +// test container, so tests can plant lines that lstk's log filter drops. +func writeLogLines(t *testing.T, ctx context.Context, lines []string) { + t.Helper() + + var script strings.Builder + for _, line := range lines { + fmt.Fprintf(&script, "echo '%s'; ", line) + } + writeContainerLogs(t, ctx, script.String(), lines[len(lines)-1]) +} + +// writeContainerLogs runs script inside the test container with stdout wired to +// PID 1's, then waits until lastMarker is visible in docker logs so tail +// assertions don't race the writes. +func writeContainerLogs(t *testing.T, ctx context.Context, script, lastMarker string) { + t.Helper() + + execResp, err := dockerClient.ExecCreate(ctx, containerName, client.ExecCreateOptions{ + Cmd: []string{"sh", "-c", "{ " + script + " } >/proc/1/fd/1"}, + }) + require.NoError(t, err, "failed to create exec") + _, err = dockerClient.ExecStart(ctx, execResp.ID, client.ExecStartOptions{Detach: true}) + require.NoError(t, err, "failed to start exec") + + require.Eventually(t, func() bool { + reader, err := dockerClient.ContainerLogs(ctx, containerName, client.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Tail: "all", + }) + if err != nil { + return false + } + defer func() { _ = reader.Close() }() + data, err := io.ReadAll(reader) + return err == nil && strings.Contains(string(data), lastMarker) + }, 10*time.Second, 100*time.Millisecond, "log lines did not appear in docker logs") +} + +// Regression: --tail counts the lines lstk prints, not raw container lines. +// A burst of filtered request logs after the newest visible line used to +// consume the whole limit, so `lstk logs --tail 1` printed nothing at all. +func TestLogsTailCountsVisibleLinesNotFilteredOnes(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + + lines := []string{"2026-07-07T10:05:11.240 INFO --- [ MainThread] l.foo : tail-visible-marker"} + for i := range 5 { + lines = append(lines, fmt.Sprintf( + "2026-07-07T10:05:%02d.240 INFO --- [et.reactor-0] localstack.request.http : GET /_localstack/tail-filtered-marker => 200", 12+i)) + } + writeLogLines(t, ctx, lines) + + configFile := writeAwsConfig(t) + stdout, stderr, err := runLstk(t, ctx, "", env.Without(), "--config", configFile, "logs", "--tail", "1") + require.NoError(t, err, "lstk logs --tail 1 should exit cleanly, stderr: %s", stderr) + assert.Contains(t, stdout, "tail-visible-marker", "--tail 1 must show the newest visible line, not an empty result") + assert.NotContains(t, stdout, "tail-filtered-marker", "filtered request logs must stay hidden") +} + +func TestLogsTailLimitsOutput(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + writeNumberedLogLines(t, ctx, 10) + + configFile := writeAwsConfig(t) + + for _, flags := range [][]string{{"--tail", "3"}, {"-n", "3"}} { + args := append([]string{"--config", configFile, "logs"}, flags...) + stdout, stderr, err := runLstk(t, ctx, "", env.Without(), args...) + require.NoError(t, err, "lstk logs %s should exit cleanly, stderr: %s", strings.Join(flags, " "), stderr) + for i := 8; i <= 10; i++ { + assert.Contains(t, stdout, fmt.Sprintf("tail-marker-%d", i), "last 3 lines should be shown with %s", strings.Join(flags, " ")) + } + for i := 1; i <= 7; i++ { + assert.NotContains(t, stdout, fmt.Sprintf("tail-marker-%d\n", i), "older lines should be cut off with %s", strings.Join(flags, " ")) + } + } +} + +func TestLogsWithoutTailShowsAllLines(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + writeNumberedLogLines(t, ctx, 10) + + configFile := writeAwsConfig(t) + stdout, stderr, err := runLstk(t, ctx, "", env.Without(), "--config", configFile, "logs") + require.NoError(t, err, "lstk logs should exit cleanly, stderr: %s", stderr) + for i := 1; i <= 10; i++ { + assert.Contains(t, stdout, fmt.Sprintf("tail-marker-%d", i), "all lines should be shown without --tail") + } +} + +func TestLogsTailRejectsInvalidValue(t *testing.T) { + t.Parallel() + + configFile := writeAwsConfig(t) + _, stderr, err := runLstk(t, testContext(t), "", env.Without(), "--config", configFile, "logs", "--tail", "bogus") + require.Error(t, err, "expected lstk logs --tail bogus to fail") + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "bogus", "error should name the invalid value") + assert.Contains(t, stderr, "--tail", "error should name the flag") +} + +func TestLogsTailWithFollowStartsFromTail(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + writeNumberedLogLines(t, ctx, 10) + + configFile := writeAwsConfig(t) + // Uses StdoutPipe for streaming — cannot use runLstk. + logsCmd := exec.CommandContext(ctx, binaryPath(), "--config", configFile, "logs", "--follow", "--tail", "3") + logsCmd.Env = env.Without() + stdout, err := logsCmd.StdoutPipe() + require.NoError(t, err, "failed to get stdout pipe") + + err = logsCmd.Start() + require.NoError(t, err, "failed to start lstk logs --follow --tail 3") + t.Cleanup(func() { _ = logsCmd.Process.Kill() }) + + // The backlog is capped at the last 3 lines, so the first line streamed + // must be tail-marker-8; seeing an older marker first means tail was ignored. + firstLine := make(chan string, 1) + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "tail-marker-") { + firstLine <- line + return + } + } + }() + + select { + case line := <-firstLine: + assert.Contains(t, line, "tail-marker-8", "follow should start from the last 3 lines") + case <-ctx.Done(): + t.Fatal("no marker appeared in lstk logs --follow --tail output within timeout") + } +} + func TestLogsFollowStreamsOutput(t *testing.T) { requireDocker(t) cleanup()