Skip to content

Commit

Permalink
feat: add basic network management (#188)
Browse files Browse the repository at this point in the history
Related #163 

This PR adds three things (in order from smaller to bigger):
* Getting a container descriptor inside container. This is useful when tests already running inside container with mounted docker socket for container management.
* Execution commands inside running container. This is useful "in general".  I.e. we have project with test case that simulates RabbitMQ restarts using `rabbitmqctl stop_app` and `rabbitmqctl start_app`. Also it's used in new tests.
* Basic network management. Docker networks can be created and deleted using `Pool`, containers can be connected to network using `BuildAndRunWithOptions` and `RunWithOptions`, already running containers can be connected to network using `ConnectToNetwork`.

Currrently it's working pretty good but requires too much boilerplate with `docker` package for network management and commands execution.

Also it helps in cases metioned in #163 when you need to run some end-to-end or integration tests with multiple linked containers.
  • Loading branch information
xakep666 committed Apr 10, 2020
1 parent 74d0e5f commit 1deddef
Show file tree
Hide file tree
Showing 2 changed files with 308 additions and 0 deletions.
193 changes: 193 additions & 0 deletions dockertest.go
Expand Up @@ -2,6 +2,7 @@ package dockertest

import (
"fmt"
"io"
"io/ioutil"
"net"
"os"
Expand All @@ -16,12 +17,27 @@ import (
"github.com/pkg/errors"
)

var (
ErrNotInContainer = errors.New("not running in container")
)

// Pool represents a connection to the docker API and is used to create and remove docker images.
type Pool struct {
Client *dc.Client
MaxWait time.Duration
}

// Network represents a docker network.
type Network struct {
pool *Pool
Network *dc.Network
}

// Close removes network by calling pool.RemoveNetwork.
func (n *Network) Close() error {
return n.pool.RemoveNetwork(n)
}

// Resource represents a docker container.
type Resource struct {
pool *Pool
Expand Down Expand Up @@ -74,6 +90,118 @@ func (r *Resource) GetHostPort(portID string) string {
return net.JoinHostPort(ip, m[0].HostPort)
}

type ExecOptions struct {
// Command environment, optional.
Env []string

// StdIn will be attached as command stdin if provided.
StdIn io.Reader

// StdOut will be attached as command stdout if provided.
StdOut io.Writer

// StdErr will be attached as command stdout if provided.
StdErr io.Writer

// Allocate TTY for command or not.
TTY bool
}

// Exec executes command within container.
func (r *Resource) Exec(cmd []string, opts ExecOptions) (exitCode int, err error) {
exec, err := r.pool.Client.CreateExec(dc.CreateExecOptions{
Container: r.Container.ID,
Cmd: cmd,
Env: opts.Env,
AttachStderr: opts.StdErr != nil,
AttachStdout: opts.StdOut != nil,
AttachStdin: opts.StdIn != nil,
Tty: opts.TTY,
})
if err != nil {
return -1, errors.Wrap(err, "Create exec failed")
}

err = r.pool.Client.StartExec(exec.ID, dc.StartExecOptions{
InputStream: opts.StdIn,
OutputStream: opts.StdOut,
ErrorStream: opts.StdErr,
Tty: opts.TTY,
})
if err != nil {
return -1, errors.Wrap(err, "Start exec failed")
}

inspectExec, err := r.pool.Client.InspectExec(exec.ID)
if err != nil {
return -1, errors.Wrap(err, "Inspect exec failed")
}

return inspectExec.ExitCode, nil
}

// GetIPInNetwork returns container IP address in network.
func (r *Resource) GetIPInNetwork(network *Network) string {
if r.Container == nil || r.Container.NetworkSettings == nil {
return ""
}

netCfg, ok := r.Container.NetworkSettings.Networks[network.Network.Name]
if !ok {
return ""
}

return netCfg.IPAddress
}

// ConnectToNetwork connects container to network.
func (r *Resource) ConnectToNetwork(network *Network) error {
err := r.pool.Client.ConnectNetwork(
network.Network.ID,
dc.NetworkConnectionOptions{Container: r.Container.ID},
)
if err != nil {
return errors.Wrap(err, "Failed to connect container to network")
}

// refresh internal representation
r.Container, err = r.pool.Client.InspectContainer(r.Container.ID)
if err != nil {
return errors.Wrap(err, "Failed to refresh container information")
}

network.Network, err = r.pool.Client.NetworkInfo(network.Network.ID)
if err != nil {
return errors.Wrap(err, "Failed to refresh network information")
}

return nil
}

// DisconnectFromNetwork disconnects container from network.
func (r *Resource) DisconnectFromNetwork(network *Network) error {
err := r.pool.Client.DisconnectNetwork(
network.Network.ID,
dc.NetworkConnectionOptions{Container: r.Container.ID},
)
if err != nil {
return errors.Wrap(err, "Failed to connect container to network")
}

// refresh internal representation
r.Container, err = r.pool.Client.InspectContainer(r.Container.ID)
if err != nil {
return errors.Wrap(err, "Failed to refresh container information")
}

network.Network, err = r.pool.Client.NetworkInfo(network.Network.ID)
if err != nil {
return errors.Wrap(err, "Failed to refresh network information")
}

return nil
}

// Close removes a container and linked volumes from docker by calling pool.Purge.
func (r *Resource) Close() error {
return r.pool.Purge(r)
Expand Down Expand Up @@ -167,6 +295,7 @@ type RunOptions struct {
DNS []string
WorkingDir string
NetworkID string
Networks []*Network // optional networks to join
Labels map[string]string
Auth dc.AuthConfiguration
PortBindings map[dc.Port][]dc.PortBinding
Expand Down Expand Up @@ -259,6 +388,9 @@ func (d *Pool) RunWithOptions(opts *RunOptions, hcOpts ...func(*dc.HostConfig))
if opts.NetworkID != "" {
networkingConfig.EndpointsConfig[opts.NetworkID] = &dc.EndpointConfig{}
}
for _, network := range opts.Networks {
networkingConfig.EndpointsConfig[network.Network.ID] = &dc.EndpointConfig{}
}

_, err := d.Client.InspectImage(fmt.Sprintf("%s:%s", repository, tag))
if err != nil {
Expand Down Expand Up @@ -316,6 +448,13 @@ func (d *Pool) RunWithOptions(opts *RunOptions, hcOpts ...func(*dc.HostConfig))
return nil, errors.Wrap(err, "")
}

for _, network := range opts.Networks {
network.Network, err = d.Client.NetworkInfo(network.Network.ID)
if err != nil {
return nil, errors.Wrap(err, "")
}
}

return &Resource{
pool: d,
Container: c,
Expand Down Expand Up @@ -404,3 +543,57 @@ func (d *Pool) Retry(op func() error) error {
bo.MaxElapsedTime = d.MaxWait
return backoff.Retry(op, bo)
}

// CurrentContainer returns current container descriptor if this function called within running container.
// It returns ErrNotInContainer as error if this function running not in container.
func (d *Pool) CurrentContainer() (*Resource, error) {
// docker daemon puts short container id into hostname
hostname, err := os.Hostname()
if err != nil {
return nil, errors.Wrap(err, "Get hostname failed")
}

container, err := d.Client.InspectContainer(hostname)
switch err.(type) {
case nil:
return &Resource{
pool: d,
Container: container,
}, nil
case *dc.NoSuchContainer:
return nil, ErrNotInContainer
default:
return nil, errors.Wrap(err, "")
}
}

// CreateNetwork creates docker network. It's useful for linking multiple containers.
func (d *Pool) CreateNetwork(name string, opts ...func(config *dc.CreateNetworkOptions)) (*Network, error) {
var cfg dc.CreateNetworkOptions
cfg.Name = name
for _, opt := range opts {
opt(&cfg)
}

network, err := d.Client.CreateNetwork(cfg)
if err != nil {
return nil, errors.Wrap(err, "")
}

return &Network{
pool: d,
Network: network,
}, nil
}

// RemoveNetwork disconnects containers and removes provided network.
func (d *Pool) RemoveNetwork(network *Network) error {
for container := range network.Network.Containers {
_ = d.Client.DisconnectNetwork(
network.Network.ID,
dc.NetworkConnectionOptions{Container: container, Force: true},
)
}

return d.Client.RemoveNetwork(network.Network.ID)
}
115 changes: 115 additions & 0 deletions dockertest_test.go
@@ -1,12 +1,14 @@
package dockertest

import (
"bytes"
"database/sql"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -221,3 +223,116 @@ func TestRemoveContainerByName(t *testing.T) {
require.Nil(t, err)
require.Nil(t, pool.Purge(resource))
}

func TestExec(t *testing.T) {
resource, err := pool.Run("postgres", "9.5", nil)
require.Nil(t, err)
assert.NotEmpty(t, resource.GetPort("5432/tcp"))
assert.NotEmpty(t, resource.GetBoundIP("5432/tcp"))

defer resource.Close()

var pgVersion string
err = pool.Retry(func() error {
db, err := sql.Open("postgres", fmt.Sprintf("postgres://postgres:secret@localhost:%s/postgres?sslmode=disable", resource.GetPort("5432/tcp")))
if err != nil {
return err
}
return db.QueryRow("SHOW server_version").Scan(&pgVersion)
})
require.Nil(t, err)

var stdout bytes.Buffer
exitCode, err := resource.Exec(
[]string{"psql", "-qtAX", "-U", "postgres", "-c", "SHOW server_version"},
ExecOptions{StdOut: &stdout},
)
require.Nil(t, err)
require.Zero(t, exitCode)

require.Equal(t, pgVersion, strings.TrimRight(stdout.String(), "\n"))
}

func TestNetworking_on_start(t *testing.T) {
network, err := pool.CreateNetwork("test-on-start")
require.Nil(t, err)
defer network.Close()

resourceFirst, err := pool.RunWithOptions(&RunOptions{
Repository: "postgres",
Tag: "9.5",
Networks: []*Network{network},
})
require.Nil(t, err)
defer resourceFirst.Close()

resourceSecond, err := pool.RunWithOptions(&RunOptions{
Repository: "postgres",
Tag: "11",
Networks: []*Network{network},
})
require.Nil(t, err)
defer resourceSecond.Close()

var expectedVersion string
err = pool.Retry(func() error {
db, err := sql.Open(
"postgres",
fmt.Sprintf(
"postgres://postgres:secret@localhost:%s/postgres?sslmode=disable",
resourceSecond.GetPort("5432/tcp"),
),
)
if err != nil {
return err
}
return db.QueryRow("SHOW server_version").Scan(&expectedVersion)
})
require.Nil(t, err)
}

func TestNetworking_after_start(t *testing.T) {
network, err := pool.CreateNetwork("test-after-start")
require.Nil(t, err)
defer network.Close()

resourceFirst, err := pool.Run("postgres", "9.6", nil)
require.Nil(t, err)
defer resourceFirst.Close()

err = resourceFirst.ConnectToNetwork(network)
require.Nil(t, err)

resourceSecond, err := pool.Run("postgres", "11", nil)
require.Nil(t, err)
defer resourceSecond.Close()

err = resourceSecond.ConnectToNetwork(network)
require.Nil(t, err)

var expectedVersion string
err = pool.Retry(func() error {
db, err := sql.Open(
"postgres",
fmt.Sprintf(
"postgres://postgres:secret@localhost:%s/postgres?sslmode=disable",
resourceSecond.GetPort("5432/tcp"),
),
)
if err != nil {
return err
}
return db.QueryRow("SHOW server_version").Scan(&expectedVersion)
})
require.Nil(t, err)

var stdout bytes.Buffer
exitCode, err := resourceFirst.Exec(
[]string{"psql", "-qtAX", "-h", resourceSecond.GetIPInNetwork(network), "-U", "postgres", "-c", "SHOW server_version"},
ExecOptions{StdOut: &stdout},
)
require.Nil(t, err)
require.Zero(t, exitCode)

require.Equal(t, expectedVersion, strings.TrimRight(stdout.String(), "\n"))
}

0 comments on commit 1deddef

Please sign in to comment.