mirror of
https://github.com/docker/compose.git
synced 2026-05-13 13:58:02 +00:00
Merge 9874c52800 into 659b269e52
This commit is contained in:
commit
f06a55c268
6 changed files with 377 additions and 180 deletions
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue