diff --git a/internal/desktop/proxy.go b/internal/desktop/proxy.go new file mode 100644 index 00000000000..0f2af1d0f06 --- /dev/null +++ b/internal/desktop/proxy.go @@ -0,0 +1,115 @@ +/* + Copyright 2026 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package desktop + +import ( + "context" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/moby/moby/client" + "github.com/sirupsen/logrus" + + "github.com/docker/compose/v5/internal/memnet" +) + +// Endpoint returns the Docker Desktop API socket endpoint advertised via the +// engine info labels, or "" when the active engine is not Docker Desktop. +func Endpoint(ctx context.Context, apiClient client.APIClient) (string, error) { + res, err := apiClient.Info(ctx, client.InfoOptions{}) + if err != nil { + return "", err + } + for _, l := range res.Info.Labels { + if k, v, ok := strings.Cut(l, "="); ok && k == EngineLabel { + return v, nil + } + } + return "", nil +} + +// httpProxySocketEndpoint derives Docker Desktop's HTTP proxy socket endpoint +// from a Docker Desktop socket endpoint in the same directory. Returns "" +// when the input is not a recognized form or when the derived unix socket +// does not exist (older DD versions or non-DD installs). +// +// On macOS/Linux: unix:///path/to/Data/docker-cli.sock → unix:///path/to/Data/httpproxy.sock +// On Windows: npipe://./pipe/dockerDesktopLinuxEngine → npipe://./pipe/dockerHttpProxy +func httpProxySocketEndpoint(endpoint string) string { + if sockPath, ok := strings.CutPrefix(endpoint, "unix://"); ok { + proxyPath := filepath.Join(filepath.Dir(sockPath), "httpproxy.sock") + if _, err := os.Stat(proxyPath); err != nil { + return "" + } + return "unix://" + proxyPath + } + if strings.HasPrefix(endpoint, "npipe://") { + return "npipe://./pipe/dockerHttpProxy" + } + return "" +} + +// ProxyTransport returns an http.RoundTripper that routes traffic through +// Docker Desktop's PAC-aware HTTP proxy when DD exposes the proxy socket, +// or nil when no override is needed (callers should use their own default +// transport in that case — for the OCI resolver this means containerd's +// built-in transport). Pass "" for endpoint when DD is not the active +// engine. +// +// When DD is available, the returned transport is a clone of +// http.DefaultTransport with only Proxy and DialContext overridden, so it +// preserves stdlib timeout, pooling, and HTTP/2 defaults. +func ProxyTransport(endpoint string) http.RoundTripper { + proxyEndpoint := httpProxySocketEndpoint(endpoint) + if proxyEndpoint == "" { + logrus.Debug("Docker Desktop HTTP proxy not available; deferring to caller's default transport") + return nil + } + logrus.Debugf("routing OCI traffic through Docker Desktop HTTP proxy at %s", proxyEndpoint) + // Clone http.DefaultTransport to inherit stdlib timeout, pool, and + // HTTP/2 defaults. Type-assertion is guarded since a process may have + // replaced http.DefaultTransport with a wrapping RoundTripper (e.g. + // instrumentation libraries); fall back to a fresh transport in that + // case rather than panicking. + var tr *http.Transport + if defaultTr, ok := http.DefaultTransport.(*http.Transport); ok { + tr = defaultTr.Clone() + } else { + tr = &http.Transport{} + } + tr.Proxy = http.ProxyURL(&url.URL{Scheme: "http"}) + tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + return memnet.DialEndpoint(ctx, proxyEndpoint) + } + return tr +} + +// ProxyTransportFor discovers the Docker Desktop endpoint via apiClient and +// returns the matching transport, or nil when DD is not active or discovery +// fails (so callers fall back to their own default transport). +func ProxyTransportFor(ctx context.Context, apiClient client.APIClient) http.RoundTripper { + endpoint, err := Endpoint(ctx, apiClient) + if err != nil { + logrus.Debugf("could not detect Docker Desktop endpoint, deferring to caller's default transport: %v", err) + return nil + } + return ProxyTransport(endpoint) +} diff --git a/internal/desktop/proxy_test.go b/internal/desktop/proxy_test.go new file mode 100644 index 00000000000..460ec4181ed --- /dev/null +++ b/internal/desktop/proxy_test.go @@ -0,0 +1,107 @@ +/* + Copyright 2026 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package desktop + +import ( + "net/http" + "os" + "path/filepath" + "runtime" + "testing" + + "gotest.tools/v3/assert" +) + +func TestHTTPProxySocketEndpoint_UnixSocketExists(t *testing.T) { + dir := t.TempDir() + cliSock := filepath.Join(dir, "docker-cli.sock") + proxySock := filepath.Join(dir, "httpproxy.sock") + mustTouch(t, cliSock) + mustTouch(t, proxySock) + + got := httpProxySocketEndpoint("unix://" + cliSock) + assert.Equal(t, got, "unix://"+proxySock) +} + +func TestHTTPProxySocketEndpoint_UnixSocketMissing(t *testing.T) { + // httpproxy.sock deliberately not created — older DD or partial install. + dir := t.TempDir() + cliSock := filepath.Join(dir, "docker-cli.sock") + mustTouch(t, cliSock) + + got := httpProxySocketEndpoint("unix://" + cliSock) + assert.Equal(t, got, "", "stat miss must fall back so callers do not dial a non-existent socket") +} + +func TestHTTPProxySocketEndpoint_WindowsNamedPipe(t *testing.T) { + got := httpProxySocketEndpoint("npipe://./pipe/dockerCli") + assert.Equal(t, got, "npipe://./pipe/dockerHttpProxy") +} + +func TestHTTPProxySocketEndpoint_EmptyOrUnknown(t *testing.T) { + assert.Equal(t, httpProxySocketEndpoint(""), "") + assert.Equal(t, httpProxySocketEndpoint("tcp://localhost:1234"), "") +} + +func TestProxyTransport_NilWhenNoDockerDesktop(t *testing.T) { + assert.Assert(t, ProxyTransport("") == nil, + "must return nil so callers fall back to their own (e.g. containerd's) default transport") +} + +func TestProxyTransport_NilWhenSocketMissing(t *testing.T) { + // no httpproxy.sock created + dir := t.TempDir() + cliSock := filepath.Join(dir, "docker-cli.sock") + mustTouch(t, cliSock) + + assert.Assert(t, ProxyTransport("unix://"+cliSock) == nil, + "must return nil when DD endpoint is set but proxy socket is missing, not a transport that would dial a dead socket") +} + +func TestProxyTransport_RoutesThroughDockerDesktop(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix sockets test path; Windows uses named pipes which os.Stat handles differently") + } + dir := t.TempDir() + cliSock := filepath.Join(dir, "docker-cli.sock") + proxySock := filepath.Join(dir, "httpproxy.sock") + mustTouch(t, cliSock) + mustTouch(t, proxySock) + + got := ProxyTransport("unix://" + cliSock) + tr, ok := got.(*http.Transport) + assert.Assert(t, ok, "expected *http.Transport when DD endpoint is set and socket exists") + assert.Assert(t, tr != http.DefaultTransport, "must be a clone, not DefaultTransport itself") + + // Verify the clone preserved http.DefaultTransport's production + // settings (timeouts, idle pool, HTTP/2). Compare to the source + // fields rather than asserting fixed values so this test follows + // stdlib changes. + src := http.DefaultTransport.(*http.Transport) + assert.Equal(t, tr.MaxIdleConns, src.MaxIdleConns) + assert.Equal(t, tr.IdleConnTimeout, src.IdleConnTimeout) + assert.Equal(t, tr.TLSHandshakeTimeout, src.TLSHandshakeTimeout) + assert.Equal(t, tr.ExpectContinueTimeout, src.ExpectContinueTimeout) + assert.Equal(t, tr.ForceAttemptHTTP2, src.ForceAttemptHTTP2) +} + +func mustTouch(t *testing.T, path string) { + t.Helper() + f, err := os.Create(path) + assert.NilError(t, err) + assert.NilError(t, f.Close()) +} diff --git a/internal/oci/resolver.go b/internal/oci/resolver.go index f18c474de57..317fa23a318 100644 --- a/internal/oci/resolver.go +++ b/internal/oci/resolver.go @@ -19,6 +19,7 @@ package oci import ( "context" "io" + "net/http" "net/url" "slices" "strings" @@ -35,28 +36,35 @@ import ( "github.com/docker/compose/v5/internal/registry" ) -// NewResolver setup an OCI Resolver based on docker/cli config to provide registry credentials -func NewResolver(config *configfile.ConfigFile, insecureRegistries ...string) remotes.Resolver { - return docker.NewResolver(docker.ResolverOptions{ - Hosts: docker.ConfigureDefaultRegistries( - docker.WithAuthorizer(docker.NewDockerAuthorizer( - docker.WithAuthCreds(func(host string) (string, string, error) { - host = registry.GetAuthConfigKey(host) - auth, err := config.GetAuthConfig(host) - if err != nil { - return "", "", err - } - if auth.IdentityToken != "" { - return "", auth.IdentityToken, nil - } - return auth.Username, auth.Password, nil - }), - )), - docker.WithPlainHTTP(func(domain string) (bool, error) { - // Should be used for testing **only** - return slices.Contains(insecureRegistries, domain), nil +// NewResolver sets up an OCI Resolver based on docker/cli config to provide +// registry credentials. When transport is non-nil it is used as the HTTP +// transport for all registry calls (e.g. to route through Docker Desktop's +// PAC-aware proxy); nil falls back to containerd's default transport. +func NewResolver(config *configfile.ConfigFile, transport http.RoundTripper, insecureRegistries ...string) remotes.Resolver { + opts := []docker.RegistryOpt{ + docker.WithAuthorizer(docker.NewDockerAuthorizer( + docker.WithAuthCreds(func(host string) (string, string, error) { + host = registry.GetAuthConfigKey(host) + auth, err := config.GetAuthConfig(host) + if err != nil { + return "", "", err + } + if auth.IdentityToken != "" { + return "", auth.IdentityToken, nil + } + return auth.Username, auth.Password, nil }), - ), + )), + docker.WithPlainHTTP(func(domain string) (bool, error) { + // Should be used for testing **only** + return slices.Contains(insecureRegistries, domain), nil + }), + } + if transport != nil { + opts = append(opts, docker.WithClient(&http.Client{Transport: transport})) + } + return docker.NewResolver(docker.ResolverOptions{ + Hosts: docker.ConfigureDefaultRegistries(opts...), }) } diff --git a/internal/oci/resolver_test.go b/internal/oci/resolver_test.go new file mode 100644 index 00000000000..514132e83d5 --- /dev/null +++ b/internal/oci/resolver_test.go @@ -0,0 +1,70 @@ +/* + Copyright 2026 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package oci + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/docker/cli/cli/config/configfile" + "gotest.tools/v3/assert" +) + +// recordingRoundTripper counts RoundTrip invocations on a delegate so tests +// can verify a supplied transport is actually used by the resolver. +type recordingRoundTripper struct { + delegate http.RoundTripper + calls atomic.Int32 +} + +func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + r.calls.Add(1) + return r.delegate.RoundTrip(req) +} + +// TestNewResolver_UsesProvidedTransport guards that the transport passed to +// NewResolver actually carries OCI traffic. The httptest server returns 401 +// so the resolver fails fast without real network access. +func TestNewResolver_UsesProvidedTransport(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(server.Close) + + host := server.Listener.Addr().String() + // Bare *http.Transport (Proxy: nil) keeps the test hermetic — delegating + // to http.DefaultTransport would honor HTTP[S]_PROXY env vars in CI or + // dev shells and route requests away from our local httptest server. + rec := &recordingRoundTripper{delegate: &http.Transport{}} + + // Mark the test host insecure so the resolver uses HTTP scheme; this + // avoids needing a TLS cert chain just to exercise plumbing. + resolver := NewResolver(&configfile.ConfigFile{}, rec, host) + + // We expect 401, but only care that the request reached our transport. + _, _, _ = resolver.Resolve(t.Context(), host+"/test/image:latest") + + assert.Assert(t, rec.calls.Load() > 0, + "resolver did not invoke the supplied transport — wiring is broken") +} + +func TestNewResolver_NilTransportIsValid(t *testing.T) { + resolver := NewResolver(&configfile.ConfigFile{}, nil) + assert.Assert(t, resolver != nil, "NewResolver must return a non-nil resolver when transport is nil") +} diff --git a/pkg/compose/desktop.go b/pkg/compose/desktop.go index 9ca8ea46494..86d5446aee0 100644 --- a/pkg/compose/desktop.go +++ b/pkg/compose/desktop.go @@ -18,28 +18,12 @@ package compose import ( "context" - "strings" - - "github.com/moby/moby/client" "github.com/docker/compose/v5/internal/desktop" ) -// desktopEndpoint returns the Docker Desktop API socket address discovered -// from the Docker engine info labels. It returns "" when the active engine -// is not a Docker Desktop instance. func (s *composeService) desktopEndpoint(ctx context.Context) (string, error) { - res, err := s.apiClient().Info(ctx, client.InfoOptions{}) - if err != nil { - return "", err - } - for _, l := range res.Info.Labels { - k, v, ok := strings.Cut(l, "=") - if ok && k == desktop.EngineLabel { - return v, nil - } - } - return "", nil + return desktop.Endpoint(ctx, s.apiClient()) } // isDesktopIntegrationActive returns true when Docker Desktop is the active engine. diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go index 30b6a6a49b7..67dc7d83e8a 100644 --- a/pkg/compose/publish.go +++ b/pkg/compose/publish.go @@ -37,6 +37,7 @@ import ( v1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sirupsen/logrus" + "github.com/docker/compose/v5/internal/desktop" "github.com/docker/compose/v5/internal/oci" "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/compose/transform" @@ -94,7 +95,7 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re insecureRegistries = append(insecureRegistries, reference.Domain(named)) } - resolver := oci.NewResolver(s.configFile(), insecureRegistries...) + resolver := oci.NewResolver(s.configFile(), desktop.ProxyTransportFor(ctx, s.apiClient()), insecureRegistries...) descriptor, err := oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion) if err != nil { diff --git a/pkg/remote/oci.go b/pkg/remote/oci.go index e64570d2ab0..58d1a61589f 100644 --- a/pkg/remote/oci.go +++ b/pkg/remote/oci.go @@ -20,10 +20,12 @@ import ( "context" "encoding/json" "fmt" + "net/http" "os" "path/filepath" "strconv" "strings" + "sync" "github.com/compose-spec/compose-go/v2/loader" "github.com/containerd/containerd/v2/core/images" @@ -32,6 +34,7 @@ import ( "github.com/docker/cli/cli/command" spec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/docker/compose/v5/internal/desktop" "github.com/docker/compose/v5/internal/oci" "github.com/docker/compose/v5/pkg/api" ) @@ -79,7 +82,7 @@ func ociRemoteLoaderEnabled() (bool, error) { } func NewOCIRemoteLoader(dockerCli command.Cli, offline bool, options api.OCIOptions) loader.ResourceLoader { - return ociRemoteLoader{ + return &ociRemoteLoader{ dockerCli: dockerCli, offline: offline, known: map[string]string{}, @@ -92,14 +95,26 @@ type ociRemoteLoader struct { offline bool known map[string]string insecureRegistries []string + + // HTTP transport for the OCI resolver, initialized lazily so DD + // detection happens once per loader rather than per Load() call. + transportOnce sync.Once + transport http.RoundTripper +} + +func (g *ociRemoteLoader) httpTransport(ctx context.Context) http.RoundTripper { + g.transportOnce.Do(func() { + g.transport = desktop.ProxyTransportFor(ctx, g.dockerCli.Client()) + }) + return g.transport } -func (g ociRemoteLoader) Accept(path string) bool { +func (g *ociRemoteLoader) Accept(path string) bool { return strings.HasPrefix(path, OciPrefix) } //nolint:gocyclo -func (g ociRemoteLoader) Load(ctx context.Context, path string) (string, error) { +func (g *ociRemoteLoader) Load(ctx context.Context, path string) (string, error) { enabled, err := ociRemoteLoaderEnabled() if err != nil { return "", err @@ -119,7 +134,7 @@ func (g ociRemoteLoader) Load(ctx context.Context, path string) (string, error) return "", err } - resolver := oci.NewResolver(g.dockerCli.ConfigFile(), g.insecureRegistries...) + resolver := oci.NewResolver(g.dockerCli.ConfigFile(), g.httpTransport(ctx), g.insecureRegistries...) descriptor, content, err := oci.Get(ctx, resolver, ref) if err != nil { @@ -179,11 +194,11 @@ func (g ociRemoteLoader) Load(ctx context.Context, path string) (string, error) return filepath.Join(local, "compose.yaml"), nil } -func (g ociRemoteLoader) Dir(path string) string { +func (g *ociRemoteLoader) Dir(path string) string { return g.known[path] } -func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, manifest spec.Manifest, ref reference.Named, resolver remotes.Resolver) error { +func (g *ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, manifest spec.Manifest, ref reference.Named, resolver remotes.Resolver) error { err := os.MkdirAll(local, 0o700) if err != nil { return err @@ -259,4 +274,4 @@ func writeEnvFile(layer spec.Descriptor, local string, content []byte) error { return err } -var _ loader.ResourceLoader = ociRemoteLoader{} +var _ loader.ResourceLoader = (*ociRemoteLoader)(nil)