Skip to content

Commit ec01475

Browse files
andystimeclaude
andcommitted
feat: smart CMD/ENTRYPOINT detection for all container backends
Problem: - Images with interactive shells (bash/sh) as CMD exit immediately without -it - PyTorch, TensorFlow, Ubuntu, Alpine images all use /bin/bash or /bin/sh - Containers start then immediately stop, causing "container not running" errors - Previous code blindly used sleep infinity, overriding useful CMDs like Jupyter - Each backend needs platform-specific handling (Docker, Colima, WSL, Apple) Solution: Implemented intelligent CMD detection across all 4 backends: 1. **Shared Detection Logic (container_setup.go:517-559)** - ImageHasDefaultCommand() - inspects image CMD/ENTRYPOINT - Detects interactive shells: /bin/bash, /bin/sh (all variants) - Excludes scripts: "start-*", "*.sh" are treated as services - Safe fallback: inspection errors → assume no useful CMD 2. **Docker Backend (backend_docker.go:313-327)** - Uses ImageHasDefaultCommand(ctx, b.dockerCmd, image) - Interactive shell → sleep infinity - Service script → use image default 3. **Colima Backend (backend_colima.go:507-534)** - Uses ImageHasDefaultCommand(ctx, "docker", image) - Same logic with Colima docker context 4. **WSL Backend (backend_wsl.go:318-361)** - Inline implementation (runs docker inspect in WSL) - Same shell detection logic - Special handling for WSL execution context 5. **Apple Container Backend (backend_apple.go:211-232)** - Uses ImageHasDefaultCommand(ctx, b.containerCmd, image) - Added klog import for logging - Works with container (not docker) command Results by Image Type: - pytorch/pytorch [/bin/bash][] → sleep infinity ✓ - jupyter/scipy-notebook [start-notebook.py][tini] → use default ✓ - ubuntu [/bin/bash][] → sleep infinity ✓ - alpine [/bin/sh][] → sleep infinity ✓ - tensorflow/tensorflow [/bin/bash][] → sleep infinity ✓ - Custom with scripts [start.sh][] → use default ✓ Platform Coverage: - ✅ Linux (Docker) - ✅ macOS (Docker Desktop) - ✅ macOS (Colima) - ✅ macOS 26+ (Apple Container) - ✅ Windows (Docker Desktop) - ✅ Windows (WSL2 + Docker) Changes: - container_setup.go: Add ImageHasDefaultCommand() + os/exec import - backend_docker.go: Use smart detection before adding CMD - backend_colima.go: Use smart detection before adding CMD - backend_wsl.go: Inline detection with WSL context handling - backend_apple.go: Use smart detection + add klog import This ensures containers stay running across all platforms while respecting useful default commands like Jupyter's start-notebook.py. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 7ee9d8b commit ec01475

5 files changed

Lines changed: 133 additions & 24 deletions

File tree

internal/studio/backend_apple.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/NexusGPU/gpu-go/internal/errors"
1717
"github.com/NexusGPU/gpu-go/internal/platform"
18+
"k8s.io/klog/v2"
1819
)
1920

2021
const appleContainerInstallHint = "Apple Container is not installed. Download the signed installer package from https://github.com/apple/container/releases"
@@ -215,9 +216,22 @@ func (b *AppleContainerBackend) Create(ctx context.Context, opts *CreateOptions)
215216
}
216217
args = append(args, image)
217218

