From 17d4229e57feb8ec2f774439719178037b3d629a Mon Sep 17 00:00:00 2001 From: Milas Bowman Date: Tue, 12 Mar 2024 09:47:41 -0400 Subject: [PATCH] feat(desktop): add Docker Desktop detection and client skeleton (#11593) --- cmd/compose/compose.go | 33 +++++-- cmd/main.go | 13 +-- go.mod | 2 +- internal/desktop/client.go | 93 ++++++++++++++++++++ internal/desktop/integration.go | 25 ++++++ internal/memnet/conn.go | 50 +++++++++++ internal/{tracing => memnet}/conn_unix.go | 25 +++--- internal/{tracing => memnet}/conn_windows.go | 16 ++-- internal/tracing/docker_context.go | 5 +- pkg/compose/compose.go | 21 ++++- pkg/compose/desktop.go | 77 ++++++++++++++++ 11 files changed, 321 insertions(+), 39 deletions(-) create mode 100644 internal/desktop/client.go create mode 100644 internal/desktop/integration.go create mode 100644 internal/memnet/conn.go rename internal/{tracing => memnet}/conn_unix.go (64%) rename internal/{tracing => memnet}/conn_windows.go (73%) create mode 100644 pkg/compose/desktop.go diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 8e9d52599..8af123e51 100644 --- a/cmd/compose/compose.go +++ b/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 }, } diff --git a/cmd/main.go b/cmd/main.go index d8c011e4d..038d07cf5 100644 --- a/cmd/main.go +++ b/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 } diff --git a/go.mod b/go.mod index f8435dda6..e84508c7c 100644 --- a/go.mod +++ b/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 diff --git a/internal/desktop/client.go b/internal/desktop/client.go new file mode 100644 index 000000000..e43a10a09 --- /dev/null +++ b/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, "/") +} diff --git a/internal/desktop/integration.go b/internal/desktop/integration.go new file mode 100644 index 000000000..62dd4b931 --- /dev/null +++ b/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 +} diff --git a/internal/memnet/conn.go b/internal/memnet/conn.go new file mode 100644 index 000000000..224bec788 --- /dev/null +++ b/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) + } +} diff --git a/internal/tracing/conn_unix.go b/internal/memnet/conn_unix.go similarity index 64% rename from internal/tracing/conn_unix.go rename to internal/memnet/conn_unix.go index 78294f4be..e15198484 100644 --- a/internal/tracing/conn_unix.go +++ b/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://") - - if len(addr) > maxUnixSocketPathSize { - //goland:noinspection GoErrorStringFormat - return nil, fmt.Errorf("Unix socket address is too long: %s", addr) - } - - var d net.Dialer - return d.DialContext(ctx, "unix", addr) +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 { + return fmt.Errorf("socket address is too long: %s", addr) + } + return nil } diff --git a/internal/tracing/conn_windows.go b/internal/memnet/conn_windows.go similarity index 73% rename from internal/tracing/conn_windows.go rename to internal/memnet/conn_windows.go index 30deaa464..b7f7d9ea8 100644 --- a/internal/tracing/conn_windows.go +++ b/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 +} diff --git a/internal/tracing/docker_context.go b/internal/tracing/docker_context.go index f5f5ece3f..229e77477 100644 --- a/internal/tracing/docker_context.go +++ b/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 { diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index a025b9d90..fa631bfad 100644 --- a/pkg/compose/compose.go +++ b/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() } diff --git a/pkg/compose/desktop.go b/pkg/compose/desktop.go new file mode 100644 index 000000000..9af977fde --- /dev/null +++ b/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 +}