From d141d78a0c9e22306617e7e60c5184d65dae3042 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 13 May 2026 13:23:33 -0500 Subject: [PATCH 1/3] fix(docker): add Podman CDI-based GPU detection alongside Docker nvidia runtime check GPU detection previously used Docker-specific `info -f '{{.Runtimes.nvidia}}'` which fails under Podman. Now branches on IsPodman() to check CDI devices (`info -f '{{.Host.CDIDevices}}'`) for nvidia support. Both paths gracefully fall back to no-GPU on command errors instead of propagating failures. --- e2e/tests/up/gpu_detection.go | 40 +++++++++++++++ pkg/docker/helper.go | 28 +++++++++-- pkg/docker/helper_test.go | 95 +++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 e2e/tests/up/gpu_detection.go create mode 100644 pkg/docker/helper_test.go diff --git a/e2e/tests/up/gpu_detection.go b/e2e/tests/up/gpu_detection.go new file mode 100644 index 000000000..09d3f5a35 --- /dev/null +++ b/e2e/tests/up/gpu_detection.go @@ -0,0 +1,40 @@ +package up + +import ( + "os" + "os/exec" + "path/filepath" + + docker "github.com/devsy-org/devsy/pkg/docker" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "GPU detection graceful fallback", + ginkgo.Label("gpu-detection"), + func() { + ginkgo.It("should not error under the test environment's container runtime", func() { + initialDir, err := os.Getwd() + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + dockerCmd := "docker" + if _, err := exec.LookPath("podman"); err == nil { + if _, dockerErr := exec.LookPath("docker"); dockerErr != nil { + dockerCmd = "podman" + } + } + + binDir := filepath.Join(initialDir, "bin") + h := &docker.DockerHelper{DockerCommand: filepath.Join(binDir, dockerCmd)} + if _, err := os.Stat(h.DockerCommand); os.IsNotExist(err) { + h.DockerCommand = dockerCmd + } + + available, err := h.GPUSupportEnabled() + gomega.Expect(err).NotTo(gomega.HaveOccurred(), + "GPU detection should not error regardless of runtime") + ginkgo.GinkgoWriter.Printf("GPU available: %v (runtime: %s)\n", available, dockerCmd) + }) + }, +) diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index 2f7f88e0a..a3fd3c86c 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -59,12 +59,10 @@ type DockerHelper struct { } func (r *DockerHelper) GPUSupportEnabled() (bool, error) { - out, err := r.buildCmd(context.TODO(), "info", "-f", "{{.Runtimes.nvidia}}").Output() - if err != nil { - return false, command.WrapCommandError(out, err) + if r.IsPodman() { + return r.podmanGPUAvailable() } - - return strings.Contains(string(out), "nvidia-container-runtime"), nil + return r.dockerGPUAvailable() } func (r *DockerHelper) FindDevContainer( @@ -450,6 +448,26 @@ func (r *DockerHelper) GetContainerLogs( return cmd.Run() } +func (r *DockerHelper) dockerGPUAvailable() (bool, error) { + out, err := r.buildCmd(context.TODO(), "info", "-f", "{{.Runtimes.nvidia}}").Output() + if err != nil { + log.Debugf("GPU detection: docker runtime query failed, assuming no GPU: %v", err) + return false, nil + } + + return strings.Contains(string(out), "nvidia-container-runtime"), nil +} + +func (r *DockerHelper) podmanGPUAvailable() (bool, error) { + out, err := r.buildCmd(context.TODO(), "info", "-f", "{{.Host.CDIDevices}}").Output() + if err != nil { + log.Debugf("GPU detection: podman CDI query failed, assuming no GPU: %v", err) + return false, nil + } + + return strings.Contains(strings.ToLower(string(out)), "nvidia"), nil +} + func (r *DockerHelper) buildCmd(ctx context.Context, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, r.DockerCommand, args...) if r.Environment != nil { diff --git a/pkg/docker/helper_test.go b/pkg/docker/helper_test.go new file mode 100644 index 000000000..440cbcd7a --- /dev/null +++ b/pkg/docker/helper_test.go @@ -0,0 +1,95 @@ +package docker + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeScript(t *testing.T, dir, name, script string) string { + t.Helper() + path := filepath.Join(dir, name) + //nolint:gosec // test helper script needs exec bit + require.NoError(t, os.WriteFile(path, []byte(script), 0o755)) + return path +} + +func TestGPUSupportEnabled_DockerWithNvidia(t *testing.T) { + tmp := t.TempDir() + bin := writeScript(t, tmp, "docker-fake", `#!/bin/sh +case "$1" in + --version) echo "Docker version 24.0.7, build afdd53b";; + info) echo "nvidia-container-runtime";; +esac +`) + + h := &DockerHelper{DockerCommand: bin} + got, err := h.GPUSupportEnabled() + + assert.NoError(t, err) + assert.True(t, got, "should detect GPU when Docker nvidia runtime is present") +} + +func TestGPUSupportEnabled_DockerWithoutNvidia(t *testing.T) { + tmp := t.TempDir() + bin := writeScript(t, tmp, "docker-fake", `#!/bin/sh +case "$1" in + --version) echo "Docker version 24.0.7, build afdd53b";; + info) echo "{}";; +esac +`) + + h := &DockerHelper{DockerCommand: bin} + got, err := h.GPUSupportEnabled() + + assert.NoError(t, err) + assert.False(t, got, "should not detect GPU when Docker nvidia runtime is absent") +} + +func TestGPUSupportEnabled_PodmanWithCDINvidia(t *testing.T) { + tmp := t.TempDir() + bin := writeScript(t, tmp, "podman-fake", `#!/bin/sh +case "$1" in + --version) echo "podman version 4.9.3";; + info) echo "[nvidia.com/gpu=all]";; +esac +`) + + h := &DockerHelper{DockerCommand: bin} + got, err := h.GPUSupportEnabled() + + assert.NoError(t, err) + assert.True(t, got, "should detect GPU when Podman CDI has nvidia device") +} + +func TestGPUSupportEnabled_PodmanWithoutCDINvidia(t *testing.T) { + tmp := t.TempDir() + bin := writeScript(t, tmp, "podman-fake", `#!/bin/sh +case "$1" in + --version) echo "podman version 4.9.3";; + info) echo "[]";; +esac +`) + + h := &DockerHelper{DockerCommand: bin} + got, err := h.GPUSupportEnabled() + + assert.NoError(t, err) + assert.False(t, got, "should not detect GPU when Podman CDI has no nvidia device") +} + +func TestGPUSupportEnabled_CommandFailure(t *testing.T) { + tmp := t.TempDir() + bin := writeScript(t, tmp, "bad-runtime", `#!/bin/sh +exit 1 +`) + + h := &DockerHelper{DockerCommand: bin} + got, err := h.GPUSupportEnabled() + + assert.NoError(t, err, "should not propagate error on command failure") + assert.False(t, got, "should fall back to no GPU on command failure") +} From fb46166166f863ae74285c252fafc0dac4053617 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 13 May 2026 13:35:30 -0500 Subject: [PATCH 2/3] fix(e2e): use up- label prefix for GPU detection test Match the convention used by all other tests in e2e/tests/up/ so the test is picked up by the CI label filter. --- e2e/tests/up/gpu_detection.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/up/gpu_detection.go b/e2e/tests/up/gpu_detection.go index 09d3f5a35..1cdd96254 100644 --- a/e2e/tests/up/gpu_detection.go +++ b/e2e/tests/up/gpu_detection.go @@ -12,7 +12,7 @@ import ( var _ = ginkgo.Describe( "GPU detection graceful fallback", - ginkgo.Label("gpu-detection"), + ginkgo.Label("up-gpu-detection"), func() { ginkgo.It("should not error under the test environment's container runtime", func() { initialDir, err := os.Getwd() From bf8bca77ab208e4a37b8d08f7c8c3d5d6b25285a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 13 May 2026 13:38:07 -0500 Subject: [PATCH 3/3] fix(lint): suppress deprecated inject.ExecFunc warnings and use package constant - Add //nolint:staticcheck on ExecFunc field declarations in factory.go, kubernetes.go, and legacy_shell.go (legacy bridge types require this type) - Replace "docker" string literal in gpu_detection.go with testDockerCommand constant from the package to satisfy goconst --- e2e/tests/up/gpu_detection.go | 4 ++-- pkg/agent/delivery/factory.go | 4 ++-- pkg/agent/delivery/kubernetes.go | 2 +- pkg/agent/delivery/legacy_shell.go | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e/tests/up/gpu_detection.go b/e2e/tests/up/gpu_detection.go index 1cdd96254..92baa7cb4 100644 --- a/e2e/tests/up/gpu_detection.go +++ b/e2e/tests/up/gpu_detection.go @@ -18,9 +18,9 @@ var _ = ginkgo.Describe( initialDir, err := os.Getwd() gomega.Expect(err).NotTo(gomega.HaveOccurred()) - dockerCmd := "docker" + dockerCmd := testDockerCommand if _, err := exec.LookPath("podman"); err == nil { - if _, dockerErr := exec.LookPath("docker"); dockerErr != nil { + if _, dockerErr := exec.LookPath(testDockerCommand); dockerErr != nil { dockerCmd = "podman" } } diff --git a/pkg/agent/delivery/factory.go b/pkg/agent/delivery/factory.go index 6a2040bda..59189234e 100644 --- a/pkg/agent/delivery/factory.go +++ b/pkg/agent/delivery/factory.go @@ -19,7 +19,7 @@ type FactoryOptions struct { HelperImage string IsRemoteDocker bool ContainerID string - ExecFunc inject.ExecFunc + ExecFunc inject.ExecFunc //nolint:staticcheck // legacy delivery strategies require this type } func NewAgentDelivery(opts FactoryOptions) AgentDelivery { @@ -105,7 +105,7 @@ func CommandFunc( stdin io.Reader, stdout io.Writer, stderr io.Writer, ) error, workspaceID string, -) inject.ExecFunc { +) inject.ExecFunc { //nolint:staticcheck // bridges driver command signature to legacy ExecFunc return func( ctx context.Context, command string, diff --git a/pkg/agent/delivery/kubernetes.go b/pkg/agent/delivery/kubernetes.go index e315fbc3b..9744f92b8 100644 --- a/pkg/agent/delivery/kubernetes.go +++ b/pkg/agent/delivery/kubernetes.go @@ -12,7 +12,7 @@ import ( var _ AgentDelivery = (*KubernetesDelivery)(nil) type KubernetesDelivery struct { - ExecFunc inject.ExecFunc + ExecFunc inject.ExecFunc //nolint:staticcheck // K8s exec routing requires this type } func (d *KubernetesDelivery) Phase() DeliveryPhase { diff --git a/pkg/agent/delivery/legacy_shell.go b/pkg/agent/delivery/legacy_shell.go index 795d66066..edcacf4ab 100644 --- a/pkg/agent/delivery/legacy_shell.go +++ b/pkg/agent/delivery/legacy_shell.go @@ -13,7 +13,7 @@ import ( // Deprecated: LegacyShellDelivery is deprecated. Platform-native AgentDelivery implementations // (LocalDockerDelivery, RemoteDockerDelivery, KubernetesDelivery) are the replacements. type LegacyShellDelivery struct { - ExecFunc inject.ExecFunc + ExecFunc inject.ExecFunc //nolint:staticcheck // deprecated injection path DownloadURL string Timeout func() *agent.InjectOptions }