cli/azd/pkg/tools/docker/docker.go (225 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package docker import ( "context" "fmt" "io" "log" "os" "path/filepath" "regexp" "strconv" "strings" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/blang/semver/v4" ) const DefaultPlatform string = "linux/amd64" var _ tools.ExternalTool = (*Cli)(nil) func NewCli(commandRunner exec.CommandRunner) *Cli { return &Cli{ commandRunner: commandRunner, } } type Cli struct { commandRunner exec.CommandRunner } func (d *Cli) Login(ctx context.Context, loginServer string, username string, password string) error { runArgs := exec.NewRunArgs( "docker", "login", "--username", username, "--password-stdin", loginServer, ).WithStdIn(strings.NewReader(password)) _, err := d.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("failed logging into docker: %w", err) } return nil } // Runs a Docker build for a given Dockerfile, writing the output of docker build to [stdOut] when it is // not nil. If the platform is not specified (empty) it defaults to amd64. If the build is successful, // the function returns the image id of the built image. func (d *Cli) Build( ctx context.Context, cwd string, dockerFilePath string, platform string, target string, buildContext string, tagName string, buildArgs []string, buildSecrets []string, buildEnv []string, buildProgress io.Writer, ) (string, error) { if strings.TrimSpace(platform) == "" { platform = DefaultPlatform } tmpFolder, err := os.MkdirTemp(os.TempDir(), "azd-docker-build") defer func() { // fail to remove tmp files is not so bad as the OS will delete it // eventually _ = os.RemoveAll(tmpFolder) }() if err != nil { return "", fmt.Errorf("building image: %w", err) } imgIdFile := filepath.Join(tmpFolder, "imgId") args := []string{ "build", "-f", dockerFilePath, "--platform", platform, } if target != "" { args = append(args, "--target", target) } if tagName != "" { args = append(args, "-t", tagName) } for _, arg := range buildArgs { args = append(args, "--build-arg", arg) } for _, arg := range buildSecrets { args = append(args, "--secret", arg) } args = append(args, buildContext) // create a file with the docker img id args = append(args, "--iidfile", imgIdFile) // Build and produce output runArgs := exec.NewRunArgs("docker", args...).WithCwd(cwd).WithEnv(buildEnv) if buildProgress != nil { // setting stderr and stdout both, as it's been noticed // that docker log goes to stderr on macOS, but stdout on Ubuntu. runArgs = runArgs.WithStdOut(buildProgress).WithStdErr(buildProgress) } _, err = d.commandRunner.Run(ctx, runArgs) if err != nil { return "", fmt.Errorf("building image: %w", err) } imgId, err := os.ReadFile(imgIdFile) if err != nil { return "", fmt.Errorf("building image: %w", err) } return strings.TrimSpace(string(imgId)), nil } func (d *Cli) Tag(ctx context.Context, cwd string, imageName string, tag string) error { _, err := d.executeCommand(ctx, cwd, "tag", imageName, tag) if err != nil { return fmt.Errorf("tagging image: %w", err) } return nil } func (d *Cli) Push(ctx context.Context, cwd string, tag string) error { _, err := d.executeCommand(ctx, cwd, "push", tag) if err != nil { return fmt.Errorf("pushing image: %w", err) } return nil } func (d *Cli) Pull(ctx context.Context, imageName string) error { _, err := d.executeCommand(ctx, "", "pull", imageName) if err != nil { return fmt.Errorf("pulling image: %w", err) } return nil } func (d *Cli) Inspect(ctx context.Context, imageName string, format string) (string, error) { out, err := d.executeCommand(ctx, "", "image", "inspect", "--format", format, imageName) if err != nil { return "", fmt.Errorf("inspecting image: %w", err) } return out.Stdout, nil } func (d *Cli) versionInfo() tools.VersionInfo { return tools.VersionInfo{ MinimumVersion: semver.Version{ Major: 17, Minor: 9, Patch: 0}, UpdateCommand: "Visit https://docs.docker.com/engine/release-notes/ to upgrade", } } // dockerVersionRegexp is a regular expression which matches the text printed by "docker --version" // and captures the version and build components. var dockerVersionStringRegexp = regexp.MustCompile(`Docker version ([^,]*), build ([a-f0-9]*)`) // dockerVersionReleaseBuildRegexp is a regular expression which matches the three part version number // from a docker version from an official release. The major and minor components are captured. var dockerVersionReleaseBuildRegexp = regexp.MustCompile(`^(\d+).(\d+).\d+`) // dockerVersionMasterBuildRegexp is a regular expression which matches the three part version number // from a docker version from a release from master. Each version component is captured independently. var dockerVersionMasterBuildRegexp = regexp.MustCompile(`^master-dockerproject-(\d+)-(\d+)-(\d+)`) // isSupportedDockerVersion returns true if the version string appears to be for a docker version // of 17.09 or later and false if it does not. func isSupportedDockerVersion(cliOutput string) (bool, error) { log.Printf("determining version from docker --version string: %s", cliOutput) matches := dockerVersionStringRegexp.FindStringSubmatch(cliOutput) // (3 matches, the entire string, and the two captures) if len(matches) != 3 { return false, fmt.Errorf("could not extract version component from docker version string") } version := matches[1] build := matches[2] log.Printf("extracted docker version: %s, build: %s from version string", version, build) // For official release builds, the version number looks something like: // // 17.09.0-ce or 20.10.17+azure-1 // // Note this is not a semver (the leading zero in the second component of the 17.09 string is not allowed per semver) // so we need to take this apart ourselves. if releaseVersionMatches := dockerVersionReleaseBuildRegexp.FindStringSubmatch(version); releaseVersionMatches != nil { major, err := strconv.Atoi(releaseVersionMatches[1]) if err != nil { return false, fmt.Errorf( "failed to convert major version component %s to an integer: %w", releaseVersionMatches[1], err, ) } minor, err := strconv.Atoi(releaseVersionMatches[2]) if err != nil { return false, fmt.Errorf( "failed to convert minor version component %s to an integer: %w", releaseVersionMatches[2], err, ) } return (major > 17 || (major == 17 && minor >= 9)), nil } // For builds which come out of master, we'll assume any build from 2018 or later will work // (since we support 17.09 which was released in September of 2017) if masterVersionMatches := dockerVersionMasterBuildRegexp.FindStringSubmatch(version); masterVersionMatches != nil { year, err := strconv.Atoi(masterVersionMatches[1]) if err != nil { return false, fmt.Errorf( "failed to convert major version component %s to an integer: %w", masterVersionMatches[1], err, ) } return year >= 2018, nil } // If we reach this point, we don't understand how to validate the version based on its scheme. return false, fmt.Errorf("could not determine version from docker version string: %s", version) } func (d *Cli) CheckInstalled(ctx context.Context) error { toolName := d.Name() err := tools.ToolInPath("docker") if err != nil { return err } dockerRes, err := tools.ExecuteCommand(ctx, d.commandRunner, "docker", "--version") if err != nil { return fmt.Errorf("checking %s version: %w", toolName, err) } log.Printf("docker version: %s", dockerRes) supported, err := isSupportedDockerVersion(dockerRes) if err != nil { return err } if !supported { return &tools.ErrSemver{ToolName: toolName, VersionInfo: d.versionInfo()} } // Check if docker daemon is running if _, err := tools.ExecuteCommand(ctx, d.commandRunner, "docker", "ps"); err != nil { return fmt.Errorf("the %s daemon is not running, please start the %s service: %w", toolName, toolName, err) } return nil } func (d *Cli) InstallUrl() string { return "https://aka.ms/azure-dev/docker-install" } func (d *Cli) Name() string { return "Docker" } func (d *Cli) executeCommand(ctx context.Context, cwd string, args ...string) (exec.RunResult, error) { runArgs := exec.NewRunArgs("docker", args...). WithCwd(cwd) return d.commandRunner.Run(ctx, runArgs) } // SplitDockerImage splits the image into the name and tag. // If the image does not have a tag or is invalid, the full string is returned as name, and tag will be empty. func SplitDockerImage(fullImg string) (name string, tag string) { split := -1 // the colon separator can appear in two places: // 1. between the image and the tag, image:tag // 2. between the host and the port, in which case, it would be host:port/image:tag to be valid. for i, r := range fullImg { switch r { case ':': split = i case '/': // if we see a path separator, we know that the previously found // colon is not the image:tag separator, since a tag cannot have a path separator split = -1 } } if split == -1 || split == len(fullImg)-1 { return fullImg, "" } return fullImg[:split], fullImg[split+1:] }