Skip to content
Merged
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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 12 additions & 12 deletions build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}

}
Expand Down
46 changes: 35 additions & 11 deletions resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"bytes"
"context"
"fmt"
"io"
"net"

"github.com/containerd/errdefs"
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package dockertest_test

import (
"errors"
"io"
"net/netip"
"testing"

Expand Down Expand Up @@ -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)
}
}
73 changes: 73 additions & 0 deletions run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package dockertest_test

import (
"bytes"
"errors"
"testing"

"github.com/containerd/errdefs"
Expand Down Expand Up @@ -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")
Expand Down
Loading