From 37b2e9ffaa3712b7180147dc83ce62c01cff3500 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 15:16:23 +0000 Subject: [PATCH 1/8] Initial plan From 3a4e71db8d84d496358a46d81f1e570b658f5a2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 15:30:39 +0000 Subject: [PATCH 2/8] Add Podman support to Azure Developer CLI - Add support for Podman as an alternative container runtime - Implement automatic fallback from Docker to Podman - Add AZURE_CONTAINER_RUNTIME environment variable for explicit runtime selection - Update version checking to support Podman version format - Add comprehensive tests for Podman support - Update error messages to mention both Docker and Podman Co-authored-by: spboyer <7681382+spboyer@users.noreply.github.com> --- cli/azd/pkg/tools/docker/docker.go | 130 +++++++++++++-- cli/azd/pkg/tools/docker/docker_test.go | 205 +++++++++++++++++++++++- 2 files changed, 317 insertions(+), 18 deletions(-) diff --git a/cli/azd/pkg/tools/docker/docker.go b/cli/azd/pkg/tools/docker/docker.go index e06763385b9..09b37905342 100644 --- a/cli/azd/pkg/tools/docker/docker.go +++ b/cli/azd/pkg/tools/docker/docker.go @@ -25,17 +25,28 @@ var _ tools.ExternalTool = (*Cli)(nil) func NewCli(commandRunner exec.CommandRunner) *Cli { return &Cli{ - commandRunner: commandRunner, + commandRunner: commandRunner, + containerEngine: "", } } type Cli struct { - commandRunner exec.CommandRunner + commandRunner exec.CommandRunner + containerEngine string // "docker" or "podman", detected during CheckInstalled +} + +// getContainerEngine returns the container engine command to use ("docker" or "podman") +func (d *Cli) getContainerEngine() string { + if d.containerEngine != "" { + return d.containerEngine + } + // Default to "docker" if not yet detected + return "docker" } func (d *Cli) Login(ctx context.Context, loginServer string, username string, password string) error { runArgs := exec.NewRunArgs( - "docker", "login", + d.getContainerEngine(), "login", "--username", username, "--password-stdin", loginServer, @@ -43,7 +54,7 @@ func (d *Cli) Login(ctx context.Context, loginServer string, username string, pa _, err := d.commandRunner.Run(ctx, runArgs) if err != nil { - return fmt.Errorf("failed logging into docker: %w", err) + return fmt.Errorf("failed logging into %s: %w", d.Name(), err) } return nil @@ -108,7 +119,7 @@ func (d *Cli) Build( args = append(args, "--iidfile", imgIdFile) // Build and produce output - runArgs := exec.NewRunArgs("docker", args...).WithCwd(cwd).WithEnv(buildEnv) + runArgs := exec.NewRunArgs(d.getContainerEngine(), args...).WithCwd(cwd).WithEnv(buildEnv) if buildProgress != nil { // setting stderr and stdout both, as it's been noticed @@ -180,7 +191,8 @@ func (d *Cli) versionInfo() tools.VersionInfo { Major: 17, Minor: 9, Patch: 0}, - UpdateCommand: "Visit https://docs.docker.com/engine/release-notes/ to upgrade", + UpdateCommand: "Visit https://docs.docker.com/engine/release-notes/ or " + + "https://podman.io/getting-started/installation to upgrade", } } @@ -188,6 +200,10 @@ func (d *Cli) versionInfo() tools.VersionInfo { // and captures the version and build components. var dockerVersionStringRegexp = regexp.MustCompile(`Docker version ([^,]*), build ([a-f0-9]*)`) +// podmanVersionStringRegexp is a regular expression which matches the text printed by "podman --version" +// and captures the version component. +var podmanVersionStringRegexp = regexp.MustCompile(`podman version ([^\s]+)`) + // dockerVersionReleaseBuildRegexp is a regular expression which matches the three part version number // from a docker version from an official release. The major and minor components are captured. var dockerVersionReleaseBuildRegexp = regexp.MustCompile(`^(\d+).(\d+).\d+`) @@ -259,28 +275,105 @@ func isSupportedDockerVersion(cliOutput string) (bool, error) { // If we reach this point, we don't understand how to validate the version based on its scheme. return false, fmt.Errorf("could not determine version from docker version string: %s", version) } + +// isSupportedPodmanVersion returns true if the version string appears to be for a podman version +// of 3.0 or later (podman 3.0 was released in 2021 with stable docker compatibility) +func isSupportedPodmanVersion(cliOutput string) (bool, error) { + log.Printf("determining version from podman --version string: %s", cliOutput) + + matches := podmanVersionStringRegexp.FindStringSubmatch(cliOutput) + + // (2 matches, the entire string, and the version capture) + if len(matches) != 2 { + return false, fmt.Errorf("could not extract version component from podman version string") + } + + versionStr := matches[1] + log.Printf("extracted podman version: %s from version string", versionStr) + + // Podman uses semantic versioning, so we can parse it directly + version, err := semver.Parse(versionStr) + if err != nil { + return false, fmt.Errorf("failed to parse podman version %s: %w", versionStr, err) + } + + // Require podman 3.0 or later for stable docker compatibility + minVersion := semver.Version{Major: 3, Minor: 0, Patch: 0} + return version.GTE(minVersion), nil +} func (d *Cli) CheckInstalled(ctx context.Context) error { - toolName := d.Name() - err := d.commandRunner.ToolInPath("docker") + // Check for environment variable override first + containerRuntime := os.Getenv("AZURE_CONTAINER_RUNTIME") + + // Try the specified runtime if set + if containerRuntime != "" { + if containerRuntime != "docker" && containerRuntime != "podman" { + return fmt.Errorf( + "unsupported container runtime '%s' specified in AZURE_CONTAINER_RUNTIME. "+ + "Supported values: docker, podman", + containerRuntime) + } + return d.checkContainerEngine(ctx, containerRuntime) + } + + // Otherwise, try docker first, then fall back to podman + if err := d.checkContainerEngine(ctx, "docker"); err == nil { + return nil + } + + // If docker is not available, try podman + if err := d.checkContainerEngine(ctx, "podman"); err == nil { + return nil + } + + // Neither docker nor podman is available + return fmt.Errorf( + "neither docker nor podman is installed or running. "+ + "Please install Docker or Podman: %s", + d.InstallUrl()) +} + +// checkContainerEngine checks if a specific container engine (docker or podman) is installed and running +func (d *Cli) checkContainerEngine(ctx context.Context, engineName string) error { + // Check if command is in path + err := d.commandRunner.ToolInPath(engineName) if err != nil { return err } - dockerRes, err := tools.ExecuteCommand(ctx, d.commandRunner, "docker", "--version") + + // Check version + versionOutput, err := tools.ExecuteCommand(ctx, d.commandRunner, engineName, "--version") if err != nil { - return fmt.Errorf("checking %s version: %w", toolName, err) + return fmt.Errorf("checking %s version: %w", engineName, err) + } + log.Printf("%s version: %s", engineName, versionOutput) + + var supported bool + if engineName == "docker" { + supported, err = isSupportedDockerVersion(versionOutput) + } else if engineName == "podman" { + supported, err = isSupportedPodmanVersion(versionOutput) + } else { + return fmt.Errorf("unknown container engine: %s", engineName) } - log.Printf("docker version: %s", dockerRes) - supported, err := isSupportedDockerVersion(dockerRes) + if err != nil { return err } if !supported { - return &tools.ErrSemver{ToolName: toolName, VersionInfo: d.versionInfo()} + return &tools.ErrSemver{ToolName: d.Name(), VersionInfo: d.versionInfo()} } - // Check if docker daemon is running - if _, err := tools.ExecuteCommand(ctx, d.commandRunner, "docker", "ps"); err != nil { - return fmt.Errorf("the %s daemon is not running, please start the %s service: %w", toolName, toolName, err) + + // Check if daemon/service is running + if _, err := tools.ExecuteCommand(ctx, d.commandRunner, engineName, "ps"); err != nil { + if engineName == "podman" { + return fmt.Errorf("the Podman service is not running, please start the %s service: %w", engineName, err) + } + return fmt.Errorf("the Docker daemon is not running, please start the %s service: %w", engineName, err) } + + // Store the detected container engine for future use + d.containerEngine = engineName return nil } @@ -289,6 +382,9 @@ func (d *Cli) InstallUrl() string { } func (d *Cli) Name() string { + if d.containerEngine == "podman" { + return "Podman" + } return "Docker" } @@ -306,7 +402,7 @@ func (d *Cli) IsContainerdEnabled(ctx context.Context) (bool, error) { } func (d *Cli) executeCommand(ctx context.Context, cwd string, args ...string) (exec.RunResult, error) { - runArgs := exec.NewRunArgs("docker", args...). + runArgs := exec.NewRunArgs(d.getContainerEngine(), args...). WithCwd(cwd) return d.commandRunner.Run(ctx, runArgs) diff --git a/cli/azd/pkg/tools/docker/docker_test.go b/cli/azd/pkg/tools/docker/docker_test.go index 34922c78183..ce40ff3f43a 100644 --- a/cli/azd/pkg/tools/docker/docker_test.go +++ b/cli/azd/pkg/tools/docker/docker_test.go @@ -511,7 +511,7 @@ func Test_DockerLogin(t *testing.T) { require.Equal(t, true, ran) require.NotNil(t, err) - require.Equal(t, fmt.Sprintf("failed logging into docker: %s", customErrorMessage), err.Error()) + require.Equal(t, fmt.Sprintf("failed logging into %s: %s", "Docker", customErrorMessage), err.Error()) }) } @@ -573,6 +573,209 @@ func Test_IsSupportedDockerVersion(t *testing.T) { } } +func Test_IsSupportedPodmanVersion(t *testing.T) { + cases := []struct { + name string + version string + supported bool + expectError bool + }{ + { + name: "Podman_4_3_1", + version: "podman version 4.3.1", + supported: true, + expectError: false, + }, + { + name: "Podman_3_0_0", + version: "podman version 3.0.0", + supported: true, + expectError: false, + }, + { + name: "Podman_3_4_4", + version: "podman version 3.4.4", + supported: true, + expectError: false, + }, + { + name: "Podman_5_0_0", + version: "podman version 5.0.0", + supported: true, + expectError: false, + }, + { + name: "Podman_TooOld", + version: "podman version 2.9.0", + supported: false, + expectError: false, + }, + { + name: "InvalidFormat", + version: "podman ver 4.3.1", + supported: false, + expectError: true, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + supported, err := isSupportedPodmanVersion(testCase.version) + require.Equal(t, testCase.supported, supported) + if testCase.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func Test_CheckInstalled_Docker(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + docker := NewCli(mockContext.CommandRunner) + + // Mock ToolInPath for docker + mockContext.CommandRunner.MockToolInPath("docker", nil) + + // Mock docker --version + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker --version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: "Docker version 20.10.17, build 100c701", + ExitCode: 0, + }, nil + }) + + // Mock docker ps + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker ps") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES", + ExitCode: 0, + }, nil + }) + + err := docker.CheckInstalled(context.Background()) + require.NoError(t, err) + require.Equal(t, "docker", docker.containerEngine) + require.Equal(t, "Docker", docker.Name()) +} + +func Test_CheckInstalled_Podman(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + docker := NewCli(mockContext.CommandRunner) + + // Mock ToolInPath - docker fails, podman succeeds + mockContext.CommandRunner.MockToolInPath("docker", errors.New("docker not found")) + mockContext.CommandRunner.MockToolInPath("podman", nil) + + // Mock podman --version + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "podman --version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: "podman version 4.3.1", + ExitCode: 0, + }, nil + }) + + // Mock podman ps + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "podman ps") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES", + ExitCode: 0, + }, nil + }) + + err := docker.CheckInstalled(context.Background()) + require.NoError(t, err) + require.Equal(t, "podman", docker.containerEngine) + require.Equal(t, "Podman", docker.Name()) +} + +func Test_CheckInstalled_EnvVarOverride(t *testing.T) { + t.Run("DockerOverride", func(t *testing.T) { + os.Setenv("AZURE_CONTAINER_RUNTIME", "docker") + defer os.Unsetenv("AZURE_CONTAINER_RUNTIME") + + mockContext := mocks.NewMockContext(context.Background()) + docker := NewCli(mockContext.CommandRunner) + + mockContext.CommandRunner.MockToolInPath("docker", nil) + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker --version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: "Docker version 20.10.17, build 100c701", + ExitCode: 0, + }, nil + }) + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker ps") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES", + ExitCode: 0, + }, nil + }) + + err := docker.CheckInstalled(context.Background()) + require.NoError(t, err) + require.Equal(t, "docker", docker.containerEngine) + }) + + t.Run("PodmanOverride", func(t *testing.T) { + os.Setenv("AZURE_CONTAINER_RUNTIME", "podman") + defer os.Unsetenv("AZURE_CONTAINER_RUNTIME") + + mockContext := mocks.NewMockContext(context.Background()) + docker := NewCli(mockContext.CommandRunner) + + mockContext.CommandRunner.MockToolInPath("podman", nil) + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "podman --version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: "podman version 4.3.1", + ExitCode: 0, + }, nil + }) + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "podman ps") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES", + ExitCode: 0, + }, nil + }) + + err := docker.CheckInstalled(context.Background()) + require.NoError(t, err) + require.Equal(t, "podman", docker.containerEngine) + }) + + t.Run("InvalidOverride", func(t *testing.T) { + os.Setenv("AZURE_CONTAINER_RUNTIME", "invalid") + defer os.Unsetenv("AZURE_CONTAINER_RUNTIME") + + mockContext := mocks.NewMockContext(context.Background()) + docker := NewCli(mockContext.CommandRunner) + + err := docker.CheckInstalled(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported container runtime") + }) +} + func TestSplitDockerImage(t *testing.T) { tests := []struct { name string From fb0a5e652e40a378f5088f69598f420bb3ce29de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 15:41:36 +0000 Subject: [PATCH 3/8] Rename environment variable to AZD_CONTAINER_RUNTIME Changed from AZURE_CONTAINER_RUNTIME to AZD_CONTAINER_RUNTIME to better reflect that this is a local azd tool configuration, not an Azure service configuration. Co-authored-by: spboyer <7681382+spboyer@users.noreply.github.com> --- cli/azd/pkg/tools/docker/docker.go | 4 ++-- cli/azd/pkg/tools/docker/docker_test.go | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cli/azd/pkg/tools/docker/docker.go b/cli/azd/pkg/tools/docker/docker.go index 09b37905342..1065f7833a9 100644 --- a/cli/azd/pkg/tools/docker/docker.go +++ b/cli/azd/pkg/tools/docker/docker.go @@ -303,13 +303,13 @@ func isSupportedPodmanVersion(cliOutput string) (bool, error) { } func (d *Cli) CheckInstalled(ctx context.Context) error { // Check for environment variable override first - containerRuntime := os.Getenv("AZURE_CONTAINER_RUNTIME") + containerRuntime := os.Getenv("AZD_CONTAINER_RUNTIME") // Try the specified runtime if set if containerRuntime != "" { if containerRuntime != "docker" && containerRuntime != "podman" { return fmt.Errorf( - "unsupported container runtime '%s' specified in AZURE_CONTAINER_RUNTIME. "+ + "unsupported container runtime '%s' specified in AZD_CONTAINER_RUNTIME. "+ "Supported values: docker, podman", containerRuntime) } diff --git a/cli/azd/pkg/tools/docker/docker_test.go b/cli/azd/pkg/tools/docker/docker_test.go index ce40ff3f43a..c86821694d3 100644 --- a/cli/azd/pkg/tools/docker/docker_test.go +++ b/cli/azd/pkg/tools/docker/docker_test.go @@ -700,8 +700,8 @@ func Test_CheckInstalled_Podman(t *testing.T) { func Test_CheckInstalled_EnvVarOverride(t *testing.T) { t.Run("DockerOverride", func(t *testing.T) { - os.Setenv("AZURE_CONTAINER_RUNTIME", "docker") - defer os.Unsetenv("AZURE_CONTAINER_RUNTIME") + os.Setenv("AZD_CONTAINER_RUNTIME", "docker") + defer os.Unsetenv("AZD_CONTAINER_RUNTIME") mockContext := mocks.NewMockContext(context.Background()) docker := NewCli(mockContext.CommandRunner) @@ -732,8 +732,8 @@ func Test_CheckInstalled_EnvVarOverride(t *testing.T) { }) t.Run("PodmanOverride", func(t *testing.T) { - os.Setenv("AZURE_CONTAINER_RUNTIME", "podman") - defer os.Unsetenv("AZURE_CONTAINER_RUNTIME") + os.Setenv("AZD_CONTAINER_RUNTIME", "podman") + defer os.Unsetenv("AZD_CONTAINER_RUNTIME") mockContext := mocks.NewMockContext(context.Background()) docker := NewCli(mockContext.CommandRunner) @@ -764,8 +764,8 @@ func Test_CheckInstalled_EnvVarOverride(t *testing.T) { }) t.Run("InvalidOverride", func(t *testing.T) { - os.Setenv("AZURE_CONTAINER_RUNTIME", "invalid") - defer os.Unsetenv("AZURE_CONTAINER_RUNTIME") + os.Setenv("AZD_CONTAINER_RUNTIME", "invalid") + defer os.Unsetenv("AZD_CONTAINER_RUNTIME") mockContext := mocks.NewMockContext(context.Background()) docker := NewCli(mockContext.CommandRunner) From 46852b14613f27c1766478cf1dcaf1311b10f915 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:29:47 +0000 Subject: [PATCH 4/8] Address code review feedback - Make versionInfo runtime-aware with separate podmanVersionInfo() - Simplify error messages for service not running (consistent format) - Use t.Setenv() instead of os.Setenv/defer for test isolation - Update regex pattern to use \S+ for better clarity - Add test coverage for Podman versions with pre-release/build metadata - Add test for when neither Docker nor Podman is available - Keep getContainerEngine() backward compatible (defaults to docker) Co-authored-by: spboyer <7681382+spboyer@users.noreply.github.com> --- cli/azd/pkg/tools/docker/docker.go | 35 +++++++++++++++------- cli/azd/pkg/tools/docker/docker_test.go | 40 +++++++++++++++++++++---- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/cli/azd/pkg/tools/docker/docker.go b/cli/azd/pkg/tools/docker/docker.go index 1065f7833a9..69a102c59f5 100644 --- a/cli/azd/pkg/tools/docker/docker.go +++ b/cli/azd/pkg/tools/docker/docker.go @@ -35,13 +35,16 @@ type Cli struct { containerEngine string // "docker" or "podman", detected during CheckInstalled } -// getContainerEngine returns the container engine command to use ("docker" or "podman") +// 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. func (d *Cli) getContainerEngine() string { - if d.containerEngine != "" { - return d.containerEngine + if d.containerEngine == "" { + // Default to "docker" for backward compatibility with existing code + // that may not call CheckInstalled() first + return "docker" } - // Default to "docker" if not yet detected - return "docker" + return d.containerEngine } func (d *Cli) Login(ctx context.Context, loginServer string, username string, password string) error { @@ -196,13 +199,23 @@ func (d *Cli) versionInfo() tools.VersionInfo { } } +func (d *Cli) podmanVersionInfo() tools.VersionInfo { + return tools.VersionInfo{ + MinimumVersion: semver.Version{ + Major: 3, + Minor: 0, + Patch: 0}, + UpdateCommand: "Visit https://podman.io/getting-started/installation to upgrade", + } +} + // dockerVersionRegexp is a regular expression which matches the text printed by "docker --version" // and captures the version and build components. var dockerVersionStringRegexp = regexp.MustCompile(`Docker version ([^,]*), build ([a-f0-9]*)`) // podmanVersionStringRegexp is a regular expression which matches the text printed by "podman --version" // and captures the version component. -var podmanVersionStringRegexp = regexp.MustCompile(`podman version ([^\s]+)`) +var podmanVersionStringRegexp = regexp.MustCompile(`podman version (\S+)`) // dockerVersionReleaseBuildRegexp is a regular expression which matches the three part version number // from a docker version from an official release. The major and minor components are captured. @@ -349,10 +362,13 @@ func (d *Cli) checkContainerEngine(ctx context.Context, engineName string) error log.Printf("%s version: %s", engineName, versionOutput) var supported bool + var versionInfo tools.VersionInfo if engineName == "docker" { supported, err = isSupportedDockerVersion(versionOutput) + versionInfo = d.versionInfo() } else if engineName == "podman" { supported, err = isSupportedPodmanVersion(versionOutput) + versionInfo = d.podmanVersionInfo() } else { return fmt.Errorf("unknown container engine: %s", engineName) } @@ -361,15 +377,12 @@ func (d *Cli) checkContainerEngine(ctx context.Context, engineName string) error return err } if !supported { - return &tools.ErrSemver{ToolName: d.Name(), VersionInfo: d.versionInfo()} + return &tools.ErrSemver{ToolName: d.Name(), VersionInfo: versionInfo} } // Check if daemon/service is running if _, err := tools.ExecuteCommand(ctx, d.commandRunner, engineName, "ps"); err != nil { - if engineName == "podman" { - return fmt.Errorf("the Podman service is not running, please start the %s service: %w", engineName, err) - } - return fmt.Errorf("the Docker daemon is not running, please start the %s service: %w", engineName, err) + return fmt.Errorf("the %s service is not running, please start it: %w", engineName, err) } // Store the detected container engine for future use diff --git a/cli/azd/pkg/tools/docker/docker_test.go b/cli/azd/pkg/tools/docker/docker_test.go index c86821694d3..f3c2707947e 100644 --- a/cli/azd/pkg/tools/docker/docker_test.go +++ b/cli/azd/pkg/tools/docker/docker_test.go @@ -616,6 +616,24 @@ func Test_IsSupportedPodmanVersion(t *testing.T) { supported: false, expectError: true, }, + { + name: "Podman_WithPreRelease", + version: "podman version 4.3.1-dev", + supported: true, + expectError: false, + }, + { + name: "Podman_WithBuildMetadata", + version: "podman version 4.3.1+build123", + supported: true, + expectError: false, + }, + { + name: "Podman_WithPreReleaseAndBuild", + version: "podman version 4.3.1-rc1+build456", + supported: true, + expectError: false, + }, } for _, testCase := range cases { @@ -700,8 +718,7 @@ func Test_CheckInstalled_Podman(t *testing.T) { func Test_CheckInstalled_EnvVarOverride(t *testing.T) { t.Run("DockerOverride", func(t *testing.T) { - os.Setenv("AZD_CONTAINER_RUNTIME", "docker") - defer os.Unsetenv("AZD_CONTAINER_RUNTIME") + t.Setenv("AZD_CONTAINER_RUNTIME", "docker") mockContext := mocks.NewMockContext(context.Background()) docker := NewCli(mockContext.CommandRunner) @@ -732,8 +749,7 @@ func Test_CheckInstalled_EnvVarOverride(t *testing.T) { }) t.Run("PodmanOverride", func(t *testing.T) { - os.Setenv("AZD_CONTAINER_RUNTIME", "podman") - defer os.Unsetenv("AZD_CONTAINER_RUNTIME") + t.Setenv("AZD_CONTAINER_RUNTIME", "podman") mockContext := mocks.NewMockContext(context.Background()) docker := NewCli(mockContext.CommandRunner) @@ -764,8 +780,7 @@ func Test_CheckInstalled_EnvVarOverride(t *testing.T) { }) t.Run("InvalidOverride", func(t *testing.T) { - os.Setenv("AZD_CONTAINER_RUNTIME", "invalid") - defer os.Unsetenv("AZD_CONTAINER_RUNTIME") + t.Setenv("AZD_CONTAINER_RUNTIME", "invalid") mockContext := mocks.NewMockContext(context.Background()) docker := NewCli(mockContext.CommandRunner) @@ -776,6 +791,19 @@ func Test_CheckInstalled_EnvVarOverride(t *testing.T) { }) } +func Test_CheckInstalled_NeitherAvailable(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + docker := NewCli(mockContext.CommandRunner) + + // Mock both docker and podman as not available + mockContext.CommandRunner.MockToolInPath("docker", errors.New("docker not found")) + mockContext.CommandRunner.MockToolInPath("podman", errors.New("podman not found")) + + err := docker.CheckInstalled(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "neither docker nor podman is installed or running") +} + func TestSplitDockerImage(t *testing.T) { tests := []struct { name string From 86a66b780db035352cc3b94c779bc7cf3e805128 Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Wed, 10 Dec 2025 11:42:46 -0800 Subject: [PATCH 5/8] add agents.md --- cli/azd/AGENTS.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 cli/azd/AGENTS.md diff --git a/cli/azd/AGENTS.md b/cli/azd/AGENTS.md new file mode 100644 index 00000000000..eed6d3504a6 --- /dev/null +++ b/cli/azd/AGENTS.md @@ -0,0 +1,16 @@ +# Agent Development Guide + +A file for [guiding coding agents](https://agents.md/). + +## Commands + +- **Build:** `go build` +- **Test (Golang):** `go test ./... -short` +- **Test -- include functional end-to-end tests (Golang)**: `go test ./...` +- **Cspell check**: `cspell lint "**/*.go" --relative --config ./.vscode/cspell.yaml` +- **Linter**: `golangci-lint run ./...` + +## Directory Structure + +- Functional tests: `test/` +- Docs: `docs/` From 0c72da877b15c01443f59287471435d70c28ef3f Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Wed, 10 Dec 2025 11:42:46 -0800 Subject: [PATCH 6/8] fix docker/podman install checks --- cli/azd/pkg/tools/docker/docker.go | 56 ++++++++++++++++-------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/cli/azd/pkg/tools/docker/docker.go b/cli/azd/pkg/tools/docker/docker.go index 69a102c59f5..d15356cc027 100644 --- a/cli/azd/pkg/tools/docker/docker.go +++ b/cli/azd/pkg/tools/docker/docker.go @@ -318,41 +318,39 @@ func (d *Cli) CheckInstalled(ctx context.Context) error { // Check for environment variable override first containerRuntime := os.Getenv("AZD_CONTAINER_RUNTIME") - // Try the specified runtime if set if containerRuntime != "" { + // Validate the specified runtime if containerRuntime != "docker" && containerRuntime != "podman" { return fmt.Errorf( "unsupported container runtime '%s' specified in AZD_CONTAINER_RUNTIME. "+ "Supported values: docker, podman", containerRuntime) } - return d.checkContainerEngine(ctx, containerRuntime) - } - - // Otherwise, try docker first, then fall back to podman - if err := d.checkContainerEngine(ctx, "docker"); err == nil { - return nil - } - - // If docker is not available, try podman - if err := d.checkContainerEngine(ctx, "podman"); err == nil { - return nil + d.containerEngine = containerRuntime + } else { + // Auto-select: try docker first, then fall back to podman + if d.commandRunner.ToolInPath("docker") == nil { + d.containerEngine = "docker" + } else if d.commandRunner.ToolInPath("podman") == nil { + d.containerEngine = "podman" + } else { + // Neither tool is installed + return fmt.Errorf( + "neither docker nor podman is installed. " + + "Please install Docker: https://aka.ms/azure-dev/docker-install " + + "or Podman: https://aka.ms/azure-dev/podman-install") + } } - // Neither docker nor podman is available - return fmt.Errorf( - "neither docker nor podman is installed or running. "+ - "Please install Docker or Podman: %s", - d.InstallUrl()) + // Now validate the selected engine (version check and daemon/service running) + return d.validateContainerEngine(ctx) } -// checkContainerEngine checks if a specific container engine (docker or podman) is installed and running -func (d *Cli) checkContainerEngine(ctx context.Context, engineName string) error { - // Check if command is in path - err := d.commandRunner.ToolInPath(engineName) - if err != nil { - return err - } +// validateContainerEngine validates that the selected container engine (docker or podman) meets version +// and readiness requirements. +// The engine must have been selected first via CheckInstalled (stored in d.containerEngine). +func (d *Cli) validateContainerEngine(ctx context.Context) error { + engineName := d.getContainerEngine() // Check version versionOutput, err := tools.ExecuteCommand(ctx, d.commandRunner, engineName, "--version") @@ -385,12 +383,13 @@ func (d *Cli) checkContainerEngine(ctx context.Context, engineName string) error return fmt.Errorf("the %s service is not running, please start it: %w", engineName, err) } - // Store the detected container engine for future use - d.containerEngine = engineName return nil } func (d *Cli) InstallUrl() string { + if d.containerEngine == "podman" { + return "https://aka.ms/azure-dev/podman-install" + } return "https://aka.ms/azure-dev/docker-install" } @@ -403,6 +402,11 @@ func (d *Cli) Name() string { // IsContainerdEnabled checks if Docker is using containerd as the image store func (d *Cli) IsContainerdEnabled(ctx context.Context) (bool, error) { + // Containerd image store is only applicable to Docker, not Podman + if d.getContainerEngine() == "podman" { + return false, nil + } + result, err := d.executeCommand(ctx, "", "system", "info", "--format", "{{.DriverStatus}}") if err != nil { return false, fmt.Errorf("checking docker driver status: %w", err) From 25555a674f8c1a72ac6f6a634ad92859ccf3d0b1 Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Mon, 5 Jan 2026 15:28:04 -0800 Subject: [PATCH 7/8] add e2e test harness --- .../docker/acceptance/acceptance_test.go | 168 ++++++++++++++++++ .../docker/acceptance/testdata/Dockerfile | 13 ++ 2 files changed, 181 insertions(+) create mode 100644 cli/azd/pkg/tools/docker/acceptance/acceptance_test.go create mode 100644 cli/azd/pkg/tools/docker/acceptance/testdata/Dockerfile diff --git a/cli/azd/pkg/tools/docker/acceptance/acceptance_test.go b/cli/azd/pkg/tools/docker/acceptance/acceptance_test.go new file mode 100644 index 00000000000..a914bc4380f --- /dev/null +++ b/cli/azd/pkg/tools/docker/acceptance/acceptance_test.go @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package acceptance + +import ( + "bytes" + "context" + "fmt" + "os" + osexec "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools/docker" + "github.com/stretchr/testify/require" +) + +const ( + localRegistryAddr = "localhost:5000" + imageName = "azd-acceptance-test" +) + +// Test_DockerAcceptance validates the Docker CLI tool works end-to-end. +// Opt-in test: only runs if AZD_TEST_DOCKER_E2E=1 +// Requires: Container runtime (Docker or Podman) installed and running +// Requires: Local registry at localhost:5000, OR set REGISTRY_SERVER for Azure Container Registry +// +// Example: +// +// AZD_TEST_DOCKER_E2E=1 AZD_CONTAINER_RUNTIME=podman REGISTRY_SERVER='myregistry.azurecr.io' go test . +func Test_DockerAcceptance(t *testing.T) { + if os.Getenv("AZD_TEST_DOCKER_E2E") != "1" { + t.Skip("Skipping Docker acceptance test. Set AZD_TEST_DOCKER_E2E=1 to enable.") + } + + ctx := context.Background() + cli := docker.NewCli(exec.NewCommandRunner(nil)) + + // 1. CheckInstalled - validates runtime is available + err := cli.CheckInstalled(ctx) + require.NoError(t, err, "container runtime should be installed and running") + + t.Logf("Using container runtime: %s", cli.Name()) + + // Determine registry - use Azure Container Registry if REGISTRY_SERVER is set + registryServer := os.Getenv("REGISTRY_SERVER") + if registryServer != "" { + t.Logf("Using Azure Container Registry: %s", registryServer) + loginToAzureRegistry(t, ctx, cli, registryServer) + } else { + registryServer = localRegistryAddr + t.Logf("Using local registry: %s", registryServer) + } + + // 2. Build - build an image from Dockerfile with build args and target + cwd, err := os.Getwd() + require.NoError(t, err) + + dockerfilePath := filepath.Join(cwd, "testdata", "Dockerfile") + tag := fmt.Sprintf("%s:%d", imageName, time.Now().Unix()) + + buildArgs := []string{ + fmt.Sprintf("BUILD_VERSION=%s", "1.0.0-test"), + fmt.Sprintf("BUILD_DATE=%s", time.Now().Format(time.RFC3339)), + } + + // Create a temporary secret file for build secrets + dir := t.TempDir() + secretFile, err := os.CreateTemp(dir, "azd-test-secret-*") + require.NoError(t, err) + defer os.Remove(secretFile.Name()) + _, err = secretFile.WriteString("test-secret-value") + require.NoError(t, err) + require.NoError(t, secretFile.Close()) + + buildSecrets := []string{ + fmt.Sprintf("id=test_secret,src=%s", secretFile.Name()), + } + + buildEnv := []string{ + "DOCKER_BUILDKIT=1", + } + + var buildOutput bytes.Buffer + imageID, err := cli.Build( + ctx, + cwd, + dockerfilePath, + "linux/amd64", + "final", + filepath.Join(cwd, "testdata"), + tag, + buildArgs, + buildSecrets, + buildEnv, + &buildOutput, + ) + require.NoError(t, err, "build should succeed") + require.NotEmpty(t, imageID, "build should return image ID") + t.Logf("Built image: %s (ID: %s)", tag, imageID) + + // Cleanup: remove local images at end + remoteTag := fmt.Sprintf("%s/%s", registryServer, tag) + defer func() { + _ = cli.Remove(ctx, tag) + _ = cli.Remove(ctx, remoteTag) + }() + + // 3. Tag - tag image for registry + err = cli.Tag(ctx, cwd, tag, remoteTag) + require.NoError(t, err, "tag should succeed") + t.Logf("Tagged image: %s", remoteTag) + + // 4. Push - push to registry + err = cli.Push(ctx, cwd, remoteTag) + require.NoError(t, err, "push should succeed") + t.Logf("Pushed image: %s", remoteTag) + + // 5. Remove local image to verify pull works + err = cli.Remove(ctx, remoteTag) + require.NoError(t, err, "remove should succeed") + t.Logf("Removed local image: %s", remoteTag) + + // 6. Pull - pull from registry + err = cli.Pull(ctx, remoteTag) + require.NoError(t, err, "pull should succeed") + t.Logf("Pulled image: %s", remoteTag) + + // 7. Inspect - verify pulled image + output, err := cli.Inspect(ctx, remoteTag, "{{.Id}}") + require.NoError(t, err, "inspect should succeed") + require.NotEmpty(t, output, "inspect should return image info") + t.Logf("Inspected image ID: %s", strings.TrimSpace(output)) + + // 8. IsContainerdEnabled - should work without error + _, err = cli.IsContainerdEnabled(ctx) + require.NoError(t, err, "IsContainerdEnabled should not error") +} + +// loginToAzureRegistry logs into Azure Container Registry using managed identity +func loginToAzureRegistry(t *testing.T, ctx context.Context, cli *docker.Cli, registryServer string) { + t.Helper() + + // Extract registry name from server (e.g., "myregistry.azurecr.io" -> "myregistry") + registryName := strings.Split(registryServer, ".")[0] + + // Get access token using managed identity + cmd := osexec.CommandContext(ctx, "az", "acr", "login", + "--name", registryName, + "--expose-token", + "--query", "accessToken", + "--output", "tsv", + ) + tokenOutput, err := cmd.Output() + require.NoError(t, err, "az acr login --expose-token should succeed") + + token := strings.TrimSpace(string(tokenOutput)) + require.NotEmpty(t, token, "access token should not be empty") + + // Login with managed identity UUID as username and token as password + err = cli.Login(ctx, registryServer, "00000000-0000-0000-0000-000000000000", token) + require.NoError(t, err, "docker login to ACR should succeed") + t.Logf("Logged into Azure Container Registry: %s", registryServer) +} diff --git a/cli/azd/pkg/tools/docker/acceptance/testdata/Dockerfile b/cli/azd/pkg/tools/docker/acceptance/testdata/Dockerfile new file mode 100644 index 00000000000..04c8bb68d1f --- /dev/null +++ b/cli/azd/pkg/tools/docker/acceptance/testdata/Dockerfile @@ -0,0 +1,13 @@ +FROM alpine:3.19 AS base +ARG BUILD_VERSION=unknown +ARG BUILD_DATE=unknown +RUN --mount=type=secret,id=test_secret \ + echo "azd acceptance test image" > /info.txt && \ + echo "version: ${BUILD_VERSION}" >> /info.txt && \ + echo "date: ${BUILD_DATE}" >> /info.txt && \ + if [ -f /run/secrets/test_secret ]; then \ + echo "secret: present" >> /info.txt; \ + fi + +FROM base AS final +CMD ["cat", "/info.txt"] From b6f824feb7a368660ca04d77ce2d345606e21578 Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Mon, 5 Jan 2026 16:11:58 -0800 Subject: [PATCH 8/8] fix test --- cli/azd/pkg/tools/docker/acceptance/acceptance_test.go | 1 + cli/azd/pkg/tools/docker/docker_test.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/azd/pkg/tools/docker/acceptance/acceptance_test.go b/cli/azd/pkg/tools/docker/acceptance/acceptance_test.go index a914bc4380f..fe8de3b3d2c 100644 --- a/cli/azd/pkg/tools/docker/acceptance/acceptance_test.go +++ b/cli/azd/pkg/tools/docker/acceptance/acceptance_test.go @@ -149,6 +149,7 @@ func loginToAzureRegistry(t *testing.T, ctx context.Context, cli *docker.Cli, re registryName := strings.Split(registryServer, ".")[0] // Get access token using managed identity + /* #nosec G204 - Subprocess launched with a potential tainted input or cmd arguments */ cmd := osexec.CommandContext(ctx, "az", "acr", "login", "--name", registryName, "--expose-token", diff --git a/cli/azd/pkg/tools/docker/docker_test.go b/cli/azd/pkg/tools/docker/docker_test.go index f3c2707947e..b13fefac23f 100644 --- a/cli/azd/pkg/tools/docker/docker_test.go +++ b/cli/azd/pkg/tools/docker/docker_test.go @@ -801,7 +801,7 @@ func Test_CheckInstalled_NeitherAvailable(t *testing.T) { err := docker.CheckInstalled(context.Background()) require.Error(t, err) - require.Contains(t, err.Error(), "neither docker nor podman is installed or running") + require.Contains(t, err.Error(), "neither docker nor podman is installed") } func TestSplitDockerImage(t *testing.T) {