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] 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 +}