Skip to content

Commit d328e09

Browse files
committed
Merge branch 'main' into v1
* main: fix: resource clean up for tests and examples (#2738)
2 parents e1705ce + b60497e commit d328e09

File tree

220 files changed

+3630
-4168
lines changed

Some content is hidden

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

220 files changed

+3630
-4168
lines changed

.gitignore

+4-1
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,7 @@ TEST-*.xml
1515
coverage.out
1616
tcvenv
1717

18-
**/go.work
18+
**/go.work
19+
20+
# VS Code settings
21+
.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(ctr StartedContainer, options ...TerminateOption) error {
53+
if isNil(ctr) {
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 := ctr.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

+8-17
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,7 @@ func TestBuildImageWithContexts(t *testing.T) {
288288
}
289289

290290
c, err := testcontainers.Run(ctx, req)
291-
292-
defer testcontainers.TerminateContainerOnEnd(t, ctx, c)
291+
testcontainers.CleanupContainer(t, c)
293292

294293
if testCase.ExpectedError != "" {
295294
require.EqualError(t, err, testCase.ExpectedError)
@@ -313,7 +312,7 @@ func TestGetLogsFromFailedContainer(t *testing.T) {
313312
// }
314313

315314
c, err := testcontainers.Run(ctx, req)
316-
testcontainers.TerminateContainerOnEnd(t, ctx, c)
315+
testcontainers.CleanupContainer(t, c)
317316
require.Error(t, err)
318317
require.Contains(t, err.Error(), "container exited with code 0")
319318

@@ -386,17 +385,13 @@ func TestImageSubstitutors(t *testing.T) {
386385
}
387386

388387
ctr, err := testcontainers.Run(ctx, req)
388+
testcontainers.CleanupContainer(t, ctr)
389389
if test.expectedError != nil {
390390
require.ErrorIs(t, err, test.expectedError)
391391
return
392392
}
393393

394-
if err != nil {
395-
t.Fatal(err)
396-
}
397-
defer func() {
398-
testcontainers.TerminateContainerOnEnd(t, ctx, ctr)
399-
}()
394+
require.NoError(t, err)
400395

401396
assert.Equal(t, test.expectedImage, ctr.Image)
402397
})
@@ -419,17 +414,13 @@ func TestShouldStartContainersInParallel(t *testing.T) {
419414
Started: true,
420415
}
421416
ctr, err := testcontainers.Run(ctx, req)
422-
if err != nil {
423-
t.Fatalf("could not start container: %v", err)
424-
}
417+
testcontainers.CleanupContainer(t, ctr)
418+
require.NoError(t, err)
419+
425420
// mappedPort {
426421
port, err := ctr.MappedPort(ctx, nginxDefaultPort)
427422
// }
428-
if err != nil {
429-
t.Fatalf("could not get mapped port: %v", err)
430-
}
431-
432-
testcontainers.TerminateContainerOnEnd(t, ctx, ctr)
423+
require.NoError(t, err)
433424

434425
t.Logf("Parallel container [iteration_%d] listening on %d\n", i, port.Int())
435426
})

docker.go

+26-4
Original file line numberDiff line numberDiff line change
@@ -624,9 +624,14 @@ func (c *DockerContainer) State(ctx context.Context) (*types.ContainerState, err
624624
// otherwise the engine default. A negative timeout value can be specified,
625625
// meaning no timeout, i.e. no forceful termination is performed.
626626
func (c *DockerContainer) Stop(ctx context.Context, timeout *time.Duration) error {
627+
// Note we can't check isRunning here because we allow external creation
628+
// without exposing the ability to fully initialize the container state.
629+
// See: https://github.com/testcontainers/testcontainers-go/issues/2667
630+
// TODO: Add a check for isRunning when the above issue is resolved.
631+
627632
err := c.stoppingHook(ctx)
628633
if err != nil {
629-
return err
634+
return fmt.Errorf("stopping hook: %w", err)
630635
}
631636

632637
var options container.StopOptions
@@ -643,21 +648,36 @@ func (c *DockerContainer) Stop(ctx context.Context, timeout *time.Duration) erro
643648
defer cli.Close()
644649

645650
if err := cli.ContainerStop(ctx, c.ID, options); err != nil {
646-
return err
651+
return fmt.Errorf("container stop: %w", err)
647652
}
648653

649654
c.isRunning = false
650655

651656
err = c.stoppedHook(ctx)
652657
if err != nil {
653-
return err
658+
return fmt.Errorf("stopped hook: %w", err)
654659
}
655660

656661
return nil
657662
}
658663

659-
// Terminate is used to kill the container. It is usually triggered by as defer function.
664+
// Terminate calls stops and then removes the container including its volumes.
665+
// If its image was built it and all child images are also removed unless
666+
// the [FromDockerfile.KeepImage] on the [ContainerRequest] was set to true.
667+
//
668+
// The following hooks are called in order:
669+
// - [ContainerLifecycleHooks.PreTerminates]
670+
// - [ContainerLifecycleHooks.PostTerminates]
660671
func (c *DockerContainer) Terminate(ctx context.Context) error {
672+
// ContainerRemove hardcodes stop timeout to 3 seconds which is too short
673+
// to ensure that child containers are stopped so we manually call stop.
674+
// TODO: make this configurable via a functional option.
675+
timeout := 10 * time.Second
676+
err := c.Stop(ctx, &timeout)
677+
if err != nil && !isCleanupSafe(err) {
678+
return fmt.Errorf("stop: %w", err)
679+
}
680+
661681
select {
662682
// close reaper if it was created
663683
case c.terminationSignal <- true:
@@ -670,6 +690,8 @@ func (c *DockerContainer) Terminate(ctx context.Context) error {
670690
}
671691
defer cli.Close()
672692

693+
// TODO: Handle errors from ContainerRemove more correctly, e.g. should we
694+
// run the terminated hook?
673695
errs := []error{
674696
c.terminatingHook(ctx),
675697
cli.ContainerRemove(ctx, c.GetContainerID(), container.RemoveOptions{

docker_auth_test.go

+6-9
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ func TestBuildContainerFromDockerfile(t *testing.T) {
2929
}
3030

3131
redisC, err := Run(ctx, req)
32+
CleanupContainer(t, redisC)
3233
require.NoError(t, err)
33-
TerminateContainerOnEnd(t, ctx, redisC)
3434
}
3535

3636
// removeImageFromLocalCache removes the image from the local cache
@@ -67,8 +67,7 @@ func TestBuildContainerFromDockerfileWithDockerAuthConfig(t *testing.T) {
6767
BuildArgs: map[string]*string{
6868
"REGISTRY_HOST": &registryHost,
6969
},
70-
Repo: "localhost",
71-
PrintBuildLog: true,
70+
Repo: "localhost",
7271
},
7372
AlwaysPullImage: true, // make sure the authentication takes place
7473
ExposedPorts: []string{"6379/tcp"},
@@ -77,8 +76,8 @@ func TestBuildContainerFromDockerfileWithDockerAuthConfig(t *testing.T) {
7776
}
7877

7978
redisC, err := Run(ctx, req)
79+
CleanupContainer(t, redisC)
8080
require.NoError(t, err)
81-
TerminateContainerOnEnd(t, ctx, redisC)
8281
}
8382

8483
func TestBuildContainerFromDockerfileShouldFailWithWrongDockerAuthConfig(t *testing.T) {
@@ -104,8 +103,8 @@ func TestBuildContainerFromDockerfileShouldFailWithWrongDockerAuthConfig(t *test
104103
}
105104

106105
redisC, err := Run(ctx, req)
106+
CleanupContainer(t, redisC)
107107
require.Error(t, err)
108-
TerminateContainerOnEnd(t, ctx, redisC)
109108
}
110109

111110
func TestCreateContainerFromPrivateRegistry(t *testing.T) {
@@ -124,8 +123,8 @@ func TestCreateContainerFromPrivateRegistry(t *testing.T) {
124123
}
125124

126125
redisContainer, err := Run(ctx, req)
126+
CleanupContainer(t, redisContainer)
127127
require.NoError(t, err)
128-
TerminateContainerOnEnd(t, ctx, redisContainer)
129128
}
130129

131130
func prepareLocalRegistryWithAuth(t *testing.T) string {
@@ -157,6 +156,7 @@ func prepareLocalRegistryWithAuth(t *testing.T) string {
157156
}
158157

159158
registryC, err := Run(ctx, req)
159+
CleanupContainer(t, registryC)
160160
require.NoError(t, err)
161161

162162
mappedPort, err := registryC.MappedPort(ctx, "5000/tcp")
@@ -169,9 +169,6 @@ func prepareLocalRegistryWithAuth(t *testing.T) string {
169169
t.Cleanup(func() {
170170
removeImageFromLocalCache(t, addr+"/redis:5.0-alpine")
171171
})
172-
t.Cleanup(func() {
173-
require.NoError(t, registryC.Terminate(context.Background()))
174-
})
175172

176173
_, cancel := context.WithCancel(context.Background())
177174
t.Cleanup(cancel)

0 commit comments

Comments
 (0)