Skip to content

Commit

Permalink
feat: support passing io.Reader for compose files when creating a com…
Browse files Browse the repository at this point in the history
…pose instance (#2509)

* feat: support passing io.Reader when creating a compose instance

* docs: change title
  • Loading branch information
mdelapenya committed Apr 22, 2024
1 parent c94d40d commit 201b3de
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 17 deletions.
21 changes: 19 additions & 2 deletions docs/features/docker_compose.md
Expand Up @@ -20,7 +20,7 @@ Because `compose` v2 is implemented in Go it's possible for _Testcontainers for
use [`github.com/docker/compose`](https://github.com/docker/compose) directly and skip any process execution/_docker-compose-in-a-container_ scenario.
The `ComposeStack` API exposes this variant of using `docker compose` in an easy way.

### Basic examples
### Usage

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

Use the advanced `NewDockerComposeWith(...)` constructor allowing you to specify an identifier:
Use the advanced `NewDockerComposeWith(...)` constructor allowing you to customise the compose execution with options:

- `StackIdentifier`: the identifier for the stack, which is used to name the network and containers. If not passed, a random identifier is generated.
- `WithStackFiles`: specify the Docker Compose stack files to use, as a variadic argument of string paths where the stack files are located.
- `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.

#### Compose Up options

- `RemoveOrphans`: remove orphaned containers after the stack is stopped.
- `Wait`: will wait until the containers reached the running|healthy state.

#### Compose Down options

- `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.
- `RemoveOrphans`: remove orphaned containers after the stack is stopped.
- `RemoveVolumes`: remove volumes after the stack is stopped.

#### Example

```go
package example_test
Expand Down
40 changes: 25 additions & 15 deletions modules/compose/compose.go
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"path/filepath"
"runtime"
"strings"
Expand All @@ -27,9 +28,10 @@ const (
var ErrNoStackConfigured = errors.New("no stack files configured")

type composeStackOptions struct {
Identifier string
Paths []string
Logger testcontainers.Logging
Identifier string
Paths []string
temporaryPaths map[string]bool
Logger testcontainers.Logging
}

type ComposeStackOption interface {
Expand Down Expand Up @@ -95,14 +97,21 @@ func WithStackFiles(filePaths ...string) ComposeStackOption {
return ComposeStackFiles(filePaths)
}

// WithStackReaders supports reading the compose file/s from a reader.
// This function will panic if it's no possible to read the content from the reader.
func WithStackReaders(readers ...io.Reader) ComposeStackOption {
return ComposeStackReaders(readers)
}

func NewDockerCompose(filePaths ...string) (*dockerCompose, error) {
return NewDockerComposeWith(WithStackFiles(filePaths...))
}

func NewDockerComposeWith(opts ...ComposeStackOption) (*dockerCompose, error) {
composeOptions := composeStackOptions{
Identifier: uuid.New().String(),
Logger: testcontainers.Logger,
Identifier: uuid.New().String(),
temporaryPaths: make(map[string]bool),
Logger: testcontainers.Logger,
}

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

composeAPI := &dockerCompose{
name: composeOptions.Identifier,
configs: composeOptions.Paths,
logger: composeOptions.Logger,
composeService: compose.NewComposeService(dockerCli),
dockerClient: dockerCli.Client(),
waitStrategies: make(map[string]wait.Strategy),
containers: make(map[string]*testcontainers.DockerContainer),
networks: make(map[string]*testcontainers.DockerNetwork),
sessionID: testcontainers.SessionID(),
reaper: composeReaper,
name: composeOptions.Identifier,
configs: composeOptions.Paths,
temporaryConfigs: composeOptions.temporaryPaths,
logger: composeOptions.Logger,
composeService: compose.NewComposeService(dockerCli),
dockerClient: dockerCli.Client(),
waitStrategies: make(map[string]wait.Strategy),
containers: make(map[string]*testcontainers.DockerContainer),
networks: make(map[string]*testcontainers.DockerNetwork),
sessionID: testcontainers.SessionID(),
reaper: composeReaper,
}

return composeAPI, nil
Expand Down
50 changes: 50 additions & 0 deletions modules/compose/compose_api.go
Expand Up @@ -3,9 +3,14 @@ package compose
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"

"github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/types"
Expand Down Expand Up @@ -43,9 +48,12 @@ func RunServices(serviceNames ...string) StackUpOption {
})
}

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

// Deprecated: will be removed in the next major release
//
//nolint:unused
func (io IgnoreOrphans) applyToStackUp(co *api.CreateOptions, _ *api.StartOptions) {
co.IgnoreOrphans = bool(io)
Expand Down Expand Up @@ -87,6 +95,40 @@ func (ri RemoveImages) applyToStackDown(o *stackDownOptions) {
}
}

type ComposeStackReaders []io.Reader

func (r ComposeStackReaders) applyToComposeStack(o *composeStackOptions) {
f := make([]string, len(r))
baseName := "docker-compose-%d.yml"
for i, reader := range r {
tmp := os.TempDir()
tmp = filepath.Join(tmp, strconv.FormatInt(time.Now().UnixNano(), 10))
err := os.MkdirAll(tmp, 0755)
if err != nil {
panic(err)
}

name := fmt.Sprintf(baseName, i)

bs, err := io.ReadAll(reader)
if err != nil {
panic(err)
}

err = os.WriteFile(filepath.Join(tmp, name), bs, 0644)
if err != nil {
panic(err)
}

f[i] = filepath.Join(tmp, name)

// mark the file for removal as it was generated on the fly
o.temporaryPaths[f[i]] = true
}

o.Paths = f
}

type ComposeStackFiles []string

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

// used to remove temporary files that were generated on the fly
temporaryConfigs map[string]bool

// used to set logger in DockerContainer
logger testcontainers.Logging

Expand Down Expand Up @@ -186,6 +231,11 @@ func (d *dockerCompose) Down(ctx context.Context, opts ...StackDownOption) error
for i := range opts {
opts[i].applyToStackDown(&options)
}
defer func() {
for cfg := range d.temporaryConfigs {
_ = os.Remove(cfg)
}
}()

return d.composeService.Down(ctx, d.name, options.DownOptions)
}
Expand Down
41 changes: 41 additions & 0 deletions modules/compose/compose_api_test.go
Expand Up @@ -4,7 +4,9 @@ import (
"context"
"fmt"
"hash/fnv"
"os"
"path/filepath"
"strings"
"testing"
"time"

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

func TestDockerComposeAPIWithStackReader(t *testing.T) {
identifier := testNameHash(t.Name())

composeContent := `version: '3.7'
services:
api-nginx:
image: docker.io/nginx:stable-alpine
environment:
bar: ${bar}
foo: ${foo}
`

compose, err := NewDockerComposeWith(WithStackReaders(strings.NewReader(composeContent)), identifier)
require.NoError(t, err, "NewDockerCompose()")

ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

err = compose.
WithEnv(map[string]string{
"foo": "FOO",
"bar": "BAR",
}).
Up(ctx, Wait(true))
require.NoError(t, err, "compose.Up()")

serviceNames := compose.Services()

assert.Len(t, serviceNames, 1)
assert.Contains(t, serviceNames, "api-nginx")

require.NoError(t, compose.Down(context.Background(), RemoveOrphans(true), RemoveImagesLocal), "compose.Down()")

// check files where removed
f, err := os.Stat(compose.configs[0])
require.Error(t, err, "File should be removed")
require.True(t, os.IsNotExist(err), "File should be removed")
require.Nil(t, f, "File should be removed")
}
func TestDockerComposeAPIWithEnvironment(t *testing.T) {
identifier := testNameHash(t.Name())

Expand Down

0 comments on commit 201b3de

Please sign in to comment.