mirror of
https://github.com/docker/cli.git
synced 2026-11-06 13:10:41 -04:00
Allow plugins to return informational messages without the next-steps heading or indentation. Invoke each hook once and render generic messages before the shared next-steps block. Centralize grouping and formatting in PrintMessages, with a callback for template evaluation and error handling. This avoids the need for an intermediate EvaluatedMessage type and simplifies hook collection to return the original responses. Skip messages containing only whitespace while preserving empty lines within non-empty messages. Keep PrintNextSteps as a deprecated wrapper to preserve existing callers and output. Update the printer tests and remove the obsolete appendMessages helper and its tests. Co-authored-by: Nicolas Beck <nicolas.beck@docker.com> Signed-off-by: Nicolas Beck <nicolas.beck@docker.com> Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
214 lines
6.4 KiB
Go
214 lines
6.4 KiB
Go
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||
//go:build go1.26
|
||
|
||
package manager
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/docker/cli/cli-plugins/hooks"
|
||
"github.com/docker/cli/cli/config"
|
||
"github.com/docker/cli/cli/config/configfile"
|
||
"github.com/sirupsen/logrus"
|
||
"github.com/spf13/cobra"
|
||
"github.com/spf13/pflag"
|
||
)
|
||
|
||
// HookPluginData is the type representing the information
|
||
// that plugins declaring support for hooks get passed when
|
||
// being invoked following a CLI command execution.
|
||
//
|
||
// Deprecated: use [hooks.Request] instead.
|
||
//
|
||
//go:fix inline
|
||
type HookPluginData = hooks.Request
|
||
|
||
// RunCLICommandHooks is the entrypoint into the hooks execution flow after
|
||
// a main CLI command was executed. It calls the hook subcommand for all
|
||
// present CLI plugins that declare support for hooks in their metadata and
|
||
// parses/prints their responses.
|
||
func RunCLICommandHooks(ctx context.Context, dockerCLI config.Provider, rootCmd, subCommand *cobra.Command, cmdErrorMessage string) {
|
||
commandName := strings.TrimPrefix(subCommand.CommandPath(), rootCmd.Name()+" ")
|
||
flags := getCommandFlags(subCommand)
|
||
|
||
runHooks(ctx, dockerCLI.ConfigFile(), rootCmd, subCommand, commandName, flags, cmdErrorMessage)
|
||
}
|
||
|
||
// RunPluginHooks is the entrypoint for the hooks execution flow
|
||
// after a plugin command was just executed by the CLI.
|
||
func RunPluginHooks(ctx context.Context, dockerCLI config.Provider, rootCmd, subCommand *cobra.Command, args []string, cmdErrorMessage string) {
|
||
commandName := strings.Join(args, " ")
|
||
flags := getNaiveFlags(args)
|
||
|
||
runHooks(ctx, dockerCLI.ConfigFile(), rootCmd, subCommand, commandName, flags, cmdErrorMessage)
|
||
}
|
||
|
||
func runHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCommand *cobra.Command, invokedCommand string, flags map[string]string, cmdErrorMessage string) {
|
||
messages := invokeAndCollectHooks(ctx, cfg, rootCmd, invokedCommand, flags, cmdErrorMessage)
|
||
hooks.PrintMessages(subCommand.ErrOrStderr(), messages, func(message hooks.Response) []string {
|
||
lines, err := hooks.ParseTemplate(message.Template, subCommand)
|
||
if err != nil {
|
||
logrus.WithError(err).Debug("Failed to evaluate hook template")
|
||
return nil
|
||
}
|
||
return lines
|
||
})
|
||
}
|
||
|
||
func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []hooks.Response {
|
||
if ctx.Err() != nil {
|
||
return nil
|
||
}
|
||
|
||
pluginsCfg := cfg.Plugins
|
||
if pluginsCfg == nil {
|
||
return nil
|
||
}
|
||
|
||
pluginDirs := getPluginDirs(cfg)
|
||
var collected []hooks.Response
|
||
|
||
tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (hooks.Response, error) {
|
||
match, matched := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage)
|
||
if !matched {
|
||
return hooks.Response{}, nil
|
||
}
|
||
|
||
p, err := getPlugin(pluginName, pluginDirs, rootCmd)
|
||
if err != nil {
|
||
return hooks.Response{}, err
|
||
}
|
||
|
||
resp, err := p.RunHook(ctx, hooks.Request{
|
||
RootCmd: match,
|
||
Flags: flags,
|
||
CommandError: cmdErrorMessage,
|
||
})
|
||
if err != nil {
|
||
return hooks.Response{}, err
|
||
}
|
||
|
||
var message hooks.Response
|
||
if err := json.Unmarshal(resp, &message); err != nil {
|
||
return hooks.Response{}, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err)
|
||
}
|
||
|
||
if message.Type != hooks.NextSteps && message.Type != hooks.GenericMessage {
|
||
return hooks.Response{}, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type)))
|
||
}
|
||
|
||
return message, nil
|
||
}
|
||
|
||
for pluginName, pluginCfg := range pluginsCfg {
|
||
message, err := tryInvokeHook(pluginName, pluginCfg)
|
||
if err != nil {
|
||
// skip misbehaving plugins, but don't halt execution
|
||
logrus.WithFields(logrus.Fields{
|
||
"error": err,
|
||
"plugin": pluginName,
|
||
}).Debug("Plugin hook invocation failed")
|
||
continue
|
||
}
|
||
if message.Template == "" {
|
||
continue
|
||
}
|
||
|
||
collected = append(collected, message)
|
||
}
|
||
return collected
|
||
}
|
||
|
||
// pluginMatch takes a plugin configuration and a string representing the
|
||
// command being executed (such as 'image ls' – the root 'docker' is omitted)
|
||
// and, if the configuration includes a hook for the invoked command, returns
|
||
// the configured hook string.
|
||
//
|
||
// Plugins can declare two types of hooks in their configuration:
|
||
// - "hooks": fires on every command invocation (success or failure)
|
||
// - "error-hooks": fires only when a command fails (cmdErrorMessage is non-empty)
|
||
func pluginMatch(pluginCfg map[string]string, subCmd string, cmdErrorMessage string) (string, bool) {
|
||
// Check "hooks" first — these always fire regardless of command outcome.
|
||
if match, ok := matchHookConfig(pluginCfg["hooks"], subCmd); ok {
|
||
return match, true
|
||
}
|
||
|
||
// Check "error-hooks" — these only fire when there was an error.
|
||
if cmdErrorMessage != "" {
|
||
if match, ok := matchHookConfig(pluginCfg["error-hooks"], subCmd); ok {
|
||
return match, true
|
||
}
|
||
}
|
||
|
||
return "", false
|
||
}
|
||
|
||
// matchHookConfig checks if a comma-separated hook configuration string
|
||
// contains a prefix match for the given subcommand.
|
||
func matchHookConfig(configuredHooks string, subCmd string) (string, bool) {
|
||
if configuredHooks == "" {
|
||
return "", false
|
||
}
|
||
|
||
for hookCmd := range strings.SplitSeq(configuredHooks, ",") {
|
||
if hookMatch(hookCmd, subCmd) {
|
||
return hookCmd, true
|
||
}
|
||
}
|
||
|
||
return "", false
|
||
}
|
||
|
||
func hookMatch(hookCmd, subCmd string) bool {
|
||
hookCmdTokens := strings.Split(hookCmd, " ")
|
||
subCmdTokens := strings.Split(subCmd, " ")
|
||
|
||
if len(hookCmdTokens) > len(subCmdTokens) {
|
||
return false
|
||
}
|
||
|
||
for i, v := range hookCmdTokens {
|
||
if v != subCmdTokens[i] {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
func getCommandFlags(cmd *cobra.Command) map[string]string {
|
||
flags := make(map[string]string)
|
||
cmd.Flags().Visit(func(f *pflag.Flag) {
|
||
var fValue string
|
||
if f.Value.Type() == "bool" {
|
||
fValue = f.Value.String()
|
||
}
|
||
flags[f.Name] = fValue
|
||
})
|
||
return flags
|
||
}
|
||
|
||
// getNaiveFlags string-matches argv and parses them into a map.
|
||
// This is used when calling hooks after a plugin command, since
|
||
// in this case we can't rely on the cobra command tree to parse
|
||
// flags in this case. In this case, no values are ever passed,
|
||
// since we don't have enough information to process them.
|
||
func getNaiveFlags(args []string) map[string]string {
|
||
flags := make(map[string]string)
|
||
for _, arg := range args {
|
||
if strings.HasPrefix(arg, "--") {
|
||
flags[arg[2:]] = ""
|
||
continue
|
||
}
|
||
if strings.HasPrefix(arg, "-") {
|
||
flags[arg[1:]] = ""
|
||
}
|
||
}
|
||
return flags
|
||
}
|