diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 84a9c5ff5..8500a6ac6 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -232,6 +232,14 @@ jobs: fail-fast: false matrix: include: + - label: docker-context + runner: ubuntu-latest + free-disk-space: false + install-kind: false + requires-secret: false + test-timeout: 300s + job-timeout-minutes: 10 + - label: ide runner: ubuntu-latest free-disk-space: false diff --git a/cmd/internal/agentworkspace/docker_darwin_test.go b/cmd/internal/agentworkspace/docker_darwin_test.go index 5a167bdbe..2be8490f4 100644 --- a/cmd/internal/agentworkspace/docker_darwin_test.go +++ b/cmd/internal/agentworkspace/docker_darwin_test.go @@ -9,65 +9,99 @@ import ( "testing" ) -func TestFindDarwinDocker_FoundAtKnownPath(t *testing.T) { - // Create a temp directory with a fake docker binary. - tmpDir := t.TempDir() - fakeBin := filepath.Join(tmpDir, "docker") - if err := os.WriteFile(fakeBin, []byte("#!/bin/sh\n"), 0o755); err != nil { - t.Fatalf("failed to create fake docker binary: %v", err) +func writeExecutable(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) } + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } +} - // Override the package-level path list so that our temp path is checked. - original := darwinDockerPaths - darwinDockerPaths = []string{fakeBin} - t.Cleanup(func() { darwinDockerPaths = original }) +func TestFindDarwinDockerCLIAtKnownPath(t *testing.T) { + fakeBin := filepath.Join(t.TempDir(), "docker") + writeExecutable(t, fakeBin) + path, err := findDarwinDockerCLIInPaths([]string{fakeBin}) + if err != nil || path != fakeBin { + t.Fatalf("find docker CLI = %q, %v; want %q", path, err, fakeBin) + } +} - path, err := findDarwinDocker() - if err != nil { - t.Fatalf("expected no error, got: %v", err) +func TestFindDarwinDockerCLIRancherDesktopPath(t *testing.T) { + home := t.TempDir() + rancher := filepath.Join(home, ".rd", "bin", "docker") + writeExecutable(t, rancher) + path, err := findDarwinDockerCLIInPaths(darwinDockerCandidatePaths(home)) + if err != nil || path != rancher { + t.Fatalf("find docker CLI = %q, %v; want %q", path, err, rancher) } - if path != fakeBin { - t.Fatalf("expected path %q, got %q", fakeBin, path) +} + +func TestFindDarwinDockerCLIPreservesPrecedence(t *testing.T) { + tmp := t.TempDir() + static, rancher := filepath.Join( + tmp, + "static", + "docker", + ), filepath.Join( + tmp, + ".rd", + "bin", + "docker", + ) + writeExecutable(t, static) + writeExecutable(t, rancher) + path, err := findDarwinDockerCLIInPaths([]string{static, rancher}) + if err != nil || path != static { + t.Fatalf("find docker CLI = %q, %v; want %q", path, err, static) } } -func TestFindDarwinDocker_NotFound(t *testing.T) { - // Point at paths that definitely don't exist. - original := darwinDockerPaths - darwinDockerPaths = []string{"/nonexistent/path/docker"} - t.Cleanup(func() { darwinDockerPaths = original }) +func TestFindDarwinDockerCLINotFound(t *testing.T) { + _, err := findDarwinDockerCLIInPaths([]string{filepath.Join(t.TempDir(), "docker")}) + if err == nil || !strings.Contains(err.Error(), "docker CLI") || + !strings.Contains(err.Error(), "DOCKER_PATH") { + t.Fatalf("expected actionable docker CLI error, got %v", err) + } + if strings.Contains(err.Error(), "install Docker Desktop") { + t.Fatalf("error must not require Docker Desktop: %v", err) + } +} - _, err := findDarwinDocker() - if err == nil { - t.Fatal("expected an error when no docker binary exists") +func TestFindDarwinDockerCLIHomeFailureDoesNotBreakStaticDiscovery(t *testing.T) { + static := filepath.Join(t.TempDir(), "docker") + writeExecutable(t, static) + paths := append([]string{static}, darwinDockerCandidatePaths("")...) + path, err := findDarwinDockerCLIInPaths(paths) + if err != nil || path != static { + t.Fatalf("find docker CLI = %q, %v; want %q", path, err, static) } - if !strings.Contains(err.Error(), "Docker Desktop") { - t.Fatalf("error should mention Docker Desktop, got: %v", err) +} + +func TestFindDarwinDockerCLIRejectsNonExecutable(t *testing.T) { + path := filepath.Join(t.TempDir(), "docker") + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) } - if strings.Contains(err.Error(), "unsupported OS") { - t.Fatalf("error must NOT mention unsupported OS, got: %v", err) + if _, err := findDarwinDockerCLIInPaths([]string{path}); err == nil { + t.Fatal("expected non-executable candidate to be rejected") } } -func TestFindDarwinDocker_PrefersFirstPath(t *testing.T) { - tmpDir := t.TempDir() - first := filepath.Join(tmpDir, "docker-first") - second := filepath.Join(tmpDir, "docker-second") - for _, p := range []string{first, second} { - if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil { - t.Fatalf("failed to create fake binary: %v", err) - } +func TestFindDarwinDockerCLIRejectsInaccessibleCandidate(t *testing.T) { + tmp := t.TempDir() + inaccessible := filepath.Join(tmp, "inaccessible", "docker") + writeExecutable(t, inaccessible) + if err := os.Chmod(inaccessible, 0o001); err != nil { + t.Fatal(err) } - original := darwinDockerPaths - darwinDockerPaths = []string{first, second} - t.Cleanup(func() { darwinDockerPaths = original }) + valid := filepath.Join(tmp, "valid", "docker") + writeExecutable(t, valid) - path, err := findDarwinDocker() - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - if path != first { - t.Fatalf("expected first path %q, got %q", first, path) + path, err := findDarwinDockerCLIInPaths([]string{inaccessible, valid}) + if err != nil || path != valid { + t.Fatalf("expected fallback to valid candidate %q, got %q (err: %v)", valid, path, err) } } diff --git a/cmd/internal/agentworkspace/up.go b/cmd/internal/agentworkspace/up.go index d36a5ffbb..46c215740 100644 --- a/cmd/internal/agentworkspace/up.go +++ b/cmd/internal/agentworkspace/up.go @@ -434,7 +434,7 @@ func (w *workspaceInitializer) ensureDockerInstalled(ctx context.Context) (strin } if dockerCmd == "docker" && runtime.GOOS == "darwin" { - return findDarwinDocker() + return findDarwinDockerCLI() } if w.isDockerInstallDisabled() { @@ -449,26 +449,36 @@ func (w *workspaceInitializer) ensureDockerInstalled(ctx context.Context) (strin return dockerPath, err } -// darwinDockerPaths are well-known locations where Docker Desktop installs -// the docker CLI on macOS. Declared as a variable so tests can override. +// darwinDockerPaths are existing, system-wide locations for the Docker CLI. var darwinDockerPaths = []string{ "/usr/local/bin/docker", "/opt/homebrew/bin/docker", "/Applications/Docker.app/Contents/Resources/bin/docker", } -// findDarwinDocker checks well-known macOS Docker Desktop paths and returns -// the first one that exists. If none are found it returns an error directing -// the user to install Docker Desktop. -func findDarwinDocker() (string, error) { - for _, path := range darwinDockerPaths { - if _, err := os.Stat(path); err == nil { - log.Debugf("found docker at %s", path) - return path, nil +func findDarwinDockerCLI() (string, error) { + homeDir, _ := os.UserHomeDir() + return findDarwinDockerCLIInPaths(darwinDockerCandidatePaths(homeDir)) +} + +func darwinDockerCandidatePaths(homeDir string) []string { + paths := append([]string(nil), darwinDockerPaths...) + if homeDir != "" { + paths = append(paths, filepath.Join(homeDir, ".rd", "bin", "docker")) + } + return paths +} + +func findDarwinDockerCLIInPaths(paths []string) (string, error) { + for _, path := range paths { + if resolved, err := exec.LookPath(path); err == nil { + log.Debugf("found docker at %s", resolved) + return resolved, nil } } return "", fmt.Errorf( - "docker Desktop not found; on macOS, install Docker Desktop from https://www.docker.com/products/docker-desktop", + "docker CLI not found; configure DOCKER_PATH or install a supported " + + "Docker-compatible runtime such as Docker Desktop or Rancher Desktop", ) } diff --git a/sites/docs-devsy-sh/content/docs/managing-providers/manage-providers.mdx b/sites/docs-devsy-sh/content/docs/managing-providers/manage-providers.mdx index 3fa3caacb..17b3a64e2 100644 --- a/sites/docs-devsy-sh/content/docs/managing-providers/manage-providers.mdx +++ b/sites/docs-devsy-sh/content/docs/managing-providers/manage-providers.mdx @@ -222,6 +222,28 @@ To direct Docker commands directly to a specific socket or remote daemon endpoin For remote TCP daemons, enable mutual TLS authentication by configuring `DOCKER_TLS_VERIFY=1` and pointing `DOCKER_CERT_PATH` to your client certificates. +### Rancher Desktop + +Devsy supports Rancher Desktop through the Docker provider when Rancher Desktop uses the **Moby** container engine. It follows the same Docker context semantics described above; Rancher Desktop typically provides its CLI at `~/.rd/bin/docker` and uses the `rancher-desktop` context. On macOS, Devsy automatically checks that CLI location when `docker` is not available on the application `PATH`. + +You can configure both values explicitly: + +```sh +devsy provider set docker -o DOCKER_PATH="$HOME/.rd/bin/docker" +devsy provider set docker -o DOCKER_CONTEXT=rancher-desktop +``` + +For troubleshooting, verify the CLI and context directly, then inspect the Devsy provider configuration: + +```sh +"$HOME/.rd/bin/docker" context ls +"$HOME/.rd/bin/docker" context inspect rancher-desktop +"$HOME/.rd/bin/docker" --context rancher-desktop info +devsy provider get docker +``` + +If Rancher Desktop is configured for `containerd`, switch it to Moby before using Devsy's Docker provider. Rancher Desktop `containerd`/`nerdctl` parity is not yet validated by this release. + ### Precedence Devsy resolves the active Docker connection in the same order as the Docker CLI: