From 8daafc2294d882029c7857525a914aae003bca05 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Fri, 14 Aug 2026 19:39:31 +0800 Subject: [PATCH 1/9] feat: add container image passthrough Adds docker.imagePassthrough so service targets can consume a final remote image reference without azd building, pulling, tagging, copying, or publishing it.\n\nFixes #9587 --- cli/azd/grpc/proto/models.proto | 1 + cli/azd/pkg/azdext/models.pb.go | 39 ++++---- cli/azd/pkg/project/container_helper.go | 71 +++++++++++++-- cli/azd/pkg/project/container_helper_test.go | 90 +++++++++++++++++++ .../pkg/project/framework_service_docker.go | 21 ++--- .../project/framework_service_docker_test.go | 56 ++++++++++++ cli/azd/pkg/project/mapper_registry.go | 59 ++++++------ cli/azd/pkg/project/mapper_registry_test.go | 37 ++++---- cli/azd/pkg/project/service_target_aks.go | 10 +-- .../pkg/project/service_target_appservice.go | 9 +- .../project/service_target_appservice_test.go | 39 ++++++++ .../project/service_target_containerapp.go | 7 +- .../service_target_containerapp_test.go | 34 +++++++ docs/reference/azure-yaml-schema.md | 29 ++++++ schemas/alpha/azure.yaml.json | 29 ++++++ schemas/v1.0/azure.yaml.json | 29 ++++++ 16 files changed, 473 insertions(+), 87 deletions(-) diff --git a/cli/azd/grpc/proto/models.proto b/cli/azd/grpc/proto/models.proto index e492b2d586a..72e42de4d9b 100644 --- a/cli/azd/grpc/proto/models.proto +++ b/cli/azd/grpc/proto/models.proto @@ -120,6 +120,7 @@ message DockerProjectOptions { bool remote_build = 8; repeated string build_args = 9; string network = 10; + bool image_passthrough = 11; } // ServiceContext defines the shared pipeline state across all phases of the service lifecycle diff --git a/cli/azd/pkg/azdext/models.pb.go b/cli/azd/pkg/azdext/models.pb.go index 9b527ef5629..ab709c4b051 100644 --- a/cli/azd/pkg/azdext/models.pb.go +++ b/cli/azd/pkg/azdext/models.pb.go @@ -1070,19 +1070,20 @@ func (x *InfraOptions) GetModule() string { // DockerProjectOptions message definition type DockerProjectOptions struct { - state protoimpl.MessageState `protogen:"open.v1"` - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` - Context string `protobuf:"bytes,2,opt,name=context,proto3" json:"context,omitempty"` - Platform string `protobuf:"bytes,3,opt,name=platform,proto3" json:"platform,omitempty"` - Target string `protobuf:"bytes,4,opt,name=target,proto3" json:"target,omitempty"` - Registry string `protobuf:"bytes,5,opt,name=registry,proto3" json:"registry,omitempty"` - Image string `protobuf:"bytes,6,opt,name=image,proto3" json:"image,omitempty"` - Tag string `protobuf:"bytes,7,opt,name=tag,proto3" json:"tag,omitempty"` - RemoteBuild bool `protobuf:"varint,8,opt,name=remote_build,json=remoteBuild,proto3" json:"remote_build,omitempty"` - BuildArgs []string `protobuf:"bytes,9,rep,name=build_args,json=buildArgs,proto3" json:"build_args,omitempty"` - Network string `protobuf:"bytes,10,opt,name=network,proto3" json:"network,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Context string `protobuf:"bytes,2,opt,name=context,proto3" json:"context,omitempty"` + Platform string `protobuf:"bytes,3,opt,name=platform,proto3" json:"platform,omitempty"` + Target string `protobuf:"bytes,4,opt,name=target,proto3" json:"target,omitempty"` + Registry string `protobuf:"bytes,5,opt,name=registry,proto3" json:"registry,omitempty"` + Image string `protobuf:"bytes,6,opt,name=image,proto3" json:"image,omitempty"` + Tag string `protobuf:"bytes,7,opt,name=tag,proto3" json:"tag,omitempty"` + RemoteBuild bool `protobuf:"varint,8,opt,name=remote_build,json=remoteBuild,proto3" json:"remote_build,omitempty"` + BuildArgs []string `protobuf:"bytes,9,rep,name=build_args,json=buildArgs,proto3" json:"build_args,omitempty"` + Network string `protobuf:"bytes,10,opt,name=network,proto3" json:"network,omitempty"` + ImagePassthrough bool `protobuf:"varint,11,opt,name=image_passthrough,json=imagePassthrough,proto3" json:"image_passthrough,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DockerProjectOptions) Reset() { @@ -1185,6 +1186,13 @@ func (x *DockerProjectOptions) GetNetwork() string { return "" } +func (x *DockerProjectOptions) GetImagePassthrough() bool { + if x != nil { + return x.ImagePassthrough + } + return false +} + // ServiceContext defines the shared pipeline state across all phases of the service lifecycle type ServiceContext struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1457,7 +1465,7 @@ const file_models_proto_rawDesc = "" + "\fInfraOptions\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12\x16\n" + - "\x06module\x18\x03 \x01(\tR\x06module\"\x98\x02\n" + + "\x06module\x18\x03 \x01(\tR\x06module\"\xc5\x02\n" + "\x14DockerProjectOptions\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x18\n" + "\acontext\x18\x02 \x01(\tR\acontext\x12\x1a\n" + @@ -1470,7 +1478,8 @@ const file_models_proto_rawDesc = "" + "\n" + "build_args\x18\t \x03(\tR\tbuildArgs\x12\x18\n" + "\anetwork\x18\n" + - " \x01(\tR\anetwork\"\xe6\x01\n" + + " \x01(\tR\anetwork\x12+\n" + + "\x11image_passthrough\x18\v \x01(\bR\x10imagePassthrough\"\xe6\x01\n" + "\x0eServiceContext\x12*\n" + "\arestore\x18\x01 \x03(\v2\x10.azdext.ArtifactR\arestore\x12&\n" + "\x05build\x18\x02 \x03(\v2\x10.azdext.ArtifactR\x05build\x12*\n" + diff --git a/cli/azd/pkg/project/container_helper.go b/cli/azd/pkg/project/container_helper.go index 52c6b2d941e..25add2ffbc7 100644 --- a/cli/azd/pkg/project/container_helper.go +++ b/cli/azd/pkg/project/container_helper.go @@ -255,8 +255,47 @@ func (ch *ContainerHelper) LocalImageTag( return configuredImage.Local(), nil } -func (ch *ContainerHelper) RequiredExternalTools(ctx context.Context, serviceConfig *ServiceConfig) []tools.ExternalTool { +func resolveImagePassthrough( + serviceConfig *ServiceConfig, + env *environment.Environment, +) (string, error) { + if !serviceConfig.Docker.ImagePassthrough { + return "", nil + } if serviceConfig.Docker.RemoteBuild { + return "", fmt.Errorf("docker.imagePassthrough cannot be combined with docker.remoteBuild") + } + + image, err := serviceConfig.Image.Envsubst(env.Getenv) + if err != nil { + return "", fmt.Errorf("substituting environment variables in passthrough image: %w", err) + } + if strings.TrimSpace(image) == "" { + return "", fmt.Errorf("docker.imagePassthrough requires the service image property") + } + + parsed, err := docker.ParseContainerImage(image) + if err != nil { + return "", fmt.Errorf("parsing passthrough image: %w", err) + } + return parsed.Remote(), nil +} + +func imagePassthroughArtifact(image string) *Artifact { + return &Artifact{ + Kind: ArtifactKindContainer, + Location: image, + LocationKind: LocationKindRemote, + Metadata: map[string]string{ + "imagePassthrough": "true", + "remoteImage": image, + "sourceImage": image, + }, + } +} + +func (ch *ContainerHelper) RequiredExternalTools(ctx context.Context, serviceConfig *ServiceConfig) []tools.ExternalTool { + if serviceConfig.Docker.ImagePassthrough || serviceConfig.Docker.RemoteBuild { return []tools.ExternalTool{} } @@ -354,6 +393,12 @@ func (ch *ContainerHelper) Build( env *environment.Environment, progress *async.Progress[ServiceProgress], ) (*ServiceBuildResult, error) { + if serviceConfig.Docker.ImagePassthrough { + if _, err := resolveImagePassthrough(serviceConfig, env); err != nil { + return nil, err + } + return &ServiceBuildResult{}, nil + } if serviceConfig.Docker.RemoteBuild || useDotnetPublishForDockerBuild(serviceConfig) { return &ServiceBuildResult{}, nil } @@ -542,6 +587,15 @@ func (ch *ContainerHelper) Package( env *environment.Environment, progress *async.Progress[ServiceProgress], ) (*ServicePackageResult, error) { + if serviceConfig.Docker.ImagePassthrough { + image, err := resolveImagePassthrough(serviceConfig, env) + if err != nil { + return nil, err + } + return &ServicePackageResult{ + Artifacts: ArtifactCollection{imagePassthroughArtifact(image)}, + }, nil + } if serviceConfig.Docker.RemoteBuild || useDotnetPublishForDockerBuild(serviceConfig) { return &ServicePackageResult{}, nil } @@ -636,7 +690,12 @@ func (ch *ContainerHelper) Publish( return nil, err } - if serviceConfig.Docker.RemoteBuild { + if serviceConfig.Docker.ImagePassthrough { + if imageOverride != nil { + return nil, fmt.Errorf("docker.imagePassthrough cannot be combined with a publish image override") + } + remoteImage, err = resolveImagePassthrough(serviceConfig, env) + } else 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 @@ -664,13 +723,15 @@ func (ch *ContainerHelper) Publish( } // Create publish artifact with remote image reference + metadata := map[string]string{"remoteImage": remoteImage} + if serviceConfig.Docker.ImagePassthrough { + metadata["imagePassthrough"] = "true" + } publishArtifact := &Artifact{ Kind: ArtifactKindContainer, Location: remoteImage, LocationKind: LocationKindRemote, // Remote after publish - Metadata: map[string]string{ - "remoteImage": remoteImage, - }, + Metadata: metadata, } return &ServicePublishResult{ diff --git a/cli/azd/pkg/project/container_helper_test.go b/cli/azd/pkg/project/container_helper_test.go index c97eb0ca928..e8c5ec30731 100644 --- a/cli/azd/pkg/project/container_helper_test.go +++ b/cli/azd/pkg/project/container_helper_test.go @@ -1280,6 +1280,65 @@ func (m *mockContainerRegistryService) FindContainerRegistryResourceGroup( args := m.Called(ctx, subscriptionId, registryName) return args.String(0), args.Error(1) } +func TestResolveImagePassthrough(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + image string + docker DockerProjectOptions + env map[string]string + want string + wantErr bool + errContains string + }{ + {name: "disabled"}, + { + name: "expands service image", + image: "${PRIVATE_REGISTRY}/team/agent:v1", + docker: DockerProjectOptions{ImagePassthrough: true}, + env: map[string]string{"PRIVATE_REGISTRY": "private.example.com"}, + want: "private.example.com/team/agent:v1", + }, + { + name: "requires service image", + docker: DockerProjectOptions{ImagePassthrough: true}, + wantErr: true, + errContains: "requires the service image property", + }, + { + name: "conflicts with remote build", + image: "private.example.com/team/agent:v1", + docker: DockerProjectOptions{ + ImagePassthrough: true, + RemoteBuild: true, + }, + wantErr: true, + errContains: "cannot be combined", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + serviceConfig := &ServiceConfig{ + Image: osutil.NewExpandableString(tt.image), + Docker: tt.docker, + } + got, err := resolveImagePassthrough( + serviceConfig, + environment.NewWithValues("test", tt.env), + ) + if tt.wantErr { + require.ErrorContains(t, err, tt.errContains) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + func Test_ContainerHelper_Publish(t *testing.T) { tests := []struct { name string @@ -1292,6 +1351,7 @@ func Test_ContainerHelper_Publish(t *testing.T) { targetImage string publishOptions *PublishOptions expectedRemoteImage string + imagePassthrough bool expectDockerLoginCalled bool expectDockerPullCalled bool expectDockerTagCalled bool @@ -1359,6 +1419,30 @@ func Test_ContainerHelper_Publish(t *testing.T) { expectedRemoteImage: "nginx", expectError: false, }, + { + name: "Image passthrough with configured destination registry", + image: "private.example.com/team/agent:v1", + registry: osutil.NewExpandableString("contoso.azurecr.io"), + imagePassthrough: true, + publishOptions: &PublishOptions{}, + expectDockerLoginCalled: false, + expectDockerPullCalled: false, + expectDockerTagCalled: false, + expectDockerPushCalled: false, + expectedRemoteImage: "private.example.com/team/agent:v1", + expectError: false, + }, + { + name: "Image passthrough rejects publish image override", + image: "private.example.com/team/agent:v1", + imagePassthrough: true, + publishOptions: &PublishOptions{Image: "other.example.com/team/agent:v2"}, + expectDockerLoginCalled: false, + expectDockerPullCalled: false, + expectDockerTagCalled: false, + expectDockerPushCalled: false, + expectError: true, + }, { name: "With publish options overwrite", project: "./src/api", @@ -1460,6 +1544,7 @@ func Test_ContainerHelper_Publish(t *testing.T) { serviceConfig.Image = osutil.NewExpandableString(tt.image) serviceConfig.RelativePath = tt.project serviceConfig.Docker.Registry = tt.registry + serviceConfig.Docker.ImagePassthrough = tt.imagePassthrough packageOutput := &ServicePackageResult{ Artifacts: ArtifactCollection{ @@ -1522,7 +1607,12 @@ func Test_ContainerHelper_Publish(t *testing.T) { require.Len(t, publishResult.Artifacts, 1) artifact := publishResult.Artifacts[0] require.Equal(t, ArtifactKindContainer, artifact.Kind) + require.Equal(t, LocationKindRemote, artifact.LocationKind) + require.Equal(t, tt.expectedRemoteImage, artifact.Location) require.Equal(t, tt.expectedRemoteImage, artifact.Metadata["remoteImage"]) + if tt.imagePassthrough { + require.Equal(t, "true", artifact.Metadata["imagePassthrough"]) + } } }) } diff --git a/cli/azd/pkg/project/framework_service_docker.go b/cli/azd/pkg/project/framework_service_docker.go index dab9544e1b2..496a8c468ff 100644 --- a/cli/azd/pkg/project/framework_service_docker.go +++ b/cli/azd/pkg/project/framework_service_docker.go @@ -19,16 +19,17 @@ import ( ) 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"` - Image osutil.ExpandableString `yaml:"image,omitempty" json:"image"` - Tag osutil.ExpandableString `yaml:"tag,omitempty" json:"tag"` - RemoteBuild bool `yaml:"remoteBuild,omitempty" json:"remoteBuild,omitempty"` - Network string `yaml:"network,omitempty" json:"network,omitempty"` - BuildArgs []osutil.ExpandableString `yaml:"buildArgs,omitempty" json:"buildArgs,omitempty"` + 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"` + Image osutil.ExpandableString `yaml:"image,omitempty" json:"image"` + Tag osutil.ExpandableString `yaml:"tag,omitempty" json:"tag"` + RemoteBuild bool `yaml:"remoteBuild,omitempty" json:"remoteBuild,omitempty"` + ImagePassthrough bool `yaml:"imagePassthrough,omitempty" json:"imagePassthrough,omitempty"` + Network string `yaml:"network,omitempty" json:"network,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:"-"` diff --git a/cli/azd/pkg/project/framework_service_docker_test.go b/cli/azd/pkg/project/framework_service_docker_test.go index ff598f737a0..e9990518ed3 100644 --- a/cli/azd/pkg/project/framework_service_docker_test.go +++ b/cli/azd/pkg/project/framework_service_docker_test.go @@ -594,6 +594,22 @@ func Test_DockerProject_Build(t *testing.T) { } } +func Test_DockerProject_Build_ImagePassthrough(t *testing.T) { + t.Parallel() + + env := environment.NewWithValues("test", nil) + project := NewDockerProject(env, nil, &ContainerHelper{}, nil, nil, nil) + serviceConfig := &ServiceConfig{ + RelativePath: "./src/api", + Image: osutil.NewExpandableString("private.example.com/team/agent:v1"), + Docker: DockerProjectOptions{ImagePassthrough: true}, + } + + result, err := project.Build(t.Context(), serviceConfig, NewServiceContext(), nil) + require.NoError(t, err) + require.Empty(t, result.Artifacts) +} + func Test_DockerProject_Package(t *testing.T) { tests := []struct { name string @@ -672,6 +688,25 @@ func Test_DockerProject_Package(t *testing.T) { expectDockerPullCalled: true, expectDockerTagCalled: true, }, + { + name: "image passthrough", + image: "private.example.com/team/agent:v1", + docker: DockerProjectOptions{ + ImagePassthrough: true, + }, + expectedArtifact: Artifact{ + Kind: ArtifactKindContainer, + Location: "private.example.com/team/agent:v1", + LocationKind: LocationKindRemote, + Metadata: map[string]string{ + "imagePassthrough": "true", + "remoteImage": "private.example.com/team/agent:v1", + "sourceImage": "private.example.com/team/agent:v1", + }, + }, + expectDockerPullCalled: false, + expectDockerTagCalled: false, + }, { name: "fully qualified image with custom docker options", image: "docker.io/repository/image:latest", @@ -771,9 +806,16 @@ func Test_DockerProject_Package(t *testing.T) { // Compare the artifact with expected values require.Equal(t, tt.expectedArtifact.Location, artifact.Location) + if tt.expectedArtifact.LocationKind != "" { + require.Equal(t, tt.expectedArtifact.LocationKind, artifact.LocationKind) + } require.Equal(t, tt.expectedArtifact.Metadata["imageHash"], artifact.Metadata["imageHash"]) require.Equal(t, tt.expectedArtifact.Metadata["sourceImage"], artifact.Metadata["sourceImage"]) require.Equal(t, tt.expectedArtifact.Metadata["targetImage"], artifact.Metadata["targetImage"]) + if tt.expectedArtifact.Metadata["imagePassthrough"] != "" { + require.Equal(t, tt.expectedArtifact.Metadata["imagePassthrough"], artifact.Metadata["imagePassthrough"]) + require.Equal(t, tt.expectedArtifact.Metadata["remoteImage"], artifact.Metadata["remoteImage"]) + } _, dockerPullCalled := mockResults["docker-pull"] _, dockerTagCalled := mockResults["docker-tag"] @@ -813,6 +855,20 @@ func Test_dockerProject_RequiredExternalTools_RemoteBuild(t *testing.T) { assert.Empty(t, tools) } +func Test_dockerProject_RequiredExternalTools_ImagePassthrough(t *testing.T) { + env := environment.NewWithValues("test-env", nil) + ch := &ContainerHelper{} + p := NewDockerProject(env, nil, ch, nil, nil, nil) + + svcConfig := &ServiceConfig{ + Image: osutil.NewExpandableString("private.example.com/team/agent:v1"), + Docker: DockerProjectOptions{ImagePassthrough: true}, + } + + tools := p.(FrameworkService).RequiredExternalTools(t.Context(), svcConfig) + assert.Empty(t, tools) +} + func Test_dockerProject_Initialize(t *testing.T) { env := environment.NewWithValues("test-env", nil) p := NewDockerProject(env, nil, &ContainerHelper{}, nil, nil, nil) diff --git a/cli/azd/pkg/project/mapper_registry.go b/cli/azd/pkg/project/mapper_registry.go index c9fd947c223..cfbe4e410be 100644 --- a/cli/azd/pkg/project/mapper_registry.go +++ b/cli/azd/pkg/project/mapper_registry.go @@ -201,16 +201,17 @@ func registerProjectMappings() { } return &azdext.DockerProjectOptions{ - Path: src.Path, - Context: src.Context, - Platform: src.Platform, - Target: src.Target, - Registry: registry, - Image: image, - Tag: tag, - RemoteBuild: src.RemoteBuild, - BuildArgs: buildArgs, - Network: src.Network, + Path: src.Path, + Context: src.Context, + Platform: src.Platform, + Target: src.Target, + Registry: registry, + Image: image, + Tag: tag, + RemoteBuild: src.RemoteBuild, + ImagePassthrough: src.ImagePassthrough, + BuildArgs: buildArgs, + Network: src.Network, }, nil }) @@ -427,15 +428,16 @@ func registerProjectMappings() { } result := DockerProjectOptions{ - Path: src.Path, - Context: src.Context, - Platform: src.Platform, - Target: src.Target, - Registry: osutil.NewExpandableString(src.Registry), - Image: osutil.NewExpandableString(src.Image), - Tag: osutil.NewExpandableString(src.Tag), - RemoteBuild: src.RemoteBuild, - Network: src.Network, + Path: src.Path, + Context: src.Context, + Platform: src.Platform, + Target: src.Target, + Registry: osutil.NewExpandableString(src.Registry), + Image: osutil.NewExpandableString(src.Image), + Tag: osutil.NewExpandableString(src.Tag), + RemoteBuild: src.RemoteBuild, + ImagePassthrough: src.ImagePassthrough, + Network: src.Network, } if len(src.BuildArgs) > 0 { @@ -455,15 +457,16 @@ func registerProjectMappings() { } result := &DockerProjectOptions{ - Path: src.Path, - Context: src.Context, - Platform: src.Platform, - Target: src.Target, - Registry: osutil.NewExpandableString(src.Registry), - Image: osutil.NewExpandableString(src.Image), - Tag: osutil.NewExpandableString(src.Tag), - RemoteBuild: src.RemoteBuild, - Network: src.Network, + Path: src.Path, + Context: src.Context, + Platform: src.Platform, + Target: src.Target, + Registry: osutil.NewExpandableString(src.Registry), + Image: osutil.NewExpandableString(src.Image), + Tag: osutil.NewExpandableString(src.Tag), + RemoteBuild: src.RemoteBuild, + ImagePassthrough: src.ImagePassthrough, + Network: src.Network, } if len(src.BuildArgs) > 0 { diff --git a/cli/azd/pkg/project/mapper_registry_test.go b/cli/azd/pkg/project/mapper_registry_test.go index cff1321b2c4..26c741d502d 100644 --- a/cli/azd/pkg/project/mapper_registry_test.go +++ b/cli/azd/pkg/project/mapper_registry_test.go @@ -445,12 +445,12 @@ func TestServiceConfigRoundTripMapping(t *testing.T) { func TestDockerProjectOptionsMapping(t *testing.T) { dockerOptions := DockerProjectOptions{ - Path: "./Dockerfile", - Context: ".", - Platform: "linux/amd64", - Target: "production", - Network: "host", - RemoteBuild: true, + Path: "./Dockerfile", + Context: ".", + Platform: "linux/amd64", + Target: "production", + Network: "host", + ImagePassthrough: true, } var protoOptions *azdext.DockerProjectOptions @@ -462,7 +462,8 @@ func TestDockerProjectOptionsMapping(t *testing.T) { require.Equal(t, "linux/amd64", protoOptions.Platform) require.Equal(t, "production", protoOptions.Target) require.Equal(t, "host", protoOptions.Network) - require.True(t, protoOptions.RemoteBuild) + require.False(t, protoOptions.RemoteBuild) + require.True(t, protoOptions.ImagePassthrough) } func TestServiceBuildResultMapping(t *testing.T) { @@ -725,16 +726,17 @@ func TestFromProtoServiceConfigMapping(t *testing.T) { func TestFromProtoDockerProjectOptionsMapping(t *testing.T) { // Create test proto docker options protoOptions := &azdext.DockerProjectOptions{ - Path: "./Dockerfile.test", - Context: "..", - Platform: "linux/arm64", - Target: "test", - Network: "host", - Registry: "testregistry.azurecr.io", - Image: "testimage", - Tag: "v2.0.0", - RemoteBuild: false, - BuildArgs: []string{"TEST_ARG=test_value"}, + Path: "./Dockerfile.test", + Context: "..", + Platform: "linux/arm64", + Target: "test", + Network: "host", + Registry: "testregistry.azurecr.io", + Image: "testimage", + Tag: "v2.0.0", + RemoteBuild: false, + ImagePassthrough: true, + BuildArgs: []string{"TEST_ARG=test_value"}, } var dockerOptions *DockerProjectOptions @@ -750,6 +752,7 @@ func TestFromProtoDockerProjectOptionsMapping(t *testing.T) { require.Equal(t, "testimage", dockerOptions.Image.MustEnvsubst(func(string) string { return "" })) require.Equal(t, "v2.0.0", dockerOptions.Tag.MustEnvsubst(func(string) string { return "" })) require.False(t, dockerOptions.RemoteBuild) + require.True(t, dockerOptions.ImagePassthrough) require.Len(t, dockerOptions.BuildArgs, 1) require.Equal(t, "TEST_ARG=test_value", dockerOptions.BuildArgs[0].MustEnvsubst(func(string) string { return "" })) } diff --git a/cli/azd/pkg/project/service_target_aks.go b/cli/azd/pkg/project/service_target_aks.go index bd85ec71a1c..f8e7e24081c 100644 --- a/cli/azd/pkg/project/service_target_aks.go +++ b/cli/azd/pkg/project/service_target_aks.go @@ -204,11 +204,11 @@ func (t *aksTarget) Publish( } } - // Only publish the container image if a package output has been defined - // Empty package details is a valid scenario for any AKS deployment that does not build any containers - // Ex) Helm charts, or other manifests that reference external images - if serviceConfig.Docker.RemoteBuild || hasPackage { - // Login, tag & push container image to ACR + // Only handle a container image when remote build, image passthrough, or a package output is configured. + // Empty package details is a valid scenario for an AKS deployment that does not build any containers, + // such as Helm charts or other manifests that reference external images. + if serviceConfig.Docker.RemoteBuild || serviceConfig.Docker.ImagePassthrough || hasPackage { + // Build and publish the image, or pass through the final remote image reference. publishResult, err := t.containerHelper.Publish( ctx, serviceConfig, serviceContext, targetResource, t.env, progress, publishOptions) if err != nil { diff --git a/cli/azd/pkg/project/service_target_appservice.go b/cli/azd/pkg/project/service_target_appservice.go index 18ff767e527..f58f29af4a1 100644 --- a/cli/azd/pkg/project/service_target_appservice.go +++ b/cli/azd/pkg/project/service_target_appservice.go @@ -134,10 +134,11 @@ func (st *appServiceTarget) Publish( var publishResult *ServicePublishResult var err error - // Check if the package artifact is already a remote image reference - if artifact, found := serviceContext.Package.FindFirst(WithKind(ArtifactKindContainer)); found { - if parsedImage, parseErr := docker.ParseContainerImage(artifact.Location); parseErr == nil { - if parsedImage.Registry != "" { + // Check if the package artifact is already a remote image reference. Image passthrough + // is handled by ContainerHelper.Publish so validation and artifact metadata stay consistent. + if !serviceConfig.Docker.ImagePassthrough { + if artifact, found := serviceContext.Package.FindFirst(WithKind(ArtifactKindContainer)); found { + if parsedImage, parseErr := docker.ParseContainerImage(artifact.Location); parseErr == nil && parsedImage.Registry != "" { publishResult = &ServicePublishResult{ Artifacts: ArtifactCollection{ { diff --git a/cli/azd/pkg/project/service_target_appservice_test.go b/cli/azd/pkg/project/service_target_appservice_test.go index 00e1271ff2d..e0261670b65 100644 --- a/cli/azd/pkg/project/service_target_appservice_test.go +++ b/cli/azd/pkg/project/service_target_appservice_test.go @@ -19,6 +19,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azapi" "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/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools/docker" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/mocks/mockazapi" @@ -385,6 +386,44 @@ func Test_appServiceTarget_Publish(t *testing.T) { assert.Equal(t, "myregistry.azurecr.io/myapp:abc123", env.GetServiceProperty("web", "IMAGE_NAME")) }) + + t.Run("ContainerDeploy_ImagePassthrough", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + env := environment.New("test") + envManager := &mockenv.MockEnvManager{} + envManager.On("Save", mock.Anything, mock.Anything).Return(nil) + image := "private.example.com/team/agent:v1" + serviceConfig := &ServiceConfig{ + Name: "web", + Language: ServiceLanguageDocker, + Image: osutil.NewExpandableString(image), + Docker: DockerProjectOptions{ImagePassthrough: true}, + } + serviceContext := NewServiceContext() + require.NoError(t, serviceContext.Package.Add(imagePassthroughArtifact(image))) + targetResource := environment.NewTargetResource( + "SUB_ID", "RG_ID", "WEB_APP_NAME", string(azapi.AzureResourceTypeWebSite), + ) + target := &appServiceTarget{ + env: env, + envManager: envManager, + containerHelper: &ContainerHelper{}, + } + + result, err := target.Publish( + *mockContext.Context, + serviceConfig, + serviceContext, + targetResource, + async.NewNoopProgress[ServiceProgress](), + &PublishOptions{}, + ) + require.NoError(t, err) + require.Len(t, result.Artifacts, 1) + require.Equal(t, image, result.Artifacts[0].Location) + require.Equal(t, "true", result.Artifacts[0].Metadata["imagePassthrough"]) + require.Equal(t, image, env.GetServiceProperty("web", "IMAGE_NAME")) + }) } func Test_NewAppServiceTarget(t *testing.T) { diff --git a/cli/azd/pkg/project/service_target_containerapp.go b/cli/azd/pkg/project/service_target_containerapp.go index c8cfe81ae00..66bda5e253c 100644 --- a/cli/azd/pkg/project/service_target_containerapp.go +++ b/cli/azd/pkg/project/service_target_containerapp.go @@ -123,9 +123,10 @@ func (at *containerAppTarget) Publish( } // Skip publishing to the container registry if packagePath is a remote image reference, - // such as when called through `azd deploy --from-package ` - if parsedImage, err := docker.ParseContainerImage(packagePath); err == nil { - if parsedImage.Registry != "" { + // such as when called through `azd deploy --from-package `. Image passthrough + // is handled by ContainerHelper.Publish so validation and artifact metadata stay consistent. + if !serviceConfig.Docker.ImagePassthrough { + if parsedImage, err := docker.ParseContainerImage(packagePath); err == nil && parsedImage.Registry != "" { publishResult = &ServicePublishResult{ Artifacts: ArtifactCollection{ { diff --git a/cli/azd/pkg/project/service_target_containerapp_test.go b/cli/azd/pkg/project/service_target_containerapp_test.go index bd4f088f3d2..e44066b0508 100644 --- a/cli/azd/pkg/project/service_target_containerapp_test.go +++ b/cli/azd/pkg/project/service_target_containerapp_test.go @@ -22,6 +22,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/infra" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools/docker" "github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet" "github.com/azure/azure-dev/cli/azd/test/mocks" @@ -238,6 +239,39 @@ func Test_ContainerApp_Publish(t *testing.T) { require.Equal(t, "REGISTRY.azurecr.io/test-app/api-test:azd-deploy-0", publishArtifacts[0].Location) } +func Test_ContainerApp_Publish_ImagePassthrough(t *testing.T) { + t.Parallel() + + mockContext := mocks.NewMockContext(t.Context()) + setupMocksForContainerAppTarget(mockContext) + env := createEnv() + serviceTarget := createContainerAppServiceTarget(mockContext, env) + image := "private.example.com/team/agent:v1" + serviceConfig := createTestServiceConfig("", ContainerAppTarget, ServiceLanguageDocker) + serviceConfig.Image = osutil.NewExpandableString(image) + serviceConfig.Docker.ImagePassthrough = true + serviceContext := NewServiceContext() + serviceContext.Package = ArtifactCollection{imagePassthroughArtifact(image)} + targetResource := environment.NewTargetResource( + "SUBSCRIPTION_ID", + "RESOURCE_GROUP", + "CONTAINER_APP", + string(azapi.AzureResourceTypeContainerApp), + ) + + result, err := logProgress( + t, func(progress *async.Progress[ServiceProgress]) (*ServicePublishResult, error) { + return serviceTarget.Publish( + *mockContext.Context, serviceConfig, serviceContext, targetResource, progress, &PublishOptions{}) + }, + ) + require.NoError(t, err) + require.Len(t, result.Artifacts, 1) + require.Equal(t, image, result.Artifacts[0].Location) + require.Equal(t, "true", result.Artifacts[0].Metadata["imagePassthrough"]) + require.Equal(t, image, env.GetServiceProperty(serviceConfig.Name, "IMAGE_NAME")) +} + func createContainerAppServiceTarget( mockContext *mocks.MockContext, env *environment.Environment, diff --git a/docs/reference/azure-yaml-schema.md b/docs/reference/azure-yaml-schema.md index 8803ebccd11..8f5dcada280 100644 --- a/docs/reference/azure-yaml-schema.md +++ b/docs/reference/azure-yaml-schema.md @@ -58,6 +58,35 @@ services: | `uses` | list | Service dependencies | | `remoteBuild` | boolean | Enable remote build for code-based Azure Functions | +### Docker Properties + +| Property | Type | Description | +|---|---|---| +| `path` | string | Path to the Dockerfile | +| `context` | string | Docker build context path | +| `platform` | string | Container platform target | +| `target` | string | Dockerfile build target | +| `registry` | string | Destination container registry | +| `image` | string | Name applied to a built container image | +| `tag` | string | Tag applied to a built container image | +| `buildArgs` | list | Arguments passed to the container build | +| `network` | string | Networking mode for Dockerfile `RUN` instructions | +| `remoteBuild` | boolean | Build and push with Azure Container Registry remote build instead of building locally | +| `imagePassthrough` | boolean | Use the service `image` unchanged without building, pulling, tagging, copying, or publishing it | + +`docker.imagePassthrough` requires the service-level `image` property and cannot be combined with +`docker.remoteBuild`. When omitted or `false`, an external service image can still be pulled and copied into the +configured destination registry. For example: + +```yaml +services: + api: + host: containerapp + image: registry.example.com/apps/api:1.0 + docker: + imagePassthrough: true +``` + ## Hooks Hooks run user-defined scripts at lifecycle points: diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index d103b88fb2a..a998c621d18 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -676,6 +676,30 @@ "remoteBuild": false } } + }, + { + "comment": "imagePassthrough requires a service image and cannot be combined with docker.remoteBuild", + "if": { + "required": ["docker"], + "properties": { + "docker": { + "required": ["imagePassthrough"], + "properties": { + "imagePassthrough": { "const": true } + } + } + } + }, + "then": { + "required": ["image"], + "properties": { + "docker": { + "properties": { + "remoteBuild": { "const": false } + } + } + } + } } ] } @@ -1310,6 +1334,11 @@ "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." + }, + "imagePassthrough": { + "type": "boolean", + "title": "Optional. Whether to use the service image as the final remote artifact", + "description": "If set to true, azd passes the service image to the target unchanged without building, pulling, tagging, copying, or publishing it. Requires the service image property and cannot be combined with docker.remoteBuild." } } }, diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index f7a7c9b5895..71854593b87 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -636,6 +636,30 @@ "remoteBuild": false } } + }, + { + "comment": "imagePassthrough requires a service image and cannot be combined with docker.remoteBuild", + "if": { + "required": ["docker"], + "properties": { + "docker": { + "required": ["imagePassthrough"], + "properties": { + "imagePassthrough": { "const": true } + } + } + } + }, + "then": { + "required": ["image"], + "properties": { + "docker": { + "properties": { + "remoteBuild": { "const": false } + } + } + } + } } ] } @@ -1270,6 +1294,11 @@ "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 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." + }, + "imagePassthrough": { + "type": "boolean", + "title": "Optional. Whether to use the service image as the final remote artifact", + "description": "If set to true, azd passes the service image to the target unchanged without building, pulling, tagging, copying, or publishing it. Requires the service image property and cannot be combined with docker.remoteBuild." } } }, From ce2af1c603d05609cd199a43e0799825e6d9a9d5 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Mon, 17 Aug 2026 19:03:20 +0800 Subject: [PATCH 2/9] fix: wrap app service image parsing condition --- cli/azd/pkg/project/service_target_appservice.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/azd/pkg/project/service_target_appservice.go b/cli/azd/pkg/project/service_target_appservice.go index f58f29af4a1..04dc426bc3e 100644 --- a/cli/azd/pkg/project/service_target_appservice.go +++ b/cli/azd/pkg/project/service_target_appservice.go @@ -138,7 +138,8 @@ func (st *appServiceTarget) Publish( // is handled by ContainerHelper.Publish so validation and artifact metadata stay consistent. if !serviceConfig.Docker.ImagePassthrough { if artifact, found := serviceContext.Package.FindFirst(WithKind(ArtifactKindContainer)); found { - if parsedImage, parseErr := docker.ParseContainerImage(artifact.Location); parseErr == nil && parsedImage.Registry != "" { + if parsedImage, parseErr := docker.ParseContainerImage(artifact.Location); parseErr == nil && + parsedImage.Registry != "" { publishResult = &ServicePublishResult{ Artifacts: ArtifactCollection{ { From f6bcdaf15a033d2afd041a741d2a22eca9353cf8 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Tue, 18 Aug 2026 18:17:40 +0800 Subject: [PATCH 3/9] fix: validate image passthrough publishing --- cli/azd/go.mod | 2 + cli/azd/go.sum | 4 ++ cli/azd/pkg/project/container_helper.go | 23 +++++++--- cli/azd/pkg/project/container_helper_test.go | 9 ++++ cli/azd/pkg/project/service_manager.go | 4 ++ cli/azd/pkg/project/service_manager_test.go | 23 ++++++++++ .../pkg/project/service_target_aks_test.go | 43 +++++++++++++++++++ 7 files changed, 102 insertions(+), 6 deletions(-) diff --git a/cli/azd/go.mod b/cli/azd/go.mod index bb372f4d03a..fa4ed4b3dac 100644 --- a/cli/azd/go.mod +++ b/cli/azd/go.mod @@ -41,6 +41,7 @@ require ( github.com/charmbracelet/glamour v0.10.0 github.com/cli/browser v1.3.0 github.com/denormal/go-gitignore v0.0.0-20180930084346-ae8ad1d07817 + github.com/distribution/reference v0.6.0 github.com/drone/envsubst v1.0.3 github.com/fatih/color v1.18.0 github.com/fsnotify/fsnotify v1.9.0 @@ -127,6 +128,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/otiai10/mint v1.6.3 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/cli/azd/go.sum b/cli/azd/go.sum index 49853c170fa..9d2446f5b66 100644 --- a/cli/azd/go.sum +++ b/cli/azd/go.sum @@ -140,6 +140,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denormal/go-gitignore v0.0.0-20180930084346-ae8ad1d07817 h1:0nsrg//Dc7xC74H/TZ5sYR8uk4UQRNjsw8zejqH5a4Q= github.com/denormal/go-gitignore v0.0.0-20180930084346-ae8ad1d07817/go.mod h1:C/+sI4IFnEpCn6VQ3GIPEp+FrQnQw+YQP3+n+GdGq7o= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= @@ -248,6 +250,8 @@ github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d/g github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= diff --git a/cli/azd/pkg/project/container_helper.go b/cli/azd/pkg/project/container_helper.go index 25add2ffbc7..5c48116ff51 100644 --- a/cli/azd/pkg/project/container_helper.go +++ b/cli/azd/pkg/project/container_helper.go @@ -40,6 +40,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet" "github.com/azure/azure-dev/cli/azd/pkg/tools/pack" "github.com/benbjohnson/clock" + "github.com/distribution/reference" "github.com/sethvargo/go-retry" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -274,11 +275,12 @@ func resolveImagePassthrough( return "", fmt.Errorf("docker.imagePassthrough requires the service image property") } - parsed, err := docker.ParseContainerImage(image) - if err != nil { + if _, err := reference.Parse(image); err != nil { return "", fmt.Errorf("parsing passthrough image: %w", err) } - return parsed.Remote(), nil + + // Passthrough preserves the expanded reference exactly, including tag and digest combinations. + return image, nil } func imagePassthroughArtifact(image string) *Artifact { @@ -666,6 +668,14 @@ func (ch *ContainerHelper) Package( }, nil } +func validatePublishOptions(serviceConfig *ServiceConfig, options *PublishOptions) error { + if serviceConfig.Docker.ImagePassthrough && options != nil && options.Image != "" { + return fmt.Errorf("docker.imagePassthrough cannot be combined with a publish image override") + } + + return nil +} + // Publish pushes an image to a remote server and returns the fully qualified remote image name. func (ch *ContainerHelper) Publish( ctx context.Context, @@ -684,6 +694,10 @@ func (ch *ContainerHelper) Publish( var remoteImage string + if err := validatePublishOptions(serviceConfig, options); err != nil { + return nil, err + } + // Parse PublishOptions into ImageOverride imageOverride, err := parseImageOverride(options) if err != nil { @@ -691,9 +705,6 @@ func (ch *ContainerHelper) Publish( } if serviceConfig.Docker.ImagePassthrough { - if imageOverride != nil { - return nil, fmt.Errorf("docker.imagePassthrough cannot be combined with a publish image override") - } remoteImage, err = resolveImagePassthrough(serviceConfig, env) } else if serviceConfig.Docker.RemoteBuild { remoteImage, err = ch.runRemoteBuild(ctx, serviceConfig, targetResource, env, progress, imageOverride) diff --git a/cli/azd/pkg/project/container_helper_test.go b/cli/azd/pkg/project/container_helper_test.go index e8c5ec30731..f25ca65ee0a 100644 --- a/cli/azd/pkg/project/container_helper_test.go +++ b/cli/azd/pkg/project/container_helper_test.go @@ -1300,6 +1300,15 @@ func TestResolveImagePassthrough(t *testing.T) { env: map[string]string{"PRIVATE_REGISTRY": "private.example.com"}, want: "private.example.com/team/agent:v1", }, + { + name: "preserves tag and digest", + image: "${PRIVATE_REGISTRY}/team/agent:v1@sha256:" + + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + docker: DockerProjectOptions{ImagePassthrough: true}, + env: map[string]string{"PRIVATE_REGISTRY": "private.example.com"}, + want: "private.example.com/team/agent:v1@sha256:" + + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, { name: "requires service image", docker: DockerProjectOptions{ImagePassthrough: true}, diff --git a/cli/azd/pkg/project/service_manager.go b/cli/azd/pkg/project/service_manager.go index 82d6cc173a5..6a74130159c 100644 --- a/cli/azd/pkg/project/service_manager.go +++ b/cli/azd/pkg/project/service_manager.go @@ -502,6 +502,10 @@ func (sm *serviceManager) Publish( progress *async.Progress[ServiceProgress], publishOptions *PublishOptions, ) (*ServicePublishResult, error) { + if err := validatePublishOptions(serviceConfig, publishOptions); err != nil { + return nil, err + } + if serviceContext == nil { serviceContext = NewServiceContext() } diff --git a/cli/azd/pkg/project/service_manager_test.go b/cli/azd/pkg/project/service_manager_test.go index cf2f10b14ec..0d52a29f25e 100644 --- a/cli/azd/pkg/project/service_manager_test.go +++ b/cli/azd/pkg/project/service_manager_test.go @@ -320,6 +320,29 @@ func Test_ServiceManager_Publish(t *testing.T) { require.True(t, raisedPostPublishEvent) } +func Test_ServiceManager_Publish_RejectsImageOverrideForPassthrough(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + setupMocksForServiceManager(mockContext) + env := environment.New("test") + sm := createServiceManager(mockContext, env, ServiceOperationCache{}) + serviceConfig := createTestServiceConfig("./src/api", ServiceTargetFake, ServiceLanguageFake) + serviceConfig.Docker.ImagePassthrough = true + + publishCalled := new(false) + ctx := context.WithValue(*mockContext.Context, serviceTargetPublishCalled, publishCalled) + + _, err := sm.Publish( + ctx, + serviceConfig, + NewServiceContext(), + async.NewProgress[ServiceProgress](), + &PublishOptions{Image: "other.example.com/team/agent:v2"}, + ) + + require.ErrorContains(t, err, "docker.imagePassthrough cannot be combined with a publish image override") + require.False(t, *publishCalled) +} + func Test_ServiceManager_GetFrameworkService(t *testing.T) { t.Run("Standard", func(t *testing.T) { mockContext := mocks.NewMockContext(t.Context()) diff --git a/cli/azd/pkg/project/service_target_aks_test.go b/cli/azd/pkg/project/service_target_aks_test.go index 52cfd1277de..13e651edfee 100644 --- a/cli/azd/pkg/project/service_target_aks_test.go +++ b/cli/azd/pkg/project/service_target_aks_test.go @@ -245,6 +245,49 @@ func Test_AKS_Publish(t *testing.T) { require.Equal(t, "REGISTRY.azurecr.io/test-app/api-test:azd-deploy-0", publishArtifacts[0].Location) } +func Test_AKS_Publish_ImagePassthrough(t *testing.T) { + tempDir := t.TempDir() + ostest.Chdir(t, tempDir) + + mockContext := mocks.NewMockContext(t.Context()) + err := setupMocksForAksTarget(mockContext) + require.NoError(t, err) + + const image = "private.example.com/team/agent:v1" + serviceConfig := createTestServiceConfig(tempDir, AksTarget, ServiceLanguageTypeScript) + serviceConfig.Image = osutil.NewExpandableString(image) + serviceConfig.Docker.ImagePassthrough = true + env := createEnv() + azdCtx := createTestAzdContext(t, env) + + serviceTarget := createAksServiceTarget(mockContext, serviceConfig, env, nil, azdCtx) + err = simulateInitliaze(*mockContext.Context, serviceTarget, serviceConfig) + require.NoError(t, err) + + serviceContext := NewServiceContext() + serviceContext.Package = ArtifactCollection{} + scope := environment.NewTargetResource("SUB_ID", "RG_ID", "", string(azapi.AzureResourceTypeManagedCluster)) + + publishResult, err := logProgress( + t, func(progress *async.Progress[ServiceProgress]) (*ServicePublishResult, error) { + return serviceTarget.Publish( + *mockContext.Context, serviceConfig, serviceContext, scope, progress, &PublishOptions{}) + }, + ) + + require.NoError(t, err) + require.NotNil(t, publishResult) + require.Len(t, publishResult.Artifacts, 1) + require.Equal(t, image, env.Dotenv()["SERVICE_API_IMAGE_NAME"]) + + artifact := publishResult.Artifacts[0] + require.Equal(t, ArtifactKindContainer, artifact.Kind) + require.Equal(t, image, artifact.Location) + require.Equal(t, LocationKindRemote, artifact.LocationKind) + require.Equal(t, "true", artifact.Metadata["imagePassthrough"]) + require.Equal(t, image, artifact.Metadata["remoteImage"]) +} + func Test_AKS_Publish_NoContainer(t *testing.T) { tempDir := t.TempDir() ostest.Chdir(t, tempDir) From ffb42cba72818cd840ebb8161d9759b4dc5231dd Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Tue, 18 Aug 2026 19:06:59 +0800 Subject: [PATCH 4/9] fix: reject passthrough package overrides --- cli/azd/pkg/project/container_helper.go | 16 +++++++++++ cli/azd/pkg/project/container_helper_test.go | 12 ++++++++ cli/azd/pkg/project/service_manager.go | 3 ++ cli/azd/pkg/project/service_manager_test.go | 29 ++++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/cli/azd/pkg/project/container_helper.go b/cli/azd/pkg/project/container_helper.go index 5c48116ff51..900c37219a0 100644 --- a/cli/azd/pkg/project/container_helper.go +++ b/cli/azd/pkg/project/container_helper.go @@ -676,6 +676,19 @@ func validatePublishOptions(serviceConfig *ServiceConfig, options *PublishOption return nil } +func validateImagePassthroughPackage(serviceConfig *ServiceConfig, serviceContext *ServiceContext) error { + if !serviceConfig.Docker.ImagePassthrough || serviceContext == nil { + return nil + } + + artifact, found := serviceContext.Package.FindFirst(WithKind(ArtifactKindContainer)) + if found && artifact.Location != "" && artifact.Metadata["imagePassthrough"] != "true" { + return fmt.Errorf("docker.imagePassthrough cannot be combined with a package image override") + } + + return nil +} + // Publish pushes an image to a remote server and returns the fully qualified remote image name. func (ch *ContainerHelper) Publish( ctx context.Context, @@ -697,6 +710,9 @@ func (ch *ContainerHelper) Publish( if err := validatePublishOptions(serviceConfig, options); err != nil { return nil, err } + if err := validateImagePassthroughPackage(serviceConfig, serviceContext); err != nil { + return nil, err + } // Parse PublishOptions into ImageOverride imageOverride, err := parseImageOverride(options) diff --git a/cli/azd/pkg/project/container_helper_test.go b/cli/azd/pkg/project/container_helper_test.go index f25ca65ee0a..3516b018fef 100644 --- a/cli/azd/pkg/project/container_helper_test.go +++ b/cli/azd/pkg/project/container_helper_test.go @@ -1452,6 +1452,18 @@ func Test_ContainerHelper_Publish(t *testing.T) { expectDockerPushCalled: false, expectError: true, }, + { + name: "Image passthrough rejects package image override", + image: "private.example.com/team/agent:v1", + packagePath: "other.example.com/team/agent:v2", + imagePassthrough: true, + publishOptions: &PublishOptions{}, + expectDockerLoginCalled: false, + expectDockerPullCalled: false, + expectDockerTagCalled: false, + expectDockerPushCalled: false, + expectError: true, + }, { name: "With publish options overwrite", project: "./src/api", diff --git a/cli/azd/pkg/project/service_manager.go b/cli/azd/pkg/project/service_manager.go index 6a74130159c..620b3900557 100644 --- a/cli/azd/pkg/project/service_manager.go +++ b/cli/azd/pkg/project/service_manager.go @@ -509,6 +509,9 @@ func (sm *serviceManager) Publish( if serviceContext == nil { serviceContext = NewServiceContext() } + if err := validateImagePassthroughPackage(serviceConfig, serviceContext); err != nil { + return nil, err + } cachedResult, ok := sm.getOperationResult(serviceConfig, ServiceEventPublish) if ok && cachedResult != nil { diff --git a/cli/azd/pkg/project/service_manager_test.go b/cli/azd/pkg/project/service_manager_test.go index 0d52a29f25e..5af888a122f 100644 --- a/cli/azd/pkg/project/service_manager_test.go +++ b/cli/azd/pkg/project/service_manager_test.go @@ -343,6 +343,35 @@ func Test_ServiceManager_Publish_RejectsImageOverrideForPassthrough(t *testing.T require.False(t, *publishCalled) } +func Test_ServiceManager_Publish_RejectsPackageOverrideForPassthrough(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + setupMocksForServiceManager(mockContext) + env := environment.New("test") + sm := createServiceManager(mockContext, env, ServiceOperationCache{}) + serviceConfig := createTestServiceConfig("./src/api", ServiceTargetFake, ServiceLanguageFake) + serviceConfig.Docker.ImagePassthrough = true + + publishCalled := new(false) + ctx := context.WithValue(*mockContext.Context, serviceTargetPublishCalled, publishCalled) + serviceContext := NewServiceContext() + require.NoError(t, serviceContext.Package.Add(&Artifact{ + Kind: ArtifactKindContainer, + Location: "other.example.com/team/agent:v2", + LocationKind: LocationKindLocal, + })) + + _, err := sm.Publish( + ctx, + serviceConfig, + serviceContext, + async.NewProgress[ServiceProgress](), + &PublishOptions{}, + ) + + require.ErrorContains(t, err, "docker.imagePassthrough cannot be combined with a package image override") + require.False(t, *publishCalled) +} + func Test_ServiceManager_GetFrameworkService(t *testing.T) { t.Run("Standard", func(t *testing.T) { mockContext := mocks.NewMockContext(t.Context()) From c26d104c3c5900568dade715c8014b1d1e597fb2 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Wed, 19 Aug 2026 16:24:47 +0800 Subject: [PATCH 5/9] fix: preserve passthrough package overrides --- cli/azd/internal/cmd/publish.go | 77 +++++++++--- cli/azd/internal/cmd/publish_test.go | 62 ++++++++++ cli/azd/pkg/project/artifact.go | 3 + cli/azd/pkg/project/container_helper.go | 61 +++++++--- cli/azd/pkg/project/container_helper_test.go | 113 +++++++++++++++--- cli/azd/pkg/project/service_manager.go | 2 +- cli/azd/pkg/project/service_manager_test.go | 91 +++++++++++++- .../pkg/project/service_target_aks_test.go | 101 ++++++++++------ .../project/service_target_appservice_test.go | 44 ++++++- .../service_target_containerapp_test.go | 79 +++++++----- docs/reference/azure-yaml-schema.md | 25 +++- schemas/alpha/azure.yaml.json | 2 +- schemas/v1.0/azure.yaml.json | 2 +- 13 files changed, 535 insertions(+), 127 deletions(-) create mode 100644 cli/azd/internal/cmd/publish_test.go diff --git a/cli/azd/internal/cmd/publish.go b/cli/azd/internal/cmd/publish.go index 4d109640e1c..a689d231dc9 100644 --- a/cli/azd/internal/cmd/publish.go +++ b/cli/azd/internal/cmd/publish.go @@ -209,6 +209,23 @@ func (pa *PublishAction) Run(ctx context.Context) (*actions.ActionResult, error) } } + // Create publish options from flags + publishOptions := &project.PublishOptions{ + Image: pa.flags.To, + } + + if err := pa.projectManager.Initialize(ctx, pa.projectConfig); err != nil { + return nil, err + } + + stableServices, err := pa.importManager.ServiceStableFiltered(ctx, pa.projectConfig, targetServiceName, pa.env.Getenv) + if err != nil { + return nil, err + } + if err := validateImagePassthroughPublishFlags(stableServices, pa.flags); err != nil { + return nil, err + } + if pa.flags.FromPackage != "" { if parsedImage, err := docker.ParseContainerImage(pa.flags.FromPackage); err == nil && parsedImage.Registry != "" { return nil, &internal.ErrorWithSuggestion{ @@ -220,15 +237,6 @@ func (pa *PublishAction) Run(ctx context.Context) (*actions.ActionResult, error) } } - // Create publish options from flags - publishOptions := &project.PublishOptions{ - Image: pa.flags.To, - } - - if err := pa.projectManager.Initialize(ctx, pa.projectConfig); err != nil { - return nil, err - } - if err := pa.projectManager.EnsureServiceTargetTools(ctx, pa.projectConfig, func(svc *project.ServiceConfig) bool { return targetServiceName == "" || svc.Name == targetServiceName }); err != nil { @@ -242,16 +250,12 @@ func (pa *PublishAction) Run(ctx context.Context) (*actions.ActionResult, error) startTime := time.Now() - stableServices, err := pa.importManager.ServiceStableFiltered(ctx, pa.projectConfig, targetServiceName, pa.env.Getenv) - if err != nil { - return nil, err - } - projectEventArgs := project.ProjectLifecycleEventArgs{ Project: pa.projectConfig, } publishResults := map[string]*project.ServicePublishResult{} + passthroughServiceCount := 0 err = pa.projectConfig.Invoke(ctx, project.ProjectEventPublish, projectEventArgs, func() error { for _, svc := range stableServices { @@ -352,7 +356,13 @@ func (pa *PublishAction) Run(ctx context.Context) (*actions.ActionResult, error) } } - pa.console.StopSpinner(ctx, stepMessage, input.GetStepResultFormat(err)) + if svc.Docker.ImagePassthrough { + passthroughServiceCount++ + stepMessage = fmt.Sprintf("Publishing service %s (using existing remote image)", svc.Name) + pa.console.StopSpinner(ctx, stepMessage, input.StepSkipped) + } else { + pa.console.StopSpinner(ctx, stepMessage, input.GetStepResultFormat(err)) + } publishResults[svc.Name] = publishResult pa.console.MessageUxItem(ctx, publishResult.Artifacts) @@ -376,14 +386,43 @@ func (pa *PublishAction) Run(ctx context.Context) (*actions.ActionResult, error) } } + resultHeader := fmt.Sprintf("Your application was published in %s.", ux.DurationAsText(since(startTime))) + if passthroughServiceCount == len(publishResults) && passthroughServiceCount > 0 { + resultHeader = "No images were published. Existing remote images are configured for deployment." + } else if passthroughServiceCount > 0 { + resultHeader += " Existing remote images were used for image passthrough services." + } + return &actions.ActionResult{ - Message: &actions.ResultMessage{ - Header: fmt.Sprintf("Your application was published in %s.", - ux.DurationAsText(since(startTime))), - }, + Message: &actions.ResultMessage{Header: resultHeader}, }, nil } +func validateImagePassthroughPublishFlags(services []*project.ServiceConfig, flags *PublishFlags) error { + for _, svc := range services { + if !svc.Docker.ImagePassthrough { + continue + } + if flags.FromPackage != "" { + return fmt.Errorf( + "--from-package is not supported by azd publish for image passthrough service %q; "+ + "use azd deploy %s --from-package to override its image", + svc.Name, + svc.Name, + ) + } + if flags.To != "" { + return fmt.Errorf( + "--to is not supported by azd publish for image passthrough service %q; "+ + "disable docker.imagePassthrough to publish the image", + svc.Name, + ) + } + } + + return nil +} + // supportsPublish checks if the service host supports publishing. func (pa *PublishAction) supportsPublish(ctx context.Context, serviceConfig *project.ServiceConfig) bool { // Built-in container targets support publish diff --git a/cli/azd/internal/cmd/publish_test.go b/cli/azd/internal/cmd/publish_test.go new file mode 100644 index 00000000000..ae76c39b4f5 --- /dev/null +++ b/cli/azd/internal/cmd/publish_test.go @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/stretchr/testify/require" +) + +func TestValidateImagePassthroughPublishFlags(t *testing.T) { + passthroughService := &project.ServiceConfig{ + Name: "api", + Docker: project.DockerProjectOptions{ + ImagePassthrough: true, + }, + } + normalService := &project.ServiceConfig{Name: "web"} + + tests := []struct { + name string + services []*project.ServiceConfig + flags *PublishFlags + errorContains string + }{ + { + name: "passthrough without overrides", + services: []*project.ServiceConfig{passthroughService}, + flags: &PublishFlags{}, + }, + { + name: "normal service with from package", + services: []*project.ServiceConfig{normalService}, + flags: &PublishFlags{FromPackage: "api:v1"}, + }, + { + name: "passthrough with from package", + services: []*project.ServiceConfig{passthroughService}, + flags: &PublishFlags{FromPackage: "registry.example.com/team/api:v2"}, + errorContains: "--from-package is not supported by azd publish", + }, + { + name: "passthrough with destination override", + services: []*project.ServiceConfig{passthroughService}, + flags: &PublishFlags{To: "registry.example.com/team/api:v2"}, + errorContains: "--to is not supported by azd publish", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateImagePassthroughPublishFlags(tt.services, tt.flags) + if tt.errorContains == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tt.errorContains) + } + }) + } +} diff --git a/cli/azd/pkg/project/artifact.go b/cli/azd/pkg/project/artifact.go index a365c629296..6ccadc9b022 100644 --- a/cli/azd/pkg/project/artifact.go +++ b/cli/azd/pkg/project/artifact.go @@ -22,6 +22,9 @@ const ( // MetadataKeyNote adds a note line below the artifact output. MetadataKeyNote = "note" + + // MetadataKeyImagePassthrough identifies a remote container image that azd reused without publishing. + MetadataKeyImagePassthrough = "imagePassthrough" ) // ArtifactKind represents well-known artifact types in the Azure Developer CLI diff --git a/cli/azd/pkg/project/container_helper.go b/cli/azd/pkg/project/container_helper.go index 900c37219a0..45087cf9bc6 100644 --- a/cli/azd/pkg/project/container_helper.go +++ b/cli/azd/pkg/project/container_helper.go @@ -275,8 +275,8 @@ func resolveImagePassthrough( return "", fmt.Errorf("docker.imagePassthrough requires the service image property") } - if _, err := reference.Parse(image); err != nil { - return "", fmt.Errorf("parsing passthrough image: %w", err) + if _, err := reference.ParseNamed(image); err != nil { + return "", fmt.Errorf("passthrough image must be a fully qualified remote container image: %w", err) } // Passthrough preserves the expanded reference exactly, including tag and digest combinations. @@ -289,9 +289,9 @@ func imagePassthroughArtifact(image string) *Artifact { Location: image, LocationKind: LocationKindRemote, Metadata: map[string]string{ - "imagePassthrough": "true", - "remoteImage": image, - "sourceImage": image, + MetadataKeyImagePassthrough: "true", + "remoteImage": image, + "sourceImage": image, }, } } @@ -676,17 +676,40 @@ func validatePublishOptions(serviceConfig *ServiceConfig, options *PublishOption return nil } -func validateImagePassthroughPackage(serviceConfig *ServiceConfig, serviceContext *ServiceContext) error { - if !serviceConfig.Docker.ImagePassthrough || serviceContext == nil { - return nil +func imagePassthroughPackageOverride( + serviceConfig *ServiceConfig, + serviceContext *ServiceContext, +) (string, bool, error) { + if !serviceConfig.Docker.ImagePassthrough || serviceContext == nil || len(serviceContext.Package) == 0 { + return "", false, nil } - artifact, found := serviceContext.Package.FindFirst(WithKind(ArtifactKindContainer)) - if found && artifact.Location != "" && artifact.Metadata["imagePassthrough"] != "true" { - return fmt.Errorf("docker.imagePassthrough cannot be combined with a package image override") + if len(serviceContext.Package) != 1 { + return "", false, fmt.Errorf( + "docker.imagePassthrough supports exactly one --from-package artifact, got %d", + len(serviceContext.Package), + ) } - return nil + artifact := serviceContext.Package[0] + if artifact.Kind != ArtifactKindContainer { + return "", false, fmt.Errorf( + "docker.imagePassthrough does not support %s artifacts from --from-package; "+ + "use a fully qualified remote container image", + artifact.Kind, + ) + } + + if _, err := reference.ParseNamed(artifact.Location); err != nil { + return "", false, fmt.Errorf( + "docker.imagePassthrough requires --from-package to be a fully qualified remote container image: %w", + err, + ) + } + + // The package artifact is the per-run input selected by --from-package. Its location wins over + // the service image while imagePassthrough continues to control how the selected image is handled. + return artifact.Location, true, nil } // Publish pushes an image to a remote server and returns the fully qualified remote image name. @@ -710,7 +733,11 @@ func (ch *ContainerHelper) Publish( if err := validatePublishOptions(serviceConfig, options); err != nil { return nil, err } - if err := validateImagePassthroughPackage(serviceConfig, serviceContext); err != nil { + passthroughOverride, hasPassthroughOverride, err := imagePassthroughPackageOverride( + serviceConfig, + serviceContext, + ) + if err != nil { return nil, err } @@ -721,7 +748,11 @@ func (ch *ContainerHelper) Publish( } if serviceConfig.Docker.ImagePassthrough { - remoteImage, err = resolveImagePassthrough(serviceConfig, env) + if hasPassthroughOverride { + remoteImage = passthroughOverride + } else { + remoteImage, err = resolveImagePassthrough(serviceConfig, env) + } } else if serviceConfig.Docker.RemoteBuild { remoteImage, err = ch.runRemoteBuild(ctx, serviceConfig, targetResource, env, progress, imageOverride) if err != nil { @@ -752,7 +783,7 @@ func (ch *ContainerHelper) Publish( // Create publish artifact with remote image reference metadata := map[string]string{"remoteImage": remoteImage} if serviceConfig.Docker.ImagePassthrough { - metadata["imagePassthrough"] = "true" + metadata[MetadataKeyImagePassthrough] = "true" } publishArtifact := &Artifact{ Kind: ArtifactKindContainer, diff --git a/cli/azd/pkg/project/container_helper_test.go b/cli/azd/pkg/project/container_helper_test.go index 3516b018fef..448aa54bbc9 100644 --- a/cli/azd/pkg/project/container_helper_test.go +++ b/cli/azd/pkg/project/container_helper_test.go @@ -1315,6 +1315,13 @@ func TestResolveImagePassthrough(t *testing.T) { wantErr: true, errContains: "requires the service image property", }, + { + name: "requires fully qualified remote service image", + image: "team/agent:v1", + docker: DockerProjectOptions{ImagePassthrough: true}, + wantErr: true, + errContains: "fully qualified remote container image", + }, { name: "conflicts with remote build", image: "private.example.com/team/agent:v1", @@ -1355,6 +1362,8 @@ func Test_ContainerHelper_Publish(t *testing.T) { image string project string packagePath string + packageKind ArtifactKind + packageLocationKind LocationKind imageHash string sourceImage string targetImage string @@ -1366,6 +1375,7 @@ func Test_ContainerHelper_Publish(t *testing.T) { expectDockerTagCalled bool expectDockerPushCalled bool expectError bool + expectedError string }{ { name: "Source code and registry", @@ -1453,9 +1463,71 @@ func Test_ContainerHelper_Publish(t *testing.T) { expectError: true, }, { - name: "Image passthrough rejects package image override", + name: "Image passthrough uses remote package image override", image: "private.example.com/team/agent:v1", packagePath: "other.example.com/team/agent:v2", + packageKind: ArtifactKindContainer, + packageLocationKind: LocationKindLocal, + imagePassthrough: true, + publishOptions: &PublishOptions{}, + expectDockerLoginCalled: false, + expectDockerPullCalled: false, + expectDockerTagCalled: false, + expectDockerPushCalled: false, + expectedRemoteImage: "other.example.com/team/agent:v2", + }, + { + name: "Image passthrough preserves tag and digest package override", + image: "private.example.com/team/agent:v1", + packagePath: "other.example.com/team/agent:v2@sha256:" + + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + packageKind: ArtifactKindContainer, + packageLocationKind: LocationKindLocal, + imagePassthrough: true, + publishOptions: &PublishOptions{}, + expectDockerLoginCalled: false, + expectDockerPullCalled: false, + expectDockerTagCalled: false, + expectDockerPushCalled: false, + expectedRemoteImage: "other.example.com/team/agent:v2@sha256:" + + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + { + name: "Image passthrough rejects local package image override", + image: "private.example.com/team/agent:v1", + packagePath: "team/agent:v2", + packageKind: ArtifactKindContainer, + packageLocationKind: LocationKindLocal, + imagePassthrough: true, + publishOptions: &PublishOptions{}, + expectDockerLoginCalled: false, + expectDockerPullCalled: false, + expectDockerTagCalled: false, + expectDockerPushCalled: false, + expectError: true, + expectedError: "fully qualified remote container image", + }, + { + name: "Image passthrough rejects archive package override", + image: "private.example.com/team/agent:v1", + packagePath: "agent.zip", + packageKind: ArtifactKindArchive, + packageLocationKind: LocationKindLocal, + imagePassthrough: true, + publishOptions: &PublishOptions{}, + expectDockerLoginCalled: false, + expectDockerPullCalled: false, + expectDockerTagCalled: false, + expectDockerPushCalled: false, + expectError: true, + expectedError: "does not support archive artifacts", + }, + { + name: "Image passthrough rejects directory package override", + image: "private.example.com/team/agent:v1", + packagePath: "agent", + packageKind: ArtifactKindDirectory, + packageLocationKind: LocationKindLocal, imagePassthrough: true, publishOptions: &PublishOptions{}, expectDockerLoginCalled: false, @@ -1463,6 +1535,7 @@ func Test_ContainerHelper_Publish(t *testing.T) { expectDockerTagCalled: false, expectDockerPushCalled: false, expectError: true, + expectedError: "does not support directory artifacts", }, { name: "With publish options overwrite", @@ -1567,24 +1640,29 @@ func Test_ContainerHelper_Publish(t *testing.T) { serviceConfig.Docker.Registry = tt.registry serviceConfig.Docker.ImagePassthrough = tt.imagePassthrough - packageOutput := &ServicePackageResult{ - Artifacts: ArtifactCollection{ - { - Kind: ArtifactKindContainer, - Location: tt.packagePath, - LocationKind: LocationKindLocal, - Metadata: map[string]string{ - "imageHash": tt.imageHash, - "sourceImage": tt.sourceImage, - "targetImage": tt.targetImage, - }, + packageArtifacts := ArtifactCollection{} + if tt.packagePath != "" || tt.imageHash != "" || tt.sourceImage != "" || tt.targetImage != "" { + packageKind := tt.packageKind + if packageKind == "" { + packageKind = ArtifactKindContainer + } + packageLocationKind := tt.packageLocationKind + if packageLocationKind == "" { + packageLocationKind = LocationKindLocal + } + packageArtifacts = append(packageArtifacts, &Artifact{ + Kind: packageKind, + Location: tt.packagePath, + LocationKind: packageLocationKind, + Metadata: map[string]string{ + "imageHash": tt.imageHash, + "sourceImage": tt.sourceImage, + "targetImage": tt.targetImage, }, - }, + }) } - serviceContext := &ServiceContext{ - Package: packageOutput.Artifacts, - } + serviceContext := &ServiceContext{Package: packageArtifacts} publishResult, err := logProgress( t, func(progress *async.Progress[ServiceProgress]) (*ServicePublishResult, error) { @@ -1596,6 +1674,9 @@ func Test_ContainerHelper_Publish(t *testing.T) { if tt.expectError { require.Error(t, err) + if tt.expectedError != "" { + require.ErrorContains(t, err, tt.expectedError) + } } else { require.NoError(t, err) require.NotNil(t, publishResult) diff --git a/cli/azd/pkg/project/service_manager.go b/cli/azd/pkg/project/service_manager.go index 620b3900557..19e8c8b771b 100644 --- a/cli/azd/pkg/project/service_manager.go +++ b/cli/azd/pkg/project/service_manager.go @@ -509,7 +509,7 @@ func (sm *serviceManager) Publish( if serviceContext == nil { serviceContext = NewServiceContext() } - if err := validateImagePassthroughPackage(serviceConfig, serviceContext); err != nil { + if _, _, err := imagePassthroughPackageOverride(serviceConfig, serviceContext); err != nil { return nil, err } diff --git a/cli/azd/pkg/project/service_manager_test.go b/cli/azd/pkg/project/service_manager_test.go index 5af888a122f..1c1954f3b3e 100644 --- a/cli/azd/pkg/project/service_manager_test.go +++ b/cli/azd/pkg/project/service_manager_test.go @@ -343,10 +343,12 @@ func Test_ServiceManager_Publish_RejectsImageOverrideForPassthrough(t *testing.T require.False(t, *publishCalled) } -func Test_ServiceManager_Publish_RejectsPackageOverrideForPassthrough(t *testing.T) { +func Test_ServiceManager_Publish_AcceptsRemotePackageOverrideForPassthrough(t *testing.T) { mockContext := mocks.NewMockContext(t.Context()) setupMocksForServiceManager(mockContext) - env := environment.New("test") + env := environment.NewWithValues("test", map[string]string{ + environment.SubscriptionIdEnvVarName: "SUBSCRIPTION_ID", + }) sm := createServiceManager(mockContext, env, ServiceOperationCache{}) serviceConfig := createTestServiceConfig("./src/api", ServiceTargetFake, ServiceLanguageFake) serviceConfig.Docker.ImagePassthrough = true @@ -368,8 +370,89 @@ func Test_ServiceManager_Publish_RejectsPackageOverrideForPassthrough(t *testing &PublishOptions{}, ) - require.ErrorContains(t, err, "docker.imagePassthrough cannot be combined with a package image override") - require.False(t, *publishCalled) + require.NoError(t, err) + require.True(t, *publishCalled) +} + +func Test_ServiceManager_Publish_RejectsInvalidPackageOverrideForPassthrough(t *testing.T) { + tests := []struct { + name string + artifact *Artifact + additionalArtifact *Artifact + errorContains string + }{ + { + name: "local container image", + artifact: &Artifact{ + Kind: ArtifactKindContainer, + Location: "team/agent:v2", + LocationKind: LocationKindLocal, + }, + errorContains: "fully qualified remote container image", + }, + { + name: "archive", + artifact: &Artifact{ + Kind: ArtifactKindArchive, + Location: "agent.zip", + LocationKind: LocationKindLocal, + }, + errorContains: "does not support archive artifacts", + }, + { + name: "directory", + artifact: &Artifact{ + Kind: ArtifactKindDirectory, + Location: "agent", + LocationKind: LocationKindLocal, + }, + errorContains: "does not support directory artifacts", + }, + { + name: "multiple artifacts", + artifact: &Artifact{ + Kind: ArtifactKindContainer, + Location: "other.example.com/team/agent:v2", + LocationKind: LocationKindLocal, + }, + additionalArtifact: &Artifact{ + Kind: ArtifactKindArchive, + Location: "agent.zip", + LocationKind: LocationKindLocal, + }, + errorContains: "supports exactly one --from-package artifact", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + setupMocksForServiceManager(mockContext) + env := environment.New("test") + sm := createServiceManager(mockContext, env, ServiceOperationCache{}) + serviceConfig := createTestServiceConfig("./src/api", ServiceTargetFake, ServiceLanguageFake) + serviceConfig.Docker.ImagePassthrough = true + + publishCalled := new(false) + ctx := context.WithValue(*mockContext.Context, serviceTargetPublishCalled, publishCalled) + serviceContext := NewServiceContext() + require.NoError(t, serviceContext.Package.Add(tt.artifact)) + if tt.additionalArtifact != nil { + require.NoError(t, serviceContext.Package.Add(tt.additionalArtifact)) + } + + _, err := sm.Publish( + ctx, + serviceConfig, + serviceContext, + async.NewProgress[ServiceProgress](), + &PublishOptions{}, + ) + + require.ErrorContains(t, err, tt.errorContains) + require.False(t, *publishCalled) + }) + } } func Test_ServiceManager_GetFrameworkService(t *testing.T) { diff --git a/cli/azd/pkg/project/service_target_aks_test.go b/cli/azd/pkg/project/service_target_aks_test.go index 13e651edfee..9f253e217a1 100644 --- a/cli/azd/pkg/project/service_target_aks_test.go +++ b/cli/azd/pkg/project/service_target_aks_test.go @@ -246,46 +246,71 @@ func Test_AKS_Publish(t *testing.T) { } func Test_AKS_Publish_ImagePassthrough(t *testing.T) { - tempDir := t.TempDir() - ostest.Chdir(t, tempDir) - - mockContext := mocks.NewMockContext(t.Context()) - err := setupMocksForAksTarget(mockContext) - require.NoError(t, err) - - const image = "private.example.com/team/agent:v1" - serviceConfig := createTestServiceConfig(tempDir, AksTarget, ServiceLanguageTypeScript) - serviceConfig.Image = osutil.NewExpandableString(image) - serviceConfig.Docker.ImagePassthrough = true - env := createEnv() - azdCtx := createTestAzdContext(t, env) - - serviceTarget := createAksServiceTarget(mockContext, serviceConfig, env, nil, azdCtx) - err = simulateInitliaze(*mockContext.Context, serviceTarget, serviceConfig) - require.NoError(t, err) - - serviceContext := NewServiceContext() - serviceContext.Package = ArtifactCollection{} - scope := environment.NewTargetResource("SUB_ID", "RG_ID", "", string(azapi.AzureResourceTypeManagedCluster)) - - publishResult, err := logProgress( - t, func(progress *async.Progress[ServiceProgress]) (*ServicePublishResult, error) { - return serviceTarget.Publish( - *mockContext.Context, serviceConfig, serviceContext, scope, progress, &PublishOptions{}) + tests := []struct { + name string + packageArtifacts ArtifactCollection + wantImage string + }{ + { + name: "configured image", + wantImage: "private.example.com/team/agent:v1", }, - ) + { + name: "from package override", + packageArtifacts: ArtifactCollection{&Artifact{ + Kind: ArtifactKindContainer, + Location: "other.example.com/team/agent:v2", + LocationKind: LocationKindLocal, + }}, + wantImage: "other.example.com/team/agent:v2", + }, + } - require.NoError(t, err) - require.NotNil(t, publishResult) - require.Len(t, publishResult.Artifacts, 1) - require.Equal(t, image, env.Dotenv()["SERVICE_API_IMAGE_NAME"]) - - artifact := publishResult.Artifacts[0] - require.Equal(t, ArtifactKindContainer, artifact.Kind) - require.Equal(t, image, artifact.Location) - require.Equal(t, LocationKindRemote, artifact.LocationKind) - require.Equal(t, "true", artifact.Metadata["imagePassthrough"]) - require.Equal(t, image, artifact.Metadata["remoteImage"]) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + ostest.Chdir(t, tempDir) + + mockContext := mocks.NewMockContext(t.Context()) + err := setupMocksForAksTarget(mockContext) + require.NoError(t, err) + + serviceConfig := createTestServiceConfig(tempDir, AksTarget, ServiceLanguageTypeScript) + serviceConfig.Image = osutil.NewExpandableString("private.example.com/team/agent:v1") + serviceConfig.Docker.ImagePassthrough = true + env := createEnv() + azdCtx := createTestAzdContext(t, env) + + serviceTarget := createAksServiceTarget(mockContext, serviceConfig, env, nil, azdCtx) + err = simulateInitliaze(*mockContext.Context, serviceTarget, serviceConfig) + require.NoError(t, err) + + serviceContext := NewServiceContext() + serviceContext.Package = tt.packageArtifacts + scope := environment.NewTargetResource( + "SUB_ID", "RG_ID", "", string(azapi.AzureResourceTypeManagedCluster), + ) + + publishResult, err := logProgress( + t, func(progress *async.Progress[ServiceProgress]) (*ServicePublishResult, error) { + return serviceTarget.Publish( + *mockContext.Context, serviceConfig, serviceContext, scope, progress, &PublishOptions{}) + }, + ) + + require.NoError(t, err) + require.NotNil(t, publishResult) + require.Len(t, publishResult.Artifacts, 1) + require.Equal(t, tt.wantImage, env.Dotenv()["SERVICE_API_IMAGE_NAME"]) + + artifact := publishResult.Artifacts[0] + require.Equal(t, ArtifactKindContainer, artifact.Kind) + require.Equal(t, tt.wantImage, artifact.Location) + require.Equal(t, LocationKindRemote, artifact.LocationKind) + require.Equal(t, "true", artifact.Metadata[MetadataKeyImagePassthrough]) + require.Equal(t, tt.wantImage, artifact.Metadata["remoteImage"]) + }) + } } func Test_AKS_Publish_NoContainer(t *testing.T) { diff --git a/cli/azd/pkg/project/service_target_appservice_test.go b/cli/azd/pkg/project/service_target_appservice_test.go index e0261670b65..1d81c269f7f 100644 --- a/cli/azd/pkg/project/service_target_appservice_test.go +++ b/cli/azd/pkg/project/service_target_appservice_test.go @@ -421,9 +421,51 @@ func Test_appServiceTarget_Publish(t *testing.T) { require.NoError(t, err) require.Len(t, result.Artifacts, 1) require.Equal(t, image, result.Artifacts[0].Location) - require.Equal(t, "true", result.Artifacts[0].Metadata["imagePassthrough"]) + require.Equal(t, "true", result.Artifacts[0].Metadata[MetadataKeyImagePassthrough]) require.Equal(t, image, env.GetServiceProperty("web", "IMAGE_NAME")) }) + + t.Run("ContainerDeploy_ImagePassthroughFromPackageOverride", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + env := environment.New("test") + envManager := &mockenv.MockEnvManager{} + envManager.On("Save", mock.Anything, mock.Anything).Return(nil) + serviceConfig := &ServiceConfig{ + Name: "web", + Language: ServiceLanguageDocker, + Image: osutil.NewExpandableString("private.example.com/team/agent:v1"), + Docker: DockerProjectOptions{ImagePassthrough: true}, + } + const overrideImage = "other.example.com/team/agent:v2" + serviceContext := NewServiceContext() + require.NoError(t, serviceContext.Package.Add(&Artifact{ + Kind: ArtifactKindContainer, + Location: overrideImage, + LocationKind: LocationKindLocal, + })) + targetResource := environment.NewTargetResource( + "SUB_ID", "RG_ID", "WEB_APP_NAME", string(azapi.AzureResourceTypeWebSite), + ) + target := &appServiceTarget{ + env: env, + envManager: envManager, + containerHelper: &ContainerHelper{}, + } + + result, err := target.Publish( + *mockContext.Context, + serviceConfig, + serviceContext, + targetResource, + async.NewNoopProgress[ServiceProgress](), + &PublishOptions{}, + ) + require.NoError(t, err) + require.Len(t, result.Artifacts, 1) + require.Equal(t, overrideImage, result.Artifacts[0].Location) + require.Equal(t, "true", result.Artifacts[0].Metadata[MetadataKeyImagePassthrough]) + require.Equal(t, overrideImage, env.GetServiceProperty("web", "IMAGE_NAME")) + }) } func Test_NewAppServiceTarget(t *testing.T) { diff --git a/cli/azd/pkg/project/service_target_containerapp_test.go b/cli/azd/pkg/project/service_target_containerapp_test.go index e44066b0508..cf16a70f8b1 100644 --- a/cli/azd/pkg/project/service_target_containerapp_test.go +++ b/cli/azd/pkg/project/service_target_containerapp_test.go @@ -242,34 +242,59 @@ func Test_ContainerApp_Publish(t *testing.T) { func Test_ContainerApp_Publish_ImagePassthrough(t *testing.T) { t.Parallel() - mockContext := mocks.NewMockContext(t.Context()) - setupMocksForContainerAppTarget(mockContext) - env := createEnv() - serviceTarget := createContainerAppServiceTarget(mockContext, env) - image := "private.example.com/team/agent:v1" - serviceConfig := createTestServiceConfig("", ContainerAppTarget, ServiceLanguageDocker) - serviceConfig.Image = osutil.NewExpandableString(image) - serviceConfig.Docker.ImagePassthrough = true - serviceContext := NewServiceContext() - serviceContext.Package = ArtifactCollection{imagePassthroughArtifact(image)} - targetResource := environment.NewTargetResource( - "SUBSCRIPTION_ID", - "RESOURCE_GROUP", - "CONTAINER_APP", - string(azapi.AzureResourceTypeContainerApp), - ) - - result, err := logProgress( - t, func(progress *async.Progress[ServiceProgress]) (*ServicePublishResult, error) { - return serviceTarget.Publish( - *mockContext.Context, serviceConfig, serviceContext, targetResource, progress, &PublishOptions{}) + tests := []struct { + name string + packageArtifact *Artifact + wantImage string + }{ + { + name: "configured image", + packageArtifact: imagePassthroughArtifact("private.example.com/team/agent:v1"), + wantImage: "private.example.com/team/agent:v1", }, - ) - require.NoError(t, err) - require.Len(t, result.Artifacts, 1) - require.Equal(t, image, result.Artifacts[0].Location) - require.Equal(t, "true", result.Artifacts[0].Metadata["imagePassthrough"]) - require.Equal(t, image, env.GetServiceProperty(serviceConfig.Name, "IMAGE_NAME")) + { + name: "from package override", + packageArtifact: &Artifact{ + Kind: ArtifactKindContainer, + Location: "other.example.com/team/agent:v2", + LocationKind: LocationKindLocal, + }, + wantImage: "other.example.com/team/agent:v2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + mockContext := mocks.NewMockContext(t.Context()) + setupMocksForContainerAppTarget(mockContext) + env := createEnv() + serviceTarget := createContainerAppServiceTarget(mockContext, env) + serviceConfig := createTestServiceConfig("", ContainerAppTarget, ServiceLanguageDocker) + serviceConfig.Image = osutil.NewExpandableString("private.example.com/team/agent:v1") + serviceConfig.Docker.ImagePassthrough = true + serviceContext := NewServiceContext() + serviceContext.Package = ArtifactCollection{tt.packageArtifact} + targetResource := environment.NewTargetResource( + "SUBSCRIPTION_ID", + "RESOURCE_GROUP", + "CONTAINER_APP", + string(azapi.AzureResourceTypeContainerApp), + ) + + result, err := logProgress( + t, func(progress *async.Progress[ServiceProgress]) (*ServicePublishResult, error) { + return serviceTarget.Publish( + *mockContext.Context, serviceConfig, serviceContext, targetResource, progress, &PublishOptions{}) + }, + ) + require.NoError(t, err) + require.Len(t, result.Artifacts, 1) + require.Equal(t, tt.wantImage, result.Artifacts[0].Location) + require.Equal(t, "true", result.Artifacts[0].Metadata[MetadataKeyImagePassthrough]) + require.Equal(t, tt.wantImage, env.GetServiceProperty(serviceConfig.Name, "IMAGE_NAME")) + }) + } } func createContainerAppServiceTarget( diff --git a/docs/reference/azure-yaml-schema.md b/docs/reference/azure-yaml-schema.md index 8f5dcada280..235ca7eb04f 100644 --- a/docs/reference/azure-yaml-schema.md +++ b/docs/reference/azure-yaml-schema.md @@ -72,11 +72,11 @@ services: | `buildArgs` | list | Arguments passed to the container build | | `network` | string | Networking mode for Dockerfile `RUN` instructions | | `remoteBuild` | boolean | Build and push with Azure Container Registry remote build instead of building locally | -| `imagePassthrough` | boolean | Use the service `image` unchanged without building, pulling, tagging, copying, or publishing it | +| `imagePassthrough` | boolean | Reuse an existing remote service `image` without building or publishing it; `azd deploy --from-package` can override the image for one deployment | -`docker.imagePassthrough` requires the service-level `image` property and cannot be combined with -`docker.remoteBuild`. When omitted or `false`, an external service image can still be pulled and copied into the -configured destination registry. For example: +`docker.imagePassthrough` declares that azd does not own the container image lifecycle. It requires the service-level +`image` property to contain a fully qualified remote image and cannot be combined with `docker.remoteBuild`. During package, publish, and deploy operations, azd +uses the configured image as the existing remote image without building, pulling, tagging, copying, or publishing it: ```yaml services: @@ -87,6 +87,23 @@ services: imagePassthrough: true ``` +The service `image` is the default. A fully qualified remote image supplied to `azd deploy --from-package` overrides it +for that deployment and is also passed through unchanged: + +```bash +azd deploy api --from-package other-registry.example.com/apps/api:2.0 +``` + +Passthrough overrides do not support local image names, archives, or directories. The `--from-package` override above +applies only to `azd deploy`; `azd publish --from-package` and `azd publish --to` are not supported for passthrough +services. Running `azd publish` without either flag reuses the configured remote image and does not publish it. + +azd does not sign in to the source registry or verify access to it in this mode. The destination platform must already +have permission to pull the image through its managed identity or registry credentials. + +When `imagePassthrough` is omitted or `false`, an external service image can still be pulled and copied into the +configured destination registry. + ## Hooks Hooks run user-defined scripts at lifecycle points: diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index a998c621d18..35c8de50023 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -1338,7 +1338,7 @@ "imagePassthrough": { "type": "boolean", "title": "Optional. Whether to use the service image as the final remote artifact", - "description": "If set to true, azd passes the service image to the target unchanged without building, pulling, tagging, copying, or publishing it. Requires the service image property and cannot be combined with docker.remoteBuild." + "description": "If set to true, azd uses the service image as an existing remote image without building, pulling, tagging, copying, or publishing it. A fully qualified remote image passed to azd deploy --from-package overrides the service image for that deployment and is also passed through unchanged; local images, archives, and directories are not supported as overrides. azd publish --from-package and azd publish --to are not supported for passthrough services. Requires the service image property to contain a fully qualified remote image and cannot be combined with docker.remoteBuild." } } }, diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 71854593b87..826969d3ed3 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -1298,7 +1298,7 @@ "imagePassthrough": { "type": "boolean", "title": "Optional. Whether to use the service image as the final remote artifact", - "description": "If set to true, azd passes the service image to the target unchanged without building, pulling, tagging, copying, or publishing it. Requires the service image property and cannot be combined with docker.remoteBuild." + "description": "If set to true, azd uses the service image as an existing remote image without building, pulling, tagging, copying, or publishing it. A fully qualified remote image passed to azd deploy --from-package overrides the service image for that deployment and is also passed through unchanged; local images, archives, and directories are not supported as overrides. azd publish --from-package and azd publish --to are not supported for passthrough services. Requires the service image property to contain a fully qualified remote image and cannot be combined with docker.remoteBuild." } } }, From c4c0d903b46fd7fe2a236d8b5d951ac51be5b738 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Wed, 19 Aug 2026 16:45:33 +0800 Subject: [PATCH 6/9] fix: handle passthrough package collections --- cli/azd/internal/cmd/publish.go | 8 +- cli/azd/pkg/project/container_helper.go | 77 +++++++++++++++----- cli/azd/pkg/project/container_helper_test.go | 61 ++++++++++++++++ cli/azd/pkg/project/service_manager_test.go | 16 ++-- 4 files changed, 128 insertions(+), 34 deletions(-) diff --git a/cli/azd/internal/cmd/publish.go b/cli/azd/internal/cmd/publish.go index a689d231dc9..2f1335e1ed6 100644 --- a/cli/azd/internal/cmd/publish.go +++ b/cli/azd/internal/cmd/publish.go @@ -303,7 +303,7 @@ func (pa *PublishAction) Run(ctx context.Context) (*actions.ActionResult, error) } } else { // --from-package not set, automatically package the application - packageResult, err := async.RunWithProgress( + _, err := async.RunWithProgress( func(packageProgress project.ServiceProgress) { progressMessage := fmt.Sprintf("Packaging service %s (%s)", svc.Name, packageProgress.Message) pa.console.ShowSpinner(ctx, progressMessage, input.Step) @@ -317,12 +317,6 @@ func (pa *PublishAction) Run(ctx context.Context) (*actions.ActionResult, error) pa.console.StopSpinner(ctx, stepMessage, input.StepFailed) return err } - - // Append package artifacts - if err := serviceContext.Package.Add(packageResult.Artifacts...); err != nil { - pa.console.StopSpinner(ctx, stepMessage, input.StepFailed) - return err - } } publishResult, err := async.RunWithProgress( diff --git a/cli/azd/pkg/project/container_helper.go b/cli/azd/pkg/project/container_helper.go index 45087cf9bc6..339515405ba 100644 --- a/cli/azd/pkg/project/container_helper.go +++ b/cli/azd/pkg/project/container_helper.go @@ -275,7 +275,7 @@ func resolveImagePassthrough( return "", fmt.Errorf("docker.imagePassthrough requires the service image property") } - if _, err := reference.ParseNamed(image); err != nil { + if err := validateFullyQualifiedRemoteContainerImage(image); err != nil { return "", fmt.Errorf("passthrough image must be a fully qualified remote container image: %w", err) } @@ -676,6 +676,26 @@ func validatePublishOptions(serviceConfig *ServiceConfig, options *PublishOption return nil } +func validateFullyQualifiedRemoteContainerImage(image string) error { + parsed, err := reference.Parse(image) + if err != nil { + return err + } + + named, ok := parsed.(reference.Named) + if !ok { + return fmt.Errorf("image reference does not include a repository name") + } + + registry := reference.Domain(named) + if registry == "" || + (!strings.Contains(registry, ".") && !strings.Contains(registry, ":") && registry != "localhost") { + return fmt.Errorf("image reference does not include an explicit registry") + } + + return nil +} + func imagePassthroughPackageOverride( serviceConfig *ServiceConfig, serviceContext *ServiceContext, @@ -684,32 +704,49 @@ func imagePassthroughPackageOverride( return "", false, nil } - if len(serviceContext.Package) != 1 { - return "", false, fmt.Errorf( - "docker.imagePassthrough supports exactly one --from-package artifact, got %d", - len(serviceContext.Package), - ) - } + var packageImage string + for _, artifact := range serviceContext.Package { + if artifact == nil { + return "", false, fmt.Errorf("docker.imagePassthrough does not support a nil package artifact") + } - artifact := serviceContext.Package[0] - if artifact.Kind != ArtifactKindContainer { - return "", false, fmt.Errorf( - "docker.imagePassthrough does not support %s artifacts from --from-package; "+ - "use a fully qualified remote container image", - artifact.Kind, - ) + switch artifact.Kind { + case ArtifactKindContainer: + if err := validateFullyQualifiedRemoteContainerImage(artifact.Location); err != nil { + return "", false, fmt.Errorf( + "docker.imagePassthrough requires --from-package to be a fully qualified remote container image: %w", + err, + ) + } + if packageImage != "" && artifact.Location != packageImage { + return "", false, fmt.Errorf( + "docker.imagePassthrough package contains multiple distinct remote container images", + ) + } + packageImage = artifact.Location + case ArtifactKindConfig: + // Targets may add supplementary configuration alongside the container image. + case ArtifactKindArchive, ArtifactKindDirectory: + return "", false, fmt.Errorf( + "docker.imagePassthrough does not support %s artifacts from --from-package; "+ + "use a fully qualified remote container image", + artifact.Kind, + ) + default: + return "", false, fmt.Errorf( + "docker.imagePassthrough does not support %s package artifacts", + artifact.Kind, + ) + } } - if _, err := reference.ParseNamed(artifact.Location); err != nil { - return "", false, fmt.Errorf( - "docker.imagePassthrough requires --from-package to be a fully qualified remote container image: %w", - err, - ) + if packageImage == "" { + return "", false, fmt.Errorf("docker.imagePassthrough package does not contain a container image") } // The package artifact is the per-run input selected by --from-package. Its location wins over // the service image while imagePassthrough continues to control how the selected image is handled. - return artifact.Location, true, nil + return packageImage, true, nil } // Publish pushes an image to a remote server and returns the fully qualified remote image name. diff --git a/cli/azd/pkg/project/container_helper_test.go b/cli/azd/pkg/project/container_helper_test.go index 448aa54bbc9..7abd13b5a7a 100644 --- a/cli/azd/pkg/project/container_helper_test.go +++ b/cli/azd/pkg/project/container_helper_test.go @@ -1300,6 +1300,12 @@ func TestResolveImagePassthrough(t *testing.T) { env: map[string]string{"PRIVATE_REGISTRY": "private.example.com"}, want: "private.example.com/team/agent:v1", }, + { + name: "accepts explicit Docker Hub registry without library namespace", + image: "docker.io/nginx:latest", + docker: DockerProjectOptions{ImagePassthrough: true}, + want: "docker.io/nginx:latest", + }, { name: "preserves tag and digest", image: "${PRIVATE_REGISTRY}/team/agent:v1@sha256:" + @@ -1364,6 +1370,7 @@ func Test_ContainerHelper_Publish(t *testing.T) { packagePath string packageKind ArtifactKind packageLocationKind LocationKind + additionalArtifacts ArtifactCollection imageHash string sourceImage string targetImage string @@ -1476,6 +1483,59 @@ func Test_ContainerHelper_Publish(t *testing.T) { expectDockerPushCalled: false, expectedRemoteImage: "other.example.com/team/agent:v2", }, + { + name: "Image passthrough accepts explicit Docker Hub package override", + image: "private.example.com/team/agent:v1", + packagePath: "docker.io/nginx:latest", + packageKind: ArtifactKindContainer, + packageLocationKind: LocationKindLocal, + imagePassthrough: true, + publishOptions: &PublishOptions{}, + expectDockerLoginCalled: false, + expectDockerPullCalled: false, + expectDockerTagCalled: false, + expectDockerPushCalled: false, + expectedRemoteImage: "docker.io/nginx:latest", + }, + { + name: "Image passthrough deduplicates package artifacts", + image: "private.example.com/team/agent:v1", + packagePath: "private.example.com/team/agent:v1", + packageKind: ArtifactKindContainer, + packageLocationKind: LocationKindRemote, + additionalArtifacts: ArtifactCollection{&Artifact{ + Kind: ArtifactKindContainer, + Location: "private.example.com/team/agent:v1", + LocationKind: LocationKindRemote, + }}, + imagePassthrough: true, + publishOptions: &PublishOptions{}, + expectDockerLoginCalled: false, + expectDockerPullCalled: false, + expectDockerTagCalled: false, + expectDockerPushCalled: false, + expectedRemoteImage: "private.example.com/team/agent:v1", + }, + { + name: "Image passthrough rejects distinct package images", + image: "private.example.com/team/agent:v1", + packagePath: "private.example.com/team/agent:v1", + packageKind: ArtifactKindContainer, + packageLocationKind: LocationKindRemote, + additionalArtifacts: ArtifactCollection{&Artifact{ + Kind: ArtifactKindContainer, + Location: "other.example.com/team/agent:v2", + LocationKind: LocationKindLocal, + }}, + imagePassthrough: true, + publishOptions: &PublishOptions{}, + expectDockerLoginCalled: false, + expectDockerPullCalled: false, + expectDockerTagCalled: false, + expectDockerPushCalled: false, + expectError: true, + expectedError: "multiple distinct remote container images", + }, { name: "Image passthrough preserves tag and digest package override", image: "private.example.com/team/agent:v1", @@ -1662,6 +1722,7 @@ func Test_ContainerHelper_Publish(t *testing.T) { }) } + packageArtifacts = append(packageArtifacts, tt.additionalArtifacts...) serviceContext := &ServiceContext{Package: packageArtifacts} publishResult, err := logProgress( diff --git a/cli/azd/pkg/project/service_manager_test.go b/cli/azd/pkg/project/service_manager_test.go index 1c1954f3b3e..c91b5894e94 100644 --- a/cli/azd/pkg/project/service_manager_test.go +++ b/cli/azd/pkg/project/service_manager_test.go @@ -343,7 +343,7 @@ func Test_ServiceManager_Publish_RejectsImageOverrideForPassthrough(t *testing.T require.False(t, *publishCalled) } -func Test_ServiceManager_Publish_AcceptsRemotePackageOverrideForPassthrough(t *testing.T) { +func Test_ServiceManager_Publish_AcceptsDuplicateRemotePackageArtifactsForPassthrough(t *testing.T) { mockContext := mocks.NewMockContext(t.Context()) setupMocksForServiceManager(mockContext) env := environment.NewWithValues("test", map[string]string{ @@ -356,11 +356,13 @@ func Test_ServiceManager_Publish_AcceptsRemotePackageOverrideForPassthrough(t *t publishCalled := new(false) ctx := context.WithValue(*mockContext.Context, serviceTargetPublishCalled, publishCalled) serviceContext := NewServiceContext() - require.NoError(t, serviceContext.Package.Add(&Artifact{ - Kind: ArtifactKindContainer, - Location: "other.example.com/team/agent:v2", - LocationKind: LocationKindLocal, - })) + for range 2 { + require.NoError(t, serviceContext.Package.Add(&Artifact{ + Kind: ArtifactKindContainer, + Location: "other.example.com/team/agent:v2", + LocationKind: LocationKindLocal, + })) + } _, err := sm.Publish( ctx, @@ -420,7 +422,7 @@ func Test_ServiceManager_Publish_RejectsInvalidPackageOverrideForPassthrough(t * Location: "agent.zip", LocationKind: LocationKindLocal, }, - errorContains: "supports exactly one --from-package artifact", + errorContains: "does not support archive artifacts", }, } From a9286a05a5c80c9ee5e75732d5076237bfc63281 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Wed, 19 Aug 2026 17:38:18 +0800 Subject: [PATCH 7/9] fix: validate passthrough config before dispatch --- cli/azd/pkg/project/service_manager.go | 7 +++ cli/azd/pkg/project/service_manager_test.go | 56 +++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/cli/azd/pkg/project/service_manager.go b/cli/azd/pkg/project/service_manager.go index 19e8c8b771b..c784d17fd39 100644 --- a/cli/azd/pkg/project/service_manager.go +++ b/cli/azd/pkg/project/service_manager.go @@ -502,6 +502,9 @@ func (sm *serviceManager) Publish( progress *async.Progress[ServiceProgress], publishOptions *PublishOptions, ) (*ServicePublishResult, error) { + if _, err := resolveImagePassthrough(serviceConfig, sm.env); err != nil { + return nil, err + } if err := validatePublishOptions(serviceConfig, publishOptions); err != nil { return nil, err } @@ -700,6 +703,10 @@ func (sm *serviceManager) GetServiceTarget(ctx context.Context, serviceConfig *S // GetFrameworkService constructs a framework service from the underlying service configuration func (sm *serviceManager) GetFrameworkService(ctx context.Context, serviceConfig *ServiceConfig) (FrameworkService, error) { + if _, err := resolveImagePassthrough(serviceConfig, sm.env); err != nil { + return nil, err + } + var frameworkService FrameworkService // Publishing from an existing image currently follows the same lifecycle as a docker project diff --git a/cli/azd/pkg/project/service_manager_test.go b/cli/azd/pkg/project/service_manager_test.go index c91b5894e94..ab4c5e68c71 100644 --- a/cli/azd/pkg/project/service_manager_test.go +++ b/cli/azd/pkg/project/service_manager_test.go @@ -326,6 +326,7 @@ func Test_ServiceManager_Publish_RejectsImageOverrideForPassthrough(t *testing.T env := environment.New("test") sm := createServiceManager(mockContext, env, ServiceOperationCache{}) serviceConfig := createTestServiceConfig("./src/api", ServiceTargetFake, ServiceLanguageFake) + serviceConfig.Image = osutil.NewExpandableString("private.example.com/team/agent:v1") serviceConfig.Docker.ImagePassthrough = true publishCalled := new(false) @@ -343,6 +344,59 @@ func Test_ServiceManager_Publish_RejectsImageOverrideForPassthrough(t *testing.T require.False(t, *publishCalled) } +func Test_ServiceManager_Publish_ValidatesImagePassthroughConfiguration(t *testing.T) { + tests := []struct { + name string + image string + remoteBuild bool + errorContains string + }{ + { + name: "missing configured image", + errorContains: "requires the service image property", + }, + { + name: "remote build conflict", + image: "private.example.com/team/agent:v1", + remoteBuild: true, + errorContains: "cannot be combined with docker.remoteBuild", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + setupMocksForServiceManager(mockContext) + env := environment.New("test") + sm := createServiceManager(mockContext, env, ServiceOperationCache{}) + serviceConfig := createTestServiceConfig("./src/api", ServiceTargetFake, ServiceLanguageFake) + serviceConfig.Image = osutil.NewExpandableString(tt.image) + serviceConfig.Docker.ImagePassthrough = true + serviceConfig.Docker.RemoteBuild = tt.remoteBuild + + publishCalled := new(false) + ctx := context.WithValue(*mockContext.Context, serviceTargetPublishCalled, publishCalled) + serviceContext := NewServiceContext() + require.NoError(t, serviceContext.Package.Add(&Artifact{ + Kind: ArtifactKindContainer, + Location: "other.example.com/team/agent:v2", + LocationKind: LocationKindLocal, + })) + + _, err := sm.Publish( + ctx, + serviceConfig, + serviceContext, + async.NewProgress[ServiceProgress](), + &PublishOptions{}, + ) + + require.ErrorContains(t, err, tt.errorContains) + require.False(t, *publishCalled) + }) + } +} + func Test_ServiceManager_Publish_AcceptsDuplicateRemotePackageArtifactsForPassthrough(t *testing.T) { mockContext := mocks.NewMockContext(t.Context()) setupMocksForServiceManager(mockContext) @@ -351,6 +405,7 @@ func Test_ServiceManager_Publish_AcceptsDuplicateRemotePackageArtifactsForPassth }) sm := createServiceManager(mockContext, env, ServiceOperationCache{}) serviceConfig := createTestServiceConfig("./src/api", ServiceTargetFake, ServiceLanguageFake) + serviceConfig.Image = osutil.NewExpandableString("private.example.com/team/agent:v1") serviceConfig.Docker.ImagePassthrough = true publishCalled := new(false) @@ -433,6 +488,7 @@ func Test_ServiceManager_Publish_RejectsInvalidPackageOverrideForPassthrough(t * env := environment.New("test") sm := createServiceManager(mockContext, env, ServiceOperationCache{}) serviceConfig := createTestServiceConfig("./src/api", ServiceTargetFake, ServiceLanguageFake) + serviceConfig.Image = osutil.NewExpandableString("private.example.com/team/agent:v1") serviceConfig.Docker.ImagePassthrough = true publishCalled := new(false) From a1e3f90ecfad1750a198e1fc2d5494a597cfac61 Mon Sep 17 00:00:00 2001 From: Wei Meng Date: Wed, 19 Aug 2026 18:13:34 +0800 Subject: [PATCH 8/9] fix: validate passthrough extension artifacts --- cli/azd/cmd/testdata/TestFigSpec.ts | 2 +- .../cmd/testdata/TestUsage-azd-publish.snap | 5 +- cli/azd/cmd/testdata/TestUsage-azd.snap | 2 +- cli/azd/internal/cmd/publish.go | 10 +- cli/azd/pkg/project/container_helper.go | 12 ++ cli/azd/pkg/project/service_manager.go | 5 + cli/azd/pkg/project/service_manager_test.go | 121 +++++++++++++++++- 7 files changed, 144 insertions(+), 13 deletions(-) diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index da08ba69c01..c32b65ef86c 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -6366,7 +6366,7 @@ const completionSpec: Fig.Spec = { }, { name: ['publish'], - description: 'Publish a service to a container registry.', + description: 'Publish a service image or reuse an existing passthrough image.', options: [ { name: ['--all'], diff --git a/cli/azd/cmd/testdata/TestUsage-azd-publish.snap b/cli/azd/cmd/testdata/TestUsage-azd-publish.snap index 5051088b6f5..867bea137f2 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-publish.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-publish.snap @@ -1,9 +1,10 @@ -Publish a service to a container registry. +Publish a service image or reuse an existing passthrough image. - • Supports Container App services only. + • Supports Container Apps, AKS, and extension-provided service targets. • Target registry set by AZURE_CONTAINER_REGISTRY_ENDPOINT environment variable, docker.registry in azure.yaml, or '--to' flag. • Use '--from-package' to publish an existing container image, otherwise azd automatically packages the container image before publishing. + • For services with docker.imagePassthrough enabled, azd reuses the configured remote image without publishing it; '--from-package' and '--to' are not supported. Usage azd publish [flags] diff --git a/cli/azd/cmd/testdata/TestUsage-azd.snap b/cli/azd/cmd/testdata/TestUsage-azd.snap index 69a28ce46e2..79fbbf0c6da 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd.snap @@ -14,7 +14,7 @@ Commands deploy : Deploy your project code to Azure. down : Delete your project's Azure resources. provision : Provision Azure resources for your project. - publish : Publish a service to a container registry. + publish : Publish a service image or reuse an existing passthrough image. Manage and show settings completion : Generate shell completion scripts. diff --git a/cli/azd/internal/cmd/publish.go b/cli/azd/internal/cmd/publish.go index 2f1335e1ed6..6be9f0d00ee 100644 --- a/cli/azd/internal/cmd/publish.go +++ b/cli/azd/internal/cmd/publish.go @@ -80,7 +80,7 @@ func NewPublishFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) func NewPublishCmd() *cobra.Command { cmd := &cobra.Command{ Use: "publish ", - Short: "Publish a service to a container registry.", + Short: "Publish a service image or reuse an existing passthrough image.", } cmd.Args = cobra.MaximumNArgs(1) return cmd @@ -465,9 +465,9 @@ func determineArtifactKind(fromPackage string) project.ArtifactKind { func GetCmdPublishHelpDescription(*cobra.Command) string { return generateCmdHelpDescription( - "Publish a service to a container registry.", + "Publish a service image or reuse an existing passthrough image.", []string{ - formatHelpNote("Supports Container App services only."), + formatHelpNote("Supports Container Apps, AKS, and extension-provided service targets."), formatHelpNote( //nolint:lll "Target registry set by AZURE_CONTAINER_REGISTRY_ENDPOINT environment variable, docker.registry in azure.yaml, or '--to' flag.", @@ -476,6 +476,10 @@ func GetCmdPublishHelpDescription(*cobra.Command) string { //nolint:lll "Use '--from-package' to publish an existing container image, otherwise azd automatically packages the container image before publishing.", ), + formatHelpNote( + //nolint:lll + "For services with docker.imagePassthrough enabled, azd reuses the configured remote image without publishing it; '--from-package' and '--to' are not supported.", + ), }) } diff --git a/cli/azd/pkg/project/container_helper.go b/cli/azd/pkg/project/container_helper.go index 339515405ba..38441f35631 100644 --- a/cli/azd/pkg/project/container_helper.go +++ b/cli/azd/pkg/project/container_helper.go @@ -705,6 +705,7 @@ func imagePassthroughPackageOverride( } var packageImage string + containerArtifacts := []*Artifact{} for _, artifact := range serviceContext.Package { if artifact == nil { return "", false, fmt.Errorf("docker.imagePassthrough does not support a nil package artifact") @@ -724,6 +725,7 @@ func imagePassthroughPackageOverride( ) } packageImage = artifact.Location + containerArtifacts = append(containerArtifacts, artifact) case ArtifactKindConfig: // Targets may add supplementary configuration alongside the container image. case ArtifactKindArchive, ArtifactKindDirectory: @@ -744,6 +746,16 @@ func imagePassthroughPackageOverride( return "", false, fmt.Errorf("docker.imagePassthrough package does not contain a container image") } + for _, artifact := range containerArtifacts { + artifact.LocationKind = LocationKindRemote + if artifact.Metadata == nil { + artifact.Metadata = map[string]string{} + } + artifact.Metadata[MetadataKeyImagePassthrough] = "true" + artifact.Metadata["remoteImage"] = packageImage + artifact.Metadata["sourceImage"] = packageImage + } + // The package artifact is the per-run input selected by --from-package. Its location wins over // the service image while imagePassthrough continues to control how the selected image is handled. return packageImage, true, nil diff --git a/cli/azd/pkg/project/service_manager.go b/cli/azd/pkg/project/service_manager.go index c784d17fd39..6050d3f9e62 100644 --- a/cli/azd/pkg/project/service_manager.go +++ b/cli/azd/pkg/project/service_manager.go @@ -535,6 +535,11 @@ func (sm *serviceManager) Publish( } } + // Package can add target-specific artifacts, so validate the completed collection before dispatch. + if _, _, err := imagePassthroughPackageOverride(serviceConfig, serviceContext); err != nil { + return nil, err + } + serviceTarget, err := sm.cachedServiceTarget(ctx, serviceConfig) if err != nil { return nil, fmt.Errorf("getting service target: %w", err) diff --git a/cli/azd/pkg/project/service_manager_test.go b/cli/azd/pkg/project/service_manager_test.go index ab4c5e68c71..c129f44d21d 100644 --- a/cli/azd/pkg/project/service_manager_test.go +++ b/cli/azd/pkg/project/service_manager_test.go @@ -37,12 +37,14 @@ const ( ServiceLanguageFake ServiceLanguageKind = "fake-framework" ServiceTargetFake ServiceTargetKind = "fake-service-target" - frameworkRestoreCalled contextKey = "frameworkRestoreCalled" - frameworkBuildCalled contextKey = "frameworkBuildCalled" - frameworkPackageCalled contextKey = "frameworkPackageCalled" - serviceTargetPackageCalled contextKey = "serviceTargetPackageCalled" - serviceTargetDeployCalled contextKey = "serviceTargetDeployCalled" - serviceTargetPublishCalled contextKey = "serviceTargetPublishCalled" + frameworkRestoreCalled contextKey = "frameworkRestoreCalled" + frameworkBuildCalled contextKey = "frameworkBuildCalled" + frameworkPackageCalled contextKey = "frameworkPackageCalled" + serviceTargetPackageCalled contextKey = "serviceTargetPackageCalled" + serviceTargetPackageArtifact contextKey = "serviceTargetPackageArtifact" + serviceTargetDeployCalled contextKey = "serviceTargetDeployCalled" + serviceTargetPublishCalled contextKey = "serviceTargetPublishCalled" + serviceTargetPublishContext contextKey = "serviceTargetPublishContext" ) func createServiceManager( @@ -409,7 +411,9 @@ func Test_ServiceManager_Publish_AcceptsDuplicateRemotePackageArtifactsForPassth serviceConfig.Docker.ImagePassthrough = true publishCalled := new(false) + var publishedContext *ServiceContext ctx := context.WithValue(*mockContext.Context, serviceTargetPublishCalled, publishCalled) + ctx = context.WithValue(ctx, serviceTargetPublishContext, &publishedContext) serviceContext := NewServiceContext() for range 2 { require.NoError(t, serviceContext.Package.Add(&Artifact{ @@ -429,6 +433,95 @@ func Test_ServiceManager_Publish_AcceptsDuplicateRemotePackageArtifactsForPassth require.NoError(t, err) require.True(t, *publishCalled) + require.NotNil(t, publishedContext) + require.Len(t, publishedContext.Package, 2) + for _, artifact := range publishedContext.Package { + require.Equal(t, LocationKindRemote, artifact.LocationKind) + require.Equal(t, "true", artifact.Metadata[MetadataKeyImagePassthrough]) + require.Equal(t, "other.example.com/team/agent:v2", artifact.Metadata["remoteImage"]) + require.Equal(t, "other.example.com/team/agent:v2", artifact.Metadata["sourceImage"]) + } +} + +func Test_ServiceManager_Publish_ValidatesArtifactsAddedDuringPackage(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + setupMocksForServiceManager(mockContext) + env := environment.NewWithValues("test", map[string]string{ + environment.SubscriptionIdEnvVarName: "SUBSCRIPTION_ID", + }) + sm := createServiceManager(mockContext, env, ServiceOperationCache{}) + serviceConfig := createTestServiceConfig("./src/api", ServiceTargetFake, ServiceLanguageFake) + serviceConfig.Image = osutil.NewExpandableString("private.example.com/team/agent:v1") + serviceConfig.Docker.ImagePassthrough = true + + packageCalled := new(false) + publishCalled := new(false) + ctx := context.WithValue(*mockContext.Context, serviceTargetPackageCalled, packageCalled) + ctx = context.WithValue(ctx, serviceTargetPublishCalled, publishCalled) + ctx = context.WithValue(ctx, serviceTargetPackageArtifact, &Artifact{ + Kind: ArtifactKindArchive, + Location: "external-target-package.zip", + LocationKind: LocationKindLocal, + }) + + _, err := sm.Publish( + ctx, + serviceConfig, + NewServiceContext(), + async.NewProgress[ServiceProgress](), + &PublishOptions{}, + ) + + require.ErrorContains(t, err, "does not support archive artifacts") + require.True(t, *packageCalled) + require.False(t, *publishCalled) +} + +func Test_ServiceManager_Publish_AllowsConfigAddedDuringPackage(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + setupMocksForServiceManager(mockContext) + env := environment.NewWithValues("test", map[string]string{ + environment.SubscriptionIdEnvVarName: "SUBSCRIPTION_ID", + }) + sm := createServiceManager(mockContext, env, ServiceOperationCache{}) + serviceConfig := createTestServiceConfig("./src/api", ServiceTargetFake, ServiceLanguageFake) + serviceConfig.Image = osutil.NewExpandableString("private.example.com/team/agent:v1") + serviceConfig.Docker.ImagePassthrough = true + + packageCalled := new(false) + publishCalled := new(false) + var publishedContext *ServiceContext + ctx := context.WithValue(*mockContext.Context, serviceTargetPackageCalled, packageCalled) + ctx = context.WithValue(ctx, serviceTargetPublishCalled, publishCalled) + ctx = context.WithValue(ctx, serviceTargetPublishContext, &publishedContext) + ctx = context.WithValue(ctx, serviceTargetPackageArtifact, &Artifact{ + Kind: ArtifactKindConfig, + Location: "external-target-config.yaml", + LocationKind: LocationKindLocal, + }) + + _, err := sm.Publish( + ctx, + serviceConfig, + NewServiceContext(), + async.NewProgress[ServiceProgress](), + &PublishOptions{}, + ) + + require.NoError(t, err) + require.True(t, *packageCalled) + require.True(t, *publishCalled) + require.NotNil(t, publishedContext) + require.Len(t, publishedContext.Package, 2) + containerArtifact, found := publishedContext.Package.FindFirst(WithKind(ArtifactKindContainer)) + require.True(t, found) + require.Equal(t, LocationKindRemote, containerArtifact.LocationKind) + require.Equal(t, "true", containerArtifact.Metadata[MetadataKeyImagePassthrough]) + require.Equal(t, containerArtifact.Location, containerArtifact.Metadata["remoteImage"]) + require.Equal(t, containerArtifact.Location, containerArtifact.Metadata["sourceImage"]) + configArtifact, found := publishedContext.Package.FindFirst(WithKind(ArtifactKindConfig)) + require.True(t, found) + require.Equal(t, LocationKindLocal, configArtifact.LocationKind) } func Test_ServiceManager_Publish_RejectsInvalidPackageOverrideForPassthrough(t *testing.T) { @@ -882,6 +975,14 @@ func (f *fakeFramework) Package( *packageCalled = true } + if serviceConfig.Docker.ImagePassthrough { + image, err := serviceConfig.Image.Envsubst(func(string) string { return "" }) + if err != nil { + return nil, err + } + return &ServicePackageResult{Artifacts: ArtifactCollection{imagePassthroughArtifact(image)}}, nil + } + runArgs := exec.NewRunArgs("fake-framework", "package") result, err := f.commandRunner.Run(ctx, runArgs) if err != nil { @@ -932,6 +1033,10 @@ func (st *fakeServiceTarget) Package( *packageCalled = true } + if artifact, ok := ctx.Value(serviceTargetPackageArtifact).(*Artifact); ok { + return &ServicePackageResult{Artifacts: ArtifactCollection{artifact}}, nil + } + runArgs := exec.NewRunArgs("fake-service-target", "package") result, err := st.commandRunner.Run(ctx, runArgs) if err != nil { @@ -964,6 +1069,10 @@ func (st *fakeServiceTarget) Publish( if ok { *publishCalled = true } + publishedContext, ok := ctx.Value(serviceTargetPublishContext).(**ServiceContext) + if ok { + *publishedContext = serviceContext + } return &ServicePublishResult{ Artifacts: ArtifactCollection{ { From 9f568a4d3b031bb2318e02a59a0406fc4f84a13f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wei=20Meng=E2=9A=94=EF=B8=8F?= Date: Fri, 21 Aug 2026 09:16:53 +0800 Subject: [PATCH 9/9] fix: clarify passthrough package errors --- cli/azd/pkg/project/container_helper.go | 5 +++-- cli/azd/pkg/project/container_helper_test.go | 6 +++--- cli/azd/pkg/project/service_manager_test.go | 10 +++++----- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/cli/azd/pkg/project/container_helper.go b/cli/azd/pkg/project/container_helper.go index 38441f35631..320b410b1a2 100644 --- a/cli/azd/pkg/project/container_helper.go +++ b/cli/azd/pkg/project/container_helper.go @@ -715,7 +715,8 @@ func imagePassthroughPackageOverride( case ArtifactKindContainer: if err := validateFullyQualifiedRemoteContainerImage(artifact.Location); err != nil { return "", false, fmt.Errorf( - "docker.imagePassthrough requires --from-package to be a fully qualified remote container image: %w", + "docker.imagePassthrough requires package container artifacts to use "+ + "a fully qualified remote container image: %w", err, ) } @@ -730,7 +731,7 @@ func imagePassthroughPackageOverride( // Targets may add supplementary configuration alongside the container image. case ArtifactKindArchive, ArtifactKindDirectory: return "", false, fmt.Errorf( - "docker.imagePassthrough does not support %s artifacts from --from-package; "+ + "docker.imagePassthrough does not support %s package artifacts; "+ "use a fully qualified remote container image", artifact.Kind, ) diff --git a/cli/azd/pkg/project/container_helper_test.go b/cli/azd/pkg/project/container_helper_test.go index 7abd13b5a7a..e424a5e2d79 100644 --- a/cli/azd/pkg/project/container_helper_test.go +++ b/cli/azd/pkg/project/container_helper_test.go @@ -1565,7 +1565,7 @@ func Test_ContainerHelper_Publish(t *testing.T) { expectDockerTagCalled: false, expectDockerPushCalled: false, expectError: true, - expectedError: "fully qualified remote container image", + expectedError: "requires package container artifacts", }, { name: "Image passthrough rejects archive package override", @@ -1580,7 +1580,7 @@ func Test_ContainerHelper_Publish(t *testing.T) { expectDockerTagCalled: false, expectDockerPushCalled: false, expectError: true, - expectedError: "does not support archive artifacts", + expectedError: "does not support archive package artifacts", }, { name: "Image passthrough rejects directory package override", @@ -1595,7 +1595,7 @@ func Test_ContainerHelper_Publish(t *testing.T) { expectDockerTagCalled: false, expectDockerPushCalled: false, expectError: true, - expectedError: "does not support directory artifacts", + expectedError: "does not support directory package artifacts", }, { name: "With publish options overwrite", diff --git a/cli/azd/pkg/project/service_manager_test.go b/cli/azd/pkg/project/service_manager_test.go index c129f44d21d..d2e640ca578 100644 --- a/cli/azd/pkg/project/service_manager_test.go +++ b/cli/azd/pkg/project/service_manager_test.go @@ -472,7 +472,7 @@ func Test_ServiceManager_Publish_ValidatesArtifactsAddedDuringPackage(t *testing &PublishOptions{}, ) - require.ErrorContains(t, err, "does not support archive artifacts") + require.ErrorContains(t, err, "does not support archive package artifacts") require.True(t, *packageCalled) require.False(t, *publishCalled) } @@ -538,7 +538,7 @@ func Test_ServiceManager_Publish_RejectsInvalidPackageOverrideForPassthrough(t * Location: "team/agent:v2", LocationKind: LocationKindLocal, }, - errorContains: "fully qualified remote container image", + errorContains: "requires package container artifacts", }, { name: "archive", @@ -547,7 +547,7 @@ func Test_ServiceManager_Publish_RejectsInvalidPackageOverrideForPassthrough(t * Location: "agent.zip", LocationKind: LocationKindLocal, }, - errorContains: "does not support archive artifacts", + errorContains: "does not support archive package artifacts", }, { name: "directory", @@ -556,7 +556,7 @@ func Test_ServiceManager_Publish_RejectsInvalidPackageOverrideForPassthrough(t * Location: "agent", LocationKind: LocationKindLocal, }, - errorContains: "does not support directory artifacts", + errorContains: "does not support directory package artifacts", }, { name: "multiple artifacts", @@ -570,7 +570,7 @@ func Test_ServiceManager_Publish_RejectsInvalidPackageOverrideForPassthrough(t * Location: "agent.zip", LocationKind: LocationKindLocal, }, - errorContains: "does not support archive artifacts", + errorContains: "does not support archive package artifacts", }, }