Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support passing io.Reader for compose files when creating a compose instance #2509

Merged
merged 2 commits into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions docs/features/docker_compose.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading