Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/azd/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions cli/azd/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions cli/azd/grpc/proto/models.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 24 additions & 15 deletions cli/azd/pkg/azdext/models.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 77 additions & 5 deletions cli/azd/pkg/project/container_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 := reference.Parse(image); err != nil {
return "", fmt.Errorf("parsing passthrough 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{
"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{}
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -612,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,
Expand All @@ -630,13 +694,19 @@ 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 {
return nil, err
}

if serviceConfig.Docker.RemoteBuild {
if serviceConfig.Docker.ImagePassthrough {
Comment thread
m5i-work marked this conversation as resolved.
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
Expand Down Expand Up @@ -664,13 +734,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{
Expand Down
99 changes: 99 additions & 0 deletions cli/azd/pkg/project/container_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,74 @@ 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: "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: "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
Expand All @@ -1292,6 +1360,7 @@ func Test_ContainerHelper_Publish(t *testing.T) {
targetImage string
publishOptions *PublishOptions
expectedRemoteImage string
imagePassthrough bool
expectDockerLoginCalled bool
expectDockerPullCalled bool
expectDockerTagCalled bool
Expand Down Expand Up @@ -1359,6 +1428,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",
Expand Down Expand Up @@ -1460,6 +1553,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{
Expand Down Expand Up @@ -1522,7 +1616,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"])
}
}
})
}
Expand Down
Loading
Loading