Skip to content

Commit

Permalink
feat(desktop): add Docker Desktop detection and client skeleton
Browse files Browse the repository at this point in the history
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 <milas.bowman@docker.com>
  • Loading branch information
milas committed Mar 8, 2024
1 parent 4efb897 commit 85f1050
Show file tree
Hide file tree
Showing 9 changed files with 229 additions and 26 deletions.
16 changes: 13 additions & 3 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import (
"github.com/docker/cli/cli-plugins/plugin"
"github.com/docker/cli/cli/command"
"github.com/docker/compose/v2/cmd/cmdtrace"
"github.com/docker/compose/v2/internal/desktop"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"

"github.com/docker/compose/v2/cmd/compatibility"
Expand All @@ -39,16 +41,24 @@ func pluginMain() {
cmd := commands.RootCommand(dockerCli, backend)
originalPreRun := cmd.PersistentPreRunE
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

// initialize the dockerCli instance
if err := plugin.PersistentPreRunE(cmd, args); err != nil {
return err
}
// 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 db, ok := backend.(desktop.IntegrationService); ok {
if err := db.MaybeEnableDesktopIntegration(ctx); err != nil {
logrus.Debugf("failed to enable Docker Desktop integration: %v", err)
}
}

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)
Expand Down
77 changes: 77 additions & 0 deletions internal/desktop/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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, "/")
}
9 changes: 9 additions & 0 deletions internal/desktop/integration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package desktop

import (
"context"
)

type IntegrationService interface {
MaybeEnableDesktopIntegration(ctx context.Context) error
}
34 changes: 34 additions & 0 deletions internal/memnet/conn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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)
}
}
19 changes: 7 additions & 12 deletions internal/tracing/conn_unix.go → internal/memnet/conn_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 validateUnixSocketPath(addr string) error {
// AF_UNIX sockets do not have strict path limits on Windows
return nil
}
5 changes: 4 additions & 1 deletion internal/tracing/docker_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
21 changes: 20 additions & 1 deletion pkg/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ package compose

import (
"context"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"sync"

"github.com/docker/compose/v2/internal/desktop"
"github.com/docker/docker/api/types/volume"
"github.com/jonboulle/clockwork"

Expand Down Expand Up @@ -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()
}
Expand Down
58 changes: 58 additions & 0 deletions pkg/compose/desktop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package compose

import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"strconv"
"strings"

"github.com/docker/compose/v2/internal/desktop"
"github.com/sirupsen/logrus"
)

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
}

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
}

if path, ok := strings.CutPrefix(v, "unix://"); ok {
// ensure there's no old/stale socket file (not needed for named pipes)
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("inspecting Desktop socket: %w", err)
}
}

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
}

0 comments on commit 85f1050

Please sign in to comment.