From f3ba876d9de04eb248a1240825817818ffe592d4 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Tue, 2 Jun 2026 23:09:53 +0000 Subject: [PATCH] fix: pass ContainerEngine to dotnet publish when Podman is used When users have Podman instead of Docker, `dotnet publish /t:PublishContainer` defaults to Docker and fails silently, producing empty stdout. This causes azd to error with "empty dotnet configuration output". This change threads the detected container engine from docker.Cli through to the dotnet publish commands via a new `containerEngine` parameter. When the engine is "podman", azd appends `-p:ContainerEngine=podman` to the MSBuild command, telling the .NET SDK to use Podman for container operations. Changes: - Export `ContainerEngine()` getter on `docker.Cli` - Add `ContainerEngine()` delegate on `ContainerHelper` - Add `containerEngine` parameter to `BuildContainerLocal` and `PublishContainer` - Add `appendContainerEngine` helper in dotnet package - Add tests for podman/docker/empty engine scenarios Fixes #8525 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/project/container_helper.go | 8 +- .../service_target_dotnet_containerapp.go | 4 +- cli/azd/pkg/tools/docker/docker.go | 7 ++ cli/azd/pkg/tools/dotnet/dotnet.go | 18 +++- .../pkg/tools/dotnet/dotnet_commands_test.go | 98 ++++++++++++++++--- 5 files changed, 120 insertions(+), 15 deletions(-) diff --git a/cli/azd/pkg/project/container_helper.go b/cli/azd/pkg/project/container_helper.go index 257e75ee9aa..52c6b2d941e 100644 --- a/cli/azd/pkg/project/container_helper.go +++ b/cli/azd/pkg/project/container_helper.go @@ -84,6 +84,11 @@ func NewContainerHelper( } } +// ContainerEngine returns the detected container engine name ("docker" or "podman"). +func (ch *ContainerHelper) ContainerEngine() string { + return ch.docker.ContainerEngine() +} + // DockerfileBuilder returns a new DockerfileBuilder instance for building Dockerfiles programmatically. func (ch *ContainerHelper) DockerfileBuilder() *DockerfileBuilder { return NewDockerfileBuilder() @@ -1017,7 +1022,8 @@ func (ch *ContainerHelper) runDotnetPublish( imageName, dockerCreds.LoginServer, dockerCreds.Username, - dockerCreds.Password) + dockerCreds.Password, + ch.ContainerEngine()) if err != nil { return "", fmt.Errorf("publishing container: %w", err) } diff --git a/cli/azd/pkg/project/service_target_dotnet_containerapp.go b/cli/azd/pkg/project/service_target_dotnet_containerapp.go index ffea0c4eca6..205d6616f5e 100644 --- a/cli/azd/pkg/project/service_target_dotnet_containerapp.go +++ b/cli/azd/pkg/project/service_target_dotnet_containerapp.go @@ -664,7 +664,8 @@ func (at *dotnetContainerAppTarget) prepareDotNetProjectImage( imageName, dockerCreds.LoginServer, dockerCreds.Username, - dockerCreds.Password) + dockerCreds.Password, + at.containerHelper.ContainerEngine()) if err != nil { return nil, fmt.Errorf("publishing container: %w", err) } @@ -700,6 +701,7 @@ func (at *dotnetContainerAppTarget) prepareDotNetMultiStageImage( serviceConfig.Path(), "Release", localImageTag, + at.containerHelper.ContainerEngine(), ) if err != nil { return nil, fmt.Errorf("building local container: %w", err) diff --git a/cli/azd/pkg/tools/docker/docker.go b/cli/azd/pkg/tools/docker/docker.go index c670fe2f24c..71dec1cd9d1 100644 --- a/cli/azd/pkg/tools/docker/docker.go +++ b/cli/azd/pkg/tools/docker/docker.go @@ -35,6 +35,13 @@ type Cli struct { containerEngine string // "docker" or "podman", detected during CheckInstalled } +// ContainerEngine returns the detected container engine name ("docker" or "podman"). +// CheckInstalled() should be called first to detect and set the container engine. +// If not set, defaults to "docker" for backward compatibility. +func (d *Cli) ContainerEngine() string { + return d.getContainerEngine() +} + // getContainerEngine returns the container engine command to use ("docker" or "podman"). // CheckInstalled() should be called first to detect and set the container engine. // If not set, defaults to "docker" for backward compatibility. diff --git a/cli/azd/pkg/tools/dotnet/dotnet.go b/cli/azd/pkg/tools/dotnet/dotnet.go index a7c9eaf1024..5a2562b026d 100644 --- a/cli/azd/pkg/tools/dotnet/dotnet.go +++ b/cli/azd/pkg/tools/dotnet/dotnet.go @@ -228,7 +228,7 @@ func (cli *Cli) PublishAppHostManifest( // BuildContainerLocal runs `dotnet publish` with `/t:PublishContainer` to build a container image locally // without pushing to a registry. Returns the port number and image name. func (cli *Cli) BuildContainerLocal( - ctx context.Context, project, configuration, imageName string, + ctx context.Context, project, configuration, imageName, containerEngine string, ) (int, string, error) { if !strings.Contains(imageName, ":") { imageName = fmt.Sprintf("%s:latest", imageName) @@ -258,6 +258,7 @@ func (cli *Cli) BuildContainerLocal( "--getProperty:GeneratedContainerConfiguration", ) + runArgs = appendContainerEngine(runArgs, containerEngine) runArgs = appendArtifactsPath(ctx, runArgs) result, err := cli.commandRunner.Run(ctx, runArgs) @@ -274,7 +275,7 @@ func (cli *Cli) BuildContainerLocal( } func (cli *Cli) PublishContainer( - ctx context.Context, project, configuration, imageName, server, username, password string, + ctx context.Context, project, configuration, imageName, server, username, password, containerEngine string, ) (int, error) { if !strings.Contains(imageName, ":") { imageName = fmt.Sprintf("%s:latest", imageName) @@ -304,6 +305,7 @@ func (cli *Cli) PublishContainer( "--getProperty:GeneratedContainerConfiguration", ) + runArgs = appendContainerEngine(runArgs, containerEngine) runArgs = appendArtifactsPath(ctx, runArgs) runArgs = runArgs.WithEnv([]string{ @@ -327,6 +329,18 @@ func (cli *Cli) PublishContainer( return port, nil } +// appendContainerEngine appends -p:ContainerEngine= to the dotnet publish command +// when the container engine is not "docker" (the .NET SDK default). This ensures the +// .NET SDK uses the correct container runtime (e.g., Podman) for building and pushing images. +func appendContainerEngine(runArgs exec.RunArgs, containerEngine string) exec.RunArgs { + if containerEngine != "" && containerEngine != "docker" { + runArgs = runArgs.AppendParams( + fmt.Sprintf("-p:ContainerEngine=%s", containerEngine), + ) + } + return runArgs +} + // appendArtifactsPath checks the context for a per-service artifacts path // (set by [ContextWithArtifactsPath]) and, if present, appends // --artifacts-path to the dotnet publish command. This redirects all diff --git a/cli/azd/pkg/tools/dotnet/dotnet_commands_test.go b/cli/azd/pkg/tools/dotnet/dotnet_commands_test.go index 5863750a18b..d1a334d9fb2 100644 --- a/cli/azd/pkg/tools/dotnet/dotnet_commands_test.go +++ b/cli/azd/pkg/tools/dotnet/dotnet_commands_test.go @@ -408,7 +408,7 @@ func Test_Cli_BuildContainerLocal(t *testing.T) { return exec.NewRunResult(0, successContainerOutput, ""), nil }) - port, image, err := cli.BuildContainerLocal(t.Context(), "p.csproj", "Release", "myrepo") + port, image, err := cli.BuildContainerLocal(t.Context(), "p.csproj", "Release", "myrepo", "") require.NoError(t, err) require.Equal(t, 8080, port) require.Equal(t, "myrepo:latest", image) @@ -431,7 +431,7 @@ func Test_Cli_BuildContainerLocal(t *testing.T) { }) proj := filepath.Join("some", "dir", "apphost.cs") - _, _, err := cli.BuildContainerLocal(t.Context(), proj, "Release", "img:v1") + _, _, err := cli.BuildContainerLocal(t.Context(), proj, "Release", "img:v1", "") require.NoError(t, err) require.Equal(t, filepath.Dir(proj), captured.Cwd) require.Equal(t, "apphost.cs", captured.Args[1]) @@ -446,7 +446,7 @@ func Test_Cli_BuildContainerLocal(t *testing.T) { return exec.RunResult{}, errors.New("nope") }) - _, _, err := cli.BuildContainerLocal(t.Context(), "p.csproj", "Release", "img") + _, _, err := cli.BuildContainerLocal(t.Context(), "p.csproj", "Release", "img", "") require.Error(t, err) require.Contains(t, err.Error(), "dotnet publish on project 'p.csproj' failed") }) @@ -457,7 +457,7 @@ func Test_Cli_BuildContainerLocal(t *testing.T) { runner.When(matchDotnetArg0("publish")). Respond(exec.NewRunResult(0, "", "")) - _, _, err := cli.BuildContainerLocal(t.Context(), "p.csproj", "Release", "img") + _, _, err := cli.BuildContainerLocal(t.Context(), "p.csproj", "Release", "img", "") require.Error(t, err) require.Contains(t, err.Error(), "failed to get dotnet target port") }) @@ -477,7 +477,7 @@ func Test_Cli_PublishContainer(t *testing.T) { }) port, err := cli.PublishContainer( - t.Context(), "p.csproj", "Release", "repo", "registry.example.com", "user", "secret", + t.Context(), "p.csproj", "Release", "repo", "registry.example.com", "user", "secret", "", ) require.NoError(t, err) require.Equal(t, 8080, port) @@ -500,7 +500,7 @@ func Test_Cli_PublishContainer(t *testing.T) { }) proj := filepath.Join("d", "apphost.cs") - _, err := cli.PublishContainer(t.Context(), proj, "Release", "img", "r", "u", "p") + _, err := cli.PublishContainer(t.Context(), proj, "Release", "img", "r", "u", "p", "") require.NoError(t, err) require.Equal(t, filepath.Dir(proj), captured.Cwd) require.Equal(t, "apphost.cs", captured.Args[1]) @@ -515,7 +515,7 @@ func Test_Cli_PublishContainer(t *testing.T) { return exec.RunResult{}, errors.New("boom") }) - _, err := cli.PublishContainer(t.Context(), "p.csproj", "Release", "img", "r", "u", "p") + _, err := cli.PublishContainer(t.Context(), "p.csproj", "Release", "img", "r", "u", "p", "") require.Error(t, err) require.Contains(t, err.Error(), "dotnet publish on project 'p.csproj' failed") }) @@ -526,7 +526,7 @@ func Test_Cli_PublishContainer(t *testing.T) { runner.When(matchDotnetArg0("publish")). Respond(exec.NewRunResult(0, "", "")) - _, err := cli.PublishContainer(t.Context(), "p.csproj", "Release", "img", "r", "u", "p") + _, err := cli.PublishContainer(t.Context(), "p.csproj", "Release", "img", "r", "u", "p", "") require.Error(t, err) require.Contains(t, err.Error(), "failed to get dotnet target port") }) @@ -546,7 +546,7 @@ func Test_Cli_ArtifactsPathContext(t *testing.T) { }) ctx := ContextWithArtifactsPath(t.Context(), "/tmp/artifacts-svc1") - _, err := cli.PublishContainer(ctx, "p.csproj", "Release", "img", "r", "u", "p") + _, err := cli.PublishContainer(ctx, "p.csproj", "Release", "img", "r", "u", "p", "") require.NoError(t, err) joined := strings.Join(captured.Args, " ") require.Contains(t, joined, "--artifacts-path") @@ -564,7 +564,7 @@ func Test_Cli_ArtifactsPathContext(t *testing.T) { }) ctx := ContextWithArtifactsPath(t.Context(), "/tmp/artifacts-svc2") - _, _, err := cli.BuildContainerLocal(ctx, "p.csproj", "Release", "img") + _, _, err := cli.BuildContainerLocal(ctx, "p.csproj", "Release", "img", "") require.NoError(t, err) joined := strings.Join(captured.Args, " ") require.Contains(t, joined, "--artifacts-path") @@ -581,13 +581,89 @@ func Test_Cli_ArtifactsPathContext(t *testing.T) { return exec.NewRunResult(0, successContainerOutput, ""), nil }) - _, err := cli.PublishContainer(t.Context(), "p.csproj", "Release", "img", "r", "u", "p") + _, err := cli.PublishContainer(t.Context(), "p.csproj", "Release", "img", "r", "u", "p", "") require.NoError(t, err) joined := strings.Join(captured.Args, " ") require.NotContains(t, joined, "--artifacts-path") }) } +func Test_Cli_ContainerEngine(t *testing.T) { + t.Parallel() + + t.Run("podman engine appends ContainerEngine property", func(t *testing.T) { + t.Parallel() + cli, runner := newCliWithMock(t) + var captured exec.RunArgs + runner.When(matchDotnetArg0("publish")). + RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + captured = args + return exec.NewRunResult(0, successContainerOutput, ""), nil + }) + + _, _, err := cli.BuildContainerLocal( + t.Context(), "p.csproj", "Release", "img", "podman", + ) + require.NoError(t, err) + joined := strings.Join(captured.Args, " ") + require.Contains(t, joined, "-p:ContainerEngine=podman") + }) + + t.Run("docker engine does not append ContainerEngine property", func(t *testing.T) { + t.Parallel() + cli, runner := newCliWithMock(t) + var captured exec.RunArgs + runner.When(matchDotnetArg0("publish")). + RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + captured = args + return exec.NewRunResult(0, successContainerOutput, ""), nil + }) + + _, _, err := cli.BuildContainerLocal( + t.Context(), "p.csproj", "Release", "img", "docker", + ) + require.NoError(t, err) + joined := strings.Join(captured.Args, " ") + require.NotContains(t, joined, "-p:ContainerEngine=") + }) + + t.Run("empty engine does not append ContainerEngine property", func(t *testing.T) { + t.Parallel() + cli, runner := newCliWithMock(t) + var captured exec.RunArgs + runner.When(matchDotnetArg0("publish")). + RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + captured = args + return exec.NewRunResult(0, successContainerOutput, ""), nil + }) + + _, _, err := cli.BuildContainerLocal( + t.Context(), "p.csproj", "Release", "img", "", + ) + require.NoError(t, err) + joined := strings.Join(captured.Args, " ") + require.NotContains(t, joined, "-p:ContainerEngine=") + }) + + t.Run("podman engine on PublishContainer", func(t *testing.T) { + t.Parallel() + cli, runner := newCliWithMock(t) + var captured exec.RunArgs + runner.When(matchDotnetArg0("publish")). + RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + captured = args + return exec.NewRunResult(0, successContainerOutput, ""), nil + }) + + _, err := cli.PublishContainer( + t.Context(), "p.csproj", "Release", "img", "r", "u", "p", "podman", + ) + require.NoError(t, err) + joined := strings.Join(captured.Args, " ") + require.Contains(t, joined, "-p:ContainerEngine=podman") + }) +} + func Test_Cli_getTargetPort_Branches(t *testing.T) { t.Parallel()