Skip to content

Commit 85f1050

Browse files
committed
feat(desktop): add Docker Desktop detection and client skeleton
Use the system info Engine API to auto-discover the Desktop integration API endpoint (a local `AF_UNIX` socket or named pipe). If present and responding to a `/ping`, the client is populated on the Compose service for use. Otherwise, it's left `nil`, and Compose will not try to use any functionality that relies on Docker Desktop (e.g. opening `docker-desktop://` deep links to the GUI). Signed-off-by: Milas Bowman <[email protected]>
1 parent 4efb897 commit 85f1050

File tree

9 files changed

+229
-26
lines changed

9 files changed

+229
-26
lines changed

Diff for: cmd/main.go

+13-3
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import (
2424
"github.com/docker/cli/cli-plugins/plugin"
2525
"github.com/docker/cli/cli/command"
2626
"github.com/docker/compose/v2/cmd/cmdtrace"
27+
"github.com/docker/compose/v2/internal/desktop"
2728
"github.com/docker/docker/client"
29+
"github.com/sirupsen/logrus"
2830
"github.com/spf13/cobra"
2931

3032
"github.com/docker/compose/v2/cmd/compatibility"
@@ -39,16 +41,24 @@ func pluginMain() {
3941
cmd := commands.RootCommand(dockerCli, backend)
4042
originalPreRun := cmd.PersistentPreRunE
4143
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
44+
ctx := cmd.Context()
45+
4246
// initialize the dockerCli instance
4347
if err := plugin.PersistentPreRunE(cmd, args); err != nil {
4448
return err
4549
}
4650
// compose-specific initialization
4751
dockerCliPostInitialize(dockerCli)
4852

49-
// TODO(milas): add an env var to enable logging from the
50-
// OTel components for debugging purposes
51-
_ = cmdtrace.Setup(cmd, dockerCli, os.Args[1:])
53+
if db, ok := backend.(desktop.IntegrationService); ok {
54+
if err := db.MaybeEnableDesktopIntegration(ctx); err != nil {
55+
logrus.Debugf("failed to enable Docker Desktop integration: %v", err)
56+
}
57+
}
58+
59+
if err := cmdtrace.Setup(cmd, dockerCli, os.Args[1:]); err != nil {
60+
logrus.Debugf("failed to enable tracing: %v", err)
61+
}
5262

5363
if originalPreRun != nil {
5464
return originalPreRun(cmd, args)

Diff for: internal/desktop/client.go

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package desktop
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net"
8+
"net/http"
9+
"strings"
10+
11+
"github.com/docker/compose/v2/internal/memnet"
12+
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
13+
)
14+
15+
// Client for integration with Docker Desktop features.
16+
type Client struct {
17+
client *http.Client
18+
}
19+
20+
// NewClient creates a Desktop integration client for the provided in-memory
21+
// socket address (AF_UNIX or named pipe).
22+
func NewClient(apiEndpoint string) *Client {
23+
var transport http.RoundTripper = &http.Transport{
24+
DisableCompression: true,
25+
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
26+
return memnet.DialEndpoint(ctx, apiEndpoint)
27+
},
28+
}
29+
transport = otelhttp.NewTransport(transport)
30+
31+
c := &Client{
32+
client: &http.Client{Transport: transport},
33+
}
34+
return c
35+
}
36+
37+
// Close releases any open connections.
38+
func (c *Client) Close() error {
39+
c.client.CloseIdleConnections()
40+
return nil
41+
}
42+
43+
type PingResponse struct {
44+
ServerTime int64 `json:"serverTime"`
45+
}
46+
47+
// Ping is a minimal API used to ensure that the server is available.
48+
func (c *Client) Ping(ctx context.Context) (*PingResponse, error) {
49+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, backendURL("/ping"), http.NoBody)
50+
if err != nil {
51+
return nil, err
52+
}
53+
resp, err := c.client.Do(req)
54+
if err != nil {
55+
return nil, err
56+
}
57+
defer func() {
58+
_ = resp.Body.Close()
59+
}()
60+
if resp.StatusCode != http.StatusOK {
61+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
62+
}
63+
64+
var ret PingResponse
65+
if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil {
66+
return nil, err
67+
}
68+
return &ret, nil
69+
}
70+
71+
// backendURL generates a URL for the given API path.
72+
//
73+
// NOTE: Custom transport handles communication. The host is to create a valid
74+
// URL for the Go http.Client that is also descriptive in error/logs.
75+
func backendURL(path string) string {
76+
return "http://docker-desktop/" + strings.TrimPrefix(path, "/")
77+
}

Diff for: internal/desktop/integration.go

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package desktop
2+
3+
import (
4+
"context"
5+
)
6+
7+
type IntegrationService interface {
8+
MaybeEnableDesktopIntegration(ctx context.Context) error
9+
}

Diff for: internal/memnet/conn.go

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package memnet
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net"
7+
"strings"
8+
)
9+
10+
func DialEndpoint(ctx context.Context, endpoint string) (net.Conn, error) {
11+
if addr, ok := strings.CutPrefix(endpoint, "unix://"); ok {
12+
return Dial(ctx, "unix", addr)
13+
}
14+
if addr, ok := strings.CutPrefix(endpoint, "npipe://"); ok {
15+
return Dial(ctx, "npipe", addr)
16+
}
17+
return nil, fmt.Errorf("unsupported protocol for address: %s", endpoint)
18+
}
19+
20+
func Dial(ctx context.Context, network, addr string) (net.Conn, error) {
21+
var d net.Dialer
22+
switch network {
23+
case "unix":
24+
if err := validateSocketPath(addr); err != nil {
25+
return nil, err
26+
}
27+
return d.DialContext(ctx, "unix", addr)
28+
case "npipe":
29+
// N.B. this will return an error on non-Windows
30+
return dialNamedPipe(ctx, addr)
31+
default:
32+
return nil, fmt.Errorf("unsupported network: %s", network)
33+
}
34+
}

Diff for: internal/tracing/conn_unix.go renamed to internal/memnet/conn_unix.go

+7-12
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,24 @@
1616
limitations under the License.
1717
*/
1818

19-
package tracing
19+
package memnet
2020

2121
import (
2222
"context"
2323
"fmt"
2424
"net"
25-
"strings"
2625
"syscall"
2726
)
2827

2928
const maxUnixSocketPathSize = len(syscall.RawSockaddrUnix{}.Path)
3029

