Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 35 additions & 24 deletions pkg/compose/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ import (
const (
ProjectLabel = "com.docker.compose.project"
ServiceLabel = "com.docker.compose.service"
podmanCmd = "podman"
composeArg = "compose"
)

func LoadDockerComposeProject(
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
)
Expand All @@ -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
}

Expand Down
114 changes: 111 additions & 3 deletions pkg/compose/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Loading