Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions pkg/docker/elevate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package docker

import (
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"time"

"github.com/devsy-org/devsy/pkg/log"
)

// elevationAuthTimeout is generous enough for interactive credential entry,
// unlike the short per-command probe timeouts.
const elevationAuthTimeout = 2 * time.Minute

// Supported privilege-elevation helpers.
const (
elevationPkexec = "pkexec"
elevationSudo = "sudo"
elevationDoas = "doas"
)

// Elevator runs docker commands through a privilege-elevation helper (pkexec,
// sudo, doas). It authenticates once, up front, so an operation's many commands
// share a single prompt via the warmed OS credential cache.
type Elevator struct {
prefix []string // elevation command and leading args; always non-empty

once sync.Once
err error
}

// ElevatorFromName maps a helper name to an Elevator; "" and "none" return
// (nil, nil), unknown names error.
func ElevatorFromName(name string) (*Elevator, error) {
switch strings.ToLower(strings.TrimSpace(name)) {
case "", "none":
return nil, nil
case elevationPkexec:
return &Elevator{prefix: []string{elevationPkexec}}, nil
case elevationSudo:
return &Elevator{prefix: []string{elevationSudo}}, nil
case elevationDoas:
return &Elevator{prefix: []string{elevationDoas}}, nil
default:
return nil, fmt.Errorf(
"unknown privilege elevation %q (want pkexec, sudo, doas, or none)",
name,
)
}
}

// wrap builds the elevated invocation of dockerCommand. env (KEY=VAL entries,
// e.g. DOCKER_HOST) is forwarded through env(1) because sudo/pkexec/doas reset
// the child environment and would otherwise drop provider configuration.
func (e *Elevator) wrap(dockerCommand string, env, args []string) (string, []string) {
full := make([]string, 0, len(e.prefix)+len(env)+len(args)+1)
full = append(full, e.prefix[1:]...)
if len(env) > 0 {
full = append(full, "env")
full = append(full, env...)
}
full = append(full, dockerCommand)
full = append(full, args...)
return e.prefix[0], full
}

// ensureAuthenticated warms the credential cache once. It elevates the
// client-only "--version" (no daemon needed) on its own timeout, so a short
// caller deadline cannot kill the prompt.
func (e *Elevator) ensureAuthenticated(dockerCommand string, env []string) error {
e.once.Do(func() {
ctx, cancel := context.WithTimeout(context.Background(), elevationAuthTimeout)
defer cancel()

name, args := e.wrap(dockerCommand, env, []string{"--version"})
//nolint:gosec // command and args come from trusted provider config
cmd := exec.CommandContext(ctx, name, args...)
if env != nil {
cmd.Env = append(os.Environ(), env...)
}
cmd.Stdin = os.Stdin // attach terminal for the credential prompt
cmd.Stdout = io.Discard
cmd.Stderr = os.Stderr

log.Debugf("authenticating privilege elevation via %s", e.prefix[0])
if err := cmd.Run(); err != nil {
e.err = fmt.Errorf("privilege elevation via %s failed: %w", e.prefix[0], err)
}
})
return e.err
}

// EnsureElevated authenticates the configured elevator once; concurrent callers
// block on the single prompt. No-op and safe when no elevator is set.
func (r *DockerHelper) EnsureElevated() error {
if r.Elevator == nil {
return nil
}
return r.Elevator.ensureAuthenticated(r.DockerCommand, r.Environment)
}
106 changes: 106 additions & 0 deletions pkg/docker/elevate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package docker

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const (
dockerCmd = string(RuntimeDocker)
dockerHost = "DOCKER_HOST=tcp://host:2375"
)

func TestElevatorFromName(t *testing.T) {
tests := []struct {
name string
wantNil bool
wantPrefix []string
wantErr bool
}{
{name: "", wantNil: true},
{name: "none", wantNil: true},
{name: " None ", wantNil: true},
{name: elevationPkexec, wantPrefix: []string{elevationPkexec}},
{name: "SUDO", wantPrefix: []string{elevationSudo}},
{name: elevationDoas, wantPrefix: []string{elevationDoas}},
{name: "gksu", wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e, err := ElevatorFromName(tt.name)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.wantNil {
assert.Nil(t, e)
return
}
require.NotNil(t, e)
assert.Equal(t, tt.wantPrefix, e.prefix)
})
}
}

