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/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/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/internal/cmd/publish.go b/cli/azd/internal/cmd/publish.go index 4d109640e1c..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 @@ -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 { @@ -299,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) @@ -313,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( @@ -352,7 +350,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 +380,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 @@ -432,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.", @@ -443,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/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/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/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 52c6b2d941e..320b410b1a2 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" @@ -255,8 +256,48 @@ 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") + } + + if err := validateFullyQualifiedRemoteContainerImage(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. + return image, nil +} + +func imagePassthroughArtifact(image string) *Artifact { + return &Artifact{ + Kind: ArtifactKindContainer, + Location: image, + LocationKind: LocationKindRemote, + Metadata: map[string]string{ + MetadataKeyImagePassthrough: "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 +395,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 +589,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 } @@ -612,6 +668,100 @@ 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 +} + +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, +) (string, bool, error) { + if !serviceConfig.Docker.ImagePassthrough || serviceContext == nil || len(serviceContext.Package) == 0 { + return "", false, nil + } + + 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") + } + + switch artifact.Kind { + case ArtifactKindContainer: + if err := validateFullyQualifiedRemoteContainerImage(artifact.Location); err != nil { + return "", false, fmt.Errorf( + "docker.imagePassthrough requires package container artifacts to use "+ + "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 + containerArtifacts = append(containerArtifacts, artifact) + case ArtifactKindConfig: + // Targets may add supplementary configuration alongside the container image. + case ArtifactKindArchive, ArtifactKindDirectory: + return "", false, fmt.Errorf( + "docker.imagePassthrough does not support %s package artifacts; "+ + "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 packageImage == "" { + 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 +} + // Publish pushes an image to a remote server and returns the fully qualified remote image name. func (ch *ContainerHelper) Publish( ctx context.Context, @@ -630,13 +780,30 @@ func (ch *ContainerHelper) Publish( var remoteImage string + if err := validatePublishOptions(serviceConfig, options); err != nil { + return nil, err + } + passthroughOverride, hasPassthroughOverride, err := imagePassthroughPackageOverride( + serviceConfig, + serviceContext, + ) + if err != nil { + return nil, err + } + // Parse PublishOptions into ImageOverride imageOverride, err := parseImageOverride(options) if err != nil { return nil, err } - if serviceConfig.Docker.RemoteBuild { + if serviceConfig.Docker.ImagePassthrough { + 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 { // Check if a local container runtime (Docker/Podman) is available before falling back @@ -664,13 +831,15 @@ func (ch *ContainerHelper) Publish( } // Create publish artifact with remote image reference + metadata := map[string]string{"remoteImage": remoteImage} + if serviceConfig.Docker.ImagePassthrough { + metadata[MetadataKeyImagePassthrough] = "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..e424a5e2d79 100644 --- a/cli/azd/pkg/project/container_helper_test.go +++ b/cli/azd/pkg/project/container_helper_test.go @@ -1280,6 +1280,87 @@ 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: "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:" + + "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}, + 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", + 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 @@ -1287,16 +1368,21 @@ func Test_ContainerHelper_Publish(t *testing.T) { image string project string packagePath string + packageKind ArtifactKind + packageLocationKind LocationKind + additionalArtifacts ArtifactCollection imageHash string sourceImage string targetImage string publishOptions *PublishOptions expectedRemoteImage string + imagePassthrough bool expectDockerLoginCalled bool expectDockerPullCalled bool expectDockerTagCalled bool expectDockerPushCalled bool expectError bool + expectedError string }{ { name: "Source code and registry", @@ -1359,6 +1445,158 @@ 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: "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 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", + 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: "requires package container artifacts", + }, + { + 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 package 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, + expectDockerPullCalled: false, + expectDockerTagCalled: false, + expectDockerPushCalled: false, + expectError: true, + expectedError: "does not support directory package artifacts", + }, { name: "With publish options overwrite", project: "./src/api", @@ -1460,25 +1698,32 @@ 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{ - { - 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, - } + packageArtifacts = append(packageArtifacts, tt.additionalArtifacts...) + serviceContext := &ServiceContext{Package: packageArtifacts} publishResult, err := logProgress( t, func(progress *async.Progress[ServiceProgress]) (*ServicePublishResult, error) { @@ -1490,6 +1735,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) @@ -1522,7 +1770,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_manager.go b/cli/azd/pkg/project/service_manager.go index 82d6cc173a5..6050d3f9e62 100644 --- a/cli/azd/pkg/project/service_manager.go +++ b/cli/azd/pkg/project/service_manager.go @@ -502,9 +502,19 @@ 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 + } + if serviceContext == nil { serviceContext = NewServiceContext() } + if _, _, err := imagePassthroughPackageOverride(serviceConfig, serviceContext); err != nil { + return nil, err + } cachedResult, ok := sm.getOperationResult(serviceConfig, ServiceEventPublish) if ok && cachedResult != nil { @@ -525,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) @@ -693,6 +708,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 cf2f10b14ec..d2e640ca578 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( @@ -320,6 +322,290 @@ 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.Image = osutil.NewExpandableString("private.example.com/team/agent:v1") + 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_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) + 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 + + 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{ + Kind: ArtifactKindContainer, + Location: "other.example.com/team/agent:v2", + LocationKind: LocationKindLocal, + })) + } + + _, err := sm.Publish( + ctx, + serviceConfig, + serviceContext, + async.NewProgress[ServiceProgress](), + &PublishOptions{}, + ) + + 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 package 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) { + 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: "requires package container artifacts", + }, + { + name: "archive", + artifact: &Artifact{ + Kind: ArtifactKindArchive, + Location: "agent.zip", + LocationKind: LocationKindLocal, + }, + errorContains: "does not support archive package artifacts", + }, + { + name: "directory", + artifact: &Artifact{ + Kind: ArtifactKindDirectory, + Location: "agent", + LocationKind: LocationKindLocal, + }, + errorContains: "does not support directory package 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: "does not support archive package artifacts", + }, + } + + 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("private.example.com/team/agent:v1") + 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) { t.Run("Standard", func(t *testing.T) { mockContext := mocks.NewMockContext(t.Context()) @@ -689,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 { @@ -739,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 { @@ -771,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{ { 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_aks_test.go b/cli/azd/pkg/project/service_target_aks_test.go index 52cfd1277de..9f253e217a1 100644 --- a/cli/azd/pkg/project/service_target_aks_test.go +++ b/cli/azd/pkg/project/service_target_aks_test.go @@ -245,6 +245,74 @@ 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) { + 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", + }, + } + + 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) { tempDir := t.TempDir() ostest.Chdir(t, tempDir) diff --git a/cli/azd/pkg/project/service_target_appservice.go b/cli/azd/pkg/project/service_target_appservice.go index 18ff767e527..04dc426bc3e 100644 --- a/cli/azd/pkg/project/service_target_appservice.go +++ b/cli/azd/pkg/project/service_target_appservice.go @@ -134,10 +134,12 @@ 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..1d81c269f7f 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,86 @@ 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[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.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..cf16a70f8b1 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,64 @@ 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() + + 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", + }, + { + 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( mockContext *mocks.MockContext, env *environment.Environment, diff --git a/docs/reference/azure-yaml-schema.md b/docs/reference/azure-yaml-schema.md index 8803ebccd11..235ca7eb04f 100644 --- a/docs/reference/azure-yaml-schema.md +++ b/docs/reference/azure-yaml-schema.md @@ -58,6 +58,52 @@ 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 | Reuse an existing remote service `image` without building or publishing it; `azd deploy --from-package` can override the image for one deployment | + +`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: + api: + host: containerapp + image: registry.example.com/apps/api:1.0 + docker: + 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 d103b88fb2a..35c8de50023 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 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 f7a7c9b5895..826969d3ed3 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 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." } } },