diff --git a/cmd/logs.go b/cmd/logs.go index 3f43a70b..9928ebea 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: initConfig(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 0885e5eb..dd8b7927 100644 --- a/internal/container/logs.go +++ b/internal/container/logs.go @@ -11,7 +11,7 @@ import ( "github.com/localstack/lstk/internal/runtime" ) -func Logs(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []config.ContainerConfig, follow bool, verbose bool) error { +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)) @@ -27,7 +27,7 @@ func Logs(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers pr, pw := io.Pipe() errCh := make(chan error, 1) go func() { - err := rt.StreamLogs(ctx, c.Name(), pw, follow) + err := rt.StreamLogs(ctx, c.Name(), pw, follow, tail) pw.CloseWithError(err) errCh <- err }() diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index b0c96571..c85368e2 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -371,12 +371,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 0fbe8e62..911218cd 100644 --- a/internal/runtime/mock_runtime.go +++ b/internal/runtime/mock_runtime.go @@ -261,15 +261,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 44669028..ba6ef3bf 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -63,7 +63,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 ca0781ee..c41ffe72 100644 --- a/test/integration/logs_test.go +++ b/test/integration/logs_test.go @@ -2,6 +2,9 @@ package integration_test import ( "bufio" + "context" + "fmt" + "io" "os" "os/exec" "path/filepath" @@ -60,6 +63,129 @@ func TestLogsCommandFailsWhenNotRunning(t *testing.T) { assertCommandTelemetry(t, events, "logs", 1) } +// 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() + + execResp, err := dockerClient.ExecCreate(ctx, containerName, client.ExecCreateOptions{ + Cmd: []string{"sh", "-c", fmt.Sprintf("for i in $(seq 1 %d); do echo tail-marker-$i; done >/proc/1/fd/1", count)}, + }) + 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") + + lastMarker := fmt.Sprintf("tail-marker-%d", count) + 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") +} + +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()