Skip to content

Commit 2b6ea81

Browse files
committed
Allow container socket paths to be configured via config file
1 parent 3c5da31 commit 2b6ea81

4 files changed

Lines changed: 146 additions & 5 deletions

File tree

pkg/container/docker/sdk/client_unix.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func newPlatformClient(socketPath string) (*http.Client, []client.Opt) {
4444
}
4545

4646
// findPlatformContainerSocket finds a container socket path on Unix systems
47-
func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) {
47+
func findPlatformContainerSocket(rt runtime.Type, overrides socketPathConfig) (string, runtime.Type, error) {
4848
// First check for custom socket paths via environment variables
4949
if customSocketPath := os.Getenv(PodmanSocketEnv); customSocketPath != "" {
5050
//nolint:gosec // G706: socket path from trusted environment variable
@@ -76,6 +76,17 @@ func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error)
7676
return customSocketPath, runtime.TypeDocker, nil
7777
}
7878

79+
// Check config file overrides (after env vars, before auto-detection)
80+
if overrides.podmanSocket != "" {
81+
return resolveConfigSocket(overrides.podmanSocket, runtime.TypePodman, "Podman")
82+
}
83+
if overrides.dockerSocket != "" {
84+
return resolveConfigSocket(overrides.dockerSocket, runtime.TypeDocker, "Docker")
85+
}
86+
if overrides.colimaSocket != "" {
87+
return resolveConfigSocket(overrides.colimaSocket, runtime.TypeDocker, "Colima")
88+
}
89+
7990
if rt == runtime.TypePodman {
8091
socketPath, err := findPodmanSocket()
8192
if err == nil {
@@ -254,3 +265,12 @@ func findColimaSocket() (string, error) {
254265

255266
return "", fmt.Errorf("colima socket not found in standard locations")
256267
}
268+
269+
// resolveConfigSocket validates that a config-supplied socket path exists and returns it.
270+
func resolveConfigSocket(socketPath string, rt runtime.Type, runtimeName string) (string, runtime.Type, error) {
271+
slog.Debug("using socket from config", "runtime", runtimeName, "path", socketPath)
272+
if _, err := os.Stat(socketPath); err != nil {
273+
return "", rt, fmt.Errorf("invalid %s socket path from config: %w", runtimeName, err)
274+
}
275+
return socketPath, rt, nil
276+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//go:build !windows
5+
6+
package sdk
7+
8+
import (
9+
"os"
10+
"path/filepath"
11+
"testing"
12+
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/stacklok/toolhive/pkg/container/runtime"
16+
)
17+
18+
func makeTempSocket(t *testing.T) string {
19+
t.Helper()
20+
p := filepath.Join(t.TempDir(), "test.sock")
21+
require.NoError(t, os.WriteFile(p, nil, 0600))
22+
return p
23+
}
24+
25+
func TestFindContainerSocket_ConfigOverride(t *testing.T) {
26+
t.Parallel()
27+
socketPath := makeTempSocket(t)
28+
29+
gotPath, gotRuntime, err := findContainerSocket(runtime.TypeDocker, socketPathConfig{dockerSocket: socketPath})
30+
31+
require.NoError(t, err)
32+
require.Equal(t, socketPath, gotPath)
33+
require.Equal(t, runtime.TypeDocker, gotRuntime)
34+
}
35+
36+
func TestFindContainerSocket_EnvVarPrecedence(t *testing.T) {
37+
// not parallel — t.Setenv cannot be called in parallel tests
38+
envPath := makeTempSocket(t)
39+
t.Setenv(DockerSocketEnv, envPath)
40+
41+
gotPath, gotRuntime, err := findContainerSocket(runtime.TypeDocker, socketPathConfig{dockerSocket: makeTempSocket(t)})
42+
43+
require.NoError(t, err)
44+
require.Equal(t, envPath, gotPath)
45+
require.Equal(t, runtime.TypeDocker, gotRuntime)
46+
}

pkg/container/docker/sdk/client_windows.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func newPlatformClient(pipePath string) (*http.Client, []client.Opt) {
5959
}
6060

6161
// findPlatformContainerSocket finds a container socket path on Windows
62-
func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) {
62+
func findPlatformContainerSocket(rt runtime.Type, overrides socketPathConfig) (string, runtime.Type, error) {
6363
// First check for custom socket paths via environment variables
6464
if customPipePath := os.Getenv(PodmanSocketEnv); customPipePath != "" {
6565
//nolint:gosec // G706: pipe path from trusted environment variable
@@ -89,6 +89,15 @@ func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error)
8989
return customPipePath, runtime.TypeDocker, nil
9090
}
9191

92+
// Check config file overrides (after env vars, before auto-detection).
93+
// Colima is not supported on Windows.
94+
if overrides.podmanSocket != "" {
95+
return resolveConfigPipe(overrides.podmanSocket, runtime.TypePodman, "Podman")
96+
}
97+
if overrides.dockerSocket != "" {
98+
return resolveConfigPipe(overrides.dockerSocket, runtime.TypeDocker, "Docker")
99+
}
100+
92101
if rt == runtime.TypePodman {
93102
// Try Podman named pipe with timeout
94103
ctx, cancel := context.WithTimeout(context.Background(), pipeConnectionTimeout)
@@ -117,3 +126,16 @@ func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error)
117126

118127
return "", "", ErrRuntimeNotFound
119128
}
129+
130+
// resolveConfigPipe validates that a config-supplied named pipe path is connectable and returns it.
131+
func resolveConfigPipe(pipePath string, rt runtime.Type, runtimeName string) (string, runtime.Type, error) {
132+
slog.Debug("using pipe from config", "runtime", runtimeName, "path", pipePath)
133+
ctx, cancel := context.WithTimeout(context.Background(), pipeConnectionTimeout)
134+
defer cancel()
135+
conn, err := winio.DialPipeContext(ctx, pipePath)
136+
if err != nil {
137+
return "", rt, fmt.Errorf("invalid %s pipe path from config: %w", runtimeName, err)
138+
}
139+
conn.Close()
140+
return pipePath, rt, nil
141+
}

pkg/container/docker/sdk/factory.go

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ import (
88
"context"
99
"fmt"
1010
"log/slog"
11+
"os"
12+
"path/filepath"
1113

14+
"github.com/adrg/xdg"
1215
"github.com/docker/docker/client"
16+
"gopkg.in/yaml.v3"
1317

1418
"github.com/stacklok/toolhive/pkg/container/runtime"
1519
)
@@ -49,15 +53,64 @@ const (
4953

5054
var supportedSocketPaths = []runtime.Type{runtime.TypePodman, runtime.TypeDocker, runtime.TypeColima}
5155

56+
// socketPathConfig holds optional socket path overrides loaded from config.
57+
type socketPathConfig struct {
58+
podmanSocket string
59+
dockerSocket string
60+
colimaSocket string
61+
}
62+
63+
// containerRuntimeOverrides is a minimal config shape for reading socket path
64+
// overrides from the ToolHive config file. A full pkg/config import is not
65+
// possible here due to an import cycle through pkg/transport -> pkg/container.
66+
type containerRuntimeOverrides struct {
67+
ContainerRuntime struct {
68+
DockerSocket string `yaml:"docker_socket,omitempty"`
69+
PodmanSocket string `yaml:"podman_socket,omitempty"`
70+
ColimaSocket string `yaml:"colima_socket,omitempty"`
71+
} `yaml:"container_runtime,omitempty"`
72+
}
73+
74+
// loadSocketOverrides reads socket path overrides from the ToolHive config file.
75+
// Best-effort: returns empty overrides on any error so auto-detection takes over.
76+
func loadSocketOverrides() socketPathConfig {
77+
configPath, err := xdg.ConfigFile("toolhive/config.yaml")
78+
if err != nil {
79+
slog.Debug("failed to resolve config path for socket overrides", "error", err)
80+
return socketPathConfig{}
81+
}
82+
83+
// #nosec G304: path is derived from XDG config dir, not user input.
84+
data, err := os.ReadFile(filepath.Clean(configPath))
85+
if err != nil {
86+
slog.Debug("failed to read config file for socket overrides", "error", err)
87+
return socketPathConfig{}
88+
}
89+
90+
var cfg containerRuntimeOverrides
91+
if err := yaml.Unmarshal(data, &cfg); err != nil {
92+
slog.Debug("failed to parse config file for socket overrides", "error", err)
93+
return socketPathConfig{}
94+
}
95+
96+
return socketPathConfig{
97+
dockerSocket: cfg.ContainerRuntime.DockerSocket,
98+
podmanSocket: cfg.ContainerRuntime.PodmanSocket,
99+
colimaSocket: cfg.ContainerRuntime.ColimaSocket,
100+
}
101+
}
102+
52103
// NewDockerClient creates a new container client
53104
func NewDockerClient(ctx context.Context) (*client.Client, string, runtime.Type, error) {
54105
var lastErr error
55106

107+
overrides := loadSocketOverrides()
108+
56109
// We try to find a container socket for the given runtime
57110
// We try Podman first, then Docker as fallback
58111
for _, sp := range supportedSocketPaths {
59112
// Try to find a container socket for the given runtime
60-
socketPath, runtimeType, err := findContainerSocket(sp)
113+
socketPath, runtimeType, err := findContainerSocket(sp, overrides)
61114
if err != nil {
62115
//nolint:gosec // G706: runtime type from internal config
63116
slog.Debug("failed to find socket", "runtime", sp, "error", err)
@@ -105,7 +158,7 @@ func newClientWithSocketPath(ctx context.Context, socketPath string) (*client.Cl
105158
}
106159

107160
// findContainerSocket finds a container socket path, preferring Podman over Docker
108-
func findContainerSocket(rt runtime.Type) (string, runtime.Type, error) {
161+
func findContainerSocket(rt runtime.Type, overrides socketPathConfig) (string, runtime.Type, error) {
109162
// Use platform-specific implementation
110-
return findPlatformContainerSocket(rt)
163+
return findPlatformContainerSocket(rt, overrides)
111164
}

0 commit comments

Comments
 (0)