Skip to content

Commit b60497e

Browse files
authored
fix: resource clean up for tests and examples (#2738)
Ensure that all resources are cleaned up for tests and examples even if they fail. This leverages new helpers in testcontainers: * TerminateContainer for examples * CleanupContainer and CleanupNetwork for tests These are required ensuring that containers that are created but fail in later actions are returned alongside the error so that clean up can be performed. Consistently clean up created networks using a new context to ensure that the removal gets run even if original context has timed out or been cancelled. Use fmt.Print instead of log.Fatal to ensure that defers are run in all examples again ensuring that clean up is processed. Call Stop from Terminate to ensure that child containers are shutdown correctly on clean up as the hard coded timeout using by ContainerRemove is too short to allow this to happen correctly. Clean up of test logic replacing manual checks and asserts with require to make them more concise and hence easier to understand. Quiet test output by either capturing or disabling output so it's easier to identify issues when tests are run in non verbose mode. Clarify source of errors with wrapping and update tests to handle. Ensure that port forwarding container is shutdown if an error occurs during setup so it isn't orphaned. Shutdown the port forwarding container on both stop and terminate to prevent it being orphaned when the Stop is used. Add missing error checks to tests. Remove unused nolint directives and enable the nolintlint to catch any regressions. Don't use container as a variable as its overused.
1 parent b4f8294 commit b60497e

File tree

241 files changed

+3914
-4199
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

241 files changed

+3914
-4199
lines changed

.gitignore

+4-1
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,7 @@ TEST-*.xml
1414

1515
tcvenv
1616

17-
**/go.work
17+
**/go.work
18+
19+
# VS Code settings
20+
.vscode

.golangci.yml

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ linters:
88
- nonamedreturns
99
- testifylint
1010
- errcheck
11+
- nolintlint
1112

1213
linters-settings:
1314
errorlint:

cleanup.go

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package testcontainers
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"reflect"
8+
"time"
9+
)
10+
11+
// terminateOptions is a type that holds the options for terminating a container.
12+
type terminateOptions struct {
13+
ctx context.Context
14+
timeout *time.Duration
15+
volumes []string
16+
}
17+
18+
// TerminateOption is a type that represents an option for terminating a container.
19+
type TerminateOption func(*terminateOptions)
20+
21+
// StopContext returns a TerminateOption that sets the context.
22+
// Default: context.Background().
23+
func StopContext(ctx context.Context) TerminateOption {
24+
return func(c *terminateOptions) {
25+
c.ctx = ctx
26+
}
27+
}
28+
29+
// StopTimeout returns a TerminateOption that sets the timeout.
30+
// Default: See [Container.Stop].
31+
func StopTimeout(timeout time.Duration) TerminateOption {
32+
return func(c *terminateOptions) {
33+
c.timeout = &timeout
34+
}
35+
}
36+
37+
// RemoveVolumes returns a TerminateOption that sets additional volumes to remove.
38+
// This is useful when the container creates named volumes that should be removed
39+
// which are not removed by default.
40+
// Default: nil.
41+
func RemoveVolumes(volumes ...string) TerminateOption {
42+
return func(c *terminateOptions) {
43+
c.volumes = volumes
44+
}
45+
}
46+
47+
// TerminateContainer calls [Container.Terminate] on the container if it is not nil.
48+
//
49+
// This should be called as a defer directly after [GenericContainer](...)
50+
// or a modules Run(...) to ensure the container is terminated when the
51+
// function ends.
52+
func TerminateContainer(container Container, options ...TerminateOption) error {
53+
if isNil(container) {
54+
return nil
55+
}
56+
57+
c := &terminateOptions{
58+
ctx: context.Background(),
59+
}
60+
61+
for _, opt := range options {
62+
opt(c)
63+
}
64+
65+
// TODO: Add a timeout when terminate supports it.
66+
err := container.Terminate(c.ctx)
67+
if !isCleanupSafe(err) {
68+
return fmt.Errorf("terminate: %w", err)
69+
}
70+
71+
// Remove additional volumes if any.
72+
if len(c.volumes) == 0 {
73+
return nil
74+
}
75+
76+
client, err := NewDockerClientWithOpts(c.ctx)
77+
if err != nil {
78+
return fmt.Errorf("docker client: %w", err)
79+
}
80+
81+
defer client.Close()
82+
83+
// Best effort to remove all volumes.
84+
var errs []error
85+
for _, volume := range c.volumes {
86+
if errRemove := client.VolumeRemove(c.ctx, volume, true); errRemove != nil {
87+
errs = append(errs, fmt.Errorf("volume remove %q: %w", volume, errRemove))
88+
}
89+
}
90+
91+
return errors.Join(errs...)
92+
}
93+
94+
// isNil returns true if val is nil or an nil instance false otherwise.
95+
func isNil(val any) bool {
96+
if val == nil {
97+
return true
98+
}
99+
100+
valueOf := reflect.ValueOf(val)
101+
switch valueOf.Kind() {
102+
case reflect.Chan, reflect.Func, reflect.Map, reflect.Ptr, reflect.UnsafePointer, reflect.Interface, reflect.Slice:
103+
return valueOf.IsNil()
104+
default:
105+
return false
106+
}
107+
}

