diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 2b4bcb638ee..ce019e77d04 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -409,7 +409,7 @@ const PluginName = "compose" // RunningAsStandalone detects when running as a standalone program func RunningAsStandalone() bool { - return len(os.Args) < 2 || os.Args[1] != metadata.MetadataSubcommandName && os.Args[1] != PluginName + return len(os.Args) < 2 || os.Args[1] != metadata.MetadataSubcommandName && os.Args[1] != metadata.HookSubcommandName && os.Args[1] != PluginName } type BackendOptions struct { diff --git a/cmd/compose/hooks.go b/cmd/compose/hooks.go new file mode 100644 index 00000000000..59099d2d89f --- /dev/null +++ b/cmd/compose/hooks.go @@ -0,0 +1,101 @@ +/* + 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 compose + +import ( + "encoding/json" + "io" + + "github.com/docker/cli/cli-plugins/hooks" + "github.com/docker/cli/cli-plugins/metadata" + "github.com/spf13/cobra" +) + +const deepLink = "docker-desktop://dashboard/logs" + +const composeLogsHint = "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + deepLink + +const dockerLogsHint = "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + deepLink + +// hookHint defines a hint that can be returned by the hooks handler. +// When checkFlags is nil, the hint is always returned for the matching command. +// When checkFlags is set, the hint is only returned if the check passes. +type hookHint struct { + template string + checkFlags func(flags map[string]string) bool +} + +// hooksHints maps hook root commands to their hint definitions. +var hooksHints = map[string]hookHint{ + // standalone "docker logs" (not a compose subcommand) + "logs": {template: dockerLogsHint}, + "compose logs": {template: composeLogsHint}, + "compose up": { + template: composeLogsHint, + checkFlags: func(flags map[string]string) bool { + // Only show the hint when running in detached mode + _, hasDetach := flags["detach"] + _, hasD := flags["d"] + return hasDetach || hasD + }, + }, +} + +// HooksCommand returns the hidden subcommand that the Docker CLI invokes +// after command execution when the compose plugin has hooks configured. +// Docker Desktop is responsible for registering which commands trigger hooks +// and for gating on feature flags/settings — the hook handler simply +// responds with the appropriate hint message. +func HooksCommand() *cobra.Command { + return &cobra.Command{ + Use: metadata.HookSubcommandName, + Hidden: true, + // Override PersistentPreRunE to prevent the parent's PersistentPreRunE + // (plugin initialization) from running for hook invocations. + PersistentPreRunE: func(*cobra.Command, []string) error { return nil }, + RunE: func(cmd *cobra.Command, args []string) error { + return handleHook(args, cmd.OutOrStdout()) + }, + } +} + +func handleHook(args []string, w io.Writer) error { + if len(args) == 0 { + return nil + } + + var hookData hooks.Request + if err := json.Unmarshal([]byte(args[0]), &hookData); err != nil { + return nil + } + + hint, ok := hooksHints[hookData.RootCmd] + if !ok { + return nil + } + + if hint.checkFlags != nil && !hint.checkFlags(hookData.Flags) { + return nil + } + + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + return enc.Encode(hooks.Response{ + Type: hooks.NextSteps, + Template: hint.template, + }) +} diff --git a/cmd/compose/hooks_test.go b/cmd/compose/hooks_test.go new file mode 100644 index 00000000000..75660dec4df --- /dev/null +++ b/cmd/compose/hooks_test.go @@ -0,0 +1,136 @@ +/* + 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 compose + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/docker/cli/cli-plugins/hooks" + "gotest.tools/v3/assert" +) + +func TestHandleHook_NoArgs(t *testing.T) { + var buf bytes.Buffer + err := handleHook(nil, &buf) + assert.NilError(t, err) + assert.Equal(t, buf.String(), "") +} + +func TestHandleHook_InvalidJSON(t *testing.T) { + var buf bytes.Buffer + err := handleHook([]string{"not json"}, &buf) + assert.NilError(t, err) + assert.Equal(t, buf.String(), "") +} + +func TestHandleHook_UnknownCommand(t *testing.T) { + data := marshalHookData(t, hooks.Request{ + RootCmd: "compose push", + }) + var buf bytes.Buffer + err := handleHook([]string{data}, &buf) + assert.NilError(t, err) + assert.Equal(t, buf.String(), "") +} + +func TestHandleHook_LogsCommand(t *testing.T) { + tests := []struct { + rootCmd string + wantHint string + }{ + {rootCmd: "compose logs", wantHint: composeLogsHint}, + {rootCmd: "logs", wantHint: dockerLogsHint}, + } + for _, tt := range tests { + t.Run(tt.rootCmd, func(t *testing.T) { + data := marshalHookData(t, hooks.Request{ + RootCmd: tt.rootCmd, + }) + var buf bytes.Buffer + err := handleHook([]string{data}, &buf) + assert.NilError(t, err) + + msg := unmarshalResponse(t, buf.Bytes()) + assert.Equal(t, msg.Type, hooks.NextSteps) + assert.Equal(t, msg.Template, tt.wantHint) + }) + } +} + +func TestHandleHook_ComposeUpDetached(t *testing.T) { + tests := []struct { + name string + flags map[string]string + wantHint bool + }{ + { + name: "with --detach flag", + flags: map[string]string{"detach": ""}, + wantHint: true, + }, + { + name: "with -d flag", + flags: map[string]string{"d": ""}, + wantHint: true, + }, + { + name: "without detach flag", + flags: map[string]string{"build": ""}, + wantHint: false, + }, + { + name: "no flags", + flags: map[string]string{}, + wantHint: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := marshalHookData(t, hooks.Request{ + RootCmd: "compose up", + Flags: tt.flags, + }) + var buf bytes.Buffer + err := handleHook([]string{data}, &buf) + assert.NilError(t, err) + + if tt.wantHint { + msg := unmarshalResponse(t, buf.Bytes()) + assert.Equal(t, msg.Template, composeLogsHint) + } else { + assert.Equal(t, buf.String(), "") + } + }) + } +} + +func marshalHookData(t *testing.T, data hooks.Request) string { + t.Helper() + b, err := json.Marshal(data) + assert.NilError(t, err) + return string(b) +} + +func unmarshalResponse(t *testing.T, data []byte) hooks.Response { + t.Helper() + var msg hooks.Response + err := json.Unmarshal(data, &msg) + assert.NilError(t, err) + return msg +} diff --git a/cmd/formatter/shortcut.go b/cmd/formatter/shortcut.go index 163182baff9..bb07ff72def 100644 --- a/cmd/formatter/shortcut.go +++ b/cmd/formatter/shortcut.go @@ -94,13 +94,15 @@ type LogKeyboard struct { Watch *KeyboardWatch Detach func() IsDockerDesktopActive bool + IsLogsViewEnabled bool logLevel KEYBOARD_LOG_LEVEL signalChannel chan<- os.Signal } -func NewKeyboardManager(isDockerDesktopActive bool, sc chan<- os.Signal) *LogKeyboard { +func NewKeyboardManager(isDockerDesktopActive, isLogsViewEnabled bool, sc chan<- os.Signal) *LogKeyboard { return &LogKeyboard{ IsDockerDesktopActive: isDockerDesktopActive, + IsLogsViewEnabled: isLogsViewEnabled, logLevel: INFO, signalChannel: sc, } @@ -173,6 +175,10 @@ func (lk *LogKeyboard) navigationMenu() string { items = append(items, shortcutKeyColor("o")+navColor(" View Config")) } + if lk.IsLogsViewEnabled { + items = append(items, shortcutKeyColor("l")+navColor(" View Logs")) + } + isEnabled := " Enable" if lk.Watch != nil && lk.Watch.Watching { isEnabled = " Disable" @@ -232,6 +238,24 @@ func (lk *LogKeyboard) openDDComposeUI(ctx context.Context, project *types.Proje }() } +func (lk *LogKeyboard) openDDLogsView(ctx context.Context) { + if !lk.IsLogsViewEnabled { + return + } + go func() { + _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/logsview", tracing.SpanOptions{}, + func(ctx context.Context) error { + link := "docker-desktop://dashboard/logs" + err := open.Run(link) + if err != nil { + err = fmt.Errorf("could not open Docker Desktop Logs view: %w", err) + lk.keyboardError("View Logs", err) + } + return err + })() + }() +} + func (lk *LogKeyboard) openDDWatchDocs(ctx context.Context, project *types.Project) { go func() { _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/watch", tracing.SpanOptions{}, @@ -311,6 +335,8 @@ func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEv lk.ToggleWatch(ctx, options) case 'o': lk.openDDComposeUI(ctx, project) + case 'l': + lk.openDDLogsView(ctx) } switch key := event.Key; key { case keyboard.KeyCtrlC: diff --git a/cmd/main.go b/cmd/main.go index 7a5ec58d60b..7d3fab4f5ed 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -44,6 +44,7 @@ func pluginMain() { } cmd := commands.RootCommand(cli, backendOptions) + cmd.AddCommand(commands.HooksCommand()) originalPreRunE := cmd.PersistentPreRunE cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { // initialize the cli instance diff --git a/internal/desktop/client.go b/internal/desktop/client.go index 1e28899370d..b7535e1f573 100644 --- a/internal/desktop/client.go +++ b/internal/desktop/client.go @@ -23,6 +23,7 @@ import ( "io" "net" "net/http" + "path/filepath" "strings" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" @@ -31,6 +32,14 @@ import ( "github.com/docker/compose/v5/internal/memnet" ) +// EngineLabel is used to detect that Compose is running with a Docker +// Desktop context. When present, the value is an endpoint address for an +// in-memory socket (AF_UNIX or named pipe). +const EngineLabel = "com.docker.desktop.address" + +// FeatureLogsTab is the feature flag name for the Docker Desktop Logs view. +const FeatureLogsTab = "LogsTab" + // identify this client in the logs var userAgent = "compose/" + internal.Version @@ -128,6 +137,104 @@ func (c *Client) FeatureFlags(ctx context.Context) (FeatureFlagResponse, error) return ret, nil } +// SettingValue represents a Docker Desktop setting with a locked flag and a value. +type SettingValue struct { + Locked bool `json:"locked"` + Value bool `json:"value"` +} + +// DesktopSettings represents the "desktop" section of Docker Desktop settings. +type DesktopSettings struct { + EnableLogsTab SettingValue `json:"enableLogsTab"` +} + +// SettingsResponse represents the Docker Desktop settings response. +type SettingsResponse struct { + Desktop DesktopSettings `json:"desktop"` +} + +// Settings fetches the Docker Desktop application settings. +func (c *Client) Settings(ctx context.Context) (*SettingsResponse, error) { + req, err := c.newRequest(ctx, http.MethodGet, "/app/settings", 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 SettingsResponse + if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil { + return nil, err + } + return &ret, nil +} + +// IsFeatureEnabled checks both the feature flag (GET /features) and the user +// setting (GET /app/settings) for a given feature. Returns true only when the +// feature is both rolled out and enabled by the user. Features without a +// corresponding setting entry are considered enabled if the flag is set. +func (c *Client) IsFeatureEnabled(ctx context.Context, feature string) (bool, error) { + flags, err := c.FeatureFlags(ctx) + if err != nil { + return false, err + } + if !flags[feature].Enabled { + return false, nil + } + + check, hasCheck := featureSettingChecks[feature] + if !hasCheck { + // No setting to verify — feature flag alone is sufficient + return true, nil + } + + // The /app/settings endpoint is served by the backend socket, not the + // docker-cli socket. Derive the backend socket path from the current + // endpoint. + backendEndpoint := backendSocketEndpoint(c.apiEndpoint) + backendCli := NewClient(backendEndpoint) + defer backendCli.Close() //nolint:errcheck + + settings, err := backendCli.Settings(ctx) + if err != nil { + return false, err + } + return check(settings), nil +} + +// backendSocketEndpoint derives the Docker Desktop backend socket endpoint +// from any socket endpoint in the same directory. +// +// On macOS/Linux: unix:///path/to/Data/docker-cli.sock → unix:///path/to/Data/backend.sock +// On Windows: npipe://./pipe/dockerDesktopLinuxEngine → npipe://./pipe/dockerBackendApiServer +func backendSocketEndpoint(endpoint string) string { + if sockPath, ok := strings.CutPrefix(endpoint, "unix://"); ok { + return "unix://" + filepath.Join(filepath.Dir(sockPath), "backend.sock") + } + if _, ok := strings.CutPrefix(endpoint, "npipe://"); ok { + return "npipe://./pipe/dockerBackendApiServer" + } + return endpoint +} + +// featureSettingChecks maps feature flag names to their corresponding +// Docker Desktop setting check functions. +var featureSettingChecks = map[string]func(*SettingsResponse) bool{ + FeatureLogsTab: func(s *SettingsResponse) bool { + return s.Desktop.EnableLogsTab.Value + }, +} + func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, method, backendURL(path), body) if err != nil { diff --git a/internal/desktop/client_test.go b/internal/desktop/client_test.go index 7bb1546bca7..085f574be9e 100644 --- a/internal/desktop/client_test.go +++ b/internal/desktop/client_test.go @@ -24,6 +24,41 @@ import ( "gotest.tools/v3/assert" ) +func TestBackendSocketEndpoint(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "macOS unix socket", + input: "unix:///Users/me/Library/Containers/com.docker.docker/Data/docker-cli.sock", + expected: "unix:///Users/me/Library/Containers/com.docker.docker/Data/backend.sock", + }, + { + name: "Linux unix socket", + input: "unix:///run/desktop/docker-cli.sock", + expected: "unix:///run/desktop/backend.sock", + }, + { + name: "Windows named pipe", + input: "npipe://./pipe/dockerDesktopLinuxEngine", + expected: "npipe://./pipe/dockerBackendApiServer", + }, + { + name: "unknown scheme passthrough", + input: "tcp://localhost:2375", + expected: "tcp://localhost:2375", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := backendSocketEndpoint(tt.input) + assert.Equal(t, result, tt.expected) + }) + } +} + func TestClientPing(t *testing.T) { if testing.Short() { t.Skip("Skipped in short mode - test connects to Docker Desktop") diff --git a/internal/tracing/keyboard_metrics.go b/internal/tracing/keyboard_metrics.go index 2e5120fbea7..222d52cf52b 100644 --- a/internal/tracing/keyboard_metrics.go +++ b/internal/tracing/keyboard_metrics.go @@ -22,12 +22,15 @@ import ( "go.opentelemetry.io/otel/attribute" ) -func KeyboardMetrics(ctx context.Context, enabled, isDockerDesktopActive bool) { +func KeyboardMetrics(ctx context.Context, enabled, isDockerDesktopActive, isLogsViewEnabled bool) { commandAvailable := []string{} if isDockerDesktopActive { commandAvailable = append(commandAvailable, "gui") commandAvailable = append(commandAvailable, "gui/composeview") } + if isLogsViewEnabled { + commandAvailable = append(commandAvailable, "gui/logsview") + } AddAttributeToSpan(ctx, attribute.Bool("navmenu.enabled", enabled), diff --git a/pkg/compose/desktop.go b/pkg/compose/desktop.go index f89ff265442..9ca8ea46494 100644 --- a/pkg/compose/desktop.go +++ b/pkg/compose/desktop.go @@ -21,23 +21,48 @@ import ( "strings" "github.com/moby/moby/client" -) -// 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" + "github.com/docker/compose/v5/internal/desktop" +) -func (s *composeService) isDesktopIntegrationActive(ctx context.Context) (bool, error) { +// desktopEndpoint returns the Docker Desktop API socket address discovered +// from the Docker engine info labels. It returns "" when the active engine +// is not a Docker Desktop instance. +func (s *composeService) desktopEndpoint(ctx context.Context) (string, error) { res, err := s.apiClient().Info(ctx, client.InfoOptions{}) if err != nil { - return false, err + return "", err } for _, l := range res.Info.Labels { - k, _, ok := strings.Cut(l, "=") - if ok && k == engineLabelDesktopAddress { - return true, nil + k, v, ok := strings.Cut(l, "=") + if ok && k == desktop.EngineLabel { + return v, nil } } - return false, nil + return "", nil +} + +// isDesktopIntegrationActive returns true when Docker Desktop is the active engine. +func (s *composeService) isDesktopIntegrationActive(ctx context.Context) (bool, error) { + endpoint, err := s.desktopEndpoint(ctx) + return endpoint != "", err +} + +// isDesktopFeatureActive checks whether a Docker Desktop feature is both +// available (feature flag) and enabled by the user (settings). Returns false +// silently when Desktop is not running or unreachable. +func (s *composeService) isDesktopFeatureActive(ctx context.Context, feature string) bool { + endpoint, err := s.desktopEndpoint(ctx) + if err != nil || endpoint == "" { + return false + } + + ddClient := desktop.NewClient(endpoint) + defer ddClient.Close() //nolint:errcheck + + enabled, err := ddClient.IsFeatureEnabled(ctx, feature) + if err != nil { + return false + } + return enabled } diff --git a/pkg/compose/up.go b/pkg/compose/up.go index f14d8ded39c..b2bcb3f7dbb 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -36,6 +36,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/docker/compose/v5/cmd/formatter" + "github.com/docker/compose/v5/internal/desktop" "github.com/docker/compose/v5/internal/tracing" "github.com/docker/compose/v5/pkg/api" ) @@ -88,8 +89,9 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options if err != nil { return err } - tracing.KeyboardMetrics(ctx, options.Start.NavigationMenu, isDockerDesktopActive) - navigationMenu = formatter.NewKeyboardManager(isDockerDesktopActive, signalChan) + isLogsViewEnabled := s.isDesktopFeatureActive(ctx, desktop.FeatureLogsTab) + tracing.KeyboardMetrics(ctx, options.Start.NavigationMenu, isDockerDesktopActive, isLogsViewEnabled) + navigationMenu = formatter.NewKeyboardManager(isDockerDesktopActive, isLogsViewEnabled, signalChan) logConsumer = navigationMenu.Decorate(logConsumer) } }