This commit is contained in:
Guillaume Lours 2026-05-12 11:33:32 +02:00 committed by GitHub
commit f06a55c268
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 377 additions and 180 deletions

View file

@ -17,25 +17,27 @@
package compose
import (
"context"
"encoding/json"
"io"
"os"
"time"
"github.com/compose-spec/compose-go/v2/cli"
"github.com/docker/cli/cli-plugins/hooks"
"github.com/docker/cli/cli-plugins/metadata"
"github.com/spf13/cobra"
"github.com/docker/compose/v5/cmd/formatter"
"github.com/docker/compose/v5/internal/desktop"
)
const deepLink = "docker-desktop://dashboard/logs"
func composeLogsHint() string {
return "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + hintLink(deepLink)
func composeLogsHint(appID string) string {
return "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + hintLink(desktop.BuildLogsURL(appID))
}
func dockerLogsHint() string {
return "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + hintLink(deepLink)
func dockerLogsHint(appID string) string {
return "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + hintLink(desktop.BuildLogsURL(appID))
}
// hintLink returns a clickable OSC 8 terminal hyperlink when ANSI is allowed,
@ -66,35 +68,96 @@ func shouldDisableAnsi() bool {
return false
}
// 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 func() string
checkFlags func(flags map[string]string) bool
template func(appID string) string
checkFlags func(flags map[string]string) bool
resolveProject bool
}
// hooksHints maps hook root commands to their hint definitions.
var hooksHints = map[string]hookHint{
// standalone "docker logs" (not a compose subcommand)
// "docker logs": the CLI hook payload doesn't carry the positional
// container id, so the link is emitted unfiltered.
"logs": {template: dockerLogsHint},
"compose logs": {template: composeLogsHint},
"compose logs": {template: composeLogsHint, resolveProject: true},
"compose up": {
template: composeLogsHint,
template: composeLogsHint,
resolveProject: true,
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
return hasFlag(flags, "detach", "d")
},
},
}
// Test seams. Replace via t.Cleanup; not safe to mutate from t.Parallel().
var (
logsTabEnabled = func(ctx context.Context) bool {
return desktop.IsFeatureActiveStandalone(ctx, desktop.FeatureLogsTab)
}
resolveAppID = defaultResolveAppID
)
const projectNameResolveTimeout = 250 * time.Millisecond
// Root-command flags whose values change which project the loader would
// resolve. The hook payload exposes flag names but not values, so when any
// is set we skip the appId rather than emit a wrong filter. workdir is the
// deprecated alias for --project-directory; env-file can set
// COMPOSE_PROJECT_NAME via the .env file it points at.
var projectScopingFlags = []string{
"project-name", "p",
"file", "f",
"project-directory", "workdir",
"env-file",
}
func defaultResolveAppID(ctx context.Context, flags map[string]string) string {
workDir, err := os.Getwd()
if err != nil {
return ""
}
return resolveAppIDIn(ctx, flags, workDir)
}
// Split from defaultResolveAppID so tests can pass a t.TempDir() instead
// of mutating process state via t.Chdir.
func resolveAppIDIn(ctx context.Context, flags map[string]string, workDir string) string {
if hasFlag(flags, projectScopingFlags...) {
return ""
}
ctx, cancel := context.WithTimeout(ctx, projectNameResolveTimeout)
defer cancel()
opts, err := cli.NewProjectOptions(nil,
cli.WithWorkingDirectory(workDir),
cli.WithOsEnv,
cli.WithDotEnv,
cli.WithConfigFileEnv,
cli.WithDefaultConfigPath,
)
if err != nil {
return ""
}
project, err := opts.LoadProject(ctx)
if err != nil {
return ""
}
return project.Name
}
func hasFlag(flags map[string]string, names ...string) bool {
for _, n := range names {
if _, ok := flags[n]; ok {
return true
}
}
return false
}
// 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 +166,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,10 +190,19 @@ func handleHook(args []string, w io.Writer) error {
return nil
}
if !logsTabEnabled(ctx) {
return nil
}
var appID string
if hint.resolveProject {
appID = resolveAppID(ctx, hookData.Flags)
}
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(hooks.Response{
Type: hooks.NextSteps,
Template: hint.template(),
Template: hint.template(appID),
})
}

View file

