Skip to content

Commit 5283d99

Browse files
committed
Merge branch 'main' into catinapoke/main
* main: fix: resource clean up for tests and examples (testcontainers#2738) ci: add generate for mocks (testcontainers#2774) fix: docker config error handling when config file does not exist (testcontainers#2772)
2 parents b51be12 + b60497e commit 5283d99

File tree

263 files changed

+4174
-4326
lines changed

Some content is hidden

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

263 files changed

+4174
-4326
lines changed

.github/workflows/ci-test-go.yml

+13-1
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,25 @@ jobs:
8585
# takes precedence over all other caching options.
8686
skip-cache: true
8787

88+
- name: generate
89+
if: ${{ inputs.platform == 'ubuntu-latest' }}
90+
working-directory: ./${{ inputs.project-directory }}
91+
shell: bash
92+
run: |
93+
make generate
94+
git --no-pager diff && [[ 0 -eq $(git status --porcelain | wc -l) ]]
95+
8896
- name: modVerify
8997
working-directory: ./${{ inputs.project-directory }}
9098
run: go mod verify
9199

92100
- name: modTidy
101+
if: ${{ inputs.platform == 'ubuntu-latest' }}
93102
working-directory: ./${{ inputs.project-directory }}
94-
run: make tidy
103+
shell: bash
104+
run: |
105+
make tidy
106+
git --no-pager diff && [[ 0 -eq $(git status --porcelain | wc -l) ]]
95107
96108
- name: ensure compilation
97109
working-directory: ./${{ inputs.project-directory }}

.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+
}

commons-test.mk

+13-2
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@ $(GOBIN)/golangci-lint:
1111
$(GOBIN)/gotestsum:
1212
$(call go_install,gotest.tools/gotestsum@latest)
1313

14+
$(GOBIN)/mockery:
15+
$(call go_install,github.com/vektra/mockery/v2@v2.45)
16+
1417
.PHONY: install
15-
install: $(GOBIN)/golangci-lint $(GOBIN)/gotestsum
18+
install: $(GOBIN)/golangci-lint $(GOBIN)/gotestsum $(GOBIN)/mockery
1619

1720
.PHONY: clean
1821
clean:
1922
rm $(GOBIN)/golangci-lint
2023
rm $(GOBIN)/gotestsum
24+
rm $(GOBIN)/mockery
2125

2226
.PHONY: dependencies-scan
2327
dependencies-scan:
@@ -26,7 +30,11 @@ dependencies-scan:
2630

2731
.PHONY: lint
2832
lint: $(GOBIN)/golangci-lint
29-
golangci-lint run --out-format=github-actions --path-prefix=. --verbose -c $(ROOT_DIR)/.golangci.yml --fix
33+
golangci-lint run --out-format=colored-line-number --path-prefix=. --verbose -c $(ROOT_DIR)/.golangci.yml --fix
34+
35+
.PHONY: generate
36+
generate: $(GOBIN)/mockery
37+
go generate ./...
3038

3139
.PHONY: test-%
3240
test-%: $(GOBIN)/gotestsum
@@ -51,3 +59,6 @@ test-tools: $(GOBIN)/gotestsum
5159
.PHONY: tidy
5260
tidy:
5361
go mod tidy
62+
63+
.PHONY: pre-commit
64+
pre-commit: generate tidy lint

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{

0 commit comments

Comments
 (0)