refactor: drop Desktop beta-settings check; gate hint on LogsTab flag

Docker Desktop is removing the "Enable Logs view" beta setting, so drop
the /app/settings check and rely on /features alone. With the setting
gate gone, the compose hook subprocess would print the Logs view hint
regardless of LogsTab; add a flag check in handleHook. Consolidate
engine-label discovery and feature-flag evaluation into internal/desktop.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
This commit is contained in:
Guillaume Lours 2026-04-23 10:22:51 +02:00 committed by Guillaume Lours
parent 71cd334dbd
commit 554a2ba3e8
5 changed files with 90 additions and 151 deletions

View file

@ -17,6 +17,7 @@
package compose
import (
"context"
"encoding/json"
"io"
"os"
@ -26,6 +27,7 @@ import (
"github.com/spf13/cobra"
"github.com/docker/compose/v5/cmd/formatter"
"github.com/docker/compose/v5/internal/desktop"
)
const deepLink = "docker-desktop://dashboard/logs"
@ -74,7 +76,9 @@ type hookHint struct {
checkFlags func(flags map[string]string) bool
}
// hooksHints maps hook root commands to their hint definitions.
// hooksHints maps hook root commands to their hint definitions. All current
// hints promote Docker Desktop's Logs view; emission is additionally gated on
// the FeatureLogsTab flag in handleHook.
var hooksHints = map[string]hookHint{
// standalone "docker logs" (not a compose subcommand)
"logs": {template: dockerLogsHint},
@ -90,11 +94,17 @@ var hooksHints = map[string]hookHint{
},
}
// logsTabEnabled reports whether Docker Desktop is the active engine and the
// LogsTab feature flag is enabled. Overridable for tests.
var logsTabEnabled = func(ctx context.Context) bool {
return desktop.IsFeatureActiveStandalone(ctx, desktop.FeatureLogsTab)
}
// 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.
// in the docker CLI config; the handler gates all hints on the LogsTab
// feature flag before emitting them.
func HooksCommand() *cobra.Command {
return &cobra.Command{
Use: metadata.HookSubcommandName,
@ -103,12 +113,12 @@ func HooksCommand() *cobra.Command {
// (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())
return handleHook(cmd.Context(), args, cmd.OutOrStdout())
},
}
}
func handleHook(args []string, w io.Writer) error {
func handleHook(ctx context.Context, args []string, w io.Writer) error {
if len(args) == 0 {
return nil
}
@ -127,6 +137,10 @@ func handleHook(args []string, w io.Writer) error {
return nil
}
if !logsTabEnabled(ctx) {
return nil
}
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(hooks.Response{

View file

@ -18,7 +18,9 @@ package compose
import (
"bytes"
"context"
"encoding/json"
"os"
"strings"
"testing"
@ -28,16 +30,24 @@ import (
"github.com/docker/compose/v5/cmd/formatter"
)
// TestMain stubs the Docker Desktop feature-flag check so handleHook tests
// don't attempt a live engine call. Individual tests can still override
// isFeatureEnabled with their own stub + t.Cleanup to restore.
func TestMain(m *testing.M) {
logsTabEnabled = func(context.Context) bool { return true }
os.Exit(m.Run())
}
func TestHandleHook_NoArgs(t *testing.T) {
var buf bytes.Buffer
err := handleHook(nil, &buf)
err := handleHook(t.Context(), 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)
err := handleHook(t.Context(), []string{"not json"}, &buf)
assert.NilError(t, err)
assert.Equal(t, buf.String(), "")
}
@ -47,7 +57,7 @@ func TestHandleHook_UnknownCommand(t *testing.T) {
RootCmd: "compose push",
})
var buf bytes.Buffer
err := handleHook([]string{data}, &buf)
err := handleHook(t.Context(), []string{data}, &buf)
assert.NilError(t, err)
assert.Equal(t, buf.String(), "")
}
@ -66,7 +76,7 @@ func TestHandleHook_LogsCommand(t *testing.T) {
RootCmd: tt.rootCmd,
})
var buf bytes.Buffer
err := handleHook([]string{data}, &buf)
err := handleHook(t.Context(), []string{data}, &buf)
assert.NilError(t, err)
msg := unmarshalResponse(t, buf.Bytes())
@ -110,7 +120,7 @@ func TestHandleHook_ComposeUpDetached(t *testing.T) {
Flags: tt.flags,
})
var buf bytes.Buffer
err := handleHook([]string{data}, &buf)
err := handleHook(t.Context(), []string{data}, &buf)
assert.NilError(t, err)
if tt.wantHint {
@ -131,7 +141,7 @@ func TestHandleHook_HintContainsOSC8Link(t *testing.T) {
RootCmd: "compose logs",
})
var buf bytes.Buffer
err := handleHook([]string{data}, &buf)
err := handleHook(t.Context(), []string{data}, &buf)
assert.NilError(t, err)
msg := unmarshalResponse(t, buf.Bytes())
@ -147,7 +157,7 @@ func TestHandleHook_NoColorDisablesOsc8(t *testing.T) {
RootCmd: "compose logs",
})
var buf bytes.Buffer
err := handleHook([]string{data}, &buf)
err := handleHook(t.Context(), []string{data}, &buf)
assert.NilError(t, err)
msg := unmarshalResponse(t, buf.Bytes())
@ -156,13 +166,29 @@ func TestHandleHook_NoColorDisablesOsc8(t *testing.T) {
assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences")
}
func TestHandleHook_FeatureFlagDisabledSuppressesHint(t *testing.T) {
prev := logsTabEnabled
t.Cleanup(func() { logsTabEnabled = prev })
logsTabEnabled = func(context.Context) bool { return false }
for _, rootCmd := range []string{"compose logs", "logs"} {
t.Run(rootCmd, func(t *testing.T) {
data := marshalHookData(t, hooks.Request{RootCmd: rootCmd})
var buf bytes.Buffer
err := handleHook(t.Context(), []string{data}, &buf)
assert.NilError(t, err)
assert.Equal(t, buf.String(), "")
})
}
}
func TestHandleHook_ComposeAnsiNeverDisablesOsc8(t *testing.T) {
t.Setenv("COMPOSE_ANSI", "never")
data := marshalHookData(t, hooks.Request{
RootCmd: "compose logs",
})
var buf bytes.Buffer
err := handleHook([]string{data}, &buf)
err := handleHook(t.Context(), []string{data}, &buf)
assert.NilError(t, err)
msg := unmarshalResponse(t, buf.Bytes())

View file

@ -23,9 +23,11 @@ import (
"io"
"net"
"net/http"
"path/filepath"
"strings"
"github.com/docker/cli/cli/command"
cliflags "github.com/docker/cli/cli/flags"
"github.com/moby/moby/client"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"github.com/docker/compose/v5/internal"
@ -137,102 +139,51 @@ 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.
// IsFeatureEnabled checks the feature flag (GET /features) for a given
// feature. Returns true when the feature is rolled out.
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
return flags[feature].Enabled, nil
}
// IsFeatureActive reports whether Docker Desktop is the active engine and the
// given feature flag is enabled. Returns false silently on any failure — the
// engine being unreachable, Desktop not being the active engine, or the flag
// being off — so callers can use this as a single gating check.
func IsFeatureActive(ctx context.Context, apiClient client.APIClient, feature string) bool {
endpoint, err := Endpoint(ctx, apiClient)
if err != nil || endpoint == "" {
return false
}
check, hasCheck := featureSettingChecks[feature]
if !hasCheck {
// No setting to verify — feature flag alone is sufficient
return true, nil
}
c := NewClient(endpoint)
defer c.Close() //nolint:errcheck
// 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)
enabled, err := c.IsFeatureEnabled(ctx, feature)
if err != nil {
return false, err
return false
}
return check(settings), nil
return enabled
}
// 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")
// IsFeatureActiveStandalone is the convenience form of IsFeatureActive for
// callers without an existing engine API client (e.g. the compose plugin hook
// subprocess). It builds a Docker CLI using the ambient environment to
// resolve the active context, then delegates to IsFeatureActive.
func IsFeatureActiveStandalone(ctx context.Context, feature string) bool {
dockerCli, err := command.NewDockerCli(command.WithCombinedStreams(io.Discard))
if err != nil {
return false
}
if _, ok := strings.CutPrefix(endpoint, "npipe://"); ok {
return "npipe://./pipe/dockerBackendApiServer"
if err := dockerCli.Initialize(cliflags.NewClientOptions()); err != nil {
return false
}
return endpoint
}
defer dockerCli.Client().Close() //nolint:errcheck
// 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
},
return IsFeatureActive(ctx, dockerCli.Client(), feature)
}
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {

View file

@ -24,41 +24,6 @@ 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")

View file

@ -22,31 +22,14 @@ import (
"github.com/docker/compose/v5/internal/desktop"
)
func (s *composeService) desktopEndpoint(ctx context.Context) (string, error) {
return desktop.Endpoint(ctx, s.apiClient())
}
// isDesktopIntegrationActive returns true when Docker Desktop is the active engine.
func (s *composeService) isDesktopIntegrationActive(ctx context.Context) (bool, error) {
endpoint, err := s.desktopEndpoint(ctx)
endpoint, err := desktop.Endpoint(ctx, s.apiClient())
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.
// isDesktopFeatureActive checks whether a Docker Desktop feature flag is
// enabled. 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
return desktop.IsFeatureActive(ctx, s.apiClient(), feature)
}