diff --git a/cli/azd/grpc/proto/models.proto b/cli/azd/grpc/proto/models.proto index f3c292ad983..fe78339d488 100644 --- a/cli/azd/grpc/proto/models.proto +++ b/cli/azd/grpc/proto/models.proto @@ -117,6 +117,8 @@ message DockerProjectOptions { string tag = 7; bool remote_build = 8; repeated string build_args = 9; + reserved 10; + reserved "local_fallback"; } // ServiceContext defines the shared pipeline state across all phases of the service lifecycle diff --git a/cli/azd/pkg/project/container_helper.go b/cli/azd/pkg/project/container_helper.go index 2923a0cb4d7..91756e673a2 100644 --- a/cli/azd/pkg/project/container_helper.go +++ b/cli/azd/pkg/project/container_helper.go @@ -32,6 +32,7 @@ import ( "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/output/ux" "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" @@ -599,6 +600,22 @@ func (ch *ContainerHelper) Publish( if serviceConfig.Docker.RemoteBuild { remoteImage, err = ch.runRemoteBuild(ctx, serviceConfig, targetResource, env, progress, imageOverride) + if err != nil { + // Check if a local container runtime (Docker/Podman) is available before falling back + if dockerErr := ch.docker.CheckInstalled(ctx); dockerErr != nil { + return nil, fmt.Errorf( + "remote build failed: %w\n\nLocal fallback unavailable: %w", + err, dockerErr) + } + + ch.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: fmt.Sprintf( + "Remote build failed: %s\nFalling back to local Docker build.", err), + HidePrefix: false, + }) + remoteImage, err = ch.publishLocalImage( + ctx, serviceConfig, serviceContext, env, progress, imageOverride) + } } else if useDotnetPublishForDockerBuild(serviceConfig) { remoteImage, err = ch.runDotnetPublish(ctx, serviceConfig, targetResource, env, progress) } else { diff --git a/cli/azd/pkg/project/container_helper_test.go b/cli/azd/pkg/project/container_helper_test.go index 435acb51c47..bac6366aea3 100644 --- a/cli/azd/pkg/project/container_helper_test.go +++ b/cli/azd/pkg/project/container_helper_test.go @@ -1271,3 +1271,99 @@ func Test_ContainerHelper_Publish(t *testing.T) { }) } } + +func Test_ContainerHelper_Publish_RemoteBuildLocalFallback(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockResults := setupDockerMocks(mockContext) + env := environment.NewWithValues("dev", map[string]string{}) + dockerCli := docker.NewCli(mockContext.CommandRunner) + dotnetCli := dotnet.NewCli(mockContext.CommandRunner) + + // Mock Docker availability checks for local fallback + mockContext.CommandRunner.MockToolInPath("docker", nil) + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker --version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: "Docker version 20.10.17, build 100c701", + ExitCode: 0, + }, nil + }) + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker ps") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES", + ExitCode: 0, + }, nil + }) + + mockContainerRegistryService := &mockContainerRegistryService{} + setupContainerRegistryMocks(mockContext, &mockContainerRegistryService.Mock) + + containerHelper := NewContainerHelper( + clock.NewMock(), + mockContainerRegistryService, + nil, + mockContext.CommandRunner, + dockerCli, + dotnetCli, + mockContext.Console, + cloud.AzurePublic(), + ) + + serviceConfig := createTestServiceConfig("./src/api", ContainerAppTarget, ServiceLanguageTypeScript) + serviceConfig.Docker.Registry = osutil.NewExpandableString("contoso.azurecr.io") + serviceConfig.Docker.RemoteBuild = true + serviceConfig.Docker.Platform = "linux/arm64" + + dockerArtifact := &Artifact{ + Kind: ArtifactKindContainer, + Location: "my-project/my-service:azd-deploy-0", + LocationKind: LocationKindLocal, + Metadata: map[string]string{ + "imageHash": "IMAGE_ID", + "sourceImage": "", + "targetImage": "my-project/my-service:azd-deploy-0", + }, + } + + serviceContext := &ServiceContext{ + Package: ArtifactCollection{dockerArtifact}, + } + + targetResource := environment.NewTargetResource( + "SUBSCRIPTION_ID", + "RESOURCE_GROUP", + "CONTAINER_APP", + "Microsoft.App/containerApps", + ) + + publishResult, err := logProgress( + t, func(progress *async.Progress[ServiceProgress]) (*ServicePublishResult, error) { + return containerHelper.Publish( + *mockContext.Context, serviceConfig, serviceContext, targetResource, env, progress, &PublishOptions{}) + }, + ) + + require.NoError(t, err) + require.Len(t, publishResult.Artifacts, 1) + expectedImage := "contoso.azurecr.io/my-project/my-service:azd-deploy-0" + require.Equal(t, expectedImage, + publishResult.Artifacts[0].Metadata["remoteImage"]) + + _, dockerPushCalled := mockResults["docker-push"] + require.True(t, dockerPushCalled) + + warningFound := false + for _, line := range mockContext.Console.Output() { + if strings.Contains(line, "Remote build failed:") && + strings.Contains(line, "Falling back to local Docker build.") { + warningFound = true + break + } + } + require.True(t, warningFound) +} diff --git a/cli/azd/pkg/project/framework_service_docker.go b/cli/azd/pkg/project/framework_service_docker.go index a1317535ca4..d2d19409a29 100644 --- a/cli/azd/pkg/project/framework_service_docker.go +++ b/cli/azd/pkg/project/framework_service_docker.go @@ -27,7 +27,7 @@ type DockerProjectOptions struct { Registry osutil.ExpandableString `yaml:"registry,omitempty" json:"registry"` Image osutil.ExpandableString `yaml:"image,omitempty" json:"image"` Tag osutil.ExpandableString `yaml:"tag,omitempty" json:"tag"` - RemoteBuild bool `yaml:"remoteBuild,omitempty" json:"remoteBuild,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. diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 92cd9a2f864..50c1076940e 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -864,7 +864,7 @@ "remoteBuild": { "type": "boolean", "title": "Optional. Whether to build the image remotely", - "description": "If set to true, the image will be built remotely using the Azure Container Registry remote build feature. If set to false, the image will be built locally using Docker." + "description": "If set to true, the image will be built remotely using the Azure Container Registry remote build feature. If the remote build fails, azd automatically falls back to building locally using Docker or Podman if available. If set to false, the image will be built locally." } } },