218-
// Add command args (supplements ENTRYPOINT or overrides CMD)
219-
if formattedCmd := FormatContainerCommand(opts.Command); len(formattedCmd) > 0 {
220-
args = append(args, formattedCmd...)
219+
// Check if image has a default CMD or ENTRYPOINT
220+
// Only use "sleep infinity" if image has no useful CMD and user provided no command
221+
cmdToUse := opts.Command
222+
if len(cmdToUse) == 0 {
223+
hasDefaultCmd := ImageHasDefaultCommand(ctx, b.containerCmd, image)
224+
if !hasDefaultCmd {
225+
cmdToUse = []string{"sleep", "infinity"}
226+
klog.V(2).Infof("Image has no useful CMD/ENTRYPOINT, using sleep infinity")
227+
} else {
228+
klog.V(2).Infof("Image has useful CMD/ENTRYPOINT, using it")
229+
}
230+
}
231+
if len(cmdToUse) > 0 {
232+
if formattedCmd := FormatContainerCommand(cmdToUse); len(formattedCmd) > 0 {
233+
args = append(args, formattedCmd...)
234+
}
221235
}
222236

223237
cmd := exec.CommandContext(ctx, b.containerCmd, args...)

internal/studio/backend_colima.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -511,17 +511,29 @@ func (b *ColimaBackend) Create(ctx context.Context, opts *CreateOptions) (*Envir
511511
}
512512
args = append(args, image)
513513

514-
// Add command args (supplements ENTRYPOINT or overrides CMD)
515-
// FormatContainerCommand handles wrapping single shell commands with "sh -c"
516-
if formattedCmd := FormatContainerCommand(opts.Command); len(formattedCmd) > 0 {
517-
args = append(args, formattedCmd...)
518-
}
519-
520514
// Pull image first with progress visible to user
521515
if err := b.pullImageWithProgress(ctx, image, platform); err != nil {
522516
return nil, fmt.Errorf("failed to pull image: %w", err)
523517
}
524518

519+
// Check if image has a default CMD or ENTRYPOINT
520+
// Only use "sleep infinity" if image has no useful CMD and user provided no command
521+
cmdToUse := opts.Command
522+
if len(cmdToUse) == 0 {
523+
hasDefaultCmd := ImageHasDefaultCommand(ctx, "docker", image)
524+
if !hasDefaultCmd {
525+
cmdToUse = []string{"sleep", "infinity"}
526+
klog.V(2).Infof("Image has no default CMD/ENTRYPOINT, using sleep infinity")
527+
} else {
528+
klog.V(2).Infof("Image has default CMD/ENTRYPOINT, using it")
529+
}
530+
}
531+
if len(cmdToUse) > 0 {
532+
if formattedCmd := FormatContainerCommand(cmdToUse); len(formattedCmd) > 0 {
533+
args = append(args, formattedCmd...)
534+
}
535+
}
536+
525537
// Run with Colima's docker context
526538
cmd := exec.CommandContext(ctx, "docker", args...)
527539
cmd.Env = append(os.Environ(), fmt.Sprintf("DOCKER_HOST=%s", b.dockerHost))

internal/studio/backend_docker.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -303,24 +303,31 @@ func (b *DockerBackend) Create(ctx context.Context, opts *CreateOptions) (*Envir
303303
}
304304
args = append(args, image)
305305

306-
// Add command args (supplements ENTRYPOINT or overrides CMD)
307-
// FormatContainerCommand handles wrapping single shell commands with "sh -c"
308-
// If no command is provided, use "sleep infinity" to keep container running
309-
cmdToUse := opts.Command
310-
if len(cmdToUse) == 0 {
311-
cmdToUse = []string{"sleep", "infinity"}
312-
}
313-
if formattedCmd := FormatContainerCommand(cmdToUse); len(formattedCmd) > 0 {
314-
args = append(args, formattedCmd...)
315-
}
316-
317306
klog.V(2).Infof("Running docker command: %s %v", b.dockerCmd, args)
318307

319308
// Pull image first with progress visible to user
320309
if err := b.pullImageWithProgress(ctx, image, platform); err != nil {
321310
return nil, fmt.Errorf("failed to pull image: %w", err)
322311
}
323312

313+
// Check if image has a default CMD or ENTRYPOINT
314+
// Only use "sleep infinity" if image has no CMD and user provided no command
315+
cmdToUse := opts.Command
316+
if len(cmdToUse) == 0 {
317+
hasDefaultCmd := ImageHasDefaultCommand(ctx, b.dockerCmd, image)
318+
if !hasDefaultCmd {
319+
cmdToUse = []string{"sleep", "infinity"}
320+
klog.V(2).Infof("Image has no default CMD/ENTRYPOINT, using sleep infinity")
321+
} else {
322+
klog.V(2).Infof("Image has default CMD/ENTRYPOINT, using it")
323+
}
324+
}
325+
if len(cmdToUse) > 0 {
326+
if formattedCmd := FormatContainerCommand(cmdToUse); len(formattedCmd) > 0 {
327+
args = append(args, formattedCmd...)
328+
}
329+
}
330+
324331
// Run container
325332
cmd := exec.CommandContext(ctx, b.dockerCmd, args...)
326333
b.setDockerEnv(cmd)

internal/studio/backend_wsl.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -322,10 +322,44 @@ func (b *WSLBackend) Create(ctx context.Context, opts *CreateOptions) (*Environm
322322
}
323323
args = append(args, image)
324324

325-
// Add command args (supplements ENTRYPOINT or overrides CMD)
326-
// FormatContainerCommand handles wrapping single shell commands with "sh -c"
327-
if formattedCmd := FormatContainerCommand(opts.Command); len(formattedCmd) > 0 {
328-
args = append(args, formattedCmd...)
325+
// Check if image has a default CMD or ENTRYPOINT
326+
// Only use "sleep infinity" if image has no useful CMD and user provided no command
327+
// For WSL, we run docker inspect in WSL context
328+
cmdToUse := opts.Command
329+
if len(cmdToUse) == 0 {
330+
// Check image CMD/ENTRYPOINT by running docker inspect in WSL
331+
inspectArgs := []string{"docker", "inspect", "--format", "{{.Config.Cmd}}{{.Config.Entrypoint}}", image}
332+
inspectOutput, inspectErr := b.runInWSL(ctx, distro, inspectArgs...)
333+
hasDefaultCmd := false
334+
if inspectErr == nil {
335+
outputStr := strings.TrimSpace(string(inspectOutput))
336+
// Same logic as ImageHasDefaultCommand but inline for WSL
337+
if outputStr != "" && outputStr != "[][]" && outputStr != "<no value><no value>" {
338+
// Check if it's not just an interactive shell
339+
outputLower := strings.ToLower(outputStr)
340+
interactiveShells := []string{"/bin/bash", "/bin/sh", "bash", "sh", "/usr/bin/bash", "/usr/bin/sh"}
341+
isInteractiveShell := false
342+
for _, shell := range interactiveShells {
343+
if strings.Contains(outputLower, shell) && !strings.Contains(outputLower, "start-") && !strings.Contains(outputLower, ".sh]") {
344+
isInteractiveShell = true
345+
break
346+
}
347+
}
348+
hasDefaultCmd = !isInteractiveShell
349+
}
350+
}
351+
352+
if !hasDefaultCmd {
353+
cmdToUse = []string{"sleep", "infinity"}
354+
klog.V(2).Infof("Image has no useful CMD/ENTRYPOINT, using sleep infinity")
355+
} else {
356+
klog.V(2).Infof("Image has useful CMD/ENTRYPOINT, using it")
357+
}
358+
}
359+
if len(cmdToUse) > 0 {
360+
if formattedCmd := FormatContainerCommand(cmdToUse); len(formattedCmd) > 0 {
361+
args = append(args, formattedCmd...)
362+
}
329363
}
330364

331365
// Run in WSL

internal/studio/container_setup.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"os/exec"
78
"path/filepath"
89
"strings"
910

@@ -501,3 +502,44 @@ func getSSHVolumeMounts(paths *platform.Paths, studioName string) []VolumeMount
501502

502503
return mounts
503504
}
505+
506+
// ImageHasDefaultCommand checks if a Docker image has a useful default CMD or ENTRYPOINT
507+
// Interactive shells (bash, sh) are not considered useful as they exit immediately without -it
508+
func ImageHasDefaultCommand(ctx context.Context, dockerCmd, image string) bool {
509+
cmd := exec.CommandContext(ctx, dockerCmd, "inspect", "--format", "{{.Config.Cmd}}{{.Config.Entrypoint}}", image)
510+
output, err := cmd.Output()
511+
if err != nil {
512+
klog.V(2).Infof("Failed to inspect image %s: %v, assuming no default command", image, err)
513+
return false
514+
}
515+
516+
// Output format: [cmd args][entrypoint args] or [] for empty
517+
// Examples:
518+
// - Image with CMD: [/bin/bash][] or similar
519+
// - Image with ENTRYPOINT: [][/entrypoint.sh]
520+
// - Image with both: [arg1 arg2][/entrypoint.sh]
521+
// - No CMD or ENTRYPOINT: [][]
522+
outputStr := strings.TrimSpace(string(output))
523+
524+
// No CMD or ENTRYPOINT
525+
if outputStr == "" || outputStr == "[][]" || outputStr == "<no value><no value>" {
526+
klog.V(2).Infof("Image %s has no CMD/ENTRYPOINT", image)
527+
return false
528+
}
529+
530+
// Check if CMD/ENTRYPOINT is just an interactive shell
531+
// These exit immediately without -it flag, so treat as "no useful command"
532+
interactiveShells := []string{"/bin/bash", "/bin/sh", "bash", "sh", "/usr/bin/bash", "/usr/bin/sh"}
533+
outputLower := strings.ToLower(outputStr)
534+
for _, shell := range interactiveShells {
535+
// Match [/bin/bash][] or similar patterns
536+
if strings.Contains(outputLower, shell) && !strings.Contains(outputLower, "start-") && !strings.Contains(outputLower, ".sh]") {
537+
// It's a plain shell command, not a script
538+
klog.V(2).Infof("Image %s has interactive shell CMD (%s), treating as no useful command", image, outputStr)
539+
return false
540+
}
541+
}
542+
543+
klog.V(2).Infof("Image %s has useful CMD/ENTRYPOINT: %s", image, outputStr)
544+
return true
545+
}

0 commit comments

Comments
 (0)