lazydocker/pkg/commands/socket_detection_common.go
2025-12-31 00:17:50 +05:30

283 lines
8.7 KiB
Go

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"
)
var (
// 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
const socketValidationTimeout = 3 * time.Second
var (
validateSocketFunc = validateSocket
inferRuntimeFromHostFunc = inferRuntimeFromHost
getHostFromContextFunc = getHostFromContext
detectPlatformCandidatesFunc = detectPlatformCandidates
// For testing getHostFromContext
cliconfigLoadFunc = cliconfig.Load
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
const (
RuntimeDocker ContainerRuntime = "docker"
RuntimePodman ContainerRuntime = "podman"
RuntimeUnknown ContainerRuntime = "unknown"
)
// Cache for socket detection results
var (
cachedDockerHost string
cachedRuntime ContainerRuntime
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) {
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.
//
// This is serialized with DetectDockerHost via dockerHostMu.
// Primarily used for testing.
func ResetDockerHostCache() {
dockerHostMu.Lock()
defer dockerHostMu.Unlock()
cachedDockerHost = ""
cachedRuntime = RuntimeUnknown
}
func inferRuntimeFromHostHeuristic(host string) ContainerRuntime {
lowerHost := strings.ToLower(host)
if strings.Contains(lowerHost, "podman") {
return RuntimePodman
}
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 != "" {
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 %s", DockerSocketSchema)
dockerHost = DockerSocketSchema + dockerHost
}
}
if !isSSHHost(dockerHost) {
ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout)
defer cancel()
if err := validateSocketFunc(ctx, dockerHost, true); err != nil {
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)
if err != nil {
log.Debugf("Failed to infer runtime for DOCKER_HOST=%s: %v", dockerHost, err)
runtime = inferRuntimeFromHostHeuristic(dockerHost)
}
return dockerHost, runtime, 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
contextHost, err := getHostFromContextFunc()
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 !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: %s", contextHost, errMsg)
}
log.Warnf("Context host %s is not accessible: %s", contextHost, errMsg)
} 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
}
} else {
// SSH hosts can point to either Docker or Podman; we don't attempt runtime inference here.
return contextHost, RuntimeUnknown, nil
}
}
// Priority 3: Platform-specific candidates
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")
if currentContext == "" {
cf, err := cliconfigLoadFunc(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 := ctxstoreNewFunc(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
}
// 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
}