container_test.go

+22-31
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,7 @@ func Test_BuildImageWithContexts(t *testing.T) {
290290
ContainerRequest: req,
291291
Started: true,
292292
})
293-
294-
defer terminateContainerOnEnd(t, ctx, c)
293+
testcontainers.CleanupContainer(t, c)
295294

296295
if testCase.ExpectedError != "" {
297296
require.EqualError(t, err, testCase.ExpectedError)
@@ -317,7 +316,7 @@ func Test_GetLogsFromFailedContainer(t *testing.T) {
317316
ContainerRequest: req,
318317
Started: true,
319318
})
320-
terminateContainerOnEnd(t, ctx, c)
319+
testcontainers.CleanupContainer(t, c)
321320
require.Error(t, err)
322321
require.Contains(t, err.Error(), "container exited with code 0")
323322

@@ -417,25 +416,21 @@ func TestImageSubstitutors(t *testing.T) {
417416
ImageSubstitutors: test.substitutors,
418417
}
419418

420-
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
419+
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
421420
ContainerRequest: req,
422421
Started: true,
423422
})
423+
testcontainers.CleanupContainer(t, ctr)
424424
if test.expectedError != nil {
425425
require.ErrorIs(t, err, test.expectedError)
426426
return
427427
}
428428

429-
if err != nil {
430-
t.Fatal(err)
431-
}
432-
defer func() {
433-
terminateContainerOnEnd(t, ctx, container)
434-
}()
429+
require.NoError(t, err)
435430

436431
// enforce the concrete type, as GenericContainer returns an interface,
437432
// which will be changed in future implementations of the library
438-
dockerContainer := container.(*testcontainers.DockerContainer)
433+
dockerContainer := ctr.(*testcontainers.DockerContainer)
439434
assert.Equal(t, test.expectedImage, dockerContainer.Image)
440435
})
441436
}
@@ -455,21 +450,17 @@ func TestShouldStartContainersInParallel(t *testing.T) {
455450
ExposedPorts: []string{nginxDefaultPort},
456451
WaitingFor: wait.ForHTTP("/").WithStartupTimeout(10 * time.Second),
457452
}
458-
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
453+
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
459454
ContainerRequest: req,
460455
Started: true,
461456
})
462-
if err != nil {
463-
t.Fatalf("could not start container: %v", err)
464-
}
457+
testcontainers.CleanupContainer(t, ctr)
458+
require.NoError(t, err)
459+
465460
// mappedPort {
466-
port, err := container.MappedPort(ctx, nginxDefaultPort)
461+
port, err := ctr.MappedPort(ctx, nginxDefaultPort)
467462
// }
468-
if err != nil {
469-
t.Fatalf("could not get mapped port: %v", err)
470-
}
471-
472-
terminateContainerOnEnd(t, ctx, container)
463+
require.NoError(t, err)
473464

