diff --git a/README.md b/README.md index e81aa671..109001da 100644 --- a/README.md +++ b/README.md @@ -334,11 +334,17 @@ t.Log(result.StdOut) ### Container logs ```go -logs, err := resource.Logs(ctx) +// Get all logs with stdout and stderr separated +stdout, stderr, err := resource.Logs(ctx) if err != nil { t.Fatal(err) } -t.Log(logs) +t.Log(stdout) +t.Log(stderr) + +// Stream logs until container exits or ctx is cancelled +var buf bytes.Buffer +err = resource.FollowLogs(ctx, &buf, io.Discard) ``` ### Building from Dockerfile diff --git a/UPGRADE.md b/UPGRADE.md index d274a716..ee85ee06 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -420,7 +420,11 @@ Available `NetworkCreateOptions` fields: `Driver` (e.g., `"bridge"`, result, err := resource.Exec(ctx, []string{"pg_isready"}) // result.StdOut, result.StdErr, result.ExitCode -logs, err := resource.Logs(ctx) +stdout, stderr, err := resource.Logs(ctx) +// stdout and stderr are separated strings + +// Stream logs until container exits or ctx is cancelled: +err = resource.FollowLogs(ctx, os.Stdout, os.Stderr) ``` ### Advanced: Container Registry diff --git a/build_test.go b/build_test.go index 8a0def68..b635250d 100644 --- a/build_test.go +++ b/build_test.go @@ -93,20 +93,20 @@ CMD ["sh", "-c", "echo TEST_ENV=$TEST_ENV && sleep 300"] r := pool.BuildAndRunT(t, "test-build-args", buildOpts) // Verify build arg was applied by checking container env via logs - var logs string + var stdout string err := pool.Retry(t.Context(), 10*time.Second, func() error { var logErr error - logs, logErr = r.Logs(t.Context()) + stdout, _, logErr = r.Logs(t.Context()) if logErr != nil { return logErr } - if !strings.Contains(logs, "TEST_ENV=test-value") { + if !strings.Contains(stdout, "TEST_ENV=test-value") { return fmt.Errorf("logs do not yet contain expected env") } return nil }) if err != nil { - t.Fatalf("Expected logs to contain 'TEST_ENV=test-value', got: %s", logs) + t.Fatalf("Expected logs to contain 'TEST_ENV=test-value', got: %s", stdout) } } @@ -145,20 +145,20 @@ CMD ["sh", "-c", "echo $TEST_VAR && sleep 300"] ) // Verify env var took effect via logs - var logs string + var stdout string err := pool.Retry(t.Context(), 10*time.Second, func() error { var logErr error - logs, logErr = r.Logs(t.Context()) + stdout, _, logErr = r.Logs(t.Context()) if logErr != nil { return logErr } - if !strings.Contains(logs, "hello") { + if !strings.Contains(stdout, "hello") { return fmt.Errorf("logs do not yet contain expected env value") } return nil }) if err != nil { - t.Fatalf("Expected logs to contain 'hello', got: %s", logs) + t.Fatalf("Expected logs to contain 'hello', got: %s", stdout) } } @@ -193,20 +193,20 @@ func TestBuildAndRunWithBuildContext(t *testing.T) { } // Poll for expected log output - var logs string + var stdout string err := pool.Retry(t.Context(), 10*time.Second, func() error { var logErr error - logs, logErr = r.Logs(t.Context()) + stdout, _, logErr = r.Logs(t.Context()) if logErr != nil { return logErr } - if !strings.Contains(logs, "Hello, World!") { + if !strings.Contains(stdout, "Hello, World!") { return fmt.Errorf("logs do not yet contain 'Hello, World!'") } return nil }) if err != nil { - t.Fatalf("Expected logs to contain 'Hello, World!', got: %s (error: %v)", logs, err) + t.Fatalf("Expected logs to contain 'Hello, World!', got: %s (error: %v)", stdout, err) } } diff --git a/resource.go b/resource.go index a91cec78..e591e177 100644 --- a/resource.go +++ b/resource.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "fmt" + "io" "net" "github.com/containerd/errdefs" @@ -24,7 +25,8 @@ type Resource interface { GetPort(portID string) string GetBoundIP(portID string) string GetHostPort(portID string) string - Logs(ctx context.Context) (string, error) + Logs(ctx context.Context) (stdout, stderr string, err error) + FollowLogs(ctx context.Context, stdout, stderr io.Writer) error Exec(ctx context.Context, cmd []string) (ExecResult, error) ConnectToNetwork(ctx context.Context, net Network) error DisconnectFromNetwork(ctx context.Context, net Network) error @@ -153,28 +155,50 @@ func (r *resource) Cleanup(t TestingTB) { }) } -// Logs returns the container logs, demultiplexing stdout and stderr streams. -// Both stdout and stderr are combined in the returned string. -func (r *resource) Logs(ctx context.Context) (string, error) { +func (r *resource) containerLogReader(ctx context.Context, follow bool) (io.ReadCloser, error) { if r.pool == nil || r.pool.client == nil { - return "", ErrClientClosed + return nil, ErrClientClosed } - reader, err := r.pool.client.ContainerLogs(ctx, r.container.ID, mobyclient.ContainerLogsOptions{ ShowStdout: true, ShowStderr: true, + Follow: follow, }) if err != nil { - return "", fmt.Errorf("failed to get container logs: %w", err) + return nil, fmt.Errorf("failed to get container logs: %w", err) + } + return reader, nil +} + +// Logs returns the container logs, demultiplexing stdout and stderr. +func (r *resource) Logs(ctx context.Context) (stdout, stderr string, err error) { + reader, err := r.containerLogReader(ctx, false) + if err != nil { + return "", "", err } defer reader.Close() - var buf bytes.Buffer - if _, err := stdcopy.StdCopy(&buf, &buf, reader); err != nil { - return "", fmt.Errorf("failed to read container logs: %w", err) + var outBuf, errBuf bytes.Buffer + if _, err := stdcopy.StdCopy(&outBuf, &errBuf, reader); err != nil { + return "", "", fmt.Errorf("failed to read container logs: %w", err) + } + + return outBuf.String(), errBuf.String(), nil +} + +// FollowLogs streams container logs to stdout and stderr until ctx is cancelled +// or the container exits. Pass io.Discard for writers you don't need. +func (r *resource) FollowLogs(ctx context.Context, stdout, stderr io.Writer) error { + reader, err := r.containerLogReader(ctx, true) + if err != nil { + return err } + defer reader.Close() - return buf.String(), nil + if _, err := stdcopy.StdCopy(stdout, stderr, reader); err != nil { + return fmt.Errorf("failed to follow container logs: %w", err) + } + return nil } // ExecResult holds the output of a command executed inside a container. diff --git a/resource_test.go b/resource_test.go index a0a7cc80..685742e6 100644 --- a/resource_test.go +++ b/resource_test.go @@ -4,6 +4,8 @@ package dockertest_test import ( + "errors" + "io" "net/netip" "testing" @@ -128,3 +130,19 @@ func TestResourceGetHostPortIPv6(t *testing.T) { t.Errorf("GetHostPort() = %q, want %q", hostPort, "[::1]:54320") } } + +func TestLogsReturnsErrClientClosedWhenNoPool(t *testing.T) { + r := dockertest.NewResource(container.InspectResponse{ID: "test123"}) + _, _, err := r.Logs(t.Context()) + if !errors.Is(err, dockertest.ErrClientClosed) { + t.Errorf("Logs() error = %v, want ErrClientClosed", err) + } +} + +func TestFollowLogsReturnsErrClientClosedWhenNoPool(t *testing.T) { + r := dockertest.NewResource(container.InspectResponse{ID: "test123"}) + err := r.FollowLogs(t.Context(), io.Discard, io.Discard) + if !errors.Is(err, dockertest.ErrClientClosed) { + t.Errorf("FollowLogs() error = %v, want ErrClientClosed", err) + } +} diff --git a/run_test.go b/run_test.go index ee4e977f..ff2cd3d0 100644 --- a/run_test.go +++ b/run_test.go @@ -4,6 +4,8 @@ package dockertest_test import ( + "bytes" + "errors" "testing" "github.com/containerd/errdefs" @@ -531,6 +533,77 @@ func TestResourceCloseRefCounting(t *testing.T) { } } +func TestResourceLogsStdoutStderr(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + dockertest.ResetRegistry() + t.Cleanup(func() { dockertest.ResetRegistry() }) + + pool := dockertest.NewPoolT(t, "") + + resource := pool.RunT(t, "alpine", + dockertest.WithTag("latest"), + dockertest.WithCmd([]string{"sh", "-c", "echo stdout-line; echo stderr-line >&2"}), + dockertest.WithoutReuse(), + ) + + var stdout, stderr string + err := pool.Retry(t.Context(), 0, func() error { + var logErr error + stdout, stderr, logErr = resource.Logs(t.Context()) + if logErr != nil { + return logErr + } + if stdout == "" { + return errors.New("stdout not ready yet") + } + return nil + }) + if err != nil { + t.Fatalf("Logs() error = %v", err) + } + + if stdout != "stdout-line\n" { + t.Errorf("Logs() stdout = %q, want %q", stdout, "stdout-line\n") + } + if stderr != "stderr-line\n" { + t.Errorf("Logs() stderr = %q, want %q", stderr, "stderr-line\n") + } +} + +func TestResourceFollowLogs(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + dockertest.ResetRegistry() + t.Cleanup(func() { dockertest.ResetRegistry() }) + + pool := dockertest.NewPoolT(t, "") + + // Container prints output then exits — FollowLogs should return after container exit. + resource := pool.RunT(t, "alpine", + dockertest.WithTag("latest"), + dockertest.WithCmd([]string{"sh", "-c", "echo follow-stdout; echo follow-stderr >&2"}), + dockertest.WithoutReuse(), + ) + + var stdout, stderr bytes.Buffer + err := resource.FollowLogs(t.Context(), &stdout, &stderr) + if err != nil { + t.Fatalf("FollowLogs() error = %v", err) + } + + if stdout.String() != "follow-stdout\n" { + t.Errorf("FollowLogs stdout = %q, want %q", stdout.String(), "follow-stdout\n") + } + if stderr.String() != "follow-stderr\n" { + t.Errorf("FollowLogs stderr = %q, want %q", stderr.String(), "follow-stderr\n") + } +} + func TestRunWithMounts(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode")