-
Notifications
You must be signed in to change notification settings - Fork 213
Allow container socket paths to be configured via config file #4943
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small inconsistency: the config override for Colima returns Either align all three branches (env + config to return |
||
| } | ||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it be worth routing this through the existing config Provider abstraction? The reason to not just use |
||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Worth a table-driven test that points |
||
| 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) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there's a correctness issue here.
checkSocketConfigOverridesdoesn't consider thertargument, so whichever override field is non-empty wins for every iteration of the outer loop inNewDockerClient.Concrete scenario: a user has a stale
podman_socket:in config and Docker is reachable via auto-detect. Iter 1 (Podman) fails because the Podman path doesn't exist. Iter 2 should auto-detect Docker — butcheckSocketConfigOverridesreturns the same Podman error again, Docker auto-detect never runs, andNewDockerClientreturns"no supported container runtime available: invalid Podman socket path from config"on a machine where Docker works fine.Windows has the same shape at
client_windows.go:92-99.Fix I'd suggest: make the override check match the current
rt— only look atoverrides.PodmanSocketwhenrt == TypePodman, etc. Then a broken podman override only disables podman, and the outer loop's independent-per-runtime semantics work as intended. Side benefit: the "priority ordering" question (which field wins when multiple are set) disappears, since each runtime consults only its own field.