Skip to content

Commit 99cd4e7

Browse files
authored
feat(image): add Docker context resolution (#9166)
1 parent fe26969 commit 99cd4e7

File tree

7 files changed

+288
-4
lines changed

7 files changed

+288
-4
lines changed

go.mod

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ require (
188188
github.com/blang/semver v3.5.1+incompatible // indirect
189189
github.com/blang/semver/v4 v4.0.0 // indirect
190190
github.com/briandowns/spinner v1.23.0 // indirect
191+
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
191192
github.com/cespare/xxhash/v2 v2.3.0 // indirect
192193
github.com/chai2010/gettext-go v1.0.2 // indirect
193194
github.com/cloudflare/circl v1.6.1 // indirect
@@ -227,6 +228,7 @@ require (
227228
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
228229
github.com/felixge/httpsnoop v1.0.4 // indirect
229230
github.com/fsnotify/fsnotify v1.9.0 // indirect
231+
github.com/fvbommel/sortorder v1.1.0 // indirect
230232
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
231233
github.com/go-chi/chi v4.1.2+incompatible // indirect
232234
github.com/go-errors/errors v1.4.2 // indirect
@@ -272,6 +274,7 @@ require (
272274
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
273275
github.com/gosuri/uitable v0.0.4 // indirect
274276
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
277+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
275278
github.com/hashicorp/errwrap v1.1.0 // indirect
276279
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
277280
github.com/hashicorp/go-safetemp v1.0.0 // indirect
@@ -367,6 +370,7 @@ require (
367370
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
368371
github.com/tchap/go-patricia/v2 v2.3.2 // indirect
369372
github.com/theupdateframework/go-tuf v0.7.0 // indirect
373+
github.com/theupdateframework/notary v0.7.0 // indirect
370374
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
371375
github.com/tklauser/go-sysconf v0.3.13 // indirect
372376
github.com/tklauser/numcpus v0.7.0 // indirect
@@ -394,10 +398,14 @@ require (
394398
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
395399
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
396400
go.opentelemetry.io/otel v1.36.0 // indirect
401+
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect
402+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect
403+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 // indirect
397404
go.opentelemetry.io/otel/metric v1.36.0 // indirect
398405
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
399406
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
400407
go.opentelemetry.io/otel/trace v1.36.0 // indirect
408+
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
401409
go.uber.org/multierr v1.11.0 // indirect
402410
go.uber.org/zap v1.27.0 // indirect
403411
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect

go.sum

Lines changed: 68 additions & 1 deletion
Large diffs are not rendered by default.

pkg/fanal/image/daemon/context.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package daemon
2+
3+
import (
4+
"github.com/docker/cli/cli/command"
5+
"github.com/docker/cli/cli/config"
6+
cliflags "github.com/docker/cli/cli/flags"
7+
"golang.org/x/xerrors"
8+
)
9+
10+
// resolveDockerHost resolves the Docker daemon host based on priority:
11+
// 1. --docker-host flag (highest priority)
12+
// 2. DOCKER_HOST environment variable (handled by NewAPIClientFromFlags)
13+
// 3. DOCKER_CONTEXT environment variable (handled by NewAPIClientFromFlags)
14+
// 4. Current Docker context (default, handled by NewAPIClientFromFlags)
15+
func resolveDockerHost(hostFlag string) (string, error) {
16+
// --docker-host flag
17+
if hostFlag != "" {
18+
return hostFlag, nil
19+
}
20+
21+
// For DOCKER_HOST, DOCKER_CONTEXT and current context resolution, use docker/cli
22+
// This approach validates context existence and returns proper errors
23+
opts := &cliflags.ClientOptions{}
24+
25+
// Load config from DOCKER_CONFIG or default location
26+
configFile, err := config.Load("")
27+
if err != nil {
28+
return "", xerrors.Errorf("failed to load Docker config: %w", err)
29+
}
30+
31+
apiClient, err := command.NewAPIClientFromFlags(opts, configFile)
32+
if err != nil {
33+
return "", xerrors.Errorf("failed to create Docker API client: %w", err)
34+
}
35+
defer apiClient.Close()
36+
37+
// Get the host from the client
38+
return apiClient.DaemonHost(), nil
39+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package daemon
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/docker/cli/cli/config"
10+
"github.com/docker/cli/cli/context/docker"
11+
"github.com/docker/cli/cli/context/store"
12+
dockerclient "github.com/docker/docker/client"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
const testContextName = "test-context"
18+
19+
// createTestContext creates a Docker context using docker/cli context store API
20+
func createTestContext(dockerConfigDir string) error {
21+
// Create context store with proper configuration
22+
cfg := store.NewConfig(
23+
func() any { return &store.Metadata{} },
24+
store.EndpointTypeGetter(docker.DockerEndpoint, func() any { return &docker.EndpointMeta{} }),
25+
)
26+
contextStore := store.New(dockerConfigDir, cfg)
27+
28+
// Create context metadata
29+
contextMetadata := store.Metadata{
30+
Name: testContextName,
31+
Endpoints: map[string]any{
32+
docker.DockerEndpoint: docker.EndpointMeta{
33+
Host: testContextHost,
34+
},
35+
},
36+
}
37+
38+
// Create or update the context
39+
return contextStore.CreateOrUpdate(contextMetadata)
40+
}
41+
42+
// TestResolveDockerHost tests Docker host resolution with various scenarios
43+
// It's challenging to test it through DockerImage due to the need for a Docker daemon,
44+
// so we test the resolveDockerHost function directly, although it's private.
45+
func TestResolveDockerHost(t *testing.T) {
46+
tests := []struct {
47+
name string
48+
hostFlag string
49+
hostEnv string
50+
contextEnv string
51+
currentContext string
52+
want string
53+
wantErr string
54+
}{
55+
{
56+
name: "flag takes highest priority",
57+
hostFlag: testFlagHost,
58+
hostEnv: testEnvHost,
59+
contextEnv: "",
60+
currentContext: "",
61+
want: testFlagHost,
62+
},
63+
{
64+
name: "DOCKER_HOST takes priority over context",
65+
hostFlag: "",
66+
hostEnv: testEnvHost,
67+
contextEnv: "",
68+
currentContext: "",
69+
want: testEnvHost,
70+
},
71+
{
72+
name: "valid context is used",
73+
hostFlag: "",
74+
hostEnv: "",
75+
contextEnv: testContextName,
76+
currentContext: "",
77+
want: testContextHost,
78+
},
79+
{
80+
name: "current context is used when no options",
81+
hostFlag: "",
82+
hostEnv: "",
83+
contextEnv: "",
84+
currentContext: testContextName,
85+
want: testContextHost,
86+
},
87+
{
88+
name: "default context uses default socket when no options",
89+
hostFlag: "",
90+
hostEnv: "",
91+
contextEnv: "",
92+
currentContext: "",
93+
want: dockerclient.DefaultDockerHost,
94+
},
95+
{
96+
name: "invalid context fails",
97+
hostFlag: "",
98+
hostEnv: "",
99+
contextEnv: "non-existent-context",
100+
currentContext: "",
101+
wantErr: "failed to create Docker API client",
102+
},
103+
}
104+
105+
for _, tt := range tests {
106+
t.Run(tt.name, func(t *testing.T) {
107+
// Create temporary Docker config directory
108+
testDir := t.TempDir()
109+
110+
t.Setenv("DOCKER_CONFIG", testDir)
111+
t.Setenv("DOCKER_HOST", tt.hostEnv)
112+
t.Setenv("DOCKER_CONTEXT", tt.contextEnv)
113+
114+
// Set the config directory for docker/cli to use
115+
// This is required to handle global state in docker/cli config.
116+
// Due to sync.Once in docker/cli, this cannot be fully cleaned up after tests.
117+
config.SetDir(testDir)
118+
119+
// Always create a test context
120+
contextDir := filepath.Join(testDir, "contexts")
121+
122+
err := createTestContext(contextDir)
123+
require.NoError(t, err)
124+
125+
// Create config.json
126+
configData := map[string]any{
127+
"currentContext": tt.currentContext,
128+
}
129+
130+
configJSON, err := json.MarshalIndent(configData, "", " ")
131+
require.NoError(t, err)
132+
require.NoError(t, os.WriteFile(filepath.Join(testDir, "config.json"), configJSON, 0o644))
133+
134+
// Test resolveDockerHost
135+
got, err := resolveDockerHost(tt.hostFlag)
136+
if tt.wantErr != "" {
137+
assert.ErrorContains(t, err, tt.wantErr)
138+
return
139+
}
140+
141+
require.NoError(t, err)
142+
assert.Equal(t, tt.want, got)
143+
})
144+
}
145+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//go:build !windows
2+
3+
package daemon
4+
5+
const (
6+
testContextHost = "unix:///tmp/test-context.sock"
7+
8+
// Test socket paths for Unix systems
9+
testFlagHost = "unix:///tmp/flag-docker.sock"
10+
testEnvHost = "unix:///tmp/env-docker.sock"
11+
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package daemon
2+
3+
const (
4+
testContextHost = "npipe:////./pipe/test_docker_engine"
5+
6+
// Test socket paths for Windows systems
7+
testFlagHost = "npipe:////./pipe/flag_docker"
8+
testEnvHost = "npipe:////./pipe/env_docker"
9+
)

pkg/fanal/image/daemon/docker.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,18 @@ import (
1414
func DockerImage(ref name.Reference, host string) (Image, func(), error) {
1515
cleanup := func() {}
1616

17+
// Resolve Docker host based on priority: --docker-host > DOCKER_HOST > DOCKER_CONTEXT > current context
18+
resolvedHost, err := resolveDockerHost(host)
19+
if err != nil {
20+
return nil, cleanup, xerrors.Errorf("failed to resolve Docker host: %w", err)
21+
}
22+
1723
opts := []client.Opt{
1824
client.FromEnv,
1925
client.WithAPIVersionNegotiation(),
2026
}
21-
if host != "" {
22-
// adding host parameter to the last assuming it will pick up more preference
23-
opts = append(opts, client.WithHost(host))
27+
if resolvedHost != "" {
28+
opts = append(opts, client.WithHost(resolvedHost))
2429
}
2530
c, err := client.NewClientWithOpts(opts...)
2631

0 commit comments

Comments
 (0)