diff --git a/pkg/compose/helper.go b/pkg/compose/helper.go index 6e78ba104..9d53d71ba 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 tryComposeSubcommand(dockerCmd) }, + func() (*ComposeHelper, error) { return tryDockerComposeV2(dockerCmd) }, + tryDockerComposeV1, + } + } else { + detectors = []tryFunc{ + func() (*ComposeHelper, error) { return tryDockerComposeV2(dockerCmd) }, + func() (*ComposeHelper, error) { return tryComposeSubcommand("podman") }, + 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 tryComposeSubcommand(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..92668b0b1 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -5,13 +5,16 @@ import ( "strings" "testing" + "github.com/devsy-org/devsy/pkg/docker" "github.com/stretchr/testify/suite" ) 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 { @@ -164,3 +167,108 @@ 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{testComposeArg}, ch.Args) +} + +func (s *HelperTestSuite) TestNewComposeHelperDockerRuntimeUsesDockerCommand() { + helper := &docker.DockerHelper{ + DockerCommand: testDockerCmd, + 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(testDockerCmd, ch.Command) + s.Equal([]string{testComposeArg}, 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(testDockerCmd, 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", testDockerCmd, testDockerComposeCmd}, ch.Command) +} + +func (s *HelperTestSuite) TestTryComposeSubcommandUsesProvidedCommand() { + helper, err := tryComposeSubcommand("podman") + if err != nil { + s.T().Skipf("podman not available in test environment: %v", err) + } + + s.Equal("podman", helper.Command) + s.Equal([]string{testComposeArg}, helper.Args) +} + +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: testDockerCmd, + 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 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{testDockerCmd, testPodmanCmd, testDockerComposeCmd}, ch.Command) +}