github.com/grahambrereton-form3/tilt@v0.10.18/internal/docker/client.go (about)

     1  package docker
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"io/ioutil"
     8  	"net"
     9  	"net/http"
    10  	"os"
    11  	"path/filepath"
    12  	"strconv"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/blang/semver"
    17  	"github.com/docker/cli/cli/command"
    18  	"github.com/docker/cli/cli/config"
    19  	cliflags "github.com/docker/cli/cli/flags"
    20  	"github.com/docker/distribution/reference"
    21  	"github.com/docker/docker/api/types"
    22  	"github.com/docker/docker/api/types/filters"
    23  	"github.com/docker/docker/client"
    24  	"github.com/docker/docker/registry"
    25  	"github.com/docker/go-connections/tlsconfig"
    26  	"github.com/moby/buildkit/identity"
    27  	"github.com/moby/buildkit/session"
    28  	"github.com/moby/buildkit/session/auth/authprovider"
    29  	"github.com/opentracing/opentracing-go"
    30  	"github.com/pkg/errors"
    31  
    32  	"github.com/windmilleng/tilt/internal/container"
    33  	"github.com/windmilleng/tilt/pkg/logger"
    34  	"github.com/windmilleng/tilt/pkg/model"
    35  )
    36  
    37  // Label that we attach to all of the images we build.
    38  const (
    39  	BuiltByLabel = "builtby"
    40  	BuiltByValue = "tilt"
    41  )
    42  
    43  var (
    44  	BuiltByTiltLabel    = map[string]string{BuiltByLabel: BuiltByValue}
    45  	BuiltByTiltLabelStr = fmt.Sprintf("%s=%s", BuiltByLabel, BuiltByValue)
    46  )
    47  
    48  // Version info
    49  // https://docs.docker.com/develop/sdk/#api-version-matrix
    50  //
    51  // The docker API docs highly recommend we set a default version,
    52  // so that new versions don't break us.
    53  const defaultVersion = "1.39"
    54  
    55  // Minimum docker version we've tested on.
    56  // A good way to test old versions is to connect to an old version of Minikube,
    57  // so that we connect to the docker server in minikube instead of futzing with
    58  // the docker version on your machine.
    59  // https://github.com/kubernetes/minikube/releases/tag/v0.13.1
    60  var minDockerVersion = semver.MustParse("1.23.0")
    61  
    62  var minDockerVersionStableBuildkit = semver.MustParse("1.39.0")
    63  var minDockerVersionExperimentalBuildkit = semver.MustParse("1.38.0")
    64  
    65  // microk8s exposes its own docker socket
    66  // https://github.com/ubuntu/microk8s/blob/master/docs/dockerd.md
    67  const microK8sDockerHost = "unix:///var/snap/microk8s/current/docker.sock"
    68  
    69  // generate a session key
    70  var sessionSharedKey = identity.NewID()
    71  
    72  // Create an interface so this can be mocked out.
    73  type Client interface {
    74  	CheckConnected() error
    75  
    76  	// If you'd like to call this Docker instance in a separate process, these
    77  	// are the environment variables you'll need to do so.
    78  	Env() Env
    79  
    80  	// If you'd like to call this Docker instance in a separate process, this
    81  	// is the default builder version you want (buildkit or legacy)
    82  	BuilderVersion() types.BuilderVersion
    83  
    84  	ServerVersion() types.Version
    85  
    86  	// Set the orchestrator we're talking to. This is only relevant to switchClient,
    87  	// which can talk to either the Local or in-cluster docker daemon.
    88  	SetOrchestrator(orc model.Orchestrator)
    89  
    90  	ContainerList(ctx context.Context, options types.ContainerListOptions) ([]types.Container, error)
    91  	ContainerRestartNoWait(ctx context.Context, containerID string) error
    92  	CopyToContainerRoot(ctx context.Context, container string, content io.Reader) error
    93  
    94  	// Execute a command in a container, streaming the command output to `out`.
    95  	// Returns an ExitError if the command exits with a non-zero exit code.
    96  	ExecInContainer(ctx context.Context, cID container.ID, cmd model.Cmd, out io.Writer) error
    97  
    98  	ImagePush(ctx context.Context, image reference.NamedTagged) (io.ReadCloser, error)
    99  	ImageBuild(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error)
   100  	ImageTag(ctx context.Context, source, target string) error
   101  	ImageInspectWithRaw(ctx context.Context, imageID string) (types.ImageInspect, []byte, error)
   102  	ImageList(ctx context.Context, options types.ImageListOptions) ([]types.ImageSummary, error)
   103  	ImageRemove(ctx context.Context, imageID string, options types.ImageRemoveOptions) ([]types.ImageDeleteResponseItem, error)
   104  
   105  	NewVersionError(APIrequired, feature string) error
   106  	BuildCachePrune(ctx context.Context, opts types.BuildCachePruneOptions) (*types.BuildCachePruneReport, error)
   107  	ContainersPrune(ctx context.Context, pruneFilters filters.Args) (types.ContainersPruneReport, error)
   108  }
   109  
   110  type ExitError struct {
   111  	ExitCode int
   112  }
   113  
   114  func (e ExitError) Error() string {
   115  	return fmt.Sprintf("Exec command exited with status code: %d", e.ExitCode)
   116  }
   117  
   118  func IsExitError(err error) bool {
   119  	_, ok := err.(ExitError)
   120  	return ok
   121  }
   122  
   123  var _ error = ExitError{}
   124  
   125  var _ Client = &Cli{}
   126  
   127  type Cli struct {
   128  	*client.Client
   129  	builderVersion types.BuilderVersion
   130  	serverVersion  types.Version
   131  
   132  	creds     dockerCreds
   133  	initError error
   134  	initDone  chan bool
   135  	env       Env
   136  }
   137  
   138  func NewDockerClient(ctx context.Context, env Env) Client {
   139  	opts, err := CreateClientOpts(ctx, env)
   140  	if err != nil {
   141  		return newExplodingClient(err)
   142  	}
   143  	d, err := client.NewClientWithOpts(opts...)
   144  	if err != nil {
   145  		return newExplodingClient(err)
   146  	}
   147  
   148  	serverVersion, err := d.ServerVersion(ctx)
   149  	if err != nil {
   150  		return newExplodingClient(err)
   151  	}
   152  
   153  	if !SupportedVersion(serverVersion) {
   154  		return newExplodingClient(
   155  			fmt.Errorf("Tilt requires a Docker server newer than %s. Current Docker server: %s",
   156  				minDockerVersion, serverVersion.APIVersion))
   157  	}
   158  
   159  	builderVersion, err := getDockerBuilderVersion(serverVersion, env)
   160  	if err != nil {
   161  		return newExplodingClient(err)
   162  	}
   163  
   164  	cli := &Cli{
   165  		Client:         d,
   166  		env:            env,
   167  		builderVersion: builderVersion,
   168  		serverVersion:  serverVersion,
   169  		initDone:       make(chan bool),
   170  	}
   171  
   172  	go cli.backgroundInit(ctx)
   173  
   174  	return cli
   175  }
   176  
   177  func SupportedVersion(v types.Version) bool {
   178  	version, err := semver.ParseTolerant(v.APIVersion)
   179  	if err != nil {
   180  		// If the server version doesn't parse, we shouldn't even start
   181  		return false
   182  	}
   183  
   184  	return version.GTE(minDockerVersion)
   185  }
   186  
   187  func getDockerBuilderVersion(v types.Version, env Env) (types.BuilderVersion, error) {
   188  	// If the user has explicitly chosen to enable/disable buildkit, respect that.
   189  	buildkitEnv := os.Getenv("DOCKER_BUILDKIT")
   190  	if buildkitEnv != "" {
   191  		buildkitEnabled, err := strconv.ParseBool(buildkitEnv)
   192  		if err != nil {
   193  			// This error message is copied from Docker, for consistency.
   194  			return "", errors.Wrap(err, "DOCKER_BUILDKIT environment variable expects boolean value")
   195  		}
   196  		if buildkitEnabled && SupportsBuildkit(v, env) {
   197  			return types.BuilderBuildKit, nil
   198  
   199  		}
   200  		return types.BuilderV1, nil
   201  	}
   202  
   203  	if SupportsBuildkit(v, env) {
   204  		return types.BuilderBuildKit, nil
   205  	}
   206  	return types.BuilderV1, nil
   207  }
   208  
   209  // Sadly, certain versions of docker return an error if the client requests
   210  // buildkit. We have to infer whether it supports buildkit from version numbers.
   211  //
   212  // Inferred from release notes
   213  // https://docs.docker.com/engine/release-notes/
   214  func SupportsBuildkit(v types.Version, env Env) bool {
   215  	if env.IsMinikube {
   216  		// Buildkit for Minikube is currently busted. Follow
   217  		// https://github.com/kubernetes/minikube/issues/4143
   218  		// for updates.
   219  		return false
   220  	}
   221  
   222  	version, err := semver.ParseTolerant(v.APIVersion)
   223  	if err != nil {
   224  		// If the server version doesn't parse, disable buildkit
   225  		return false
   226  	}
   227  
   228  	if minDockerVersionStableBuildkit.LTE(version) {
   229  		return true
   230  	}
   231  
   232  	if minDockerVersionExperimentalBuildkit.LTE(version) && v.Experimental {
   233  		return true
   234  	}
   235  
   236  	return false
   237  }
   238  
   239  // Adapted from client.FromEnv
   240  //
   241  // Supported environment variables:
   242  // DOCKER_HOST to set the url to the docker server.
   243  // DOCKER_API_VERSION to set the version of the API to reach, leave empty for latest.
   244  // DOCKER_CERT_PATH to load the TLS certificates from.
   245  // DOCKER_TLS_VERIFY to enable or disable TLS verification, off by default.
   246  func CreateClientOpts(ctx context.Context, env Env) ([]client.Opt, error) {
   247  	result := make([]client.Opt, 0)
   248  
   249  	if env.CertPath != "" {
   250  		options := tlsconfig.Options{
   251  			CAFile:             filepath.Join(env.CertPath, "ca.pem"),
   252  			CertFile:           filepath.Join(env.CertPath, "cert.pem"),
   253  			KeyFile:            filepath.Join(env.CertPath, "key.pem"),
   254  			InsecureSkipVerify: env.TLSVerify == "",
   255  		}
   256  		tlsc, err := tlsconfig.Client(options)
   257  		if err != nil {
   258  			return nil, err
   259  		}
   260  
   261  		result = append(result, client.WithHTTPClient(&http.Client{
   262  			Transport:     &http.Transport{TLSClientConfig: tlsc},
   263  			CheckRedirect: client.CheckRedirect,
   264  		}))
   265  	}
   266  
   267  	if env.Host != "" {
   268  		result = append(result, client.WithHost(env.Host))
   269  	}
   270  
   271  	if env.APIVersion != "" {
   272  		result = append(result, client.WithVersion(env.APIVersion))
   273  	} else {
   274  		// WithAPIVersionNegotiation makes the Docker client negotiate down to a lower
   275  		// version if Docker's current API version is newer than the server version.
   276  		result = append(result, client.WithAPIVersionNegotiation())
   277  	}
   278  
   279  	return result, nil
   280  }
   281  
   282  type dockerCreds struct {
   283  	authConfigs map[string]types.AuthConfig
   284  	sessionID   string
   285  }
   286  
   287  // When we pull from a private docker registry, we have to get credentials
   288  // from somewhere. These credentials are not stored on the server. The client
   289  // is responsible for managing them.
   290  //
   291  // Docker uses two different protocols:
   292  // 1) In the legacy build engine, you have to get all the creds ahead of time
   293  //    and pass them in the ImageBuild call.
   294  // 2) In BuildKit, you have to create a persistent session. The client
   295  //    side of the session manages a miniature server that just responds
   296  //    to credential requests as the server asks for them.
   297  //
   298  // Protocol (1) is very slow. If you're using the gcloud credential store,
   299  // fetching all the creds ahead of time can take ~3 seconds.
   300  // Protocol (2) is more efficient, but also more complex to manage.
   301  func (c *Cli) initCreds(ctx context.Context) dockerCreds {
   302  	creds := dockerCreds{}
   303  
   304  	if c.builderVersion == types.BuilderBuildKit {
   305  		session, _ := session.NewSession(ctx, "tilt", sessionSharedKey)
   306  		if session != nil {
   307  			session.Allow(authprovider.NewDockerAuthProvider(logger.Get(ctx).Writer(logger.InfoLvl)))
   308  			go func() {
   309  				defer func() {
   310  					_ = session.Close()
   311  				}()
   312  
   313  				// Start the server
   314  				dialSession := func(ctx context.Context, proto string, meta map[string][]string) (net.Conn, error) {
   315  					return c.Client.DialHijack(ctx, "/session", proto, meta)
   316  				}
   317  				_ = session.Run(ctx, dialSession)
   318  			}()
   319  			creds.sessionID = session.ID()
   320  		}
   321  	} else {
   322  		configFile := config.LoadDefaultConfigFile(ioutil.Discard)
   323  
   324  		// If we fail to get credentials for some reason, that's OK.
   325  		// even the docker CLI ignores this:
   326  		// https://github.com/docker/cli/blob/23446275646041f9b598d64c51be24d5d0e49376/cli/command/image/build.go#L386
   327  		credentials, _ := configFile.GetAllCredentials()
   328  		authConfigs := make(map[string]types.AuthConfig, len(credentials))
   329  		for k, auth := range credentials {
   330  			authConfigs[k] = types.AuthConfig(auth)
   331  		}
   332  		creds.authConfigs = authConfigs
   333  	}
   334  
   335  	return creds
   336  }
   337  
   338  // Initialization that we do in the background, because
   339  // it may need to read from files or call out to gcloud.
   340  //
   341  // TODO(nick): Update ImagePush to use these auth credentials. This is less important
   342  // for local k8s (Minikube, Docker-for-Mac, MicroK8s) because they don't push.
   343  func (c *Cli) backgroundInit(ctx context.Context) {
   344  	result := make(chan dockerCreds, 1)
   345  
   346  	go func() {
   347  		result <- c.initCreds(ctx)
   348  	}()
   349  
   350  	select {
   351  	case creds := <-result:
   352  		c.creds = creds
   353  	case <-time.After(10 * time.Second):
   354  		// TODO(nick): If we move logging before the wire() call, we should
   355  		// print here instead of logging indirectly
   356  		c.initError = fmt.Errorf("Timeout fetching docker auth credentials")
   357  	}
   358  
   359  	close(c.initDone)
   360  }
   361  
   362  func (c *Cli) CheckConnected() error                  { return nil }
   363  func (c *Cli) SetOrchestrator(orc model.Orchestrator) {}
   364  func (c *Cli) Env() Env {
   365  	return c.env
   366  }
   367  
   368  func (c *Cli) BuilderVersion() types.BuilderVersion {
   369  	return c.builderVersion
   370  }
   371  
   372  func (c *Cli) ServerVersion() types.Version {
   373  	return c.serverVersion
   374  }
   375  
   376  func (c *Cli) ImagePush(ctx context.Context, ref reference.NamedTagged) (io.ReadCloser, error) {
   377  	<-c.initDone
   378  
   379  	if c.initError != nil {
   380  		logger.Get(ctx).Verbosef("%v", c.initError)
   381  	}
   382  
   383  	repoInfo, err := registry.ParseRepositoryInfo(ref)
   384  	if err != nil {
   385  		return nil, errors.Wrap(err, "ImagePush#ParseRepositoryInfo")
   386  	}
   387  
   388  	logger.Get(ctx).Infof("Authenticating to image repo: %s", repoInfo.Index.Name)
   389  	infoWriter := logger.Get(ctx).Writer(logger.InfoLvl)
   390  	cli, err := command.NewDockerCli(
   391  		command.WithCombinedStreams(infoWriter),
   392  		command.WithContentTrust(true),
   393  	)
   394  	if err != nil {
   395  		return nil, errors.Wrap(err, "ImagePush#NewDockerCli")
   396  	}
   397  
   398  	err = cli.Initialize(cliflags.NewClientOptions())
   399  	if err != nil {
   400  		return nil, errors.Wrap(err, "ImagePush#InitializeCLI")
   401  	}
   402  	authConfig := command.ResolveAuthConfig(ctx, cli, repoInfo.Index)
   403  	requestPrivilege := command.RegistryAuthenticationPrivilegedFunc(cli, repoInfo.Index, "push")
   404  
   405  	encodedAuth, err := command.EncodeAuthToBase64(authConfig)
   406  	if err != nil {
   407  		return nil, errors.Wrap(err, "ImagePush#EncodeAuthToBase64")
   408  	}
   409  
   410  	options := types.ImagePushOptions{
   411  		RegistryAuth:  encodedAuth,
   412  		PrivilegeFunc: requestPrivilege,
   413  	}
   414  
   415  	if reference.Domain(ref) == "" {
   416  		return nil, errors.Wrap(err, "ImagePush: no domain in container name")
   417  	}
   418  	logger.Get(ctx).Infof("Sending image data")
   419  	return c.Client.ImagePush(ctx, ref.String(), options)
   420  }
   421  
   422  func (c *Cli) ImageBuild(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
   423  	<-c.initDone
   424  
   425  	if c.initError != nil {
   426  		logger.Get(ctx).Verbosef("%v", c.initError)
   427  	}
   428  
   429  	opts := types.ImageBuildOptions{}
   430  	opts.Version = c.builderVersion
   431  	opts.AuthConfigs = c.creds.authConfigs
   432  	opts.SessionID = c.creds.sessionID
   433  	opts.Remove = options.Remove
   434  	opts.Context = options.Context
   435  	opts.BuildArgs = options.BuildArgs
   436  	opts.Dockerfile = options.Dockerfile
   437  	opts.Tags = options.Tags
   438  	opts.Target = options.Target
   439  
   440  	opts.Labels = BuiltByTiltLabel // label all images as built by us
   441  
   442  	return c.Client.ImageBuild(ctx, buildContext, opts)
   443  }
   444  
   445  func (c *Cli) CopyToContainerRoot(ctx context.Context, container string, content io.Reader) error {
   446  	span, ctx := opentracing.StartSpanFromContext(ctx, "daemon-CopyToContainerRoot")
   447  	defer span.Finish()
   448  	return c.CopyToContainer(ctx, container, "/", content, types.CopyToContainerOptions{})
   449  }
   450  
   451  func (c *Cli) ContainerRestartNoWait(ctx context.Context, containerID string) error {
   452  	span, ctx := opentracing.StartSpanFromContext(ctx, "daemon-ContainerRestartNoWait")
   453  	defer span.Finish()
   454  
   455  	// Don't wait on the container to fully start.
   456  	dur := time.Duration(0)
   457  
   458  	return c.ContainerRestart(ctx, containerID, &dur)
   459  }
   460  
   461  func (c *Cli) ExecInContainer(ctx context.Context, cID container.ID, cmd model.Cmd, out io.Writer) error {
   462  	span, ctx := opentracing.StartSpanFromContext(ctx, "dockerCli-ExecInContainer")
   463  	span.SetTag("cmd", strings.Join(cmd.Argv, " "))
   464  	defer span.Finish()
   465  
   466  	cfg := types.ExecConfig{
   467  		Cmd:          cmd.Argv,
   468  		AttachStdout: true,
   469  		AttachStderr: true,
   470  		Tty:          true,
   471  	}
   472  
   473  	// ContainerExecCreate error-handling is awful, so before we Create
   474  	// we do a dummy inspect, to get more reasonable error messages. See:
   475  	// https://github.com/docker/cli/blob/ae1618713f83e7da07317d579d0675f578de22fa/cli/command/container/exec.go#L77
   476  	if _, err := c.ContainerInspect(ctx, cID.String()); err != nil {
   477  		return errors.Wrap(err, "ExecInContainer")
   478  	}
   479  
   480  	execId, err := c.ContainerExecCreate(ctx, cID.String(), cfg)
   481  	if err != nil {
   482  		return errors.Wrap(err, "ExecInContainer#create")
   483  	}
   484  
   485  	connection, err := c.ContainerExecAttach(ctx, execId.ID, types.ExecStartCheck{Tty: true})
   486  	if err != nil {
   487  		return errors.Wrap(err, "ExecInContainer#attach")
   488  	}
   489  	defer connection.Close()
   490  
   491  	esSpan, ctx := opentracing.StartSpanFromContext(ctx, "dockerCli-ExecInContainer-ExecStart")
   492  	err = c.ContainerExecStart(ctx, execId.ID, types.ExecStartCheck{})
   493  	esSpan.Finish()
   494  	if err != nil {
   495  		return errors.Wrap(err, "ExecInContainer#start")
   496  	}
   497  
   498  	_, err = fmt.Fprintf(out, "RUNNING: %s\n", cmd)
   499  	if err != nil {
   500  		return errors.Wrap(err, "ExecInContainer#print")
   501  	}
   502  
   503  	bufSpan, ctx := opentracing.StartSpanFromContext(ctx, "dockerCli-ExecInContainer-readOutput")
   504  	_, err = io.Copy(out, connection.Reader)
   505  	bufSpan.Finish()
   506  	if err != nil {
   507  		return errors.Wrap(err, "ExecInContainer#copy")
   508  	}
   509  
   510  	for {
   511  		inspected, err := c.ContainerExecInspect(ctx, execId.ID)
   512  		if err != nil {
   513  			return errors.Wrap(err, "ExecInContainer#inspect")
   514  		}
   515  
   516  		if inspected.Running {
   517  			continue
   518  		}
   519  
   520  		status := inspected.ExitCode
   521  		if status != 0 {
   522  			return ExitError{ExitCode: status}
   523  		}
   524  		return nil
   525  	}
   526  }