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 22, 2024
1 parent 6e16f37 commit 15ced5d
Show file tree
Hide file tree
Showing 18 changed files with 2,516 additions and 33 deletions.
2 changes: 1 addition & 1 deletion cli/azd/internal/cmd/add/add_configure_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ func addServiceAsResource(
if _, err := os.Stat(filepath.Join(svc.RelativePath, "Dockerfile")); errors.Is(err, os.ErrNotExist) {
// default builder always specifies port 80
props.Port = 80
if svc.Language == project.ServiceLanguageJava {
if svc.Language == project.ServiceLanguageJava || svc.Language.IsDotNet() {
props.Port = 8080
}
}
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/internal/repository/app_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func TestInitializer_prjConfigFromDetect(t *testing.T) {
Type: project.ResourceTypeHostContainerApp,
Name: "dotnet",
Props: project.ContainerAppProps{
Port: 80,
Port: 8080,
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/internal/repository/infra_confirm.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ func PromptPort(
name string,
svc appdetect.Project) (int, error) {
if svc.Docker == nil || svc.Docker.Path == "" { // using default builder from azd
if svc.Language == appdetect.Java {
if svc.Language == appdetect.Java || svc.Language == appdetect.DotNet {
return 8080, nil
}
return 80, nil
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/internal/repository/infra_confirm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestInitializer_infraSpecFromDetect(t *testing.T) {
Services: []scaffold.ServiceSpec{
{
Name: "dotnet",
Port: 80,
Port: 8080,
Backend: &scaffold.Backend{},
},
},
Expand Down
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
}
51 changes: 39 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,43 @@ 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()

dockerOptions := getDockerOptionsWithDefaults(serviceConfig.Docker)

dockerfilePath := dockerOptions.Path
if !filepath.IsAbs(dockerfilePath) {
s, err := os.Stat(projectPath)
if err == nil && s.IsDir() {
dockerfilePath = filepath.Join(projectPath, dockerfilePath)
} else {
dockerfilePath = filepath.Join(filepath.Dir(projectPath), dockerfilePath)
}
}

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
Loading

0 comments on commit 15ced5d

Please sign in to comment.