Răsfoiți Sursa

feat(desktop): add Docker Desktop detection and client skeleton (#11593)

Milas Bowman 1 an în urmă
părinte
comite
17d4229e57

+ 27 - 6
cmd/compose/compose.go

@@ -37,6 +37,7 @@ import (
 	"github.com/docker/cli/cli-plugins/manager"
 	"github.com/docker/cli/cli/command"
 	"github.com/docker/compose/v2/cmd/formatter"
+	"github.com/docker/compose/v2/internal/desktop"
 	"github.com/docker/compose/v2/internal/tracing"
 	"github.com/docker/compose/v2/pkg/api"
 	"github.com/docker/compose/v2/pkg/compose"
@@ -365,11 +366,17 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
 			}
 		},
 		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+
+			// (1) process env vars
 			err := setEnvWithDotEnv(&opts)
 			if err != nil {
 				return err
 			}
 			parent := cmd.Root()
+
+			// (2) call parent pre-run
+			// TODO(milas): this seems incorrect, remove or document
 			if parent != nil {
 				parentPrerun := parent.PersistentPreRunE
 				if parentPrerun != nil {
@@ -379,6 +386,11 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
 					}
 				}
 			}
+
+			// (3) set up display/output
+			if verbose {
+				logrus.SetLevel(logrus.TraceLevel)
+			}
 			if noAnsi {
 				if ansi != "auto" {
 					return errors.New(`cannot specify DEPRECATED "--no-ansi" and "--ansi". Please use only "--ansi"`)
@@ -386,14 +398,9 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
 				ansi = "never"
 				fmt.Fprint(os.Stderr, "option '--no-ansi' is DEPRECATED ! Please use '--ansi' instead.\n")
 			}
-			if verbose {
-				logrus.SetLevel(logrus.TraceLevel)
-			}
-
 			if v, ok := os.LookupEnv("COMPOSE_ANSI"); ok && !cmd.Flags().Changed("ansi") {
 				ansi = v
 			}
-
 			formatter.SetANSIMode(dockerCli, ansi)
 
 			if noColor, ok := os.LookupEnv("NO_COLOR"); ok && noColor != "" {
@@ -430,6 +437,7 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
 				return fmt.Errorf("unsupported --progress value %q", opts.Progress)
 			}
 
+			// (4) options validation / normalization
 			if opts.WorkDir != "" {
 				if opts.ProjectDir != "" {
 					return errors.New(`cannot specify DEPRECATED "--workdir" and "--project-directory". Please use only "--project-directory" instead`)
@@ -466,13 +474,26 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
 				parallel = i
 			}
 			if parallel > 0 {
+				logrus.Debugf("Limiting max concurrency to %d jobs", parallel)
 				backend.MaxConcurrency(parallel)
 			}
-			ctx, err := backend.DryRunMode(cmd.Context(), dryRun)
+
+			// (5) dry run detection
+			ctx, err = backend.DryRunMode(ctx, dryRun)
 			if err != nil {
 				return err
 			}
 			cmd.SetContext(ctx)
+
+			// (6) Desktop integration
+			if db, ok := backend.(desktop.IntegrationService); ok {
+				if err := db.MaybeEnableDesktopIntegration(ctx); err != nil {
+					// not fatal, Compose will still work but behave as though
+					// it's not running as part of Docker Desktop
+					logrus.Debugf("failed to enable Docker Desktop integration: %v", err)
+				}
+			}
+
 			return nil
 		},
 	}

+ 7 - 6
cmd/main.go

