cli/azd/pkg/project/container_helper.go (400 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package project import ( "context" "errors" "fmt" "log" "os" "path/filepath" "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/async" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/cloud" "github.com/azure/azure-dev/cli/azd/pkg/containerregistry" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/pkg/tools/docker" "github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet" "github.com/benbjohnson/clock" "github.com/sethvargo/go-retry" ) type ContainerHelper struct { env *environment.Environment envManager environment.Manager remoteBuildManager *containerregistry.RemoteBuildManager containerRegistryService azapi.ContainerRegistryService docker *docker.Cli dotNetCli *dotnet.Cli clock clock.Clock console input.Console cloud *cloud.Cloud } func NewContainerHelper( env *environment.Environment, envManager environment.Manager, clock clock.Clock, containerRegistryService azapi.ContainerRegistryService, remoteBuildManager *containerregistry.RemoteBuildManager, docker *docker.Cli, dotNetCli *dotnet.Cli, console input.Console, cloud *cloud.Cloud, ) *ContainerHelper { return &ContainerHelper{ env: env, envManager: envManager, remoteBuildManager: remoteBuildManager, containerRegistryService: containerRegistryService, docker: docker, dotNetCli: dotNetCli, clock: clock, console: console, cloud: cloud, } } // DefaultImageName returns a default image name generated from the service name and environment name. func (ch *ContainerHelper) DefaultImageName(serviceConfig *ServiceConfig) string { return fmt.Sprintf("%s/%s-%s", strings.ToLower(serviceConfig.Project.Name), strings.ToLower(serviceConfig.Name), strings.ToLower(ch.env.Name())) } // DefaultImageTag returns a default image tag generated from the current time. func (ch *ContainerHelper) DefaultImageTag() string { return fmt.Sprintf("azd-deploy-%d", ch.clock.Now().Unix()) } // RegistryName returns the name of the destination container registry to use for the current environment from the following: // 1. AZURE_CONTAINER_REGISTRY_ENDPOINT environment variable // 2. docker.registry from the service configuration func (ch *ContainerHelper) RegistryName(ctx context.Context, serviceConfig *ServiceConfig) (string, error) { registryName, found := ch.env.LookupEnv(environment.ContainerRegistryEndpointEnvVarName) if !found { log.Printf( "Container registry not found in '%s' environment variable\n", environment.ContainerRegistryEndpointEnvVarName, ) } if registryName == "" { yamlRegistryName, err := serviceConfig.Docker.Registry.Envsubst(ch.env.Getenv) if err != nil { log.Println("Failed expanding 'docker.registry'") } registryName = yamlRegistryName } // If the service provides its own code artifacts then the expectation is that an images needs to be built and // pushed to a container registry. // If the service does not provide its own code artifacts then the expectation is a registry is optional and // an image can be referenced independently. if serviceConfig.RelativePath != "" && registryName == "" { return "", fmt.Errorf( //nolint:lll "could not determine container registry endpoint, ensure 'registry' has been set in the docker options or '%s' environment variable has been set", environment.ContainerRegistryEndpointEnvVarName, ) } return registryName, nil } // GeneratedImage returns the configured image from the service configuration // or a default image name generated from the service name and environment name. func (ch *ContainerHelper) GeneratedImage( ctx context.Context, serviceConfig *ServiceConfig, ) (*docker.ContainerImage, error) { // Parse the image from azure.yaml configuration when available configuredImage, err := serviceConfig.Docker.Image.Envsubst(ch.env.Getenv) if err != nil { return nil, fmt.Errorf("failed parsing 'image' from docker configuration, %w", err) } // Set default image name if not configured if configuredImage == "" { configuredImage = ch.DefaultImageName(serviceConfig) } parsedImage, err := docker.ParseContainerImage(configuredImage) if err != nil { return nil, fmt.Errorf("failed parsing configured image, %w", err) } if parsedImage.Tag == "" { configuredTag, err := serviceConfig.Docker.Tag.Envsubst(ch.env.Getenv) if err != nil { return nil, fmt.Errorf("failed parsing 'tag' from docker configuration, %w", err) } // Set default tag if not configured if configuredTag == "" { configuredTag = ch.DefaultImageTag() } parsedImage.Tag = configuredTag } // Set default registry if not configured if parsedImage.Registry == "" { // This can fail if called before provisioning the registry configuredRegistry, err := ch.RegistryName(ctx, serviceConfig) if err == nil { parsedImage.Registry = configuredRegistry } } return parsedImage, nil } // RemoteImageTag returns the remote image tag for the service configuration. func (ch *ContainerHelper) RemoteImageTag( ctx context.Context, serviceConfig *ServiceConfig, localImageTag string, ) (string, error) { registryName, err := ch.RegistryName(ctx, serviceConfig) if err != nil { return "", err } containerImage, err := docker.ParseContainerImage(localImageTag) if err != nil { return "", err } if registryName != "" { containerImage.Registry = registryName } return containerImage.Remote(), nil } // LocalImageTag returns the local image tag for the service configuration. func (ch *ContainerHelper) LocalImageTag(ctx context.Context, serviceConfig *ServiceConfig) (string, error) { configuredImage, err := ch.GeneratedImage(ctx, serviceConfig) if err != nil { return "", err } return configuredImage.Local(), nil } func (ch *ContainerHelper) RequiredExternalTools(ctx context.Context, serviceConfig *ServiceConfig) []tools.ExternalTool { if serviceConfig.Docker.RemoteBuild { return []tools.ExternalTool{} } if useDotnetPublishForDockerBuild(serviceConfig) { return []tools.ExternalTool{ch.dotNetCli} } return []tools.ExternalTool{ch.docker} } // Login logs into the container registry specified by AZURE_CONTAINER_REGISTRY_ENDPOINT in the environment. On success, // it returns the name of the container registry that was logged into. func (ch *ContainerHelper) Login( ctx context.Context, serviceConfig *ServiceConfig, ) (string, error) { registryName, err := ch.RegistryName(ctx, serviceConfig) if err != nil { return "", err } // Only perform automatic login for ACR // Other registries require manual login via external 'docker login' command hostParts := strings.Split(registryName, ".") if len(hostParts) == 1 || strings.HasSuffix(registryName, ch.cloud.ContainerRegistryEndpointSuffix) { return registryName, ch.containerRegistryService.Login(ctx, ch.env.GetSubscriptionId(), registryName) } return registryName, nil } var defaultCredentialsRetryDelay = 20 * time.Second func (ch *ContainerHelper) Credentials( ctx context.Context, serviceConfig *ServiceConfig, targetResource *environment.TargetResource, ) (*azapi.DockerCredentials, error) { loginServer, err := ch.RegistryName(ctx, serviceConfig) if err != nil { return nil, err } var credential *azapi.DockerCredentials credentialsError := retry.Do( ctx, // will retry just once after 1 minute based on: // https://learn.microsoft.com/en-us/azure/dns/dns-faq#how-long-does-it-take-for-dns-changes-to-take-effect- retry.WithMaxRetries(3, retry.NewConstant(defaultCredentialsRetryDelay)), func(ctx context.Context) error { cred, err := ch.containerRegistryService.Credentials(ctx, targetResource.SubscriptionId(), loginServer) if err != nil { var httpErr *azcore.ResponseError if errors.As(err, &httpErr) { if httpErr.StatusCode == 404 { // Retry if the registry is not found while logging in return retry.RetryableError(err) } } return err } credential = cred return nil }) return credential, credentialsError } // Deploy pushes and image to a remote server, and optionally writes the fully qualified remote image name to the // environment on success. func (ch *ContainerHelper) Deploy( ctx context.Context, serviceConfig *ServiceConfig, packageOutput *ServicePackageResult, targetResource *environment.TargetResource, writeImageToEnv bool, progress *async.Progress[ServiceProgress], ) (*ServiceDeployResult, error) { var remoteImage string var err error if serviceConfig.Docker.RemoteBuild { remoteImage, err = ch.runRemoteBuild(ctx, serviceConfig, targetResource, progress) } else if useDotnetPublishForDockerBuild(serviceConfig) { remoteImage, err = ch.runDotnetPublish(ctx, serviceConfig, targetResource, progress) } else { remoteImage, err = ch.runLocalBuild(ctx, serviceConfig, packageOutput, progress) } if err != nil { return nil, err } if writeImageToEnv { // Save the name of the image we pushed into the environment with a well known key. log.Printf("writing image name to environment") ch.env.SetServiceProperty(serviceConfig.Name, "IMAGE_NAME", remoteImage) if err := ch.envManager.Save(ctx, ch.env); err != nil { return nil, fmt.Errorf("saving image name to environment: %w", err) } } return &ServiceDeployResult{ Package: packageOutput, Details: &dockerDeployResult{ RemoteImageTag: remoteImage, }, }, nil } // runLocalBuild builds the image locally and pushes it to the remote registry, it returns the full remote image name. func (ch *ContainerHelper) runLocalBuild( ctx context.Context, serviceConfig *ServiceConfig, packageOutput *ServicePackageResult, progress *async.Progress[ServiceProgress], ) (string, error) { // Get ACR Login Server registryName, err := ch.RegistryName(ctx, serviceConfig) if err != nil { return "", err } var sourceImage string targetImage := packageOutput.PackagePath packageDetails, ok := packageOutput.Details.(*dockerPackageResult) if ok && packageDetails != nil { sourceImage = packageDetails.SourceImage targetImage = packageDetails.TargetImage } // Default to the local image tag remoteImage := targetImage // If we don't have a registry specified and the service does not reference a project path // then we are referencing a public/pre-existing image and don't have anything to tag or push if registryName == "" && serviceConfig.RelativePath == "" && sourceImage != "" { remoteImage = sourceImage } else { if targetImage == "" { return "", errors.New("failed retrieving package result details") } // If a registry has not been defined then there is no need to tag or push any images if registryName != "" { // When the project does not contain source and we are using an external image we first need to pull the // image before we're able to push it to a remote registry // In most cases this pull will have already been part of the package step if packageDetails != nil && serviceConfig.RelativePath == "" { progress.SetProgress(NewServiceProgress("Pulling container image")) err = ch.docker.Pull(ctx, sourceImage) if err != nil { return "", fmt.Errorf("pulling image: %w", err) } } // Tag image // Get remote remoteImageWithTag from the container helper then call docker cli remoteImageWithTag command remoteImageWithTag, err := ch.RemoteImageTag(ctx, serviceConfig, targetImage) if err != nil { return "", fmt.Errorf("getting remote image tag: %w", err) } remoteImage = remoteImageWithTag progress.SetProgress(NewServiceProgress("Tagging container image")) if err := ch.docker.Tag(ctx, serviceConfig.Path(), targetImage, remoteImage); err != nil { return "", err } log.Printf("logging into container registry '%s'\n", registryName) progress.SetProgress(NewServiceProgress("Logging into container registry")) _, err = ch.Login(ctx, serviceConfig) if err != nil { return "", err } // Push image. log.Printf("pushing %s to registry", remoteImage) progress.SetProgress(NewServiceProgress("Pushing container image")) if err := ch.docker.Push(ctx, serviceConfig.Path(), remoteImage); err != nil { errSuggestion := &internal.ErrorWithSuggestion{ Err: err, //nolint:lll Suggestion: "When pushing to an external registry, ensure you have successfully authenticated by calling 'docker login' and run 'azd deploy' again", } return "", errSuggestion } } } return remoteImage, nil } // runRemoteBuild builds the image using a remote azure container registry and tags it. // It returns the full remote image name. func (ch *ContainerHelper) runRemoteBuild( ctx context.Context, serviceConfig *ServiceConfig, target *environment.TargetResource, progress *async.Progress[ServiceProgress], ) (string, error) { dockerOptions := getDockerOptionsWithDefaults(serviceConfig.Docker) if !filepath.IsAbs(dockerOptions.Path) { dockerOptions.Path = filepath.Join(serviceConfig.Path(), dockerOptions.Path) } if !filepath.IsAbs(dockerOptions.Context) { dockerOptions.Context = filepath.Join(serviceConfig.Path(), dockerOptions.Context) } if dockerOptions.Platform != "linux/amd64" { return "", fmt.Errorf("remote build only supports the linux/amd64 platform") } progress.SetProgress(NewServiceProgress("Packing remote build context")) contextPath, dockerPath, err := containerregistry.PackRemoteBuildSource(ctx, dockerOptions.Context, dockerOptions.Path) if contextPath != "" { defer os.Remove(contextPath) } if err != nil { return "", err } progress.SetProgress(NewServiceProgress("Uploading remote build context")) registryName, err := ch.RegistryName(ctx, serviceConfig) if err != nil { return "", err } acrRegistryDomain := "." + ch.cloud.ContainerRegistryEndpointSuffix if !strings.HasSuffix(registryName, acrRegistryDomain) { return "", fmt.Errorf("remote build is only supported when the target registry is an Azure Container Registry") } registryResourceName := strings.TrimSuffix(registryName, acrRegistryDomain) source, err := ch.remoteBuildManager.UploadBuildSource( ctx, target.SubscriptionId(), target.ResourceGroupName(), registryResourceName, contextPath) if err != nil { return "", err } localImageTag, err := ch.LocalImageTag(ctx, serviceConfig) if err != nil { return "", err } imageName, err := ch.RemoteImageTag(ctx, serviceConfig, localImageTag) if err != nil { return "", err } progress.SetProgress(NewServiceProgress("Running remote build")) buildRequest := &armcontainerregistry.DockerBuildRequest{ SourceLocation: source.RelativePath, DockerFilePath: to.Ptr(dockerPath), IsPushEnabled: to.Ptr(true), ImageNames: []*string{to.Ptr(imageName)}, Platform: &armcontainerregistry.PlatformProperties{ OS: to.Ptr(armcontainerregistry.OSLinux), Architecture: to.Ptr(armcontainerregistry.ArchitectureAmd64), }, } previewerWriter := ch.console.ShowPreviewer(ctx, &input.ShowPreviewerOptions{ Prefix: " ", MaxLineCount: 8, Title: "Docker Output", }) err = ch.remoteBuildManager.RunDockerBuildRequestWithLogs( ctx, target.SubscriptionId(), target.ResourceGroupName(), registryResourceName, buildRequest, previewerWriter) ch.console.StopPreviewer(ctx, false) if err != nil { return "", err } return imageName, nil } // runDotnetPublish builds and publishes the container image using `dotnet publish`. It returns the full remote image name. func (ch *ContainerHelper) runDotnetPublish( ctx context.Context, serviceConfig *ServiceConfig, target *environment.TargetResource, progress *async.Progress[ServiceProgress], ) (string, error) { progress.SetProgress(NewServiceProgress("Logging into registry")) dockerCreds, err := ch.Credentials(ctx, serviceConfig, target) if err != nil { return "", fmt.Errorf("logging in to registry: %w", err) } progress.SetProgress(NewServiceProgress("Publishing container image")) imageName := fmt.Sprintf("%s:%s", ch.DefaultImageName(serviceConfig), ch.DefaultImageTag()) _, err = ch.dotNetCli.PublishContainer( ctx, serviceConfig.Path(), "Release", imageName, dockerCreds.LoginServer, dockerCreds.Username, dockerCreds.Password) if err != nil { return "", fmt.Errorf("publishing container: %w", err) } return fmt.Sprintf("%s/%s", dockerCreds.LoginServer, imageName), nil } type dockerDeployResult struct { RemoteImageTag string }