Skip to content
6 changes: 2 additions & 4 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ sudo: required
language: go

go:
- 1.5
- 1.6
- 1.8

install:
- go get golang.org/x/tools/cmd/vet
- go get github.com/mattn/goveralls
- go get golang.org/x/tools/cmd/cover
- go get github.com/golang/lint/golint
Expand All @@ -24,4 +22,4 @@ script:

after_failure:
- docker logs ms
- docker logs pg
- docker logs pg
90 changes: 71 additions & 19 deletions compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ import (
"log"
"os"
"regexp"
"sort"
"strings"
)

// Compose is the main type exported by the package, used to interact with a running Docker Compose configuration.
type Compose struct {
fileName string
Containers map[string]*Container
fileName string
composeProjectName string
Containers map[string]*Container
}

var (
Expand All @@ -58,23 +60,34 @@ var (
composeUpRegexp = regexp.MustCompile("(?m:docker start <- \\(u'(.*)'\\)$)")
)

const (
composeProjectName = "compose"
)

// Start starts a Docker Compose configuration.
// Fixes the Docker Compose project name to a known value so existing containers can be killed.
// If forcePull is true, it attempts do pull newer versions of the images.
// If rmFirst is true, it attempts to kill and delete containers before starting new ones.
func Start(dockerComposeYML string, forcePull, rmFirst bool) (*Compose, error) {
return StartProject(dockerComposeYML, forcePull, rmFirst, "compose")
}

// StartParallel starts a Docker Compose configuration and is suitable for concurrent usage.
// The project name is defined at random to ensure multiple instances can be run.
// Note: that the docker services should not bind to localhost ports.
func StartParallel(dockerComposeYML string, forcePull bool) (*Compose, error) {
return StartProject(dockerComposeYML, forcePull, false, randStringBytes(9))
}

// StartProject starts a Docker Compose configuration, giving fine grained control of all of the properties.
func StartProject(dockerComposeYML string, forcePull, rmFirst bool, projectName string) (*Compose, error) {

logger.Println("initializing...")

dockerComposeYML = replaceEnv(dockerComposeYML)

fName, err := writeTmp(dockerComposeYML)
if err != nil {
return nil, err
}

ids, err := composeStart(fName, forcePull, rmFirst)
ids, err := composeStart(fName, projectName, forcePull, rmFirst)
if err != nil {
return nil, err
}
Expand All @@ -92,7 +105,7 @@ func Start(dockerComposeYML string, forcePull, rmFirst bool) (*Compose, error) {
containers[container.Name[1:]] = container
}

return &Compose{fileName: fName, Containers: containers}, nil
return &Compose{fileName: fName, composeProjectName: projectName, Containers: containers}, nil
}

// MustStart is like Start, but panics on error.
Expand All @@ -104,9 +117,18 @@ func MustStart(dockerComposeYML string, forcePull, killFirst bool) *Compose {
return compose
}

// MustStartParallel is like StartParallel, but panics on error.
func MustStartParallel(dockerComposeYML string, forcePull bool) *Compose {
compose, err := StartParallel(dockerComposeYML, forcePull)
if err != nil {
panic(err)
}
return compose
}

// Kill kills any running containers for the current configuration.
func (c *Compose) Kill() error {
return composeKill(c.fileName)
return composeKill(c.fileName, c.composeProjectName)
}

// MustKill is like Kill, but panics on error.
Expand All @@ -116,6 +138,36 @@ func (c *Compose) MustKill() {
}
}

