Skip to content

Commit f766025

Browse files
committed
feat: support Ryuk for the compose module (testcontainers#2485)
* feat: add testcontainers labels to compose containers * feat: support reaper for compose * chore: increase ryuk reconnection timeout on CI * chore: cache containers on UP * chore: more tuning for compose * chore: more consistent assertion * chore: the compose stack asks for the reaper, but each container then connects to it * chore: use different error groups the first time wait is called, the context is cancelled * chore: the lookup method include cache checks * chore: update tests to make them deterministic * chore: rename local compose testss * chore: support returning the dynamic port in the helper function * chore: try with default reconnection timeout * feat: support removing networks from compose * chore: support naming test services with local and api It will allow the tests to be more deterministic, as there could be service containers started from the local test suite with the same name as in the API test suite. * Revert "chore: try with default reconnection timeout" This reverts commit 336760c. * fix: typo
1 parent cc8d930 commit f766025

16 files changed

+557
-143
lines changed

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

+2
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ jobs:
5050
continue-on-error: ${{ !inputs.fail-fast }}
5151
env:
5252
TESTCONTAINERS_RYUK_DISABLED: "${{ inputs.ryuk-disabled }}"
53+
RYUK_CONNECTION_TIMEOUT: "${{ inputs.project-directory == 'modules/compose' && '5m' || '60s' }}"
54+
RYUK_RECONNECTION_TIMEOUT: "${{ inputs.project-directory == 'modules/compose' && '30s' || '10s' }}"
5355
steps:
5456
- name: Setup rootless Docker
5557
if: ${{ inputs.rootless-docker }}

docker.go

+9
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ func (c *DockerContainer) SetProvider(provider *DockerProvider) {
9898
c.provider = provider
9999
}
100100

101+
// SetTerminationSignal sets the termination signal for the container
102+
func (c *DockerContainer) SetTerminationSignal(signal chan bool) {
103+
c.terminationSignal = signal
104+
}
105+
101106
func (c *DockerContainer) GetContainerID() string {
102107
return c.ID
103108
}
@@ -846,6 +851,10 @@ func (n *DockerNetwork) Remove(ctx context.Context) error {
846851
return n.provider.client.NetworkRemove(ctx, n.ID)
847852
}
848853

854+
func (n *DockerNetwork) SetTerminationSignal(signal chan bool) {
855+
n.terminationSignal = signal
856+
}
857+
849858
// DockerProvider implements the ContainerProvider interface
850859
type DockerProvider struct {
851860
*DockerProviderOptions

modules/compose/compose.go

+23
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package compose
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"path/filepath"
78
"runtime"
89
"strings"
@@ -121,6 +122,25 @@ func NewDockerComposeWith(opts ...ComposeStackOption) (*dockerCompose, error) {
121122
return nil, err
122123
}
123124

125+
reaperProvider, err := testcontainers.NewDockerProvider()
126+
if err != nil {
127+
return nil, fmt.Errorf("failed to create reaper provider for compose: %w", err)
128+
}
129+
130+
tcConfig := reaperProvider.Config()
131+
132+
var composeReaper *testcontainers.Reaper
133+
if !tcConfig.RyukDisabled {
134+
// NewReaper is deprecated: we need to find a way to create the reaper for compose
135+
// bypassing the deprecation.
136+
r, err := testcontainers.NewReaper(context.Background(), testcontainers.SessionID(), reaperProvider, "")
137+
if err != nil {
138+
return nil, fmt.Errorf("failed to create reaper for compose: %w", err)
139+
}
140+
141+
composeReaper = r
142+
}
143+
124144
composeAPI := &dockerCompose{
125145
name: composeOptions.Identifier,
126146
configs: composeOptions.Paths,
@@ -129,6 +149,9 @@ func NewDockerComposeWith(opts ...ComposeStackOption) (*dockerCompose, error) {
129149
dockerClient: dockerCli.Client(),
130150
waitStrategies: make(map[string]wait.Strategy),
131151
containers: make(map[string]*testcontainers.DockerContainer),
152+
networks: make(map[string]*testcontainers.DockerNetwork),
153+
sessionID: testcontainers.SessionID(),
154+
reaper: composeReaper,
132155
}
133156

134157
return composeAPI, nil

modules/compose/compose_api.go

+123-3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/compose-spec/compose-go/v2/types"
1212
"github.com/docker/cli/cli/command"
1313
"github.com/docker/compose/v2/pkg/api"
14+
dockertypes "github.com/docker/docker/api/types"
1415
"github.com/docker/docker/api/types/container"
1516
"github.com/docker/docker/api/types/filters"
1617
"github.com/docker/docker/client"
@@ -134,6 +135,9 @@ type dockerCompose struct {
134135
// used in ServiceContainer(...) function to avoid calls to the Docker API
135136
containers map[string]*testcontainers.DockerContainer
136137

138+
// cache for networks in the compose stack
139+
networks map[string]*testcontainers.DockerNetwork
140+
137141
// docker/compose API service instance used to control the compose stack
138142
composeService api.Service
139143

@@ -147,6 +151,12 @@ type dockerCompose struct {
147151
// compiled compose project
148152
// can be nil if the stack wasn't started yet
149153
project *types.Project
154+
155+
// sessionID is used to identify the reaper session
156+
sessionID string
157+
158+
// reaper is used to clean up containers after the stack is stopped
159+
reaper *testcontainers.Reaper
150160
}
151161

152162
func (d *dockerCompose) ServiceContainer(ctx context.Context, svcName string) (*testcontainers.DockerContainer, error) {
@@ -235,26 +245,89 @@ func (d *dockerCompose) Up(ctx context.Context, opts ...StackUpOption) error {
235245
return err
236246
}
237247

248+
err = d.lookupNetworks(ctx)
249+
if err != nil {
250+
return err
251+
}
252+
253+
if d.reaper != nil {
254+
for _, n := range d.networks {
255+
termSignal, err := d.reaper.Connect()
256+
if err != nil {
257+
return fmt.Errorf("failed to connect to reaper: %w", err)
258+
}
259+
n.SetTerminationSignal(termSignal)
260+
261+
// Cleanup on error, otherwise set termSignal to nil before successful return.
262+
defer func() {
263+
if termSignal != nil {
264+
termSignal <- true
265+
}
266+
}()
267+
}
268+
}
269+
270+
errGrpContainers, errGrpCtx := errgroup.WithContext(ctx)
271+
272+
for _, srv := range d.project.Services {
273+
// we are going to connect each container to the reaper
274+
srv := srv
275+
errGrpContainers.Go(func() error {
276+
dc, err := d.lookupContainer(errGrpCtx, srv.Name)
277+
if err != nil {
278+
return err
279+
}
280+
281+
if d.reaper != nil {
282+
termSignal, err := d.reaper.Connect()
283+
if err != nil {
284+
return fmt.Errorf("failed to connect to reaper: %w", err)
285+
}
286+
dc.SetTerminationSignal(termSignal)
287+
288+
// Cleanup on error, otherwise set termSignal to nil before successful return.
289+
defer func() {
290+
if termSignal != nil {
291+
termSignal <- true
292+
}
293+
}()
294+
}
295+
296+
d.containers[srv.Name] = dc
297+
298+
return nil
299+
})
300+
}
301+
302+
// wait here for the containers lookup to finish
303+
if err := errGrpContainers.Wait(); err != nil {
304+
return err
305+
}
306+
238307
if len(d.waitStrategies) == 0 {
239308
return nil
240309
}
241310

242-
errGrp, errGrpCtx := errgroup.WithContext(ctx)
311+
errGrpWait, errGrpCtx := errgroup.WithContext(ctx)
243312

244313
for svc, strategy := range d.waitStrategies { // pinning the variables
245314
svc := svc
246315
strategy := strategy
247316

248-
errGrp.Go(func() error {
317+
errGrpWait.Go(func() error {
249318
target, err := d.lookupContainer(errGrpCtx, svc)
250319
if err != nil {
251320
return err
252321
}
322+
323+
// cache all the containers on compose.up
324+
d.containers[svc] = target
325+
253326
return strategy.WaitUntilReady(errGrpCtx, target)
254327
})
255328
}
256329

257-
return errGrp.Wait()
330+
return errGrpWait.Wait()
258331
}
259332

260333
func (d *dockerCompose) WaitForService(s string, strategy wait.Strategy) ComposeStack {
@@ -327,6 +400,34 @@ func (d *dockerCompose) lookupContainer(ctx context.Context, svcName string) (*t
327400
return container, nil
328401
}
329402

403+
func (d *dockerCompose) lookupNetworks(ctx context.Context) error {
404+
d.containersLock.Lock()
405+
defer d.containersLock.Unlock()
406+
407+
listOptions := dockertypes.NetworkListOptions{
408+
Filters: filters.NewArgs(
409+
filters.Arg("label", fmt.Sprintf("%s=%s", api.ProjectLabel, d.name)),
410+
),
411+
}
412+
413+
networks, err := d.dockerClient.NetworkList(ctx, listOptions)
414+
if err != nil {
415+
return err
416+
}
417+
418+
for _, n := range networks {
419+
dn := &testcontainers.DockerNetwork{
420+
ID: n.ID,
421+
Name: n.Name,
422+
Driver: n.Driver,
423+
}
424+
425+
d.networks[n.ID] = dn
426+
}
427+
428+
return nil
429+
}
430+
330431
func (d *dockerCompose) compileProject(ctx context.Context) (*types.Project, error) {
331432
const nameAndDefaultConfigPath = 2
332433
projectOptions := make([]cli.ProjectOptionsFn, len(d.projectOptions), len(d.projectOptions)+nameAndDefaultConfigPath)
@@ -353,6 +454,11 @@ func (d *dockerCompose) compileProject(ctx context.Context) (*types.Project, err
353454
api.ConfigFilesLabel: strings.Join(proj.ComposeFiles, ","),
354455
api.OneoffLabel: "False", // default, will be overridden by `run` command
355456
}
457+
458+
for k, label := range testcontainers.GenericLabels() {
459+
s.CustomLabels[k] = label
460+
}
461+
356462
for i, envFile := range compiledOptions.EnvFiles {
357463
// add a label for each env file, indexed by its position
358464
s.CustomLabels[fmt.Sprintf("%s.%d", api.EnvironmentFileLabel, i)] = envFile
@@ -361,6 +467,20 @@ func (d *dockerCompose) compileProject(ctx context.Context) (*types.Project, err
361467
proj.Services[i] = s
362468
}
363469

470+
for key, n := range proj.Networks {
471+
n.Labels = map[string]string{
472+
api.ProjectLabel: proj.Name,
473+
api.NetworkLabel: n.Name,
474+
api.VersionLabel: api.ComposeVersion,
475+
}
476+
477+
for k, label := range testcontainers.GenericLabels() {
478+
n.Labels[k] = label
479+
}
480+
481+
proj.Networks[key] = n
482+
}
483+
364484
return proj, nil
365485
}
366486

0 commit comments

Comments
 (0)