31-
func DialInMemory(ctx context.Context, addr string) (net.Conn, error) {
32-
if !strings.HasPrefix(addr, "unix://") {
33-
return nil, fmt.Errorf("not a Unix socket address: %s", addr)
34-
}
35-
addr = strings.TrimPrefix(addr, "unix://")
30+
func dialNamedPipe(_ context.Context, _ string) (net.Conn, error) {
31+
return nil, fmt.Errorf("named pipes are only available on Windows")
32+
}
3633

34+
func validateSocketPath(addr string) error {
3735
if len(addr) > maxUnixSocketPathSize {
38-
//goland:noinspection GoErrorStringFormat
39-
return nil, fmt.Errorf("Unix socket address is too long: %s", addr)
36+
return fmt.Errorf("socket address is too long: %s", addr)
4037
}
41-
42-
var d net.Dialer
43-
return d.DialContext(ctx, "unix", addr)
38+
return nil
4439
}

Diff for: internal/tracing/conn_windows.go renamed to internal/memnet/conn_windows.go

+7-9
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,20 @@
1414
limitations under the License.
1515
*/
1616

17-
package tracing
17+
package memnet
1818

1919
import (
2020
"context"
21-
"fmt"
2221
"net"
23-
"strings"
2422

2523
"github.com/Microsoft/go-winio"
2624
)
2725

28-
func DialInMemory(ctx context.Context, addr string) (net.Conn, error) {
29-
if !strings.HasPrefix(addr, "npipe://") {
30-
return nil, fmt.Errorf("not a named pipe address: %s", addr)
31-
}
32-
addr = strings.TrimPrefix(addr, "npipe://")
33-
26+
func dialNamedPipe(ctx context.Context, addr string) (net.Conn, error) {
3427
return winio.DialPipeContext(ctx, addr)
3528
}
29+
30+
func validateUnixSocketPath(addr string) error {
31+
// AF_UNIX sockets do not have strict path limits on Windows
32+
return nil
33+
}

Diff for: internal/tracing/docker_context.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424

2525
"github.com/docker/cli/cli/command"
2626
"github.com/docker/cli/cli/context/store"
27+
"github.com/docker/compose/v2/internal/memnet"
2728
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
2829
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
2930
"google.golang.org/grpc"
@@ -67,7 +68,9 @@ func traceClientFromDockerContext(dockerCli command.Cli, otelEnv envMap) (otlptr
6768
conn, err := grpc.DialContext(
6869
dialCtx,
6970
cfg.Endpoint,
70-
grpc.WithContextDialer(DialInMemory),
71+
grpc.WithContextDialer(memnet.DialEndpoint),
72+
// this dial is restricted to using a local Unix socket / named pipe,
73+
// so there is no need for TLS
7174
grpc.WithTransportCredentials(insecure.NewCredentials()),
7275
)
7376
if err != nil {

Diff for: pkg/compose/compose.go

+20-1
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ package compose
1818

1919
import (
2020
"context"
21+
"errors"
2122
"fmt"
2223
"io"
2324
"os"
2425
"strconv"
2526
"strings"
2627
"sync"
2728

29+
"github.com/docker/compose/v2/internal/desktop"
2830
"github.com/docker/docker/api/types/volume"
2931
"github.com/jonboulle/clockwork"
3032

@@ -60,12 +62,29 @@ func NewComposeService(dockerCli command.Cli) api.Service {
6062
}
6163

6264
type composeService struct {
63-
dockerCli command.Cli
65+
dockerCli command.Cli
66+
desktopCli *desktop.Client
67+
6468
clock clockwork.Clock
6569
maxConcurrency int
6670
dryRun bool
6771
}
6872

73+
// Close releases any connections/resources held by the underlying clients.
74+
//
75+
// In practice, this service has the same lifetime as the process, so everything
76+
// will get cleaned up at about the same time regardless even if not invoked.
77+
func (s *composeService) Close() error {
78+
var errs []error
79+
if s.dockerCli != nil {
80+
errs = append(errs, s.dockerCli.Client().Close())
81+
}
82+
if s.desktopCli != nil {
83+
errs = append(errs, s.desktopCli.Close())
84+
}
85+
return errors.Join(errs...)
86+
}
87+
6988
func (s *composeService) apiClient() client.APIClient {
7089
return s.dockerCli.Client()
7190
}

Diff for: pkg/compose/desktop.go

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package compose
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io/fs"
8+
"os"
9+
"strconv"
10+
"strings"
11+
12+
"github.com/docker/compose/v2/internal/desktop"
13+
"github.com/sirupsen/logrus"
14+
)
15+
16+
const engineLabelDesktopAddress = "com.docker.desktop.address"
17+
18+
var _ desktop.IntegrationService = &composeService{}
19+
20+
// MaybeEnableDesktopIntegration initializes the desktop.Client instance if
21+
// the server info from the Docker Engine is a Docker Desktop instance.
22+
//
23+
// EXPERIMENTAL: Requires `COMPOSE_EXPERIMENTAL_DESKTOP=1` env var set.
24+
func (s *composeService) MaybeEnableDesktopIntegration(ctx context.Context) error {
25+
if desktopEnabled, _ := strconv.ParseBool(os.Getenv("COMPOSE_EXPERIMENTAL_DESKTOP")); !desktopEnabled {
26+
return nil
27+
}
28+
29+
info, err := s.dockerCli.Client().Info(ctx)
30+
if err != nil {
31+
return fmt.Errorf("querying server info: %w", err)
32+
}
33+
for _, l := range info.Labels {
34+
k, v, ok := strings.Cut(l, "=")
35+
if !ok || k != engineLabelDesktopAddress {
36+
continue
37+
}
38+
39+
if path, ok := strings.CutPrefix(v, "unix://"); ok {
40+
// ensure there's no old/stale socket file (not needed for named pipes)
41+
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
42+
return fmt.Errorf("inspecting Desktop socket: %w", err)
43+
}
44+
}
45+
46+
desktopCli := desktop.NewClient(v)
47+
_, err := desktopCli.Ping(ctx)
48+
if err != nil {
49+
return fmt.Errorf("pinging Desktop API: %w", err)
50+
}
51+
logrus.Debugf("Enabling Docker Desktop integration (experimental): %s", v)
52+
s.desktopCli = desktopCli
53+
return nil
54+
}
55+
56+
logrus.Trace("Docker Desktop not detected, no integration enabled")
57+
return nil
58+
}

0 commit comments

Comments
 (0)