// GetPublicIPAddressForService returns the IPAddress of a service
func (c *Compose) GetPublicIPAddressForService(serviceName string) (bool, string) {
Copy link
Owner

Choose a reason for hiding this comment

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

Is this actually needed? What I've traditionally been doing is assuming that the IP address for the service is the one returned by InferDockerHost(), so you could for example find the IP:port of a container by doing this:

mockServerURL := fmt.Sprintf("%v:%v", MustInferDockerHost(), compose.Containers["container-name"].MustGetFirstPublicPort(1080, "tcp"))

Copy link
Author

Choose a reason for hiding this comment

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

I'll tell you what I was thinking -

InferDockerHost gives you the IPAddress for the machine running the Docker daemon - so for me on Ubuntu it is localhost but if I was running a Mac and using the Docker toolbox (or Docker Machine or whatever it is called now) it would be the IPAddress of the virtual machine running the Docker daemon.

GetPublicIPAddressForService should give you the IPAddress the docker daemon has allocated for the container for the service defined in the docker-compose file.

InferDockerHost is really useful if your docker-compose file maps container ports to "where ever the docker daemon is running". When you are running simultaneous copies of the docker-compose file you really need to know the IPAddress of the container to call into from your test and that was why I added the method.

Copy link
Owner

@ibrt ibrt Mar 23, 2017

Choose a reason for hiding this comment

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

Sorry I'm still not sure I'm clear on this one - my understanding is that if you want to access a container from a test (i.e. from outside the virtual Docker network) your only option is to ask Docker to bind an exposed port from a container to a port on the host network, because the internal IP address of the container won't be reachable from the host otherwise.

You can do that using the "ports" section in the compose file. You won't be able to specify the port in the "hostport:containerport" format as the second container will fail to start because the port on the host is already taken. If you only specify the "containerport" port, Docker will automatically allocate a free port on the host network, and you can find out which one it is using MustGetFirstPublicPort.

Maybe I'm missing something though - is there a way to route from the host network to an IP address in the virtual Docker network?

Note that if you are instead trying to connect to a container from another one, you can just specify the dependency in the "links" section and use the container name as hostname, as the virtual Docker network has a DNS server which will resolve it to the internal IP address of the container.

Copy link
Author

@mdevilliers mdevilliers Mar 23, 2017

Choose a reason for hiding this comment

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

I've added a test - TestParallelMustConnectWithDefaults which shows the GetPublicIPAddressForService method being used to address the containers.

Copy link
Owner

@ibrt ibrt Mar 23, 2017

Choose a reason for hiding this comment

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

So for example on my machine, if I do:

$ docker run -d jamesdbloom/mockserver
$ docker ps

I get

279eff516fcb        jamesdbloom/mockserver           "/opt/mockserver/run_"   About a minute ago   Up About a minute   1080/tcp, 1090/tcp                 festive_hopper

Then

$ docker inspect festive_hopper

I get

...
"NetworkSettings": {
            "Bridge": "",
            "SandboxID": "200a8403eeae9d7e40c68a7db142cf5a24a103c24e7ad5690bb64ca79b5cd271",
            "HairpinMode": false,
            "LinkLocalIPv6Address": "",
            "LinkLocalIPv6PrefixLen": 0,
            "Ports": {
                "1080/tcp": null,
                "1090/tcp": null
            },
            "SandboxKey": "/var/run/docker/netns/200a8403eeae",
            "SecondaryIPAddresses": null,
            "SecondaryIPv6Addresses": null,
            "EndpointID": "b26b58b68f82f0bbee026b4673359260fab8912f113050b4c74d70de9c784873",
            "Gateway": "172.17.0.1",
            "GlobalIPv6Address": "",
            "GlobalIPv6PrefixLen": 0,
            "IPAddress": "172.17.0.4",
            "IPPrefixLen": 16,
            "IPv6Gateway": "",
            "MacAddress": "02:42:ac:11:00:04",
            "Networks": {

Then

$ curl -v http://172.17.0.4:1080

It times out. But:

$ docker run -d -p 1080 jamesdbloom/mockserver
$ docker ps

Gives me

d2f56406d942        jamesdbloom/mockserver   "/opt/mockserver/run_"   3 seconds ago       Up 2 seconds        1090/tcp, 0.0.0.0:32769->1080/tcp   vigilant_torvalds

Which tells me that I can connect to this container using port 32769 on the Docker host. In my case, since I'm using boot2docker, it would be:

$ curl -v http://192.168.99.100:32769

Which works! In your case it'd be "localhost:32769". If I did:

$ docker run -d -p 1080:1080 jamesdbloom/mockserver
$ docker run -d -p 1080:1080 jamesdbloom/mockserver

The first one would succeed and I would be able to connect using curl -v http://192.168.99.100:1080, the second one would fail with this error message:

docker: Error response from daemon: driver failed programming external connectivity on endpoint relaxed_saha (fd8eeb6e8c75baeda60698af4205595efb7765565568080b81464236a852315f): Bind for 0.0.0.0:1080 failed: port is already allocated.

If I did:

$ docker run -d -p 1080 jamesdbloom/mockserver
$ docker run -d -p 1080 jamesdbloom/mockserver
$ docker ps

Then I'd get:

483f78f46127        jamesdbloom/mockserver   "/opt/mockserver/run_"   1 seconds ago       Up 1 second         1090/tcp, 0.0.0.0:32771->1080/tcp   musing_shaw
78db85c9228a        jamesdbloom/mockserver   "/opt/mockserver/run_"   40 seconds ago      Up 40 seconds       1090/tcp, 0.0.0.0:32770->1080/tcp   trusting_nobel

Which means I'd still be able to reach both containers using different ports.

Copy link
Author

Choose a reason for hiding this comment

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

Ahh I see what you mean now by just specifying a "containerport" port.

I think that makes sense to me - I've always just used the IPAddress (probably too much as I've spent a lot of time with K8s)

I'll have a little think...

Copy link
Author

Choose a reason for hiding this comment

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

Okay,

Removing the GetPublicIPAddressForService and using the InferDockerHost method has one issue and that is getting the access to the correct container by name.

I hadn't noticed before that in the tests and examples you were using the "container_name" attribute to fix the name to a known value. Understandably Docker just barfs if you try to run multiple instances with the same name.

I propose a fix where I ensure that the container has the same name as the service in the returned collection of containers. This means it is possible to reliably get to the container/service you want. It is however I fear a breaking change.

To illustrate -

test_mockserver:
  image: jamesdbloom/mockserver
  ports:
    - "10000:1080"
    - "1090"
test_postgres:
  image: postgres
  ports:
    - "5432"

would return two containers in the collection called 'test_mockserver' and 'test_postgres'. I can pick out the originally specified name from the labels written by docker-compose. In both cases the actual name of the docker container would just be the usual unreliable junk.

Do you have any thoughts?

Copy link
Owner

Choose a reason for hiding this comment

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

Sounds good, would you mind coding that up?


//iterate containers
for _, container := range c.Containers {
// look in NetworkSettings.Networks
for _, network := range container.NetworkSettings.Networks {

// iterate aliases looking for the service
sort.Strings(network.Aliases)
i := sort.SearchStrings(network.Aliases, serviceName)

if i < len(network.Aliases) {
return true, network.IPAddress
}

}
}
return false, ""
}

// MustGetPublicIPAddressForService is like GetPublicIPAddressForService, but panics if not found
func (c *Compose) MustGetPublicIPAddressForService(serviceName string) string {
found, ipAddress := c.GetPublicIPAddressForService(serviceName)
if !found {
panic(fmt.Errorf("ipaddress for service %s not found", serviceName))
}
return ipAddress
}

func replaceEnv(dockerComposeYML string) string {
return replaceEnvRegexp.ReplaceAllStringFunc(dockerComposeYML, replaceEnvFunc)
}
Expand All @@ -124,25 +176,25 @@ func replaceEnvFunc(s string) string {
return os.Getenv(strings.TrimSpace(s[2 : len(s)-1]))
}

func composeStart(fName string, forcePull, rmFirst bool) ([]string, error) {
func composeStart(fName, composeProjectName string, forcePull, rmFirst bool) ([]string, error) {
if forcePull {
logger.Println("pulling images...")
if _, err := composeRun(fName, "pull"); err != nil {
if _, err := composeRun(fName, composeProjectName, "pull"); err != nil {
return nil, fmt.Errorf("compose: error pulling images: %v", err)
}
}

if rmFirst {
if err := composeKill(fName); err != nil {
if err := composeKill(fName, composeProjectName); err != nil {
return nil, err
}
if err := composeRm(fName); err != nil {
if err := composeRm(fName, composeProjectName); err != nil {
return nil, err
}
}

logger.Println("starting containers...")
out, err := composeRun(fName, "--verbose", "up", "-d")
out, err := composeRun(fName, composeProjectName, "--verbose", "up", "-d")
if err != nil {
return nil, fmt.Errorf("compose: error starting containers: %v", err)
}
Expand All @@ -157,25 +209,25 @@ func composeStart(fName string, forcePull, rmFirst bool) ([]string, error) {
return ids, nil
}

func composeKill(fName string) error {
func composeKill(fName, composeProjectName string) error {
logger.Println("killing stale containers...")
_, err := composeRun(fName, "kill")
_, err := composeRun(fName, composeProjectName, "kill")
if err != nil {
return fmt.Errorf("compose: error killing stale containers: %v", err)
}
return err
}

func composeRm(fName string) error {
func composeRm(fName, composeProjectName string) error {
logger.Println("removing stale containers...")
_, err := composeRun(fName, "rm", "--force")
_, err := composeRun(fName, composeProjectName, "rm", "--force")
if err != nil {
return fmt.Errorf("compose: error removing stale containers: %v", err)
}
return err
}

func composeRun(fName string, otherArgs ...string) (string, error) {
func composeRun(fName, composeProjectName string, otherArgs ...string) (string, error) {
args := []string{"-f", fName, "-p", composeProjectName}
args = append(args, otherArgs...)
return runCmd("docker-compose", args...)
Expand Down
53 changes: 53 additions & 0 deletions compose/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"os"
"sync"
"testing"
)

Expand Down Expand Up @@ -82,6 +83,58 @@ func TestMustConnectWithDefaults(t *testing.T) {
})
}

func TestParallelMustConnectWithDefaults(t *testing.T) {

// NOTE that the services don't bind to local port
parallelYML := `
version: '2'
services:
one:
image: jamesdbloom/mockserver
two:
image: jamesdbloom/mockserver
`

compose1 := MustStartParallel(parallelYML, false)
defer compose1.MustKill()
compose2 := MustStartParallel(parallelYML, false)
defer compose2.MustKill()

// get the URL for the service 'one' in the first docker-compose cluster
mockServer1URL := fmt.Sprintf("http://%s:%d", compose1.MustGetPublicIPAddressForService("one"), 1080)

// get the URL for the service 'two' in the second docker-compose cluster
mockServer2URL := fmt.Sprintf("http://%s:%d", compose2.MustGetPublicIPAddressForService("two"), 1080)

fmt.Println(mockServer1URL, mockServer2URL)

wg := sync.WaitGroup{}
wg.Add(2)

MustConnectWithDefaults(func() error {
logger.Print("attempting to connect to mockserver1...")
_, err := http.Get(mockServer1URL)
if err == nil {
logger.Print("connected to mockserver1")
wg.Done()
}
return err
})

MustConnectWithDefaults(func() error {
logger.Print("attempting to connect to mockserver2...")
_, err := http.Get(mockServer2URL)
if err == nil {
logger.Print("connected to mockserver2")
wg.Done()
}
return err
})

wg.Wait()

}

func TestInspectUnknownContainer(t *testing.T) {
_, err := Inspect("bad")
if err == nil {
Expand Down
9 changes: 8 additions & 1 deletion compose/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ type State struct {

// NetworkSettings models the network settings section of the `docker inspect` command.
type NetworkSettings struct {
Ports map[string][]PortBinding `json:"Ports,omitempty"`
Ports map[string][]PortBinding `json:"Ports,omitempty"`
Networks map[string]Network `json:"Networks"`
}

// PortBinding models a port binding in the network settings section of the `docker inspect command.
Expand All @@ -53,6 +54,12 @@ type PortBinding struct {
HostPort string `json:"HostPort,omitempty"`
}

// Network models the network section of the `docker inspect` command.
type Network struct {
IPAddress string `json:"IPAddress"`
Aliases []string `json:"Aliases"`
}

// Inspect inspects a container using the `docker inspect` command and returns a parsed version of its output.
func Inspect(id string) (*Container, error) {
out, err := runCmd("docker", "inspect", id)
Expand Down
13 changes: 13 additions & 0 deletions compose/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"io/ioutil"
"math/rand"
"os"
"os/exec"
"regexp"
Expand Down Expand Up @@ -37,6 +38,7 @@ func MustInferDockerHost() string {

func runCmd(name string, args ...string) (string, error) {
var outBuf bytes.Buffer

cmd := exec.Command(name, args...)
cmd.Stdout = &outBuf
cmd.Stderr = &outBuf
Expand All @@ -50,6 +52,7 @@ func runCmd(name string, args ...string) (string, error) {

func writeTmp(content string) (string, error) {
f, err := ioutil.TempFile("", "docker-compose-")

if err != nil {
return "", fmt.Errorf("compose: error creating temp file: %v", err)
}
Expand All @@ -61,3 +64,13 @@ func writeTmp(content string) (string, error) {

return f.Name(), nil
}

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func randStringBytes(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return string(b)
}