Skip to content

Commit 3fa1d89

Browse files
authored
feat: add log helpers (#656)
1 parent fa8f486 commit 3fa1d89

6 files changed

Lines changed: 151 additions & 26 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,11 +334,17 @@ t.Log(result.StdOut)
334334
### Container logs
335335

336336
```go
337-
logs, err := resource.Logs(ctx)
337+
// Get all logs with stdout and stderr separated
338+
stdout, stderr, err := resource.Logs(ctx)
338339
if err != nil {
339340
t.Fatal(err)
340341
}
341-
t.Log(logs)
342+
t.Log(stdout)
343+
t.Log(stderr)
344+
345+
// Stream logs until container exits or ctx is cancelled
346+
var buf bytes.Buffer
347+
err = resource.FollowLogs(ctx, &buf, io.Discard)
342348
```
343349

344350
### Building from Dockerfile

UPGRADE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,11 @@ Available `NetworkCreateOptions` fields: `Driver` (e.g., `"bridge"`,
420420
result, err := resource.Exec(ctx, []string{"pg_isready"})
421421
// result.StdOut, result.StdErr, result.ExitCode
422422

423-
logs, err := resource.Logs(ctx)
423+
stdout, stderr, err := resource.Logs(ctx)
424+
// stdout and stderr are separated strings
425+
426+
// Stream logs until container exits or ctx is cancelled:
427+
err = resource.FollowLogs(ctx, os.Stdout, os.Stderr)
424428
```
425429

426430
### Advanced: Container Registry

build_test.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -93,20 +93,20 @@ CMD ["sh", "-c", "echo TEST_ENV=$TEST_ENV && sleep 300"]
9393
r := pool.BuildAndRunT(t, "test-build-args", buildOpts)
9494

9595
// Verify build arg was applied by checking container env via logs
96-
var logs string
96+
var stdout string
9797
err := pool.Retry(t.Context(), 10*time.Second, func() error {
9898
var logErr error
99-
logs, logErr = r.Logs(t.Context())
99+
stdout, _, logErr = r.Logs(t.Context())
100100
if logErr != nil {
101101
return logErr
102102
}
103-
if !strings.Contains(logs, "TEST_ENV=test-value") {
103+
if !strings.Contains(stdout, "TEST_ENV=test-value") {
104104
return fmt.Errorf("logs do not yet contain expected env")
105105
}
106106
return nil
107107
})
108108
if err != nil {
109-
t.Fatalf("Expected logs to contain 'TEST_ENV=test-value', got: %s", logs)
109+
t.Fatalf("Expected logs to contain 'TEST_ENV=test-value', got: %s", stdout)
110110
}
111111
}
112112

@@ -145,20 +145,20 @@ CMD ["sh", "-c", "echo $TEST_VAR && sleep 300"]
145145
)
146146

147147
// Verify env var took effect via logs
148-
var logs string
148+
var stdout string
149149
err := pool.Retry(t.Context(), 10*time.Second, func() error {
150150
var logErr error
151-
logs, logErr = r.Logs(t.Context())
151+
stdout, _, logErr = r.Logs(t.Context())
152152
if logErr != nil {
153153
return logErr
154154
}
155-
if !strings.Contains(logs, "hello") {
155+
if !strings.Contains(stdout, "hello") {
156156
return fmt.Errorf("logs do not yet contain expected env value")
157157
}
158158
return nil
159159
})
160160
if err != nil {
161-
t.Fatalf("Expected logs to contain 'hello', got: %s", logs)
161+
t.Fatalf("Expected logs to contain 'hello', got: %s", stdout)
162162
}
163163
}
164164

@@ -193,20 +193,20 @@ func TestBuildAndRunWithBuildContext(t *testing.T) {
193193
}
194194

195195
// Poll for expected log output
196-
var logs string
196+
var stdout string
197197
err := pool.Retry(t.Context(), 10*time.Second, func() error {
198198
var logErr error
199-
logs, logErr = r.Logs(t.Context())
199+
stdout, _, logErr = r.Logs(t.Context())
200200
if logErr != nil {
201201
return logErr
202202
}
203-
if !strings.Contains(logs, "Hello, World!") {
203+
if !strings.Contains(stdout, "Hello, World!") {
204204
return fmt.Errorf("logs do not yet contain 'Hello, World!'")
205205
}
206206
return nil
207207
})
208208
if err != nil {
209-
t.Fatalf("Expected logs to contain 'Hello, World!', got: %s (error: %v)", logs, err)
209+
t.Fatalf("Expected logs to contain 'Hello, World!', got: %s (error: %v)", stdout, err)
210210
}
211211

212212
}

resource.go

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"bytes"
88
"context"
99
"fmt"
10+
"io"
1011
"net"
1112

1213
"github.com/containerd/errdefs"
@@ -24,7 +25,8 @@ type Resource interface {
2425
GetPort(portID string) string
2526
GetBoundIP(portID string) string
2627
GetHostPort(portID string) string
27-
Logs(ctx context.Context) (string, error)
28+
Logs(ctx context.Context) (stdout, stderr string, err error)
29+
FollowLogs(ctx context.Context, stdout, stderr io.Writer) error
2830
Exec(ctx context.Context, cmd []string) (ExecResult, error)
2931
ConnectToNetwork(ctx context.Context, net Network) error
3032
DisconnectFromNetwork(ctx context.Context, net Network) error
@@ -153,28 +155,50 @@ func (r *resource) Cleanup(t TestingTB) {
153155
})
154156
}
155157

156-
// Logs returns the container logs, demultiplexing stdout and stderr streams.
157-
// Both stdout and stderr are combined in the returned string.
158-
func (r *resource) Logs(ctx context.Context) (string, error) {
158+
func (r *resource) containerLogReader(ctx context.Context, follow bool) (io.ReadCloser, error) {
159159
if r.pool == nil || r.pool.client == nil {
160-
return "", ErrClientClosed
160+
return nil, ErrClientClosed
161161
}
162-
163162
reader, err := r.pool.client.ContainerLogs(ctx, r.container.ID, mobyclient.ContainerLogsOptions{
164163
ShowStdout: true,
165164
ShowStderr: true,
165+
Follow: follow,
166166
})
167167
if err != nil {
168-
return "", fmt.Errorf("failed to get container logs: %w", err)
168+
return nil, fmt.Errorf("failed to get container logs: %w", err)
169+
}
170+
return reader, nil
171+
}
172+
173+
// Logs returns the container logs, demultiplexing stdout and stderr.
174+
func (r *resource) Logs(ctx context.Context) (stdout, stderr string, err error) {
175+
reader, err := r.containerLogReader(ctx, false)
176+
if err != nil {
177+
return "", "", err
169178
}
170179
defer reader.Close()
171180

172-
var buf bytes.Buffer
173-
if _, err := stdcopy.StdCopy(&buf, &buf, reader); err != nil {
174-
return "", fmt.Errorf("failed to read container logs: %w", err)
181+
var outBuf, errBuf bytes.Buffer
182+
if _, err := stdcopy.StdCopy(&outBuf, &errBuf, reader); err != nil {
183+
return "", "", fmt.Errorf("failed to read container logs: %w", err)
184+
}
185+
186+
return outBuf.String(), errBuf.String(), nil
187+
}
188+
189+
// FollowLogs streams container logs to stdout and stderr until ctx is cancelled
190+
// or the container exits. Pass io.Discard for writers you don't need.
191+
func (r *resource) FollowLogs(ctx context.Context, stdout, stderr io.Writer) error {
192+
reader, err := r.containerLogReader(ctx, true)
193+
if err != nil {
194+
return err
175195
}
196+
defer reader.Close()
176197

177-
return buf.String(), nil
198+
if _, err := stdcopy.StdCopy(stdout, stderr, reader); err != nil {
199+
return fmt.Errorf("failed to follow container logs: %w", err)
200+
}
201+
return nil
178202
}
179203

180204
// ExecResult holds the output of a command executed inside a container.

resource_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package dockertest_test
55

66
import (
7+
"errors"
8+
"io"
79
"net/netip"
810
"testing"
911

@@ -128,3 +130,19 @@ func TestResourceGetHostPortIPv6(t *testing.T) {
128130
t.Errorf("GetHostPort() = %q, want %q", hostPort, "[::1]:54320")
129131
}
130132
}
133+
134+
func TestLogsReturnsErrClientClosedWhenNoPool(t *testing.T) {
135+
r := dockertest.NewResource(container.InspectResponse{ID: "test123"})
136+
_, _, err := r.Logs(t.Context())
137+
if !errors.Is(err, dockertest.ErrClientClosed) {
138+
t.Errorf("Logs() error = %v, want ErrClientClosed", err)
139+
}
140+
}
141+
142+
func TestFollowLogsReturnsErrClientClosedWhenNoPool(t *testing.T) {
143+
r := dockertest.NewResource(container.InspectResponse{ID: "test123"})
144+
err := r.FollowLogs(t.Context(), io.Discard, io.Discard)
145+
if !errors.Is(err, dockertest.ErrClientClosed) {
146+
t.Errorf("FollowLogs() error = %v, want ErrClientClosed", err)
147+
}
148+
}

run_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package dockertest_test
55

66
import (
7+
"bytes"
8+
"errors"
79
"testing"
810

911
"github.com/containerd/errdefs"
@@ -531,6 +533,77 @@ func TestResourceCloseRefCounting(t *testing.T) {
531533
}
532534
}
533535

536+
func TestResourceLogsStdoutStderr(t *testing.T) {
537+
if testing.Short() {
538+
t.Skip("Skipping integration test in short mode")
539+
}
540+
541+
dockertest.ResetRegistry()
542+
t.Cleanup(func() { dockertest.ResetRegistry() })
543+
544+
pool := dockertest.NewPoolT(t, "")
545+
546+
resource := pool.RunT(t, "alpine",
547+
dockertest.WithTag("latest"),
548+
dockertest.WithCmd([]string{"sh", "-c", "echo stdout-line; echo stderr-line >&2"}),
549+
dockertest.WithoutReuse(),
550+
)
551+
552+
var stdout, stderr string
553+
err := pool.Retry(t.Context(), 0, func() error {
554+
var logErr error
555+
stdout, stderr, logErr = resource.Logs(t.Context())
556+
if logErr != nil {
557+
return logErr
558+
}
559+
if stdout == "" {
560+
return errors.New("stdout not ready yet")
561+
}
562+
return nil
563+
})
564+
if err != nil {
565+
t.Fatalf("Logs() error = %v", err)
566+
}
567+
568+
if stdout != "stdout-line\n" {
569+
t.Errorf("Logs() stdout = %q, want %q", stdout, "stdout-line\n")
570+
}
571+
if stderr != "stderr-line\n" {
572+
t.Errorf("Logs() stderr = %q, want %q", stderr, "stderr-line\n")
573+
}
574+
}
575+
576+
func TestResourceFollowLogs(t *testing.T) {
577+
if testing.Short() {
578+
t.Skip("Skipping integration test in short mode")
579+
}
580+
581+
dockertest.ResetRegistry()
582+
t.Cleanup(func() { dockertest.ResetRegistry() })
583+
584+
pool := dockertest.NewPoolT(t, "")
585+
586+
// Container prints output then exits — FollowLogs should return after container exit.
587+
resource := pool.RunT(t, "alpine",
588+
dockertest.WithTag("latest"),
589+
dockertest.WithCmd([]string{"sh", "-c", "echo follow-stdout; echo follow-stderr >&2"}),
590+
dockertest.WithoutReuse(),
591+
)
592+
593+
var stdout, stderr bytes.Buffer
594+
err := resource.FollowLogs(t.Context(), &stdout, &stderr)
595+
if err != nil {
596+
t.Fatalf("FollowLogs() error = %v", err)
597+
}
598+
599+
if stdout.String() != "follow-stdout\n" {
600+
t.Errorf("FollowLogs stdout = %q, want %q", stdout.String(), "follow-stdout\n")
601+
}
602+
if stderr.String() != "follow-stderr\n" {
603+
t.Errorf("FollowLogs stderr = %q, want %q", stderr.String(), "follow-stderr\n")
604+
}
605+
}
606+
534607
func TestRunWithMounts(t *testing.T) {
535608
if testing.Short() {
536609
t.Skip("Skipping integration test in short mode")

0 commit comments

Comments
 (0)