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
25 changes: 22 additions & 3 deletions cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"os"
"strconv"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/container"
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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
}
4 changes: 2 additions & 2 deletions internal/container/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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
}()
Expand Down
7 changes: 5 additions & 2 deletions internal/runtime/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions internal/runtime/mock_runtime.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions internal/ui/run_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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})
Expand Down
126 changes: 126 additions & 0 deletions test/integration/logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package integration_test

import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -60,6 +63,129 @@ func TestLogsCommandFailsWhenNotRunning(t *testing.T) {
assertCommandTelemetry(t, events, "logs", 1)
}

// writeNumberedLogLines writes tail-marker-1..tail-marker-<count> 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()
Expand Down
Loading