Skip to content

Commit

Permalink
Merge pull request #97 from mdelapenya/25-docker-compose
Browse files Browse the repository at this point in the history
(#25) Create very basic implementation of Local Docker Compose
  • Loading branch information
gianarb committed Apr 20, 2020
2 parents a18968f + 03c0820 commit a911c8e
Show file tree
Hide file tree
Showing 7 changed files with 617 additions and 0 deletions.
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,71 @@ if err != nil {
// do something with err
}
```

## Using Docker Compose

Similar to generic containers support, it's also possible to run a bespoke set of services specified in a docker-compose.yml file.

This is intended to be useful on projects where Docker Compose is already used in dev or other environments to define services that an application may be dependent upon.

You can override Testcontainers' default behaviour and make it use a docker-compose binary installed on the local machine. This will generally yield an experience that is closer to running docker-compose locally, with the caveat that Docker Compose needs to be present on dev and CI machines.

### Examples

```go
composeFilePaths := "testresources/docker-compose.yml"
identifier := strings.ToLower(uuid.New().String())

compose := tc.NewLocalDockerCompose(composeFilePaths, identifier)
execError := compose.
WithCommand([]string{"up", "-d"}).
WithEnv(map[string]string {
"key1": "value1",
"key2": "value2",
}).
Invoke()
err := execError.Error
if err != nil {
return fmt.Errorf("Could not run compose file: %v - %v", composeFilePaths, err)
}
return nil
```

Note that the environment variables in the `env` map will be applied, if possible, to the existing variables declared in the docker compose file.

In the following example, we demonstrate how to stop a Docker compose using the convenient `Down` method.

```go
composeFilePaths := "testresources/docker-compose.yml"

compose := tc.NewLocalDockerCompose(composeFilePaths, identifierFromExistingRunningCompose)
execError := compose.Down()
err := execError.Error
if err != nil {
return fmt.Errorf("Could not run compose file: %v - %v", composeFilePaths, err)
}
return nil
```

## Troubleshooting Travis

If you want to reproduce a Travis build locally, please follow this instructions to spin up a Travis build agent locally:
```shell
export BUILDID="build-testcontainers"
export INSTANCE="travisci/ci-sardonyx:packer-1564753982-0c06deb6"
docker run --name $BUILDID -w /root/go/src/github.com/testcontainers/testcontainers-go -v /Users/mdelapenya/sourcecode/src/github.com/mdelapenya/testcontainers-go:/root/go/src/github.com/testcontainers/testcontainers-go -v /var/run/docker.sock:/var/run/docker.sock -dit $INSTANCE /sbin/init
```

Once the container has been created, enter it (`docker exec -ti $BUILDID bash`) and reproduce Travis steps:

```shell
eval "$(gimme 1.11.4)"
export GO111MODULE=on
export GOPATH="/root/go"
export PATH="$GOPATH/bin:$PATH"
go get gotest.tools/gotestsum
go mod tidy
go fmt ./...
go vet ./...
gotestsum --format short-verbose ./...
```
264 changes: 264 additions & 0 deletions compose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
package testcontainers

import (
"bytes"
"io"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"

"gopkg.in/yaml.v2"
)

const (
envProjectName = "COMPOSE_PROJECT_NAME"
envComposeFile = "COMPOSE_FILE"
)

// DockerCompose defines the contract for running Docker Compose
type DockerCompose interface {
Down() ExecError
Invoke() ExecError
WithCommand([]string) DockerCompose
WithEnv(map[string]string) DockerCompose
}

// LocalDockerCompose represents a Docker Compose execution using local binary
// docker-compose or docker-compose.exe, depending on the underlying platform
type LocalDockerCompose struct {
Executable string
ComposeFilePaths []string
absComposeFilePaths []string
Identifier string
Cmd []string
Env map[string]string
Services map[string]interface{}
}

// 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) *LocalDockerCompose {
dc := &LocalDockerCompose{}

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

dc.ComposeFilePaths = filePaths

dc.absComposeFilePaths = make([]string, len(filePaths))
for i, cfp := range dc.ComposeFilePaths {
abs, _ := filepath.Abs(cfp)
dc.absComposeFilePaths[i] = abs
}

dc.validate()

dc.Identifier = strings.ToLower(identifier)

return dc
}

// Down executes docker-compose down
func (dc *LocalDockerCompose) Down() ExecError {
return executeCompose(dc, []string{"down"})
}

func (dc *LocalDockerCompose) getDockerComposeEnvironment() map[string]string {
environment := map[string]string{}

composeFileEnvVariableValue := ""
for _, abs := range dc.absComposeFilePaths {
composeFileEnvVariableValue += abs + string(os.PathListSeparator)
}

environment[envProjectName] = dc.Identifier
environment[envComposeFile] = composeFileEnvVariableValue

return environment
}

// Invoke invokes the docker compose
func (dc *LocalDockerCompose) Invoke() ExecError {
return executeCompose(dc, dc.Cmd)
}

// WithCommand assigns the command
func (dc *LocalDockerCompose) WithCommand(cmd []string) DockerCompose {
dc.Cmd = cmd
return dc
}

// WithEnv assigns the environment
func (dc *LocalDockerCompose) WithEnv(env map[string]string) DockerCompose {
dc.Env = env
return dc
}

// validate checks if the files to be run in the compose are valid YAML files, setting up
// references to all services in them
func (dc *LocalDockerCompose) validate() error {
type compose struct {
Services map[string]interface{}
}

for _, abs := range dc.absComposeFilePaths {
c := compose{}

yamlFile, err := ioutil.ReadFile(abs)
if err != nil {
return err
}
err = yaml.Unmarshal(yamlFile, &c)
if err != nil {
return err
}

dc.Services = c.Services
}

return nil
}

// ExecError is super struct that holds any information about an execution error, so the client code
// can handle the result
type ExecError struct {
Command []string
Error error
Stdout error
Stderr error
}

// execute executes a program with arguments and environment variables inside a specific directory
func execute(
dirContext string, environment map[string]string, binary string, args []string) ExecError {

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,
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,
Error: err,
Stderr: errStderr,
Stdout: errStdout,
}
}

func executeCompose(dc *LocalDockerCompose, args []string) ExecError {
if which(dc.Executable) != nil {
panic("Local Docker Compose not found. Is " + dc.Executable + " on the PATH?")
}

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

cmds := []string{}
pwd := "."
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
if err != nil {
args := strings.Join(dc.Cmd, " ")
panic(
"Local Docker compose exited abnormally whilst running " +
dc.Executable + ": [" + args + "]. " + err.Error())
}

return execErr
}

// capturingPassThroughWriter is a writer that remembers
// data written to it and passes it to w
type capturingPassThroughWriter struct {
buf bytes.Buffer
w io.Writer
}

// newCapturingPassThroughWriter creates new capturingPassThroughWriter
func newCapturingPassThroughWriter(w io.Writer) *capturingPassThroughWriter {
return &capturingPassThroughWriter{
w: w,
}
}

func (w *capturingPassThroughWriter) Write(d []byte) (int, error) {
w.buf.Write(d)
return w.w.Write(d)
}

// Bytes returns bytes written to the writer
func (w *capturingPassThroughWriter) Bytes() []byte {
return w.buf.Bytes()
}

// Which checks if a binary is present in PATH
func which(binary string) error {
_, err := exec.LookPath(binary)

return err
}

0 comments on commit a911c8e

Please sign in to comment.