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

Support deploying with docker compose without local docker-compose #462

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
90 changes: 53 additions & 37 deletions compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,15 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"regexp"
"strings"
"sync"

"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"gopkg.in/yaml.v3"

"github.com/testcontainers/testcontainers-go/wait"
"gopkg.in/yaml.v3"
)

const (
Expand All @@ -45,7 +44,6 @@ type waitService struct {
// docker-compose or docker-compose.exe, depending on the underlying platform
type LocalDockerCompose struct {
*LocalDockerComposeOptions
Executable string
ComposeFilePaths []string
absComposeFilePaths []string
Identifier string
Expand All @@ -54,6 +52,8 @@ type LocalDockerCompose struct {
Services map[string]interface{}
waitStrategySupplied bool
WaitStrategyMap map[waitService]wait.Strategy
Executor ComposeExecutor
Context context.Context
}

type (
Expand All @@ -76,29 +76,25 @@ func (f LocalDockerComposeOptionsFunc) ApplyToLocalCompose(opts *LocalDockerComp
f(opts)
}

// NewLocalDockerCompose returns an instance of the local Docker Compose, using an
// array of Docker Compose file paths and an identifier for the Compose execution.
//
// It will iterate through the array adding '-f compose-file-path' flags to the local
// Docker Compose execution. The identifier represents the name of the execution,
// which will define the name of the underlying Docker network and the name of the
// running Compose services.
func NewLocalDockerCompose(filePaths []string, identifier string, opts ...LocalDockerComposeOption) *LocalDockerCompose {
func NewDockerCompose(
mdelapenya marked this conversation as resolved.
Show resolved Hide resolved
filePaths []string,
identifier string,
executor ComposeExecutor,
ctx context.Context,
opts ...LocalDockerComposeOption) *LocalDockerCompose {

dc := &LocalDockerCompose{
LocalDockerComposeOptions: &LocalDockerComposeOptions{
Logger: Logger,
},
Executor: executor,
Context: ctx,
}

for idx := range opts {
opts[idx].ApplyToLocalCompose(dc.LocalDockerComposeOptions)
}

dc.Executable = "docker-compose"
if runtime.GOOS == "windows" {
dc.Executable = "docker-compose.exe"
}

dc.ComposeFilePaths = filePaths

dc.absComposeFilePaths = make([]string, len(filePaths))
Expand All @@ -116,6 +112,25 @@ func NewLocalDockerCompose(filePaths []string, identifier string, opts ...LocalD
return dc
}

// NewLocalDockerCompose returns an instance of the local Docker Compose, using an
// array of Docker Compose file paths and an identifier for the Compose execution.
//
// It will iterate through the array adding '-f compose-file-path' flags to the local
// Docker Compose execution. The identifier represents the name of the execution,
// which will define the name of the underlying Docker network and the name of the
// running Compose services.
func NewLocalDockerCompose(filePaths []string, identifier string, opts ...LocalDockerComposeOption) *LocalDockerCompose {
return NewDockerCompose(filePaths, identifier, &LocalDockerComposeExecutor{}, nil, opts...)
}

func NewContainerizedDockerCompose(filePaths []string, identifier string, opts ...LocalDockerComposeOption) *LocalDockerCompose {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still return a LocalDockerCompose? It seems weird to me to use a containerised compose method that returns a LocalDC.

I'd be open to using a dedicated DockerisedDockerCompose type, although I think could come with changes in return types and generalisation...

Of course, not a blocker for this PR, but if you are interested in following-up with more refactors to make a stable API, I'm too

Copy link
Contributor Author

@fiftin fiftin Jun 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started with creating dedicated DockerisedDockerCompose type, but got too many unreasonable duplications. Using current LocalDockerCompose with different executors is a best solution for my opinion.

IMHO: interface DockerCompose should be removed and type LocalDockerCompose should renamed to DockerCompose, new interface ComposeExecutor should be used for extensions.

Alternatively:

  1. Rename type LocalDockerCompose to DockerComposeXYZ.

  2. Define type LocalDockerCompose as alias of DockerComposeXYZ for back compatibility and mark it as deprecated:

// Deprecated: Use DockerComposeXYZ instead.
type `LocalDockerCompose` `DockerComposeXYZ`

As you can see I used existing docker-compose tests for containerized docker-compose and it is works out-of-box :)

return NewDockerCompose(filePaths, identifier, &ContainerizedDockerComposeExecutor{}, context.Background(), opts...)
}

func NewNativeDockerCompose(filePaths []string, identifier string, opts ...LocalDockerComposeOption) *LocalDockerCompose {
return NewDockerCompose(filePaths, identifier, &NativeDockerComposeExecutor{}, context.Background(), opts...)
}

// Down executes docker-compose down
func (dc *LocalDockerCompose) Down() ExecError {
return executeCompose(dc, []string{"down", "--remove-orphans", "--volumes"})
Expand Down Expand Up @@ -150,6 +165,8 @@ func (dc *LocalDockerCompose) applyStrategyToRunningContainer() error {
for k := range dc.WaitStrategyMap {
containerName := dc.containerNameFromServiceName(k.service, "_")
composeV2ContainerName := dc.containerNameFromServiceName(k.service, "-")
composeV2ContainerName = regexp.MustCompile(`_\d+$`).ReplaceAllString(composeV2ContainerName, "-$1")

f := filters.NewArgs(
filters.Arg("name", containerName),
filters.Arg("name", composeV2ContainerName),
Expand Down Expand Up @@ -320,38 +337,37 @@ func execute(
}

func executeCompose(dc *LocalDockerCompose, args []string) ExecError {
if which(dc.Executable) != nil {
return ExecError{
Command: []string{dc.Executable},
Error: fmt.Errorf("Local Docker Compose not found. Is %s on the PATH?", dc.Executable),
}
}

environment := dc.getDockerComposeEnvironment()
for k, v := range dc.Env {
environment[k] = v
}

cmds := []string{}
pwd := "."
pwd, err := filepath.Abs(".")
if err != nil {
return ExecError{
Command: []string{},
Error: err,
}
}

if len(dc.absComposeFilePaths) > 0 {
pwd, _ = filepath.Split(dc.absComposeFilePaths[0])

for _, abs := range dc.absComposeFilePaths {
cmds = append(cmds, "-f", abs)
}
} else {
cmds = append(cmds, "-f", "docker-compose.yml")
}
cmds = append(cmds, args...)

execErr := execute(pwd, environment, dc.Executable, cmds)
err := execErr.Error
execErr := dc.Executor.Exec(ComposeExecutorOptions{
Env: environment,
Pwd: pwd,
Args: args,
ComposeFiles: dc.absComposeFilePaths,
Context: dc.Context,
})

err = execErr.Error
if err != nil {
args := strings.Join(dc.Cmd, " ")
return ExecError{
Command: []string{dc.Executable},
Error: fmt.Errorf("Local Docker compose exited abnormally whilst running %s: [%v]. %s", dc.Executable, args, err.Error()),
Command: []string{},
Error: fmt.Errorf("Local Docker compose exited abnormally whilst running: [%v]. %s", args, err.Error()),
}
}

Expand Down
211 changes: 211 additions & 0 deletions compose_executors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package testcontainers

import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)

type ComposeExecutorOptions struct {
Pwd string
ComposeFiles []string
Env map[string]string
Args []string
Context context.Context
}

type ComposeExecutor interface {
Exec(options ComposeExecutorOptions) ExecError
}

type memoryLogConsumer struct {
lines []string
}

type ContainerizedDockerComposeExecutor struct {
ComposeVersion string
}

func (g *memoryLogConsumer) Accept(l Log) {
line := string(l.Content)
fmt.Printf(line)
g.lines = append(g.lines, line)
}

func (e *ContainerizedDockerComposeExecutor) Exec(options ComposeExecutorOptions) ExecError {
provider, err := ProviderDocker.GetProvider()
if err != nil {
return ExecError{
Error: err,
}
}

var cmds []string

for _, p := range options.ComposeFiles {
pwd := filepath.Clean(options.Pwd)
if !strings.HasPrefix(p, pwd+string(filepath.Separator)) {
return ExecError{
Error: fmt.Errorf("one of the compose files out of pwd directory"),
}
}

cmds = append(cmds, "-f", "/app/"+filepath.ToSlash(p[len(pwd)+1:]))
}

if len(cmds) == 0 {
cmds = append(cmds, "-f", "/app/docker-compose.yml")
}

args := append(cmds, options.Args...)

version := "1.29.2"

if e.ComposeVersion != "" {
version = e.ComposeVersion
}

req := ContainerRequest{
Image: "docker/compose:" + version,
Env: options.Env,
Mounts: Mounts(
BindMount(
coalesce(os.Getenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE"), "/var/run/docker.sock"),
"/var/run/docker.sock",
),
BindMount(options.Pwd, "/app"),
),
Cmd: append([]string{"docker-compose"}, args...),
SkipReaper: true,
}
container, err := provider.RunContainer(options.Context, req)
if err != nil {
return ExecError{
Command: req.Cmd,
Error: err,
}
}

defer container.StopLogProducer()
_ = container.StartLogProducer(options.Context)
logger := memoryLogConsumer{}
container.FollowOutput(&logger)

state, err := container.State(options.Context)

for err == nil && state.Running {
time.Sleep(10 * time.Second)
state, err = container.State(options.Context)
}

if err == nil && state.Error != "" {
err = fmt.Errorf(state.Error)
}

_ = container.Terminate(options.Context)

// TODO: consider to use flag AutoRemove and checking of error "Error: No such container: "

return ExecError{
Command: req.Cmd,
Error: err,
StdoutOutput: []byte(strings.Join(logger.lines, "\n")),
StderrOutput: []byte(""),
}
}

type LocalDockerComposeExecutor struct{}

// Exec executes a program with arguments and environment variables inside a specific directory
func (e LocalDockerComposeExecutor) Exec(options ComposeExecutorOptions) ExecError {

dirContext := options.Pwd
environment := options.Env
args := options.Args
binary := "docker-compose"
if runtime.GOOS == "windows" {
binary = "docker-compose.exe"
}

if which(binary) != nil {
return ExecError{
Command: []string{binary},
Error: fmt.Errorf("Local Docker Compose not found. Is %s on the PATH?", binary),
}
}

cmds := []string{}
if len(options.ComposeFiles) > 0 {
for _, abs := range options.ComposeFiles {
cmds = append(cmds, "-f", abs)
}
} else {
cmds = append(cmds, "-f", "docker-compose.yml")
}

args = append(cmds, args...)

var errStdout, errStderr error

cmd := exec.Command(binary, args...)
cmd.Dir = dirContext
cmd.Env = os.Environ()

for key, value := range environment {
cmd.Env = append(cmd.Env, key+"="+value)
}

stdoutIn, _ := cmd.StdoutPipe()
stderrIn, _ := cmd.StderrPipe()

stdout := newCapturingPassThroughWriter(os.Stdout)
stderr := newCapturingPassThroughWriter(os.Stderr)

err := cmd.Start()
if err != nil {
execCmd := []string{"Starting command", dirContext, binary}
execCmd = append(execCmd, args...)

return ExecError{
// add information about the CMD and arguments used
Command: execCmd,
StdoutOutput: stdout.Bytes(),
StderrOutput: stderr.Bytes(),
Error: err,
Stderr: errStderr,
Stdout: errStdout,
}
}

var wg sync.WaitGroup
wg.Add(1)

go func() {
_, errStdout = io.Copy(stdout, stdoutIn)
wg.Done()
}()

_, errStderr = io.Copy(stderr, stderrIn)
wg.Wait()

err = cmd.Wait()

execCmd := []string{"Reading std", dirContext, binary}
execCmd = append(execCmd, args...)

return ExecError{
Command: execCmd,
StdoutOutput: stdout.Bytes(),
StderrOutput: stderr.Bytes(),
Error: err,
Stderr: errStderr,
Stdout: errStdout,
}
}