github.com/drud/ddev@v1.21.5-alpha1.0.20230226034409-94fcc4b94453/pkg/dockerutil/dockerutils.go (about) 1 package dockerutil 2 3 import ( 4 "bufio" 5 "bytes" 6 "fmt" 7 "io" 8 "log" 9 "net" 10 "os" 11 "os/exec" 12 "path/filepath" 13 "regexp" 14 "runtime" 15 "sort" 16 "strconv" 17 "strings" 18 "time" 19 20 ddevexec "github.com/drud/ddev/pkg/exec" 21 "github.com/drud/ddev/pkg/fileutil" 22 "github.com/drud/ddev/pkg/globalconfig" 23 "github.com/drud/ddev/pkg/versionconstants" 24 25 "net/url" 26 27 "github.com/drud/ddev/pkg/archive" 28 "github.com/drud/ddev/pkg/nodeps" 29 "github.com/drud/ddev/pkg/util" 30 31 "github.com/Masterminds/semver/v3" 32 "github.com/drud/ddev/pkg/output" 33 docker "github.com/fsouza/go-dockerclient" 34 ) 35 36 // NetName provides the default network name for ddev. 37 const NetName = "ddev_default" 38 39 // EnsureNetwork will ensure the docker network for ddev is created. 40 func EnsureNetwork(client *docker.Client, name string) error { 41 if !NetExists(client, name) { 42 netOptions := docker.CreateNetworkOptions{ 43 Name: name, 44 Driver: "bridge", 45 Internal: false, 46 } 47 _, err := client.CreateNetwork(netOptions) 48 if err != nil { 49 return err 50 } 51 output.UserOut.Println("Network", name, "created") 52 53 } 54 return nil 55 } 56 57 // EnsureDdevNetwork just creates or ensures the ddev network exists or 58 // exits with fatal. 59 func EnsureDdevNetwork() { 60 // ensure we have the fallback global ddev network 61 client := GetDockerClient() 62 err := EnsureNetwork(client, NetName) 63 if err != nil { 64 log.Fatalf("Failed to ensure docker network %s: %v", NetName, err) 65 } 66 } 67 68 // NetworkExists returns true if the named network exists 69 // Mostly intended for tests 70 func NetworkExists(netName string) bool { 71 // ensure we have docker network 72 client := GetDockerClient() 73 return NetExists(client, strings.ToLower(netName)) 74 } 75 76 // RemoveNetwork removes the named docker network 77 func RemoveNetwork(netName string) error { 78 client := GetDockerClient() 79 err := client.RemoveNetwork(netName) 80 return err 81 } 82 83 var DockerHost string 84 var DockerContext string 85 86 // GetDockerClient returns a docker client respecting the current docker context 87 // but DOCKER_HOST gets priority 88 func GetDockerClient() *docker.Client { 89 var err error 90 91 // This section is skipped if $DOCKER_HOST is set 92 if DockerHost == "" { 93 DockerContext, DockerHost, err = GetDockerContext() 94 // ddev --version may be called without docker client or context available, ignore err 95 if err != nil && len(os.Args) > 1 && os.Args[1] != "--version" && os.Args[1] != "hostname" { 96 util.Failed("Unable to get docker context: %v", err) 97 } 98 util.Debug("GetDockerClient: DockerContext=%s, DockerHost=%s", DockerContext, DockerHost) 99 } 100 // Respect DOCKER_HOST in case it's set, otherwise use host we got from context 101 if os.Getenv("DOCKER_HOST") == "" { 102 util.Debug("GetDockerClient: Setting DOCKER_HOST to '%s'", DockerHost) 103 _ = os.Setenv("DOCKER_HOST", DockerHost) 104 } 105 client, err := docker.NewClientFromEnv() 106 if err != nil { 107 output.UserOut.Warnf("could not get docker client. is docker running? error: %v", err) 108 // Use os.Exit instead of util.Failed() to avoid import cycle with util. 109 os.Exit(100) 110 } 111 return client 112 } 113 114 // GetDockerContext() returns the currently set docker context, host, and error 115 func GetDockerContext() (string, string, error) { 116 context := "" 117 dockerHost := "" 118 119 // This is a cheap way of using docker contexts by running `docker context inspect` 120 // I would wish for something far better, but trying to transplant the code from 121 // docker/cli did not succeed. rfay 2021-12-16 122 // `docker context inspect` will already respect $DOCKER_CONTEXT so we don't have to do that. 123 contextInfo, err := ddevexec.RunHostCommand("docker", "context", "inspect", "-f", `{{ .Name }} {{ .Endpoints.docker.Host }}`) 124 if err != nil { 125 return "", "", fmt.Errorf("unable to run 'docker context inspect' - please make sure docker client is in path and up-to-date: %v", err) 126 } 127 contextInfo = strings.Trim(contextInfo, " \r\n") 128 util.Debug("GetDockerContext: contextInfo='%s'", contextInfo) 129 parts := strings.SplitN(contextInfo, " ", 2) 130 if len(parts) != 2 { 131 return "", "", fmt.Errorf("unable to run split docker context info %s: %v", contextInfo, err) 132 } 133 context = parts[0] 134 dockerHost = parts[1] 135 util.Debug("Using docker context %s (%v)", context, dockerHost) 136 return context, dockerHost, nil 137 } 138 139 // GetDockerHostID returns DOCKER_HOST but with all special characters removed 140 // It stands in for docker context, but docker context name is not a reliable indicator 141 func GetDockerHostID() string { 142 _, dockerHost, err := GetDockerContext() 143 if err != nil { 144 util.Warning("Unable to GetDockerContext: %v", err) 145 } 146 // Make it shorter so we don't hit mutagen 63-char limit 147 dockerHost = strings.TrimPrefix(dockerHost, "unix://") 148 dockerHost = strings.TrimSuffix(dockerHost, "docker.sock") 149 dockerHost = strings.Trim(dockerHost, "/.") 150 // Convert remaining descriptor to alphanumeric 151 reg, err := regexp.Compile("[^a-zA-Z0-9]+") 152 if err != nil { 153 log.Fatal(err) 154 } 155 alphaOnly := reg.ReplaceAllString(dockerHost, "-") 156 return alphaOnly 157 } 158 159 // InspectContainer returns the full result of inspection 160 func InspectContainer(name string) (*docker.Container, error) { 161 client, err := docker.NewClientFromEnv() 162 163 if err != nil { 164 return nil, err 165 } 166 c, err := FindContainerByName(name) 167 if err != nil || c == nil { 168 return nil, err 169 } 170 x, err := client.InspectContainerWithOptions(docker.InspectContainerOptions{ID: c.ID}) 171 return x, err 172 } 173 174 // FindContainerByName takes a container name and returns the container ID 175 // If container is not found, returns nil with no error 176 func FindContainerByName(name string) (*docker.APIContainers, error) { 177 client := GetDockerClient() 178 179 containers, err := client.ListContainers(docker.ListContainersOptions{ 180 All: true, 181 Filters: map[string][]string{"name": {name}}, 182 }) 183 if err != nil { 184 return nil, err 185 } 186 if len(containers) == 0 { 187 return nil, nil 188 } 189 190 // ListContainers can return partial matches. Make sure we only match the exact one 191 // we're after. 192 for _, c := range containers { 193 if c.Names[0] == "/"+name { 194 return &c, nil 195 } 196 } 197 return nil, nil 198 } 199 200 // GetContainerStateByName returns container state for the named container 201 func GetContainerStateByName(name string) (string, error) { 202 container, err := FindContainerByName(name) 203 if err != nil || container == nil { 204 return "doesnotexist", fmt.Errorf("container %s does not exist", name) 205 } 206 if container.State == "running" { 207 return container.State, nil 208 } 209 return container.State, fmt.Errorf("container %s is in state=%s so can't be accessed", name, container.State) 210 } 211 212 // FindContainerByLabels takes a map of label names and values and returns any docker containers which match all labels. 213 func FindContainerByLabels(labels map[string]string) (*docker.APIContainers, error) { 214 containers, err := FindContainersByLabels(labels) 215 if err != nil { 216 return nil, err 217 } 218 if len(containers) > 0 { 219 return &containers[0], nil 220 } 221 return nil, nil 222 } 223 224 // GetDockerContainers returns a slice of all docker containers on the host system. 225 func GetDockerContainers(allContainers bool) ([]docker.APIContainers, error) { 226 client := GetDockerClient() 227 containers, err := client.ListContainers(docker.ListContainersOptions{All: allContainers}) 228 return containers, err 229 } 230 231 // FindContainersByLabels takes a map of label names and values and returns any docker containers which match all labels. 232 // Explanation of the query: 233 // * docs: https://docs.docker.com/engine/api/v1.23/ 234 // * Stack Overflow: https://stackoverflow.com/questions/28054203/docker-remote-api-filter-exited 235 func FindContainersByLabels(labels map[string]string) ([]docker.APIContainers, error) { 236 if len(labels) < 1 { 237 return []docker.APIContainers{{}}, fmt.Errorf("the provided list of labels was empty") 238 } 239 filterList := []string{} 240 for k, v := range labels { 241 filterList = append(filterList, fmt.Sprintf("%s=%s", k, v)) 242 } 243 244 client := GetDockerClient() 245 containers, err := client.ListContainers(docker.ListContainersOptions{ 246 All: true, 247 Filters: map[string][]string{"label": filterList}, 248 }) 249 if err != nil { 250 return nil, err 251 } 252 return containers, nil 253 } 254 255 // FindContainersWithLabel returns all containers with the given label 256 // It ignores the value of the label, is only interested that the label exists. 257 func FindContainersWithLabel(label string) ([]docker.APIContainers, error) { 258 client := GetDockerClient() 259 containers, err := client.ListContainers(docker.ListContainersOptions{ 260 All: true, 261 Filters: map[string][]string{"label": {label}}, 262 }) 263 if err != nil { 264 return nil, err 265 } 266 return containers, nil 267 } 268 269 // NetExists checks to see if the docker network for ddev exists. 270 func NetExists(client *docker.Client, name string) bool { 271 nets, _ := client.ListNetworks() 272 for _, n := range nets { 273 if n.Name == name { 274 return true 275 } 276 } 277 return false 278 } 279 280 // ContainerWait provides a wait loop to check for a single container in "healthy" status. 281 // waittime is in seconds. 282 // This is modeled on https://gist.github.com/ngauthier/d6e6f80ce977bedca601 283 // Returns logoutput, error, returns error if not "healthy" 284 func ContainerWait(waittime int, labels map[string]string) (string, error) { 285 286 durationWait := time.Duration(waittime) * time.Second 287 timeoutChan := time.NewTimer(durationWait) 288 tickChan := time.NewTicker(500 * time.Millisecond) 289 defer tickChan.Stop() 290 defer timeoutChan.Stop() 291 292 status := "" 293 294 for { 295 select { 296 case <-timeoutChan.C: 297 _ = timeoutChan.Stop() 298 return "", fmt.Errorf("health check timed out after %v: labels %v timed out without becoming healthy, status=%v", durationWait, labels, status) 299 300 case <-tickChan.C: 301 container, err := FindContainerByLabels(labels) 302 if err != nil || container == nil { 303 return "", fmt.Errorf("failed to query container labels=%v: %v", labels, err) 304 } 305 health, logOutput := GetContainerHealth(container) 306 307 switch health { 308 case "healthy": 309 return logOutput, nil 310 case "unhealthy": 311 return logOutput, fmt.Errorf("container %s unhealthy: %s", container.Names[0], logOutput) 312 case "exited": 313 service := container.Labels["com.docker.compose.service"] 314 suggestedCommand := fmt.Sprintf("ddev logs -s %s", service) 315 if service == "ddev-router" || service == "ddev-ssh-agent" { 316 suggestedCommand = fmt.Sprintf("docker logs %s", service) 317 } 318 return logOutput, fmt.Errorf("container exited, please use '%s' to find out why it failed", suggestedCommand) 319 } 320 } 321 } 322 323 // We should never get here. 324 //nolint: govet 325 return "", fmt.Errorf("inappropriate break out of for loop in ContainerWait() waiting for container labels %v", labels) 326 } 327 328 // ContainersWait provides a wait loop to check for multiple containers in "healthy" status. 329 // waittime is in seconds. 330 // Returns logoutput, error, returns error if not "healthy" 331 func ContainersWait(waittime int, labels map[string]string) error { 332 333 timeoutChan := time.After(time.Duration(waittime) * time.Second) 334 tickChan := time.NewTicker(500 * time.Millisecond) 335 defer tickChan.Stop() 336 337 status := "" 338 339 for { 340 select { 341 case <-timeoutChan: 342 desc := "" 343 containers, err := FindContainersByLabels(labels) 344 if err == nil && containers != nil { 345 for _, c := range containers { 346 health, _ := GetContainerHealth(&c) 347 if health != "healthy" { 348 n := strings.TrimPrefix(c.Names[0], "/") 349 desc = desc + fmt.Sprintf(" %s:%s - more info with `docker inspect --format \"{{json .State.Health }}\" %s`", n, health, n) 350 } 351 } 352 } 353 return fmt.Errorf("health check timed out: labels %v timed out without becoming healthy, status=%v, detail=%s ", labels, status, desc) 354 355 case <-tickChan.C: 356 containers, err := FindContainersByLabels(labels) 357 allHealthy := true 358 for _, c := range containers { 359 if err != nil || containers == nil { 360 return fmt.Errorf("failed to query container labels=%v: %v", labels, err) 361 } 362 health, logOutput := GetContainerHealth(&c) 363 364 switch health { 365 case "healthy": 366 continue 367 case "unhealthy": 368 return fmt.Errorf("container %s is unhealthy: %s", c.Names[0], logOutput) 369 case "exited": 370 service := c.Labels["com.docker.compose.service"] 371 suggestedCommand := fmt.Sprintf("ddev logs -s %s", service) 372 if service == "ddev-router" || service == "ddev-ssh-agent" { 373 suggestedCommand = fmt.Sprintf("docker logs %s", service) 374 } 375 return fmt.Errorf("container '%s' exited, please use '%s' to find out why it failed", service, suggestedCommand) 376 default: 377 allHealthy = false 378 } 379 } 380 if allHealthy { 381 return nil 382 } 383 } 384 } 385 386 // We should never get here. 387 //nolint: govet 388 return fmt.Errorf("inappropriate break out of for loop in ContainerWait() waiting for container labels %v", labels) 389 } 390 391 // ContainerWaitLog provides a wait loop to check for container in "healthy" status. 392 // with a given log output 393 // timeout is in seconds. 394 // This is modeled on https://gist.github.com/ngauthier/d6e6f80ce977bedca601 395 // Returns logoutput, error, returns error if not "healthy" 396 func ContainerWaitLog(waittime int, labels map[string]string, expectedLog string) (string, error) { 397 398 timeoutChan := time.After(time.Duration(waittime) * time.Second) 399 tickChan := time.NewTicker(500 * time.Millisecond) 400 defer tickChan.Stop() 401 402 status := "" 403 404 for { 405 select { 406 case <-timeoutChan: 407 return "", fmt.Errorf("health check timed out: labels %v timed out without becoming healthy, status=%v", labels, status) 408 409 case <-tickChan.C: 410 container, err := FindContainerByLabels(labels) 411 if err != nil || container == nil { 412 return "", fmt.Errorf("failed to query container labels=%v: %v", labels, err) 413 } 414 status, logOutput := GetContainerHealth(container) 415 416 switch { 417 case status == "healthy" && expectedLog == logOutput: 418 return logOutput, nil 419 case status == "unhealthy": 420 return logOutput, fmt.Errorf("container %s unhealthy: %s", container.Names[0], logOutput) 421 case status == "exited": 422 service := container.Labels["com.docker.compose.service"] 423 return logOutput, fmt.Errorf("container exited, please use 'ddev logs -s %s` to find out why it failed", service) 424 } 425 } 426 } 427 428 // We should never get here. 429 //nolint: govet 430 return "", fmt.Errorf("inappropriate break out of for loop in ContainerWaitLog() waiting for container labels %v", labels) 431 } 432 433 // ContainerName returns the container's human readable name. 434 func ContainerName(container docker.APIContainers) string { 435 return container.Names[0][1:] 436 } 437 438 // GetContainerHealth retrieves the health status of a given container. 439 // returns status, most-recent-log 440 func GetContainerHealth(container *docker.APIContainers) (string, string) { 441 if container == nil { 442 return "no container", "" 443 } 444 445 // If the container is not running, then return exited as the health. 446 // "exited" means stopped. 447 if container.State == "exited" || container.State == "restarting" { 448 return container.State, "" 449 } 450 451 client := GetDockerClient() 452 inspect, err := client.InspectContainerWithOptions(docker.InspectContainerOptions{ 453 ID: container.ID, 454 }) 455 if err != nil || inspect == nil { 456 output.UserOut.Warnf("Error getting container to inspect: %v", err) 457 return "", "" 458 } 459 460 logOutput := "" 461 status := inspect.State.Health.Status 462 // The last log is the most recent 463 if inspect.State.Health.Status != "" { 464 numLogs := len(inspect.State.Health.Log) 465 if numLogs > 0 { 466 logOutput = inspect.State.Health.Log[numLogs-1].Output 467 } 468 } else { 469 // Some containers may not have a healthcheck. In that case 470 // we use State to determine health 471 switch inspect.State.Status { 472 case "running": 473 status = "healthy" 474 case "exited": 475 status = "exited" 476 } 477 } 478 479 return status, logOutput 480 } 481 482 // ComposeWithStreams executes a docker-compose command but allows the caller to specify 483 // stdin/stdout/stderr 484 func ComposeWithStreams(composeFiles []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, action ...string) error { 485 var arg []string 486 487 runTime := util.TimeTrack(time.Now(), "dockerutil.ComposeWithStreams") 488 defer runTime() 489 490 _, err := DownloadDockerComposeIfNeeded() 491 if err != nil { 492 return err 493 } 494 495 for _, file := range composeFiles { 496 arg = append(arg, "-f") 497 arg = append(arg, file) 498 } 499 500 arg = append(arg, action...) 501 502 path, err := globalconfig.GetDockerComposePath() 503 if err != nil { 504 return err 505 } 506 proc := exec.Command(path, arg...) 507 proc.Stdout = stdout 508 proc.Stdin = stdin 509 proc.Stderr = stderr 510 511 err = proc.Run() 512 return err 513 } 514 515 // ComposeCmd executes docker-compose commands via shell. 516 // returns stdout, stderr, error/nil 517 func ComposeCmd(composeFiles []string, action ...string) (string, string, error) { 518 var arg []string 519 var stdout bytes.Buffer 520 var stderr string 521 522 _, err := DownloadDockerComposeIfNeeded() 523 if err != nil { 524 return "", "", err 525 } 526 527 for _, file := range composeFiles { 528 arg = append(arg, "-f", file) 529 } 530 531 arg = append(arg, action...) 532 533 path, err := globalconfig.GetDockerComposePath() 534 if err != nil { 535 return "", "", err 536 } 537 proc := exec.Command(path, arg...) 538 proc.Stdout = &stdout 539 proc.Stdin = os.Stdin 540 541 stderrPipe, err := proc.StderrPipe() 542 if err != nil { 543 return "", "", fmt.Errorf("Failed to proc.StderrPipe(): %v", err) 544 } 545 546 if err = proc.Start(); err != nil { 547 return "", "", fmt.Errorf("Failed to exec docker-compose: %v", err) 548 } 549 550 // read command's stdout line by line 551 in := bufio.NewScanner(stderrPipe) 552 553 // Ignore chatty things from docker-compose like: 554 // Container (or Volume) ... Creating or Created or Stopping or Starting or Removing 555 // Container Stopped or Created 556 // No resource found to remove (when doing a stop and no project exists) 557 ignoreRegex := "(^ *(Network|Container|Volume) .* (Creat|Start|Stopp|Remov)ing$|^Container .*(Stopp|Creat)(ed|ing)$|Warning: No resource found to remove$|Pulling fs layer|Waiting|Downloading|Extracting|Verifying Checksum|Download complete|Pull complete)" 558 downRE, err := regexp.Compile(ignoreRegex) 559 if err != nil { 560 util.Warning("failed to compile regex %v: %v", ignoreRegex, err) 561 } 562 563 for in.Scan() { 564 line := in.Text() 565 if len(stderr) > 0 { 566 stderr = stderr + "\n" 567 } 568 stderr = stderr + line 569 line = strings.Trim(line, "\n\r") 570 switch { 571 case downRE.MatchString(line): 572 break 573 default: 574 output.UserOut.Println(line) 575 } 576 } 577 578 err = proc.Wait() 579 if err != nil { 580 return stdout.String(), stderr, fmt.Errorf("ComposeCmd failed to run 'COMPOSE_PROJECT_NAME=%s docker-compose %v', action='%v', err='%v', stdout='%s', stderr='%s'", os.Getenv("COMPOSE_PROJECT_NAME"), strings.Join(arg, " "), action, err, stdout.String(), stderr) 581 } 582 return stdout.String(), stderr, nil 583 } 584 585 // GetAppContainers retrieves docker containers for a given sitename. 586 func GetAppContainers(sitename string) ([]docker.APIContainers, error) { 587 label := map[string]string{"com.ddev.site-name": sitename} 588 containers, err := FindContainersByLabels(label) 589 if err != nil { 590 return containers, err 591 } 592 return containers, nil 593 } 594 595 // GetContainerEnv returns the value of a given environment variable from a given container. 596 func GetContainerEnv(key string, container docker.APIContainers) string { 597 client := GetDockerClient() 598 inspect, err := client.InspectContainerWithOptions(docker.InspectContainerOptions{ 599 ID: container.ID, 600 }) 601 if err == nil { 602 envVars := inspect.Config.Env 603 604 for _, env := range envVars { 605 if strings.HasPrefix(env, key) { 606 return strings.TrimPrefix(env, key+"=") 607 } 608 } 609 } 610 return "" 611 } 612 613 // CheckDockerVersion determines if the docker version of the host system meets the provided version 614 // constraints. See https://godoc.org/github.com/Masterminds/semver#hdr-Checking_Version_Constraints 615 // for examples defining version constraints. 616 func CheckDockerVersion(versionConstraint string) error { 617 runTime := util.TimeTrack(time.Now(), "CheckDockerVersion()") 618 defer runTime() 619 620 currentVersion, err := GetDockerVersion() 621 if err != nil { 622 return fmt.Errorf("no docker") 623 } 624 // If docker version has "_ce", remove it. This happens on OpenSUSE Tumbleweed at least 625 currentVersion = strings.TrimSuffix(currentVersion, "_ce") 626 dockerVersion, err := semver.NewVersion(currentVersion) 627 if err != nil { 628 return err 629 } 630 631 // See if they're using broken docker desktop on linux 632 if runtime.GOOS == "linux" && !IsWSL2() { 633 client := GetDockerClient() 634 info, err := client.Info() 635 if err != nil { 636 return fmt.Errorf("unable to get docker info: %v", err) 637 } 638 if info.Name == "docker-desktop" { 639 return fmt.Errorf("Docker Desktop on Linux is not yet compatible with DDEV") 640 } 641 } 642 643 constraint, err := semver.NewConstraint(versionConstraint) 644 if err != nil { 645 return err 646 } 647 648 match, errs := constraint.Validate(dockerVersion) 649 if !match { 650 if len(errs) <= 1 { 651 return errs[0] 652 } 653 654 msgs := "\n" 655 for _, err := range errs { 656 msgs = fmt.Sprint(msgs, err, "\n") 657 } 658 return fmt.Errorf(msgs) 659 } 660 return nil 661 } 662 663 // CheckDockerCompose determines if docker-compose is present and executable on the host system. This 664 // relies on docker-compose being somewhere in the user's $PATH. 665 func CheckDockerCompose() error { 666 runTime := util.TimeTrack(time.Now(), "CheckDockerComposeVersion()") 667 defer runTime() 668 669 _, err := DownloadDockerComposeIfNeeded() 670 if err != nil { 671 return err 672 } 673 versionConstraint := DockerComposeVersionConstraint 674 675 v, err := GetDockerComposeVersion() 676 if err != nil { 677 return err 678 } 679 dockerComposeVersion, err := semver.NewVersion(v) 680 if err != nil { 681 return err 682 } 683 684 constraint, err := semver.NewConstraint(versionConstraint) 685 if err != nil { 686 return err 687 } 688 689 match, errs := constraint.Validate(dockerComposeVersion) 690 if !match { 691 if len(errs) <= 1 { 692 return errs[0] 693 } 694 695 msgs := "\n" 696 for _, err := range errs { 697 msgs = fmt.Sprint(msgs, err, "\n") 698 } 699 return fmt.Errorf(msgs) 700 } 701 702 return nil 703 } 704 705 // GetPublishedPort returns the published port for a given private port. 706 func GetPublishedPort(privatePort int64, container docker.APIContainers) int { 707 for _, port := range container.Ports { 708 if port.PrivatePort == privatePort { 709 return int(port.PublicPort) 710 } 711 } 712 return 0 713 } 714 715 // CheckForHTTPS determines if a container has the HTTPS_EXPOSE var 716 // set to route 443 traffic to 80 717 func CheckForHTTPS(container docker.APIContainers) bool { 718 env := GetContainerEnv("HTTPS_EXPOSE", container) 719 if env != "" && strings.Contains(env, "443:80") { 720 return true 721 } 722 return false 723 } 724 725 var dockerHostRawURL string 726 var DockerIP string 727 728 // GetDockerIP returns either the default Docker IP address (127.0.0.1) 729 // or the value as configured by $DOCKER_HOST (if DOCKER_HOST is an tcp:// URL) 730 func GetDockerIP() (string, error) { 731 if DockerIP == "" { 732 DockerIP = "127.0.0.1" 733 dockerHostRawURL = os.Getenv("DOCKER_HOST") 734 // If DOCKER_HOST is empty, then the client hasn't been initialized 735 // from the docker context 736 if dockerHostRawURL == "" { 737 _ = GetDockerClient() 738 dockerHostRawURL = os.Getenv("DOCKER_HOST") 739 } 740 if dockerHostRawURL != "" { 741 dockerHostURL, err := url.Parse(dockerHostRawURL) 742 if err != nil { 743 return "", fmt.Errorf("failed to parse $DOCKER_HOST=%s: %v", dockerHostRawURL, err) 744 } 745 hostPart := dockerHostURL.Hostname() 746 if hostPart != "" { 747 // Check to see if the hostname we found is an IP address 748 addr := net.ParseIP(hostPart) 749 if addr == nil { 750 // If it wasn't an IP address, look it up to get IP address 751 ip, err := net.LookupHost(hostPart) 752 if err == nil && len(ip) > 0 { 753 hostPart = ip[0] 754 } else { 755 return "", fmt.Errorf("failed to look up IP address for $DOCKER_HOST=%s, hostname=%s: %v", dockerHostRawURL, hostPart, err) 756 } 757 } 758 DockerIP = hostPart 759 } 760 } 761 } 762 return DockerIP, nil 763 } 764 765 // RunSimpleContainer runs a container (non-daemonized) and captures the stdout/stderr. 766 // It will block, so not to be run on a container whose entrypoint or cmd might hang or run too long. 767 // This should be the equivalent of something like 768 // docker run -t -u '%s:%s' -e SNAPSHOT_NAME='%s' -v '%s:/mnt/ddev_config' -v '%s:/var/lib/mysql' --rm --entrypoint=/migrate_file_to_volume.sh %s:%s" 769 // Example code from https://gist.github.com/fsouza/b0bf3043827f8e39c4589e88cec067d8 770 // Returns containerID, output, error 771 func RunSimpleContainer(image string, name string, cmd []string, entrypoint []string, env []string, binds []string, uid string, removeContainerAfterRun bool, detach bool, labels map[string]string) (containerID string, output string, returnErr error) { 772 client := GetDockerClient() 773 774 // Ensure image string includes a tag 775 imageChunks := strings.Split(image, ":") 776 if len(imageChunks) == 1 { 777 // Image does not specify tag 778 return "", "", fmt.Errorf("image name must specify tag: %s", image) 779 } 780 781 if tag := imageChunks[len(imageChunks)-1]; len(tag) == 0 { 782 // Image specifies malformed tag (ends with ':') 783 return "", "", fmt.Errorf("malformed tag provided: %s", image) 784 } 785 786 existsLocally, err := ImageExistsLocally(image) 787 if err != nil { 788 return "", "", fmt.Errorf("failed to check if image %s is available locally: %v", image, err) 789 } 790 791 if !existsLocally { 792 pullErr := Pull(image) 793 if pullErr != nil { 794 return "", "", fmt.Errorf("failed to pull image %s: %v", image, pullErr) 795 } 796 } 797 798 // Windows 10 Docker toolbox won't handle a bind mount like C:\..., so must convert to /c/... 799 if runtime.GOOS == "windows" { 800 for i := range binds { 801 binds[i] = strings.Replace(binds[i], `\`, `/`, -1) 802 if strings.Index(binds[i], ":") == 1 { 803 binds[i] = strings.Replace(binds[i], ":", "", 1) 804 binds[i] = "/" + binds[i] 805 // And amazingly, the drive letter must be lower-case. 806 re := regexp.MustCompile("^/[A-Z]/") 807 driveLetter := re.FindString(binds[i]) 808 if len(driveLetter) == 3 { 809 binds[i] = strings.TrimPrefix(binds[i], driveLetter) 810 binds[i] = strings.ToLower(driveLetter) + binds[i] 811 } 812 813 } 814 } 815 } 816 817 options := docker.CreateContainerOptions{ 818 Name: name, 819 Config: &docker.Config{ 820 Image: image, 821 Cmd: cmd, 822 Env: env, 823 User: uid, 824 Labels: labels, 825 Entrypoint: entrypoint, 826 AttachStderr: true, 827 AttachStdout: true, 828 }, 829 HostConfig: &docker.HostConfig{ 830 Binds: binds, 831 }, 832 } 833 834 if runtime.GOOS == "linux" && !IsDockerDesktop() { 835 options.HostConfig.ExtraHosts = []string{"host.docker.internal:host-gateway"} 836 } 837 container, err := client.CreateContainer(options) 838 if err != nil { 839 return "", "", fmt.Errorf("failed to create/start docker container (%v):%v", options, err) 840 } 841 842 if removeContainerAfterRun { 843 // nolint: errcheck 844 defer RemoveContainer(container.ID, 20) 845 } 846 err = client.StartContainer(container.ID, nil) 847 if err != nil { 848 return container.ID, "", fmt.Errorf("failed to StartContainer: %v", err) 849 } 850 exitCode := 0 851 if !detach { 852 exitCode, err = client.WaitContainer(container.ID) 853 if err != nil { 854 return container.ID, "", fmt.Errorf("failed to WaitContainer: %v", err) 855 } 856 } 857 858 // Get logs so we can report them if exitCode failed 859 var stdout bytes.Buffer 860 err = client.Logs(docker.LogsOptions{ 861 Stdout: true, 862 Stderr: true, 863 Container: container.ID, 864 OutputStream: &stdout, 865 ErrorStream: &stdout, 866 }) 867 if err != nil { 868 return container.ID, "", fmt.Errorf("failed to get Logs(): %v", err) 869 } 870 871 // This is the exitCode from the client.WaitContainer() 872 if exitCode != 0 { 873 return container.ID, stdout.String(), fmt.Errorf("container run failed with exit code %d", exitCode) 874 } 875 876 return container.ID, stdout.String(), nil 877 } 878 879 // RemoveContainer stops and removes a container 880 func RemoveContainer(id string, timeout uint) error { 881 client := GetDockerClient() 882 883 err := client.RemoveContainer(docker.RemoveContainerOptions{ID: id, Force: true}) 884 return err 885 } 886 887 // RestartContainer stops and removes a container 888 func RestartContainer(id string, timeout uint) error { 889 client := GetDockerClient() 890 891 err := client.RestartContainer(id, 20) 892 return err 893 } 894 895 // RemoveContainersByLabels removes all containers that match a set of labels 896 func RemoveContainersByLabels(labels map[string]string) error { 897 client := GetDockerClient() 898 containers, err := FindContainersByLabels(labels) 899 if err != nil { 900 return err 901 } 902 if containers == nil { 903 return nil 904 } 905 for _, c := range containers { 906 err = client.RemoveContainer(docker.RemoveContainerOptions{ID: c.ID, Force: true}) 907 if err != nil { 908 return err 909 } 910 } 911 return nil 912 } 913 914 // ImageExistsLocally determines if an image is available locally. 915 func ImageExistsLocally(imageName string) (bool, error) { 916 client := GetDockerClient() 917 918 // If inspect succeeeds, we have an image. 919 _, err := client.InspectImage(imageName) 920 if err == nil { 921 return true, nil 922 } 923 return false, nil 924 } 925 926 // Pull pulls image if it doesn't exist locally. 927 func Pull(imageName string) error { 928 exists, err := ImageExistsLocally(imageName) 929 if err != nil { 930 return err 931 } 932 if exists { 933 return nil 934 } 935 cmd := exec.Command("docker", "pull", imageName) 936 cmd.Stdout = os.Stdout 937 cmd.Stderr = os.Stderr 938 err = cmd.Run() 939 return err 940 } 941 942 // GetExposedContainerPorts takes a container pointer and returns an array 943 // of exposed ports (and error) 944 func GetExposedContainerPorts(containerID string) ([]string, error) { 945 client := GetDockerClient() 946 inspectInfo, err := client.InspectContainerWithOptions(docker.InspectContainerOptions{ 947 ID: containerID, 948 }) 949 950 if err != nil { 951 return nil, err 952 } 953 954 portMap := map[string]bool{} 955 for _, portMapping := range inspectInfo.NetworkSettings.Ports { 956 if portMapping != nil && len(portMapping) > 0 { 957 for _, item := range portMapping { 958 portMap[item.HostPort] = true 959 } 960 } 961 } 962 ports := []string{} 963 for k := range portMap { 964 ports = append(ports, k) 965 } 966 sort.Slice(ports, func(i, j int) bool { 967 return ports[i] < ports[j] 968 }) 969 return ports, nil 970 } 971 972 // MassageWindowsHostMountpoint changes C:/path/to/something to //c/path/to/something 973 // THis is required for docker bind mounts on docker toolbox. 974 // Sadly, if we have a Windows drive name, it has to be converted from C:/ to //c for Win10Home/Docker toolbox 975 func MassageWindowsHostMountpoint(mountPoint string) string { 976 if string(mountPoint[1]) == ":" { 977 pathPortion := strings.Replace(mountPoint[2:], `\`, "/", -1) 978 drive := strings.ToLower(string(mountPoint[0])) 979 mountPoint = "/" + drive + pathPortion 980 } 981 return mountPoint 982 } 983 984 // MassageWindowsNFSMount changes C:\Path\to\something to /c/Path/to/something 985 func MassageWindowsNFSMount(mountPoint string) string { 986 if string(mountPoint[1]) == ":" { 987 pathPortion := strings.Replace(mountPoint[2:], `\`, "/", -1) 988 drive := string(mountPoint[0]) 989 // Because we use $HOME to get home in exports, and $HOME has /c/Users/xxx 990 // change the drive to lower case. 991 mountPoint = "/" + strings.ToLower(drive) + pathPortion 992 } 993 return mountPoint 994 } 995 996 // RemoveVolume removes named volume. Does not throw error if the volume did not exist. 997 func RemoveVolume(volumeName string) error { 998 client := GetDockerClient() 999 if _, err := client.InspectVolume(volumeName); err == nil { 1000 err := client.RemoveVolumeWithOptions(docker.RemoveVolumeOptions{Name: volumeName}) 1001 if err != nil { 1002 if err.Error() == "volume in use and cannot be removed" { 1003 containers, err := client.ListContainers(docker.ListContainersOptions{ 1004 All: true, 1005 Filters: map[string][]string{"volume": {volumeName}}, 1006 }) 1007 // Get names of containers which are still using the volume. 1008 var containerNames []string 1009 if err == nil { 1010 for _, container := range containers { 1011 // Skip first character, it's a slash. 1012 containerNames = append(containerNames, container.Names[0][1:]) 1013 } 1014 var containerNamesString = strings.Join(containerNames, " ") 1015 return fmt.Errorf("Docker volume '%s' is in use by one or more containers and cannot be removed. Use 'docker rm -f %s' to remove them", volumeName, containerNamesString) 1016 } 1017 return fmt.Errorf("Docker volume '%s' is in use by a container and cannot be removed. Use 'docker rm -f $(docker ps -aq)' to remove all containers", volumeName) 1018 } 1019 return err 1020 } 1021 } 1022 return nil 1023 } 1024 1025 // VolumeExists checks to see if the named volume exists. 1026 func VolumeExists(volumeName string) bool { 1027 client := GetDockerClient() 1028 _, err := client.InspectVolume(volumeName) 1029 if err != nil { 1030 return false 1031 } 1032 return true 1033 } 1034 1035 // VolumeLabels returns map of labels found on volume. 1036 func VolumeLabels(volumeName string) (map[string]string, error) { 1037 client := GetDockerClient() 1038 v, err := client.InspectVolume(volumeName) 1039 if err != nil { 1040 return nil, err 1041 } 1042 return v.Labels, nil 1043 } 1044 1045 // CreateVolume creates a docker volume 1046 func CreateVolume(volumeName string, driver string, driverOpts map[string]string, labels map[string]string) (volume *docker.Volume, err error) { 1047 client := GetDockerClient() 1048 volume, err = client.CreateVolume(docker.CreateVolumeOptions{Name: volumeName, Labels: labels, Driver: driver, DriverOpts: driverOpts}) 1049 return volume, err 1050 } 1051 1052 // GetHostDockerInternalIP returns either "" (will use the hostname as is) 1053 // (for Docker Desktop on macOS and Windows with WSL2) or a usable IP address 1054 // But there are many cases to handle 1055 // Linux classic installation 1056 // Gitpod (the Linux technique does not work during prebuild) 1057 // WSL2 with Docker-ce installed inside 1058 // WSL2 with PhpStorm or vscode running inside WSL2 1059 // And it matters whether they're running IDE inside. With docker-inside-wsl2, the bridge docker0 is what we want 1060 // It's also possible to run vscode Language Server inside the web container, in which case host.docker.internal 1061 // should actually be 127.0.0.1 1062 // Inside WSL2, the way to access an app like PhpStorm running on the Windows side is described 1063 // in https://learn.microsoft.com/en-us/windows/wsl/networking#accessing-windows-networking-apps-from-linux-host-ip 1064 // and it involves parsing /etc/resolv.conf. 1065 func GetHostDockerInternalIP() (string, error) { 1066 hostDockerInternal := "" 1067 1068 switch { 1069 case nodeps.IsIPAddress(globalconfig.DdevGlobalConfig.XdebugIDELocation): 1070 // If the IDE is actually listening inside container, then localhost/127.0.0.1 should work. 1071 hostDockerInternal = globalconfig.DdevGlobalConfig.XdebugIDELocation 1072 1073 case globalconfig.DdevGlobalConfig.XdebugIDELocation == globalconfig.XdebugIDELocationContainer: 1074 // If the IDE is actually listening inside container, then localhost/127.0.0.1 should work. 1075 hostDockerInternal = "127.0.0.1" 1076 1077 case IsColima(): 1078 // Lima just specifies this as a named explicit IP address at this time 1079 // see https://github.com/lima-vm/lima/blob/master/docs/network.md#host-ip-19216852 1080 hostDockerInternal = "192.168.5.2" 1081 1082 // Gitpod has docker 20.10+ so the docker-compose has already gotten the host-gateway 1083 case nodeps.IsGitpod(): 1084 break 1085 case nodeps.IsCodespaces(): 1086 break 1087 1088 case IsWSL2() && IsDockerDesktop(): 1089 // If IDE is on Windows, return; we don't have to do anything. 1090 break 1091 1092 case IsWSL2() && globalconfig.DdevGlobalConfig.XdebugIDELocation == globalconfig.XdebugIDELocationWSL2: 1093 // If IDE is inside WSL2 then the normal linux processing should work 1094 break 1095 1096 case IsWSL2() && !IsDockerDesktop(): 1097 // If IDE is on Windows, we have to parse /etc/resolv.conf 1098 hostDockerInternal = wsl2ResolvConfNameserver() 1099 1100 // Docker on linux doesn't define host.docker.internal 1101 // so we need to go get the bridge IP address 1102 // Docker Desktop) defines host.docker.internal itself. 1103 case runtime.GOOS == "linux": 1104 // In docker 20.10+, host.docker.internal is already taken care of by extra_hosts in docker-compose 1105 break 1106 } 1107 1108 return hostDockerInternal, nil 1109 } 1110 1111 // GetNFSServerAddr gets the addrss that can be used for the NFS server. 1112 // It's almost the same as GetDockerHostInternalIP() but we have 1113 // to get the actual addr in the case of linux; still, linux rarely 1114 // is used with NFS. Returns "host.docker.internal" by default (not empty) 1115 func GetNFSServerAddr() (string, error) { 1116 nfsAddr := "host.docker.internal" 1117 1118 switch { 1119 case IsColima(): 1120 // Lima just specifies this as a named explicit IP address at this time 1121 // see https://github.com/lima-vm/lima/blob/master/docs/network.md#host-ip-19216852 1122 nfsAddr = "192.168.5.2" 1123 1124 // Gitpod has docker 20.10+ so the docker-compose has already gotten the host-gateway 1125 // However, NFS will never be used on gitpod. 1126 case nodeps.IsGitpod(): 1127 break 1128 case nodeps.IsCodespaces(): 1129 break 1130 1131 case IsWSL2() && IsDockerDesktop(): 1132 // If IDE is on Windows, return; we don't have to do anything. 1133 break 1134 1135 case IsWSL2() && !IsDockerDesktop(): 1136 // If IDE is on Windows, we have to parse /etc/resolv.conf 1137 // Else it will be fine, we can fallthrough to the linux version 1138 nfsAddr = wsl2ResolvConfNameserver() 1139 1140 // Docker on linux doesn't define host.docker.internal 1141 // so we need to go get the bridge IP address 1142 // Docker Desktop) defines host.docker.internal itself. 1143 case runtime.GOOS == "linux": 1144 // look up info from the bridge network 1145 // We can't use the docker host because that's for inside the container, 1146 // and this is for setting up the network interface 1147 client := GetDockerClient() 1148 n, err := client.NetworkInfo("bridge") 1149 if err != nil { 1150 return "", err 1151 } 1152 if len(n.IPAM.Config) > 0 { 1153 if n.IPAM.Config[0].Gateway != "" { 1154 nfsAddr = n.IPAM.Config[0].Gateway 1155 } else { 1156 util.Warning("Unable to determine docker bridge gateway - no gateway") 1157 } 1158 } 1159 } 1160 1161 return nfsAddr, nil 1162 } 1163 1164 // wsl2ResolvConfNameserver parses /etc/resolv.conf to get the nameserver, 1165 // which is the only documented way to know how to connect to the host 1166 // to connect to PhpStorm or other IDE listening there. Or for other apps. 1167 func wsl2ResolvConfNameserver() string { 1168 if IsWSL2() { 1169 isAuto, err := fileutil.FgrepStringInFile("/etc/resolv.conf", "automatically generated by WSL") 1170 if err != nil || !isAuto { 1171 util.Warning("unable to determine WSL2 host.docker.internal because /etc/resolv.conf is not available or not auto-generated") 1172 return "" 1173 } 1174 // We just grepped it so no need to check error 1175 etcResolv, _ := fileutil.ReadFileIntoString("/etc/resolv.conf") 1176 1177 nameserverRegex := regexp.MustCompile(`nameserver *([0-9\.]*)`) 1178 //nameserverRegex.ReplaceAllFunc([]byte(etcResolv), []byte(`$1`)) 1179 res := nameserverRegex.FindStringSubmatch(etcResolv) 1180 if res == nil || len(res) != 2 { 1181 util.Warning("unable to determine host.docker.internal from /etc/resolv.conf") 1182 return "" 1183 } 1184 return res[1] 1185 } 1186 util.Warning("inappropriately using wsl2ResolvConfNameserver() but not on WSL2") 1187 return "" 1188 } 1189 1190 // RemoveImage removes an image with force 1191 func RemoveImage(tag string) error { 1192 client := GetDockerClient() 1193 _, err := client.InspectImage(tag) 1194 if err == nil { 1195 err = client.RemoveImageExtended(tag, docker.RemoveImageOptions{Force: true}) 1196 1197 if err == nil { 1198 util.Debug("Deleted docker image %s", tag) 1199 } else { 1200 util.Warning("Unable to delete %s: %v", tag, err) 1201 } 1202 } 1203 return nil 1204 } 1205 1206 // CopyIntoVolume copies a file or directory on the host into a docker volume 1207 // sourcePath is the host-side full path 1208 // volumeName is the volume name to copy to 1209 // targetSubdir is where to copy it to on the volume 1210 // uid is the uid of the resulting files 1211 // exclusion is a path to be excluded 1212 // If destroyExisting the volume is removed and recreated 1213 func CopyIntoVolume(sourcePath string, volumeName string, targetSubdir string, uid string, exclusion string, destroyExisting bool) error { 1214 if destroyExisting { 1215 err := RemoveVolume(volumeName) 1216 if err != nil { 1217 util.Warning("could not remove docker volume %s: %v", volumeName, err) 1218 } 1219 } 1220 volPath := "/mnt/v" 1221 targetSubdirFullPath := volPath + "/" + targetSubdir 1222 _, err := os.Stat(sourcePath) 1223 if err != nil { 1224 return err 1225 } 1226 1227 f, err := os.Open(sourcePath) 1228 if err != nil { 1229 util.Failed("Failed to open %s: %v", sourcePath, err) 1230 } 1231 1232 // nolint errcheck 1233 defer f.Close() 1234 1235 containerName := "CopyIntoVolume_" + nodeps.RandomString(12) 1236 1237 track := util.TimeTrack(time.Now(), "CopyIntoVolume "+sourcePath+" "+volumeName) 1238 containerID, _, err := RunSimpleContainer(versionconstants.GetWebImage(), containerName, []string{"sh", "-c", "mkdir -p " + targetSubdirFullPath + " && tail -f /dev/null"}, nil, nil, []string{volumeName + ":" + volPath}, "0", false, true, nil) 1239 if err != nil { 1240 return err 1241 } 1242 // nolint: errcheck 1243 defer RemoveContainer(containerID, 0) 1244 1245 err = CopyIntoContainer(sourcePath, containerName, targetSubdirFullPath, exclusion) 1246 1247 if err != nil { 1248 return err 1249 } 1250 1251 // chown/chmod the uploaded content 1252 c := fmt.Sprintf("chown -R %s %s", uid, targetSubdirFullPath) 1253 stdout, stderr, err := Exec(containerID, c, "0") 1254 util.Debug("Exec %s stdout=%s, stderr=%s, err=%v", c, stdout, stderr, err) 1255 1256 if err != nil { 1257 return err 1258 } 1259 track() 1260 return nil 1261 } 1262 1263 // Exec does a simple docker exec, no frills, just executes the command 1264 // with the specified uid (or defaults to root=0 if empty uid) 1265 // Returns stdout, stderr, error 1266 func Exec(containerID string, command string, uid string) (string, string, error) { 1267 client := GetDockerClient() 1268 1269 if uid == "" { 1270 uid = "0" 1271 } 1272 exec, err := client.CreateExec(docker.CreateExecOptions{ 1273 Container: containerID, 1274 Cmd: []string{"sh", "-c", command}, 1275 AttachStdout: true, 1276 AttachStderr: true, 1277 User: uid, 1278 }) 1279 if err != nil { 1280 return "", "", err 1281 } 1282 1283 var stdout, stderr bytes.Buffer 1284 err = client.StartExec(exec.ID, docker.StartExecOptions{ 1285 OutputStream: &stdout, 1286 ErrorStream: &stderr, 1287 Detach: false, 1288 }) 1289 if err != nil { 1290 return "", "", err 1291 } 1292 1293 info, err := client.InspectExec(exec.ID) 1294 if err != nil { 1295 return stdout.String(), stderr.String(), err 1296 } 1297 var execErr error 1298 if info.ExitCode != 0 { 1299 execErr = fmt.Errorf("command '%s' returned exit code %v", command, info.ExitCode) 1300 } 1301 1302 return stdout.String(), stderr.String(), execErr 1303 } 1304 1305 // CheckAvailableSpace outputs a warning if docker space is low 1306 func CheckAvailableSpace() { 1307 _, out, _ := RunSimpleContainer(versionconstants.GetWebImage(), "", []string{"sh", "-c", `df / | awk '!/Mounted/ {print $4, $5;}'`}, []string{}, []string{}, []string{}, "", true, false, nil) 1308 out = strings.Trim(out, "% \r\n") 1309 parts := strings.Split(out, " ") 1310 if len(parts) != 2 { 1311 util.Warning("Unable to determine docker space usage: %s", out) 1312 return 1313 } 1314 spacePercent, _ := strconv.Atoi(parts[1]) 1315 spaceAbsolute, _ := strconv.Atoi(parts[0]) // Note that this is in KB 1316 1317 if spaceAbsolute < nodeps.MinimumDockerSpaceWarning { 1318 util.Error("Your docker install has only %d available disk space, less than %d warning level (%d%% used). Please increase disk image size.", spaceAbsolute, nodeps.MinimumDockerSpaceWarning, spacePercent) 1319 } 1320 } 1321 1322 // DownloadDockerComposeIfNeeded downloads the proper version of docker-compose 1323 // if it's either not yet installed or has the wrong version. 1324 // Returns downloaded bool (true if it did the download) and err 1325 func DownloadDockerComposeIfNeeded() (bool, error) { 1326 requiredVersion := globalconfig.GetRequiredDockerComposeVersion() 1327 var err error 1328 if requiredVersion == "" { 1329 util.Debug("globalconfig use_docker_compose_from_path is set, so not downloading") 1330 return false, nil 1331 } 1332 curVersion, err := GetLiveDockerComposeVersion() 1333 if err != nil || curVersion != requiredVersion { 1334 err = DownloadDockerCompose() 1335 if err == nil { 1336 return true, err 1337 } 1338 } 1339 return false, err 1340 } 1341 1342 // DownloadDockerCompose gets the docker-compose binary and puts it into 1343 // ~/.ddev/.bin 1344 func DownloadDockerCompose() error { 1345 globalBinDir := globalconfig.GetDDEVBinDir() 1346 destFile, _ := globalconfig.GetDockerComposePath() 1347 1348 composeURL, err := dockerComposeDownloadLink() 1349 if err != nil { 1350 return err 1351 } 1352 output.UserOut.Printf("Downloading %s ...", composeURL) 1353 1354 _ = os.Remove(destFile) 1355 1356 _ = os.MkdirAll(globalBinDir, 0777) 1357 err = util.DownloadFile(destFile, composeURL, "true" != os.Getenv("DDEV_NONINTERACTIVE")) 1358 if err != nil { 1359 return err 1360 } 1361 output.UserOut.Printf("Download complete.") 1362 1363 // Remove the cached DockerComposeVersion 1364 globalconfig.DockerComposeVersion = "" 1365 1366 err = os.Chmod(destFile, 0755) 1367 if err != nil { 1368 return err 1369 } 1370 1371 return nil 1372 } 1373 1374 func dockerComposeDownloadLink() (string, error) { 1375 v := globalconfig.GetRequiredDockerComposeVersion() 1376 if len(v) < 3 { 1377 return "", fmt.Errorf("required docker-compose version is invalid: %v", v) 1378 } 1379 baseVersion := v[1:2] 1380 1381 switch baseVersion { 1382 case "2": 1383 return dockerComposeDownloadLinkV2() 1384 } 1385 return "", fmt.Errorf("Invalid docker-compose base version %s", v) 1386 } 1387 1388 // dockerComposeDownloadLinkV2 downlods compose v1 downloads like 1389 // https://github.com/docker/compose/releases/download/v2.2.1/docker-compose-darwin-aarch64 1390 // https://github.com/docker/compose/releases/download/v2.2.1/docker-compose-darwin-x86_64 1391 // https://github.com/docker/compose/releases/download/v2.2.1/docker-compose-windows-x86_64.exe 1392 1393 func dockerComposeDownloadLinkV2() (string, error) { 1394 arch := runtime.GOARCH 1395 1396 switch arch { 1397 case "arm64": 1398 arch = "aarch64" 1399 case "amd64": 1400 arch = "x86_64" 1401 default: 1402 return "", fmt.Errorf("Only arm64 and amd64 architectures are supported for docker-compose v2, not %s", arch) 1403 } 1404 flavor := runtime.GOOS + "-" + arch 1405 ComposeURL := fmt.Sprintf("https://github.com/docker/compose/releases/download/%s/docker-compose-%s", globalconfig.GetRequiredDockerComposeVersion(), flavor) 1406 if runtime.GOOS == "windows" { 1407 ComposeURL = ComposeURL + ".exe" 1408 } 1409 return ComposeURL, nil 1410 } 1411 1412 // IsDockerDesktop detects if running on Docker Desktop 1413 func IsDockerDesktop() bool { 1414 client := GetDockerClient() 1415 info, err := client.Info() 1416 if err != nil { 1417 util.Warning("IsDockerDesktop(): Unable to get docker info, err=%v", err) 1418 return false 1419 } 1420 if info.OperatingSystem == "Docker Desktop" { 1421 return true 1422 } 1423 return false 1424 } 1425 1426 // IsColima detects if running on Colima 1427 func IsColima() bool { 1428 client := GetDockerClient() 1429 info, err := client.Info() 1430 if err != nil { 1431 util.Warning("IsColima(): Unable to get docker info, err=%v", err) 1432 return false 1433 } 1434 if strings.HasPrefix(info.Name, "colima") { 1435 return true 1436 } 1437 return false 1438 } 1439 1440 // CopyIntoContainer copies a path (file or directory) into a specified container and location 1441 func CopyIntoContainer(srcPath string, containerName string, dstPath string, exclusion string) error { 1442 startTime := time.Now() 1443 fi, err := os.Stat(srcPath) 1444 if err != nil { 1445 return err 1446 } 1447 // If a file has been passed in, we'll copy it into a temp directory 1448 if !fi.IsDir() { 1449 dirName, err := os.MkdirTemp("", "") 1450 if err != nil { 1451 return err 1452 } 1453 defer os.RemoveAll(dirName) 1454 err = fileutil.CopyFile(srcPath, filepath.Join(dirName, filepath.Base(srcPath))) 1455 if err != nil { 1456 return err 1457 } 1458 srcPath = dirName 1459 } 1460 1461 client := GetDockerClient() 1462 cid, err := FindContainerByName(containerName) 1463 if err != nil { 1464 return err 1465 } 1466 if cid == nil { 1467 return fmt.Errorf("CopyIntoContainer unable to find a container named %s", containerName) 1468 } 1469 1470 uid, _, _ := util.GetContainerUIDGid() 1471 _, stderr, err := Exec(cid.ID, "mkdir -p "+dstPath, uid) 1472 if err != nil { 1473 return fmt.Errorf("unable to mkdir -p %s inside %s: %v (stderr=%s)", dstPath, containerName, err, stderr) 1474 } 1475 1476 tarball, err := os.CreateTemp(os.TempDir(), "containercopytmp*.tar.gz") 1477 if err != nil { 1478 return err 1479 } 1480 err = tarball.Close() 1481 if err != nil { 1482 return err 1483 } 1484 // nolint: errcheck 1485 defer os.Remove(tarball.Name()) 1486 1487 // Tar up the source directory into the tarball 1488 err = archive.Tar(srcPath, tarball.Name(), exclusion) 1489 if err != nil { 1490 return err 1491 } 1492 t, err := os.Open(tarball.Name()) 1493 if err != nil { 1494 return err 1495 } 1496 1497 // nolint: errcheck 1498 defer t.Close() 1499 1500 err = client.UploadToContainer(cid.ID, docker.UploadToContainerOptions{ 1501 InputStream: t, 1502 Path: dstPath, 1503 }) 1504 if err != nil { 1505 return err 1506 } 1507 1508 util.Debug("Copied %s:%s into %s in %v", srcPath, containerName, dstPath, time.Since(startTime)) 1509 return nil 1510 } 1511 1512 // CopyFromContainer copies a path from a specified container and location to a dstPath on host 1513 func CopyFromContainer(containerName string, containerPath string, hostPath string) error { 1514 startTime := time.Now() 1515 err := os.MkdirAll(hostPath, 0755) 1516 if err != nil { 1517 return err 1518 } 1519 1520 client := GetDockerClient() 1521 cid, err := FindContainerByName(containerName) 1522 if err != nil { 1523 return err 1524 } 1525 if cid == nil { 1526 return fmt.Errorf("CopyFromContainer unable to find a container named %s", containerName) 1527 } 1528 1529 f, err := os.CreateTemp("", filepath.Base(hostPath)+".tar.gz") 1530 if err != nil { 1531 return err 1532 } 1533 //nolint: errcheck 1534 defer f.Close() 1535 //nolint: errcheck 1536 defer os.Remove(f.Name()) 1537 // nolint: errcheck 1538 1539 err = client.DownloadFromContainer(cid.ID, docker.DownloadFromContainerOptions{ 1540 Path: containerPath, 1541 OutputStream: f, 1542 }) 1543 if err != nil { 1544 return err 1545 } 1546 1547 err = f.Close() 1548 if err != nil { 1549 return err 1550 } 1551 1552 err = archive.Untar(f.Name(), hostPath, "") 1553 if err != nil { 1554 return err 1555 } 1556 util.Success("Copied %s:%s to %s in %v", containerName, containerPath, hostPath, time.Since(startTime)) 1557 1558 return nil 1559 } 1560 1561 // DockerVersionConstraint is the current minimum version of docker required for ddev. 1562 // See https://godoc.org/github.com/Masterminds/semver#hdr-Checking_Version_Constraints 1563 // for examples defining version constraints. 1564 // REMEMBER TO CHANGE docs/ddev-installation.md if you touch this! 1565 // The constraint MUST HAVE a -pre of some kind on it for successful comparison. 1566 // See https://github.com/drud/ddev/pull/738.. and regression https://github.com/drud/ddev/issues/1431 1567 var DockerVersionConstraint = ">= 20.10.0-alpha1" 1568 1569 // DockerVersion is cached version of docker 1570 var DockerVersion = "" 1571 1572 // GetDockerVersion gets the cached or api-sourced version of docker engine 1573 func GetDockerVersion() (string, error) { 1574 if DockerVersion != "" { 1575 return DockerVersion, nil 1576 } 1577 client := GetDockerClient() 1578 if client == nil { 1579 return "", fmt.Errorf("Unable to get docker version: docker client is nil") 1580 } 1581 1582 v, err := client.Version() 1583 if err != nil { 1584 return "", err 1585 } 1586 DockerVersion = v.Get("Version") 1587 1588 return DockerVersion, nil 1589 } 1590 1591 // DockerComposeVersionConstraint is the versions allowed for ddev 1592 // REMEMBER TO CHANGE docs/ddev-installation.md if you touch this! 1593 // The constraint MUST HAVE a -pre of some kind on it for successful comparison. 1594 // See https://github.com/drud/ddev/pull/738.. and regression https://github.com/drud/ddev/issues/1431 1595 var DockerComposeVersionConstraint = ">= 2.5.1" 1596 1597 // GetDockerComposeVersion runs docker-compose -v to get the current version 1598 func GetDockerComposeVersion() (string, error) { 1599 if globalconfig.DockerComposeVersion != "" { 1600 return globalconfig.DockerComposeVersion, nil 1601 } 1602 1603 return GetLiveDockerComposeVersion() 1604 } 1605 1606 // GetLiveDockerComposeVersion runs `docker-compose --version` and caches result 1607 func GetLiveDockerComposeVersion() (string, error) { 1608 if globalconfig.DockerComposeVersion != "" { 1609 return globalconfig.DockerComposeVersion, nil 1610 } 1611 1612 composePath, err := globalconfig.GetDockerComposePath() 1613 if err != nil { 1614 return "", err 1615 } 1616 1617 if !fileutil.FileExists(composePath) { 1618 globalconfig.DockerComposeVersion = "" 1619 return globalconfig.DockerComposeVersion, fmt.Errorf("docker-compose does not exist at %s", composePath) 1620 } 1621 out, err := exec.Command(composePath, "version", "--short").Output() 1622 if err != nil { 1623 return "", err 1624 } 1625 v := strings.Trim(string(out), "\r\n") 1626 1627 // docker-compose v1 and v2.3.3 return a version without the prefix "v", so add it. 1628 if !strings.HasPrefix(v, "v") { 1629 v = "v" + v 1630 } 1631 1632 globalconfig.DockerComposeVersion = v 1633 return globalconfig.DockerComposeVersion, nil 1634 }