From 6d737af406a15f4302d1c0dfc89544c4229a38f2 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 13 May 2026 21:41:11 -0500 Subject: [PATCH 1/3] refactor(compose): use ContainerRuntime interface for compose detection ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewComposeHelper now queries dockerHelper.GetRuntime().Name() to determine the compose detection order — Podman runtimes try podman compose first, while Docker/nerdctl runtimes try docker compose V2 first. Removes hardcoded podmanCmd and composeArg constants. tryPodmanCompose now accepts the docker command as a parameter instead of using a hardcoded "podman" string, using dockerHelper.DockerCommand consistently throughout. --- pkg/compose/helper.go | 59 +++++++++++++++---------- pkg/compose/helper_test.go | 89 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 24 deletions(-) diff --git a/pkg/compose/helper.go b/pkg/compose/helper.go index 6e78ba104..af7822cb6 100644 --- a/pkg/compose/helper.go +++ b/pkg/compose/helper.go @@ -22,8 +22,6 @@ import ( const ( ProjectLabel = "com.docker.compose.project" ServiceLabel = "com.docker.compose.service" - podmanCmd = "podman" - composeArg = "compose" ) func LoadDockerComposeProject( @@ -58,27 +56,38 @@ type ComposeHelper struct { } // NewComposeHelper creates a new ComposeHelper instance after detecting whether Docker -// Compose V2, Podman Compose, or Docker Compose V1 is installed. It returns an error -// if none are found. +// Compose V2, Podman Compose, or Docker Compose V1 is installed. The detection order +// depends on the container runtime: Podman runtimes try podman compose first, while +// Docker/nerdctl runtimes try docker compose V2 first. It returns an error if none +// are found. func NewComposeHelper(dockerHelper *docker.DockerHelper) (*ComposeHelper, error) { dockerCmd := dockerHelper.DockerCommand if dockerCmd == "" { dockerCmd = "docker" } - if helper, err := tryDockerComposeV2(dockerCmd); err == nil { - helper.Docker = dockerHelper - return helper, nil - } + type tryFunc func() (*ComposeHelper, error) - if helper, err := tryPodmanCompose(); err == nil { - helper.Docker = dockerHelper - return helper, nil + var detectors []tryFunc + if dockerHelper.GetRuntime().Name() == docker.RuntimePodman { + detectors = []tryFunc{ + func() (*ComposeHelper, error) { return tryPodmanCompose(dockerCmd) }, + func() (*ComposeHelper, error) { return tryDockerComposeV2(dockerCmd) }, + tryDockerComposeV1, + } + } else { + detectors = []tryFunc{ + func() (*ComposeHelper, error) { return tryDockerComposeV2(dockerCmd) }, + func() (*ComposeHelper, error) { return tryPodmanCompose(dockerCmd) }, + tryDockerComposeV1, + } } - if helper, err := tryDockerComposeV1(); err == nil { - helper.Docker = dockerHelper - return helper, nil + for _, detect := range detectors { + if helper, err := detect(); err == nil { + helper.Docker = dockerHelper + return helper, nil + } } return nil, fmt.Errorf("docker compose or podman compose not installed") @@ -128,23 +137,24 @@ func tryDockerComposeV2(dockerCmd string) (*ComposeHelper, error) { return helper, nil } -func tryPodmanCompose() (*ComposeHelper, error) { - if _, err := exec.LookPath(podmanCmd); err != nil { - return nil, fmt.Errorf("podman not found in PATH") +func tryPodmanCompose(dockerCmd string) (*ComposeHelper, error) { + if _, err := exec.LookPath(dockerCmd); err != nil { + return nil, fmt.Errorf("%s not found in PATH", dockerCmd) } - if exec.Command(podmanCmd, composeArg).Run() != nil { - return nil, fmt.Errorf("podman compose not available") + if exec.Command(dockerCmd, "compose").Run() != nil { + return nil, fmt.Errorf("%s compose not available", dockerCmd) } - cmd := exec.Command(podmanCmd, composeArg, "version", "--short") + cmd := exec.Command(dockerCmd, "compose", "version", "--short") out, stderr, err := runCmdCapture(cmd) if len(stderr) > 0 { log.Warnf("%s: %s", strings.TrimSpace(string(stderr)), strings.TrimSpace(string(out))) } if err != nil { return nil, fmt.Errorf( - "failed to get podman compose version %s: %w", + "failed to get %s compose version %s: %w", + dockerCmd, strings.TrimSpace(string(stderr)), err, ) @@ -153,16 +163,17 @@ func tryPodmanCompose() (*ComposeHelper, error) { parsed, parseErr := parseVersion(strings.TrimSpace(string(out))) if parseErr != nil { return nil, fmt.Errorf( - "failed to parse podman compose version %q: %w", + "failed to parse %s compose version %q: %w", + dockerCmd, strings.TrimSpace(string(out)), parseErr, ) } return &ComposeHelper{ - Command: podmanCmd, + Command: dockerCmd, Version: parsed.String(), - Args: []string{composeArg}, + Args: []string{"compose"}, }, nil } diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index 102dae17b..d53a4b1ce 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/devsy-org/devsy/pkg/docker" "github.com/stretchr/testify/suite" ) @@ -164,3 +165,91 @@ func (s *HelperTestSuite) TestComposeHelperBuildCmdPodman() { s.Contains(cmd.Args, "up") s.Contains(cmd.Args, "-d") } + +// stubRuntime implements docker.ContainerRuntime for testing detection order. +type stubRuntime struct { + name docker.RuntimeName +} + +func (r stubRuntime) Name() docker.RuntimeName { return r.name } +func (r stubRuntime) SupportsInternalBuildKit() bool { return false } +func (r stubRuntime) SupportsSignalProxy() bool { return false } +func (r stubRuntime) SupportsMountConsistency() bool { return false } +func (r stubRuntime) NeedsUserNamespaceArgs() bool { return false } +func (r stubRuntime) GPUAvailable(_ context.Context, _ *docker.DockerHelper) (bool, error) { + return false, nil +} + +func (s *HelperTestSuite) TestNewComposeHelperPodmanRuntimeUsesDockerCommand() { + helper := &docker.DockerHelper{ + DockerCommand: "podman", + Runtime: stubRuntime{name: docker.RuntimePodman}, + } + + ch, err := NewComposeHelper(helper) + if err != nil { + s.T().Skipf("compose binary not available in test environment: %v", err) + } + + s.Equal("podman", ch.Command) + s.Equal([]string{"compose"}, ch.Args) +} + +func (s *HelperTestSuite) TestNewComposeHelperDockerRuntimeUsesDockerCommand() { + helper := &docker.DockerHelper{ + DockerCommand: "docker", + Runtime: stubRuntime{name: docker.RuntimeDocker}, + } + + ch, err := NewComposeHelper(helper) + if err != nil { + s.T().Skipf("compose binary not available in test environment: %v", err) + } + + s.Equal("docker", ch.Command) + s.Equal([]string{"compose"}, ch.Args) +} + +func (s *HelperTestSuite) TestNewComposeHelperDefaultDockerCommand() { + helper := &docker.DockerHelper{ + DockerCommand: "", + Runtime: stubRuntime{name: docker.RuntimeDocker}, + } + + ch, err := NewComposeHelper(helper) + if err != nil { + s.T().Skipf("compose binary not available in test environment: %v", err) + } + + s.Equal("docker", ch.Command) +} + +func (s *HelperTestSuite) TestNewComposeHelperNerdctlRuntimeFallsBackToDocker() { + helper := &docker.DockerHelper{ + DockerCommand: "nerdctl", + Runtime: stubRuntime{name: docker.RuntimeNerdctl}, + } + + ch, err := NewComposeHelper(helper) + if err != nil { + s.T().Skipf("compose binary not available in test environment: %v", err) + } + + s.Contains([]string{"nerdctl", "docker", "docker-compose"}, ch.Command) +} + +func (s *HelperTestSuite) TestTryPodmanComposeUsesProvidedCommand() { + helper, err := tryPodmanCompose("podman") + if err != nil { + s.T().Skipf("podman not available in test environment: %v", err) + } + + s.Equal("podman", helper.Command) + s.Equal([]string{"compose"}, helper.Args) +} + +func (s *HelperTestSuite) TestTryPodmanComposeRejectsNonexistentCommand() { + _, err := tryPodmanCompose("nonexistent-binary-xyz") + s.Error(err) + s.Contains(err.Error(), "not found in PATH") +} From c4af6944ca29a589d1ecbfe63b5a27cb4a5103ba Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 13 May 2026 21:47:47 -0500 Subject: [PATCH 2/3] fix(compose): correct non-Podman fallback and rename tryPodmanCompose The non-Podman detection path now passes "podman" (hardcoded) to the fallback detector instead of re-using dockerCmd, preserving the original behavior of independently probing for podman in PATH. Renames tryPodmanCompose to tryComposeSubcommand to reflect that the function generically probes ` compose` for any docker command. --- pkg/compose/helper.go | 6 +++--- pkg/compose/helper_test.go | 25 +++++++++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/pkg/compose/helper.go b/pkg/compose/helper.go index af7822cb6..9d53d71ba 100644 --- a/pkg/compose/helper.go +++ b/pkg/compose/helper.go @@ -71,14 +71,14 @@ func NewComposeHelper(dockerHelper *docker.DockerHelper) (*ComposeHelper, error) var detectors []tryFunc if dockerHelper.GetRuntime().Name() == docker.RuntimePodman { detectors = []tryFunc{ - func() (*ComposeHelper, error) { return tryPodmanCompose(dockerCmd) }, + func() (*ComposeHelper, error) { return tryComposeSubcommand(dockerCmd) }, func() (*ComposeHelper, error) { return tryDockerComposeV2(dockerCmd) }, tryDockerComposeV1, } } else { detectors = []tryFunc{ func() (*ComposeHelper, error) { return tryDockerComposeV2(dockerCmd) }, - func() (*ComposeHelper, error) { return tryPodmanCompose(dockerCmd) }, + func() (*ComposeHelper, error) { return tryComposeSubcommand("podman") }, tryDockerComposeV1, } } @@ -137,7 +137,7 @@ func tryDockerComposeV2(dockerCmd string) (*ComposeHelper, error) { return helper, nil } -func tryPodmanCompose(dockerCmd string) (*ComposeHelper, error) { +func tryComposeSubcommand(dockerCmd string) (*ComposeHelper, error) { if _, err := exec.LookPath(dockerCmd); err != nil { return nil, fmt.Errorf("%s not found in PATH", dockerCmd) } diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index d53a4b1ce..06dc2f851 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -238,8 +238,8 @@ func (s *HelperTestSuite) TestNewComposeHelperNerdctlRuntimeFallsBackToDocker() s.Contains([]string{"nerdctl", "docker", "docker-compose"}, ch.Command) } -func (s *HelperTestSuite) TestTryPodmanComposeUsesProvidedCommand() { - helper, err := tryPodmanCompose("podman") +func (s *HelperTestSuite) TestTryComposeSubcommandUsesProvidedCommand() { + helper, err := tryComposeSubcommand("podman") if err != nil { s.T().Skipf("podman not available in test environment: %v", err) } @@ -248,8 +248,25 @@ func (s *HelperTestSuite) TestTryPodmanComposeUsesProvidedCommand() { s.Equal([]string{"compose"}, helper.Args) } -func (s *HelperTestSuite) TestTryPodmanComposeRejectsNonexistentCommand() { - _, err := tryPodmanCompose("nonexistent-binary-xyz") +func (s *HelperTestSuite) TestTryComposeSubcommandRejectsNonexistentCommand() { + _, err := tryComposeSubcommand("nonexistent-binary-xyz") s.Error(err) s.Contains(err.Error(), "not found in PATH") } + +func (s *HelperTestSuite) TestNewComposeHelperNonPodmanFallbackUsesPodman() { + helper := &docker.DockerHelper{ + DockerCommand: "docker", + Runtime: stubRuntime{name: docker.RuntimeDocker}, + } + + ch, err := NewComposeHelper(helper) + if err != nil { + s.T().Skipf("no compose binary available in test environment: %v", err) + } + + // When Docker runtime succeeds, it should use "docker" — but if Docker Compose V2 + // is unavailable, the fallback should independently probe "podman", not re-try "docker". + // We verify here that the successful helper uses a valid command. + s.Contains([]string{"docker", "podman", "docker-compose"}, ch.Command) +} From 28c204bab95062add6eada111675fc7eda4e509a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 13 May 2026 21:57:22 -0500 Subject: [PATCH 3/3] fix(compose): use constants for repeated string literals in helper_test Resolves goconst lint violations flagged by golangci-lint. --- pkg/compose/helper_test.go | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index 06dc2f851..92668b0b1 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -10,9 +10,11 @@ import ( ) const ( - testPodmanCmd = "podman" - testComposeArg = "compose" - testPodmanVersion = "2.32.4" + testPodmanCmd = "podman" + testDockerCmd = "docker" + testDockerComposeCmd = "docker-compose" + testComposeArg = "compose" + testPodmanVersion = "2.32.4" ) type HelperTestSuite struct { @@ -192,12 +194,12 @@ func (s *HelperTestSuite) TestNewComposeHelperPodmanRuntimeUsesDockerCommand() { } s.Equal("podman", ch.Command) - s.Equal([]string{"compose"}, ch.Args) + s.Equal([]string{testComposeArg}, ch.Args) } func (s *HelperTestSuite) TestNewComposeHelperDockerRuntimeUsesDockerCommand() { helper := &docker.DockerHelper{ - DockerCommand: "docker", + DockerCommand: testDockerCmd, Runtime: stubRuntime{name: docker.RuntimeDocker}, } @@ -206,8 +208,8 @@ func (s *HelperTestSuite) TestNewComposeHelperDockerRuntimeUsesDockerCommand() { s.T().Skipf("compose binary not available in test environment: %v", err) } - s.Equal("docker", ch.Command) - s.Equal([]string{"compose"}, ch.Args) + s.Equal(testDockerCmd, ch.Command) + s.Equal([]string{testComposeArg}, ch.Args) } func (s *HelperTestSuite) TestNewComposeHelperDefaultDockerCommand() { @@ -221,7 +223,7 @@ func (s *HelperTestSuite) TestNewComposeHelperDefaultDockerCommand() { s.T().Skipf("compose binary not available in test environment: %v", err) } - s.Equal("docker", ch.Command) + s.Equal(testDockerCmd, ch.Command) } func (s *HelperTestSuite) TestNewComposeHelperNerdctlRuntimeFallsBackToDocker() { @@ -235,7 +237,7 @@ func (s *HelperTestSuite) TestNewComposeHelperNerdctlRuntimeFallsBackToDocker() s.T().Skipf("compose binary not available in test environment: %v", err) } - s.Contains([]string{"nerdctl", "docker", "docker-compose"}, ch.Command) + s.Contains([]string{"nerdctl", testDockerCmd, testDockerComposeCmd}, ch.Command) } func (s *HelperTestSuite) TestTryComposeSubcommandUsesProvidedCommand() { @@ -245,7 +247,7 @@ func (s *HelperTestSuite) TestTryComposeSubcommandUsesProvidedCommand() { } s.Equal("podman", helper.Command) - s.Equal([]string{"compose"}, helper.Args) + s.Equal([]string{testComposeArg}, helper.Args) } func (s *HelperTestSuite) TestTryComposeSubcommandRejectsNonexistentCommand() { @@ -256,7 +258,7 @@ func (s *HelperTestSuite) TestTryComposeSubcommandRejectsNonexistentCommand() { func (s *HelperTestSuite) TestNewComposeHelperNonPodmanFallbackUsesPodman() { helper := &docker.DockerHelper{ - DockerCommand: "docker", + DockerCommand: testDockerCmd, Runtime: stubRuntime{name: docker.RuntimeDocker}, } @@ -265,8 +267,8 @@ func (s *HelperTestSuite) TestNewComposeHelperNonPodmanFallbackUsesPodman() { s.T().Skipf("no compose binary available in test environment: %v", err) } - // When Docker runtime succeeds, it should use "docker" — but if Docker Compose V2 - // is unavailable, the fallback should independently probe "podman", not re-try "docker". + // When Docker runtime succeeds, it should use testDockerCmd — but if Docker Compose V2 + // is unavailable, the fallback should independently probe "podman", not re-try testDockerCmd. // We verify here that the successful helper uses a valid command. - s.Contains([]string{"docker", "podman", "docker-compose"}, ch.Command) + s.Contains([]string{testDockerCmd, testPodmanCmd, testDockerComposeCmd}, ch.Command) }