Implement proper detection with Podman support and enhance error handling across platforms

Signed-off-by: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com>
This commit is contained in:
DeiAsPie 2025-12-27 13:19:14 +05:30
parent 0eec22e241
commit cb517d9e09
No known key found for this signature in database
6 changed files with 254 additions and 38 deletions

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}