mirror of
https://github.com/jesseduffield/lazydocker.git
synced 2026-07-22 15:11:02 +00:00
283 lines
8.7 KiB
Go
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
|
|
}
|