diff --git a/pkg/config/config.go b/pkg/config/config.go index 01222d8e77..a8884bc685 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -18,6 +18,7 @@ import ( "gopkg.in/yaml.v3" "github.com/stacklok/toolhive-core/env" + "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/container/templates" "github.com/stacklok/toolhive/pkg/lockfile" "github.com/stacklok/toolhive/pkg/secrets" @@ -47,6 +48,7 @@ type Config struct { BuildAuthFiles map[string]string `yaml:"build_auth_files,omitempty"` RuntimeConfigs map[string]*templates.RuntimeConfig `yaml:"runtime_configs,omitempty"` RegistryAuth RegistryAuth `yaml:"registry_auth,omitempty"` + ContainerRuntime runtime.SocketConfig `yaml:"container_runtime,omitempty"` } // RegistryAuthTypeOAuth is the auth type for OAuth/OIDC authentication. diff --git a/pkg/container/docker/sdk/client_unix.go b/pkg/container/docker/sdk/client_unix.go index a0b22cd6fa..39d09d4fa5 100644 --- a/pkg/container/docker/sdk/client_unix.go +++ b/pkg/container/docker/sdk/client_unix.go @@ -44,7 +44,7 @@ func newPlatformClient(socketPath string) (*http.Client, []client.Opt) { } // findPlatformContainerSocket finds a container socket path on Unix systems -func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) { +func findPlatformContainerSocket(rt runtime.Type, overrides runtime.SocketConfig) (string, runtime.Type, error) { // First check for custom socket paths via environment variables if customSocketPath := os.Getenv(PodmanSocketEnv); customSocketPath != "" { //nolint:gosec // G706: socket path from trusted environment variable @@ -76,6 +76,11 @@ func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) return customSocketPath, runtime.TypeDocker, nil } + // Check config file overrides (after env vars, before auto-detection) + if p, runtimeType, err := checkSocketConfigOverrides(overrides); p != "" || err != nil { + return p, runtimeType, err + } + if rt == runtime.TypePodman { socketPath, err := findPodmanSocket() if err == nil { @@ -254,3 +259,27 @@ func findColimaSocket() (string, error) { return "", fmt.Errorf("colima socket not found in standard locations") } + +// checkSocketConfigOverrides checks config file socket overrides in priority order. +// Returns ("", "", nil) if no override applies. +func checkSocketConfigOverrides(overrides runtime.SocketConfig) (string, runtime.Type, error) { + if overrides.PodmanSocket != "" { + return resolveConfigSocket(overrides.PodmanSocket, runtime.TypePodman, "Podman") + } + if overrides.DockerSocket != "" { + return resolveConfigSocket(overrides.DockerSocket, runtime.TypeDocker, "Docker") + } + if overrides.ColimaSocket != "" { + return resolveConfigSocket(overrides.ColimaSocket, runtime.TypeDocker, "Colima") + } + return "", "", nil +} + +// resolveConfigSocket validates that a config-supplied socket path exists and returns it. +func resolveConfigSocket(socketPath string, rt runtime.Type, runtimeName string) (string, runtime.Type, error) { + slog.Debug("using socket from config", "runtime", runtimeName, "path", socketPath) + if _, err := os.Stat(socketPath); err != nil { + return "", rt, fmt.Errorf("invalid %s socket path from config: %w", runtimeName, err) + } + return socketPath, rt, nil +} diff --git a/pkg/container/docker/sdk/client_unix_test.go b/pkg/container/docker/sdk/client_unix_test.go new file mode 100644 index 0000000000..cf95525ced --- /dev/null +++ b/pkg/container/docker/sdk/client_unix_test.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build !windows + +package sdk + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/container/runtime" +) + +func makeTempSocket(t *testing.T) string { + t.Helper() + p := filepath.Join(t.TempDir(), "test.sock") + require.NoError(t, os.WriteFile(p, nil, 0600)) + return p +} + +func TestFindContainerSocket_ConfigPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + makePath func(*testing.T) string + wantErr bool + }{ + { + name: "valid path is used", + makePath: makeTempSocket, + wantErr: false, + }, + { + name: "nonexistent path returns error", + makePath: func(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "nonexistent.sock") + }, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + socketPath := tc.makePath(t) + + _, _, err := findContainerSocket(runtime.TypeDocker, runtime.SocketConfig{DockerSocket: socketPath}) + + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestFindContainerSocket_EnvVarPrecedence(t *testing.T) { + // not parallel — t.Setenv cannot be called in parallel tests + envPath := makeTempSocket(t) + t.Setenv(DockerSocketEnv, envPath) + + gotPath, gotRuntime, err := findContainerSocket(runtime.TypeDocker, runtime.SocketConfig{DockerSocket: makeTempSocket(t)}) + + require.NoError(t, err) + require.Equal(t, envPath, gotPath) + require.Equal(t, runtime.TypeDocker, gotRuntime) +} diff --git a/pkg/container/docker/sdk/client_windows.go b/pkg/container/docker/sdk/client_windows.go index f5a382ed8e..17b8294385 100644 --- a/pkg/container/docker/sdk/client_windows.go +++ b/pkg/container/docker/sdk/client_windows.go @@ -59,7 +59,7 @@ func newPlatformClient(pipePath string) (*http.Client, []client.Opt) { } // findPlatformContainerSocket finds a container socket path on Windows -func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) { +func findPlatformContainerSocket(rt runtime.Type, overrides runtime.SocketConfig) (string, runtime.Type, error) { // First check for custom socket paths via environment variables if customPipePath := os.Getenv(PodmanSocketEnv); customPipePath != "" { //nolint:gosec // G706: pipe path from trusted environment variable @@ -89,6 +89,15 @@ func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) return customPipePath, runtime.TypeDocker, nil } + // Check config file overrides (after env vars, before auto-detection). + // Colima is not supported on Windows. + if overrides.PodmanSocket != "" { + return resolveConfigPipe(overrides.PodmanSocket, runtime.TypePodman, "Podman") + } + if overrides.DockerSocket != "" { + return resolveConfigPipe(overrides.DockerSocket, runtime.TypeDocker, "Docker") + } + if rt == runtime.TypePodman { // Try Podman named pipe with timeout ctx, cancel := context.WithTimeout(context.Background(), pipeConnectionTimeout) @@ -117,3 +126,16 @@ func findPlatformContainerSocket(rt runtime.Type) (string, runtime.Type, error) return "", "", ErrRuntimeNotFound } + +// resolveConfigPipe validates that a config-supplied named pipe path is connectable and returns it. +func resolveConfigPipe(pipePath string, rt runtime.Type, runtimeName string) (string, runtime.Type, error) { + slog.Debug("using pipe from config", "runtime", runtimeName, "path", pipePath) + ctx, cancel := context.WithTimeout(context.Background(), pipeConnectionTimeout) + defer cancel() + conn, err := winio.DialPipeContext(ctx, pipePath) + if err != nil { + return "", rt, fmt.Errorf("invalid %s pipe path from config: %w", runtimeName, err) + } + conn.Close() + return pipePath, rt, nil +} diff --git a/pkg/container/docker/sdk/factory.go b/pkg/container/docker/sdk/factory.go index dccb19828b..155b27eaab 100644 --- a/pkg/container/docker/sdk/factory.go +++ b/pkg/container/docker/sdk/factory.go @@ -8,8 +8,12 @@ import ( "context" "fmt" "log/slog" + "os" + "path/filepath" + "github.com/adrg/xdg" "github.com/docker/docker/client" + "gopkg.in/yaml.v3" "github.com/stacklok/toolhive/pkg/container/runtime" ) @@ -49,15 +53,44 @@ const ( var supportedSocketPaths = []runtime.Type{runtime.TypePodman, runtime.TypeDocker, runtime.TypeColima} +// loadSocketOverrides reads socket path overrides from the ToolHive config file. +// Best-effort: returns empty overrides on any error so auto-detection takes over. +func loadSocketOverrides() runtime.SocketConfig { + configPath, err := xdg.ConfigFile("toolhive/config.yaml") + if err != nil { + slog.Debug("failed to resolve config path for socket overrides", "error", err) + return runtime.SocketConfig{} + } + + // #nosec G304: path is derived from XDG config dir, not user input. + data, err := os.ReadFile(filepath.Clean(configPath)) + if err != nil { + slog.Debug("failed to read config file for socket overrides", "error", err) + return runtime.SocketConfig{} + } + + var cfg struct { + ContainerRuntime runtime.SocketConfig `yaml:"container_runtime,omitempty"` + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + slog.Debug("failed to parse config file for socket overrides", "error", err) + return runtime.SocketConfig{} + } + + return cfg.ContainerRuntime +} + // NewDockerClient creates a new container client func NewDockerClient(ctx context.Context) (*client.Client, string, runtime.Type, error) { var lastErr error + overrides := loadSocketOverrides() + // We try to find a container socket for the given runtime // We try Podman first, then Docker as fallback for _, sp := range supportedSocketPaths { // Try to find a container socket for the given runtime - socketPath, runtimeType, err := findContainerSocket(sp) + socketPath, runtimeType, err := findContainerSocket(sp, overrides) if err != nil { //nolint:gosec // G706: runtime type from internal config slog.Debug("failed to find socket", "runtime", sp, "error", err) @@ -105,7 +138,7 @@ func newClientWithSocketPath(ctx context.Context, socketPath string) (*client.Cl } // findContainerSocket finds a container socket path, preferring Podman over Docker -func findContainerSocket(rt runtime.Type) (string, runtime.Type, error) { +func findContainerSocket(rt runtime.Type, overrides runtime.SocketConfig) (string, runtime.Type, error) { // Use platform-specific implementation - return findPlatformContainerSocket(rt) + return findPlatformContainerSocket(rt, overrides) } diff --git a/pkg/container/runtime/types.go b/pkg/container/runtime/types.go index 04838e73a4..beef3615cb 100644 --- a/pkg/container/runtime/types.go +++ b/pkg/container/runtime/types.go @@ -192,6 +192,15 @@ type Monitor interface { StopMonitoring() } +// SocketConfig holds optional socket path overrides for container runtimes. +// When set, these paths take precedence over auto-detection but not over +// environment variables (TOOLHIVE_PODMAN_SOCKET, TOOLHIVE_DOCKER_SOCKET, etc.). +type SocketConfig struct { + PodmanSocket string `yaml:"podman_socket,omitempty"` + DockerSocket string `yaml:"docker_socket,omitempty"` + ColimaSocket string `yaml:"colima_socket,omitempty"` +} + // Type represents the type of container runtime type Type string