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
72 changes: 6 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,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.
Expand Down Expand Up @@ -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
}
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.

143 changes: 143 additions & 0 deletions pkg/commands/socket_detection_common.go
Original file line number Diff line number Diff line change
@@ -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
}
102 changes: 102 additions & 0 deletions pkg/commands/socket_detection_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading