Implement proper detection with Podman support and enhance error handling

Signed-off-by: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com>
This commit is contained in:
DeiAsPie 2025-12-27 12:45:17 +05:30
parent f4fc3669ca
commit 12a6289bef
No known key found for this signature in database
7 changed files with 443 additions and 78 deletions

View file

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

View file

@ -1,7 +0,0 @@
//go:build !windows
package commands
const (
defaultDockerHost = "unix:///var/run/docker.sock"
)

View file

@ -1,5 +0,0 @@
package commands
const (
defaultDockerHost = "npipe:////./pipe/docker_engine"
)

View file

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

View file

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

View file

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

View file

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