d8c5456f8a
* Export DockerAPI for use by other consumers As usage of DockerCluster gets more advanced, some users may want to interact with the container nodes of the cluster. While, if you already have a DockerAPI instance lying around you can reuse that safely, for use cases where an existing e.g., docker/testhelpers's runner instance is not available, reusing the existing cluster's DockerAPI is easiest. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add ability to exec commands without runner When modifying DockerTestCluster's containers manually, we might not have a Runner instance; instead, expose the ability to run commands via a DockerAPI instance directly, as they're awfully convenient. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add DNS resolver into ACME tests This updates the pkiext_binary tests to use an adjacent DNS resolver, allowing these tests to eventually be extended to solve DNS challenges, as modifying the /etc/hosts file does not allow this. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Fix loading DNS resolver onto network Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Fix bug with DNS configuration validation Both conditionals here were inverted: address being empty means a bad specification was given, and the parse being nil means that it was not a valid IP address. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Fix specifying TXT records, allow removing records Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> --------- Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
912 lines
25 KiB
Go
912 lines
25 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package docker
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/cenkalti/backoff/v3"
|
|
"github.com/docker/docker/api/types"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/api/types/filters"
|
|
"github.com/docker/docker/api/types/mount"
|
|
"github.com/docker/docker/api/types/network"
|
|
"github.com/docker/docker/api/types/strslice"
|
|
"github.com/docker/docker/client"
|
|
"github.com/docker/docker/pkg/archive"
|
|
"github.com/docker/docker/pkg/stdcopy"
|
|
"github.com/docker/go-connections/nat"
|
|
"github.com/hashicorp/go-uuid"
|
|
)
|
|
|
|
const DockerAPIVersion = "1.40"
|
|
|
|
type Runner struct {
|
|
DockerAPI *client.Client
|
|
RunOptions RunOptions
|
|
}
|
|
|
|
type RunOptions struct {
|
|
ImageRepo string
|
|
ImageTag string
|
|
ContainerName string
|
|
Cmd []string
|
|
Entrypoint []string
|
|
Env []string
|
|
NetworkName string
|
|
NetworkID string
|
|
CopyFromTo map[string]string
|
|
Ports []string
|
|
DoNotAutoRemove bool
|
|
AuthUsername string
|
|
AuthPassword string
|
|
OmitLogTimestamps bool
|
|
LogConsumer func(string)
|
|
Capabilities []string
|
|
PreDelete bool
|
|
PostStart func(string, string) error
|
|
LogStderr io.Writer
|
|
LogStdout io.Writer
|
|
VolumeNameToMountPoint map[string]string
|
|
}
|
|
|
|
func NewDockerAPI() (*client.Client, error) {
|
|
return client.NewClientWithOpts(client.FromEnv, client.WithVersion(DockerAPIVersion))
|
|
}
|
|
|
|
func NewServiceRunner(opts RunOptions) (*Runner, error) {
|
|
dapi, err := NewDockerAPI()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if opts.NetworkName == "" {
|
|
opts.NetworkName = os.Getenv("TEST_DOCKER_NETWORK_NAME")
|
|
}
|
|
if opts.NetworkName != "" {
|
|
nets, err := dapi.NetworkList(context.TODO(), types.NetworkListOptions{
|
|
Filters: filters.NewArgs(filters.Arg("name", opts.NetworkName)),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(nets) != 1 {
|
|
return nil, fmt.Errorf("expected exactly one docker network named %q, got %d", opts.NetworkName, len(nets))
|
|
}
|
|
opts.NetworkID = nets[0].ID
|
|
}
|
|
if opts.NetworkID == "" {
|
|
opts.NetworkID = os.Getenv("TEST_DOCKER_NETWORK_ID")
|
|
}
|
|
if opts.ContainerName == "" {
|
|
if strings.Contains(opts.ImageRepo, "/") {
|
|
return nil, fmt.Errorf("ContainerName is required for non-library images")
|
|
}
|
|
// If there's no slash in the repo it's almost certainly going to be
|
|
// a good container name.
|
|
opts.ContainerName = opts.ImageRepo
|
|
}
|
|
return &Runner{
|
|
DockerAPI: dapi,
|
|
RunOptions: opts,
|
|
}, nil
|
|
}
|
|
|
|
type ServiceConfig interface {
|
|
Address() string
|
|
URL() *url.URL
|
|
}
|
|
|
|
func NewServiceHostPort(host string, port int) *ServiceHostPort {
|
|
return &ServiceHostPort{address: fmt.Sprintf("%s:%d", host, port)}
|
|
}
|
|
|
|
func NewServiceHostPortParse(s string) (*ServiceHostPort, error) {
|
|
pieces := strings.Split(s, ":")
|
|
if len(pieces) != 2 {
|
|
return nil, fmt.Errorf("address must be of the form host:port, got: %v", s)
|
|
}
|
|
|
|
port, err := strconv.Atoi(pieces[1])
|
|
if err != nil || port < 1 {
|
|
return nil, fmt.Errorf("address must be of the form host:port, got: %v", s)
|
|
}
|
|
|
|
return &ServiceHostPort{s}, nil
|
|
}
|
|
|
|
type ServiceHostPort struct {
|
|
address string
|
|
}
|
|
|
|
func (s ServiceHostPort) Address() string {
|
|
return s.address
|
|
}
|
|
|
|
func (s ServiceHostPort) URL() *url.URL {
|
|
return &url.URL{Host: s.address}
|
|
}
|
|
|
|
func NewServiceURLParse(s string) (*ServiceURL, error) {
|
|
u, err := url.Parse(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &ServiceURL{u: *u}, nil
|
|
}
|
|
|
|
func NewServiceURL(u url.URL) *ServiceURL {
|
|
return &ServiceURL{u: u}
|
|
}
|
|
|
|
type ServiceURL struct {
|
|
u url.URL
|
|
}
|
|
|
|
func (s ServiceURL) Address() string {
|
|
return s.u.Host
|
|
}
|
|
|
|
func (s ServiceURL) URL() *url.URL {
|
|
return &s.u
|
|
}
|
|
|
|
// ServiceAdapter verifies connectivity to the service, then returns either the
|
|
// connection string (typically a URL) and nil, or empty string and an error.
|
|
type ServiceAdapter func(ctx context.Context, host string, port int) (ServiceConfig, error)
|
|
|
|
// StartService will start the runner's configured docker container with a
|
|
// random UUID suffix appended to the name to make it unique and will return
|
|
// either a hostname or local address depending on if a Docker network was given.
|
|
//
|
|
// Most tests can default to using this.
|
|
func (d *Runner) StartService(ctx context.Context, connect ServiceAdapter) (*Service, error) {
|
|
serv, _, err := d.StartNewService(ctx, true, false, connect)
|
|
|
|
return serv, err
|
|
}
|
|
|
|
type LogConsumerWriter struct {
|
|
consumer func(string)
|
|
}
|
|
|
|
func (l LogConsumerWriter) Write(p []byte) (n int, err error) {
|
|
// TODO this assumes that we're never passed partial log lines, which
|
|
// seems a safe assumption for now based on how docker looks to implement
|
|
// logging, but might change in the future.
|
|
scanner := bufio.NewScanner(bytes.NewReader(p))
|
|
scanner.Buffer(make([]byte, 64*1024), bufio.MaxScanTokenSize)
|
|
for scanner.Scan() {
|
|
l.consumer(scanner.Text())
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
var _ io.Writer = &LogConsumerWriter{}
|
|
|
|
// StartNewService will start the runner's configured docker container but with the
|
|
// ability to control adding a name suffix or forcing a local address to be returned.
|
|
// 'addSuffix' will add a random UUID to the end of the container name.
|
|
// 'forceLocalAddr' will force the container address returned to be in the
|
|
// form of '127.0.0.1:1234' where 1234 is the mapped container port.
|
|
func (d *Runner) StartNewService(ctx context.Context, addSuffix, forceLocalAddr bool, connect ServiceAdapter) (*Service, string, error) {
|
|
if d.RunOptions.PreDelete {
|
|
name := d.RunOptions.ContainerName
|
|
matches, err := d.DockerAPI.ContainerList(ctx, types.ContainerListOptions{
|
|
All: true,
|
|
// TODO use labels to ensure we don't delete anything we shouldn't
|
|
Filters: filters.NewArgs(
|
|
filters.Arg("name", name),
|
|
),
|
|
})
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to list containers named %q", name)
|
|
}
|
|
for _, cont := range matches {
|
|
err = d.DockerAPI.ContainerRemove(ctx, cont.ID, types.ContainerRemoveOptions{Force: true})
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to pre-delete container named %q", name)
|
|
}
|
|
}
|
|
}
|
|
result, err := d.Start(context.Background(), addSuffix, forceLocalAddr)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
consumeLogs := false
|
|
var logStdout, logStderr io.Writer
|
|
if d.RunOptions.LogStdout != nil && d.RunOptions.LogStderr != nil {
|
|
consumeLogs = true
|
|
logStdout = d.RunOptions.LogStdout
|
|
logStderr = d.RunOptions.LogStderr
|
|
} else if d.RunOptions.LogConsumer != nil {
|
|
consumeLogs = true
|
|
logStdout = &LogConsumerWriter{d.RunOptions.LogConsumer}
|
|
logStderr = &LogConsumerWriter{d.RunOptions.LogConsumer}
|
|
}
|
|
|
|
// The waitgroup wg is used here to support some stuff in NewDockerCluster.
|
|
// We can't generate the PKI cert for the https listener until we know the
|
|
// container's address, meaning we must first start the container, then
|
|
// generate the cert, then copy it into the container, then signal Vault
|
|
// to reload its config/certs. However, if we SIGHUP Vault before Vault
|
|
// has installed its signal handler, that will kill Vault, since the default
|
|
// behaviour for HUP is termination. So the PostStart that NewDockerCluster
|
|
// passes in (which does all that PKI cert stuff) waits to see output from
|
|
// Vault on stdout/stderr before it sends the signal, and we don't want to
|
|
// run the PostStart until we've hooked into the docker logs.
|
|
if consumeLogs {
|
|
wg.Add(1)
|
|
go func() {
|
|
// We must run inside a goroutine because we're using Follow:true,
|
|
// and StdCopy will block until the log stream is closed.
|
|
stream, err := d.DockerAPI.ContainerLogs(context.Background(), result.Container.ID, types.ContainerLogsOptions{
|
|
ShowStdout: true,
|
|
ShowStderr: true,
|
|
Timestamps: !d.RunOptions.OmitLogTimestamps,
|
|
Details: true,
|
|
Follow: true,
|
|
})
|
|
wg.Done()
|
|
if err != nil {
|
|
d.RunOptions.LogConsumer(fmt.Sprintf("error reading container logs: %v", err))
|
|
} else {
|
|
_, err := stdcopy.StdCopy(logStdout, logStderr, stream)
|
|
if err != nil {
|
|
d.RunOptions.LogConsumer(fmt.Sprintf("error demultiplexing docker logs: %v", err))
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if d.RunOptions.PostStart != nil {
|
|
if err := d.RunOptions.PostStart(result.Container.ID, result.RealIP); err != nil {
|
|
return nil, "", fmt.Errorf("poststart failed: %w", err)
|
|
}
|
|
}
|
|
|
|
cleanup := func() {
|
|
for i := 0; i < 10; i++ {
|
|
err := d.DockerAPI.ContainerRemove(ctx, result.Container.ID, types.ContainerRemoveOptions{Force: true})
|
|
if err == nil || client.IsErrNotFound(err) {
|
|
return
|
|
}
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
}
|
|
|
|
bo := backoff.NewExponentialBackOff()
|
|
bo.MaxInterval = time.Second * 5
|
|
bo.MaxElapsedTime = 2 * time.Minute
|
|
|
|
pieces := strings.Split(result.Addrs[0], ":")
|
|
portInt, err := strconv.Atoi(pieces[1])
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
var config ServiceConfig
|
|
err = backoff.Retry(func() error {
|
|
container, err := d.DockerAPI.ContainerInspect(ctx, result.Container.ID)
|
|
if err != nil || !container.State.Running {
|
|
return backoff.Permanent(fmt.Errorf("failed inspect or container %q not running: %w", result.Container.ID, err))
|
|
}
|
|
|
|
c, err := connect(ctx, pieces[0], portInt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c == nil {
|
|
return fmt.Errorf("service adapter returned nil error and config")
|
|
}
|
|
config = c
|
|
return nil
|
|
}, bo)
|
|
|
|
if err != nil {
|
|
if !d.RunOptions.DoNotAutoRemove {
|
|
cleanup()
|
|
}
|
|
return nil, "", err
|
|
}
|
|
|
|
return &Service{
|
|
Config: config,
|
|
Cleanup: cleanup,
|
|
Container: result.Container,
|
|
StartResult: result,
|
|
}, result.Container.ID, nil
|
|
}
|
|
|
|
type Service struct {
|
|
Config ServiceConfig
|
|
Cleanup func()
|
|
Container *types.ContainerJSON
|
|
StartResult *StartResult
|
|
}
|
|
|
|
type StartResult struct {
|
|
Container *types.ContainerJSON
|
|
Addrs []string
|
|
RealIP string
|
|
}
|
|
|
|
func (d *Runner) Start(ctx context.Context, addSuffix, forceLocalAddr bool) (*StartResult, error) {
|
|
name := d.RunOptions.ContainerName
|
|
if addSuffix {
|
|
suffix, err := uuid.GenerateUUID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name += "-" + suffix
|
|
}
|
|
|
|
cfg := &container.Config{
|
|
Hostname: name,
|
|
Image: fmt.Sprintf("%s:%s", d.RunOptions.ImageRepo, d.RunOptions.ImageTag),
|
|
Env: d.RunOptions.Env,
|
|
Cmd: d.RunOptions.Cmd,
|
|
}
|
|
if len(d.RunOptions.Ports) > 0 {
|
|
cfg.ExposedPorts = make(map[nat.Port]struct{})
|
|
for _, p := range d.RunOptions.Ports {
|
|
cfg.ExposedPorts[nat.Port(p)] = struct{}{}
|
|
}
|
|
}
|
|
if len(d.RunOptions.Entrypoint) > 0 {
|
|
cfg.Entrypoint = strslice.StrSlice(d.RunOptions.Entrypoint)
|
|
}
|
|
|
|
hostConfig := &container.HostConfig{
|
|
AutoRemove: !d.RunOptions.DoNotAutoRemove,
|
|
PublishAllPorts: true,
|
|
}
|
|
if len(d.RunOptions.Capabilities) > 0 {
|
|
hostConfig.CapAdd = d.RunOptions.Capabilities
|
|
}
|
|
|
|
netConfig := &network.NetworkingConfig{}
|
|
if d.RunOptions.NetworkID != "" {
|
|
netConfig.EndpointsConfig = map[string]*network.EndpointSettings{
|
|
d.RunOptions.NetworkID: {},
|
|
}
|
|
}
|
|
|
|
// best-effort pull
|
|
var opts types.ImageCreateOptions
|
|
if d.RunOptions.AuthUsername != "" && d.RunOptions.AuthPassword != "" {
|
|
var buf bytes.Buffer
|
|
auth := map[string]string{
|
|
"username": d.RunOptions.AuthUsername,
|
|
"password": d.RunOptions.AuthPassword,
|
|
}
|
|
if err := json.NewEncoder(&buf).Encode(auth); err != nil {
|
|
return nil, err
|
|
}
|
|
opts.RegistryAuth = base64.URLEncoding.EncodeToString(buf.Bytes())
|
|
}
|
|
resp, _ := d.DockerAPI.ImageCreate(ctx, cfg.Image, opts)
|
|
if resp != nil {
|
|
_, _ = ioutil.ReadAll(resp)
|
|
}
|
|
|
|
for vol, mtpt := range d.RunOptions.VolumeNameToMountPoint {
|
|
hostConfig.Mounts = append(hostConfig.Mounts, mount.Mount{
|
|
Type: "volume",
|
|
Source: vol,
|
|
Target: mtpt,
|
|
ReadOnly: false,
|
|
})
|
|
}
|
|
|
|
c, err := d.DockerAPI.ContainerCreate(ctx, cfg, hostConfig, netConfig, nil, cfg.Hostname)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("container create failed: %v", err)
|
|
}
|
|
|
|
for from, to := range d.RunOptions.CopyFromTo {
|
|
if err := copyToContainer(ctx, d.DockerAPI, c.ID, from, to); err != nil {
|
|
_ = d.DockerAPI.ContainerRemove(ctx, c.ID, types.ContainerRemoveOptions{})
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
err = d.DockerAPI.ContainerStart(ctx, c.ID, types.ContainerStartOptions{})
|
|
if err != nil {
|
|
_ = d.DockerAPI.ContainerRemove(ctx, c.ID, types.ContainerRemoveOptions{})
|
|
return nil, fmt.Errorf("container start failed: %v", err)
|
|
}
|
|
|
|
inspect, err := d.DockerAPI.ContainerInspect(ctx, c.ID)
|
|
if err != nil {
|
|
_ = d.DockerAPI.ContainerRemove(ctx, c.ID, types.ContainerRemoveOptions{})
|
|
return nil, err
|
|
}
|
|
|
|
var addrs []string
|
|
for _, port := range d.RunOptions.Ports {
|
|
pieces := strings.Split(port, "/")
|
|
if len(pieces) < 2 {
|
|
return nil, fmt.Errorf("expected port of the form 1234/tcp, got: %s", port)
|
|
}
|
|
if d.RunOptions.NetworkID != "" && !forceLocalAddr {
|
|
addrs = append(addrs, fmt.Sprintf("%s:%s", cfg.Hostname, pieces[0]))
|
|
} else {
|
|
mapped, ok := inspect.NetworkSettings.Ports[nat.Port(port)]
|
|
if !ok || len(mapped) == 0 {
|
|
return nil, fmt.Errorf("no port mapping found for %s", port)
|
|
}
|
|
addrs = append(addrs, fmt.Sprintf("127.0.0.1:%s", mapped[0].HostPort))
|
|
}
|
|
}
|
|
|
|
var realIP string
|
|
if d.RunOptions.NetworkID == "" {
|
|
if len(inspect.NetworkSettings.Networks) > 1 {
|
|
return nil, fmt.Errorf("Set d.RunOptions.NetworkName instead for container with multiple networks: %v", inspect.NetworkSettings.Networks)
|
|
}
|
|
for _, network := range inspect.NetworkSettings.Networks {
|
|
realIP = network.IPAddress
|
|
break
|
|
}
|
|
} else {
|
|
realIP = inspect.NetworkSettings.Networks[d.RunOptions.NetworkName].IPAddress
|
|
}
|
|
|
|
return &StartResult{
|
|
Container: &inspect,
|
|
Addrs: addrs,
|
|
RealIP: realIP,
|
|
}, nil
|
|
}
|
|
|
|
func (d *Runner) RefreshFiles(ctx context.Context, containerID string) error {
|
|
for from, to := range d.RunOptions.CopyFromTo {
|
|
if err := copyToContainer(ctx, d.DockerAPI, containerID, from, to); err != nil {
|
|
// TODO too drastic?
|
|
_ = d.DockerAPI.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{})
|
|
return err
|
|
}
|
|
}
|
|
return d.DockerAPI.ContainerKill(ctx, containerID, "SIGHUP")
|
|
}
|
|
|
|
func (d *Runner) Stop(ctx context.Context, containerID string) error {
|
|
if d.RunOptions.NetworkID != "" {
|
|
if err := d.DockerAPI.NetworkDisconnect(ctx, d.RunOptions.NetworkID, containerID, true); err != nil {
|
|
return fmt.Errorf("error disconnecting network (%v): %v", d.RunOptions.NetworkID, err)
|
|
}
|
|
}
|
|
|
|
// timeout in seconds
|
|
timeout := 5
|
|
options := container.StopOptions{
|
|
Timeout: &timeout,
|
|
}
|
|
if err := d.DockerAPI.ContainerStop(ctx, containerID, options); err != nil {
|
|
return fmt.Errorf("error stopping container: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *Runner) Restart(ctx context.Context, containerID string) error {
|
|
if err := d.DockerAPI.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil {
|
|
return err
|
|
}
|
|
|
|
ends := &network.EndpointSettings{
|
|
NetworkID: d.RunOptions.NetworkID,
|
|
}
|
|
|
|
return d.DockerAPI.NetworkConnect(ctx, d.RunOptions.NetworkID, containerID, ends)
|
|
}
|
|
|
|
func copyToContainer(ctx context.Context, dapi *client.Client, containerID, from, to string) error {
|
|
srcInfo, err := archive.CopyInfoSourcePath(from, false)
|
|
if err != nil {
|
|
return fmt.Errorf("error copying from source %q: %v", from, err)
|
|
}
|
|
|
|
srcArchive, err := archive.TarResource(srcInfo)
|
|
if err != nil {
|
|
return fmt.Errorf("error creating tar from source %q: %v", from, err)
|
|
}
|
|
defer srcArchive.Close()
|
|
|
|
dstInfo := archive.CopyInfo{Path: to}
|
|
|
|
dstDir, content, err := archive.PrepareArchiveCopy(srcArchive, srcInfo, dstInfo)
|
|
if err != nil {
|
|
return fmt.Errorf("error preparing copy from %q -> %q: %v", from, to, err)
|
|
}
|
|
defer content.Close()
|
|
err = dapi.CopyToContainer(ctx, containerID, dstDir, content, types.CopyToContainerOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("error copying from %q -> %q: %v", from, to, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type RunCmdOpt interface {
|
|
Apply(cfg *types.ExecConfig) error
|
|
}
|
|
|
|
type RunCmdUser string
|
|
|
|
var _ RunCmdOpt = (*RunCmdUser)(nil)
|
|
|
|
func (u RunCmdUser) Apply(cfg *types.ExecConfig) error {
|
|
cfg.User = string(u)
|
|
return nil
|
|
}
|
|
|
|
func (d *Runner) RunCmdWithOutput(ctx context.Context, container string, cmd []string, opts ...RunCmdOpt) ([]byte, []byte, int, error) {
|
|
return RunCmdWithOutput(d.DockerAPI, ctx, container, cmd, opts...)
|
|
}
|
|
|
|
func RunCmdWithOutput(api *client.Client, ctx context.Context, container string, cmd []string, opts ...RunCmdOpt) ([]byte, []byte, int, error) {
|
|
runCfg := types.ExecConfig{
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
Cmd: cmd,
|
|
}
|
|
|
|
for index, opt := range opts {
|
|
if err := opt.Apply(&runCfg); err != nil {
|
|
return nil, nil, -1, fmt.Errorf("error applying option (%d / %v): %w", index, opt, err)
|
|
}
|
|
}
|
|
|
|
ret, err := api.ContainerExecCreate(ctx, container, runCfg)
|
|
if err != nil {
|
|
return nil, nil, -1, fmt.Errorf("error creating execution environment: %v\ncfg: %v\n", err, runCfg)
|
|
}
|
|
|
|
resp, err := api.ContainerExecAttach(ctx, ret.ID, types.ExecStartCheck{})
|
|
if err != nil {
|
|
return nil, nil, -1, fmt.Errorf("error attaching to command execution: %v\ncfg: %v\nret: %v\n", err, runCfg, ret)
|
|
}
|
|
defer resp.Close()
|
|
|
|
var stdoutB bytes.Buffer
|
|
var stderrB bytes.Buffer
|
|
if _, err := stdcopy.StdCopy(&stdoutB, &stderrB, resp.Reader); err != nil {
|
|
return nil, nil, -1, fmt.Errorf("error reading command output: %v", err)
|
|
}
|
|
|
|
stdout := stdoutB.Bytes()
|
|
stderr := stderrB.Bytes()
|
|
|
|
// Fetch return code.
|
|
info, err := api.ContainerExecInspect(ctx, ret.ID)
|
|
if err != nil {
|
|
return stdout, stderr, -1, fmt.Errorf("error reading command exit code: %v", err)
|
|
}
|
|
|
|
return stdout, stderr, info.ExitCode, nil
|
|
}
|
|
|
|
func (d *Runner) RunCmdInBackground(ctx context.Context, container string, cmd []string, opts ...RunCmdOpt) (string, error) {
|
|
return RunCmdInBackground(d.DockerAPI, ctx, container, cmd, opts...)
|
|
}
|
|
|
|
func RunCmdInBackground(api *client.Client, ctx context.Context, container string, cmd []string, opts ...RunCmdOpt) (string, error) {
|
|
runCfg := types.ExecConfig{
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
Cmd: cmd,
|
|
}
|
|
|
|
for index, opt := range opts {
|
|
if err := opt.Apply(&runCfg); err != nil {
|
|
return "", fmt.Errorf("error applying option (%d / %v): %w", index, opt, err)
|
|
}
|
|
}
|
|
|
|
ret, err := api.ContainerExecCreate(ctx, container, runCfg)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error creating execution environment: %w\ncfg: %v\n", err, runCfg)
|
|
}
|
|
|
|
err = api.ContainerExecStart(ctx, ret.ID, types.ExecStartCheck{})
|
|
if err != nil {
|
|
return "", fmt.Errorf("error starting command execution: %w\ncfg: %v\nret: %v\n", err, runCfg, ret)
|
|
}
|
|
|
|
return ret.ID, nil
|
|
}
|
|
|
|
// Mapping of path->contents
|
|
type PathContents interface {
|
|
UpdateHeader(header *tar.Header) error
|
|
Get() ([]byte, error)
|
|
SetMode(mode int64)
|
|
SetOwners(uid int, gid int)
|
|
}
|
|
|
|
type FileContents struct {
|
|
Data []byte
|
|
Mode int64
|
|
UID int
|
|
GID int
|
|
}
|
|
|
|
func (b FileContents) UpdateHeader(header *tar.Header) error {
|
|
header.Mode = b.Mode
|
|
header.Uid = b.UID
|
|
header.Gid = b.GID
|
|
return nil
|
|
}
|
|
|
|
func (b FileContents) Get() ([]byte, error) {
|
|
return b.Data, nil
|
|
}
|
|
|
|
func (b *FileContents) SetMode(mode int64) {
|
|
b.Mode = mode
|
|
}
|
|
|
|
func (b *FileContents) SetOwners(uid int, gid int) {
|
|
b.UID = uid
|
|
b.GID = gid
|
|
}
|
|
|
|
func PathContentsFromBytes(data []byte) PathContents {
|
|
return &FileContents{
|
|
Data: data,
|
|
Mode: 0o644,
|
|
}
|
|
}
|
|
|
|
func PathContentsFromString(data string) PathContents {
|
|
return PathContentsFromBytes([]byte(data))
|
|
}
|
|
|
|
type BuildContext map[string]PathContents
|
|
|
|
func NewBuildContext() BuildContext {
|
|
return BuildContext{}
|
|
}
|
|
|
|
func BuildContextFromTarball(reader io.Reader) (BuildContext, error) {
|
|
archive := tar.NewReader(reader)
|
|
bCtx := NewBuildContext()
|
|
|
|
for true {
|
|
header, err := archive.Next()
|
|
if err != nil {
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to parse provided tarball: %v", err)
|
|
}
|
|
|
|
data := make([]byte, int(header.Size))
|
|
read, err := archive.Read(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse read from provided tarball: %v", err)
|
|
}
|
|
|
|
if read != int(header.Size) {
|
|
return nil, fmt.Errorf("unexpectedly short read on tarball: %v of %v", read, header.Size)
|
|
}
|
|
|
|
bCtx[header.Name] = &FileContents{
|
|
Data: data,
|
|
Mode: header.Mode,
|
|
UID: header.Uid,
|
|
GID: header.Gid,
|
|
}
|
|
}
|
|
|
|
return bCtx, nil
|
|
}
|
|
|
|
func (bCtx *BuildContext) ToTarball() (io.Reader, error) {
|
|
var err error
|
|
buffer := new(bytes.Buffer)
|
|
tarBuilder := tar.NewWriter(buffer)
|
|
defer tarBuilder.Close()
|
|
|
|
now := time.Now()
|
|
for filepath, contents := range *bCtx {
|
|
fileHeader := &tar.Header{
|
|
Name: filepath,
|
|
ModTime: now,
|
|
AccessTime: now,
|
|
ChangeTime: now,
|
|
}
|
|
if contents == nil && !strings.HasSuffix(filepath, "/") {
|
|
return nil, fmt.Errorf("expected file path (%v) to have trailing / due to nil contents, indicating directory", filepath)
|
|
}
|
|
|
|
if err := contents.UpdateHeader(fileHeader); err != nil {
|
|
return nil, fmt.Errorf("failed to update tar header entry for %v: %w", filepath, err)
|
|
}
|
|
|
|
var rawContents []byte
|
|
if contents != nil {
|
|
rawContents, err = contents.Get()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get file contents for %v: %w", filepath, err)
|
|
}
|
|
|
|
fileHeader.Size = int64(len(rawContents))
|
|
}
|
|
|
|
if err := tarBuilder.WriteHeader(fileHeader); err != nil {
|
|
return nil, fmt.Errorf("failed to write tar header entry for %v: %w", filepath, err)
|
|
}
|
|
|
|
if contents != nil {
|
|
if _, err := tarBuilder.Write(rawContents); err != nil {
|
|
return nil, fmt.Errorf("failed to write tar file entry for %v: %w", filepath, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return bytes.NewReader(buffer.Bytes()), nil
|
|
}
|
|
|
|
type BuildOpt interface {
|
|
Apply(cfg *types.ImageBuildOptions) error
|
|
}
|
|
|
|
type BuildRemove bool
|
|
|
|
var _ BuildOpt = (*BuildRemove)(nil)
|
|
|
|
func (u BuildRemove) Apply(cfg *types.ImageBuildOptions) error {
|
|
cfg.Remove = bool(u)
|
|
return nil
|
|
}
|
|
|
|
type BuildForceRemove bool
|
|
|
|
var _ BuildOpt = (*BuildForceRemove)(nil)
|
|
|
|
func (u BuildForceRemove) Apply(cfg *types.ImageBuildOptions) error {
|
|
cfg.ForceRemove = bool(u)
|
|
return nil
|
|
}
|
|
|
|
type BuildPullParent bool
|
|
|
|
var _ BuildOpt = (*BuildPullParent)(nil)
|
|
|
|
func (u BuildPullParent) Apply(cfg *types.ImageBuildOptions) error {
|
|
cfg.PullParent = bool(u)
|
|
return nil
|
|
}
|
|
|
|
type BuildArgs map[string]*string
|
|
|
|
var _ BuildOpt = (*BuildArgs)(nil)
|
|
|
|
func (u BuildArgs) Apply(cfg *types.ImageBuildOptions) error {
|
|
cfg.BuildArgs = u
|
|
return nil
|
|
}
|
|
|
|
type BuildTags []string
|
|
|
|
var _ BuildOpt = (*BuildTags)(nil)
|
|
|
|
func (u BuildTags) Apply(cfg *types.ImageBuildOptions) error {
|
|
cfg.Tags = u
|
|
return nil
|
|
}
|
|
|
|
const containerfilePath = "_containerfile"
|
|
|
|
func (d *Runner) BuildImage(ctx context.Context, containerfile string, containerContext BuildContext, opts ...BuildOpt) ([]byte, error) {
|
|
return BuildImage(ctx, d.DockerAPI, containerfile, containerContext, opts...)
|
|
}
|
|
|
|
func BuildImage(ctx context.Context, api *client.Client, containerfile string, containerContext BuildContext, opts ...BuildOpt) ([]byte, error) {
|
|
var cfg types.ImageBuildOptions
|
|
|
|
// Build container context tarball, provisioning containerfile in.
|
|
containerContext[containerfilePath] = PathContentsFromBytes([]byte(containerfile))
|
|
tar, err := containerContext.ToTarball()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create build image context tarball: %w", err)
|
|
}
|
|
cfg.Dockerfile = "/" + containerfilePath
|
|
|
|
// Apply all given options
|
|
for index, opt := range opts {
|
|
if err := opt.Apply(&cfg); err != nil {
|
|
return nil, fmt.Errorf("failed to apply option (%d / %v): %w", index, opt, err)
|
|
}
|
|
}
|
|
|
|
resp, err := api.ImageBuild(ctx, tar, cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build image: %v", err)
|
|
}
|
|
|
|
output, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read image build output: %w", err)
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
func (d *Runner) CopyTo(container string, destination string, contents BuildContext) error {
|
|
// XXX: currently we use the default options but we might want to allow
|
|
// modifying cfg.CopyUIDGID in the future.
|
|
var cfg types.CopyToContainerOptions
|
|
|
|
// Convert our provided contents to a tarball to ship up.
|
|
tar, err := contents.ToTarball()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build contents into tarball: %v", err)
|
|
}
|
|
|
|
return d.DockerAPI.CopyToContainer(context.Background(), container, destination, tar, cfg)
|
|
}
|
|
|
|
func (d *Runner) CopyFrom(container string, source string) (BuildContext, *types.ContainerPathStat, error) {
|
|
reader, stat, err := d.DockerAPI.CopyFromContainer(context.Background(), container, source)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to read %v from container: %v", source, err)
|
|
}
|
|
|
|
result, err := BuildContextFromTarball(reader)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to build archive from result: %v", err)
|
|
}
|
|
|
|
return result, &stat, nil
|
|
}
|
|
|
|
func (d *Runner) GetNetworkAndAddresses(container string) (map[string]string, error) {
|
|
response, err := d.DockerAPI.ContainerInspect(context.Background(), container)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch container inspection data: %v", err)
|
|
}
|
|
|
|
if response.NetworkSettings == nil || len(response.NetworkSettings.Networks) == 0 {
|
|
return nil, fmt.Errorf("container (%v) had no associated network settings: %v", container, response)
|
|
}
|
|
|
|
ret := make(map[string]string)
|
|
ns := response.NetworkSettings.Networks
|
|
for network, data := range ns {
|
|
if data == nil {
|
|
continue
|
|
}
|
|
|
|
ret[network] = data.IPAddress
|
|
}
|
|
|
|
if len(ret) == 0 {
|
|
return nil, fmt.Errorf("no valid network data for container (%v): %v", container, response)
|
|
}
|
|
|
|
return ret, nil
|
|
}
|