mirror of
https://github.com/docker/compose.git
synced 2026-06-28 04:03:48 +00:00
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:
parent
71cd334dbd
commit
554a2ba3e8
5 changed files with 90 additions and 151 deletions
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue