diff --git a/e2e/tests/up/provider_podman.go b/e2e/tests/up/provider_podman.go index 598b1bb21..b38c7c37f 100644 --- a/e2e/tests/up/provider_podman.go +++ b/e2e/tests/up/provider_podman.go @@ -47,6 +47,38 @@ var _ = ginkgo.Describe( }, ginkgo.SpecTimeout(framework.TimeoutShort()), ) + + // Regression: rootless podman bind-mounts the workspace folder + // root-owned, so a non-root remoteUser couldn't chdir into it + // and the in-container SSH server failed with "fork/exec + // /usr/bin/bash: permission denied". + ginkgo.It( + "should ssh into a workspace with a non-root remoteUser", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-nonroot-user", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + whoami, err := f.DevsySSH(ctx, tempDir, "whoami") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(whoami)).To(gomega.Equal("devsyuser")) + + // Assert ownership, not just chdir success: a + // world-searchable bind source would let chdir pass even + // without the chown. + owner, err := f.DevsySSH(ctx, tempDir, `stat -c %U "$PWD"`) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(owner)).To(gomega.Equal("devsyuser"), + "workspace folder should be chowned to the remote user") + }, + ginkgo.SpecTimeout(framework.TimeoutLong()), + ) }) ginkgo.Context("build", func() { diff --git a/e2e/tests/up/testdata/docker-nonroot-user/.devcontainer.json b/e2e/tests/up/testdata/docker-nonroot-user/.devcontainer.json new file mode 100644 index 000000000..3703fc592 --- /dev/null +++ b/e2e/tests/up/testdata/docker-nonroot-user/.devcontainer.json @@ -0,0 +1,7 @@ +{ + "name": "Non-root user", + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "devsyuser" +} diff --git a/e2e/tests/up/testdata/docker-nonroot-user/Dockerfile b/e2e/tests/up/testdata/docker-nonroot-user/Dockerfile new file mode 100644 index 000000000..3b0568e4e --- /dev/null +++ b/e2e/tests/up/testdata/docker-nonroot-user/Dockerfile @@ -0,0 +1,4 @@ +FROM ghcr.io/devsy-org/test-images/base:ubuntu + +# Non-root user, used as remoteUser to exercise the podman workspace-chown path. +RUN useradd --create-home --shell /bin/bash devsyuser diff --git a/pkg/devcontainer/run.go b/pkg/devcontainer/run.go index 2f9c56fa6..1ace5901e 100644 --- a/pkg/devcontainer/run.go +++ b/pkg/devcontainer/run.go @@ -350,7 +350,7 @@ func mountSetConsistency(mount, value string) string { } func needsDefaultConsistency() bool { - return runtime.GOOS != "linux" + return runtime.GOOS != goosLinux } func getWorkspace( diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index f42207f16..974a097a0 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -20,6 +20,7 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/crane" "github.com/devsy-org/devsy/pkg/devcontainer/sshtunnel" + "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/ide" "github.com/devsy-org/devsy/pkg/log" @@ -31,6 +32,10 @@ const ( stringTrue = "true" stringFalse = "false" containerRootUser = "root" + + goosLinux = "linux" + goosDarwin = "darwin" + goosWindows = "windows" ) // resolvePullFromInsideContainer: explicit override > crane+git > "". @@ -331,11 +336,25 @@ func (r *runner) addSetupFlags(args *[]string) { } func (r *runner) addChownFlag(args *[]string, isDockerDriver bool) { - if runtime.GOOS == "linux" || !isDockerDriver { + if shouldChownWorkspace(runtime.GOOS, isDockerDriver, r.isPodmanRuntime()) { *args = append(*args, "--chown-workspace") } } +// shouldChownWorkspace reports whether the agent should chown the workspace +// folder to the remote user during setup. Podman needs it on any host OS: +// its `podman machine` bind mounts are root-owned inside the container, so a +// non-root remote user can't enter the workspace folder otherwise. +func shouldChownWorkspace(goos string, isDockerDriver, isPodman bool) bool { + return goos == goosLinux || !isDockerDriver || isPodman +} + +// isPodmanRuntime reports whether the docker driver is backed by the Podman +// runtime (agent.docker.runtime: podman). +func (r *runner) isPodmanRuntime() bool { + return strings.EqualFold(r.WorkspaceConfig.Agent.Docker.Runtime, string(docker.RuntimePodman)) +} + func (r *runner) addDriverFlags(args *[]string, isDockerDriver bool) { if !isDockerDriver { *args = append(*args, "--stream-mounts") diff --git a/pkg/devcontainer/setup_test.go b/pkg/devcontainer/setup_test.go index 963d6e8a3..be9821a25 100644 --- a/pkg/devcontainer/setup_test.go +++ b/pkg/devcontainer/setup_test.go @@ -4,12 +4,91 @@ import ( "testing" "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" ) func boolPtr(v bool) *bool { return &v } +func TestShouldChownWorkspace(t *testing.T) { + cases := []struct { + name string + goos string + isDockerDriver bool + isPodman bool + want bool + }{ + { + name: "linux docker host always chowns", + goos: goosLinux, isDockerDriver: true, isPodman: false, want: true, + }, + { + name: "non-docker driver always chowns (stream mounts)", + goos: goosDarwin, isDockerDriver: false, isPodman: false, want: true, + }, + { + name: "docker desktop on macOS skips chown", + goos: goosDarwin, isDockerDriver: true, isPodman: false, want: false, + }, + { + name: "docker desktop on windows skips chown", + goos: goosWindows, isDockerDriver: true, isPodman: false, want: false, + }, + { + // Regression: previously skipped because podman uses the docker + // driver, breaking non-root remoteUser on macOS/Windows. + name: "podman on macOS chowns despite docker driver", + goos: goosDarwin, isDockerDriver: true, isPodman: true, want: true, + }, + { + name: "podman on windows chowns despite docker driver", + goos: goosWindows, isDockerDriver: true, isPodman: true, want: true, + }, + { + name: "podman on linux chowns", + goos: goosLinux, isDockerDriver: true, isPodman: true, want: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := shouldChownWorkspace(c.goos, c.isDockerDriver, c.isPodman) + if got != c.want { + t.Errorf("shouldChownWorkspace(%q, %v, %v) = %v, want %v", + c.goos, c.isDockerDriver, c.isPodman, got, c.want) + } + }) + } +} + +func TestRunnerIsPodmanRuntime(t *testing.T) { + cases := []struct { + name string + runtime string + want bool + }{ + {name: "podman", runtime: string(docker.RuntimePodman), want: true}, + {name: "podman mixed case", runtime: "Podman", want: true}, + {name: "docker runtime", runtime: string(docker.RuntimeDocker), want: false}, + {name: "empty defaults to non-podman", runtime: "", want: false}, + {name: "nerdctl", runtime: string(docker.RuntimeNerdctl), want: false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := &runner{ + WorkspaceConfig: &provider2.AgentWorkspaceInfo{ + Agent: provider2.ProviderAgentConfig{ + Docker: provider2.ProviderDockerDriverConfig{Runtime: c.runtime}, + }, + }, + } + if got := r.isPodmanRuntime(); got != c.want { + t.Errorf("isPodmanRuntime() with runtime=%q = %v, want %v", c.runtime, got, c.want) + } + }) + } +} + func TestResolvePullFromInsideContainer(t *testing.T) { cases := []struct { name string