diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index a3fd3c86c..d11bc0668 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -56,13 +56,23 @@ type DockerHelper struct { // allow command to have a custom environment Environment []string Builder DockerBuilder + Runtime ContainerRuntime } func (r *DockerHelper) GPUSupportEnabled() (bool, error) { - if r.IsPodman() { - return r.podmanGPUAvailable() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return r.GetRuntime().GPUAvailable(ctx, r) +} + +// GetRuntime returns the container runtime for this helper. +// If no runtime was explicitly set, it auto-detects from the docker command. +func (r *DockerHelper) GetRuntime() ContainerRuntime { + if r.Runtime != nil { + return r.Runtime } - return r.dockerGPUAvailable() + return DetectRuntime(r.DockerCommand) } func (r *DockerHelper) FindDevContainer( @@ -332,25 +342,11 @@ func (r *DockerHelper) InspectContainers( } func (r *DockerHelper) IsPodman() bool { - args := []string{"--version"} - - out, err := r.buildCmd(context.TODO(), args...).Output() - if err != nil { - return false - } - - return strings.Contains(string(out), "podman") + return r.GetRuntime().Name() == RuntimePodman } func (r *DockerHelper) IsNerdctl() bool { - args := []string{"--version"} - - out, err := r.buildCmd(context.TODO(), args...).Output() - if err != nil { - return false - } - - return strings.Contains(string(out), "nerdctl") + return r.GetRuntime().Name() == RuntimeNerdctl } func (r *DockerHelper) Inspect( @@ -448,26 +444,6 @@ 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/runtime.go b/pkg/docker/runtime.go new file mode 100644 index 000000000..376d6476a --- /dev/null +++ b/pkg/docker/runtime.go @@ -0,0 +1,144 @@ +package docker + +import ( + "context" + "fmt" + "os/exec" + "strings" + "sync" + "time" +) + +// RuntimeName identifies a container runtime. +type RuntimeName string + +const ( + RuntimeDocker RuntimeName = "docker" + RuntimePodman RuntimeName = "podman" + RuntimeNerdctl RuntimeName = "nerdctl" +) + +// ContainerRuntime describes the capabilities of a container runtime (Docker, Podman, nerdctl). +// Rather than checking IsPodman()/IsNerdctl() at every call site, callers query capability +// methods that express *why* the code branches. +type ContainerRuntime interface { + Name() RuntimeName + SupportsInternalBuildKit() bool + SupportsSignalProxy() bool + SupportsMountConsistency() bool + NeedsUserNamespaceArgs() bool + GPUAvailable(ctx context.Context, helper *DockerHelper) (bool, error) +} + +type dockerRuntime struct{} + +func (dockerRuntime) Name() RuntimeName { return RuntimeDocker } +func (dockerRuntime) SupportsInternalBuildKit() bool { return true } +func (dockerRuntime) SupportsSignalProxy() bool { return true } +func (dockerRuntime) SupportsMountConsistency() bool { return true } +func (dockerRuntime) NeedsUserNamespaceArgs() bool { return false } + +func (dockerRuntime) GPUAvailable(ctx context.Context, h *DockerHelper) (bool, error) { + out, err := h.buildCmd(ctx, "info", "-f", "{{.Runtimes.nvidia}}").Output() + if err != nil { + return false, nil + } + return strings.Contains(string(out), "nvidia-container-runtime"), nil +} + +type podmanRuntime struct{} + +func (podmanRuntime) Name() RuntimeName { return RuntimePodman } +func (podmanRuntime) SupportsInternalBuildKit() bool { return false } +func (podmanRuntime) SupportsSignalProxy() bool { return true } +func (podmanRuntime) SupportsMountConsistency() bool { return true } +func (podmanRuntime) NeedsUserNamespaceArgs() bool { return true } + +func (podmanRuntime) GPUAvailable(ctx context.Context, h *DockerHelper) (bool, error) { + out, err := h.buildCmd(ctx, "info", "-f", "{{.Host.CDIDevices}}").Output() + if err != nil { + return false, nil + } + return strings.Contains(strings.ToLower(string(out)), "nvidia"), nil +} + +type nerdctlRuntime struct{} + +func (nerdctlRuntime) Name() RuntimeName { return RuntimeNerdctl } +func (nerdctlRuntime) SupportsInternalBuildKit() bool { return true } +func (nerdctlRuntime) SupportsSignalProxy() bool { return false } +func (nerdctlRuntime) SupportsMountConsistency() bool { return false } +func (nerdctlRuntime) NeedsUserNamespaceArgs() bool { return false } + +func (nerdctlRuntime) GPUAvailable(ctx context.Context, h *DockerHelper) (bool, error) { + out, err := h.buildCmd(ctx, "info", "-f", "{{.Runtimes.nvidia}}").Output() + if err != nil { + return false, nil + } + return strings.Contains(string(out), "nvidia-container-runtime"), nil +} + +// DetectRuntime probes the binary at dockerCommand to determine which runtime it is. +// The result is cached per binary path. +func DetectRuntime(dockerCommand string) ContainerRuntime { + return runtimeCache.get(dockerCommand) +} + +// RuntimeFromName returns a ContainerRuntime for the given explicit name. +// Empty string defaults to Docker. Unknown non-empty names return an error +// to catch typos in provider configuration. +func RuntimeFromName(name string) (ContainerRuntime, error) { + switch RuntimeName(strings.ToLower(strings.TrimSpace(name))) { + case "", RuntimeDocker: + return dockerRuntime{}, nil + case RuntimePodman: + return podmanRuntime{}, nil + case RuntimeNerdctl: + return nerdctlRuntime{}, nil + default: + return nil, fmt.Errorf("unknown container runtime %q", name) + } +} + +const detectTimeout = 5 * time.Second + +var runtimeCache = &runtimeDetectionCache{entries: make(map[string]ContainerRuntime)} + +type runtimeDetectionCache struct { + mu sync.Mutex + entries map[string]ContainerRuntime +} + +func (c *runtimeDetectionCache) get(dockerCommand string) ContainerRuntime { + c.mu.Lock() + if rt, ok := c.entries[dockerCommand]; ok { + c.mu.Unlock() + return rt + } + c.mu.Unlock() + + rt := detect(dockerCommand) + + c.mu.Lock() + c.entries[dockerCommand] = rt + c.mu.Unlock() + return rt +} + +func detect(dockerCommand string) ContainerRuntime { + ctx, cancel := context.WithTimeout(context.Background(), detectTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, dockerCommand, "--version").Output() + if err != nil { + return dockerRuntime{} + } + lower := strings.ToLower(string(out)) + if strings.Contains(lower, "podman") { + return podmanRuntime{} + } + if strings.Contains(lower, "nerdctl") { + return nerdctlRuntime{} + } + return dockerRuntime{} +} diff --git a/pkg/docker/runtime_test.go b/pkg/docker/runtime_test.go new file mode 100644 index 000000000..f399eb6cb --- /dev/null +++ b/pkg/docker/runtime_test.go @@ -0,0 +1,107 @@ +package docker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRuntimeFromName(t *testing.T) { + tests := []struct { + name string + input string + expected RuntimeName + }{ + {"docker", string(RuntimeDocker), RuntimeDocker}, + {"podman", string(RuntimePodman), RuntimePodman}, + {"nerdctl", string(RuntimeNerdctl), RuntimeNerdctl}, + {"Docker-uppercase", "Docker", RuntimeDocker}, + {"Podman-uppercase", "Podman", RuntimePodman}, + {"NERDCTL-uppercase", "NERDCTL", RuntimeNerdctl}, + {"empty", "", RuntimeDocker}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt, err := RuntimeFromName(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, rt.Name()) + }) + } +} + +func TestRuntimeFromNameUnknown(t *testing.T) { + _, err := RuntimeFromName("unknown") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown container runtime") +} + +func TestDockerRuntimeCapabilities(t *testing.T) { + rt, err := RuntimeFromName(string(RuntimeDocker)) + require.NoError(t, err) + assert.Equal(t, RuntimeDocker, rt.Name()) + assert.True(t, rt.SupportsInternalBuildKit()) + assert.True(t, rt.SupportsSignalProxy()) + assert.True(t, rt.SupportsMountConsistency()) + assert.False(t, rt.NeedsUserNamespaceArgs()) +} + +func TestPodmanRuntimeCapabilities(t *testing.T) { + rt, err := RuntimeFromName(string(RuntimePodman)) + require.NoError(t, err) + assert.Equal(t, RuntimePodman, rt.Name()) + assert.False(t, rt.SupportsInternalBuildKit()) + assert.True(t, rt.SupportsSignalProxy()) + assert.True(t, rt.SupportsMountConsistency()) + assert.True(t, rt.NeedsUserNamespaceArgs()) +} + +func TestNerdctlRuntimeCapabilities(t *testing.T) { + rt, err := RuntimeFromName(string(RuntimeNerdctl)) + require.NoError(t, err) + assert.Equal(t, RuntimeNerdctl, rt.Name()) + assert.True(t, rt.SupportsInternalBuildKit()) + assert.False(t, rt.SupportsSignalProxy()) + assert.False(t, rt.SupportsMountConsistency()) + assert.False(t, rt.NeedsUserNamespaceArgs()) +} + +func TestDockerHelperGetRuntimeFallback(t *testing.T) { + h := &DockerHelper{DockerCommand: "nonexistent-binary-xyz"} + rt := h.GetRuntime() + assert.Equal(t, RuntimeDocker, rt.Name(), "should fall back to docker for unknown binary") +} + +func TestDockerHelperGetRuntimeExplicit(t *testing.T) { + h := &DockerHelper{ + DockerCommand: string(RuntimeDocker), + Runtime: podmanRuntime{}, + } + rt := h.GetRuntime() + assert.Equal(t, RuntimePodman, rt.Name(), "should use explicitly set runtime") +} + +func TestDockerHelperIsPodmanDelegates(t *testing.T) { + h := &DockerHelper{ + DockerCommand: string(RuntimeDocker), + Runtime: podmanRuntime{}, + } + assert.True(t, h.IsPodman()) + assert.False(t, h.IsNerdctl()) +} + +func TestDockerHelperIsNerdctlDelegates(t *testing.T) { + h := &DockerHelper{ + DockerCommand: string(RuntimeDocker), + Runtime: nerdctlRuntime{}, + } + assert.False(t, h.IsPodman()) + assert.True(t, h.IsNerdctl()) +} + +func TestDetectRuntimeCaching(t *testing.T) { + rt1 := DetectRuntime("nonexistent-binary-abc") + rt2 := DetectRuntime("nonexistent-binary-abc") + assert.Equal(t, rt1, rt2, "same binary should return same cached runtime") +} diff --git a/pkg/driver/docker/build.go b/pkg/driver/docker/build.go index 91c6d95e4..9df78524e 100644 --- a/pkg/driver/docker/build.go +++ b/pkg/driver/docker/build.go @@ -272,9 +272,11 @@ type buildOrchestrator struct { } func (o *buildOrchestrator) selectStrategy(options provider.BuildOptions) buildStrategy { - // Podman doesn't support the Docker BuildKit session API, so force CLI build. - if o.driver.Docker.IsPodman() { - log.Debugf("podman detected, forcing docker buildx strategy") + if !o.driver.Docker.GetRuntime().SupportsInternalBuildKit() { + log.Debugf( + "%s detected, forcing docker buildx strategy", + o.driver.Docker.GetRuntime().Name(), + ) return &dockerBuildxStrategy{driver: o.driver} } diff --git a/pkg/driver/docker/docker.go b/pkg/driver/docker/docker.go index fb84e0893..43fbf6033 100644 --- a/pkg/driver/docker/docker.go +++ b/pkg/driver/docker/docker.go @@ -49,13 +49,25 @@ func NewDockerDriver( return nil, err } - log.Debugf("using docker command: command=%s", dockerCommand) + var rt docker.ContainerRuntime + if workspaceInfo.Agent.Docker.Runtime != "" { + var err error + rt, err = docker.RuntimeFromName(workspaceInfo.Agent.Docker.Runtime) + if err != nil { + return nil, fmt.Errorf("invalid runtime config: %w", err) + } + } else { + rt = docker.DetectRuntime(dockerCommand) + } + + log.Debugf("using docker command: command=%s, runtime=%s", dockerCommand, rt.Name()) return &dockerDriver{ Docker: &docker.DockerHelper{ DockerCommand: dockerCommand, Environment: makeEnvironment(workspaceInfo.Agent.Docker.Env), ContainerID: workspaceInfo.Workspace.Source.Container, Builder: builder, + Runtime: rt, }, IDLabels: workspaceInfo.CLIOptions.IDLabels, UpdateRemoteUserUIDDefault: workspaceInfo.CLIOptions.UpdateRemoteUserUIDDefault, @@ -482,7 +494,7 @@ func (d *dockerDriver) buildRunArgs( params: params, } - if !helper.IsNerdctl() { + if helper.GetRuntime().SupportsSignalProxy() { b.args = append(b.args, "--sig-proxy=false") } @@ -626,7 +638,7 @@ func (d *dockerDriver) addWorkspaceMountArgs( if options.WorkspaceMount != nil { workspacePath := d.EnsurePath(options.WorkspaceMount) mountPath := workspacePath.String() - if helper.IsNerdctl() { + if !helper.GetRuntime().SupportsMountConsistency() { mountPath = stripMountConsistency(mountPath) } args = append(args, "--mount", mountPath) @@ -825,7 +837,7 @@ func (d *dockerDriver) getPodmanArgs( options *driver.RunOptions, parsedConfig *config.DevContainerConfig, ) ([]string, error) { - if !d.Docker.IsPodman() { + if !d.Docker.GetRuntime().NeedsUserNamespaceArgs() { return []string{}, nil } diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 845333bae..8580ebe0d 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -198,6 +198,10 @@ type ProviderDockerDriverConfig struct { // HelperImage is used by LocalDockerDelivery for volume population. // When empty, defaults to busybox:latest. A direct-copy fallback is used if the helper container approach fails. HelperImage string `json:"helperImage,omitempty"` + + // Runtime identifies the container runtime explicitly (docker, podman, nerdctl). + // When empty, the runtime is auto-detected from the binary at Path. + Runtime string `json:"runtime,omitempty"` } type ProviderKubernetesDriverConfig struct { diff --git a/providers/podman/provider.yaml b/providers/podman/provider.yaml index b28845b61..15f1adc28 100644 --- a/providers/podman/provider.yaml +++ b/providers/podman/provider.yaml @@ -25,6 +25,7 @@ agent: docker: path: ${PODMAN_PATH} install: false + runtime: podman env: DOCKER_HOST: ${PODMAN_HOST} exec: