From 12a6289befadfff8ac53642b48fc783f10c99c1e Mon Sep 17 00:00:00 2001 From: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> Date: Sat, 27 Dec 2025 12:45:17 +0530 Subject: [PATCH 1/7] Implement proper detection with Podman support and enhance error handling Signed-off-by: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> --- pkg/commands/docker.go | 72 +---------- pkg/commands/docker_host_unix.go | 7 -- pkg/commands/docker_host_windows.go | 5 - pkg/commands/socket_detection_common.go | 143 +++++++++++++++++++++ pkg/commands/socket_detection_test.go | 102 +++++++++++++++ pkg/commands/socket_detection_unix.go | 151 +++++++++++++++++++++++ pkg/commands/socket_detection_windows.go | 41 ++++++ 7 files changed, 443 insertions(+), 78 deletions(-) delete mode 100644 pkg/commands/docker_host_unix.go delete mode 100644 pkg/commands/docker_host_windows.go create mode 100644 pkg/commands/socket_detection_common.go create mode 100644 pkg/commands/socket_detection_test.go create mode 100644 pkg/commands/socket_detection_unix.go create mode 100644 pkg/commands/socket_detection_windows.go diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index 35afef2f..47706daa 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -13,9 +13,6 @@ import ( "sync" "time" - cliconfig "github.com/docker/cli/cli/config" - ddocker "github.com/docker/cli/cli/context/docker" - ctxstore "github.com/docker/cli/cli/context/store" "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" "github.com/imdario/mergo" @@ -72,11 +69,15 @@ func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject { // NewDockerCommand it runs docker commands func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.TranslationSet, config *config.AppConfig, errorChan chan error) (*DockerCommand, error) { - dockerHost, err := determineDockerHost() + // Use new detection with caching and validation + dockerHost, runtime, err := DetectDockerHost(log) if err != nil { - ogLog.Printf("> could not determine host %v", err) + // 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) } + log.Infof("Detected %s runtime at %s", runtime, dockerHost) + // NOTE: Inject the determined docker host to the environment. This allows the // `SSHHandler.HandleSSHDockerHost()` to create a local unix socket tunneled // over SSH to the specified ssh host. @@ -362,64 +363,3 @@ func (c *DockerCommand) DockerComposeConfig() string { } return output } - -// determineDockerHost tries to the determine the docker host that we should connect to -// in the following order of decreasing precedence: -// - value of "DOCKER_HOST" environment variable -// - host retrieved from the current context (specified via DOCKER_CONTEXT) -// - "default docker host" for the host operating system, otherwise -func determineDockerHost() (string, error) { - // If the docker host is explicitly set via the "DOCKER_HOST" environment variable, - // then its a no-brainer :shrug: - if os.Getenv("DOCKER_HOST") != "" { - return os.Getenv("DOCKER_HOST"), nil - } - - currentContext := os.Getenv("DOCKER_CONTEXT") - if currentContext == "" { - cf, err := cliconfig.Load(cliconfig.Dir()) - if err != nil { - return "", err - } - currentContext = cf.CurrentContext - } - - // On some systems (windows) `default` is stored in the docker config as the currentContext. - if currentContext == "" || currentContext == "default" { - // If a docker context is neither specified via the "DOCKER_CONTEXT" environment variable nor via the - // $HOME/.docker/config file, then we fall back to connecting to the "default docker host" meant for - // the host operating system. - return defaultDockerHost, nil - } - - storeConfig := ctxstore.NewConfig( - func() interface{} { return &ddocker.EndpointMeta{} }, - ctxstore.EndpointTypeGetter(ddocker.DockerEndpoint, func() interface{} { return &ddocker.EndpointMeta{} }), - ) - - st := ctxstore.New(cliconfig.ContextStoreDir(), storeConfig) - md, err := st.GetMetadata(currentContext) - if err != nil { - return "", err - } - dockerEP, ok := md.Endpoints[ddocker.DockerEndpoint] - if !ok { - return "", err - } - dockerEPMeta, ok := dockerEP.(ddocker.EndpointMeta) - if !ok { - return "", fmt.Errorf("expected docker.EndpointMeta, got %T", dockerEP) - } - - if dockerEPMeta.Host != "" { - return dockerEPMeta.Host, nil - } - - // We might end up here, if the context was created with the `host` set to an empty value (i.e. ''). - // For example: - // ```sh - // docker context create foo --docker "host=" - // ``` - // In such scenario, we mimic the `docker` cli and try to connect to the "default docker host". - return defaultDockerHost, nil -} diff --git a/pkg/commands/docker_host_unix.go b/pkg/commands/docker_host_unix.go deleted file mode 100644 index ee5fb7af..00000000 --- a/pkg/commands/docker_host_unix.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows - -package commands - -const ( - defaultDockerHost = "unix:///var/run/docker.sock" -) diff --git a/pkg/commands/docker_host_windows.go b/pkg/commands/docker_host_windows.go deleted file mode 100644 index 471b55a2..00000000 --- a/pkg/commands/docker_host_windows.go +++ /dev/null @@ -1,5 +0,0 @@ -package commands - -const ( - defaultDockerHost = "npipe:////./pipe/docker_engine" -) diff --git a/pkg/commands/socket_detection_common.go b/pkg/commands/socket_detection_common.go new file mode 100644 index 00000000..6fedea23 --- /dev/null +++ b/pkg/commands/socket_detection_common.go @@ -0,0 +1,143 @@ +package commands + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "time" + + cliconfig "github.com/docker/cli/cli/config" + ddocker "github.com/docker/cli/cli/context/docker" + ctxstore "github.com/docker/cli/cli/context/store" + "github.com/docker/docker/client" + "github.com/sirupsen/logrus" +) + +// Timeout for validating socket connectivity +const socketValidationTimeout = 3 * time.Second + +// Runtime type detection +type ContainerRuntime string + +const ( + RuntimeDocker ContainerRuntime = "docker" + RuntimePodman ContainerRuntime = "podman" + RuntimeUnknown ContainerRuntime = "unknown" +) + +// Cache for socket detection results +var ( + cachedDockerHost string + cachedRuntime ContainerRuntime + dockerHostOnce sync.Once + dockerHostErr error +) + +// 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 +} + +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) + if !strings.HasPrefix(dockerHost, "ssh://") { + ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) + defer cancel() + if err := validateSocket(ctx, dockerHost, true); err != nil { + log.Warnf("DOCKER_HOST=%s is set but not accessible: %v", dockerHost, err) + } + } + return dockerHost, RuntimeUnknown, nil + } + + // Priority 2: Docker Context + contextHost, err := getHostFromContext() + if err != nil { + // If DOCKER_CONTEXT was explicitly set, we should fail + if os.Getenv("DOCKER_CONTEXT") != "" { + return "", RuntimeUnknown, fmt.Errorf("failed to use DOCKER_CONTEXT: %w", err) + } + log.Debugf("Failed to get host from default context: %v", err) + } else if contextHost != "" { + log.Debugf("Using host from Docker context: %s", contextHost) + if !strings.HasPrefix(contextHost, "ssh://") { + ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) + defer cancel() + if err := validateSocket(ctx, contextHost, false); err != nil { + log.Warnf("Context host %s is not accessible: %v", contextHost, err) + } + } + return contextHost, RuntimeUnknown, nil + } + + // Priority 3: Platform-specific candidates + return detectPlatformCandidates(log) +} + +// getHostFromContext retrieves the host from the current Docker context +func getHostFromContext() (string, error) { + currentContext := os.Getenv("DOCKER_CONTEXT") + if currentContext == "" { + cf, err := cliconfig.Load(cliconfig.Dir()) + if err != nil { + return "", err + } + currentContext = cf.CurrentContext + } + + if currentContext == "" || currentContext == "default" { + return "", nil + } + + storeConfig := ctxstore.NewConfig( + func() interface{} { return &ddocker.EndpointMeta{} }, + ctxstore.EndpointTypeGetter(ddocker.DockerEndpoint, func() interface{} { return &ddocker.EndpointMeta{} }), + ) + + st := ctxstore.New(cliconfig.ContextStoreDir(), storeConfig) + md, err := st.GetMetadata(currentContext) + if err != nil { + return "", err + } + dockerEP, ok := md.Endpoints[ddocker.DockerEndpoint] + if !ok { + return "", nil + } + dockerEPMeta, ok := dockerEP.(ddocker.EndpointMeta) + if !ok { + return "", fmt.Errorf("expected docker.EndpointMeta, got %T", dockerEP) + } + + return dockerEPMeta.Host, nil +} + +// validateSocket attempts to connect to the Docker API at the given host +func validateSocket(ctx context.Context, host string, useEnv bool) error { + var opts []client.Opt + if useEnv { + // If we're validating the host from the environment, use FromEnv to pick up TLS settings + opts = append(opts, client.FromEnv) + } + opts = append(opts, client.WithHost(host), client.WithAPIVersionNegotiation()) + + cli, err := client.NewClientWithOpts(opts...) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + defer cli.Close() + + _, err = cli.Ping(ctx) + if err != nil { + return fmt.Errorf("ping failed: %w", err) + } + + return nil +} diff --git a/pkg/commands/socket_detection_test.go b/pkg/commands/socket_detection_test.go new file mode 100644 index 00000000..b5e95796 --- /dev/null +++ b/pkg/commands/socket_detection_test.go @@ -0,0 +1,102 @@ +//go:build !windows + +package commands + +import ( + "os" + "sync" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestGetSocketCandidates(t *testing.T) { + // Save env vars + 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/runtime") + os.Setenv("HOME", "/home/user") + + candidates := getSocketCandidates() + + // Check some expected candidates + foundDocker := false + foundPodman := false + for _, c := range candidates { + if c.Path == "unix:///var/run/docker.sock" { + foundDocker = true + } + if c.Path == "unix:///tmp/runtime/podman/podman.sock" { + foundPodman = true + } + } + + assert.True(t, foundDocker, "Standard Docker socket should be in candidates") + assert.True(t, foundPodman, "Rootless Podman socket should be in candidates") +} + +func TestDetectDockerHost_DOCKER_HOST_Priority(t *testing.T) { + // Save env var + oldDockerHost := os.Getenv("DOCKER_HOST") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + expectedHost := "unix:///tmp/custom.sock" + os.Setenv("DOCKER_HOST", expectedHost) + + // Reset cache for test + dockerHostOnce = sync.Once{} + cachedDockerHost = "" + + log := logrus.NewEntry(logrus.New()) + host, _, err := DetectDockerHost(log) + assert.NoError(t, err) + assert.Equal(t, expectedHost, host) +} +func TestDetectDockerHost_Caching(t *testing.T) { + // Save env var + oldDockerHost := os.Getenv("DOCKER_HOST") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + os.Setenv("DOCKER_HOST", "unix:///tmp/first.sock") + + // Reset cache for test + dockerHostOnce = sync.Once{} + cachedDockerHost = "" + + log := logrus.NewEntry(logrus.New()) + host1, _, _ := DetectDockerHost(log) + + // Change env var - should still return first one from cache + os.Setenv("DOCKER_HOST", "unix:///tmp/second.sock") + host2, _, _ := DetectDockerHost(log) + + assert.Equal(t, host1, host2) + assert.Equal(t, "unix:///tmp/first.sock", host2) +} +func TestDetectDockerHost_Context_Invalid(t *testing.T) { + // Save env vars + oldDockerHost := os.Getenv("DOCKER_HOST") + oldDockerContext := os.Getenv("DOCKER_CONTEXT") + defer func() { + os.Setenv("DOCKER_HOST", oldDockerHost) + os.Setenv("DOCKER_CONTEXT", oldDockerContext) + }() + + os.Setenv("DOCKER_HOST", "") + os.Setenv("DOCKER_CONTEXT", "nonexistent-context-12345") + + // Reset cache for test + dockerHostOnce = sync.Once{} + cachedDockerHost = "" + + log := logrus.NewEntry(logrus.New()) + _, _, err := DetectDockerHost(log) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to use DOCKER_CONTEXT") +} diff --git a/pkg/commands/socket_detection_unix.go b/pkg/commands/socket_detection_unix.go new file mode 100644 index 00000000..004e5afc --- /dev/null +++ b/pkg/commands/socket_detection_unix.go @@ -0,0 +1,151 @@ +//go:build !windows + +package commands + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/sirupsen/logrus" +) + +const ( + DockerSocketSchema = "unix://" + DockerSocketPath = "/var/run/docker.sock" + + 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 + Runtime ContainerRuntime +} + +// getSocketCandidates returns all possible socket paths in priority order +func getSocketCandidates() []SocketCandidate { + var candidates []SocketCandidate + + // Helper to add candidate if path is valid + addCandidate := func(path string, runtime ContainerRuntime) { + if path != "" { + candidates = append(candidates, SocketCandidate{ + Path: DockerSocketSchema + path, + Runtime: runtime, + }) + } + } + + // 1. Standard Docker daemon socket + addCandidate(DockerSocketPath, RuntimeDocker) + + xdgRuntime := os.Getenv("XDG_RUNTIME_DIR") + home, _ := os.UserHomeDir() + uid := os.Getuid() + + // 2. Rootless Docker: $XDG_RUNTIME_DIR/docker.sock + if xdgRuntime != "" { + addCandidate(filepath.Join(xdgRuntime, "docker.sock"), RuntimeDocker) + } + + // 3. Rootless Docker: ~/.docker/run/docker.sock + if home != "" { + addCandidate(filepath.Join(home, ".docker", "run", "docker.sock"), RuntimeDocker) + // 4. Docker Desktop: ~/.docker/desktop/docker.sock + addCandidate(filepath.Join(home, ".docker", "desktop", "docker.sock"), RuntimeDocker) + } + + // 5. Rootless Docker: /run/user/$UID/docker.sock + addCandidate(filepath.Join("/run", "user", strconv.Itoa(uid), "docker.sock"), RuntimeDocker) + + // 6. Colima + if home != "" { + addCandidate(filepath.Join(home, ".colima", "default", "docker.sock"), RuntimeDocker) + addCandidate(filepath.Join(home, ".colima", "docker.sock"), RuntimeDocker) + } + + // 7. OrbStack + if home != "" { + addCandidate(filepath.Join(home, ".orbstack", "run", "docker.sock"), RuntimeDocker) + } + + // 8. Lima + if home != "" { + addCandidate(filepath.Join(home, ".lima", "default", "sock", "docker.sock"), RuntimeDocker) + } + + // 9. Rancher Desktop + if home != "" { + addCandidate(filepath.Join(home, ".rd", "docker.sock"), RuntimeDocker) + } + + // 10. Snap Docker + addCandidate("/var/snap/docker/current/run/docker.sock", RuntimeDocker) + + // 11. Rootless Podman: $XDG_RUNTIME_DIR/podman/podman.sock + if xdgRuntime != "" { + addCandidate(filepath.Join(xdgRuntime, "podman", "podman.sock"), RuntimePodman) + } + + // 12. Rootless Podman: /run/user/$UID/podman/podman.sock + addCandidate(filepath.Join("/run", "user", strconv.Itoa(uid), "podman", "podman.sock"), RuntimePodman) + + // 13. Rootless Podman: ~/.local/share/containers/podman/podman.sock + if home != "" { + addCandidate(filepath.Join(home, ".local", "share", "containers", "podman", "podman.sock"), RuntimePodman) + } + + // 14. Rootful Podman: /run/podman/podman.sock + addCandidate("/run/podman/podman.sock", RuntimePodman) + + return candidates +} + +func detectPlatformCandidates(log *logrus.Entry) (string, ContainerRuntime, error) { + var lastErr error + candidates := getSocketCandidates() + + for _, candidate := range candidates { + socketPath := strings.TrimPrefix(candidate.Path, DockerSocketSchema) + + // Fast path: check if socket file exists + if _, err := os.Stat(socketPath); err != nil { + continue + } + + // Validate by actually connecting + ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) + err := validateSocket(ctx, candidate.Path, false) + cancel() + + if err != nil { + log.Debugf("Socket %s exists but validation failed: %v", candidate.Path, err) + if strings.Contains(err.Error(), "permission denied") { + lastErr = fmt.Errorf("%s: permission denied (are you in the docker group?)", candidate.Path) + } else { + lastErr = fmt.Errorf("%s: %w", candidate.Path, err) + } + continue + } + + log.Infof("Connected to %s runtime via %s", candidate.Runtime, candidate.Path) + return candidate.Path, candidate.Runtime, nil + } + + // All candidates failed - provide actionable error + if lastErr != nil { + return "", RuntimeUnknown, fmt.Errorf("%w: last error: %v", ErrNoDockerSocket, lastErr) + } + + msg := fmt.Sprintf("%v: ensure Docker or Podman is running", ErrNoDockerSocket) + return "", RuntimeUnknown, errors.New(msg) +} diff --git a/pkg/commands/socket_detection_windows.go b/pkg/commands/socket_detection_windows.go new file mode 100644 index 00000000..6c9727b0 --- /dev/null +++ b/pkg/commands/socket_detection_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package commands + +import ( + "context" + + "github.com/sirupsen/logrus" +) + +const ( + DockerSocketSchema = "npipe://" + DockerSocketPath = "//./pipe/docker_engine" + + defaultDockerHost = DockerSocketSchema + DockerSocketPath +) + +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() + + 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() + + if err == nil { + return podmanHost, RuntimePodman, nil + } + + // Fallback to default Docker host + return dockerHost, RuntimeDocker, nil +} From 0eec22e2418d5f70928e5b8f91b43f6a767737bc Mon Sep 17 00:00:00 2001 From: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> Date: Sat, 27 Dec 2025 13:00:08 +0530 Subject: [PATCH 2/7] docs: add Podman and alternative runtime support to README Documents automatic socket detection for Podman, Colima, OrbStack, Lima, and Rancher Desktop. Includes setup instructions for Podman users. Signed-off-by: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 97967d36..ffa07d32 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,33 @@ Memorising docker commands is hard. Memorising aliases is slightly less hard. Ke - Docker >= **29.0.0** (API >= **1.24**) - Docker-Compose >= **1.23.2** (optional) +## Container Runtime Support + +Lazydocker automatically detects and works with multiple container runtimes: + +- **Docker** (standard, rootless, and Docker Desktop) +- **Podman** (rootful and rootless) +- **Colima, OrbStack, Lima, Rancher Desktop** + +No manual configuration needed—socket detection is automatic! + +### Using Podman + +If lazydocker can't connect to Podman automatically, enable the Podman socket: + +```sh +# Enable Podman socket service +systemctl --user enable --now podman.socket + +# Run lazydocker +lazydocker +``` + +Alternatively, set `DOCKER_HOST`: +```sh +export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock" +``` + ## Installation ### Homebrew From cb517d9e091dc8d5ed119f0d7bfd5d3cb214c076 Mon Sep 17 00:00:00 2001 From: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> Date: Sat, 27 Dec 2025 13:19:14 +0530 Subject: [PATCH 3/7] Implement proper detection with Podman support and enhance error handling across platforms Signed-off-by: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> --- pkg/commands/docker.go | 3 +- pkg/commands/socket_detection_common.go | 51 ++++++++-- pkg/commands/socket_detection_test.go | 92 +++++++++++++++++-- pkg/commands/socket_detection_unix.go | 27 ++++-- pkg/commands/socket_detection_windows.go | 57 +++++++++--- pkg/commands/socket_detection_windows_test.go | 62 +++++++++++++ 6 files changed, 254 insertions(+), 38 deletions(-) create mode 100644 pkg/commands/socket_detection_windows_test.go 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) +} From 81e4e443091b4035162ecd89334062f0f76f3f50 Mon Sep 17 00:00:00 2001 From: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:56:14 +0530 Subject: [PATCH 4/7] Enhance Podman support with improved socket detection and error handling across platforms Signed-off-by: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> --- pkg/commands/socket_detection_common.go | 36 +- pkg/commands/socket_detection_test.go | 403 ++++++++++++++---- pkg/commands/socket_detection_unix.go | 13 +- pkg/commands/socket_detection_windows.go | 7 +- pkg/commands/socket_detection_windows_test.go | 40 ++ 5 files changed, 408 insertions(+), 91 deletions(-) diff --git a/pkg/commands/socket_detection_common.go b/pkg/commands/socket_detection_common.go index ba84af81..9b885b3e 100644 --- a/pkg/commands/socket_detection_common.go +++ b/pkg/commands/socket_detection_common.go @@ -23,7 +23,13 @@ var ( const socketValidationTimeout = 3 * time.Second var ( - validateSocketFunc = validateSocket + validateSocketFunc = validateSocket + getHostFromContextFunc = getHostFromContext + detectPlatformCandidatesFunc = detectPlatformCandidates + + // For testing getHostFromContext + cliconfigLoadFunc = cliconfig.Load + ctxstoreNewFunc = ctxstore.New ) // Runtime type detection @@ -67,7 +73,7 @@ func ResetDockerHostCache() { dockerHostMu.Lock() defer dockerHostMu.Unlock() cachedDockerHost = "" - cachedRuntime = "" + cachedRuntime = RuntimeUnknown } func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, error) { @@ -78,8 +84,8 @@ func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, erro // 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 + log.Debugf("DOCKER_HOST is a plain path, assuming %s", DockerSocketSchema) + dockerHost = DockerSocketSchema + dockerHost } } @@ -87,14 +93,14 @@ func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, erro ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) defer cancel() if err := validateSocketFunc(ctx, dockerHost, true); err != nil { - log.Warnf("DOCKER_HOST=%s is set but not accessible: %v", dockerHost, err) + return "", RuntimeUnknown, fmt.Errorf("DOCKER_HOST=%s is set but not accessible: %w", dockerHost, err) } } - return dockerHost, RuntimeUnknown, nil + return dockerHost, RuntimeDocker, nil } // Priority 2: Docker Context - contextHost, err := getHostFromContext() + contextHost, err := getHostFromContextFunc() if err != nil { // If DOCKER_CONTEXT was explicitly set, we should fail if os.Getenv("DOCKER_CONTEXT") != "" { @@ -103,25 +109,33 @@ func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, erro log.Debugf("Failed to get host from default context: %v", err) } else if contextHost != "" { log.Debugf("Using host from Docker context: %s", contextHost) + isValid := true if !strings.HasPrefix(contextHost, "ssh://") { ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) defer cancel() if err := validateSocketFunc(ctx, contextHost, false); err != nil { + if os.Getenv("DOCKER_CONTEXT") != "" { + return "", RuntimeUnknown, fmt.Errorf("DOCKER_CONTEXT host %s is not accessible: %w", contextHost, err) + } log.Warnf("Context host %s is not accessible: %v", contextHost, err) + isValid = false } } - return contextHost, RuntimeUnknown, nil + + if isValid { + return contextHost, RuntimeDocker, nil + } } // Priority 3: Platform-specific candidates - return detectPlatformCandidates(log) + return detectPlatformCandidatesFunc(log) } // getHostFromContext retrieves the host from the current Docker context func getHostFromContext() (string, error) { currentContext := os.Getenv("DOCKER_CONTEXT") if currentContext == "" { - cf, err := cliconfig.Load(cliconfig.Dir()) + cf, err := cliconfigLoadFunc(cliconfig.Dir()) if err != nil { return "", err } @@ -137,7 +151,7 @@ func getHostFromContext() (string, error) { ctxstore.EndpointTypeGetter(ddocker.DockerEndpoint, func() interface{} { return &ddocker.EndpointMeta{} }), ) - st := ctxstore.New(cliconfig.ContextStoreDir(), storeConfig) + st := ctxstoreNewFunc(cliconfig.ContextStoreDir(), storeConfig) md, err := st.GetMetadata(currentContext) if err != nil { return "", err diff --git a/pkg/commands/socket_detection_test.go b/pkg/commands/socket_detection_test.go index 4818eb2a..cc750aa0 100644 --- a/pkg/commands/socket_detection_test.go +++ b/pkg/commands/socket_detection_test.go @@ -8,38 +8,52 @@ import ( "os" "testing" + "github.com/docker/cli/cli/config/configfile" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) func TestGetSocketCandidates(t *testing.T) { - // Save env vars - oldXdg := os.Getenv("XDG_RUNTIME_DIR") - oldHome := os.Getenv("HOME") + // Save and restore + oldGetuid := getuidFunc + oldGetenv := getenvFunc + oldUserHomeDir := userHomeDirFunc defer func() { - os.Setenv("XDG_RUNTIME_DIR", oldXdg) - os.Setenv("HOME", oldHome) + getuidFunc = oldGetuid + getenvFunc = oldGetenv + userHomeDirFunc = oldUserHomeDir }() - os.Setenv("XDG_RUNTIME_DIR", "/tmp/runtime") - os.Setenv("HOME", "/home/user") + getuidFunc = func() int { return 1000 } + getenvFunc = func(key string) string { + if key == "XDG_RUNTIME_DIR" { + return "/run/user/1000" + } + return "" + } + userHomeDirFunc = func() (string, error) { return "/home/user", nil } candidates := getSocketCandidates() - // Check some expected candidates - foundDocker := false - foundPodman := false - for _, c := range candidates { - if c.Path == "unix:///var/run/docker.sock" { - foundDocker = true - } - if c.Path == "unix:///tmp/runtime/podman/podman.sock" { - foundPodman = true - } + expectedPaths := []string{ + "unix:///var/run/docker.sock", + "unix:///run/user/1000/docker.sock", + "unix:///home/user/.docker/run/docker.sock", + "unix:///run/user/1000/podman/podman.sock", + "unix:///home/user/.colima/default/docker.sock", + "unix:///home/user/.orbstack/run/docker.sock", + "unix:///home/user/.lima/default/sock/docker.sock", + "unix:///home/user/.rd/docker.sock", } - assert.True(t, foundDocker, "Standard Docker socket should be in candidates") - assert.True(t, foundPodman, "Rootless Podman socket should be in candidates") + var paths []string + for _, c := range candidates { + paths = append(paths, c.Path) + } + + for _, expected := range expectedPaths { + assert.Contains(t, paths, expected) + } } func TestDetectDockerHost_DOCKER_HOST_Priority(t *testing.T) { @@ -50,6 +64,13 @@ func TestDetectDockerHost_DOCKER_HOST_Priority(t *testing.T) { expectedHost := "unix:///tmp/custom.sock" os.Setenv("DOCKER_HOST", expectedHost) + // Mock validateSocketFunc to succeed + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return nil + } + // Reset cache for test ResetDockerHostCache() @@ -58,6 +79,7 @@ func TestDetectDockerHost_DOCKER_HOST_Priority(t *testing.T) { assert.NoError(t, err) assert.Equal(t, expectedHost, host) } + func TestDetectDockerHost_Caching(t *testing.T) { // Save env var oldDockerHost := os.Getenv("DOCKER_HOST") @@ -65,6 +87,13 @@ func TestDetectDockerHost_Caching(t *testing.T) { os.Setenv("DOCKER_HOST", "unix:///tmp/first.sock") + // Mock validateSocketFunc to succeed + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return nil + } + // Reset cache for test ResetDockerHostCache() @@ -78,6 +107,89 @@ func TestDetectDockerHost_Caching(t *testing.T) { assert.Equal(t, host1, host2) assert.Equal(t, "unix:///tmp/first.sock", host2) } + +func TestDetectDockerHost_DOCKER_HOST_Invalid(t *testing.T) { + oldDockerHost := os.Getenv("DOCKER_HOST") + os.Setenv("DOCKER_HOST", "unix:///tmp/invalid.sock") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + ResetDockerHostCache() + + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return errors.New("invalid") + } + + log := logrus.NewEntry(logrus.New()) + _, _, err := DetectDockerHost(log) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "DOCKER_HOST") + } +} + +func TestDetectDockerHost_Context_Success(t *testing.T) { + oldDockerHost := os.Getenv("DOCKER_HOST") + os.Setenv("DOCKER_HOST", "") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + oldGetHost := getHostFromContextFunc + oldValidate := validateSocketFunc + defer func() { + getHostFromContextFunc = oldGetHost + validateSocketFunc = oldValidate + }() + + getHostFromContextFunc = func() (string, error) { + return "unix:///tmp/context.sock", nil + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + if host == "unix:///tmp/context.sock" { + return nil + } + return errors.New("invalid") + } + + ResetDockerHostCache() + + log := logrus.NewEntry(logrus.New()) + host, _, err := DetectDockerHost(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///tmp/context.sock", host) +} + +func TestDetectDockerHost_Context_Invalid_Fallback(t *testing.T) { + oldDockerHost := os.Getenv("DOCKER_HOST") + os.Setenv("DOCKER_HOST", "") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + oldGetHost := getHostFromContextFunc + oldValidate := validateSocketFunc + oldDetectPlatform := detectPlatformCandidatesFunc + defer func() { + getHostFromContextFunc = oldGetHost + validateSocketFunc = oldValidate + detectPlatformCandidatesFunc = oldDetectPlatform + }() + + getHostFromContextFunc = func() (string, error) { + return "unix:///tmp/invalid-context.sock", nil + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return errors.New("invalid") + } + detectPlatformCandidatesFunc = func(log *logrus.Entry) (string, ContainerRuntime, error) { + return "unix:///tmp/fallback.sock", RuntimeDocker, nil + } + + ResetDockerHostCache() + + log := logrus.NewEntry(logrus.New()) + host, _, err := DetectDockerHost(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///tmp/fallback.sock", host) +} + func TestDetectDockerHost_Context_Invalid(t *testing.T) { // Save env vars oldDockerHost := os.Getenv("DOCKER_HOST") @@ -99,15 +211,123 @@ func TestDetectDockerHost_Context_Invalid(t *testing.T) { 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 TestDetectDockerHost_Context_Error_NoEnv(t *testing.T) { + // Save and restore + oldGetHost := getHostFromContextFunc + oldDetectPlatform := detectPlatformCandidatesFunc + defer func() { + getHostFromContextFunc = oldGetHost + detectPlatformCandidatesFunc = oldDetectPlatform + }() + + // Mock getHostFromContext to return error + getHostFromContextFunc = func() (string, error) { + return "", errors.New("context error") } + + // Mock detectPlatformCandidates to return success so we can see it falls through + detectPlatformCandidatesFunc = func(log *logrus.Entry) (string, ContainerRuntime, error) { + return "unix:///tmp/fallback.sock", RuntimeDocker, nil + } + + // Clear DOCKER_HOST + oldDockerHost := os.Getenv("DOCKER_HOST") + os.Setenv("DOCKER_HOST", "") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + ResetDockerHostCache() + + log := logrus.NewEntry(logrus.New()) + host, runtime, err := DetectDockerHost(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///tmp/fallback.sock", host) + assert.Equal(t, RuntimeDocker, runtime) +} + +func TestDetectDockerHost_SSH(t *testing.T) { + oldDockerHost := os.Getenv("DOCKER_HOST") + os.Setenv("DOCKER_HOST", "ssh://user@host") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + ResetDockerHostCache() + + log := logrus.NewEntry(logrus.New()) + host, _, err := DetectDockerHost(log) + assert.NoError(t, err) + assert.Equal(t, "ssh://user@host", host) +} + +func TestDetectDockerHost_PlainPath(t *testing.T) { + oldDockerHost := os.Getenv("DOCKER_HOST") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + // Create a temporary file to act as a socket + tmpFile, err := os.CreateTemp("", "lazydocker-test-*.sock") + assert.NoError(t, err) + defer os.Remove(tmpFile.Name()) + tmpFile.Close() + + os.Setenv("DOCKER_HOST", tmpFile.Name()) + + ResetDockerHostCache() + + // Mock validateSocketFunc to succeed + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return nil + } + + log := logrus.NewEntry(logrus.New()) + host, _, err := DetectDockerHost(log) + assert.NoError(t, err) + assert.Equal(t, "unix://"+tmpFile.Name(), host) +} + +func TestValidateSocket_UseEnv(t *testing.T) { + ctx := context.Background() + oldDockerHost := os.Getenv("DOCKER_HOST") + os.Setenv("DOCKER_HOST", "unix:///tmp/test.sock") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + // This will fail ping but cover the useEnv branch + err := validateSocket(ctx, "unix:///tmp/test.sock", true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ping failed") +} + +func TestGetHostFromContext_Implementation(t *testing.T) { + // Save and restore + oldLoad := cliconfigLoadFunc + defer func() { cliconfigLoadFunc = oldLoad }() + + t.Run("DOCKER_CONTEXT set", func(t *testing.T) { + oldCtx := os.Getenv("DOCKER_CONTEXT") + os.Setenv("DOCKER_CONTEXT", "default") + defer os.Setenv("DOCKER_CONTEXT", oldCtx) + + host, err := getHostFromContext() + assert.NoError(t, err) + assert.Empty(t, host) + }) + + t.Run("Config load success but empty context", func(t *testing.T) { + cliconfigLoadFunc = func(dir string) (*configfile.ConfigFile, error) { + return &configfile.ConfigFile{CurrentContext: ""}, nil + } + host, err := getHostFromContext() + assert.NoError(t, err) + assert.Empty(t, host) + }) + + t.Run("Config load error", func(t *testing.T) { + cliconfigLoadFunc = func(dir string) (*configfile.ConfigFile, error) { + return nil, errors.New("load error") + } + _, err := getHostFromContext() + assert.Error(t, err) + assert.Contains(t, err.Error(), "load error") + }) } func TestValidateSocket_Failures(t *testing.T) { @@ -123,58 +343,95 @@ func TestValidateSocket_Failures(t *testing.T) { } 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 + // Save and restore oldStat := statFunc + oldValidate := validateSocketFunc defer func() { - validateSocketFunc = oldValidate statFunc = oldStat + validateSocketFunc = oldValidate }() - 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 - } + log := logrus.New().WithField("test", "test") - validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { - if host == expectedPath { - return nil + t.Run("No sockets exist", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist } - return errors.New("mock failure") - } + _, _, err := detectPlatformCandidates(log) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ensure Docker or Podman is running") + }) - log := logrus.NewEntry(logrus.New()) - host, runtime, err := detectPlatformCandidates(log) - assert.NoError(t, err) - assert.Equal(t, expectedPath, host) - assert.Equal(t, RuntimeDocker, runtime) + t.Run("Docker socket exists and is valid", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + if name == "/var/run/docker.sock" { + return nil, nil + } + return nil, os.ErrNotExist + } + validateSocketFunc = func(ctx context.Context, host string, silent bool) error { + if host == "unix:///var/run/docker.sock" { + return nil + } + return errors.New("invalid") + } + path, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///var/run/docker.sock", path) + assert.Equal(t, RuntimeDocker, runtime) + }) + + t.Run("Docker socket exists but permission denied", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + if name == "/var/run/docker.sock" { + return nil, nil + } + return nil, os.ErrNotExist + } + validateSocketFunc = func(ctx context.Context, host string, silent bool) error { + return errors.New("permission denied") + } + _, _, err := detectPlatformCandidates(log) + assert.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") + }) + + t.Run("Docker socket exists but other error", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + if name == "/var/run/docker.sock" { + return nil, nil + } + return nil, os.ErrNotExist + } + validateSocketFunc = func(ctx context.Context, host string, silent bool) error { + return errors.New("other error") + } + _, _, err := detectPlatformCandidates(log) + assert.Error(t, err) + assert.Contains(t, err.Error(), "other error") + }) + + t.Run("Podman socket exists and is valid", func(t *testing.T) { + // Mock getuid to return 1000 + oldGetuid := getuidFunc + getuidFunc = func() int { return 1000 } + defer func() { getuidFunc = oldGetuid }() + + statFunc = func(name string) (os.FileInfo, error) { + if name == "/run/user/1000/podman/podman.sock" { + return nil, nil + } + return nil, os.ErrNotExist + } + validateSocketFunc = func(ctx context.Context, host string, silent bool) error { + if host == "unix:///run/user/1000/podman/podman.sock" { + return nil + } + return errors.New("invalid") + } + path, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///run/user/1000/podman/podman.sock", path) + assert.Equal(t, RuntimePodman, runtime) + }) } diff --git a/pkg/commands/socket_detection_unix.go b/pkg/commands/socket_detection_unix.go index a4b629e0..5bbc2fd7 100644 --- a/pkg/commands/socket_detection_unix.go +++ b/pkg/commands/socket_detection_unix.go @@ -15,7 +15,10 @@ import ( ) var ( - statFunc = os.Stat + statFunc = os.Stat + getuidFunc = os.Getuid + getenvFunc = os.Getenv + userHomeDirFunc = os.UserHomeDir ) const ( @@ -48,9 +51,9 @@ func getSocketCandidates() []SocketCandidate { // 1. Standard Docker daemon socket addCandidate(DockerSocketPath, RuntimeDocker) - xdgRuntime := os.Getenv("XDG_RUNTIME_DIR") - home, _ := os.UserHomeDir() - uid := os.Getuid() + xdgRuntime := getenvFunc("XDG_RUNTIME_DIR") + home, _ := userHomeDirFunc() + uid := getuidFunc() // 2. Rootless Docker: $XDG_RUNTIME_DIR/docker.sock if xdgRuntime != "" { @@ -139,7 +142,7 @@ func detectPlatformCandidates(log *logrus.Entry) (string, ContainerRuntime, erro log.Debugf("Socket %s exists but validation failed: %v", candidate.Path, err) 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) + lastErr = fmt.Errorf("%s: permission denied (are you in the %v group?)", candidate.Path, candidate.Runtime) } else { lastErr = fmt.Errorf("%s: %v", candidate.Path, err) } diff --git a/pkg/commands/socket_detection_windows.go b/pkg/commands/socket_detection_windows.go index 36badfca..fe681e04 100644 --- a/pkg/commands/socket_detection_windows.go +++ b/pkg/commands/socket_detection_windows.go @@ -18,15 +18,17 @@ const ( defaultDockerHost = DockerSocketSchema + DockerSocketPath ) -func getPodmanPipes() []string { +func getPodmanPipes(log *logrus.Entry) []string { home, err := os.UserHomeDir() if err != nil { + log.Debugf("Failed to get user home directory: %v", err) return []string{"npipe:////./pipe/podman-machine-default"} } configDir := filepath.Join(home, ".config", "containers", "podman", "machine", "wsl") files, err := os.ReadDir(configDir) if err != nil { + log.Debugf("Failed to read Podman machine config directory %s: %v", configDir, err) return []string{"npipe:////./pipe/podman-machine-default"} } @@ -39,6 +41,7 @@ func getPodmanPipes() []string { } if len(pipes) == 0 { + log.Debug("No Podman machine config files found, falling back to default") return []string{"npipe:////./pipe/podman-machine-default"} } return pipes @@ -58,7 +61,7 @@ func detectPlatformCandidates(log *logrus.Entry) (string, ContainerRuntime, erro } // Try Podman machines on Windows - for _, podmanHost := range getPodmanPipes() { + for _, podmanHost := range getPodmanPipes(log) { err = func() error { ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) defer cancel() diff --git a/pkg/commands/socket_detection_windows_test.go b/pkg/commands/socket_detection_windows_test.go index 2efd211b..fd4a8644 100644 --- a/pkg/commands/socket_detection_windows_test.go +++ b/pkg/commands/socket_detection_windows_test.go @@ -5,6 +5,8 @@ package commands import ( "context" "errors" + "os" + "path/filepath" "testing" "github.com/sirupsen/logrus" @@ -60,3 +62,41 @@ func TestDetectPlatformCandidates_Windows_Failure(t *testing.T) { assert.Error(t, err) assert.Equal(t, ErrNoDockerSocket, err) } + +func TestGetPodmanPipes(t *testing.T) { + log := logrus.NewEntry(logrus.New()) + + // Test fallback when home dir is missing or empty + // We can't easily mock UserHomeDir without refactoring, but we can test the logic + // by creating the expected directory structure in a temp dir and setting USERPROFILE. + + tmpDir, err := os.MkdirTemp("", "podman-test") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Set USERPROFILE to our temp dir + oldProfile := os.Getenv("USERPROFILE") + os.Setenv("USERPROFILE", tmpDir) + defer os.Setenv("USERPROFILE", oldProfile) + + // 1. Test fallback when directory doesn't exist + pipes := getPodmanPipes(log) + assert.Equal(t, []string{"npipe:////./pipe/podman-machine-default"}, pipes) + + // 2. Test with actual config files + configDir := filepath.Join(tmpDir, ".config", "containers", "podman", "machine", "wsl") + err = os.MkdirAll(configDir, 0755) + assert.NoError(t, err) + + err = os.WriteFile(filepath.Join(configDir, "machine1.json"), []byte("{}"), 0644) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(configDir, "machine2.json"), []byte("{}"), 0644) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(configDir, "not-a-config.txt"), []byte("{}"), 0644) + assert.NoError(t, err) + + pipes = getPodmanPipes(log) + assert.Len(t, pipes, 2) + assert.Contains(t, pipes, "npipe:////./pipe/machine1") + assert.Contains(t, pipes, "npipe:////./pipe/machine2") +} From 22b68c552e82f6eb4d9a631feffefb6972d9a071 Mon Sep 17 00:00:00 2001 From: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> Date: Mon, 29 Dec 2025 12:33:33 +0530 Subject: [PATCH 5/7] Implement proper detection and support for Podman, enhancing socket validation and error handling Signed-off-by: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> --- pkg/commands/socket_detection_common.go | 71 +++++++++++++++++++++++-- pkg/commands/socket_detection_test.go | 27 ++++++++-- pkg/commands/socket_detection_unix.go | 4 +- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/pkg/commands/socket_detection_common.go b/pkg/commands/socket_detection_common.go index 9b885b3e..c1993e78 100644 --- a/pkg/commands/socket_detection_common.go +++ b/pkg/commands/socket_detection_common.go @@ -24,6 +24,7 @@ const socketValidationTimeout = 3 * time.Second var ( validateSocketFunc = validateSocket + inferRuntimeFromHostFunc = inferRuntimeFromHost getHostFromContextFunc = getHostFromContext detectPlatformCandidatesFunc = detectPlatformCandidates @@ -68,7 +69,10 @@ func DetectDockerHost(log *logrus.Entry) (string, ContainerRuntime, error) { return host, runtime, nil } -// ResetDockerHostCache resets the cached docker host. Used for testing. +// ResetDockerHostCache resets the cached docker host. +// +// This is serialized with DetectDockerHost via dockerHostMu. +// Primarily used for testing. func ResetDockerHostCache() { dockerHostMu.Lock() defer dockerHostMu.Unlock() @@ -76,6 +80,14 @@ func ResetDockerHostCache() { cachedRuntime = RuntimeUnknown } +func inferRuntimeFromHostHeuristic(host string) ContainerRuntime { + lowerHost := strings.ToLower(host) + if strings.Contains(lowerHost, "podman") { + return RuntimePodman + } + return RuntimeDocker +} + func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, error) { // Priority 1: Explicit DOCKER_HOST environment variable if dockerHost := os.Getenv("DOCKER_HOST"); dockerHost != "" { @@ -95,8 +107,17 @@ func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, erro if err := validateSocketFunc(ctx, dockerHost, true); err != nil { return "", RuntimeUnknown, fmt.Errorf("DOCKER_HOST=%s is set but not accessible: %w", dockerHost, err) } + + runtime, err := inferRuntimeFromHostFunc(ctx, dockerHost, true) + if err != nil { + log.Debugf("Failed to infer runtime for DOCKER_HOST=%s: %v", dockerHost, err) + runtime = inferRuntimeFromHostHeuristic(dockerHost) + } + return dockerHost, runtime, nil } - return dockerHost, RuntimeDocker, nil + + // SSH hosts can point to either Docker or Podman; we don't attempt runtime inference here. + return dockerHost, RuntimeUnknown, nil } // Priority 2: Docker Context @@ -123,7 +144,14 @@ func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, erro } if isValid { - return contextHost, RuntimeDocker, nil + ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) + defer cancel() + runtime, err := inferRuntimeFromHostFunc(ctx, contextHost, false) + if err != nil { + log.Debugf("Failed to infer runtime for Docker context host %s: %v", contextHost, err) + runtime = inferRuntimeFromHostHeuristic(contextHost) + } + return contextHost, runtime, nil } } @@ -190,3 +218,40 @@ func validateSocket(ctx context.Context, host string, useEnv bool) error { return nil } + +// inferRuntimeFromHost inspects the engine behind the host to distinguish Docker vs Podman. +// +// It uses the Docker-compatible API. Podman supports this and typically reports itself +// via version metadata. +func inferRuntimeFromHost(ctx context.Context, host string, useEnv bool) (ContainerRuntime, error) { + var opts []client.Opt + if useEnv { + // If we're validating/inferencing the host from the environment, use FromEnv to pick up TLS settings + opts = append(opts, client.FromEnv) + } + opts = append(opts, client.WithHost(host), client.WithAPIVersionNegotiation()) + + cli, err := client.NewClientWithOpts(opts...) + if err != nil { + return RuntimeUnknown, fmt.Errorf("create client: %w", err) + } + defer cli.Close() + + v, err := cli.ServerVersion(ctx) + if err != nil { + return RuntimeUnknown, fmt.Errorf("server version: %w", err) + } + + // Heuristics: Podman typically identifies itself in platform name or components. + needle := "podman" + if strings.Contains(strings.ToLower(v.Platform.Name), needle) { + return RuntimePodman, nil + } + for _, c := range v.Components { + if strings.Contains(strings.ToLower(c.Name), needle) { + return RuntimePodman, nil + } + } + + return RuntimeDocker, nil +} diff --git a/pkg/commands/socket_detection_test.go b/pkg/commands/socket_detection_test.go index cc750aa0..e45d2499 100644 --- a/pkg/commands/socket_detection_test.go +++ b/pkg/commands/socket_detection_test.go @@ -66,18 +66,24 @@ func TestDetectDockerHost_DOCKER_HOST_Priority(t *testing.T) { // Mock validateSocketFunc to succeed oldValidate := validateSocketFunc + oldInfer := inferRuntimeFromHostFunc defer func() { validateSocketFunc = oldValidate }() + defer func() { inferRuntimeFromHostFunc = oldInfer }() validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { return nil } + inferRuntimeFromHostFunc = func(ctx context.Context, host string, useEnv bool) (ContainerRuntime, error) { + return RuntimePodman, nil + } // Reset cache for test ResetDockerHostCache() log := logrus.NewEntry(logrus.New()) - host, _, err := DetectDockerHost(log) + host, runtime, err := DetectDockerHost(log) assert.NoError(t, err) assert.Equal(t, expectedHost, host) + assert.Equal(t, RuntimePodman, runtime) } func TestDetectDockerHost_Caching(t *testing.T) { @@ -89,23 +95,30 @@ func TestDetectDockerHost_Caching(t *testing.T) { // Mock validateSocketFunc to succeed oldValidate := validateSocketFunc + oldInfer := inferRuntimeFromHostFunc defer func() { validateSocketFunc = oldValidate }() + defer func() { inferRuntimeFromHostFunc = oldInfer }() validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { return nil } + inferRuntimeFromHostFunc = func(ctx context.Context, host string, useEnv bool) (ContainerRuntime, error) { + return RuntimePodman, nil + } // Reset cache for test ResetDockerHostCache() log := logrus.NewEntry(logrus.New()) - host1, _, _ := DetectDockerHost(log) + host1, runtime1, _ := DetectDockerHost(log) // Change env var - should still return first one from cache os.Setenv("DOCKER_HOST", "unix:///tmp/second.sock") - host2, _, _ := DetectDockerHost(log) + host2, runtime2, _ := DetectDockerHost(log) assert.Equal(t, host1, host2) assert.Equal(t, "unix:///tmp/first.sock", host2) + assert.Equal(t, runtime1, runtime2) + assert.Equal(t, RuntimePodman, runtime2) } func TestDetectDockerHost_DOCKER_HOST_Invalid(t *testing.T) { @@ -135,9 +148,11 @@ func TestDetectDockerHost_Context_Success(t *testing.T) { oldGetHost := getHostFromContextFunc oldValidate := validateSocketFunc + oldInfer := inferRuntimeFromHostFunc defer func() { getHostFromContextFunc = oldGetHost validateSocketFunc = oldValidate + inferRuntimeFromHostFunc = oldInfer }() getHostFromContextFunc = func() (string, error) { @@ -149,13 +164,17 @@ func TestDetectDockerHost_Context_Success(t *testing.T) { } return errors.New("invalid") } + inferRuntimeFromHostFunc = func(ctx context.Context, host string, useEnv bool) (ContainerRuntime, error) { + return RuntimePodman, nil + } ResetDockerHostCache() log := logrus.NewEntry(logrus.New()) - host, _, err := DetectDockerHost(log) + host, runtime, err := DetectDockerHost(log) assert.NoError(t, err) assert.Equal(t, "unix:///tmp/context.sock", host) + assert.Equal(t, RuntimePodman, runtime) } func TestDetectDockerHost_Context_Invalid_Fallback(t *testing.T) { diff --git a/pkg/commands/socket_detection_unix.go b/pkg/commands/socket_detection_unix.go index 5bbc2fd7..75a54925 100644 --- a/pkg/commands/socket_detection_unix.go +++ b/pkg/commands/socket_detection_unix.go @@ -132,8 +132,8 @@ func detectPlatformCandidates(log *logrus.Entry) (string, ContainerRuntime, erro } // Validate by actually connecting - ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) err := func() error { + ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) defer cancel() return validateSocketFunc(ctx, candidate.Path, false) }() @@ -142,7 +142,7 @@ func detectPlatformCandidates(log *logrus.Entry) (string, ContainerRuntime, erro log.Debugf("Socket %s exists but validation failed: %v", candidate.Path, err) 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 %v group?)", candidate.Path, candidate.Runtime) + lastErr = fmt.Errorf("%s: permission denied (check your user permissions for this socket)", candidate.Path) } else { lastErr = fmt.Errorf("%s: %v", candidate.Path, err) } From 886b79377558387dc3371414c8905278aea690ad Mon Sep 17 00:00:00 2001 From: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> Date: Tue, 30 Dec 2025 23:35:09 +0530 Subject: [PATCH 6/7] Enhance Podman support with improved socket detection and error messaging Signed-off-by: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> --- pkg/commands/socket_detection_common.go | 31 ++- pkg/commands/socket_detection_test.go | 335 +++++++++++++++++------- pkg/commands/socket_detection_unix.go | 10 +- 3 files changed, 267 insertions(+), 109 deletions(-) diff --git a/pkg/commands/socket_detection_common.go b/pkg/commands/socket_detection_common.go index c1993e78..15af7c23 100644 --- a/pkg/commands/socket_detection_common.go +++ b/pkg/commands/socket_detection_common.go @@ -30,9 +30,15 @@ var ( // For testing getHostFromContext cliconfigLoadFunc = cliconfig.Load - ctxstoreNewFunc = ctxstore.New + ctxstoreNewFunc = func(dir string, config ctxstore.Config) storeInterface { + return ctxstore.New(dir, config) + } ) +type storeInterface interface { + GetMetadata(name string) (ctxstore.Metadata, error) +} + // Runtime type detection type ContainerRuntime string @@ -130,7 +136,6 @@ func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, erro log.Debugf("Failed to get host from default context: %v", err) } else if contextHost != "" { log.Debugf("Using host from Docker context: %s", contextHost) - isValid := true if !strings.HasPrefix(contextHost, "ssh://") { ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) defer cancel() @@ -139,19 +144,17 @@ func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, erro return "", RuntimeUnknown, fmt.Errorf("DOCKER_CONTEXT host %s is not accessible: %w", contextHost, err) } log.Warnf("Context host %s is not accessible: %v", contextHost, err) - isValid = false + } else { + runtime, err := inferRuntimeFromHostFunc(ctx, contextHost, false) + if err != nil { + log.Debugf("Failed to infer runtime for Docker context host %s: %v", contextHost, err) + runtime = inferRuntimeFromHostHeuristic(contextHost) + } + return contextHost, runtime, nil } - } - - if isValid { - ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) - defer cancel() - runtime, err := inferRuntimeFromHostFunc(ctx, contextHost, false) - if err != nil { - log.Debugf("Failed to infer runtime for Docker context host %s: %v", contextHost, err) - runtime = inferRuntimeFromHostHeuristic(contextHost) - } - return contextHost, runtime, nil + } else { + // SSH hosts can point to either Docker or Podman; we don't attempt runtime inference here. + return contextHost, RuntimeUnknown, nil } } diff --git a/pkg/commands/socket_detection_test.go b/pkg/commands/socket_detection_test.go index e45d2499..c229075b 100644 --- a/pkg/commands/socket_detection_test.go +++ b/pkg/commands/socket_detection_test.go @@ -4,11 +4,17 @@ package commands import ( "context" + "encoding/json" "errors" + "net/http" + "net/http/httptest" "os" "testing" "github.com/docker/cli/cli/config/configfile" + ddocker "github.com/docker/cli/cli/context/docker" + ctxstore "github.com/docker/cli/cli/context/store" + "github.com/docker/docker/api/types" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) @@ -303,6 +309,241 @@ func TestDetectDockerHost_PlainPath(t *testing.T) { assert.Equal(t, "unix://"+tmpFile.Name(), host) } +func TestInferRuntimeFromHost(t *testing.T) { + tests := []struct { + name string + versionResponse types.Version + expectedRuntime ContainerRuntime + }{ + { + name: "Docker", + versionResponse: types.Version{ + Platform: struct{ Name string }{Name: "Docker Engine - Community"}, + Components: []types.ComponentVersion{ + {Name: "Engine", Version: "20.10.7"}, + }, + }, + expectedRuntime: RuntimeDocker, + }, + { + name: "Podman via Platform", + versionResponse: types.Version{ + Platform: struct{ Name string }{Name: "Podman Engine"}, + }, + expectedRuntime: RuntimePodman, + }, + { + name: "Podman via Components", + versionResponse: types.Version{ + Components: []types.ComponentVersion{ + {Name: "Podman Engine", Version: "3.2.3"}, + }, + }, + expectedRuntime: RuntimePodman, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(tt.versionResponse) + })) + defer server.Close() + + runtime, err := inferRuntimeFromHost(context.Background(), server.URL, false) + assert.NoError(t, err) + assert.Equal(t, tt.expectedRuntime, runtime) + }) + } +} + +func TestValidateSocket(t *testing.T) { + t.Run("Success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err := validateSocket(context.Background(), server.URL, false) + assert.NoError(t, err) + }) + + t.Run("Failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + err := validateSocket(context.Background(), server.URL, false) + assert.Error(t, err) + }) +} + +func TestGetHostFromContext(t *testing.T) { + oldLoad := cliconfigLoadFunc + oldNew := ctxstoreNewFunc + defer func() { + cliconfigLoadFunc = oldLoad + ctxstoreNewFunc = oldNew + }() + + t.Run("Default context", func(t *testing.T) { + cliconfigLoadFunc = func(dir string) (*configfile.ConfigFile, error) { + return &configfile.ConfigFile{CurrentContext: "default"}, nil + } + host, err := getHostFromContext() + assert.NoError(t, err) + assert.Equal(t, "", host) + }) + + t.Run("Custom context", func(t *testing.T) { + cliconfigLoadFunc = func(dir string) (*configfile.ConfigFile, error) { + return &configfile.ConfigFile{CurrentContext: "my-context"}, nil + } + ctxstoreNewFunc = func(dir string, config ctxstore.Config) storeInterface { + return &mockStore{ + metadata: ctxstore.Metadata{ + Endpoints: map[string]interface{}{ + ddocker.DockerEndpoint: ddocker.EndpointMeta{ + Host: "unix:///tmp/my-context.sock", + }, + }, + }, + } + } + host, err := getHostFromContext() + assert.NoError(t, err) + assert.Equal(t, "unix:///tmp/my-context.sock", host) + }) +} + +type mockStore struct { + metadata ctxstore.Metadata +} + +func (m *mockStore) GetMetadata(name string) (ctxstore.Metadata, error) { + return m.metadata, nil +} + +func TestDetectPlatformCandidates_Unix(t *testing.T) { + // Save and restore + oldStat := statFunc + oldValidate := validateSocketFunc + defer func() { + statFunc = oldStat + validateSocketFunc = oldValidate + }() + + log := logrus.New().WithField("test", "test") + + t.Run("No sockets exist", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + _, _, err := detectPlatformCandidates(log) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no Docker or Podman socket found") + assert.Contains(t, err.Error(), "systemctl --user enable --now podman.socket") + }) + + t.Run("Docker socket exists and is valid", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + if name == "/var/run/docker.sock" { + return nil, nil + } + return nil, os.ErrNotExist + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + if host == "unix:///var/run/docker.sock" { + return nil + } + return errors.New("invalid") + } + host, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///var/run/docker.sock", host) + assert.Equal(t, RuntimeDocker, runtime) + }) + + t.Run("Docker socket exists but permission denied", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + if name == "/var/run/docker.sock" { + return nil, nil + } + return nil, os.ErrNotExist + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return errors.New("permission denied") + } + _, _, err := detectPlatformCandidates(log) + assert.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") + }) + + t.Run("Podman socket exists and is valid", func(t *testing.T) { + // Mock getuid to return 1000 + oldGetuid := getuidFunc + defer func() { getuidFunc = oldGetuid }() + getuidFunc = func() int { return 1000 } + + statFunc = func(name string) (os.FileInfo, error) { + if name == "/run/user/1000/podman/podman.sock" { + return nil, nil + } + return nil, os.ErrNotExist + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + if host == "unix:///run/user/1000/podman/podman.sock" { + return nil + } + return errors.New("invalid") + } + host, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///run/user/1000/podman/podman.sock", host) + assert.Equal(t, RuntimePodman, runtime) + }) +} + +func TestDetectPlatformCandidates(t *testing.T) { + oldStat := statFunc + oldValidate := validateSocketFunc + defer func() { + statFunc = oldStat + validateSocketFunc = oldValidate + }() + + log := logrus.NewEntry(logrus.New()) + + t.Run("First candidate succeeds", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + return nil, nil // exists + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return nil + } + host, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///var/run/docker.sock", host) + assert.Equal(t, RuntimeDocker, runtime) + }) + + t.Run("First fails, second succeeds", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + if name == "/var/run/docker.sock" { + return nil, os.ErrNotExist + } + return nil, nil + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return nil + } + host, _, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.NotEqual(t, "unix:///var/run/docker.sock", host) + }) +} + func TestValidateSocket_UseEnv(t *testing.T) { ctx := context.Background() oldDockerHost := os.Getenv("DOCKER_HOST") @@ -360,97 +601,3 @@ func TestValidateSocket_Failures(t *testing.T) { err = validateSocket(ctx, "invalid:///tmp/test.sock", false) assert.Error(t, err) } - -func TestDetectPlatformCandidates_Unix(t *testing.T) { - // Save and restore - oldStat := statFunc - oldValidate := validateSocketFunc - defer func() { - statFunc = oldStat - validateSocketFunc = oldValidate - }() - - log := logrus.New().WithField("test", "test") - - t.Run("No sockets exist", func(t *testing.T) { - statFunc = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - _, _, err := detectPlatformCandidates(log) - assert.Error(t, err) - assert.Contains(t, err.Error(), "ensure Docker or Podman is running") - }) - - t.Run("Docker socket exists and is valid", func(t *testing.T) { - statFunc = func(name string) (os.FileInfo, error) { - if name == "/var/run/docker.sock" { - return nil, nil - } - return nil, os.ErrNotExist - } - validateSocketFunc = func(ctx context.Context, host string, silent bool) error { - if host == "unix:///var/run/docker.sock" { - return nil - } - return errors.New("invalid") - } - path, runtime, err := detectPlatformCandidates(log) - assert.NoError(t, err) - assert.Equal(t, "unix:///var/run/docker.sock", path) - assert.Equal(t, RuntimeDocker, runtime) - }) - - t.Run("Docker socket exists but permission denied", func(t *testing.T) { - statFunc = func(name string) (os.FileInfo, error) { - if name == "/var/run/docker.sock" { - return nil, nil - } - return nil, os.ErrNotExist - } - validateSocketFunc = func(ctx context.Context, host string, silent bool) error { - return errors.New("permission denied") - } - _, _, err := detectPlatformCandidates(log) - assert.Error(t, err) - assert.Contains(t, err.Error(), "permission denied") - }) - - t.Run("Docker socket exists but other error", func(t *testing.T) { - statFunc = func(name string) (os.FileInfo, error) { - if name == "/var/run/docker.sock" { - return nil, nil - } - return nil, os.ErrNotExist - } - validateSocketFunc = func(ctx context.Context, host string, silent bool) error { - return errors.New("other error") - } - _, _, err := detectPlatformCandidates(log) - assert.Error(t, err) - assert.Contains(t, err.Error(), "other error") - }) - - t.Run("Podman socket exists and is valid", func(t *testing.T) { - // Mock getuid to return 1000 - oldGetuid := getuidFunc - getuidFunc = func() int { return 1000 } - defer func() { getuidFunc = oldGetuid }() - - statFunc = func(name string) (os.FileInfo, error) { - if name == "/run/user/1000/podman/podman.sock" { - return nil, nil - } - return nil, os.ErrNotExist - } - validateSocketFunc = func(ctx context.Context, host string, silent bool) error { - if host == "unix:///run/user/1000/podman/podman.sock" { - return nil - } - return errors.New("invalid") - } - path, runtime, err := detectPlatformCandidates(log) - assert.NoError(t, err) - assert.Equal(t, "unix:///run/user/1000/podman/podman.sock", path) - assert.Equal(t, RuntimePodman, runtime) - }) -} diff --git a/pkg/commands/socket_detection_unix.go b/pkg/commands/socket_detection_unix.go index 75a54925..17068863 100644 --- a/pkg/commands/socket_detection_unix.go +++ b/pkg/commands/socket_detection_unix.go @@ -158,6 +158,14 @@ func detectPlatformCandidates(log *logrus.Entry) (string, ContainerRuntime, erro return "", RuntimeUnknown, fmt.Errorf("%w: last error: %v", ErrNoDockerSocket, lastErr) } - msg := fmt.Sprintf("%v: ensure Docker or Podman is running", ErrNoDockerSocket) + var checkedSockets []string + for _, candidate := range candidates { + checkedSockets = append(checkedSockets, strings.TrimPrefix(candidate.Path, DockerSocketSchema)) + } + + msg := fmt.Sprintf("%v: no Docker or Podman socket found; checked %s. "+ + "If you are using Podman with systemd socket activation, try running "+ + "'systemctl --user enable --now podman.socket' and then re-run this command.", + ErrNoDockerSocket, strings.Join(checkedSockets, ", ")) return "", RuntimeUnknown, errors.New(msg) } From b2e0d44c6b87be106162217614f944791bd7ff40 Mon Sep 17 00:00:00 2001 From: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:17:50 +0530 Subject: [PATCH 7/7] Refactor socket error handling to unify Docker and Podman socket detection; enhance error messaging for better user guidance Signed-off-by: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com> --- pkg/commands/socket_detection_common.go | 35 +++++++++++++++---- pkg/commands/socket_detection_unix.go | 8 ++--- pkg/commands/socket_detection_windows.go | 2 +- pkg/commands/socket_detection_windows_test.go | 33 +++++++++++++++-- 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/pkg/commands/socket_detection_common.go b/pkg/commands/socket_detection_common.go index 15af7c23..fdfc8808 100644 --- a/pkg/commands/socket_detection_common.go +++ b/pkg/commands/socket_detection_common.go @@ -16,7 +16,11 @@ import ( ) var ( - ErrNoDockerSocket = fmt.Errorf("no working Docker/Podman socket found") + // ErrNoContainerSocket is returned when no working Docker or Podman socket is found. + ErrNoContainerSocket = fmt.Errorf("no working Docker/Podman socket found") + // ErrNoDockerSocket is an alias for ErrNoContainerSocket for backwards compatibility. + // Deprecated: Use ErrNoContainerSocket instead. + ErrNoDockerSocket = ErrNoContainerSocket ) // Timeout for validating socket connectivity @@ -94,6 +98,11 @@ func inferRuntimeFromHostHeuristic(host string) ContainerRuntime { return RuntimeDocker } +// isSSHHost returns true if the host uses the SSH protocol. +func isSSHHost(host string) bool { + return strings.HasPrefix(host, "ssh://") +} + func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, error) { // Priority 1: Explicit DOCKER_HOST environment variable if dockerHost := os.Getenv("DOCKER_HOST"); dockerHost != "" { @@ -107,11 +116,12 @@ func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, erro } } - if !strings.HasPrefix(dockerHost, "ssh://") { + if !isSSHHost(dockerHost) { ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) defer cancel() if err := validateSocketFunc(ctx, dockerHost, true); err != nil { - return "", RuntimeUnknown, fmt.Errorf("DOCKER_HOST=%s is set but not accessible: %w", dockerHost, err) + errMsg := formatConnectionError(ctx, err) + return "", RuntimeUnknown, fmt.Errorf("DOCKER_HOST=%s is set but not accessible: %s", dockerHost, errMsg) } runtime, err := inferRuntimeFromHostFunc(ctx, dockerHost, true) @@ -136,14 +146,15 @@ func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, erro log.Debugf("Failed to get host from default context: %v", err) } else if contextHost != "" { log.Debugf("Using host from Docker context: %s", contextHost) - if !strings.HasPrefix(contextHost, "ssh://") { + if !isSSHHost(contextHost) { ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) defer cancel() if err := validateSocketFunc(ctx, contextHost, false); err != nil { + errMsg := formatConnectionError(ctx, err) if os.Getenv("DOCKER_CONTEXT") != "" { - return "", RuntimeUnknown, fmt.Errorf("DOCKER_CONTEXT host %s is not accessible: %w", contextHost, err) + return "", RuntimeUnknown, fmt.Errorf("DOCKER_CONTEXT host %s is not accessible: %s", contextHost, errMsg) } - log.Warnf("Context host %s is not accessible: %v", contextHost, err) + log.Warnf("Context host %s is not accessible: %s", contextHost, errMsg) } else { runtime, err := inferRuntimeFromHostFunc(ctx, contextHost, false) if err != nil { @@ -162,6 +173,18 @@ func detectDockerHostInternal(log *logrus.Entry) (string, ContainerRuntime, erro return detectPlatformCandidatesFunc(log) } +// formatConnectionError returns a user-friendly error message, distinguishing timeout from other errors. +func formatConnectionError(ctx context.Context, err error) string { + if ctx.Err() == context.DeadlineExceeded { + return "connection timed out (is the container runtime running?)" + } + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "permission denied") || strings.Contains(errStr, "eacces") { + return "permission denied (check your user permissions for this socket)" + } + return err.Error() +} + // getHostFromContext retrieves the host from the current Docker context func getHostFromContext() (string, error) { currentContext := os.Getenv("DOCKER_CONTEXT") diff --git a/pkg/commands/socket_detection_unix.go b/pkg/commands/socket_detection_unix.go index 17068863..9ad94dc8 100644 --- a/pkg/commands/socket_detection_unix.go +++ b/pkg/commands/socket_detection_unix.go @@ -4,7 +4,6 @@ package commands import ( "context" - "errors" "fmt" "os" "path/filepath" @@ -155,7 +154,7 @@ func detectPlatformCandidates(log *logrus.Entry) (string, ContainerRuntime, erro // All candidates failed - provide actionable error if lastErr != nil { - return "", RuntimeUnknown, fmt.Errorf("%w: last error: %v", ErrNoDockerSocket, lastErr) + return "", RuntimeUnknown, fmt.Errorf("%w: %v", ErrNoContainerSocket, lastErr) } var checkedSockets []string @@ -163,9 +162,8 @@ func detectPlatformCandidates(log *logrus.Entry) (string, ContainerRuntime, erro checkedSockets = append(checkedSockets, strings.TrimPrefix(candidate.Path, DockerSocketSchema)) } - msg := fmt.Sprintf("%v: no Docker or Podman socket found; checked %s. "+ + return "", RuntimeUnknown, fmt.Errorf("%w: no Docker or Podman socket found; checked %s. "+ "If you are using Podman with systemd socket activation, try running "+ "'systemctl --user enable --now podman.socket' and then re-run this command.", - ErrNoDockerSocket, strings.Join(checkedSockets, ", ")) - return "", RuntimeUnknown, errors.New(msg) + ErrNoContainerSocket, strings.Join(checkedSockets, ", ")) } diff --git a/pkg/commands/socket_detection_windows.go b/pkg/commands/socket_detection_windows.go index fe681e04..228c233c 100644 --- a/pkg/commands/socket_detection_windows.go +++ b/pkg/commands/socket_detection_windows.go @@ -73,5 +73,5 @@ func detectPlatformCandidates(log *logrus.Entry) (string, ContainerRuntime, erro } } - return "", RuntimeUnknown, ErrNoDockerSocket + return "", RuntimeUnknown, ErrNoContainerSocket } diff --git a/pkg/commands/socket_detection_windows_test.go b/pkg/commands/socket_detection_windows_test.go index fd4a8644..bec77a15 100644 --- a/pkg/commands/socket_detection_windows_test.go +++ b/pkg/commands/socket_detection_windows_test.go @@ -60,7 +60,7 @@ func TestDetectPlatformCandidates_Windows_Failure(t *testing.T) { log := logrus.NewEntry(logrus.New()) _, _, err := detectPlatformCandidates(log) assert.Error(t, err) - assert.Equal(t, ErrNoDockerSocket, err) + assert.ErrorIs(t, err, ErrNoContainerSocket) } func TestGetPodmanPipes(t *testing.T) { @@ -72,12 +72,16 @@ func TestGetPodmanPipes(t *testing.T) { tmpDir, err := os.MkdirTemp("", "podman-test") assert.NoError(t, err) - defer os.RemoveAll(tmpDir) + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("failed to remove temp dir %s: %v", tmpDir, err) + } + }) // Set USERPROFILE to our temp dir oldProfile := os.Getenv("USERPROFILE") + t.Cleanup(func() { os.Setenv("USERPROFILE", oldProfile) }) os.Setenv("USERPROFILE", tmpDir) - defer os.Setenv("USERPROFILE", oldProfile) // 1. Test fallback when directory doesn't exist pipes := getPodmanPipes(log) @@ -99,4 +103,27 @@ func TestGetPodmanPipes(t *testing.T) { assert.Len(t, pipes, 2) assert.Contains(t, pipes, "npipe:////./pipe/machine1") assert.Contains(t, pipes, "npipe:////./pipe/machine2") + + // 3. Test with empty config directory (no JSON files) + emptyDir := filepath.Join(tmpDir, ".config", "containers", "podman", "machine", "empty-wsl") + err = os.MkdirAll(emptyDir, 0755) + assert.NoError(t, err) + + // Create a new temp dir for this specific test + tmpDir2, err := os.MkdirTemp("", "podman-test-empty") + assert.NoError(t, err) + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir2); err != nil { + t.Errorf("failed to remove temp dir %s: %v", tmpDir2, err) + } + }) + os.Setenv("USERPROFILE", tmpDir2) + + // Create the wsl directory but leave it empty + emptyConfigDir := filepath.Join(tmpDir2, ".config", "containers", "podman", "machine", "wsl") + err = os.MkdirAll(emptyConfigDir, 0755) + assert.NoError(t, err) + + pipes = getPodmanPipes(log) + assert.Equal(t, []string{"npipe:////./pipe/podman-machine-default"}, pipes) }