From 46806e9e084fa6163ed979fe93c9e1e47aafe771 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 13 May 2026 14:44:28 -0500 Subject: [PATCH 1/3] feat(compose): add podman compose detection path NewComposeHelper now detects podman compose as a third option between Docker Compose V2 and V1. This enables compose workflows on systems where only Podman is installed. --- pkg/compose/helper.go | 44 +++++++++++++++++++++- pkg/compose/helper_test.go | 76 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/pkg/compose/helper.go b/pkg/compose/helper.go index 1b8de3774..26689917f 100644 --- a/pkg/compose/helper.go +++ b/pkg/compose/helper.go @@ -56,7 +56,8 @@ type ComposeHelper struct { } // NewComposeHelper creates a new ComposeHelper instance after detecting whether Docker -// Compose V1 or V2 is installed. It returns an error if neither is found. +// Compose V2, Podman Compose, or Docker Compose V1 is installed. It returns an error +// if none are found. func NewComposeHelper(dockerHelper *docker.DockerHelper) (*ComposeHelper, error) { dockerCmd := dockerHelper.DockerCommand if dockerCmd == "" { @@ -68,12 +69,17 @@ func NewComposeHelper(dockerHelper *docker.DockerHelper) (*ComposeHelper, error) return helper, nil } + if helper, err := tryPodmanCompose(); err == nil { + helper.Docker = dockerHelper + return helper, nil + } + if helper, err := tryDockerComposeV1(); err == nil { helper.Docker = dockerHelper return helper, nil } - return nil, fmt.Errorf("docker compose not installed") + return nil, fmt.Errorf("docker compose or podman compose not installed") } // tryDockerComposeV2 checks if Docker Compose V2 is available and returns a ComposeHelper if so. @@ -120,6 +126,40 @@ func tryDockerComposeV2(dockerCmd string) (*ComposeHelper, error) { return helper, nil } +func tryPodmanCompose() (*ComposeHelper, error) { + if _, err := exec.LookPath("podman"); err != nil { + return nil, fmt.Errorf("podman not found in PATH") + } + + if exec.Command("podman", "compose").Run() != nil { + return nil, fmt.Errorf("podman compose not available") + } + + cmd := exec.Command("podman", "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", + strings.TrimSpace(string(stderr)), + err, + ) + } + + version := strings.TrimSpace(string(out)) + if _, parseErr := parseVersion(version); parseErr != nil { + return nil, fmt.Errorf("failed to parse podman compose version %q: %w", version, parseErr) + } + + return &ComposeHelper{ + Command: "podman", + Version: version, + Args: []string{"compose"}, + }, nil +} + func tryDockerComposeV1() (*ComposeHelper, error) { if _, err := exec.LookPath("docker-compose"); err != nil { return nil, fmt.Errorf("docker-compose not found in PATH") diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index bcbcb03e1..136752727 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -1,6 +1,7 @@ package compose import ( + "context" "testing" "github.com/stretchr/testify/suite" @@ -80,3 +81,78 @@ func (s *HelperTestSuite) TestParseVersionWithPodmanWarning() { s.NoError(err) s.Equal("5.1.0", v.String()) } + +func (s *HelperTestSuite) TestParseVersionPodmanCompose() { + tests := []struct { + name string + version string + want string + wantErr bool + }{ + { + name: "podman compose standard version", + version: "2.32.4", + want: "2.32.4", + }, + { + name: "podman compose with v prefix", + version: "v2.32.4", + want: "2.32.4", + }, + { + name: "podman-compose python variant", + version: "1.0.6", + want: "1.0.6", + }, + { + name: "podman compose with trailing newline", + version: "2.32.4\n", + want: "2.32.4", + }, + { + name: "podman compose with external provider warning", + version: ">>>> Executing external compose provider. Please see podman-compose(1) <<<<\n\n2.32.4\n", + want: "2.32.4", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + got, err := parseVersion(tt.version) + if tt.wantErr { + s.Error(err) + } else { + s.NoError(err) + s.Equal(tt.want, got.String()) + } + }) + } +} + +func (s *HelperTestSuite) TestComposeHelperPodmanFields() { + helper := &ComposeHelper{ + Command: "podman", + Version: "2.32.4", + Args: []string{"compose"}, + } + + s.Equal("podman", helper.Command) + s.Equal("2.32.4", helper.Version) + s.Equal([]string{"compose"}, helper.Args) +} + +func (s *HelperTestSuite) TestComposeHelperBuildCmdPodman() { + helper := &ComposeHelper{ + Command: "podman", + Version: "2.32.4", + Args: []string{"compose"}, + } + + cmd := helper.buildCmd(context.TODO(), "--project-name", "test", "up", "-d") + s.Equal("podman", cmd.Path[:len(cmd.Path)]) // binary name ends the path + s.Contains(cmd.Args, "compose") + s.Contains(cmd.Args, "--project-name") + s.Contains(cmd.Args, "test") + s.Contains(cmd.Args, "up") + s.Contains(cmd.Args, "-d") +} From 839736bbaaee9a30f9df0f75e055fbdfb580f1e7 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 13 May 2026 14:50:22 -0500 Subject: [PATCH 2/3] fix(compose): store parsed version and fix test path assertion Use parsed semver string for ComposeHelper.Version instead of raw stdout that may contain Podman warning prefixes. Fix broken path assertion in TestComposeHelperBuildCmdPodman to use HasSuffix check. --- pkg/compose/helper.go | 12 ++++++++---- pkg/compose/helper_test.go | 3 ++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pkg/compose/helper.go b/pkg/compose/helper.go index 26689917f..81df20918 100644 --- a/pkg/compose/helper.go +++ b/pkg/compose/helper.go @@ -148,14 +148,18 @@ func tryPodmanCompose() (*ComposeHelper, error) { ) } - version := strings.TrimSpace(string(out)) - if _, parseErr := parseVersion(version); parseErr != nil { - return nil, fmt.Errorf("failed to parse podman compose version %q: %w", version, parseErr) + parsed, parseErr := parseVersion(strings.TrimSpace(string(out))) + if parseErr != nil { + return nil, fmt.Errorf( + "failed to parse podman compose version %q: %w", + strings.TrimSpace(string(out)), + parseErr, + ) } return &ComposeHelper{ Command: "podman", - Version: version, + Version: parsed.String(), Args: []string{"compose"}, }, nil } diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index 136752727..fd0b20df1 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -2,6 +2,7 @@ package compose import ( "context" + "strings" "testing" "github.com/stretchr/testify/suite" @@ -149,7 +150,7 @@ func (s *HelperTestSuite) TestComposeHelperBuildCmdPodman() { } cmd := helper.buildCmd(context.TODO(), "--project-name", "test", "up", "-d") - s.Equal("podman", cmd.Path[:len(cmd.Path)]) // binary name ends the path + s.True(strings.HasSuffix(cmd.Path, "podman")) s.Contains(cmd.Args, "compose") s.Contains(cmd.Args, "--project-name") s.Contains(cmd.Args, "test") From 5aee80647bebe600d23ca1b1060e0be93b994bcf Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 13 May 2026 14:58:59 -0500 Subject: [PATCH 3/3] fix(compose): extract constants to satisfy goconst linter Add podmanCmd and composeArg constants in helper.go, and test-scoped constants in helper_test.go to eliminate repeated string literals flagged by golangci-lint. --- pkg/compose/helper.go | 12 ++++++---- pkg/compose/helper_test.go | 47 ++++++++++++++++++++++---------------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/pkg/compose/helper.go b/pkg/compose/helper.go index 81df20918..6e78ba104 100644 --- a/pkg/compose/helper.go +++ b/pkg/compose/helper.go @@ -22,6 +22,8 @@ import ( const ( ProjectLabel = "com.docker.compose.project" ServiceLabel = "com.docker.compose.service" + podmanCmd = "podman" + composeArg = "compose" ) func LoadDockerComposeProject( @@ -127,15 +129,15 @@ func tryDockerComposeV2(dockerCmd string) (*ComposeHelper, error) { } func tryPodmanCompose() (*ComposeHelper, error) { - if _, err := exec.LookPath("podman"); err != nil { + if _, err := exec.LookPath(podmanCmd); err != nil { return nil, fmt.Errorf("podman not found in PATH") } - if exec.Command("podman", "compose").Run() != nil { + if exec.Command(podmanCmd, composeArg).Run() != nil { return nil, fmt.Errorf("podman compose not available") } - cmd := exec.Command("podman", "compose", "version", "--short") + cmd := exec.Command(podmanCmd, composeArg, "version", "--short") out, stderr, err := runCmdCapture(cmd) if len(stderr) > 0 { log.Warnf("%s: %s", strings.TrimSpace(string(stderr)), strings.TrimSpace(string(out))) @@ -158,9 +160,9 @@ func tryPodmanCompose() (*ComposeHelper, error) { } return &ComposeHelper{ - Command: "podman", + Command: podmanCmd, Version: parsed.String(), - Args: []string{"compose"}, + Args: []string{composeArg}, }, nil } diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index fd0b20df1..102dae17b 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -8,6 +8,12 @@ import ( "github.com/stretchr/testify/suite" ) +const ( + testPodmanCmd = "podman" + testComposeArg = "compose" + testPodmanVersion = "2.32.4" +) + type HelperTestSuite struct { suite.Suite } @@ -92,13 +98,13 @@ func (s *HelperTestSuite) TestParseVersionPodmanCompose() { }{ { name: "podman compose standard version", - version: "2.32.4", - want: "2.32.4", + version: testPodmanVersion, + want: testPodmanVersion, }, { name: "podman compose with v prefix", - version: "v2.32.4", - want: "2.32.4", + version: "v" + testPodmanVersion, + want: testPodmanVersion, }, { name: "podman-compose python variant", @@ -107,13 +113,14 @@ func (s *HelperTestSuite) TestParseVersionPodmanCompose() { }, { name: "podman compose with trailing newline", - version: "2.32.4\n", - want: "2.32.4", + version: testPodmanVersion + "\n", + want: testPodmanVersion, }, { - name: "podman compose with external provider warning", - version: ">>>> Executing external compose provider. Please see podman-compose(1) <<<<\n\n2.32.4\n", - want: "2.32.4", + name: "podman compose with external provider warning", + version: ">>>> Executing external compose provider." + + " Please see podman-compose(1) <<<<\n\n" + testPodmanVersion + "\n", + want: testPodmanVersion, }, } @@ -132,26 +139,26 @@ func (s *HelperTestSuite) TestParseVersionPodmanCompose() { func (s *HelperTestSuite) TestComposeHelperPodmanFields() { helper := &ComposeHelper{ - Command: "podman", - Version: "2.32.4", - Args: []string{"compose"}, + Command: testPodmanCmd, + Version: testPodmanVersion, + Args: []string{testComposeArg}, } - s.Equal("podman", helper.Command) - s.Equal("2.32.4", helper.Version) - s.Equal([]string{"compose"}, helper.Args) + s.Equal(testPodmanCmd, helper.Command) + s.Equal(testPodmanVersion, helper.Version) + s.Equal([]string{testComposeArg}, helper.Args) } func (s *HelperTestSuite) TestComposeHelperBuildCmdPodman() { helper := &ComposeHelper{ - Command: "podman", - Version: "2.32.4", - Args: []string{"compose"}, + Command: testPodmanCmd, + Version: testPodmanVersion, + Args: []string{testComposeArg}, } cmd := helper.buildCmd(context.TODO(), "--project-name", "test", "up", "-d") - s.True(strings.HasSuffix(cmd.Path, "podman")) - s.Contains(cmd.Args, "compose") + s.True(strings.HasSuffix(cmd.Path, testPodmanCmd)) + s.Contains(cmd.Args, testComposeArg) s.Contains(cmd.Args, "--project-name") s.Contains(cmd.Args, "test") s.Contains(cmd.Args, "up")