diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index 47706daa..59411f6d 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -72,8 +72,7 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat // Use new detection with caching and validation dockerHost, runtime, err := DetectDockerHost(log) if err != nil { - // Provide user-friendly error - return nil, fmt.Errorf("cannot connect to container runtime: %w; Troubleshooting: Docker - ensure Docker daemon is running; Podman - run 'systemctl --user enable --now podman.socket'; or set DOCKER_HOST environment variable explicitly", err) + return nil, fmt.Errorf("cannot connect to container runtime: %w", err) } log.Infof("Detected %s runtime at %s", runtime, dockerHost) diff --git a/pkg/commands/socket_detection_common.go b/pkg/commands/socket_detection_common.go index 6fedea23..ba84af81 100644 --- a/pkg/commands/socket_detection_common.go +++ b/pkg/commands/socket_detection_common.go @@ -15,9 +15,17 @@ import ( "github.com/sirupsen/logrus" ) +var ( + ErrNoDockerSocket = fmt.Errorf("no working Docker/Podman socket found") +) + // Timeout for validating socket connectivity const socketValidationTimeout = 3 * time.Second +var ( + validateSocketFunc = validateSocket +) + // Runtime type detection type ContainerRuntime string @@ -31,27 +39,54 @@ const ( var ( cachedDockerHost string cachedRuntime ContainerRuntime - dockerHostOnce sync.Once - dockerHostErr error + dockerHostMu sync.Mutex ) // DetectDockerHost finds a working Docker/Podman socket // Results are cached after first successful detection func DetectDockerHost(log *logrus.Entry) (string, ContainerRuntime, error) { - dockerHostOnce.Do(func() { - cachedDockerHost, cachedRuntime, dockerHostErr = detectDockerHostInternal(log) - }) - return cachedDockerHost, cachedRuntime, dockerHostErr + dockerHostMu.Lock() + defer dockerHostMu.Unlock() + + if cachedDockerHost != "" { + return cachedDockerHost, cachedRuntime, nil + } + + host, runtime, err := detectDockerHostInternal(log) + if err != nil { + return "", RuntimeUnknown, err + } + + cachedDockerHost = host + cachedRuntime = runtime + return host, runtime, nil +} + +// ResetDockerHostCache resets the cached docker host. Used for testing. +func ResetDockerHostCache() { + dockerHostMu.Lock() + defer dockerHostMu.Unlock() + cachedDockerHost = "" + cachedRuntime = "" } func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, error) { // Priority 1: Explicit DOCKER_HOST environment variable if dockerHost := os.Getenv("DOCKER_HOST"); dockerHost != "" { log.Debugf("Using DOCKER_HOST from environment: %s", dockerHost) + + // Handle plain paths without schema + if !strings.Contains(dockerHost, "://") { + if _, err := os.Stat(dockerHost); err == nil { + log.Debugf("DOCKER_HOST is a plain path, assuming unix://") + dockerHost = "unix://" + dockerHost + } + } + if !strings.HasPrefix(dockerHost, "ssh://") { ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) defer cancel() - if err := validateSocket(ctx, dockerHost, true); err != nil { + if err := validateSocketFunc(ctx, dockerHost, true); err != nil { log.Warnf("DOCKER_HOST=%s is set but not accessible: %v", dockerHost, err) } } @@ -71,7 +106,7 @@ func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, erro if !strings.HasPrefix(contextHost, "ssh://") { ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) defer cancel() - if err := validateSocket(ctx, contextHost, false); err != nil { + if err := validateSocketFunc(ctx, contextHost, false); err != nil { log.Warnf("Context host %s is not accessible: %v", contextHost, err) } } diff --git a/pkg/commands/socket_detection_test.go b/pkg/commands/socket_detection_test.go index b5e95796..4818eb2a 100644 --- a/pkg/commands/socket_detection_test.go +++ b/pkg/commands/socket_detection_test.go @@ -3,8 +3,9 @@ package commands import ( + "context" + "errors" "os" - "sync" "testing" "github.com/sirupsen/logrus" @@ -50,8 +51,7 @@ func TestDetectDockerHost_DOCKER_HOST_Priority(t *testing.T) { os.Setenv("DOCKER_HOST", expectedHost) // Reset cache for test - dockerHostOnce = sync.Once{} - cachedDockerHost = "" + ResetDockerHostCache() log := logrus.NewEntry(logrus.New()) host, _, err := DetectDockerHost(log) @@ -66,8 +66,7 @@ func TestDetectDockerHost_Caching(t *testing.T) { os.Setenv("DOCKER_HOST", "unix:///tmp/first.sock") // Reset cache for test - dockerHostOnce = sync.Once{} - cachedDockerHost = "" + ResetDockerHostCache() log := logrus.NewEntry(logrus.New()) host1, _, _ := DetectDockerHost(log) @@ -92,11 +91,90 @@ func TestDetectDockerHost_Context_Invalid(t *testing.T) { os.Setenv("DOCKER_CONTEXT", "nonexistent-context-12345") // Reset cache for test - dockerHostOnce = sync.Once{} - cachedDockerHost = "" + ResetDockerHostCache() log := logrus.NewEntry(logrus.New()) _, _, err := DetectDockerHost(log) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to use DOCKER_CONTEXT") } + +func TestGetHostFromContext(t *testing.T) { + // This test is tricky because it depends on the Docker CLI config. + // We'll skip it if we can't easily mock the config directory. + // But we can at least test the "default" case. + host, err := getHostFromContext() + if err == nil { + // If it succeeded, it should be empty or a valid host + assert.True(t, host == "" || host != "") + } +} + +func TestValidateSocket_Failures(t *testing.T) { + ctx := context.Background() + + // Test non-existent path + err := validateSocket(ctx, "unix:///tmp/nonexistent-12345.sock", false) + assert.Error(t, err) + + // Test invalid schema + err = validateSocket(ctx, "invalid:///tmp/test.sock", false) + assert.Error(t, err) +} + +func TestDetectPlatformCandidates_Unix(t *testing.T) { + // Mock validateSocketFunc to always fail + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return errors.New("mock failure") + } + + // Mock environment to ensure no candidates are found + oldXdg := os.Getenv("XDG_RUNTIME_DIR") + oldHome := os.Getenv("HOME") + defer func() { + os.Setenv("XDG_RUNTIME_DIR", oldXdg) + os.Setenv("HOME", oldHome) + }() + + os.Setenv("XDG_RUNTIME_DIR", "/tmp/nonexistent-xdg") + os.Setenv("HOME", "/tmp/nonexistent-home") + + log := logrus.NewEntry(logrus.New()) + _, _, err := detectPlatformCandidates(log) + assert.Error(t, err) + assert.Contains(t, err.Error(), ErrNoDockerSocket.Error()) +} + +func TestDetectPlatformCandidates_Unix_Success(t *testing.T) { + // Mock validateSocketFunc to succeed for a specific path + oldValidate := validateSocketFunc + oldStat := statFunc + defer func() { + validateSocketFunc = oldValidate + statFunc = oldStat + }() + + expectedPath := "unix:///var/run/docker.sock" + statFunc = func(name string) (os.FileInfo, error) { + if name == "/var/run/docker.sock" { + return nil, nil // Mock success + } + return nil, os.ErrNotExist + } + + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + if host == expectedPath { + return nil + } + return errors.New("mock failure") + } + + log := logrus.NewEntry(logrus.New()) + host, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, expectedPath, host) + assert.Equal(t, RuntimeDocker, runtime) +} diff --git a/pkg/commands/socket_detection_unix.go b/pkg/commands/socket_detection_unix.go index 004e5afc..a4b629e0 100644 --- a/pkg/commands/socket_detection_unix.go +++ b/pkg/commands/socket_detection_unix.go @@ -14,6 +14,10 @@ import ( "github.com/sirupsen/logrus" ) +var ( + statFunc = os.Stat +) + const ( DockerSocketSchema = "unix://" DockerSocketPath = "/var/run/docker.sock" @@ -21,10 +25,6 @@ const ( defaultDockerHost = DockerSocketSchema + DockerSocketPath ) -var ( - ErrNoDockerSocket = errors.New("no working Docker/Podman socket found") -) - // SocketCandidate represents a potential socket to try type SocketCandidate struct { Path string @@ -107,6 +107,12 @@ func getSocketCandidates() []SocketCandidate { // 14. Rootful Podman: /run/podman/podman.sock addCandidate("/run/podman/podman.sock", RuntimePodman) + // 15. Podman on macOS (default machine) + if home != "" { + addCandidate(filepath.Join(home, ".local", "share", "containers", "podman", "machine", "podman-machine-default", "podman.sock"), RuntimePodman) + addCandidate(filepath.Join(home, ".local", "share", "containers", "podman", "machine", "qemu", "podman.sock"), RuntimePodman) + } + return candidates } @@ -118,21 +124,24 @@ func detectPlatformCandidates(log *logrus.Entry) (string, ContainerRuntime, erro socketPath := strings.TrimPrefix(candidate.Path, DockerSocketSchema) // Fast path: check if socket file exists - if _, err := os.Stat(socketPath); err != nil { + if _, err := statFunc(socketPath); err != nil { continue } // Validate by actually connecting ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) - err := validateSocket(ctx, candidate.Path, false) - cancel() + err := func() error { + defer cancel() + return validateSocketFunc(ctx, candidate.Path, false) + }() if err != nil { log.Debugf("Socket %s exists but validation failed: %v", candidate.Path, err) - if strings.Contains(err.Error(), "permission denied") { + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "permission denied") || strings.Contains(errStr, "eacces") { lastErr = fmt.Errorf("%s: permission denied (are you in the docker group?)", candidate.Path) } else { - lastErr = fmt.Errorf("%s: %w", candidate.Path, err) + lastErr = fmt.Errorf("%s: %v", candidate.Path, err) } continue } diff --git a/pkg/commands/socket_detection_windows.go b/pkg/commands/socket_detection_windows.go index 6c9727b0..36badfca 100644 --- a/pkg/commands/socket_detection_windows.go +++ b/pkg/commands/socket_detection_windows.go @@ -4,6 +4,9 @@ package commands import ( "context" + "os" + "path/filepath" + "strings" "github.com/sirupsen/logrus" ) @@ -15,27 +18,57 @@ const ( defaultDockerHost = DockerSocketSchema + DockerSocketPath ) +func getPodmanPipes() []string { + home, err := os.UserHomeDir() + if err != nil { + return []string{"npipe:////./pipe/podman-machine-default"} + } + + configDir := filepath.Join(home, ".config", "containers", "podman", "machine", "wsl") + files, err := os.ReadDir(configDir) + if err != nil { + return []string{"npipe:////./pipe/podman-machine-default"} + } + + var pipes []string + for _, f := range files { + if !f.IsDir() && filepath.Ext(f.Name()) == ".json" { + name := strings.TrimSuffix(f.Name(), ".json") + pipes = append(pipes, "npipe:////./pipe/"+name) + } + } + + if len(pipes) == 0 { + return []string{"npipe:////./pipe/podman-machine-default"} + } + return pipes +} + func detectPlatformCandidates(log *logrus.Entry) (string, ContainerRuntime, error) { // Try Docker Desktop first dockerHost := defaultDockerHost - ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) - err := validateSocket(ctx, dockerHost, false) - cancel() + err := func() error { + ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) + defer cancel() + return validateSocketFunc(ctx, dockerHost, false) + }() if err == nil { return dockerHost, RuntimeDocker, nil } - // Try Podman on Windows - podmanHost := "npipe:////./pipe/podman-machine-default" - ctx, cancel = context.WithTimeout(context.Background(), socketValidationTimeout) - err = validateSocket(ctx, podmanHost, false) - cancel() + // Try Podman machines on Windows + for _, podmanHost := range getPodmanPipes() { + err = func() error { + ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) + defer cancel() + return validateSocketFunc(ctx, podmanHost, false) + }() - if err == nil { - return podmanHost, RuntimePodman, nil + if err == nil { + return podmanHost, RuntimePodman, nil + } } - // Fallback to default Docker host - return dockerHost, RuntimeDocker, nil + return "", RuntimeUnknown, ErrNoDockerSocket } diff --git a/pkg/commands/socket_detection_windows_test.go b/pkg/commands/socket_detection_windows_test.go new file mode 100644 index 00000000..2efd211b --- /dev/null +++ b/pkg/commands/socket_detection_windows_test.go @@ -0,0 +1,62 @@ +//go:build windows + +package commands + +import ( + "context" + "errors" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestDetectPlatformCandidates_Windows_DockerSuccess(t *testing.T) { + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + if host == "npipe:////./pipe/docker_engine" { + return nil + } + return errors.New("mock failure") + } + + log := logrus.NewEntry(logrus.New()) + host, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, "npipe:////./pipe/docker_engine", host) + assert.Equal(t, RuntimeDocker, runtime) +} + +func TestDetectPlatformCandidates_Windows_PodmanSuccess(t *testing.T) { + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + if host == "npipe:////./pipe/podman-machine-default" { + return nil + } + return errors.New("mock failure") + } + + log := logrus.NewEntry(logrus.New()) + host, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, "npipe:////./pipe/podman-machine-default", host) + assert.Equal(t, RuntimePodman, runtime) +} + +func TestDetectPlatformCandidates_Windows_Failure(t *testing.T) { + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return errors.New("mock failure") + } + + log := logrus.NewEntry(logrus.New()) + _, _, err := detectPlatformCandidates(log) + assert.Error(t, err) + assert.Equal(t, ErrNoDockerSocket, err) +}