Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 96 additions & 24 deletions cmd/compose/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Expand All @@ -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),
})
}
Loading
Loading