diff --git a/dockertest.go b/dockertest.go index d4302e34..66636bc7 100644 --- a/dockertest.go +++ b/dockertest.go @@ -2,6 +2,7 @@ package dockertest import ( "fmt" + "io" "io/ioutil" "net" "os" @@ -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 @@ -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) @@ -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 @@ -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 { @@ -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, @@ -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) +} diff --git a/dockertest_test.go b/dockertest_test.go index 7c2c29bc..b947391e 100644 --- a/dockertest_test.go +++ b/dockertest_test.go @@ -1,12 +1,14 @@ package dockertest import ( + "bytes" "database/sql" "fmt" "io/ioutil" "log" "net/http" "os" + "strings" "testing" "time" @@ -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")) +}