Skip to content
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 5 additions & 66 deletions pkg/commands/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -72,11 +69,14 @@ 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)
return nil, fmt.Errorf("cannot connect to container runtime: %w", 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.
Expand Down Expand Up @@ -362,64 +362,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
}
7 changes: 0 additions & 7 deletions pkg/commands/docker_host_unix.go

This file was deleted.

5 changes: 0 additions & 5 deletions pkg/commands/docker_host_windows.go

This file was deleted.

192 changes: 192 additions & 0 deletions pkg/commands/socket_detection_common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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 (
ErrNoDockerSocket = fmt.Errorf("no working Docker/Podman socket found")
)

// Timeout for validating socket connectivity
const socketValidationTimeout = 3 * time.Second

var (
validateSocketFunc = validateSocket
getHostFromContextFunc = getHostFromContext
detectPlatformCandidatesFunc = detectPlatformCandidates

// For testing getHostFromContext
cliconfigLoadFunc = cliconfig.Load
ctxstoreNewFunc = ctxstore.New
)

// 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. Used for testing.
func ResetDockerHostCache() {
dockerHostMu.Lock()
defer dockerHostMu.Unlock()
cachedDockerHost = ""
cachedRuntime = RuntimeUnknown
}

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 !strings.HasPrefix(dockerHost, "ssh://") {
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)
}
}
return dockerHost, RuntimeDocker, 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)
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
}
}

if isValid {
return contextHost, RuntimeDocker, nil
}
}

// Priority 3: Platform-specific candidates
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 := 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
}
Loading