diff --git a/README.md b/README.md index 97967d36..ffa07d32 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index b055441a..4d377a5f 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -15,9 +15,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" @@ -101,11 +98,14 @@ func newDockerClient(dockerHost string) (*client.Client, error) { // 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. @@ -522,64 +522,3 @@ func (c *DockerCommand) DockerComposeConfigForProject(project *Project) 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 -} diff --git a/pkg/commands/docker_host_unix.go b/pkg/commands/docker_host_unix.go deleted file mode 100644 index ee5fb7af..00000000 --- a/pkg/commands/docker_host_unix.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows - -package commands - -const ( - defaultDockerHost = "unix:///var/run/docker.sock" -) diff --git a/pkg/commands/docker_host_windows.go b/pkg/commands/docker_host_windows.go deleted file mode 100644 index 471b55a2..00000000 --- a/pkg/commands/docker_host_windows.go +++ /dev/null @@ -1,5 +0,0 @@ -package commands - -const ( - defaultDockerHost = "npipe:////./pipe/docker_engine" -) diff --git a/pkg/commands/socket_detection_common.go b/pkg/commands/socket_detection_common.go new file mode 100644 index 00000000..fdfc8808 --- /dev/null +++ b/pkg/commands/socket_detection_common.go @@ -0,0 +1,283 @@ +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 +} diff --git a/pkg/commands/socket_detection_test.go b/pkg/commands/socket_detection_test.go new file mode 100644 index 00000000..c229075b --- /dev/null +++ b/pkg/commands/socket_detection_test.go @@ -0,0 +1,603 @@ +//go:build !windows + +package commands + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/docker/cli/cli/config/configfile" + ddocker "github.com/docker/cli/cli/context/docker" + ctxstore "github.com/docker/cli/cli/context/store" + "github.com/docker/docker/api/types" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestGetSocketCandidates(t *testing.T) { + // Save and restore + oldGetuid := getuidFunc + oldGetenv := getenvFunc + oldUserHomeDir := userHomeDirFunc + defer func() { + getuidFunc = oldGetuid + getenvFunc = oldGetenv + userHomeDirFunc = oldUserHomeDir + }() + + getuidFunc = func() int { return 1000 } + getenvFunc = func(key string) string { + if key == "XDG_RUNTIME_DIR" { + return "/run/user/1000" + } + return "" + } + userHomeDirFunc = func() (string, error) { return "/home/user", nil } + + candidates := getSocketCandidates() + + expectedPaths := []string{ + "unix:///var/run/docker.sock", + "unix:///run/user/1000/docker.sock", + "unix:///home/user/.docker/run/docker.sock", + "unix:///run/user/1000/podman/podman.sock", + "unix:///home/user/.colima/default/docker.sock", + "unix:///home/user/.orbstack/run/docker.sock", + "unix:///home/user/.lima/default/sock/docker.sock", + "unix:///home/user/.rd/docker.sock", + } + + var paths []string + for _, c := range candidates { + paths = append(paths, c.Path) + } + + for _, expected := range expectedPaths { + assert.Contains(t, paths, expected) + } +} + +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) + + // Mock validateSocketFunc to succeed + oldValidate := validateSocketFunc + oldInfer := inferRuntimeFromHostFunc + defer func() { validateSocketFunc = oldValidate }() + defer func() { inferRuntimeFromHostFunc = oldInfer }() + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return nil + } + inferRuntimeFromHostFunc = func(ctx context.Context, host string, useEnv bool) (ContainerRuntime, error) { + return RuntimePodman, nil + } + + // Reset cache for test + ResetDockerHostCache() + + log := logrus.NewEntry(logrus.New()) + host, runtime, err := DetectDockerHost(log) + assert.NoError(t, err) + assert.Equal(t, expectedHost, host) + assert.Equal(t, RuntimePodman, runtime) +} + +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") + + // Mock validateSocketFunc to succeed + oldValidate := validateSocketFunc + oldInfer := inferRuntimeFromHostFunc + defer func() { validateSocketFunc = oldValidate }() + defer func() { inferRuntimeFromHostFunc = oldInfer }() + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return nil + } + inferRuntimeFromHostFunc = func(ctx context.Context, host string, useEnv bool) (ContainerRuntime, error) { + return RuntimePodman, nil + } + + // Reset cache for test + ResetDockerHostCache() + + log := logrus.NewEntry(logrus.New()) + host1, runtime1, _ := DetectDockerHost(log) + + // Change env var - should still return first one from cache + os.Setenv("DOCKER_HOST", "unix:///tmp/second.sock") + host2, runtime2, _ := DetectDockerHost(log) + + assert.Equal(t, host1, host2) + assert.Equal(t, "unix:///tmp/first.sock", host2) + assert.Equal(t, runtime1, runtime2) + assert.Equal(t, RuntimePodman, runtime2) +} + +func TestDetectDockerHost_DOCKER_HOST_Invalid(t *testing.T) { + oldDockerHost := os.Getenv("DOCKER_HOST") + os.Setenv("DOCKER_HOST", "unix:///tmp/invalid.sock") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + ResetDockerHostCache() + + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return errors.New("invalid") + } + + log := logrus.NewEntry(logrus.New()) + _, _, err := DetectDockerHost(log) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "DOCKER_HOST") + } +} + +func TestDetectDockerHost_Context_Success(t *testing.T) { + oldDockerHost := os.Getenv("DOCKER_HOST") + os.Setenv("DOCKER_HOST", "") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + oldGetHost := getHostFromContextFunc + oldValidate := validateSocketFunc + oldInfer := inferRuntimeFromHostFunc + defer func() { + getHostFromContextFunc = oldGetHost + validateSocketFunc = oldValidate + inferRuntimeFromHostFunc = oldInfer + }() + + getHostFromContextFunc = func() (string, error) { + return "unix:///tmp/context.sock", nil + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + if host == "unix:///tmp/context.sock" { + return nil + } + return errors.New("invalid") + } + inferRuntimeFromHostFunc = func(ctx context.Context, host string, useEnv bool) (ContainerRuntime, error) { + return RuntimePodman, nil + } + + ResetDockerHostCache() + + log := logrus.NewEntry(logrus.New()) + host, runtime, err := DetectDockerHost(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///tmp/context.sock", host) + assert.Equal(t, RuntimePodman, runtime) +} + +func TestDetectDockerHost_Context_Invalid_Fallback(t *testing.T) { + oldDockerHost := os.Getenv("DOCKER_HOST") + os.Setenv("DOCKER_HOST", "") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + oldGetHost := getHostFromContextFunc + oldValidate := validateSocketFunc + oldDetectPlatform := detectPlatformCandidatesFunc + defer func() { + getHostFromContextFunc = oldGetHost + validateSocketFunc = oldValidate + detectPlatformCandidatesFunc = oldDetectPlatform + }() + + getHostFromContextFunc = func() (string, error) { + return "unix:///tmp/invalid-context.sock", nil + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return errors.New("invalid") + } + detectPlatformCandidatesFunc = func(log *logrus.Entry) (string, ContainerRuntime, error) { + return "unix:///tmp/fallback.sock", RuntimeDocker, nil + } + + ResetDockerHostCache() + + log := logrus.NewEntry(logrus.New()) + host, _, err := DetectDockerHost(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///tmp/fallback.sock", host) +} + +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 + ResetDockerHostCache() + + log := logrus.NewEntry(logrus.New()) + _, _, err := DetectDockerHost(log) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to use DOCKER_CONTEXT") +} + +func TestDetectDockerHost_Context_Error_NoEnv(t *testing.T) { + // Save and restore + oldGetHost := getHostFromContextFunc + oldDetectPlatform := detectPlatformCandidatesFunc + defer func() { + getHostFromContextFunc = oldGetHost + detectPlatformCandidatesFunc = oldDetectPlatform + }() + + // Mock getHostFromContext to return error + getHostFromContextFunc = func() (string, error) { + return "", errors.New("context error") + } + + // Mock detectPlatformCandidates to return success so we can see it falls through + detectPlatformCandidatesFunc = func(log *logrus.Entry) (string, ContainerRuntime, error) { + return "unix:///tmp/fallback.sock", RuntimeDocker, nil + } + + // Clear DOCKER_HOST + oldDockerHost := os.Getenv("DOCKER_HOST") + os.Setenv("DOCKER_HOST", "") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + ResetDockerHostCache() + + log := logrus.NewEntry(logrus.New()) + host, runtime, err := DetectDockerHost(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///tmp/fallback.sock", host) + assert.Equal(t, RuntimeDocker, runtime) +} + +func TestDetectDockerHost_SSH(t *testing.T) { + oldDockerHost := os.Getenv("DOCKER_HOST") + os.Setenv("DOCKER_HOST", "ssh://user@host") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + ResetDockerHostCache() + + log := logrus.NewEntry(logrus.New()) + host, _, err := DetectDockerHost(log) + assert.NoError(t, err) + assert.Equal(t, "ssh://user@host", host) +} + +func TestDetectDockerHost_PlainPath(t *testing.T) { + oldDockerHost := os.Getenv("DOCKER_HOST") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + // Create a temporary file to act as a socket + tmpFile, err := os.CreateTemp("", "lazydocker-test-*.sock") + assert.NoError(t, err) + defer os.Remove(tmpFile.Name()) + tmpFile.Close() + + os.Setenv("DOCKER_HOST", tmpFile.Name()) + + ResetDockerHostCache() + + // Mock validateSocketFunc to succeed + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return nil + } + + log := logrus.NewEntry(logrus.New()) + host, _, err := DetectDockerHost(log) + assert.NoError(t, err) + assert.Equal(t, "unix://"+tmpFile.Name(), host) +} + +func TestInferRuntimeFromHost(t *testing.T) { + tests := []struct { + name string + versionResponse types.Version + expectedRuntime ContainerRuntime + }{ + { + name: "Docker", + versionResponse: types.Version{ + Platform: struct{ Name string }{Name: "Docker Engine - Community"}, + Components: []types.ComponentVersion{ + {Name: "Engine", Version: "20.10.7"}, + }, + }, + expectedRuntime: RuntimeDocker, + }, + { + name: "Podman via Platform", + versionResponse: types.Version{ + Platform: struct{ Name string }{Name: "Podman Engine"}, + }, + expectedRuntime: RuntimePodman, + }, + { + name: "Podman via Components", + versionResponse: types.Version{ + Components: []types.ComponentVersion{ + {Name: "Podman Engine", Version: "3.2.3"}, + }, + }, + expectedRuntime: RuntimePodman, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(tt.versionResponse) + })) + defer server.Close() + + runtime, err := inferRuntimeFromHost(context.Background(), server.URL, false) + assert.NoError(t, err) + assert.Equal(t, tt.expectedRuntime, runtime) + }) + } +} + +func TestValidateSocket(t *testing.T) { + t.Run("Success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err := validateSocket(context.Background(), server.URL, false) + assert.NoError(t, err) + }) + + t.Run("Failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + err := validateSocket(context.Background(), server.URL, false) + assert.Error(t, err) + }) +} + +func TestGetHostFromContext(t *testing.T) { + oldLoad := cliconfigLoadFunc + oldNew := ctxstoreNewFunc + defer func() { + cliconfigLoadFunc = oldLoad + ctxstoreNewFunc = oldNew + }() + + t.Run("Default context", func(t *testing.T) { + cliconfigLoadFunc = func(dir string) (*configfile.ConfigFile, error) { + return &configfile.ConfigFile{CurrentContext: "default"}, nil + } + host, err := getHostFromContext() + assert.NoError(t, err) + assert.Equal(t, "", host) + }) + + t.Run("Custom context", func(t *testing.T) { + cliconfigLoadFunc = func(dir string) (*configfile.ConfigFile, error) { + return &configfile.ConfigFile{CurrentContext: "my-context"}, nil + } + ctxstoreNewFunc = func(dir string, config ctxstore.Config) storeInterface { + return &mockStore{ + metadata: ctxstore.Metadata{ + Endpoints: map[string]interface{}{ + ddocker.DockerEndpoint: ddocker.EndpointMeta{ + Host: "unix:///tmp/my-context.sock", + }, + }, + }, + } + } + host, err := getHostFromContext() + assert.NoError(t, err) + assert.Equal(t, "unix:///tmp/my-context.sock", host) + }) +} + +type mockStore struct { + metadata ctxstore.Metadata +} + +func (m *mockStore) GetMetadata(name string) (ctxstore.Metadata, error) { + return m.metadata, nil +} + +func TestDetectPlatformCandidates_Unix(t *testing.T) { + // Save and restore + oldStat := statFunc + oldValidate := validateSocketFunc + defer func() { + statFunc = oldStat + validateSocketFunc = oldValidate + }() + + log := logrus.New().WithField("test", "test") + + t.Run("No sockets exist", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + _, _, err := detectPlatformCandidates(log) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no Docker or Podman socket found") + assert.Contains(t, err.Error(), "systemctl --user enable --now podman.socket") + }) + + t.Run("Docker socket exists and is valid", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + if name == "/var/run/docker.sock" { + return nil, nil + } + return nil, os.ErrNotExist + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + if host == "unix:///var/run/docker.sock" { + return nil + } + return errors.New("invalid") + } + host, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///var/run/docker.sock", host) + assert.Equal(t, RuntimeDocker, runtime) + }) + + t.Run("Docker socket exists but permission denied", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + if name == "/var/run/docker.sock" { + return nil, nil + } + return nil, os.ErrNotExist + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return errors.New("permission denied") + } + _, _, err := detectPlatformCandidates(log) + assert.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") + }) + + t.Run("Podman socket exists and is valid", func(t *testing.T) { + // Mock getuid to return 1000 + oldGetuid := getuidFunc + defer func() { getuidFunc = oldGetuid }() + getuidFunc = func() int { return 1000 } + + statFunc = func(name string) (os.FileInfo, error) { + if name == "/run/user/1000/podman/podman.sock" { + return nil, nil + } + return nil, os.ErrNotExist + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + if host == "unix:///run/user/1000/podman/podman.sock" { + return nil + } + return errors.New("invalid") + } + host, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///run/user/1000/podman/podman.sock", host) + assert.Equal(t, RuntimePodman, runtime) + }) +} + +func TestDetectPlatformCandidates(t *testing.T) { + oldStat := statFunc + oldValidate := validateSocketFunc + defer func() { + statFunc = oldStat + validateSocketFunc = oldValidate + }() + + log := logrus.NewEntry(logrus.New()) + + t.Run("First candidate succeeds", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + return nil, nil // exists + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return nil + } + host, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, "unix:///var/run/docker.sock", host) + assert.Equal(t, RuntimeDocker, runtime) + }) + + t.Run("First fails, second succeeds", func(t *testing.T) { + statFunc = func(name string) (os.FileInfo, error) { + if name == "/var/run/docker.sock" { + return nil, os.ErrNotExist + } + return nil, nil + } + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return nil + } + host, _, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.NotEqual(t, "unix:///var/run/docker.sock", host) + }) +} + +func TestValidateSocket_UseEnv(t *testing.T) { + ctx := context.Background() + oldDockerHost := os.Getenv("DOCKER_HOST") + os.Setenv("DOCKER_HOST", "unix:///tmp/test.sock") + defer os.Setenv("DOCKER_HOST", oldDockerHost) + + // This will fail ping but cover the useEnv branch + err := validateSocket(ctx, "unix:///tmp/test.sock", true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ping failed") +} + +func TestGetHostFromContext_Implementation(t *testing.T) { + // Save and restore + oldLoad := cliconfigLoadFunc + defer func() { cliconfigLoadFunc = oldLoad }() + + t.Run("DOCKER_CONTEXT set", func(t *testing.T) { + oldCtx := os.Getenv("DOCKER_CONTEXT") + os.Setenv("DOCKER_CONTEXT", "default") + defer os.Setenv("DOCKER_CONTEXT", oldCtx) + + host, err := getHostFromContext() + assert.NoError(t, err) + assert.Empty(t, host) + }) + + t.Run("Config load success but empty context", func(t *testing.T) { + cliconfigLoadFunc = func(dir string) (*configfile.ConfigFile, error) { + return &configfile.ConfigFile{CurrentContext: ""}, nil + } + host, err := getHostFromContext() + assert.NoError(t, err) + assert.Empty(t, host) + }) + + t.Run("Config load error", func(t *testing.T) { + cliconfigLoadFunc = func(dir string) (*configfile.ConfigFile, error) { + return nil, errors.New("load error") + } + _, err := getHostFromContext() + assert.Error(t, err) + assert.Contains(t, err.Error(), "load error") + }) +} + +func TestValidateSocket_Failures(t *testing.T) { + ctx := context.Background() + + // Test non-existent path + err := validateSocket(ctx, "unix:///tmp/nonexistent-12345.sock", false) + assert.Error(t, err) + + // Test invalid schema + err = validateSocket(ctx, "invalid:///tmp/test.sock", false) + assert.Error(t, err) +} diff --git a/pkg/commands/socket_detection_unix.go b/pkg/commands/socket_detection_unix.go new file mode 100644 index 00000000..9ad94dc8 --- /dev/null +++ b/pkg/commands/socket_detection_unix.go @@ -0,0 +1,169 @@ +//go:build !windows + +package commands + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/sirupsen/logrus" +) + +var ( + statFunc = os.Stat + getuidFunc = os.Getuid + getenvFunc = os.Getenv + userHomeDirFunc = os.UserHomeDir +) + +const ( + DockerSocketSchema = "unix://" + DockerSocketPath = "/var/run/docker.sock" + + defaultDockerHost = DockerSocketSchema + DockerSocketPath +) + +// 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 := getenvFunc("XDG_RUNTIME_DIR") + home, _ := userHomeDirFunc() + uid := getuidFunc() + + // 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) + + // 15. Podman on macOS (default machine) + if home != "" { + addCandidate(filepath.Join(home, ".local", "share", "containers", "podman", "machine", "podman-machine-default", "podman.sock"), RuntimePodman) + addCandidate(filepath.Join(home, ".local", "share", "containers", "podman", "machine", "qemu", "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 := statFunc(socketPath); err != nil { + continue + } + + // Validate by actually connecting + err := func() error { + ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) + defer cancel() + return validateSocketFunc(ctx, candidate.Path, false) + }() + + if err != nil { + log.Debugf("Socket %s exists but validation failed: %v", candidate.Path, err) + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "permission denied") || strings.Contains(errStr, "eacces") { + lastErr = fmt.Errorf("%s: permission denied (check your user permissions for this socket)", candidate.Path) + } else { + lastErr = fmt.Errorf("%s: %v", 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: %v", ErrNoContainerSocket, lastErr) + } + + var checkedSockets []string + for _, candidate := range candidates { + checkedSockets = append(checkedSockets, strings.TrimPrefix(candidate.Path, DockerSocketSchema)) + } + + return "", RuntimeUnknown, fmt.Errorf("%w: no Docker or Podman socket found; checked %s. "+ + "If you are using Podman with systemd socket activation, try running "+ + "'systemctl --user enable --now podman.socket' and then re-run this command.", + ErrNoContainerSocket, strings.Join(checkedSockets, ", ")) +} diff --git a/pkg/commands/socket_detection_windows.go b/pkg/commands/socket_detection_windows.go new file mode 100644 index 00000000..228c233c --- /dev/null +++ b/pkg/commands/socket_detection_windows.go @@ -0,0 +1,77 @@ +//go:build windows + +package commands + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/sirupsen/logrus" +) + +const ( + DockerSocketSchema = "npipe://" + DockerSocketPath = "//./pipe/docker_engine" + + defaultDockerHost = DockerSocketSchema + DockerSocketPath +) + +func getPodmanPipes(log *logrus.Entry) []string { + home, err := os.UserHomeDir() + if err != nil { + log.Debugf("Failed to get user home directory: %v", err) + return []string{"npipe:////./pipe/podman-machine-default"} + } + + configDir := filepath.Join(home, ".config", "containers", "podman", "machine", "wsl") + files, err := os.ReadDir(configDir) + if err != nil { + log.Debugf("Failed to read Podman machine config directory %s: %v", configDir, err) + return []string{"npipe:////./pipe/podman-machine-default"} + } + + var pipes []string + for _, f := range files { + if !f.IsDir() && filepath.Ext(f.Name()) == ".json" { + name := strings.TrimSuffix(f.Name(), ".json") + pipes = append(pipes, "npipe:////./pipe/"+name) + } + } + + if len(pipes) == 0 { + log.Debug("No Podman machine config files found, falling back to default") + return []string{"npipe:////./pipe/podman-machine-default"} + } + return pipes +} + +func detectPlatformCandidates(log *logrus.Entry) (string, ContainerRuntime, error) { + // Try Docker Desktop first + dockerHost := defaultDockerHost + err := func() error { + ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) + defer cancel() + return validateSocketFunc(ctx, dockerHost, false) + }() + + if err == nil { + return dockerHost, RuntimeDocker, nil + } + + // Try Podman machines on Windows + for _, podmanHost := range getPodmanPipes(log) { + err = func() error { + ctx, cancel := context.WithTimeout(context.Background(), socketValidationTimeout) + defer cancel() + return validateSocketFunc(ctx, podmanHost, false) + }() + + if err == nil { + return podmanHost, RuntimePodman, nil + } + } + + return "", RuntimeUnknown, ErrNoContainerSocket +} diff --git a/pkg/commands/socket_detection_windows_test.go b/pkg/commands/socket_detection_windows_test.go new file mode 100644 index 00000000..bec77a15 --- /dev/null +++ b/pkg/commands/socket_detection_windows_test.go @@ -0,0 +1,129 @@ +//go:build windows + +package commands + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestDetectPlatformCandidates_Windows_DockerSuccess(t *testing.T) { + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + if host == "npipe:////./pipe/docker_engine" { + return nil + } + return errors.New("mock failure") + } + + log := logrus.NewEntry(logrus.New()) + host, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, "npipe:////./pipe/docker_engine", host) + assert.Equal(t, RuntimeDocker, runtime) +} + +func TestDetectPlatformCandidates_Windows_PodmanSuccess(t *testing.T) { + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + if host == "npipe:////./pipe/podman-machine-default" { + return nil + } + return errors.New("mock failure") + } + + log := logrus.NewEntry(logrus.New()) + host, runtime, err := detectPlatformCandidates(log) + assert.NoError(t, err) + assert.Equal(t, "npipe:////./pipe/podman-machine-default", host) + assert.Equal(t, RuntimePodman, runtime) +} + +func TestDetectPlatformCandidates_Windows_Failure(t *testing.T) { + oldValidate := validateSocketFunc + defer func() { validateSocketFunc = oldValidate }() + + validateSocketFunc = func(ctx context.Context, host string, useEnv bool) error { + return errors.New("mock failure") + } + + log := logrus.NewEntry(logrus.New()) + _, _, err := detectPlatformCandidates(log) + assert.Error(t, err) + assert.ErrorIs(t, err, ErrNoContainerSocket) +} + +func TestGetPodmanPipes(t *testing.T) { + log := logrus.NewEntry(logrus.New()) + + // Test fallback when home dir is missing or empty + // We can't easily mock UserHomeDir without refactoring, but we can test the logic + // by creating the expected directory structure in a temp dir and setting USERPROFILE. + + tmpDir, err := os.MkdirTemp("", "podman-test") + assert.NoError(t, err) + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + // Set USERPROFILE to our temp dir + oldProfile := os.Getenv("USERPROFILE") + t.Cleanup(func() { os.Setenv("USERPROFILE", oldProfile) }) + os.Setenv("USERPROFILE", tmpDir) + + // 1. Test fallback when directory doesn't exist + pipes := getPodmanPipes(log) + assert.Equal(t, []string{"npipe:////./pipe/podman-machine-default"}, pipes) + + // 2. Test with actual config files + configDir := filepath.Join(tmpDir, ".config", "containers", "podman", "machine", "wsl") + err = os.MkdirAll(configDir, 0755) + assert.NoError(t, err) + + err = os.WriteFile(filepath.Join(configDir, "machine1.json"), []byte("{}"), 0644) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(configDir, "machine2.json"), []byte("{}"), 0644) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(configDir, "not-a-config.txt"), []byte("{}"), 0644) + assert.NoError(t, err) + + pipes = getPodmanPipes(log) + assert.Len(t, pipes, 2) + assert.Contains(t, pipes, "npipe:////./pipe/machine1") + assert.Contains(t, pipes, "npipe:////./pipe/machine2") + + // 3. Test with empty config directory (no JSON files) + emptyDir := filepath.Join(tmpDir, ".config", "containers", "podman", "machine", "empty-wsl") + err = os.MkdirAll(emptyDir, 0755) + assert.NoError(t, err) + + // Create a new temp dir for this specific test + tmpDir2, err := os.MkdirTemp("", "podman-test-empty") + assert.NoError(t, err) + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir2); err != nil { + t.Errorf("failed to remove temp dir %s: %v", tmpDir2, err) + } + }) + os.Setenv("USERPROFILE", tmpDir2) + + // Create the wsl directory but leave it empty + emptyConfigDir := filepath.Join(tmpDir2, ".config", "containers", "podman", "machine", "wsl") + err = os.MkdirAll(emptyConfigDir, 0755) + assert.NoError(t, err) + + pipes = getPodmanPipes(log) + assert.Equal(t, []string{"npipe:////./pipe/podman-machine-default"}, pipes) +}