func TestElevatorWrap(t *testing.T) {
e, err := ElevatorFromName(elevationPkexec)
require.NoError(t, err)

name, args := e.wrap(dockerCmd, nil, []string{"ps", "-q"})
assert.Equal(t, elevationPkexec, name)
assert.Equal(t, []string{dockerCmd, "ps", "-q"}, args)

// No docker args.
name, args = e.wrap("/usr/bin/docker", nil, nil)
assert.Equal(t, elevationPkexec, name)
assert.Equal(t, []string{"/usr/bin/docker"}, args)

// Environment is forwarded through env(1).
name, args = e.wrap(dockerCmd, []string{dockerHost}, []string{"ps"})
assert.Equal(t, elevationPkexec, name)
assert.Equal(t, []string{"env", dockerHost, dockerCmd, "ps"}, args)
}

func TestEnsureElevatedNoOpWithoutElevator(t *testing.T) {
r := &DockerHelper{DockerCommand: dockerCmd}
assert.NoError(t, r.EnsureElevated())
}

func TestBuildCmdWithoutElevator(t *testing.T) {
r := &DockerHelper{DockerCommand: dockerCmd}
cmd := r.buildCmd(t.Context(), "ps", "-q")
assert.Equal(t, []string{dockerCmd, "ps", "-q"}, cmd.Args)
}

func TestBuildCmdWithElevator(t *testing.T) {
e, err := ElevatorFromName(elevationSudo)
require.NoError(t, err)
// Mark authentication as already done so buildCmd does not attempt an
// interactive prompt during the test.
e.once.Do(func() {})

r := &DockerHelper{DockerCommand: dockerCmd, Elevator: e}
cmd := r.buildCmd(t.Context(), "ps", "-q")
assert.Equal(t, []string{elevationSudo, dockerCmd, "ps", "-q"}, cmd.Args)
}

func TestBuildCmdWithElevatorForwardsEnv(t *testing.T) {
e, err := ElevatorFromName(elevationSudo)
require.NoError(t, err)
e.once.Do(func() {})

r := &DockerHelper{
DockerCommand: dockerCmd,
Environment: []string{dockerHost},
Elevator: e,
}
cmd := r.buildCmd(t.Context(), "ps")
assert.Equal(t,
[]string{elevationSudo, "env", dockerHost, dockerCmd, "ps"},
cmd.Args,
)
}
11 changes: 10 additions & 1 deletion pkg/docker/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ type DockerHelper struct {
Environment []string
Builder DockerBuilder
Runtime ContainerRuntime
// Elevator, when set, runs docker commands through a privilege-elevation
// helper (pkexec/sudo/doas). Nil disables elevation.
Elevator *Elevator
}

func (r *DockerHelper) GPUSupportEnabled() (bool, error) {
Expand Down Expand Up @@ -523,7 +526,13 @@ func failedBootSentinel(status string, graceElapsed bool) error {
}

func (r *DockerHelper) buildCmd(ctx context.Context, args ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, r.DockerCommand, args...)
name, cmdArgs := r.DockerCommand, args
if r.Elevator != nil {
_ = r.EnsureElevated() // defensive; normally already done in NewDockerDriver
name, cmdArgs = r.Elevator.wrap(r.DockerCommand, r.Environment, args)
}
//nolint:gosec // command and args come from trusted provider config
cmd := exec.CommandContext(ctx, name, cmdArgs...)
if r.Environment != nil {
cmd.Env = append(os.Environ(), r.Environment...)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Expand Down
28 changes: 21 additions & 7 deletions pkg/driver/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,29 @@ func NewDockerDriver(
rt = docker.DetectRuntime(dockerCommand)
}

elevator, err := docker.ElevatorFromName(workspaceInfo.Agent.Docker.Elevation)
if err != nil {
return nil, fmt.Errorf("invalid elevation config: %w", err)
}

log.Debugf("using docker command: command=%s, runtime=%s", dockerCommand, rt.Name())
dockerHelper := &docker.DockerHelper{
DockerCommand: dockerCommand,
Environment: makeEnvironment(workspaceInfo.Agent.Docker.Env),
ContainerID: workspaceInfo.Workspace.Source.Container,
Builder: builder,
Runtime: rt,
Elevator: elevator,
}

// Authenticate before any command runs, keeping the prompt out of the short
// per-command probe timeouts that would otherwise kill it.
if err := dockerHelper.EnsureElevated(); err != nil {
return nil, err
}

return &dockerDriver{
Docker: &docker.DockerHelper{
DockerCommand: dockerCommand,
Environment: makeEnvironment(workspaceInfo.Agent.Docker.Env),
ContainerID: workspaceInfo.Workspace.Source.Container,
Builder: builder,
Runtime: rt,
},
Docker: dockerHelper,
IDLabels: workspaceInfo.CLIOptions.IDLabels,
UpdateRemoteUserUIDDefault: workspaceInfo.CLIOptions.UpdateRemoteUserUIDDefault,
}, nil
Expand Down
4 changes: 4 additions & 0 deletions pkg/options/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,10 @@ func resolveAgentDockerConfig(
options map[string]string,
) {
agentConfig.Docker.Path = resolver.ResolveDefaultValue(agentConfig.Docker.Path, options)
agentConfig.Docker.Elevation = resolver.ResolveDefaultValue(
agentConfig.Docker.Elevation,
options,
)
agentConfig.Docker.Builder = resolver.ResolveDefaultValue(agentConfig.Docker.Builder, options)
agentConfig.Docker.Install = types.StrBool(
resolver.ResolveDefaultValue(string(agentConfig.Docker.Install), options),
Expand Down
5 changes: 5 additions & 0 deletions pkg/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ type ProviderDockerDriverConfig struct {
// Runtime identifies the container runtime explicitly (docker, podman, nerdctl).
// When empty, the runtime is auto-detected from the binary at Path.
Runtime string `json:"runtime,omitempty"`

// Elevation optionally runs docker commands through a privilege-elevation
// helper for rootful daemons whose socket is not accessible to the current
// user. One of "" / "none" (disabled), "pkexec", "sudo", or "doas".
Elevation string `json:"elevation,omitempty"`
}

type ProviderKubernetesDriverConfig struct {
Expand Down
5 changes: 5 additions & 0 deletions providers/docker/provider.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ optionGroups:
- options:
- DOCKER_PATH
- DOCKER_HOST
- DOCKER_ELEVATION
- INACTIVITY_TIMEOUT
- DOCKER_BUILDER
name: "Advanced Options"
Expand All @@ -17,6 +18,9 @@ options:
DOCKER_PATH:
description: The path where to find the docker binary.
default: docker
DOCKER_ELEVATION:
description: "Optionally run docker commands through a privilege-elevation helper for rootful daemons whose socket the current user cannot access. One of: pkexec, sudo, doas, or none. Note: pkexec targets local desktop sessions with a running polkit agent; use sudo or doas on headless/SSH hosts."
default: none
DOCKER_HOST:
global: true
description: The docker host to use.
Expand All @@ -28,6 +32,7 @@ agent:
local: true
docker:
path: ${DOCKER_PATH}
elevation: ${DOCKER_ELEVATION}
builder: ${DOCKER_BUILDER}
install: false
env:
Expand Down
5 changes: 5 additions & 0 deletions providers/podman/provider.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ optionGroups:
- options:
- PODMAN_PATH
- PODMAN_HOST
- PODMAN_ELEVATION
- INACTIVITY_TIMEOUT
name: "Advanced Options"
options:
Expand All @@ -16,6 +17,9 @@ options:
PODMAN_PATH:
description: The path where to find the podman binary.
default: podman
PODMAN_ELEVATION:
description: "Optionally run podman commands through a privilege-elevation helper for a rootful podman socket the current user cannot access. One of: pkexec, sudo, doas, or none. Leave as none for rootless podman: elevating would target the separate rootful podman instead. Note: pkexec targets local desktop sessions with a running polkit agent; use sudo or doas on headless/SSH hosts."
default: none
PODMAN_HOST:
global: true
description: The podman host to use.
Expand All @@ -24,6 +28,7 @@ agent:
local: true
docker:
path: ${PODMAN_PATH}
elevation: ${PODMAN_ELEVATION}
install: false
runtime: podman
env:
Expand Down
Loading