Skip to content

Commit

Permalink
dotnet: Support dotnet publish to produce container image
Browse files Browse the repository at this point in the history
The `dotnet` tool has native support for producing (and pushing) a
container image. This does not require a local docker daemon and in
general "does the right thing" and is the prefered way for .NET
customers to produce container
images. https://learn.microsoft.com/dotnet/core/docker/publish-as-container
gives a good overview of how this support works and we've been using
it to build and push container images for Aspire apps.

This change updates things such that we can use this support when
building a non Aspire based .NET app. If a `Dockerfile` exists, we
respect it, but otherwise instead of trying to use Oryx to produce a
container image, we will use `dotnet publish`.

A new recorded test was added - it builds on top of the existing
`containerapp` sample, and the test aranges to remove the `Dockerfile`
file from the template before running `up`.

Fixes #2632
  • Loading branch information
ellismg committed Nov 21, 2024
1 parent 6e16f37 commit 7e7e128
Show file tree
Hide file tree
Showing 14 changed files with 2,509 additions and 29 deletions.
48 changes: 47 additions & 1 deletion cli/azd/pkg/project/container_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/tools"
"github.com/azure/azure-dev/cli/azd/pkg/tools/azcli"
"github.com/azure/azure-dev/cli/azd/pkg/tools/docker"
"github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet"
"github.com/benbjohnson/clock"
"github.com/sethvargo/go-retry"
)
Expand All @@ -32,6 +33,7 @@ type ContainerHelper struct {
remoteBuildManager *containerregistry.RemoteBuildManager
containerRegistryService azcli.ContainerRegistryService
docker *docker.Cli
dotNetCli *dotnet.Cli
clock clock.Clock
console input.Console
cloud *cloud.Cloud
Expand All @@ -44,6 +46,7 @@ func NewContainerHelper(
containerRegistryService azcli.ContainerRegistryService,
remoteBuildManager *containerregistry.RemoteBuildManager,
docker *docker.Cli,
dotNetCli *dotnet.Cli,
console input.Console,
cloud *cloud.Cloud,
) *ContainerHelper {
Expand All @@ -53,6 +56,7 @@ func NewContainerHelper(
remoteBuildManager: remoteBuildManager,
containerRegistryService: containerRegistryService,
docker: docker,
dotNetCli: dotNetCli,
clock: clock,
console: console,
cloud: cloud,
Expand Down Expand Up @@ -194,6 +198,10 @@ func (ch *ContainerHelper) RequiredExternalTools(ctx context.Context, serviceCon
return []tools.ExternalTool{}
}

if useDotnetPublishForDockerBuild(serviceConfig) {
return []tools.ExternalTool{ch.dotNetCli}
}

return []tools.ExternalTool{ch.docker}
}

Expand Down Expand Up @@ -270,6 +278,8 @@ func (ch *ContainerHelper) Deploy(

if serviceConfig.Docker.RemoteBuild {
remoteImage, err = ch.runRemoteBuild(ctx, serviceConfig, targetResource, progress)
} else if useDotnetPublishForDockerBuild(serviceConfig) {
remoteImage, err = ch.runDotnetPublish(ctx, serviceConfig, targetResource, progress)
} else {
remoteImage, err = ch.runLocalBuild(ctx, serviceConfig, packageOutput, progress)
}
Expand Down Expand Up @@ -382,7 +392,8 @@ func (ch *ContainerHelper) runLocalBuild(
return remoteImage, nil
}

// runLocalBuild builds the image using a remote azure container registry and tags it. It returns the full remote image name.
// runRemoteBuild builds the image using a remote azure container registry and tags it.
// It returns the full remote image name.
func (ch *ContainerHelper) runRemoteBuild(
ctx context.Context,
serviceConfig *ServiceConfig,
Expand Down Expand Up @@ -473,6 +484,41 @@ func (ch *ContainerHelper) runRemoteBuild(
return imageName, nil
}

// runDotnetPublish builds and publishes the container image using `dotnet publish`. It returns the full remote image name.
func (ch *ContainerHelper) runDotnetPublish(
ctx context.Context,
serviceConfig *ServiceConfig,
target *environment.TargetResource,
progress *async.Progress[ServiceProgress],
) (string, error) {
progress.SetProgress(NewServiceProgress("Logging into registry"))

dockerCreds, err := ch.Credentials(ctx, serviceConfig, target)
if err != nil {
return "", fmt.Errorf("logging in to registry: %w", err)
}

progress.SetProgress(NewServiceProgress("Publishing container image"))

imageName := fmt.Sprintf("%s:%s",
ch.DefaultImageName(serviceConfig),
ch.DefaultImageTag())

_, err = ch.dotNetCli.PublishContainer(
ctx,
serviceConfig.Path(),
"Release",
imageName,
dockerCreds.LoginServer,
dockerCreds.Username,
dockerCreds.Password)
if err != nil {
return "", fmt.Errorf("publishing container: %w", err)
}

return fmt.Sprintf("%s/%s", dockerCreds.LoginServer, imageName), nil
}

type dockerDeployResult struct {
RemoteImageTag string
}
19 changes: 11 additions & 8 deletions cli/azd/pkg/project/container_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/osutil"
"github.com/azure/azure-dev/cli/azd/pkg/tools/azcli"
"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"
"github.com/azure/azure-dev/cli/azd/test/mocks/mockenv"
"github.com/benbjohnson/clock"
Expand Down Expand Up @@ -62,7 +63,7 @@ func Test_ContainerHelper_LocalImageTag(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := environment.NewWithValues("dev", map[string]string{})
containerHelper := NewContainerHelper(env, nil, clock.NewMock(), nil, nil, nil, nil, cloud.AzurePublic())
containerHelper := NewContainerHelper(env, nil, clock.NewMock(), nil, nil, nil, nil, nil, cloud.AzurePublic())
serviceConfig.Docker = tt.dockerConfig

tag, err := containerHelper.LocalImageTag(*mockContext.Context, serviceConfig)
Expand Down Expand Up @@ -111,7 +112,7 @@ func Test_ContainerHelper_RemoteImageTag(t *testing.T) {

mockContext := mocks.NewMockContext(context.Background())
env := environment.NewWithValues("dev", map[string]string{})
containerHelper := NewContainerHelper(env, nil, clock.NewMock(), nil, nil, nil, nil, cloud.AzurePublic())
containerHelper := NewContainerHelper(env, nil, clock.NewMock(), nil, nil, nil, nil, nil, cloud.AzurePublic())

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -138,7 +139,7 @@ func Test_ContainerHelper_Resolve_RegistryName(t *testing.T) {
environment.ContainerRegistryEndpointEnvVarName: "contoso.azurecr.io",
})
envManager := &mockenv.MockEnvManager{}
containerHelper := NewContainerHelper(env, envManager, clock.NewMock(), nil, nil, nil, nil, cloud.AzurePublic())
containerHelper := NewContainerHelper(env, envManager, clock.NewMock(), nil, nil, nil, nil, nil, cloud.AzurePublic())
serviceConfig := createTestServiceConfig("./src/api", ContainerAppTarget, ServiceLanguageTypeScript)
registryName, err := containerHelper.RegistryName(*mockContext.Context, serviceConfig)

Expand All @@ -150,7 +151,7 @@ func Test_ContainerHelper_Resolve_RegistryName(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
env := environment.NewWithValues("dev", map[string]string{})
envManager := &mockenv.MockEnvManager{}
containerHelper := NewContainerHelper(env, envManager, clock.NewMock(), nil, nil, nil, nil, cloud.AzurePublic())
containerHelper := NewContainerHelper(env, envManager, clock.NewMock(), nil, nil, nil, nil, nil, cloud.AzurePublic())
serviceConfig := createTestServiceConfig("./src/api", ContainerAppTarget, ServiceLanguageTypeScript)
serviceConfig.Docker.Registry = osutil.NewExpandableString("contoso.azurecr.io")
registryName, err := containerHelper.RegistryName(*mockContext.Context, serviceConfig)
Expand All @@ -164,7 +165,7 @@ func Test_ContainerHelper_Resolve_RegistryName(t *testing.T) {
env := environment.NewWithValues("dev", map[string]string{})
env.DotenvSet("MY_CUSTOM_REGISTRY", "custom.azurecr.io")
envManager := &mockenv.MockEnvManager{}
containerHelper := NewContainerHelper(env, envManager, clock.NewMock(), nil, nil, nil, nil, cloud.AzurePublic())
containerHelper := NewContainerHelper(env, envManager, clock.NewMock(), nil, nil, nil, nil, nil, cloud.AzurePublic())
serviceConfig := createTestServiceConfig("./src/api", ContainerAppTarget, ServiceLanguageTypeScript)
serviceConfig.Docker.Registry = osutil.NewExpandableString("${MY_CUSTOM_REGISTRY}")
registryName, err := containerHelper.RegistryName(*mockContext.Context, serviceConfig)
Expand All @@ -177,7 +178,7 @@ func Test_ContainerHelper_Resolve_RegistryName(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
env := environment.NewWithValues("dev", map[string]string{})
envManager := &mockenv.MockEnvManager{}
containerHelper := NewContainerHelper(env, envManager, clock.NewMock(), nil, nil, nil, nil, cloud.AzurePublic())
containerHelper := NewContainerHelper(env, envManager, clock.NewMock(), nil, nil, nil, nil, nil, cloud.AzurePublic())
serviceConfig := createTestServiceConfig("./src/api", ContainerAppTarget, ServiceLanguageTypeScript)
registryName, err := containerHelper.RegistryName(*mockContext.Context, serviceConfig)

Expand Down Expand Up @@ -334,6 +335,7 @@ func Test_ContainerHelper_Deploy(t *testing.T) {
mockResults := setupDockerMocks(mockContext)
env := environment.NewWithValues("dev", map[string]string{})
dockerCli := docker.NewCli(mockContext.CommandRunner)
dotnetCli := dotnet.NewCli(mockContext.CommandRunner)
envManager := &mockenv.MockEnvManager{}
envManager.On("Save", *mockContext.Context, env).Return(nil)

Expand All @@ -347,6 +349,7 @@ func Test_ContainerHelper_Deploy(t *testing.T) {
mockContainerRegistryService,
nil,
dockerCli,
dotnetCli,
mockContext.Console,
cloud.AzurePublic(),
)
Expand Down Expand Up @@ -411,7 +414,7 @@ func Test_ContainerHelper_Deploy(t *testing.T) {
func Test_ContainerHelper_ConfiguredImage(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
env := environment.NewWithValues("dev", map[string]string{})
containerHelper := NewContainerHelper(env, nil, clock.NewMock(), nil, nil, nil, nil, cloud.AzurePublic())
containerHelper := NewContainerHelper(env, nil, clock.NewMock(), nil, nil, nil, nil, nil, cloud.AzurePublic())

tests := []struct {
name string
Expand Down Expand Up @@ -597,7 +600,7 @@ func Test_ContainerHelper_Credential_Retry(t *testing.T) {
defaultCredentialsRetryDelay = 1 * time.Millisecond

containerHelper := NewContainerHelper(
env, envManager, clock.NewMock(), mockContainerService, nil, nil, nil, cloud.AzurePublic())
env, envManager, clock.NewMock(), mockContainerService, nil, nil, nil, nil, cloud.AzurePublic())

serviceConfig := createTestServiceConfig("path", ContainerAppTarget, ServiceLanguageDotNet)
serviceConfig.Docker.Registry = osutil.NewExpandableString("contoso.azurecr.io")
Expand Down
4 changes: 4 additions & 0 deletions cli/azd/pkg/project/framework_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,7 @@ func validatePackageOutput(packagePath string) error {

return nil
}

func (slk ServiceLanguageKind) IsDotNet() bool {
return slk == ServiceLanguageDotNet || slk == ServiceLanguageCsharp || slk == ServiceLanguageFsharp
}
48 changes: 36 additions & 12 deletions cli/azd/pkg/project/framework_service_docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"path/filepath"
"strings"

"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/internal/appdetect"
"github.com/azure/azure-dev/cli/azd/internal/tracing"
Expand Down Expand Up @@ -163,12 +164,8 @@ func (p *dockerProject) Requirements() FrameworkRequirements {
}

// Gets the required external tools for the project
func (p *dockerProject) RequiredExternalTools(_ context.Context, sc *ServiceConfig) []tools.ExternalTool {
if sc.Docker.RemoteBuild {
return []tools.ExternalTool{}
}

return []tools.ExternalTool{p.docker}
func (p *dockerProject) RequiredExternalTools(ctx context.Context, sc *ServiceConfig) []tools.ExternalTool {
return p.containerHelper.RequiredExternalTools(ctx, sc)
}

// Initializes the docker project
Expand Down Expand Up @@ -199,7 +196,7 @@ func (p *dockerProject) Build(
restoreOutput *ServiceRestoreResult,
progress *async.Progress[ServiceProgress],
) (*ServiceBuildResult, error) {
if serviceConfig.Docker.RemoteBuild {
if serviceConfig.Docker.RemoteBuild || useDotnetPublishForDockerBuild(serviceConfig) {
return &ServiceBuildResult{Restore: restoreOutput}, nil
}

Expand Down Expand Up @@ -275,12 +272,12 @@ func (p *dockerProject) Build(
strings.ToLower(serviceConfig.Name),
)

path := dockerOptions.Path
if !filepath.IsAbs(path) {
path = filepath.Join(serviceConfig.Path(), path)
dockerfilePath := dockerOptions.Path
if !filepath.IsAbs(dockerfilePath) {
dockerfilePath = filepath.Join(serviceConfig.Path(), dockerfilePath)
}

_, err = os.Stat(path)
_, err = os.Stat(dockerfilePath)
if errors.Is(err, os.ErrNotExist) && serviceConfig.Docker.Path == "" {
// Build the container from source when:
// 1. No Dockerfile path is specified, and
Expand Down Expand Up @@ -341,13 +338,40 @@ func (p *dockerProject) Build(
}, nil
}

func useDotnetPublishForDockerBuild(serviceConfig *ServiceConfig) bool {
if serviceConfig.useDotNetPublishForDockerBuild != nil {
return *serviceConfig.useDotNetPublishForDockerBuild
}

serviceConfig.useDotNetPublishForDockerBuild = to.Ptr(false)

if serviceConfig.Language.IsDotNet() {
projectPath := serviceConfig.Path()

var dockerFilePath string

s, err := os.Stat(projectPath)
if err == nil && s.IsDir() {
dockerFilePath = filepath.Join(projectPath, "Dockerfile")
} else {
dockerFilePath = filepath.Join(filepath.Dir(projectPath), "Dockerfile")
}

if _, err := os.Stat(dockerFilePath); errors.Is(err, os.ErrNotExist) {
serviceConfig.useDotNetPublishForDockerBuild = to.Ptr(true)
}
}

return *serviceConfig.useDotNetPublishForDockerBuild
}

func (p *dockerProject) Package(
ctx context.Context,
serviceConfig *ServiceConfig,
buildOutput *ServiceBuildResult,
progress *async.Progress[ServiceProgress],
) (*ServicePackageResult, error) {
if serviceConfig.Docker.RemoteBuild {
if serviceConfig.Docker.RemoteBuild || useDotnetPublishForDockerBuild(serviceConfig) {
return &ServicePackageResult{Build: buildOutput}, nil
}

Expand Down
17 changes: 13 additions & 4 deletions cli/azd/pkg/project/framework_service_docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"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/pkg/tools/npm"
"github.com/azure/azure-dev/cli/azd/test/mocks"
"github.com/azure/azure-dev/cli/azd/test/mocks/mockarmresources"
Expand Down Expand Up @@ -100,14 +101,16 @@ services:

npmCli := npm.NewCli(mockContext.CommandRunner)
docker := docker.NewCli(mockContext.CommandRunner)
dotnetCli := dotnet.NewCli(mockContext.CommandRunner)

internalFramework := NewNpmProject(npmCli, env)
progressMessages := []string{}

framework := NewDockerProject(
env,
docker,
NewContainerHelper(env, envManager, clock.NewMock(), nil, nil, docker, mockContext.Console, cloud.AzurePublic()),
NewContainerHelper(
env, envManager, clock.NewMock(), nil, nil, docker, dotnetCli, mockContext.Console, cloud.AzurePublic()),
mockinput.NewMockConsole(),
mockContext.AlphaFeaturesManager,
mockContext.CommandRunner)
Expand Down Expand Up @@ -194,6 +197,7 @@ services:

npmCli := npm.NewCli(mockContext.CommandRunner)
docker := docker.NewCli(mockContext.CommandRunner)
dotnetCli := dotnet.NewCli(mockContext.CommandRunner)

projectConfig, err := Parse(*mockContext.Context, testProj)
require.NoError(t, err)
Expand All @@ -211,7 +215,8 @@ services:
framework := NewDockerProject(
env,
docker,
NewContainerHelper(env, envManager, clock.NewMock(), nil, nil, docker, mockContext.Console, cloud.AzurePublic()),
NewContainerHelper(
env, envManager, clock.NewMock(), nil, nil, docker, dotnetCli, mockContext.Console, cloud.AzurePublic()),
mockinput.NewMockConsole(),
mockContext.AlphaFeaturesManager,
mockContext.CommandRunner)
Expand Down Expand Up @@ -444,6 +449,7 @@ func Test_DockerProject_Build(t *testing.T) {
}

dockerCli := docker.NewCli(mockContext.CommandRunner)
dotnetCli := dotnet.NewCli(mockContext.CommandRunner)
serviceConfig := createTestServiceConfig(tt.project, ContainerAppTarget, tt.language)
serviceConfig.Project.Path = temp
serviceConfig.Docker = tt.dockerOptions
Expand All @@ -466,7 +472,8 @@ func Test_DockerProject_Build(t *testing.T) {
env,
dockerCli,
NewContainerHelper(
env, envManager, clock.NewMock(), nil, nil, dockerCli, mockContext.Console, cloud.AzurePublic()),
env, envManager, clock.NewMock(), nil, nil, dockerCli, dotnetCli, mockContext.Console,
cloud.AzurePublic()),
mockinput.NewMockConsole(),
mockContext.AlphaFeaturesManager,
mockContext.CommandRunner)
Expand Down Expand Up @@ -582,13 +589,15 @@ func Test_DockerProject_Package(t *testing.T) {

env := environment.NewWithValues("test", map[string]string{})
dockerCli := docker.NewCli(mockContext.CommandRunner)
dotnetCli := dotnet.NewCli(mockContext.CommandRunner)
serviceConfig := createTestServiceConfig("./src/api", ContainerAppTarget, ServiceLanguageTypeScript)

dockerProject := NewDockerProject(
env,
dockerCli,
NewContainerHelper(
env, envManager, clock.NewMock(), nil, nil, dockerCli, mockContext.Console, cloud.AzurePublic()),
env, envManager, clock.NewMock(), nil, nil, dockerCli, dotnetCli, mockContext.Console,
cloud.AzurePublic()),
mockinput.NewMockConsole(),
mockContext.AlphaFeaturesManager,
mockContext.CommandRunner)
Expand Down
3 changes: 3 additions & 0 deletions cli/azd/pkg/project/service_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ type ServiceConfig struct {
DotNetContainerApp *DotNetContainerAppOptions `yaml:"-,omitempty"`
// Custom configuration for the service target
Config map[string]any `yaml:"config,omitempty"`
// Computed lazily by useDotnetPublishForDockerBuild and cached. This is true when the project
// is a dotnet project and there is not an explicit Dockerfile in the project directory.
useDotNetPublishForDockerBuild *bool

*ext.EventDispatcher[ServiceLifecycleEventArgs] `yaml:"-"`
}
Expand Down
Loading

0 comments on commit 7e7e128

Please sign in to comment.