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 <[email protected]>
  • Loading branch information
milas committed Mar 8, 2024
1 parent 4efb897 commit cab1ac5
Show file tree
Hide file tree
Showing 11 changed files with 318 additions and 36 deletions.
33 changes: 27 additions & 6 deletions cmd/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -379,21 +386,21 @@ 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"`)
}
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 != "" {
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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)

Check warning on line 477 in cmd/compose/compose.go

View check run for this annotation

Codecov / codecov/patch

cmd/compose/compose.go#L477

Added line #L477 was not covered by tests
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)
}

Check warning on line 494 in cmd/compose/compose.go

View check run for this annotation

Codecov / codecov/patch

cmd/compose/compose.go#L491-L494

Added lines #L491 - L494 were not covered by tests
}

return nil
},
}
Expand Down
13 changes: 7 additions & 6 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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)
}

Check warning on line 52 in cmd/main.go

View check run for this annotation

Codecov / codecov/patch

cmd/main.go#L51-L52

Added lines #L51 - L52 were not covered by tests

if originalPreRun != nil {
return originalPreRun(cmd, args)
if originalPreRunE != nil {
return originalPreRunE(cmd, args)
}
return nil
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions internal/desktop/client.go
Original file line number Diff line number Diff line change
@@ -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)
},

Check warning on line 43 in internal/desktop/client.go

View check run for this annotation

Codecov / codecov/patch

internal/desktop/client.go#L38-L43

Added lines #L38 - L43 were not covered by tests
}
transport = otelhttp.NewTransport(transport)

c := &Client{
client: &http.Client{Transport: transport},
}
return c

Check warning on line 50 in internal/desktop/client.go

View check run for this annotation

Codecov / codecov/patch

internal/desktop/client.go#L45-L50

Added lines #L45 - L50 were not covered by tests
}

// Close releases any open connections.
func (c *Client) Close() error {
c.client.CloseIdleConnections()
return nil

Check warning on line 56 in internal/desktop/client.go

View check run for this annotation

Codecov / codecov/patch

internal/desktop/client.go#L54-L56

Added lines #L54 - L56 were not covered by tests
}

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)
}

Check warning on line 78 in internal/desktop/client.go

View check run for this annotation

Codecov / codecov/patch

internal/desktop/client.go#L64-L78

Added lines #L64 - L78 were not covered by tests

var ret PingResponse
if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil {
return nil, err
}
return &ret, nil

Check warning on line 84 in internal/desktop/client.go

View check run for this annotation

Codecov / codecov/patch

internal/desktop/client.go#L80-L84

Added lines #L80 - L84 were not covered by tests
}

// 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, "/")

Check warning on line 92 in internal/desktop/client.go

View check run for this annotation

Codecov / codecov/patch

internal/desktop/client.go#L91-L92

Added lines #L91 - L92 were not covered by tests
}
25 changes: 25 additions & 0 deletions internal/desktop/integration.go
Original file line number Diff line number Diff line change
@@ -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 changes: 50 additions & 0 deletions internal/memnet/conn.go
Original file line number Diff line number Diff line change
@@ -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)

Check warning on line 33 in internal/memnet/conn.go

View check run for this annotation

Codecov / codecov/patch

internal/memnet/conn.go#L26-L33

Added lines #L26 - L33 were not covered by tests
}

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)

Check warning on line 48 in internal/memnet/conn.go

View check run for this annotation

Codecov / codecov/patch

internal/memnet/conn.go#L36-L48

Added lines #L36 - L48 were not covered by tests
}
}
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")

Check warning on line 31 in internal/memnet/conn_unix.go

View check run for this annotation

Codecov / codecov/patch

internal/memnet/conn_unix.go#L30-L31

Added lines #L30 - L31 were not covered by tests
}

func validateSocketPath(addr string) error {

Check warning on line 34 in internal/memnet/conn_unix.go

View check run for this annotation

Codecov / codecov/patch

internal/memnet/conn_unix.go#L34

Added line #L34 was not covered by tests
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)

Check warning on line 36 in internal/memnet/conn_unix.go

View check run for this annotation

Codecov / codecov/patch

internal/memnet/conn_unix.go#L36

Added line #L36 was not covered by tests
}

var d net.Dialer
return d.DialContext(ctx, "unix", addr)
return nil

Check warning on line 38 in internal/memnet/conn_unix.go

View check run for this annotation

Codecov / codecov/patch

internal/memnet/conn_unix.go#L38

Added line #L38 was not covered by tests
}
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

Check warning on line 73 in internal/tracing/docker_context.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/docker_context.go#L71-L73

Added lines #L71 - L73 were not covered by tests
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
Expand Down
Loading

0 comments on commit cab1ac5

Please sign in to comment.