cli/azd/pkg/project/framework_service_docker.go (494 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package project import ( "context" "encoding/json" "errors" "fmt" "log" "os" "path" "path/filepath" "strings" "go.opentelemetry.io/otel/trace" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/appdetect" "github.com/azure/azure-dev/cli/azd/internal/tracing" "github.com/azure/azure-dev/cli/azd/internal/tracing/events" "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/apphost" "github.com/azure/azure-dev/cli/azd/pkg/async" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "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/pack" ) type DockerProjectOptions struct { Path string `yaml:"path,omitempty" json:"path,omitempty"` Context string `yaml:"context,omitempty" json:"context,omitempty"` Platform string `yaml:"platform,omitempty" json:"platform,omitempty"` Target string `yaml:"target,omitempty" json:"target,omitempty"` Registry osutil.ExpandableString `yaml:"registry,omitempty" json:"registry,omitempty"` Image osutil.ExpandableString `yaml:"image,omitempty" json:"image,omitempty"` Tag osutil.ExpandableString `yaml:"tag,omitempty" json:"tag,omitempty"` RemoteBuild bool `yaml:"remoteBuild,omitempty" json:"remoteBuild,omitempty"` BuildArgs []osutil.ExpandableString `yaml:"buildArgs,omitempty" json:"buildArgs,omitempty"` // not supported from azure.yaml directly yet. Adding it for Aspire to use it, initially. // Aspire would pass the secret keys, which are env vars that azd will set just to run docker build. BuildSecrets []string `yaml:"-" json:"-"` BuildEnv []string `yaml:"-" json:"-"` } type dockerBuildResult struct { ImageId string `json:"imageId"` ImageName string `json:"imageName"` } func (dbr *dockerBuildResult) ToString(currentIndentation string) string { lines := []string{ fmt.Sprintf("%s- Image ID: %s", currentIndentation, output.WithLinkFormat(dbr.ImageId)), fmt.Sprintf("%s- Image Name: %s", currentIndentation, output.WithLinkFormat(dbr.ImageName)), } return strings.Join(lines, "\n") } func (dbr *dockerBuildResult) MarshalJSON() ([]byte, error) { return json.Marshal(*dbr) } type dockerPackageResult struct { // The image hash that is generated from a docker build ImageHash string `json:"imageHash"` // The external source image specified when not building from source SourceImage string `json:"sourceImage"` // The target image with tag that is used for publishing and deployment when targeting a container registry TargetImage string `json:"targetImage"` } func (dpr *dockerPackageResult) ToString(currentIndentation string) string { builder := strings.Builder{} if dpr.ImageHash != "" { builder.WriteString(fmt.Sprintf("%s- Image Hash: %s\n", currentIndentation, output.WithLinkFormat(dpr.ImageHash))) } if dpr.SourceImage != "" { builder.WriteString( fmt.Sprintf("%s- Source Image: %s\n", currentIndentation, output.WithLinkFormat(dpr.SourceImage), ), ) } if dpr.TargetImage != "" { builder.WriteString( fmt.Sprintf("%s- Target Image: %s\n", currentIndentation, output.WithLinkFormat(dpr.TargetImage), ), ) } return builder.String() } func (dpr *dockerPackageResult) MarshalJSON() ([]byte, error) { return json.Marshal(*dpr) } type dockerProject struct { env *environment.Environment docker *docker.Cli framework FrameworkService containerHelper *ContainerHelper console input.Console alphaFeatureManager *alpha.FeatureManager commandRunner exec.CommandRunner } // NewDockerProject creates a new instance of a Azd project that // leverages docker for building func NewDockerProject( env *environment.Environment, docker *docker.Cli, containerHelper *ContainerHelper, console input.Console, alphaFeatureManager *alpha.FeatureManager, commandRunner exec.CommandRunner, ) CompositeFrameworkService { return &dockerProject{ env: env, docker: docker, containerHelper: containerHelper, console: console, alphaFeatureManager: alphaFeatureManager, commandRunner: commandRunner, framework: NewNoOpProject(env), } } // NewDockerProjectAsFrameworkService is the same as NewDockerProject().(FrameworkService) and exists to support our // use of DI and ServiceLocators, where we sometimes need to resolve this type as a FrameworkService instance instead // of a CompositeFrameworkService as [NewDockerProject] does. func NewDockerProjectAsFrameworkService( env *environment.Environment, docker *docker.Cli, containerHelper *ContainerHelper, console input.Console, alphaFeatureManager *alpha.FeatureManager, commandRunner exec.CommandRunner, ) FrameworkService { return NewDockerProject(env, docker, containerHelper, console, alphaFeatureManager, commandRunner) } func (p *dockerProject) Requirements() FrameworkRequirements { return FrameworkRequirements{ Package: FrameworkPackageRequirements{ RequireRestore: false, // Docker project needs to build a container image RequireBuild: true, }, } } // Gets the required external tools for the project func (p *dockerProject) RequiredExternalTools(ctx context.Context, sc *ServiceConfig) []tools.ExternalTool { return p.containerHelper.RequiredExternalTools(ctx, sc) } // Initializes the docker project func (p *dockerProject) Initialize(ctx context.Context, serviceConfig *ServiceConfig) error { return p.framework.Initialize(ctx, serviceConfig) } // Sets the inner framework service used for restore and build command func (p *dockerProject) SetSource(inner FrameworkService) { p.framework = inner } // Restores the dependencies for the docker project func (p *dockerProject) Restore( ctx context.Context, serviceConfig *ServiceConfig, progress *async.Progress[ServiceProgress], ) (*ServiceRestoreResult, error) { // When the program runs the restore actions for the underlying project (containerapp), // the dependencies are installed locally return p.framework.Restore(ctx, serviceConfig, progress) } // Builds the docker project based on the docker options specified within the Service configuration func (p *dockerProject) Build( ctx context.Context, serviceConfig *ServiceConfig, restoreOutput *ServiceRestoreResult, progress *async.Progress[ServiceProgress], ) (*ServiceBuildResult, error) { if serviceConfig.Docker.RemoteBuild || useDotnetPublishForDockerBuild(serviceConfig) { return &ServiceBuildResult{Restore: restoreOutput}, nil } dockerOptions := getDockerOptionsWithDefaults(serviceConfig.Docker) resolveParameters := func(source []string) ([]string, error) { result := make([]string, len(source)) for i, arg := range source { evaluatedString, err := apphost.EvalString(arg, func(match string) (string, error) { path := match value, has := p.env.Config.GetString(path) if !has { return "", fmt.Errorf("parameter %s not found", path) } return value, nil }) if err != nil { return nil, err } result[i] = evaluatedString } return result, nil } dockerBuildArgs := []string{} for _, arg := range dockerOptions.BuildArgs { buildArgValue, err := arg.Envsubst(p.env.Getenv) if err != nil { return nil, fmt.Errorf("substituting environment variables in build args: %w", err) } dockerBuildArgs = append(dockerBuildArgs, buildArgValue) } // resolve parameters for build args and secrets resolvedBuildArgs, err := resolveParameters(dockerBuildArgs) if err != nil { return nil, err } resolvedBuildEnv, err := resolveParameters(dockerOptions.BuildEnv) if err != nil { return nil, err } dockerOptions.BuildEnv = resolvedBuildEnv // For services that do not specify a project path and have not specified a language then // there is nothing to build and we can return an empty build result // Ex) A container app project that uses an external image path if serviceConfig.RelativePath == "" && (serviceConfig.Language == ServiceLanguageNone || serviceConfig.Language == ServiceLanguageDocker) { return &ServiceBuildResult{}, nil } buildArgs := []string{} for _, arg := range resolvedBuildArgs { buildArgs = append(buildArgs, exec.RedactSensitiveData(arg)) } log.Printf( "building image for service %s, cwd: %s, path: %s, context: %s, buildArgs: %s)", serviceConfig.Name, serviceConfig.Path(), dockerOptions.Path, dockerOptions.Context, buildArgs, ) imageName := fmt.Sprintf( "%s-%s", strings.ToLower(serviceConfig.Project.Name), strings.ToLower(serviceConfig.Name), ) dockerfilePath := dockerOptions.Path if !filepath.IsAbs(dockerfilePath) { dockerfilePath = filepath.Join(serviceConfig.Path(), dockerfilePath) } _, err = os.Stat(dockerfilePath) if errors.Is(err, os.ErrNotExist) && serviceConfig.Docker.Path == "" { // Build the container from source when: // 1. No Dockerfile path is specified, and // 2. <service directory>/Dockerfile doesn't exist progress.SetProgress(NewServiceProgress("Building Docker image from source")) res, err := p.packBuild(ctx, serviceConfig, dockerOptions, imageName) if err != nil { return nil, err } res.Restore = restoreOutput return res, nil } // Include full environment variables for the docker build including: // 1. Environment variables from the host // 2. Environment variables from the service configuration // 3. Environment variables from the docker configuration dockerEnv := []string{} dockerEnv = append(dockerEnv, os.Environ()...) dockerEnv = append(dockerEnv, p.env.Environ()...) dockerEnv = append(dockerEnv, dockerOptions.BuildEnv...) // Build the container progress.SetProgress(NewServiceProgress("Building Docker image")) previewerWriter := p.console.ShowPreviewer(ctx, &input.ShowPreviewerOptions{ Prefix: " ", MaxLineCount: 8, Title: "Docker Output", }) imageId, err := p.docker.Build( ctx, serviceConfig.Path(), dockerOptions.Path, dockerOptions.Platform, dockerOptions.Target, dockerOptions.Context, imageName, resolvedBuildArgs, dockerOptions.BuildSecrets, dockerEnv, previewerWriter, ) p.console.StopPreviewer(ctx, false) if err != nil { return nil, fmt.Errorf("building container: %s at %s: %w", serviceConfig.Name, dockerOptions.Context, err) } log.Printf("built image %s for %s", imageId, serviceConfig.Name) return &ServiceBuildResult{ Restore: restoreOutput, BuildOutputPath: imageId, Details: &dockerBuildResult{ ImageId: imageId, ImageName: imageName, }, }, nil } func useDotnetPublishForDockerBuild(serviceConfig *ServiceConfig) bool { if serviceConfig.useDotNetPublishForDockerBuild != nil { return *serviceConfig.useDotNetPublishForDockerBuild } serviceConfig.useDotNetPublishForDockerBuild = to.Ptr(false) if serviceConfig.Language.IsDotNet() { projectPath := serviceConfig.Path() dockerOptions := getDockerOptionsWithDefaults(serviceConfig.Docker) dockerfilePath := dockerOptions.Path if !filepath.IsAbs(dockerfilePath) { s, err := os.Stat(projectPath) if err == nil && s.IsDir() { dockerfilePath = filepath.Join(projectPath, dockerfilePath) } else { dockerfilePath = filepath.Join(filepath.Dir(projectPath), dockerfilePath) } } if _, err := os.Stat(dockerfilePath); errors.Is(err, os.ErrNotExist) { serviceConfig.useDotNetPublishForDockerBuild = to.Ptr(true) } } return *serviceConfig.useDotNetPublishForDockerBuild } func (p *dockerProject) Package( ctx context.Context, serviceConfig *ServiceConfig, buildOutput *ServiceBuildResult, progress *async.Progress[ServiceProgress], ) (*ServicePackageResult, error) { if serviceConfig.Docker.RemoteBuild || useDotnetPublishForDockerBuild(serviceConfig) { return &ServicePackageResult{Build: buildOutput}, nil } var imageId string if buildOutput != nil { imageId = buildOutput.BuildOutputPath } packageDetails := &dockerPackageResult{ ImageHash: imageId, } // If we don't have an image ID from a docker build then an external source image is being used if imageId == "" { sourceImageValue, err := serviceConfig.Image.Envsubst(p.env.Getenv) if err != nil { return nil, fmt.Errorf("substituting environment variables in image: %w", err) } sourceImage, err := docker.ParseContainerImage(sourceImageValue) if err != nil { return nil, fmt.Errorf("parsing source container image: %w", err) } remoteImageUrl := sourceImage.Remote() progress.SetProgress(NewServiceProgress("Pulling container source image")) if err := p.docker.Pull(ctx, remoteImageUrl); err != nil { return nil, fmt.Errorf("pulling source container image: %w", err) } imageId = remoteImageUrl packageDetails.SourceImage = remoteImageUrl } // Generate a local tag from the 'docker' configuration section of the service imageWithTag, err := p.containerHelper.LocalImageTag(ctx, serviceConfig) if err != nil { return nil, fmt.Errorf("generating local image tag: %w", err) } // Tag image. log.Printf("tagging image %s as %s", imageId, imageWithTag) progress.SetProgress(NewServiceProgress("Tagging container image")) if err := p.docker.Tag(ctx, serviceConfig.Path(), imageId, imageWithTag); err != nil { return nil, fmt.Errorf("tagging image: %w", err) } packageDetails.TargetImage = imageWithTag return &ServicePackageResult{ Build: buildOutput, PackagePath: packageDetails.SourceImage, Details: packageDetails, }, nil } // Default builder image to produce container images from source, needn't java jdk storage, use the standard bp const DefaultBuilderImage = "mcr.microsoft.com/oryx/builder:debian-bullseye-20240424.1" func (p *dockerProject) packBuild( ctx context.Context, svc *ServiceConfig, dockerOptions DockerProjectOptions, imageName string) (*ServiceBuildResult, error) { packCli, err := pack.NewCli(ctx, p.console, p.commandRunner) if err != nil { return nil, err } builder := DefaultBuilderImage environ := []string{} userDefinedImage := false if os.Getenv("AZD_BUILDER_IMAGE") != "" { builder = os.Getenv("AZD_BUILDER_IMAGE") userDefinedImage = true } svcPath := svc.Path() buildContext := svcPath if svc.Docker.Context != "" { buildContext = svc.Docker.Context if !filepath.IsAbs(buildContext) { buildContext = filepath.Join(svcPath, buildContext) } } if !userDefinedImage { // Always default to port 80 for consistency across languages environ = append(environ, "ORYX_RUNTIME_PORT=80") if svc.Language == ServiceLanguageJava { environ = append(environ, "ORYX_RUNTIME_PORT=8080") if buildContext != svcPath { svcRelPath, err := filepath.Rel(buildContext, svcPath) if err != nil { return nil, fmt.Errorf("calculating relative context path: %w", err) } environ = append(environ, fmt.Sprintf("BP_MAVEN_BUILT_MODULE=%s", filepath.ToSlash(svcRelPath))) } } if svc.OutputPath != "" && (svc.Language == ServiceLanguageTypeScript || svc.Language == ServiceLanguageJavaScript) { inDockerOutputPath := path.Join("/workspace", svc.OutputPath) // A dist folder has been set. // We assume that the service is a front-end service, configuring a nginx web server to serve the static content // produced. environ = append(environ, "ORYX_RUNTIME_IMAGE=nginx:1.25.2-bookworm", fmt.Sprintf( //nolint:lll "ORYX_RUNTIME_SCRIPT=[ -d \"%s\" ] || { echo \"error: directory '%s' does not exist. ensure the 'dist' path in azure.yaml is specified correctly.\"; exit 1; } && "+ "rm -rf /usr/share/nginx/html && ln -sT %s /usr/share/nginx/html && "+ "nginx -g 'daemon off;'", inDockerOutputPath, svc.OutputPath, inDockerOutputPath, )) } if svc.Language == ServiceLanguagePython { pyEnviron, err := getEnvironForPython(ctx, svc) if err != nil { return nil, err } if len(pyEnviron) > 0 { environ = append(environ, pyEnviron...) } } } previewer := p.console.ShowPreviewer(ctx, &input.ShowPreviewerOptions{ Prefix: " ", MaxLineCount: 8, Title: "Docker (pack) Output", }) ctx, span := tracing.Start( ctx, events.PackBuildEvent, trace.WithAttributes(fields.ProjectServiceLanguageKey.String(string(svc.Language)))) img, tag := docker.SplitDockerImage(builder) if userDefinedImage { span.SetAttributes( fields.StringHashed(fields.PackBuilderImage, img), fields.StringHashed(fields.PackBuilderTag, tag), ) } else { span.SetAttributes( fields.PackBuilderImage.String(img), fields.PackBuilderTag.String(tag), ) } err = packCli.Build( ctx, buildContext, builder, imageName, environ, previewer) p.console.StopPreviewer(ctx, false) if err != nil { span.EndWithStatus(err) var statusCodeErr *pack.StatusCodeError if errors.As(err, &statusCodeErr) && statusCodeErr.Code == pack.StatusCodeUndetectedNoError { return nil, &internal.ErrorWithSuggestion{ Err: err, Suggestion: "No Dockerfile was found, and image could not be automatically built from source. " + fmt.Sprintf( "\nSuggested action: Author a Dockerfile and save it as %s", filepath.Join(svc.Path(), dockerOptions.Path)), } } return nil, err } span.End() imageId, err := p.docker.Inspect(ctx, imageName, "{{.Id}}") if err != nil { return nil, err } imageId = strings.TrimSpace(imageId) return &ServiceBuildResult{ BuildOutputPath: imageId, Details: &dockerBuildResult{ ImageId: imageId, ImageName: imageName, }, }, nil } func getEnvironForPython(ctx context.Context, svc *ServiceConfig) ([]string, error) { prj, err := appdetect.DetectDirectory(ctx, svc.Path()) if err != nil { return nil, err } if prj == nil { // Undetected project, resume build from the Oryx builder return nil, nil } // Support for FastAPI apps since the Oryx builder does not support it yet for _, dep := range prj.Dependencies { if dep == appdetect.PyFastApi { launch, err := appdetect.PyFastApiLaunch(prj.Path) if err != nil { return nil, err } // If launch isn't detected, fallback to default Oryx runtime logic, which may recover for scenarios // such as a simple main entrypoint launch. if launch == "" { return nil, nil } return []string{ "POST_BUILD_COMMAND=pip install uvicorn", //nolint:lll "ORYX_RUNTIME_SCRIPT=oryx create-script -appPath ./oryx-output -bindPort 80 -userStartupCommand " + "'uvicorn " + launch + " --port $PORT --host $HOST' && ./run.sh"}, nil } } return nil, nil } func getDockerOptionsWithDefaults(options DockerProjectOptions) DockerProjectOptions { if options.Path == "" { options.Path = "./Dockerfile" } if options.Platform == "" { options.Platform = docker.DefaultPlatform } if options.Context == "" { options.Context = "." } return options }