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 }