mirror of
https://github.com/jesseduffield/lazydocker.git
synced 2026-07-22 15:11:02 +00:00
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:
parent
0eec22e241
commit
cb517d9e09
6 changed files with 254 additions and 38 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
62
pkg/commands/socket_detection_windows_test.go
Normal file
62
pkg/commands/socket_detection_windows_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue