From e5f25df36f0061e2a213bc3e6be22bb6d95fc1a4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 7 Sep 2026 04:51:13 +0000 Subject: [PATCH 1/5] fix(docker): preserve client context during credential injection --- .../config_preservation_test.go | 107 ++++++++++++++++++ pkg/dockercredentials/dockercredentials.go | 51 +++++++++ 2 files changed, 158 insertions(+) create mode 100644 pkg/dockercredentials/config_preservation_test.go diff --git a/pkg/dockercredentials/config_preservation_test.go b/pkg/dockercredentials/config_preservation_test.go new file mode 100644 index 000000000..19dc7ad9a --- /dev/null +++ b/pkg/dockercredentials/config_preservation_test.go @@ -0,0 +1,107 @@ +package dockercredentials + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPreserveDockerConfig_ContextMetadata(t *testing.T) { + tempDir := t.TempDir() + srcDir := filepath.Join(tempDir, "src") + require.NoError(t, os.MkdirAll(srcDir, 0o750)) + + t.Setenv("DOCKER_CONFIG", srcDir) + + configContent := []byte(`{"currentContext": "desktop-linux"}`) + configFile := filepath.Join(srcDir, "config.json") + require.NoError(t, os.WriteFile(configFile, configContent, 0o600)) + + metaDir := filepath.Join(srcDir, "contexts", "meta", "some-id") + require.NoError(t, os.MkdirAll(metaDir, 0o750)) + metaFile := filepath.Join(metaDir, "meta.json") + require.NoError(t, os.WriteFile(metaFile, []byte(`{}`), 0o600)) + + destDir := filepath.Join(tempDir, "dest") + require.NoError(t, preserveDockerConfig(destDir)) + + configBytes, err := os.ReadFile(filepath.Clean(filepath.Join(destDir, "config.json"))) + require.NoError(t, err) + assert.Contains(t, string(configBytes), "desktop-linux") + + destMetaFile := filepath.Clean( + filepath.Join(destDir, "contexts", "meta", "some-id", "meta.json"), + ) + _, err = os.Stat(destMetaFile) + require.NoError(t, err) +} + +func TestPreserveDockerConfig_EmptySource(t *testing.T) { + tempDir := t.TempDir() + srcDir := filepath.Join(tempDir, "src") + t.Setenv("DOCKER_CONFIG", srcDir) + + destDir := filepath.Join(tempDir, "dest") + err := preserveDockerConfig(destDir) + require.NoError(t, err) + + _, err = os.Stat(destDir) + require.NoError(t, err) +} + +func TestPreserveDockerConfig_InaccessibleSource(t *testing.T) { + tempDir := t.TempDir() + srcDir := filepath.Join(tempDir, "inaccessible-src") + require.NoError(t, os.MkdirAll(srcDir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "config.json"), []byte("{}"), 0o600)) + + require.NoError(t, os.Chmod(srcDir, 0o000)) + t.Cleanup(func() { + _ = os.Chmod(srcDir, 0o700) //nolint:gosec // restore directory permissions for cleanup + }) + + t.Setenv("DOCKER_CONFIG", srcDir) + + destDir := filepath.Join(tempDir, "dest") + err := preserveDockerConfig(destDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "inspect Docker config") +} + +func TestPreserveDockerConfig_InaccessibleContexts(t *testing.T) { + tempDir := t.TempDir() + srcDir := filepath.Join(tempDir, "src-inaccessible-contexts") + require.NoError(t, os.MkdirAll(srcDir, 0o750)) + t.Setenv("DOCKER_CONFIG", srcDir) + + contextsDir := filepath.Join(srcDir, "contexts") + require.NoError(t, os.MkdirAll(contextsDir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(contextsDir, "meta.json"), []byte("{}"), 0o600)) + require.NoError(t, os.Chmod(contextsDir, 0o000)) + t.Cleanup(func() { + _ = os.Chmod(contextsDir, 0o700) //nolint:gosec // restore directory permissions for cleanup + }) + + destDir := filepath.Join(tempDir, "dest") + err := preserveDockerConfig(destDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "copy contexts") +} + +func TestPreserveDockerConfig_StatErrorContexts(t *testing.T) { + tempDir := t.TempDir() + srcDir := filepath.Join(tempDir, "src-loop-contexts") + require.NoError(t, os.MkdirAll(srcDir, 0o750)) + t.Setenv("DOCKER_CONFIG", srcDir) + + contextsDir := filepath.Join(srcDir, "contexts") + require.NoError(t, os.Symlink(contextsDir, contextsDir)) + + destDir := filepath.Join(tempDir, "dest") + err := preserveDockerConfig(destDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "inspect Docker contexts") +} diff --git a/pkg/dockercredentials/dockercredentials.go b/pkg/dockercredentials/dockercredentials.go index 185bfce74..6057051ef 100644 --- a/pkg/dockercredentials/dockercredentials.go +++ b/pkg/dockercredentials/dockercredentials.go @@ -11,6 +11,7 @@ import ( dockerconfig "github.com/containers/image/v5/pkg/docker/config" "github.com/devsy-org/devsy/pkg/command" pkgconfig "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/copy" "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/file" "github.com/devsy-org/devsy/pkg/log" @@ -158,6 +159,12 @@ func buildHelperContent(binaryPath, shebang string, port int) []byte { func ConfigureCredentialsDockerless(targetFolder string, port int) (string, error) { dockerConfigDir := newDockerCredentialsDir(targetFolder) + + if err := preserveDockerConfig(dockerConfigDir); err != nil { + _ = os.RemoveAll(dockerConfigDir) + return "", err + } + err := configureCredentials( "", "#!/.dockerless/bin/sh", @@ -187,6 +194,12 @@ func ConfigureCredentialsDockerless(targetFolder string, port int) (string, erro func ConfigureCredentialsMachine(targetFolder string, port int) (string, error) { dockerConfigDir := newDockerCredentialsDir(targetFolder) + + if err := preserveDockerConfig(dockerConfigDir); err != nil { + _ = os.RemoveAll(dockerConfigDir) + return "", err + } + err := configureCredentials("", "#!/bin/sh", dockerConfigDir, dockerConfigDir, port) if err != nil { _ = os.RemoveAll(dockerConfigDir) @@ -208,6 +221,44 @@ func ConfigureCredentialsMachine(targetFolder string, port int) (string, error) return dockerConfigDir, nil } +func getDockerConfigDir() string { + if cfgDir := os.Getenv("DOCKER_CONFIG"); cfgDir != "" { + return cfgDir + } + return config.Dir() +} + +func preserveDockerConfig(dockerConfigDir string) error { + srcDir := getDockerConfigDir() + if err := file.MkdirAll("", dockerConfigDir, 0o750); err != nil { + return err + } + + // Copy config.json if it exists + srcConfigFile := filepath.Join(srcDir, config.ConfigFileName) + if _, err := os.Stat(srcConfigFile); err == nil { + destConfigFile := filepath.Join(dockerConfigDir, config.ConfigFileName) + if err := copy.File(srcConfigFile, destConfigFile, 0o600); err != nil { + return fmt.Errorf("copy config.json: %w", err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect Docker config: %w", err) + } + + // Copy contexts if they exist + srcContextsDir := filepath.Join(srcDir, "contexts") + if _, err := os.Stat(srcContextsDir); err == nil { + destContextsDir := filepath.Join(dockerConfigDir, "contexts") + if err := copy.Directory(srcContextsDir, destContextsDir); err != nil { + return fmt.Errorf("copy contexts: %w", err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect Docker contexts: %w", err) + } + + return nil +} + func ListCredentials() (*ListResponse, error) { retList := &ListResponse{Registries: map[string]string{}} // Get all of the credentials from container tools From f0638159edb8dde6080b00e5cc96897fc1c7ad4d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 7 Sep 2026 04:51:15 +0000 Subject: [PATCH 2/5] feat(docker): support explicit Docker context selection --- providers/docker/provider.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/providers/docker/provider.yaml b/providers/docker/provider.yaml index fd8b70bcd..4e1636b4c 100644 --- a/providers/docker/provider.yaml +++ b/providers/docker/provider.yaml @@ -8,6 +8,7 @@ optionGroups: - options: - DOCKER_PATH - DOCKER_HOST + - DOCKER_CONTEXT - DOCKER_ELEVATION - INACTIVITY_TIMEOUT - DOCKER_BUILDER @@ -24,6 +25,9 @@ options: DOCKER_HOST: global: true description: The docker host to use. + DOCKER_CONTEXT: + global: true + description: The Docker context to use. DOCKER_BUILDER: global: true description: The docker builder to use. @@ -37,6 +41,7 @@ agent: install: false env: DOCKER_HOST: ${DOCKER_HOST} + DOCKER_CONTEXT: ${DOCKER_CONTEXT} exec: command: |- "${DEVSY}" internal sh -c "${COMMAND}" From 0430e58666bdb1448a5dd556571d995b949a77da Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 7 Sep 2026 04:51:17 +0000 Subject: [PATCH 3/5] fix(docker): improve runtime preflight diagnostics --- cmd/internal/agentworkspace/up.go | 2 +- pkg/docker/helper.go | 108 ++++++++++++++++++++++++++ pkg/docker/helper_test.go | 116 ++++++++++++++++++++++++++++ pkg/driver/docker/docker.go | 107 +++++++++++++++++++------ pkg/driver/docker/preflight_test.go | 2 +- 5 files changed, 311 insertions(+), 24 deletions(-) diff --git a/cmd/internal/agentworkspace/up.go b/cmd/internal/agentworkspace/up.go index aacde7186..d36a5ffbb 100644 --- a/cmd/internal/agentworkspace/up.go +++ b/cmd/internal/agentworkspace/up.go @@ -421,7 +421,7 @@ func (w *workspaceInitializer) ensureDockerInstalled(ctx context.Context) (strin dockerCmd := w.getDockerCommand() if command.Exists(dockerCmd) { - log.Debug("docker command exists, skipping installation") + log.Debug("docker CLI found") return "", nil } diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index 78216ada1..f97ed9f1d 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -178,6 +178,50 @@ func runCmdCombined(ctx context.Context, cmd *exec.Cmd) error { return nil } +const envUnset = "" + +// RuntimeDiagnostics resolves information about the effective docker runtime configuration. +func (r *DockerHelper) RuntimeDiagnostics(ctx context.Context) map[string]string { + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + diagnostics := map[string]string{ + "command": r.DockerCommand, + "docker_config": resolveEnvValue(r.Environment, "DOCKER_CONFIG"), + "docker_host": resolveEnvValue(r.Environment, "DOCKER_HOST"), + "docker_context": resolveEnvValue(r.Environment, "DOCKER_CONTEXT"), + } + + if r.IsPodman() { + runtimeDiagnosticsPodman(r, diagnostics) + return diagnostics + } + + r.runtimeDiagnosticsDocker(cctx, diagnostics) + return diagnostics +} + +func resolveEnvValue(env []string, key string) string { + if val, ok := envValue(env, key); ok { + return val + } + if val := os.Getenv(key); val != "" { + return val + } + return envUnset +} + +func runtimeDiagnosticsPodman(r *DockerHelper, diag map[string]string) { + diag["context"] = "" + if host := resolveEnvValue(r.Environment, "CONTAINER_HOST"); host != envUnset { + diag["endpoint"] = host + } else if host := resolveEnvValue(r.Environment, "DOCKER_HOST"); host != envUnset { + diag["endpoint"] = host + } else { + diag["endpoint"] = "" + } +} + // Ping reports whether the runtime daemon is reachable, returning its own // message (e.g. "Cannot connect to Podman") on failure. It runs a bare `info` // and judges reachability by exit status: `--format` field names differ @@ -762,6 +806,70 @@ func (r *DockerHelper) buildCmd(ctx context.Context, args ...string) *exec.Cmd { return cmd } +func (r *DockerHelper) resolveDockerContext(ctx context.Context, envContext string) string { + if envContext != envUnset { + return envContext + } + out, err := r.buildCmd(ctx, "context", "show").Output() + if err == nil { + if cur := strings.TrimSpace(string(out)); cur != "" { + return cur + } + } + return "default" +} + +func (r *DockerHelper) getEndpointFromContext(ctx context.Context, contextName string) string { + out, err := r.buildCmd( + ctx, + "context", + "inspect", + contextName, + "--format", + "{{.Endpoints.docker.Host}}", + ).Output() + if err == nil { + if endpoint := strings.TrimSpace(string(out)); endpoint != "" && endpoint != "" { + return endpoint + } + } + return "" +} + +func (r *DockerHelper) endpointForContext(ctx context.Context, contextName string) string { + if ep := r.getEndpointFromContext(ctx, contextName); ep != "" { + return ep + } + if contextName == "default" { + return "unix:///var/run/docker.sock" + } + return "" +} + +func (r *DockerHelper) resolveDockerEndpoint( + ctx context.Context, + activeContext, envContext, dockerHost string, +) string { + if envContext != envUnset { + return r.endpointForContext(ctx, envContext) + } + if dockerHost != envUnset { + return dockerHost + } + return r.endpointForContext(ctx, activeContext) +} + +func (r *DockerHelper) runtimeDiagnosticsDocker(ctx context.Context, diag map[string]string) { + activeContext := r.resolveDockerContext(ctx, diag["docker_context"]) + diag["context"] = activeContext + diag["endpoint"] = r.resolveDockerEndpoint( + ctx, + activeContext, + diag["docker_context"], + diag["docker_host"], + ) +} + // PrepareForGroupCancellation sets the Cancel function of the given exec.Cmd // to kill the entire process group, allowing for cleanup of child processes. // This is necessary because exec.Cmd does not automatically kill child processes diff --git a/pkg/docker/helper_test.go b/pkg/docker/helper_test.go index f4a41c16f..ac076436d 100644 --- a/pkg/docker/helper_test.go +++ b/pkg/docker/helper_test.go @@ -536,3 +536,119 @@ esac assert.Contains(t, err.Error(), "Cannot connect to the Docker daemon", "inspect error should be included for diagnostics") } + +func TestRuntimeDiagnostics_Docker_DefaultContext(t *testing.T) { + tmp := t.TempDir() + bin := writeScript(t, tmp, "docker-fake", `#!/bin/sh +case "$1" in + context) + case "$2" in + show) echo "default";; + inspect) echo "";; + esac + ;; +esac +`) + h := &DockerHelper{DockerCommand: bin} + diag := h.RuntimeDiagnostics(context.Background()) + + assert.Equal(t, "default", diag["context"]) + assert.Equal(t, "unix:///var/run/docker.sock", diag["endpoint"]) +} + +func TestRuntimeDiagnostics_Docker_CustomContext(t *testing.T) { + tmp := t.TempDir() + bin := writeScript(t, tmp, "docker-fake", `#!/bin/sh +case "$1" in + context) + case "$2" in + show) echo "custom-ctx";; + inspect) echo "unix:///custom/docker.sock";; + esac + ;; +esac +`) + h := &DockerHelper{DockerCommand: bin} + diag := h.RuntimeDiagnostics(context.Background()) + + assert.Equal(t, "custom-ctx", diag["context"]) + assert.Equal(t, "unix:///custom/docker.sock", diag["endpoint"]) +} + +func TestRuntimeDiagnostics_Docker_HostOverride(t *testing.T) { + tmp := t.TempDir() + bin := writeScript(t, tmp, "docker-fake", `#!/bin/sh +case "$1" in + context) + case "$2" in + show) echo "default";; + esac + ;; +esac +`) + t.Setenv("DOCKER_HOST", "tcp://example:2376") + t.Setenv("DOCKER_CONTEXT", "") + + h := &DockerHelper{DockerCommand: bin} + diag := h.RuntimeDiagnostics(context.Background()) + + assert.Equal(t, "tcp://example:2376", diag["endpoint"]) + assert.Equal(t, "tcp://example:2376", diag["docker_host"]) +} + +func TestRuntimeDiagnostics_Docker_ExplicitContextOverride(t *testing.T) { + tmp := t.TempDir() + bin := writeScript(t, tmp, "docker-fake", `#!/bin/sh +case "$1" in + context) + case "$2" in + inspect) echo "unix:///explicit/docker.sock";; + esac + ;; +esac +`) + t.Setenv("DOCKER_HOST", "tcp://example:2376") + t.Setenv("DOCKER_CONTEXT", "explicit-ctx") + + h := &DockerHelper{DockerCommand: bin} + diag := h.RuntimeDiagnostics(context.Background()) + + assert.Equal(t, "explicit-ctx", diag["context"]) + assert.Equal(t, "unix:///explicit/docker.sock", diag["endpoint"]) +} + +func TestRuntimeDiagnostics_Podman(t *testing.T) { + tmp := t.TempDir() + bin := writeScript(t, tmp, "podman-fake", `#!/bin/sh +echo "should not be called" >&2 +exit 1 +`) + t.Setenv("DOCKER_HOST", "") + t.Setenv("CONTAINER_HOST", "") + + h := &DockerHelper{ + DockerCommand: bin, + Runtime: podmanRuntime{}, + } + diag := h.RuntimeDiagnostics(context.Background()) + + assert.Equal(t, "", diag["context"]) + assert.Equal(t, "", diag["endpoint"]) +} + +func TestRuntimeDiagnostics_PodmanWithHost(t *testing.T) { + tmp := t.TempDir() + bin := writeScript(t, tmp, "podman-fake", `#!/bin/sh +exit 1 +`) + t.Setenv("CONTAINER_HOST", "unix:///run/user/1000/podman/podman.sock") + + h := &DockerHelper{ + DockerCommand: bin, + Runtime: podmanRuntime{}, + } + diag := h.RuntimeDiagnostics(context.Background()) + + assert.Equal(t, "", diag["context"]) + assert.Equal(t, "unix:///run/user/1000/podman/podman.sock", diag["endpoint"]) +} diff --git a/pkg/driver/docker/docker.go b/pkg/driver/docker/docker.go index d73957b67..27c7645e0 100644 --- a/pkg/driver/docker/docker.go +++ b/pkg/driver/docker/docker.go @@ -111,11 +111,12 @@ var ( // starting a stopped Podman machine unless auto-start is disabled. func (d *dockerDriver) Preflight(ctx context.Context, opts driver.PreflightOptions) error { probe := dockerProbe{ - command: d.Docker.DockerCommand, - runtime: d.Docker.GetRuntime().Name(), - lookPath: exec.LookPath, - ping: d.Docker.Ping, - start: d.Docker.StartPodmanMachine, + command: d.Docker.DockerCommand, + runtime: d.Docker.GetRuntime().Name(), + lookPath: exec.LookPath, + ping: d.Docker.Ping, + start: d.Docker.StartPodmanMachine, + diagnostics: d.Docker.RuntimeDiagnostics, } if podmanMachineApplicable { probe.machineExists = d.Docker.PodmanMachineExists @@ -178,6 +179,9 @@ type dockerProbe struct { // rootless is true when Podman is running rootless (Linux non-root user). rootless bool + + // diagnostics resolves information about the effective docker runtime configuration. + diagnostics func(context.Context) map[string]string } var podmanMachineApplicable = runtime.GOOS != osLinux @@ -185,6 +189,8 @@ var podmanMachineApplicable = runtime.GOOS != osLinux func runPreflight(ctx context.Context, opts driver.PreflightOptions, p dockerProbe) error { runtimeName := string(p.runtime) + logRuntimeDiagnostics(ctx, p) + if _, err := p.lookPath(p.command); err != nil { return &driver.PreflightError{ Provider: runtimeName, @@ -201,30 +207,87 @@ func runPreflight(ctx context.Context, opts driver.PreflightOptions, p dockerPro return nil } - if p.runtime == docker.RuntimePodman && - p.machineExists != nil { // podman machine may be stopped - if exists, checkErr := p.machineExists( - ctx, - ); checkErr == nil && - !exists { // machine does not exist - return &driver.PreflightError{ - Provider: runtimeName, - Err: fmt.Errorf("%w: podman machine is not running", err), - } + if machineErr := checkPodmanMachine(ctx, p, err); machineErr != nil { + return &driver.PreflightError{ + Provider: runtimeName, + Err: machineErr, } } - reachability := fmt.Sprintf("%s daemon is not reachable", p.runtime) + reachability := formatReachabilityMessage(p.runtime, err) + diagDetails := formatDiagnosticDetails(ctx, p) + recommendation := formatDaemonRecommendation(p.runtime) + + return &driver.PreflightError{ + Provider: runtimeName, + Err: fmt.Errorf("%w\n%s%s\n\n%s", err, reachability, diagDetails, recommendation), + } +} + +func logRuntimeDiagnostics(ctx context.Context, p dockerProbe) { + if p.diagnostics == nil { + return + } + diag := p.diagnostics(ctx) + log.Debugf( + "docker runtime resolved:\n command=%s\n context=%s\n endpoint=%s\n"+ + " docker_config=%s\n docker_host=%s\n docker_context=%s", + diag["command"], diag["context"], diag["endpoint"], + diag["docker_config"], diag["docker_host"], diag["docker_context"], + ) +} + +func checkPodmanMachine(ctx context.Context, p dockerProbe, pingErr error) error { + if p.runtime != docker.RuntimePodman || p.machineExists == nil { + return nil + } + exists, checkErr := p.machineExists(ctx) + if checkErr == nil && !exists { + return fmt.Errorf("%w: podman machine is not running", pingErr) + } + return nil +} + +func formatReachabilityMessage(runtime docker.RuntimeName, err error) string { if errors.Is(err, context.DeadlineExceeded) { - reachability = fmt.Sprintf( - "%s daemon did not respond in time (it may just be slow to start, not necessarily down)", - p.runtime, + return fmt.Sprintf( + "%s daemon did not respond in time (it may just be slow to start, not necessarily down).", + runtime, ) } - return &driver.PreflightError{ - Provider: runtimeName, - Err: fmt.Errorf("%w: %s", err, reachability), + return fmt.Sprintf( + "%s CLI is installed, but the selected %s daemon is unreachable.", + runtime, + runtime, + ) +} + +func formatDiagnosticDetails(ctx context.Context, p dockerProbe) string { + if p.diagnostics == nil { + return "" + } + diag := p.diagnostics(ctx) + details := fmt.Sprintf( + "\n\nContext: %s\nEndpoint: %s\nDOCKER_CONFIG: %s\nDOCKER_HOST: %s\nDOCKER_CONTEXT: %s", + diag["context"], + diag["endpoint"], + diag["docker_config"], + diag["docker_host"], + diag["docker_context"], + ) + if rest, ok := strings.CutPrefix(diag["endpoint"], "unix://"); ok { + if _, statErr := os.Stat(rest); os.IsNotExist(statErr) { + details += "\nSocket: not found" + } + } + return details +} + +func formatDaemonRecommendation(runtime docker.RuntimeName) string { + if runtime == docker.RuntimePodman { + return fmt.Sprintf("Check that the %s daemon is running.", runtime) } + return fmt.Sprintf("Check that the %s daemon for the selected context is running.", runtime) } // recoverPodman attempts to bring a stopped Podman backend back up after a ping diff --git a/pkg/driver/docker/preflight_test.go b/pkg/driver/docker/preflight_test.go index a3e52861c..aae5abc9c 100644 --- a/pkg/driver/docker/preflight_test.go +++ b/pkg/driver/docker/preflight_test.go @@ -379,5 +379,5 @@ func TestRunPreflightDaemonRefusalKeepsUnreachableMessage(t *testing.T) { err := runPreflight(context.Background(), driver.PreflightOptions{}, p) require.Error(t, err) require.NotErrorIs(t, err, context.DeadlineExceeded) - require.Contains(t, err.Error(), "is not reachable") + require.Contains(t, err.Error(), "daemon is unreachable") } From 6e1bbad4837a236605972bd67d4428f3b9472839 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 7 Sep 2026 04:51:20 +0000 Subject: [PATCH 4/5] test(e2e): cover Docker context preservation --- e2e/e2e_suite_test.go | 1 + e2e/tests/dockercontext/dockercontext.go | 144 +++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 e2e/tests/dockercontext/dockercontext.go diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index 0898131c1..87d335591 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -14,6 +14,7 @@ import ( _ "github.com/devsy-org/devsy/e2e/tests/configread" _ "github.com/devsy-org/devsy/e2e/tests/context" _ "github.com/devsy-org/devsy/e2e/tests/delivery" + _ "github.com/devsy-org/devsy/e2e/tests/dockercontext" _ "github.com/devsy-org/devsy/e2e/tests/dockerinstall" _ "github.com/devsy-org/devsy/e2e/tests/down" _ "github.com/devsy-org/devsy/e2e/tests/exec" diff --git a/e2e/tests/dockercontext/dockercontext.go b/e2e/tests/dockercontext/dockercontext.go new file mode 100644 index 000000000..f0600d6aa --- /dev/null +++ b/e2e/tests/dockercontext/dockercontext.go @@ -0,0 +1,144 @@ +package dockercontext + +import ( + "context" + "os" + "os/exec" + "path/filepath" + + "github.com/devsy-org/devsy/e2e/framework" + docker "github.com/devsy-org/devsy/pkg/docker" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "docker context test suite", + ginkgo.Label("docker-context"), + func() { + var ( + initialDir string + dockerHelper *docker.DockerHelper + f *framework.Framework + ) + + ginkgo.BeforeEach(func(ctx context.Context) { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + + dockerHelper = &docker.DockerHelper{DockerCommand: "docker"} + if pingErr := dockerHelper.Ping(ctx); pingErr != nil { + ginkgo.Skip("docker daemon is unreachable: " + pingErr.Error()) + } + + f, err = framework.SetupDockerProvider(filepath.Join(initialDir, "bin"), "docker") + framework.ExpectNoError(err) + }) + + ginkgo.It( + "persisted non-default Docker context survives credential injection", + ginkgo.SpecTimeout(framework.TimeoutShort()), + func(ctx context.Context) { + const testContextName = "devsy-test" + + _ = exec.CommandContext(ctx, "docker", "context", "rm", testContextName).Run() + + err := exec.CommandContext( + ctx, + "docker", + "context", + "create", + testContextName, + "--docker", + "host=unix:///var/run/docker.sock", + ).Run() + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = exec.CommandContext(cleanupCtx, "docker", "context", "use", "default").Run() + _ = exec.CommandContext(cleanupCtx, "docker", "context", "rm", testContextName). + Run() + }) + + err = exec.CommandContext(ctx, "docker", "context", "use", testContextName).Run() + framework.ExpectNoError(err) + + origContext, hasContext := os.LookupEnv("DOCKER_CONTEXT") + origHost, hasHost := os.LookupEnv("DOCKER_HOST") + _ = os.Unsetenv("DOCKER_CONTEXT") + _ = os.Unsetenv("DOCKER_HOST") + ginkgo.DeferCleanup(func() { + if hasContext { + _ = os.Setenv("DOCKER_CONTEXT", origContext) + } + if hasHost { + _ = os.Setenv("DOCKER_HOST", origHost) + } + }) + + tempDir, err := framework.CopyToTempDir("tests/up/testdata/docker") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, tempDir) + + stdout, stderr, err := f.DevsyUpStreams(ctx, tempDir) + framework.ExpectNoError(err) + + combined := stdout + "\n" + stderr + gomega.Expect(combined).To(gomega.ContainSubstring("context=" + testContextName)) + }, + ) + + ginkgo.It( + "explicit DOCKER_CONTEXT overrides the persisted default", + ginkgo.SpecTimeout(framework.TimeoutShort()), + func(ctx context.Context) { + const explicitContextName = "devsy-test-explicit" + + _ = exec.CommandContext(ctx, "docker", "context", "rm", explicitContextName).Run() + + err := exec.CommandContext( + ctx, + "docker", + "context", + "create", + explicitContextName, + "--docker", + "host=unix:///var/run/docker.sock", + ).Run() + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = exec.CommandContext(cleanupCtx, "docker", "context", "rm", explicitContextName). + Run() + }) + + err = exec.CommandContext(ctx, "docker", "context", "use", "default").Run() + framework.ExpectNoError(err) + + origContext, hasContext := os.LookupEnv("DOCKER_CONTEXT") + _ = os.Setenv("DOCKER_CONTEXT", explicitContextName) + ginkgo.DeferCleanup(func() { + if hasContext { + _ = os.Setenv("DOCKER_CONTEXT", origContext) + } else { + _ = os.Unsetenv("DOCKER_CONTEXT") + } + }) + + tempDir, err := framework.CopyToTempDir("tests/up/testdata/docker") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, tempDir) + + stdout, stderr, err := f.DevsyUpStreams(ctx, tempDir) + framework.ExpectNoError(err) + + combined := stdout + "\n" + stderr + gomega.Expect(combined). + To(gomega.ContainSubstring("context=" + explicitContextName)) + gomega.Expect(combined). + To(gomega.ContainSubstring("docker_context=" + explicitContextName)) + }, + ) + }, +) From 275b1641b06c9bb8d8f4a0ad5f4e29e0b1617b6a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 7 Sep 2026 04:51:23 +0000 Subject: [PATCH 5/5] docs(docker): document Docker context selection --- .../managing-providers/manage-providers.mdx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) 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 b10596543..5c8b44f2b 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 @@ -183,6 +183,51 @@ devsy provider set aws --option AWS_DISK_SIZE=120 Run `devsy provider get aws` again to confirm — the `VALUE` column for `AWS_DISK_SIZE` now reads `120`. + +## Docker Provider: Contexts and Daemon Endpoints + +Devsy follows standard Docker CLI context and endpoint semantics when running workspaces with the Docker provider. + +### Context Preservation + +When Devsy configures credentials for Docker, it automatically preserves your existing Docker client configuration (`config.json`) and stored context metadata (`contexts/`). This ensures that non-default Docker contexts—such as those managed by Docker Desktop (`desktop-linux`), OrbStack (`orbstack`), or custom remote endpoints—remain active and accessible during workspace creation and execution. + +### Explicit Context Selection (`DOCKER_CONTEXT`) + +You can target a specific Docker context explicitly: + +- Via provider option: + ```sh + devsy provider set docker -o DOCKER_CONTEXT= + ``` +- Via environment variable: + ```sh + DOCKER_CONTEXT= devsy workspace up + ``` + +When set, `DOCKER_CONTEXT` takes precedence over both `DOCKER_HOST` and the persisted current context set in `config.json`. + +### Explicit Daemon Targeting (`DOCKER_HOST`) + +To direct Docker commands directly to a specific socket or remote daemon endpoint without using a context, configure `DOCKER_HOST`: + +- Via provider option: + ```sh + devsy provider set docker -o DOCKER_HOST=tcp://192.0.2.1:2376 + ``` +- Via environment variable: + ```sh + DOCKER_HOST=tcp://192.0.2.1:2376 devsy workspace up + ``` + +### Precedence + +Devsy resolves the active Docker connection in the same order as the Docker CLI: +1. `DOCKER_CONTEXT` (explicit context selection) +2. `DOCKER_HOST` (explicit daemon endpoint) +3. Persisted active context in `~/.docker/config.json` (e.g. Docker Desktop, OrbStack, or custom context) +4. Default local Unix socket (`unix:///var/run/docker.sock`) + ## Single Machine Provider By default, Devsy will use a separate machine for each workspace using the same provider,