@@ -25,6 +25,7 @@ import (
 	"github.com/docker/cli/cli/command"
 	"github.com/docker/compose/v2/cmd/cmdtrace"
 	"github.com/docker/docker/client"
+	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 
 	"github.com/docker/compose/v2/cmd/compatibility"
@@ -37,7 +38,7 @@ func pluginMain() {
 	plugin.Run(func(dockerCli command.Cli) *cobra.Command {
 		backend := compose.NewComposeService(dockerCli)
 		cmd := commands.RootCommand(dockerCli, backend)
-		originalPreRun := cmd.PersistentPreRunE
+		originalPreRunE := cmd.PersistentPreRunE
 		cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
 			// initialize the dockerCli instance
 			if err := plugin.PersistentPreRunE(cmd, args); err != nil {
@@ -46,12 +47,12 @@ func pluginMain() {
 			// compose-specific initialization
 			dockerCliPostInitialize(dockerCli)
 
-			// TODO(milas): add an env var to enable logging from the
-			// OTel components for debugging purposes
-			_ = cmdtrace.Setup(cmd, dockerCli, os.Args[1:])
+			if err := cmdtrace.Setup(cmd, dockerCli, os.Args[1:]); err != nil {
+				logrus.Debugf("failed to enable tracing: %v", err)
+			}
 
-			if originalPreRun != nil {
-				return originalPreRun(cmd, args)
+			if originalPreRunE != nil {
+				return originalPreRunE(cmd, args)
 			}
 			return nil
 		}

+ 1 - 1
go.mod

@@ -38,6 +38,7 @@ require (
 	github.com/stretchr/testify v1.8.4
 	github.com/theupdateframework/notary v0.7.0
 	github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0
 	go.opentelemetry.io/otel v1.19.0
 	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0
 	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0
@@ -147,7 +148,6 @@ require (
 	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.45.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 // indirect
 	go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.42.0 // indirect
 	go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.42.0 // indirect
 	go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.42.0 // indirect

+ 93 - 0
internal/desktop/client.go

@@ -0,0 +1,93 @@
+/*
+   Copyright 2024 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"
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/http"
+	"strings"
+
+	"github.com/docker/compose/v2/internal/memnet"
+	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
+)
+
+// Client for integration with Docker Desktop features.
+type Client struct {
+	client *http.Client
+}
+
+// NewClient creates a Desktop integration client for the provided in-memory
+// socket address (AF_UNIX or named pipe).
+func NewClient(apiEndpoint string) *Client {
+	var transport http.RoundTripper = &http.Transport{
+		DisableCompression: true,
+		DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+			return memnet.DialEndpoint(ctx, apiEndpoint)
+		},
+	}
+	transport = otelhttp.NewTransport(transport)
+
+	c := &Client{
+		client: &http.Client{Transport: transport},
+	}
+	return c
+}
+
+// Close releases any open connections.
+func (c *Client) Close() error {
+	c.client.CloseIdleConnections()
+	return nil
+}
+
+type PingResponse struct {
+	ServerTime int64 `json:"serverTime"`
+}
+
+// Ping is a minimal API used to ensure that the server is available.
+func (c *Client) Ping(ctx context.Context) (*PingResponse, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, backendURL("/ping"), http.NoBody)
+	if err != nil {
+		return nil, err
+	}
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		_ = resp.Body.Close()
+	}()
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+	}
+
+	var ret PingResponse
+	if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil {
+		return nil, err
+	}
+	return &ret, nil
+}
+
+// backendURL generates a URL for the given API path.
+//
+// NOTE: Custom transport handles communication. The host is to create a valid
+// URL for the Go http.Client that is also descriptive in error/logs.
+func backendURL(path string) string {
+	return "http://docker-desktop/" + strings.TrimPrefix(path, "/")
+}

+ 25 - 0
internal/desktop/integration.go

@@ -0,0 +1,25 @@
+/*
+   Copyright 2024 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"
+)
+
+type IntegrationService interface {
+	MaybeEnableDesktopIntegration(ctx context.Context) error
+}

+ 50 - 0
internal/memnet/conn.go

@@ -0,0 +1,50 @@
+/*
+   Copyright 2020 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 memnet
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"strings"
+)
+
+func DialEndpoint(ctx context.Context, endpoint string) (net.Conn, error) {
+	if addr, ok := strings.CutPrefix(endpoint, "unix://"); ok {
+		return Dial(ctx, "unix", addr)
+	}
+	if addr, ok := strings.CutPrefix(endpoint, "npipe://"); ok {
+		return Dial(ctx, "npipe", addr)
+	}
+	return nil, fmt.Errorf("unsupported protocol for address: %s", endpoint)
+}
+
+func Dial(ctx context.Context, network, addr string) (net.Conn, error) {
+	var d net.Dialer
+	switch network {
+	case "unix":
+		if err := validateSocketPath(addr); err != nil {
+			return nil, err
+		}
+		return d.DialContext(ctx, "unix", addr)
+	case "npipe":
+		// N.B. this will return an error on non-Windows
+		return dialNamedPipe(ctx, addr)
+	default:
+		return nil, fmt.Errorf("unsupported network: %s", network)
+	}
+}

+ 7 - 12
internal/tracing/conn_unix.go → internal/memnet/conn_unix.go

@@ -16,29 +16,24 @@
    limitations under the License.
 */
 
-package tracing
+package memnet
 
 import (
 	"context"
 	"fmt"
 	"net"
-	"strings"
 	"syscall"
 )
 
 const maxUnixSocketPathSize = len(syscall.RawSockaddrUnix{}.Path)
 
-func DialInMemory(ctx context.Context, addr string) (net.Conn, error) {
-	if !strings.HasPrefix(addr, "unix://") {
-		return nil, fmt.Errorf("not a Unix socket address: %s", addr)
-	}
-	addr = strings.TrimPrefix(addr, "unix://")
+func dialNamedPipe(_ context.Context, _ string) (net.Conn, error) {
+	return nil, fmt.Errorf("named pipes are only available on Windows")
+}
 
+func validateSocketPath(addr string) error {
 	if len(addr) > maxUnixSocketPathSize {
-		//goland:noinspection GoErrorStringFormat
-		return nil, fmt.Errorf("Unix socket address is too long: %s", addr)
+		return fmt.Errorf("socket address is too long: %s", addr)
 	}
-
-	var d net.Dialer
-	return d.DialContext(ctx, "unix", addr)
+	return nil
 }

+ 7 - 9
internal/tracing/conn_windows.go → internal/memnet/conn_windows.go

@@ -14,22 +14,20 @@
    limitations under the License.
 */
 
-package tracing
+package memnet
 
 import (
 	"context"
-	"fmt"
 	"net"
-	"strings"
 
 	"github.com/Microsoft/go-winio"
 )
 
-func DialInMemory(ctx context.Context, addr string) (net.Conn, error) {
-	if !strings.HasPrefix(addr, "npipe://") {
-		return nil, fmt.Errorf("not a named pipe address: %s", addr)
-	}
-	addr = strings.TrimPrefix(addr, "npipe://")
-
+func dialNamedPipe(ctx context.Context, addr string) (net.Conn, error) {
 	return winio.DialPipeContext(ctx, addr)
 }
+
+func validateSocketPath(addr string) error {
+	// AF_UNIX sockets do not have strict path limits on Windows
+	return nil
+}

+ 4 - 1
internal/tracing/docker_context.go

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

+ 20 - 1
pkg/compose/compose.go

@@ -18,6 +18,7 @@ package compose
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"io"
 	"os"
@@ -25,6 +26,7 @@ import (
 	"strings"
 	"sync"
 
+	"github.com/docker/compose/v2/internal/desktop"
 	"github.com/docker/docker/api/types/volume"
 	"github.com/jonboulle/clockwork"
 
@@ -60,12 +62,29 @@ func NewComposeService(dockerCli command.Cli) api.Service {
 }
 
 type composeService struct {
-	dockerCli      command.Cli
+	dockerCli  command.Cli
+	desktopCli *desktop.Client
+
 	clock          clockwork.Clock
 	maxConcurrency int
 	dryRun         bool
 }
 
+// Close releases any connections/resources held by the underlying clients.
+//
+// In practice, this service has the same lifetime as the process, so everything
+// will get cleaned up at about the same time regardless even if not invoked.
+func (s *composeService) Close() error {
+	var errs []error
+	if s.dockerCli != nil {
+		errs = append(errs, s.dockerCli.Client().Close())
+	}
+	if s.desktopCli != nil {
+		errs = append(errs, s.desktopCli.Close())
+	}
+	return errors.Join(errs...)
+}
+
 func (s *composeService) apiClient() client.APIClient {
 	return s.dockerCli.Client()
 }

+ 77 - 0
pkg/compose/desktop.go

@@ -0,0 +1,77 @@
+/*
+   Copyright 2024 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 compose
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/docker/compose/v2/internal/desktop"
+	"github.com/sirupsen/logrus"
+)
+
+// engineLabelDesktopAddress is used to detect that Compose is running with a
+// Docker Desktop context. When this label is present, the value is an endpoint
+// address for an in-memory socket (AF_UNIX or named pipe).
+const engineLabelDesktopAddress = "com.docker.desktop.address"
+
+var _ desktop.IntegrationService = &composeService{}
+
+// MaybeEnableDesktopIntegration initializes the desktop.Client instance if
+// the server info from the Docker Engine is a Docker Desktop instance.
+//
+// EXPERIMENTAL: Requires `COMPOSE_EXPERIMENTAL_DESKTOP=1` env var set.
+func (s *composeService) MaybeEnableDesktopIntegration(ctx context.Context) error {
+	if desktopEnabled, _ := strconv.ParseBool(os.Getenv("COMPOSE_EXPERIMENTAL_DESKTOP")); !desktopEnabled {
+		return nil
+	}
+
+	if s.dryRun {
+		return nil
+	}
+
+	// safeguard to make sure this doesn't get stuck indefinitely
+	ctx, cancel := context.WithTimeout(ctx, time.Second)
+	defer cancel()
+
+	info, err := s.dockerCli.Client().Info(ctx)
+	if err != nil {
+		return fmt.Errorf("querying server info: %w", err)
+	}
+	for _, l := range info.Labels {
+		k, v, ok := strings.Cut(l, "=")
+		if !ok || k != engineLabelDesktopAddress {
+			continue
+		}
+
+		desktopCli := desktop.NewClient(v)
+		_, err := desktopCli.Ping(ctx)
+		if err != nil {
+			return fmt.Errorf("pinging Desktop API: %w", err)
+		}
+		logrus.Debugf("Enabling Docker Desktop integration (experimental): %s", v)
+		s.desktopCli = desktopCli
+		return nil
+	}
+
+	logrus.Trace("Docker Desktop not detected, no integration enabled")
+	return nil
+}