Skip to content

Commit

Permalink
docker: add run (container create+start) method (#4991)
Browse files Browse the repository at this point in the history
Single-shot method to create and run a container, similar to
`docker run`. The container create + container run semantics of
the Docker API are more low-level than we care about right now,
so it's easier to encapsulate these into a single method. It
also has some convenience bits around getting container logs and
the execution result (which has quirky semantics).
  • Loading branch information
milas committed Sep 29, 2021
1 parent 948bda7 commit 567e0fd
Show file tree
Hide file tree
Showing 8 changed files with 435 additions and 0 deletions.
112 changes: 112 additions & 0 deletions internal/docker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import (
cliflags "github.com/docker/cli/cli/flags"
"github.com/docker/distribution/reference"
"github.com/docker/docker/api/types"
mobycontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/docker/docker/registry"
"github.com/docker/go-connections/tlsconfig"
"github.com/moby/buildkit/identity"
Expand Down Expand Up @@ -82,6 +84,8 @@ type Client interface {
ContainerList(ctx context.Context, options types.ContainerListOptions) ([]types.Container, error)
ContainerRestartNoWait(ctx context.Context, containerID string) error

Run(ctx context.Context, opts RunConfig) (RunResult, error)

// Execute a command in a container, streaming the command output to `out`.
// Returns an ExitError if the command exits with a non-zero exit code.
ExecInContainer(ctx context.Context, cID container.ID, cmd model.Cmd, in io.Reader, out io.Writer) error
Expand Down Expand Up @@ -645,3 +649,111 @@ func (c *Cli) ExecInContainer(ctx context.Context, cID container.ID, cmd model.C
return nil
}
}

func (c *Cli) Run(ctx context.Context, opts RunConfig) (RunResult, error) {
if opts.Pull {
namedRef, ok := opts.Image.(reference.Named)
if !ok {
return RunResult{}, fmt.Errorf("invalid reference type %T for pull", opts.Image)
}
if _, err := c.ImagePull(ctx, namedRef); err != nil {
return RunResult{}, fmt.Errorf("error pulling image %q: %v", opts.Image, err)
}
}

cc := &mobycontainer.Config{
Image: opts.Image.String(),
AttachStdout: opts.Stdout != nil,
AttachStderr: opts.Stderr != nil,
Cmd: opts.Cmd,
Labels: BuiltByTiltLabel,
}

hc := &mobycontainer.HostConfig{
Mounts: opts.Mounts,
}

createResp, err := c.Client.ContainerCreate(ctx,
cc,
hc,
nil,
nil,
opts.ContainerName,
)
if err != nil {
return RunResult{}, fmt.Errorf("could not create container: %v", err)
}

tearDown := func(containerID string) error {
return c.Client.ContainerRemove(ctx, createResp.ID, types.ContainerRemoveOptions{Force: true})
}

var containerStarted bool
defer func(containerID string) {
// make an effort to clean up any container we create but don't successfully start
if containerStarted {
return
}
if err := tearDown(containerID); err != nil {
logger.Get(ctx).Debugf("Failed to remove container after error before start (id=%s): %v", createResp.ID, err)
}
}(createResp.ID)

statusCh, statusErrCh := c.Client.ContainerWait(ctx, createResp.ID, mobycontainer.WaitConditionNextExit)
// ContainerWait() can immediately write to the error channel before returning if it can't start the API request,
// so catch these errors early (it _also_ can write to that channel later, so it's still passed to the RunResult)
select {
case err = <-statusErrCh:
return RunResult{}, fmt.Errorf("could not wait for container (id=%s): %v", createResp.ID, err)
default:
}

err = c.Client.ContainerStart(ctx, createResp.ID, types.ContainerStartOptions{})
if err != nil {
return RunResult{}, fmt.Errorf("could not start container (id=%s): %v", createResp.ID, err)
}
containerStarted = true

logsErrCh := make(chan error, 1)
if opts.Stdout != nil || opts.Stderr != nil {
var logsResp io.ReadCloser
logsResp, err = c.Client.ContainerLogs(
ctx, createResp.ID, types.ContainerLogsOptions{
ShowStdout: opts.Stdout != nil,
ShowStderr: opts.Stderr != nil,
Follow: true,
},
)
if err != nil {
return RunResult{}, fmt.Errorf("could not read container logs: %v", err)
}

go func() {
stdout := opts.Stdout
if stdout == nil {
stdout = io.Discard
}
stderr := opts.Stderr
if stderr == nil {
stderr = io.Discard
}

_, err = stdcopy.StdCopy(stdout, stderr, logsResp)
_ = logsResp.Close()
logsErrCh <- err
}()
} else {
// there is no I/O so immediately signal so that the result call doesn't block on it
logsErrCh <- nil
}

result := RunResult{
ContainerID: createResp.ID,
logsErrCh: logsErrCh,
statusRespCh: statusCh,
statusErrCh: statusErrCh,
tearDown: tearDown,
}

return result, nil
}
41 changes: 41 additions & 0 deletions internal/docker/client_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//+build !skipcontainertests

package docker

import (
"bytes"
"testing"

"github.com/docker/distribution/reference"
"github.com/stretchr/testify/require"

wmcontainer "github.com/tilt-dev/tilt/internal/container"
"github.com/tilt-dev/tilt/internal/k8s"
"github.com/tilt-dev/tilt/internal/testutils"
)

func TestCli_Run(t *testing.T) {
ctx, _, _ := testutils.CtxAndAnalyticsForTest()
dEnv := ProvideClusterEnv(ctx, "gke", k8s.EnvGKE, wmcontainer.RuntimeDocker, k8s.FakeMinikube{})
cli := NewDockerClient(ctx, Env(dEnv))
defer func() {
// release any idle connections to avoid out of file errors if running test many times
_ = cli.(*Cli).Close()
}()

ref, err := reference.ParseNamed("docker.io/library/hello-world")
require.NoError(t, err)

var stdout bytes.Buffer
r, err := cli.Run(ctx, RunConfig{
Pull: true,
Image: ref,
Stdout: &stdout,
})
require.NoError(t, err, "Error during run")
exitCode, err := r.Wait()
require.NoError(t, err, "Error waiting for exit")
require.NoError(t, r.Close(), "Error cleaning up container")
require.Equal(t, int64(0), exitCode, "Non-zero exit code from container")
require.Contains(t, stdout.String(), "Hello from Docker", "Bad stdout")
}
3 changes: 3 additions & 0 deletions internal/docker/exploding.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ func (c explodingClient) ContainerList(ctx context.Context, options types.Contai
func (c explodingClient) ContainerRestartNoWait(ctx context.Context, containerID string) error {
return c.err
}
func (c explodingClient) Run(ctx context.Context, opts RunConfig) (RunResult, error) {
return RunResult{}, c.err
}
func (c explodingClient) ExecInContainer(ctx context.Context, cID container.ID, cmd model.Cmd, in io.Reader, out io.Writer) error {
return c.err
}
Expand Down
4 changes: 4 additions & 0 deletions internal/docker/fake_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ func (c *FakeClient) ContainerRestartNoWait(ctx context.Context, containerID str
return nil
}

func (c *FakeClient) Run(ctx context.Context, opts RunConfig) (RunResult, error) {
return RunResult{}, nil
}

func (c *FakeClient) ExecInContainer(ctx context.Context, cID container.ID, cmd model.Cmd, in io.Reader, out io.Writer) error {
if cmd.Argv[0] == "tar" {
c.CopyCount++
Expand Down
81 changes: 81 additions & 0 deletions internal/docker/run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package docker

import (
"fmt"
"io"

"github.com/docker/distribution/reference"
mobycontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
)

// RunConfig defines the container to create and start.
type RunConfig struct {
// Image to execute.
//
// If Pull is true, this must be a reference.Named.
Image reference.Reference
// Pull will ensure the image exists locally before running.
//
// If an image will only be used once, this is a convenience to avoid calling ImagePull first.
// If an image will be used multiple times (across containers), prefer explicitly calling ImagePull
// to avoid the overhead of calling the registry API to check if the image is up-to-date every time.
Pull bool
// ContainerName is a unique name for the container. If not specified, Docker will generate a random name.
ContainerName string
// Stdout from the container will be written here if non-nil.
//
// Errors copying the container output are logged but not propagated.
Stdout io.Writer
// Stderr from the container will be written here if non-nil.
//
// Errors copying the container output are logged but not propagated.
Stderr io.Writer
// Cmd to run when starting the container.
Cmd []string
// Mounts to attach to the container.
Mounts []mount.Mount
}

// RunResult contains information about a container execution.
type RunResult struct {
ContainerID string

logsErrCh <-chan error
statusRespCh <-chan mobycontainer.ContainerWaitOKBody
statusErrCh <-chan error
tearDown func(containerID string) error
}

// Wait blocks until stdout and stderr have been fully consumed (if writers were passed via RunConfig) and the
// container has exited. If there is any error consuming stdout/stderr or monitoring the container execution, an
// error will be returned.
func (r *RunResult) Wait() (int64, error) {
select {
case err := <-r.statusErrCh:
return -1, err
case statusResp := <-r.statusRespCh:
if statusResp.Error != nil {
return -1, fmt.Errorf("error waiting on container: %s", statusResp.Error.Message)
}
logsErr := <-r.logsErrCh
if logsErr != nil {
// error is
return statusResp.StatusCode, fmt.Errorf("error reading container logs: %w", logsErr)
}
return statusResp.StatusCode, nil
}
}

// Close removes the container (forcibly if it's still running).
func (r *RunResult) Close() error {
if r.tearDown == nil {
return nil
}
err := r.tearDown(r.ContainerID)
if err != nil {
return err
}
r.tearDown = nil
return nil
}
3 changes: 3 additions & 0 deletions internal/docker/switch.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ func (c *switchCli) ContainerList(ctx context.Context, options types.ContainerLi
func (c *switchCli) ContainerRestartNoWait(ctx context.Context, containerID string) error {
return c.client().ContainerRestartNoWait(ctx, containerID)
}
func (c *switchCli) Run(ctx context.Context, opts RunConfig) (RunResult, error) {
return c.client().Run(ctx, opts)
}
func (c *switchCli) ExecInContainer(ctx context.Context, cID container.ID, cmd model.Cmd, in io.Reader, out io.Writer) error {
return c.client().ExecInContainer(ctx, cID, cmd, in, out)
}
Expand Down

0 comments on commit 567e0fd

Please sign in to comment.