@ -18,7 +18,10 @@ package compose
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
@ -26,18 +29,31 @@ import (
"gotest.tools/v3/assert"
"github.com/docker/compose/v5/cmd/formatter"
"github.com/docker/compose/v5/internal/desktop"
)
const testDeepLink = "docker-desktop://dashboard/logs"
// TestMain stubs the Docker Desktop feature-flag check and the project
// loader so handleHook tests don't make a live engine call or read a
// compose file from the test runner's working directory. Individual tests
// override either stub with t.Cleanup to restore.
func TestMain(m *testing.M) {
logsTabEnabled = func(context.Context) bool { return true }
resolveAppID = func(context.Context, map[string]string) string { return "" }
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 +63,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(), "")
}
@ -55,7 +71,7 @@ func TestHandleHook_UnknownCommand(t *testing.T) {
func TestHandleHook_LogsCommand(t *testing.T) {
tests := []struct {
rootCmd string
wantHint func() string
wantHint func(appID string) string
}{
{rootCmd: "compose logs", wantHint: composeLogsHint},
{rootCmd: "logs", wantHint: dockerLogsHint},
@ -66,12 +82,12 @@ 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())
assert.Equal(t, msg.Type, hooks.NextSteps)
assert.Equal(t, msg.Template, tt.wantHint())
assert.Equal(t, msg.Template, tt.wantHint(""))
})
}
}
@ -110,12 +126,12 @@ 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 {
msg := unmarshalResponse(t, buf.Bytes())
assert.Equal(t, msg.Template, composeLogsHint())
assert.Equal(t, msg.Template, composeLogsHint(""))
} else {
assert.Equal(t, buf.String(), "")
}
@ -131,13 +147,13 @@ 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())
// Verify the template contains the OSC 8 hyperlink sequence
wantLink := formatter.OSC8Link(deepLink, deepLink)
assert.Assert(t, len(wantLink) > len(deepLink), "OSC8Link should wrap the URL with escape sequences")
wantLink := formatter.OSC8Link(testDeepLink, testDeepLink)
assert.Assert(t, len(wantLink) > len(testDeepLink), "OSC8Link should wrap the URL with escape sequences")
assert.Assert(t, strings.Contains(msg.Template, wantLink), "hint should contain OSC 8 hyperlink")
}
@ -147,29 +163,169 @@ 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())
// With NO_COLOR set, the hint should contain the plain URL without escape sequences
assert.Assert(t, strings.Contains(msg.Template, deepLink), "hint should contain the deep link URL")
assert.Assert(t, strings.Contains(msg.Template, testDeepLink), "hint should contain the deep link URL")
assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences")
}
func TestHandleHook_AppIDEncodedInURL(t *testing.T) {
prev := resolveAppID
t.Cleanup(func() { resolveAppID = prev })
resolveAppID = func(context.Context, map[string]string) string { return "myapp" }
t.Setenv("NO_COLOR", "1") // emit a plain URL we can substring-match
for _, rootCmd := range []string{"compose logs", "compose up"} {
t.Run(rootCmd, func(t *testing.T) {
data := marshalHookData(t, hooks.Request{
RootCmd: rootCmd,
Flags: map[string]string{"d": "true"},
})
var buf bytes.Buffer
err := handleHook(t.Context(), []string{data}, &buf)
assert.NilError(t, err)
msg := unmarshalResponse(t, buf.Bytes())
assert.Assert(t, strings.Contains(msg.Template, desktop.BuildLogsURL("myapp")),
"hint should include the project-scoped URL, got %q", msg.Template)
})
}
}
func TestHandleHook_DockerLogsIgnoresAppID(t *testing.T) {
// resolveAppID is not consulted for "logs" because that hint isn't
// resolveProject; assert the URL stays paramless even if a stub
// would otherwise return a value.
prev := resolveAppID
t.Cleanup(func() { resolveAppID = prev })
resolveAppID = func(context.Context, map[string]string) string {
t.Fatalf("resolveAppID should not be called for docker logs")
return ""
}
t.Setenv("NO_COLOR", "1")
data := marshalHookData(t, hooks.Request{RootCmd: "logs"})
var buf bytes.Buffer
err := handleHook(t.Context(), []string{data}, &buf)
assert.NilError(t, err)
msg := unmarshalResponse(t, buf.Bytes())
assert.Assert(t, strings.Contains(msg.Template, testDeepLink),
"docker logs hint should contain the bare deep link")
assert.Assert(t, !strings.Contains(msg.Template, "?appId="),
"docker logs hint must not encode an appId")
}
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())
assert.Assert(t, strings.Contains(msg.Template, deepLink), "hint should contain the deep link URL")
assert.Assert(t, strings.Contains(msg.Template, testDeepLink), "hint should contain the deep link URL")
assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences")
}
func TestResolveAppID_ShortCircuitsOnFlag(t *testing.T) {
tests := []struct {
name string
flags map[string]string
}{
{name: "long --project-name", flags: map[string]string{"project-name": ""}},
{name: "short -p", flags: map[string]string{"p": ""}},
{name: "long --file", flags: map[string]string{"file": ""}},
{name: "short -f", flags: map[string]string{"f": ""}},
{name: "long --project-directory", flags: map[string]string{"project-directory": ""}},
{name: "deprecated --workdir alias", flags: map[string]string{"workdir": ""}},
{name: "long --env-file", flags: map[string]string{"env-file": ""}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Use a real tmpdir as workDir so the short-circuit path is
// exercised independently of the loader's file discovery.
got := resolveAppIDIn(t.Context(), tt.flags, t.TempDir())
assert.Equal(t, got, "")
})
}
}
func TestResolveAppID_NameFromComposeFile(t *testing.T) {
dir := t.TempDir()
mustWrite(t, dir, "compose.yaml", "name: from-yaml\nservices:\n svc:\n image: nginx\n")
unsetEnv(t, "COMPOSE_PROJECT_NAME")
unsetEnv(t, "COMPOSE_FILE")
got := resolveAppIDIn(t.Context(), nil, dir)
assert.Equal(t, got, "from-yaml")
}
func TestResolveAppID_EnvVarOverridesYAML(t *testing.T) {
dir := t.TempDir()
mustWrite(t, dir, "compose.yaml", "name: from-yaml\nservices:\n svc:\n image: nginx\n")
t.Setenv("COMPOSE_PROJECT_NAME", "from-env")
unsetEnv(t, "COMPOSE_FILE")
got := resolveAppIDIn(t.Context(), nil, dir)
assert.Equal(t, got, "from-env")
}
func TestResolveAppID_NoComposeFileReturnsEmpty(t *testing.T) {
unsetEnv(t, "COMPOSE_PROJECT_NAME")
unsetEnv(t, "COMPOSE_FILE")
got := resolveAppIDIn(t.Context(), nil, t.TempDir())
assert.Equal(t, got, "")
}
// unsetEnv removes an env var for the lifetime of the test, restoring its
// prior state on cleanup. t.Setenv("", "") is not equivalent to unset:
// compose-go's WithConfigFileEnv treats empty as a meaningful override.
func unsetEnv(t *testing.T, key string) {
t.Helper()
prev, had := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unsetenv %s: %v", key, err)
}
t.Cleanup(func() {
if !had {
return
}
if err := os.Setenv(key, prev); err != nil {
t.Errorf("restore env %s: %v", key, err)
}
})
}
func mustWrite(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
func marshalHookData(t *testing.T, data hooks.Request) string {
t.Helper()
b, err := json.Marshal(data)

View file

@ -31,6 +31,7 @@ import (
"github.com/eiannone/keyboard"
"github.com/skratchdot/open-golang/open"
"github.com/docker/compose/v5/internal/desktop"
"github.com/docker/compose/v5/internal/tracing"
"github.com/docker/compose/v5/pkg/api"
)
@ -238,14 +239,14 @@ func (lk *LogKeyboard) openDDComposeUI(ctx context.Context, project *types.Proje
}()
}
func (lk *LogKeyboard) openDDLogsView(ctx context.Context) {
func (lk *LogKeyboard) openDDLogsView(ctx context.Context, project *types.Project) {
if !lk.IsLogsViewEnabled {
return
}
go func() {
_ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/logsview", tracing.SpanOptions{},
func(ctx context.Context) error {
link := "docker-desktop://dashboard/logs"
link := desktop.BuildLogsURL(project.Name)
err := open.Run(link)
if err != nil {
err = fmt.Errorf("could not open Docker Desktop Logs view: %w", err)
@ -336,7 +337,7 @@ func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEv
case 'o':
lk.openDDComposeUI(ctx, project)
case 'l':
lk.openDDLogsView(ctx)
lk.openDDLogsView(ctx, project)
}
switch key := event.Key; key {
case keyboard.KeyCtrlC:

View file

@ -23,9 +23,12 @@ import (
"io"
"net"
"net/http"
"path/filepath"
"net/url"
"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"
@ -40,6 +43,31 @@ const EngineLabel = "com.docker.desktop.address"
// FeatureLogsTab is the feature flag name for the Docker Desktop Logs view.
const FeatureLogsTab = "LogsTab"
const logsDeepLink = "docker-desktop://dashboard/logs"
// LogsAppIDMaxLen mirrors the byte-length cap Docker Desktop's URL handler
// applies to the appId query parameter; values longer than this are
// truncated by the receiver, so we trim ahead of time to avoid emitting
// hyperlinks that will be silently shortened. The slice in BuildLogsURL is
// a byte slice — Compose project names are restricted to the ASCII set
// `[a-z0-9_-]` by loader.NormalizeProjectName, so a byte cap and a rune
// cap coincide for any value that could legitimately reach this builder.
const LogsAppIDMaxLen = 256
// BuildLogsURL returns the deep link that opens Docker Desktop's Logs view,
// optionally pre-filtered to a Compose project. An empty appID yields the
// unfiltered URL.
func BuildLogsURL(appID string) string {
if appID == "" {
return logsDeepLink
}
if len(appID) > LogsAppIDMaxLen {
appID = appID[:LogsAppIDMaxLen]
}
q := url.Values{"appId": []string{appID}}
return logsDeepLink + "?" + q.Encode()
}
// identify this client in the logs
var userAgent = "compose/" + internal.Version
@ -137,102 +165,68 @@ 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
}
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)
// EndpointFromEngine returns the Docker Desktop API socket address advertised
// by the engine in its Info labels, or "" when the active engine is not
// Docker Desktop.
func EndpointFromEngine(ctx context.Context, apiClient client.APIClient) (string, error) {
info, err := apiClient.Info(ctx, client.InfoOptions{})
if err != nil {
return false, err
return "", err
}
return check(settings), nil
for _, l := range info.Info.Labels {
k, v, ok := strings.Cut(l, "=")
if ok && k == EngineLabel {
return v, nil
}
}
return "", 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")
// 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 := EndpointFromEngine(ctx, apiClient)
if err != nil || endpoint == "" {
return false
}
if _, ok := strings.CutPrefix(endpoint, "npipe://"); ok {
return "npipe://./pipe/dockerBackendApiServer"
c := NewClient(endpoint)
defer c.Close() //nolint:errcheck
enabled, err := c.IsFeatureEnabled(ctx, feature)
if err != nil {
return false
}
return endpoint
return enabled
}
// 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
},
// 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 err := dockerCli.Initialize(cliflags.NewClientOptions()); err != nil {
return false
}
defer dockerCli.Client().Close() //nolint:errcheck
return IsFeatureActive(ctx, dockerCli.Client(), feature)
}
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {

View file

@ -18,47 +18,54 @@ package desktop
import (
"os"
"strings"
"testing"
"time"
"gotest.tools/v3/assert"
)
func TestBackendSocketEndpoint(t *testing.T) {
func TestBuildLogsURL(t *testing.T) {
tests := []struct {
name string
input string
expected string
name string
appID string
want 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: "empty app id yields paramless url",
appID: "",
want: "docker-desktop://dashboard/logs",
},
{
name: "Linux unix socket",
input: "unix:///run/desktop/docker-cli.sock",
expected: "unix:///run/desktop/backend.sock",
name: "simple project name",
appID: "myapp",
want: "docker-desktop://dashboard/logs?appId=myapp",
},
{
name: "Windows named pipe",
input: "npipe://./pipe/dockerDesktopLinuxEngine",
expected: "npipe://./pipe/dockerBackendApiServer",
name: "name with hyphen and digits is preserved",
appID: "my-app-2",
want: "docker-desktop://dashboard/logs?appId=my-app-2",
},
{
name: "unknown scheme passthrough",
input: "tcp://localhost:2375",
expected: "tcp://localhost:2375",
name: "characters that need percent-encoding are escaped",
appID: "weird name/with spaces",
want: "docker-desktop://dashboard/logs?appId=weird+name%2Fwith+spaces",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := backendSocketEndpoint(tt.input)
assert.Equal(t, result, tt.expected)
assert.Equal(t, BuildLogsURL(tt.appID), tt.want)
})
}
}
func TestBuildLogsURL_TruncatesLongAppID(t *testing.T) {
long := strings.Repeat("a", LogsAppIDMaxLen+50)
got := BuildLogsURL(long)
want := "docker-desktop://dashboard/logs?appId=" + strings.Repeat("a", LogsAppIDMaxLen)
assert.Equal(t, got, want)
}
func TestClientPing(t *testing.T) {
if testing.Short() {
t.Skip("Skipped in short mode - test connects to Docker Desktop")

View file

@ -18,51 +18,18 @@ package compose
import (
"context"
"strings"
"github.com/moby/moby/client"
"github.com/docker/compose/v5/internal/desktop"
)
// 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 "", err
}
for _, l := range res.Info.Labels {
k, v, ok := strings.Cut(l, "=")
if ok && k == desktop.EngineLabel {
return v, 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)
endpoint, err := desktop.EndpointFromEngine(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)
}