diff --git a/e2e/framework/timeouts.go b/e2e/framework/timeouts.go index bfba3f670..8bdeb4472 100644 --- a/e2e/framework/timeouts.go +++ b/e2e/framework/timeouts.go @@ -1,36 +1,21 @@ package framework import ( - "runtime" "time" ) -const osWindows = "windows" - func TimeoutShort() time.Duration { - if runtime.GOOS == osWindows { - return 10 * time.Minute - } return 3 * time.Minute } func TimeoutModerate() time.Duration { - if runtime.GOOS == osWindows { - return 25 * time.Minute - } return 5 * time.Minute } func TimeoutLong() time.Duration { - if runtime.GOOS == osWindows { - return 50 * time.Minute - } return 10 * time.Minute } func TimeoutVeryLong() time.Duration { - if runtime.GOOS == osWindows { - return 100 * time.Minute - } return 20 * time.Minute } diff --git a/e2e/tests/up/provider_podman_rootful_lifecycle_2.go b/e2e/tests/up/provider_podman_rootful_lifecycle_2.go index 21f6db91c..7fc5ea256 100644 --- a/e2e/tests/up/provider_podman_rootful_lifecycle_2.go +++ b/e2e/tests/up/provider_podman_rootful_lifecycle_2.go @@ -137,7 +137,7 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) gomega.Expect(string(two)).To(gomega.Equal("initCmdTwo")) }, - ginkgo.SpecTimeout(framework.TimeoutShort()), + ginkgo.SpecTimeout(framework.TimeoutModerate()), ) ginkgo.It( //nolint:dupl // mirrors rootless lifecycle secrets-file test diff --git a/pkg/agent/delivery/factory.go b/pkg/agent/delivery/factory.go index 3b7f2cba4..da1111716 100644 --- a/pkg/agent/delivery/factory.go +++ b/pkg/agent/delivery/factory.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "os" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/inject" @@ -86,19 +85,19 @@ func microsandboxDelivery(opts FactoryOptions) AgentDelivery { return &KubernetesDelivery{Exec: opts.PodExec} } +// dockerDelivery is only reached when the caller (NewAgentDelivery) has +// already determined, from the workspace's resolved DOCKER_HOST, that the +// daemon is local. func dockerDelivery(opts FactoryOptions) AgentDelivery { - if isDockerLocal(opts.DockerCommand) { - log.Debugf("using local docker delivery (named volume)") - return &LocalDockerDelivery{ - DockerCommand: opts.DockerCommand, - Environment: opts.DockerEnv, - HelperImage: opts.HelperImage, - } + log.Debugf("using local docker delivery (named volume)") + return &LocalDockerDelivery{ + DockerCommand: opts.DockerCommand, + Environment: opts.DockerEnv, + HelperImage: opts.HelperImage, } - log.Debugf("using remote docker delivery for non-local docker daemon") - return remoteDockerDelivery(opts) } +// remoteDockerDelivery handles the non-local case. func remoteDockerDelivery(opts FactoryOptions) AgentDelivery { return &RemoteDockerDelivery{ DockerCommand: opts.DockerCommand, @@ -118,21 +117,6 @@ func legacyShellDelivery(opts FactoryOptions, reason string) AgentDelivery { } } -func isDockerLocal(_ string) bool { - envHost := os.Getenv("DOCKER_HOST") - return envHost == "" || isLocalDockerHost(envHost) -} - -func isLocalDockerHost(host string) bool { - if host == "" { - return true - } - hasPrefix := func(s, prefix string) bool { - return len(s) >= len(prefix) && s[:len(prefix)] == prefix - } - return hasPrefix(host, "unix://") || hasPrefix(host, "npipe://") -} - // CommandFunc adapts a driver's command function to inject.ExecFunc. func CommandFunc( driverCmd func(ctx context.Context, params *driver.CommandParams) error, diff --git a/pkg/agent/delivery/factory_test.go b/pkg/agent/delivery/factory_test.go index 5d23ed38c..c15c61c42 100644 --- a/pkg/agent/delivery/factory_test.go +++ b/pkg/agent/delivery/factory_test.go @@ -5,6 +5,7 @@ import ( "io" "testing" + dockerpkg "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/provider" "github.com/stretchr/testify/assert" @@ -137,14 +138,14 @@ func TestNewAgentDelivery_KubernetesDriver_FallsBackWhenNoPodExec(t *testing.T) assert.Equal(t, PhasePostStart, d.Phase()) } -func TestIsDockerLocal(t *testing.T) { - assert.True(t, isLocalDockerHost("")) - assert.True(t, isLocalDockerHost("unix:///var/run/docker.sock")) - assert.True(t, isLocalDockerHost("unix:///home/user/.docker/desktop/docker.sock")) - assert.True(t, isLocalDockerHost("npipe:////./pipe/docker_engine")) - assert.True(t, isLocalDockerHost("npipe:////./pipe/podman-machine-default")) - assert.False(t, isLocalDockerHost("tcp://192.168.1.100:2376")) - assert.False(t, isLocalDockerHost("ssh://user@remote-host")) +func TestIsLocalDockerHost(t *testing.T) { + assert.True(t, dockerpkg.IsLocalDockerHost("")) + assert.True(t, dockerpkg.IsLocalDockerHost("unix:///var/run/docker.sock")) + assert.True(t, dockerpkg.IsLocalDockerHost("unix:///home/user/.docker/desktop/docker.sock")) + assert.True(t, dockerpkg.IsLocalDockerHost("npipe:////./pipe/docker_engine")) + assert.True(t, dockerpkg.IsLocalDockerHost("npipe:////./pipe/podman-machine-default")) + assert.False(t, dockerpkg.IsLocalDockerHost("tcp://192.168.1.100:2376")) + assert.False(t, dockerpkg.IsLocalDockerHost("ssh://user@remote-host")) } func TestDeliver_PreStart(t *testing.T) { diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index d38e9839c..5ef53d490 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -148,6 +148,7 @@ func (r *runner) newAgentDelivery() delivery.AgentDelivery { WorkspaceID: r.id, DockerCommand: dockerCmd, DockerEnv: dockerEnv, + IsRemoteDocker: docker.RemoteDockerHost(dockerEnv), HelperImage: r.workspaceConfig.Agent.Docker.HelperImage, ContainerID: r.id, ExecFunc: execFn, diff --git a/pkg/devcontainer/setup_test.go b/pkg/devcontainer/setup_test.go index 11941937a..c6014a6b5 100644 --- a/pkg/devcontainer/setup_test.go +++ b/pkg/devcontainer/setup_test.go @@ -1,14 +1,60 @@ package devcontainer import ( + "reflect" "testing" + "github.com/devsy-org/devsy/pkg/agent/delivery" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/docker" provider2 "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/types" ) +const testDockerHostEnvKey = "DOCKER_HOST" + +func TestNewAgentDelivery_RemoteDockerHostWiring(t *testing.T) { + cases := []struct { + name string + env map[string]string + wantType any + }{ + { + name: "unset DOCKER_HOST uses local delivery", + env: nil, + wantType: &delivery.LocalDockerDelivery{}, + }, + { + name: "unix socket DOCKER_HOST uses local delivery", + env: map[string]string{testDockerHostEnvKey: "unix:///var/run/docker.sock"}, + wantType: &delivery.LocalDockerDelivery{}, + }, + { + name: "ssh DOCKER_HOST uses remote delivery", + env: map[string]string{testDockerHostEnvKey: "ssh://user@localhost"}, + wantType: &delivery.RemoteDockerDelivery{}, + }, + { + name: "tcp DOCKER_HOST uses remote delivery", + env: map[string]string{testDockerHostEnvKey: "tcp://192.168.1.100:2376"}, + wantType: &delivery.RemoteDockerDelivery{}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := newTestRunner(&mockDriver{}) + r.workspaceConfig.Agent.Driver = provider2.DockerDriver + r.workspaceConfig.Agent.Docker = provider2.ProviderDockerDriverConfig{Env: tc.env} + + got := r.newAgentDelivery() + if reflect.TypeOf(got) != reflect.TypeOf(tc.wantType) { + t.Errorf("newAgentDelivery() = %T, want %T", got, tc.wantType) + } + }) + } +} + func TestShouldChownWorkspace(t *testing.T) { cases := []struct { name string diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index 55607e66d..f9f190c19 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -109,6 +109,35 @@ func (r *DockerHelper) GPUSupportEnabled() (bool, error) { return r.GetRuntime().GPUAvailable(ctx, r) } +// IsLocalDockerHost reports whether host points at a daemon sharing the local +// filesystem. An empty value is the docker default local socket. +func IsLocalDockerHost(host string) bool { + if host == "" { + return true + } + return strings.HasPrefix(host, "unix://") || strings.HasPrefix(host, "npipe://") +} + +// RemoteDockerHost reports whether env targets a daemon on a different host +// than the devsy process, by inspecting DOCKER_HOST. +func RemoteDockerHost(env []string) bool { + host, ok := envValue(env, "DOCKER_HOST") + if !ok { + return false + } + return !IsLocalDockerHost(host) +} + +func envValue(env []string, name string) (string, bool) { + prefix := name + "=" + for _, e := range env { + if v, ok := strings.CutPrefix(e, prefix); ok { + return v, true + } + } + return "", false +} + // GetRuntime returns the container runtime for this helper. // If no runtime was explicitly set, it auto-detects from the docker command. func (r *DockerHelper) GetRuntime() ContainerRuntime { diff --git a/pkg/driver/docker/runargs.go b/pkg/driver/docker/runargs.go index bcab94574..1e2ee721b 100644 --- a/pkg/driver/docker/runargs.go +++ b/pkg/driver/docker/runargs.go @@ -481,22 +481,22 @@ func (d *dockerDriver) getRemoteUser( } func (d *dockerDriver) EnsurePath(path *config.Mount) *config.Mount { - // Local Windows to remote Linux over TCP requires manual path conversion. - if runtime.GOOS == "windows" { - for _, v := range d.Docker.Environment { - // Convert only when DOCKER_HOST is a direct TCP connection to a - // docker daemon running in WSL, not the docker-desktop engine. - if strings.Contains(v, "DOCKER_HOST=tcp://") { - unixPath := path.Source - unixPath = strings.Replace(unixPath, "C:", "c", 1) - unixPath = strings.ReplaceAll(unixPath, "\\", "/") - unixPath = "/mnt/" + unixPath - - path.Source = unixPath - - return path - } - } + if runtime.GOOS != "windows" { + return path + } + // A remote daemon (ssh://, tcp://, etc.) lives on a different host whose + // filesystem does not share Windows drive paths; translate to the WSL + // /mnt/ form the remote daemon can resolve. + if !docker.RemoteDockerHost(d.Docker.Environment) { + return path } + + path.Source = windowsToWSLPath(path.Source) return path } + +func windowsToWSLPath(winPath string) string { + unixPath := strings.Replace(winPath, "C:", "c", 1) + unixPath = strings.ReplaceAll(unixPath, "\\", "/") + return "/mnt/" + unixPath +} diff --git a/pkg/driver/docker/runargs_ensurepath_test.go b/pkg/driver/docker/runargs_ensurepath_test.go new file mode 100644 index 000000000..c0eedad66 --- /dev/null +++ b/pkg/driver/docker/runargs_ensurepath_test.go @@ -0,0 +1,86 @@ +package docker + +import ( + "testing" + + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/docker" +) + +func TestWindowsToWSLPath(t *testing.T) { + cases := []struct { + winPath string + want string + }{ + {`C:\Users\me\repo`, "/mnt/c/Users/me/repo"}, + {`C:\projects\security_dev`, "/mnt/c/projects/security_dev"}, + {`\projects\repo`, "/mnt//projects/repo"}, + } + for _, tc := range cases { + t.Run(tc.winPath, func(t *testing.T) { + if got := windowsToWSLPath(tc.winPath); got != tc.want { + t.Errorf("windowsToWSLPath(%q) = %q, want %q", tc.winPath, got, tc.want) + } + }) + } +} + +func TestIsLocalDockerHost(t *testing.T) { + cases := []struct { + host string + want bool + }{ + {"", true}, + {"unix:///var/run/docker.sock", true}, + {"npipe:////./pipe/docker_engine", true}, + {"tcp://localhost:2375", false}, + {"ssh://user@localhost", false}, + } + for _, tc := range cases { + t.Run(tc.host, func(t *testing.T) { + if got := docker.IsLocalDockerHost(tc.host); got != tc.want { + t.Errorf("IsLocalDockerHost(%q) = %v, want %v", tc.host, got, tc.want) + } + }) + } +} + +func TestRemoteDockerHost(t *testing.T) { + cases := []struct { + name string + env []string + want bool + }{ + {name: "nil env", env: nil, want: false}, + {name: "no DOCKER_HOST", env: []string{"PATH=/usr/bin"}, want: false}, + { + name: "unix socket", + env: []string{"DOCKER_HOST=unix:///var/run/docker.sock"}, + want: false, + }, + {name: "npipe", env: []string{"DOCKER_HOST=npipe:////./pipe/docker_engine"}, want: false}, + {name: "tcp", env: []string{"DOCKER_HOST=tcp://localhost:2375"}, want: true}, + {name: "ssh", env: []string{"DOCKER_HOST=ssh://user@localhost"}, want: true}, + { + name: "ssh with other env first", + env: []string{"PATH=/x", "DOCKER_HOST=ssh://u@localhost"}, + want: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := docker.RemoteDockerHost(tc.env); got != tc.want { + t.Errorf("RemoteDockerHost(%v) = %v, want %v", tc.env, got, tc.want) + } + }) + } +} + +func (s *DockerDriverTestSuite) TestEnsurePath_NoopOffWindows() { + // EnsurePath only converts on Windows; guard that no-op on other OSes. + s.driver.Docker = &docker.DockerHelper{ + Environment: []string{"DOCKER_HOST=ssh://user@localhost"}, + } + mount := &config.Mount{Source: `C:\repo`, Target: "/workspace"} + s.Equal(`C:\repo`, s.driver.EnsurePath(mount).Source) +} diff --git a/sites/docs-devsy-sh/content/docs/tutorials/docker-provider-via-wsl.mdx b/sites/docs-devsy-sh/content/docs/tutorials/docker-provider-via-wsl.mdx index 0b545070b..03551fbec 100644 --- a/sites/docs-devsy-sh/content/docs/tutorials/docker-provider-via-wsl.mdx +++ b/sites/docs-devsy-sh/content/docs/tutorials/docker-provider-via-wsl.mdx @@ -5,51 +5,69 @@ sidebar_label: Docker provider via WSL ## Purpose -The purpose of this quickstart is to provide a Docker environment via WSL, and a guide on how to integrate Devsy to Docker running in WSL. +This tutorial sets up a Docker engine inside WSL and connects Devsy, running on Windows, to it over SSH. It keeps Docker and the project files in the WSL filesystem for volume performance while running Devsy and the editor on Windows. -There are 3 parts to this tutorial. -1. Step 1 to Step 5 relates to installing Docker in WSL -2. Step 6 and Step 7 relate to integrating Docker client (for Windows) with Docker daemon in WSL -3. Step 8 relates to Integrating Devsy with Docker in WSL ## Installing Docker in WSL -### 1. Enable WSL2 feature +### Enable WSL2 -To enable WSL2 on your Windows machine, run this command in **Powershell (run as Administrator)**. Make sure to restart your computer after this command is completed. +Run these commands in **PowerShell as Administrator**. -You can skip this if you have installed WSL2 previously. +You can skip this step if WSL2 is already installed. +On a recent Windows build, the single command below is the simplest path. It enables the required Windows features, downloads the WSL kernel, sets WSL2 as the default version, and installs a default Linux distribution: + +```powershell +wsl --install ``` + +If `wsl --install` is unavailable, or you need to enable the features by hand, enable both the Windows Subsystem for Linux and the Virtual Machine Platform. WSL2 requires both optional features: the Virtual Machine Platform provides the lightweight virtual machine that runs the Linux kernel, and without it WSL falls back to WSL1: + +```powershell dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart +dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart ``` -### 2. Install WSL2 Distro - -Install `Ubuntu-24.04`. Supply the `username` and `password` when asked. +Restart the machine, then set WSL2 as the default version so the distribution installed in Step 2 runs as WSL2 rather than WSL1: +```powershell +wsl --set-default-version 2 ``` + +### Install a WSL2 distribution + +If you ran `wsl --install` in Step 1 without naming a distribution, a default Linux distribution is installed and you can skip ahead. To install `Ubuntu-24.04`, run the following and supply the `username` and `password` when prompted: + +```powershell wsl --install Ubuntu-24.04 ``` -To access the Ubuntu shell, type `wsl` in Powershell. - -### 3. Install Docker in Ubuntu-24.04 +To open the Ubuntu shell, type `wsl` in PowerShell. -Use the command below to install the Docker engine on Ubuntu 24.04. + +Steps 3 and 5 use `systemctl` to start Docker and the SSH server. `systemctl` requires systemd enabled inside WSL. Ubuntu 24.04 installed through `wsl --install` has systemd enabled by default. If `systemctl` is unavailable, enable it by adding the following to `/etc/wsl.conf` inside WSL, then restart WSL (`wsl --shutdown` from PowerShell, followed by `wsl`): +```ini +[boot] +systemd=true ``` -#!/bin/bash -# If your machine is behind corporate firewall, -# make sure to define your HTTP_PROXY and HTTPS_PROXY before running the command below + + +### Install Docker in the WSL distribution + +Run the following inside WSL to install the Docker engine on Ubuntu 24.04. This follows the [official Docker Engine installation for Ubuntu](https://docs.docker.com/engine/install/ubuntu/). + +```bash +# If your machine is behind a corporate firewall, +# define HTTP_PROXY and HTTPS_PROXY before running the commands below. sudo apt-get update sudo apt-get install ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings -sudo -E curl --verbose -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc +sudo -E curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc -# Add the repository to Apt sources: echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ @@ -58,84 +76,110 @@ sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin sudo usermod -aG docker $USER -sudo systemctl enable docker.service -sudo systemctl enable containerd.service - -sudo systemctl start docker.service -sudo systemctl start containerd.service - +sudo systemctl enable --now docker.service +sudo systemctl enable --now containerd.service ``` -### 4. [optional] Configure docker daemon proxy +### Configure a Docker daemon proxy (optional) -``` -#!/bin/bash -# If your machine is behind corporate firewall, -# make sure to define your HTTP_PROXY and HTTPS_PROXY before running the command below +If your machine is behind a corporate firewall, configure the Docker daemon to use the proxy. Run the following inside WSL: +```bash sudo mkdir -p /etc/systemd/system/docker.service.d -sudo touch /etc/systemd/system/docker.service.d/http-proxy.conf -echo "[Service]" | sudo tee -a /etc/systemd/system/docker.service.d/http-proxy.conf -echo "Environment='HTTP_PROXY=$HTTP_PROXY'" | sudo tee -a /etc/systemd/system/docker.service.d/http-proxy.conf -echo "Environment='HTTPS_PROXY=$HTTPS_PROXY'" | sudo tee -a /etc/systemd/system/docker.service.d/http-proxy.conf -echo "Environment='NO_PROXY=$NO_PROXY'" | sudo tee -a /etc/systemd/system/docker.service.d/http-proxy.conf -echo "Environment='http_proxy=$http_proxy'" | sudo tee -a /etc/systemd/system/docker.service.d/http-proxy.conf -echo "Environment='https_proxy=$https_proxy'" | sudo tee -a /etc/systemd/system/docker.service.d/http-proxy.conf -echo "Environment='no_proxy=$no_proxy'" | sudo tee -a /etc/systemd/system/docker.service.d/http-proxy.conf - -# restart docker daemon +sudo tee /etc/systemd/system/docker.service.d/http-proxy.conf > /dev/null <` with the username you set up in Step 2: +### Create a Docker context for the WSL daemon -``` -# run this command in Powershell +Create a Docker context that connects to the WSL Docker daemon over SSH. Replace `` with the username created in Step 2. Run these commands in PowerShell: -docker --version +```powershell docker context create lin --docker "host=ssh://@localhost" docker context use lin docker run hello-world ``` -The first connection will prompt you to accept the SSH host key. If you did not set up key-based authentication in Step 5, you will also be prompted for the WSL user's password. +The first connection prompts you to accept the SSH host key. If you did not set up key authentication in Step 5, it prompts for the WSL user's password as well. + +## Connect Devsy to Docker in WSL + +### Configure the Devsy Docker provider + +Add the built-in Docker provider and point it at the WSL daemon over SSH. Set `DOCKER_HOST` to the same SSH endpoint used in Step 7, and `DOCKER_PATH` to the `docker` binary on `PATH`: + +```bash +devsy provider add docker -o DOCKER_HOST=ssh://@localhost -o DOCKER_PATH=docker +``` + +Replace `` with your WSL username. Confirm the provider is configured: + +```bash +devsy provider get docker +``` + +If the provider is already added, update its options with `devsy provider set` instead: + +```bash +devsy provider set docker -o DOCKER_HOST=ssh://@localhost -o DOCKER_PATH=docker +``` + +`DOCKER_HOST` is passed through to the Docker CLI as the `DOCKER_HOST` environment variable, so any value the Docker CLI accepts (here, an `ssh://` endpoint) works the same way as it does for `docker context`. + + + +When the Docker daemon runs inside WSL but Devsy runs on Windows, the daemon does not see the Windows filesystem at the same paths Windows does. Devsy detects a non-local `DOCKER_HOST` (an `ssh://` or `tcp://` endpoint, rather than the Docker Desktop engine's local socket) and converts Windows drive paths in mounts to their WSL `/mnt/` representation before passing them to the Docker CLI. + +For example, a project at `C:\Users\me\repo` is mounted as `/mnt/c/Users/me/repo` inside the container, matching the location the WSL Docker daemon can read. Keep project files on a Windows drive and let Devsy translate the path; placing project files directly under `\\wsl.localhost\...` UNC paths is not supported for mounts. + + +### Connect the editor (SSH) + +Create the workspace and open it in your editor. Devsy writes an SSH config entry for the workspace and the editor connects through Devsy's built-in SSH proxy: + +```bash +devsy up +``` -## Integrate Devsy with Docker in WSL +In VS Code / VSCodium with the [Open Remote - SSH](https://marketplace.visualstudio.com/items?itemName=jeanp413.open-remote-ssh) extension, select **Open Folder in Remote...** and choose the host matching `.devsy` from your SSH config. Devsy tunnels the editor's SSH session through the WSL Docker daemon into the devcontainer, so no manual SSH jump host configuration is required. -### 8. Setup Devsy Docker provider to use Docker installed in WSL + -Set `Docker Host` as `ssh://@localhost` (replace `` with your WSL username) and `Docker Path` as `docker`. +The Devsy tunnel reuses your local SSH keys and SSH agent. If you set up key authentication in Step 5, the same key works for the editor connection. If you see `write EPIPE` in the editor's remote-SSH log, run `devsy up` with `DEVSY_DEBUG=true` so the structured tunnel logs are surfaced — the underlying cause (for example the WSL SSH server not running, or the host key not yet trusted) is otherwise hidden behind the editor's generic pipe error. + -## Summary +## Next steps -Following this tutorial, you should be able to use Devsy with a Docker provider running in WSL. -You can try any of the examples in `Create Workspace`. +With the provider configured, you can create a workspace that runs on the Docker daemon in WSL. Try any of the examples in [Create a Workspace](../developing-in-workspaces/create-a-workspace.mdx).