474465
t.Logf("Parallel container [iteration_%d] listening on %d\n", i, port.Int())
475466
})
@@ -480,28 +471,28 @@ func ExampleGenericContainer_withSubstitutors() {
480471
ctx := context.Background()
481472

482473
// applyImageSubstitutors {
483-
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
474+
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
484475
ContainerRequest: testcontainers.ContainerRequest{
485476
Image: "alpine:latest",
486477
ImageSubstitutors: []testcontainers.ImageSubstitutor{dockerImageSubstitutor{}},
487478
},
488479
Started: true,
489480
})
490-
// }
491-
if err != nil {
492-
log.Fatalf("could not start container: %v", err)
493-
}
494-
495481
defer func() {
496-
err := container.Terminate(ctx)
497-
if err != nil {
498-
log.Fatalf("could not terminate container: %v", err)
482+
if err := testcontainers.TerminateContainer(ctr); err != nil {
483+
log.Printf("failed to terminate container: %s", err)
499484
}
500485
}()
501486

487+
// }
488+
if err != nil {
489+
log.Printf("could not start container: %v", err)
490+
return
491+
}
492+
502493
// enforce the concrete type, as GenericContainer returns an interface,
503494
// which will be changed in future implementations of the library
504-
dockerContainer := container.(*testcontainers.DockerContainer)
495+
dockerContainer := ctr.(*testcontainers.DockerContainer)
505496

506497
fmt.Println(dockerContainer.Image)
507498

docker.go

+27-4
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,14 @@ func (c *DockerContainer) Start(ctx context.Context) error {
259259
//
260260
// If the container is already stopped, the method is a no-op.
261261
func (c *DockerContainer) Stop(ctx context.Context, timeout *time.Duration) error {
262+
// Note we can't check isRunning here because we allow external creation
263+
// without exposing the ability to fully initialize the container state.
264+
// See: https://github.com/testcontainers/testcontainers-go/issues/2667
265+
// TODO: Add a check for isRunning when the above issue is resolved.
266+
262267
err := c.stoppingHook(ctx)
263268
if err != nil {
264-
return err
269+
return fmt.Errorf("stopping hook: %w", err)
265270
}
266271

267272
var options container.StopOptions
@@ -272,22 +277,38 @@ func (c *DockerContainer) Stop(ctx context.Context, timeout *time.Duration) erro
272277
}
273278

274279
if err := c.provider.client.ContainerStop(ctx, c.ID, options); err != nil {
275-
return err
280+
return fmt.Errorf("container stop: %w", err)
276281
}
282+
277283
defer c.provider.Close()
278284

279285
c.isRunning = false
280286

281287
err = c.stoppedHook(ctx)
282288
if err != nil {
283-
return err
289+
return fmt.Errorf("stopped hook: %w", err)
284290
}
285291

286292
return nil
287293
}
288294

289-
// Terminate is used to kill the container. It is usually triggered by as defer function.
295+
// Terminate calls stops and then removes the container including its volumes.
296+
// If its image was built it and all child images are also removed unless
297+
// the [FromDockerfile.KeepImage] on the [ContainerRequest] was set to true.
298+
//
299+
// The following hooks are called in order:
300+
// - [ContainerLifecycleHooks.PreTerminates]
301+
// - [ContainerLifecycleHooks.PostTerminates]
290302
func (c *DockerContainer) Terminate(ctx context.Context) error {
303+
// ContainerRemove hardcodes stop timeout to 3 seconds which is too short
304+
// to ensure that child containers are stopped so we manually call stop.
305+
// TODO: make this configurable via a functional option.
306+
timeout := 10 * time.Second
307+
err := c.Stop(ctx, &timeout)
308+
if err != nil && !isCleanupSafe(err) {
309+
return fmt.Errorf("stop: %w", err)
310+
}
311+
291312
select {
292313
// close reaper if it was created
293314
case c.terminationSignal <- true:
@@ -296,6 +317,8 @@ func (c *DockerContainer) Terminate(ctx context.Context) error {
296317

297318
defer c.provider.client.Close()
298319

320+
// TODO: Handle errors from ContainerRemove more correctly, e.g. should we
321+
// run the terminated hook?
299322
errs := []error{
300323
c.terminatingHook(ctx),
301324
c.provider.client.ContainerRemove(ctx, c.GetContainerID(), container.RemoveOptions{

docker_auth_test.go

+6-9
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,8 @@ func TestBuildContainerFromDockerfile(t *testing.T) {
164164
}
165165

166166
redisC, err := prepareRedisImage(ctx, req)
167+
CleanupContainer(t, redisC)
167168
require.NoError(t, err)
168-
terminateContainerOnEnd(t, ctx, redisC)
169169
}
170170

171171
// removeImageFromLocalCache removes the image from the local cache
@@ -202,16 +202,15 @@ func TestBuildContainerFromDockerfileWithDockerAuthConfig(t *testing.T) {
202202
BuildArgs: map[string]*string{
203203
"REGISTRY_HOST": &registryHost,
204204
},
205-
Repo: "localhost",
206-
PrintBuildLog: true,
205+
Repo: "localhost",
207206
},
208207
AlwaysPullImage: true, // make sure the authentication takes place
209208
ExposedPorts: []string{"6379/tcp"},
210209
WaitingFor: wait.ForLog("Ready to accept connections"),
211210
}
212211

213212
redisC, err := prepareRedisImage(ctx, req)
214-
terminateContainerOnEnd(t, ctx, redisC)
213+
CleanupContainer(t, redisC)
215214
require.NoError(t, err)
216215
}
217216

@@ -237,7 +236,7 @@ func TestBuildContainerFromDockerfileShouldFailWithWrongDockerAuthConfig(t *test
237236
}
238237

239238
redisC, err := prepareRedisImage(ctx, req)
240-
terminateContainerOnEnd(t, ctx, redisC)
239+
CleanupContainer(t, redisC)
241240
require.Error(t, err)
242241
}
243242

@@ -259,7 +258,7 @@ func TestCreateContainerFromPrivateRegistry(t *testing.T) {
259258
ContainerRequest: req,
260259
Started: true,
261260
})
262-
terminateContainerOnEnd(t, ctx, redisContainer)
261+
CleanupContainer(t, redisContainer)
263262
require.NoError(t, err)
264263
}
265264

@@ -298,6 +297,7 @@ func prepareLocalRegistryWithAuth(t *testing.T) string {
298297
}
299298

300299
registryC, err := GenericContainer(ctx, genContainerReq)
300+
CleanupContainer(t, registryC)
301301
require.NoError(t, err)
302302

303303
mappedPort, err := registryC.MappedPort(ctx, "5000/tcp")
@@ -310,9 +310,6 @@ func prepareLocalRegistryWithAuth(t *testing.T) string {
310310
t.Cleanup(func() {
311311
removeImageFromLocalCache(t, addr+"/redis:5.0-alpine")
312312
})
313-
t.Cleanup(func() {
314-
require.NoError(t, registryC.Terminate(context.Background()))
315-
})
316313

317314
_, cancel := context.WithCancel(context.Background())
318315
t.Cleanup(cancel)

0 commit comments

Comments
 (0)