Skip to content

Commit d7f26ab

Browse files
committed
feat: support passing io.Reader for compose files when creating a compose instance (testcontainers#2509)
* feat: support passing io.Reader when creating a compose instance * docs: change title
1 parent 86771e8 commit d7f26ab

File tree

4 files changed

+135
-17
lines changed

4 files changed

+135
-17
lines changed

docs/features/docker_compose.md

+19-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Because `compose` v2 is implemented in Go it's possible for _Testcontainers for
2020
use [`github.com/docker/compose`](https://github.com/docker/compose) directly and skip any process execution/_docker-compose-in-a-container_ scenario.
2121
The `ComposeStack` API exposes this variant of using `docker compose` in an easy way.
2222

23-
### Basic examples
23+
### Usage
2424

2525
Use the convenience `NewDockerCompose(...)` constructor which creates a random identifier and takes a variable number
2626
of stack files:
@@ -53,7 +53,24 @@ func TestSomething(t *testing.T) {
5353
}
5454
```
5555

56-
Use the advanced `NewDockerComposeWith(...)` constructor allowing you to specify an identifier:
56+
Use the advanced `NewDockerComposeWith(...)` constructor allowing you to customise the compose execution with options:
57+
58+
- `StackIdentifier`: the identifier for the stack, which is used to name the network and containers. If not passed, a random identifier is generated.
59+
- `WithStackFiles`: specify the Docker Compose stack files to use, as a variadic argument of string paths where the stack files are located.
60+
- `WithStackReaders`: specify the Docker Compose stack files to use, as a variadic argument of `io.Reader` instances. It will create a temporary file in the temp dir of the given O.S., that will be removed after the `Down` method is called. You can use both `WithComposeStackFiles` and `WithComposeStackReaders` at the same time.
61+
62+
#### Compose Up options
63+
64+
- `RemoveOrphans`: remove orphaned containers after the stack is stopped.
65+
- `Wait`: will wait until the containers reached the running|healthy state.
66+
67+
#### Compose Down options
68+
69+
- `RemoveImages`: remove images after the stack is stopped. The `RemoveImagesAll` option will remove all images, while `RemoveImagesLocal` will remove only the images that don't have a tag.
70+
- `RemoveOrphans`: remove orphaned containers after the stack is stopped.
71+
- `RemoveVolumes`: remove volumes after the stack is stopped.
72+
73+
#### Example
5774

5875
```go
5976
package example_test

modules/compose/compose.go

+25-15
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io"
78
"path/filepath"
89
"runtime"
910
"strings"
@@ -27,9 +28,10 @@ const (
2728
var ErrNoStackConfigured = errors.New("no stack files configured")
2829

2930
type composeStackOptions struct {
30-
Identifier string
31-
Paths []string
32-
Logger testcontainers.Logging
31+
Identifier string
32+
Paths []string
33+
temporaryPaths map[string]bool
34+
Logger testcontainers.Logging
3335
}
3436

3537
type ComposeStackOption interface {
@@ -95,14 +97,21 @@ func WithStackFiles(filePaths ...string) ComposeStackOption {
9597
return ComposeStackFiles(filePaths)
9698
}
9799

100+
// WithStackReaders supports reading the compose file/s from a reader.
101+
// This function will panic if it's no possible to read the content from the reader.
102+
func WithStackReaders(readers ...io.Reader) ComposeStackOption {
103+
return ComposeStackReaders(readers)
104+
}
105+
98106
func NewDockerCompose(filePaths ...string) (*dockerCompose, error) {
99107
return NewDockerComposeWith(WithStackFiles(filePaths...))
100108
}
101109

102110
func NewDockerComposeWith(opts ...ComposeStackOption) (*dockerCompose, error) {
103111
composeOptions := composeStackOptions{
104-
Identifier: uuid.New().String(),
105-
Logger: testcontainers.Logger,
112+
Identifier: uuid.New().String(),
113+
temporaryPaths: make(map[string]bool),
114+
Logger: testcontainers.Logger,
106115
}
107116

108117
for i := range opts {
@@ -142,16 +151,17 @@ func NewDockerComposeWith(opts ...ComposeStackOption) (*dockerCompose, error) {
142151
}
143152

144153
composeAPI := &dockerCompose{
145-
name: composeOptions.Identifier,
146-
configs: composeOptions.Paths,
147-
logger: composeOptions.Logger,
148-
composeService: compose.NewComposeService(dockerCli),
149-
dockerClient: dockerCli.Client(),
150-
waitStrategies: make(map[string]wait.Strategy),
151-
containers: make(map[string]*testcontainers.DockerContainer),
152-
networks: make(map[string]*testcontainers.DockerNetwork),
153-
sessionID: testcontainers.SessionID(),
154-
reaper: composeReaper,
154+
name: composeOptions.Identifier,
155+
configs: composeOptions.Paths,
156+
temporaryConfigs: composeOptions.temporaryPaths,
157+
logger: composeOptions.Logger,
158+
composeService: compose.NewComposeService(dockerCli),
159+
dockerClient: dockerCli.Client(),
160+
waitStrategies: make(map[string]wait.Strategy),
161+
containers: make(map[string]*testcontainers.DockerContainer),
162+
networks: make(map[string]*testcontainers.DockerNetwork),
163+
sessionID: testcontainers.SessionID(),
164+
reaper: composeReaper,
155165
}
156166

157167
return composeAPI, nil

modules/compose/compose_api.go

+50
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ package compose
33
import (
44
"context"
55
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
69
"sort"
10+
"strconv"
711
"strings"
812
"sync"
13+
"time"
914

1015
"github.com/compose-spec/compose-go/v2/cli"
1116
"github.com/compose-spec/compose-go/v2/types"
@@ -43,9 +48,12 @@ func RunServices(serviceNames ...string) StackUpOption {
4348
})
4449
}
4550

51+
// Deprecated: will be removed in the next major release
4652
// IgnoreOrphans - Ignore legacy containers for services that are not defined in the project
4753
type IgnoreOrphans bool
4854

55+
// Deprecated: will be removed in the next major release
56+
//
4957
//nolint:unused
5058
func (io IgnoreOrphans) applyToStackUp(co *api.CreateOptions, _ *api.StartOptions) {
5159
co.IgnoreOrphans = bool(io)
@@ -87,6 +95,40 @@ func (ri RemoveImages) applyToStackDown(o *stackDownOptions) {
8795
}
8896
}
8997

98+
type ComposeStackReaders []io.Reader
99+
100+
func (r ComposeStackReaders) applyToComposeStack(o *composeStackOptions) {
101+
f := make([]string, len(r))
102+
baseName := "docker-compose-%d.yml"
103+
for i, reader := range r {
104+
tmp := os.TempDir()
105+
tmp = filepath.Join(tmp, strconv.FormatInt(time.Now().UnixNano(), 10))
106+
err := os.MkdirAll(tmp, 0755)
107+
if err != nil {
108+
panic(err)
109+
}
110+
111+
name := fmt.Sprintf(baseName, i)
112+
113+
bs, err := io.ReadAll(reader)
114+
if err != nil {
115+
panic(err)
116+
}
117+
118+
err = os.WriteFile(filepath.Join(tmp, name), bs, 0644)
119+
if err != nil {
120+
panic(err)
121+
}
122+
123+
f[i] = filepath.Join(tmp, name)
124+
125+
// mark the file for removal as it was generated on the fly
126+
o.temporaryPaths[f[i]] = true
127+
}
128+
129+
o.Paths = f
130+
}
131+
90132
type ComposeStackFiles []string
91133

92134
func (f ComposeStackFiles) applyToComposeStack(o *composeStackOptions) {
@@ -121,6 +163,9 @@ type dockerCompose struct {
121163
// paths to stack files that will be considered when compiling the final compose project
122164
configs []string
123165

166+
// used to remove temporary files that were generated on the fly
167+
temporaryConfigs map[string]bool
168+
124169
// used to set logger in DockerContainer
125170
logger testcontainers.Logging
126171

@@ -186,6 +231,11 @@ func (d *dockerCompose) Down(ctx context.Context, opts ...StackDownOption) error
186231
for i := range opts {
187232
opts[i].applyToStackDown(&options)
188233
}
234+
defer func() {
235+
for cfg := range d.temporaryConfigs {
236+
_ = os.Remove(cfg)
237+
}
238+
}()
189239

190240
return d.composeService.Down(ctx, d.name, options.DownOptions)
191241
}

modules/compose/compose_api_test.go

+41
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"context"
55
"fmt"
66
"hash/fnv"
7+
"os"
78
"path/filepath"
9+
"strings"
810
"testing"
911
"time"
1012

@@ -429,6 +431,45 @@ func TestDockerComposeAPIComplex(t *testing.T) {
429431
assert.Contains(t, serviceNames, "api-mysql")
430432
}
431433

434+
func TestDockerComposeAPIWithStackReader(t *testing.T) {
435+
identifier := testNameHash(t.Name())
436+
437+
composeContent := `version: '3.7'
438+
services:
439+
api-nginx:
440+
image: docker.io/nginx:stable-alpine
441+
environment:
442+
bar: ${bar}
443+
foo: ${foo}
444+
`
445+
446+
compose, err := NewDockerComposeWith(WithStackReaders(strings.NewReader(composeContent)), identifier)
447+
require.NoError(t, err, "NewDockerCompose()")
448+
449+
ctx, cancel := context.WithCancel(context.Background())
450+
t.Cleanup(cancel)
451+
452+
err = compose.
453+
WithEnv(map[string]string{
454+
"foo": "FOO",
455+
"bar": "BAR",
456+
}).
457+
Up(ctx, Wait(true))
458+
require.NoError(t, err, "compose.Up()")
459+
460+
serviceNames := compose.Services()
461+
462+
assert.Len(t, serviceNames, 1)
463+
assert.Contains(t, serviceNames, "api-nginx")
464+
465+
require.NoError(t, compose.Down(context.Background(), RemoveOrphans(true), RemoveImagesLocal), "compose.Down()")
466+
467+
// check files where removed
468+
f, err := os.Stat(compose.configs[0])
469+
require.Error(t, err, "File should be removed")
470+
require.True(t, os.IsNotExist(err), "File should be removed")
471+
require.Nil(t, f, "File should be removed")
472+
}
432473
func TestDockerComposeAPIWithEnvironment(t *testing.T) {
433474
identifier := testNameHash(t.Name())
434475

0 commit comments

Comments
 (0)