diff --git a/docs/arch/01-deployment-modes.md b/docs/arch/01-deployment-modes.md index d438618088..45f50b2f32 100644 --- a/docs/arch/01-deployment-modes.md +++ b/docs/arch/01-deployment-modes.md @@ -101,6 +101,7 @@ The CLI automatically detects container runtimes in this order: - `$TOOLHIVE_DOCKER_SOCKET` (if set) - `/var/run/docker.sock` - `~/.docker/run/docker.sock` (Docker Desktop on macOS) + - `~/.docker/desktop/docker.sock` (Docker Desktop on Linux) - `~/.rd/docker.sock` (Rancher Desktop on macOS) - `~/.orbstack/run/docker.sock` (OrbStack on macOS) diff --git a/pkg/container/docker/sdk/client_unix.go b/pkg/container/docker/sdk/client_unix.go index a0b22cd6fa..c7d81d358f 100644 --- a/pkg/container/docker/sdk/client_unix.go +++ b/pkg/container/docker/sdk/client_unix.go @@ -166,17 +166,22 @@ func findPodmanSocket() (string, error) { return "", fmt.Errorf("podman socket not found in standard locations") } +// systemDockerSocketPath is the system-wide Docker socket path probed by +// findDockerSocket. It defaults to DockerSocketPath and is package-private so +// tests can redirect the system check to a sandbox path. +var systemDockerSocketPath = DockerSocketPath + // findDockerSocket attempts to locate a Docker socket func findDockerSocket() (string, error) { // Try Docker socket as fallback - _, err := os.Stat(DockerSocketPath) + _, err := os.Stat(systemDockerSocketPath) if err == nil { - slog.Debug("found Docker socket", "path", DockerSocketPath) - return DockerSocketPath, nil + slog.Debug("found Docker socket", "path", systemDockerSocketPath) + return systemDockerSocketPath, nil } - slog.Debug("failed to check Docker socket", "path", DockerSocketPath, "error", err) + slog.Debug("failed to check Docker socket", "path", systemDockerSocketPath, "error", err) // Try Docker Desktop socket path on macOS if home := os.Getenv("HOME"); home != "" { @@ -193,6 +198,21 @@ func findDockerSocket() (string, error) { slog.Debug("failed to check Docker Desktop socket", "path", dockerDesktopPath, "error", err) } + // Try Docker Desktop socket path on Linux + if home := os.Getenv("HOME"); home != "" { + dockerDesktopLinuxPath := filepath.Join(home, DockerDesktopLinuxSocketPath) + _, err := os.Stat(dockerDesktopLinuxPath) // #nosec G703 -- path is built from HOME + constant socket path + + if err == nil { + //nolint:gosec // G706: socket path derived from HOME env var + slog.Debug("found Docker Desktop socket", "path", dockerDesktopLinuxPath) + return dockerDesktopLinuxPath, nil + } + + //nolint:gosec // G706: socket path derived from HOME env var + slog.Debug("failed to check Docker Desktop socket", "path", dockerDesktopLinuxPath, "error", err) + } + // Try Rancher Desktop socket path on macOS if home := os.Getenv("HOME"); home != "" { rancherDesktopPath := filepath.Join(home, RancherDesktopMacSocketPath) diff --git a/pkg/container/docker/sdk/client_unix_test.go b/pkg/container/docker/sdk/client_unix_test.go new file mode 100644 index 0000000000..10b03c3310 --- /dev/null +++ b/pkg/container/docker/sdk/client_unix_test.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build !windows + +package sdk + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/container/runtime" +) + +// clearSocketEnv removes any inherited TOOLHIVE_*_SOCKET overrides so the +// helpers fall through to filesystem discovery during the test. +func clearSocketEnv(t *testing.T) { + t.Helper() + t.Setenv(PodmanSocketEnv, "") + t.Setenv(DockerSocketEnv, "") + t.Setenv(ColimaSocketEnv, "") +} + +// redirectSystemDockerSocket points the system-socket probe at a path that +// definitely does not exist, so user-level fallbacks are exercised regardless +// of whether the host has a real /var/run/docker.sock. +func redirectSystemDockerSocket(t *testing.T) { + t.Helper() + orig := systemDockerSocketPath + systemDockerSocketPath = filepath.Join(t.TempDir(), "no-such-docker.sock") + t.Cleanup(func() { systemDockerSocketPath = orig }) +} + +func TestFindDockerSocket_DockerDesktopOnLinux(t *testing.T) { + clearSocketEnv(t) + redirectSystemDockerSocket(t) + + home := t.TempDir() + t.Setenv("HOME", home) + + socketDir := filepath.Join(home, filepath.Dir(DockerDesktopLinuxSocketPath)) + require.NoError(t, os.MkdirAll(socketDir, 0o755)) + socketPath := filepath.Join(home, DockerDesktopLinuxSocketPath) + require.NoError(t, os.WriteFile(socketPath, nil, 0o600)) + + got, err := findDockerSocket() + require.NoError(t, err) + assert.Equal(t, socketPath, got) +} + +func TestFindPlatformContainerSocket_DockerEnvOverrideWins(t *testing.T) { + clearSocketEnv(t) + + tmp := t.TempDir() + envSocket := filepath.Join(tmp, "docker-from-env.sock") + require.NoError(t, os.WriteFile(envSocket, nil, 0o600)) + t.Setenv(DockerSocketEnv, envSocket) + + // Even with a Docker Desktop on Linux socket present at $HOME, the env + // var must take precedence. + home := t.TempDir() + t.Setenv("HOME", home) + socketDir := filepath.Join(home, filepath.Dir(DockerDesktopLinuxSocketPath)) + require.NoError(t, os.MkdirAll(socketDir, 0o755)) + homeSocket := filepath.Join(home, DockerDesktopLinuxSocketPath) + require.NoError(t, os.WriteFile(homeSocket, nil, 0o600)) + + path, rt, err := findPlatformContainerSocket(runtime.TypeDocker) + require.NoError(t, err) + assert.Equal(t, envSocket, path) + assert.Equal(t, runtime.TypeDocker, rt) +} + +func TestFindPlatformContainerSocket_NotFound(t *testing.T) { + clearSocketEnv(t) + redirectSystemDockerSocket(t) + + // Empty HOME with no sockets created — every discovery path should miss. + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_RUNTIME_DIR", "") + t.Setenv("TMPDIR", "") + + _, _, err := findPlatformContainerSocket(runtime.TypeDocker) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrRuntimeNotFound), "expected ErrRuntimeNotFound, got %v", err) +} diff --git a/pkg/container/docker/sdk/factory.go b/pkg/container/docker/sdk/factory.go index dccb19828b..bec523bb15 100644 --- a/pkg/container/docker/sdk/factory.go +++ b/pkg/container/docker/sdk/factory.go @@ -39,6 +39,10 @@ const ( DockerSocketPath = "/var/run/docker.sock" // DockerDesktopMacSocketPath is the Docker Desktop socket path on macOS DockerDesktopMacSocketPath = ".docker/run/docker.sock" + // DockerDesktopLinuxSocketPath is the Docker Desktop socket path on Linux + // (relative to $HOME). Docker Desktop on Linux registers a "desktop-linux" + // Docker context that points to this socket. + DockerDesktopLinuxSocketPath = ".docker/desktop/docker.sock" // RancherDesktopMacSocketPath is the Docker socket path for Rancher Desktop on macOS RancherDesktopMacSocketPath = ".rd/docker.sock" // OrbStackMacSocketPath is the Docker socket path for OrbStack on macOS