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

This commit is contained in:
Milas Bowman 2024-03-12 09:47:41 -04:00 committed by GitHub
parent 4efb89709c
commit 17d4229e57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 321 additions and 39 deletions

View File

@ -37,6 +37,7 @@ import (
"github.com/docker/cli/cli-plugins/manager" "github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/compose/v2/cmd/formatter" "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/internal/tracing"
"github.com/docker/compose/v2/pkg/api" "github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/compose" "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 { PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
// (1) process env vars
err := setEnvWithDotEnv(&opts) err := setEnvWithDotEnv(&opts)
if err != nil { if err != nil {
return err return err
} }
parent := cmd.Root() parent := cmd.Root()
// (2) call parent pre-run
// TODO(milas): this seems incorrect, remove or document
if parent != nil { if parent != nil {
parentPrerun := parent.PersistentPreRunE parentPrerun := parent.PersistentPreRunE
if parentPrerun != nil { 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 noAnsi {
if ansi != "auto" { if ansi != "auto" {
return errors.New(`cannot specify DEPRECATED "--no-ansi" and "--ansi". Please use only "--ansi"`) 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" ansi = "never"
fmt.Fprint(os.Stderr, "option '--no-ansi' is DEPRECATED ! Please use '--ansi' instead.\n") 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") { if v, ok := os.LookupEnv("COMPOSE_ANSI"); ok && !cmd.Flags().Changed("ansi") {
ansi = v ansi = v
} }
formatter.SetANSIMode(dockerCli, ansi) formatter.SetANSIMode(dockerCli, ansi)
if noColor, ok := os.LookupEnv("NO_COLOR"); ok && noColor != "" { 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) return fmt.Errorf("unsupported --progress value %q", opts.Progress)
} }
// (4) options validation / normalization
if opts.WorkDir != "" { if opts.WorkDir != "" {
if opts.ProjectDir != "" { if opts.ProjectDir != "" {
return errors.New(`cannot specify DEPRECATED "--workdir" and "--project-directory". Please use only "--project-directory" instead`) 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 parallel = i
} }
if parallel > 0 { if parallel > 0 {
logrus.Debugf("Limiting max concurrency to %d jobs", parallel)
backend.MaxConcurrency(parallel) backend.MaxConcurrency(parallel)
} }
ctx, err := backend.DryRunMode(cmd.Context(), dryRun)
// (5) dry run detection
ctx, err = backend.DryRunMode(ctx, dryRun)
if err != nil { if err != nil {
return err return err
} }
cmd.SetContext(ctx) 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 return nil
}, },
} }

View File

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

2
go.mod
View File

@ -38,6 +38,7 @@ require (
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
github.com/theupdateframework/notary v0.7.0 github.com/theupdateframework/notary v0.7.0
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 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 v1.19.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc 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 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/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/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 v0.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc 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 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.42.0 // indirect

View File

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

View File

@ -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
internal/memnet/conn.go Normal file
View File

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

View File

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

View File

@ -14,22 +14,20 @@
limitations under the License. limitations under the License.
*/ */
package tracing package memnet
import ( import (
"context" "context"
"fmt"
"net" "net"
"strings"
"github.com/Microsoft/go-winio" "github.com/Microsoft/go-winio"
) )
func DialInMemory(ctx context.Context, addr string) (net.Conn, error) { func dialNamedPipe(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://")
return winio.DialPipeContext(ctx, addr) return winio.DialPipeContext(ctx, addr)
} }
func validateSocketPath(addr string) error {
// AF_UNIX sockets do not have strict path limits on Windows
return nil
}

View File

@ -24,6 +24,7 @@ import (
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/context/store" "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"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"google.golang.org/grpc" "google.golang.org/grpc"
@ -67,7 +68,9 @@ func traceClientFromDockerContext(dockerCli command.Cli, otelEnv envMap) (otlptr
conn, err := grpc.DialContext( conn, err := grpc.DialContext(
dialCtx, dialCtx,
cfg.Endpoint, 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()), grpc.WithTransportCredentials(insecure.NewCredentials()),
) )
if err != nil { if err != nil {

View File

@ -18,6 +18,7 @@ package compose
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@ -25,6 +26,7 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/docker/compose/v2/internal/desktop"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
"github.com/jonboulle/clockwork" "github.com/jonboulle/clockwork"
@ -61,11 +63,28 @@ func NewComposeService(dockerCli command.Cli) api.Service {
type composeService struct { type composeService struct {
dockerCli command.Cli dockerCli command.Cli
desktopCli *desktop.Client
clock clockwork.Clock clock clockwork.Clock
maxConcurrency int maxConcurrency int
dryRun bool 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 { func (s *composeService) apiClient() client.APIClient {
return s.dockerCli.Client() return s.dockerCli.Client()
} }

77
pkg/compose/desktop.go Normal file
View File

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