// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: //go:build go1.25 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) { nextSteps := invokeAndCollectHooks(ctx, cfg, rootCmd, subCommand, invokedCommand, flags, cmdErrorMessage) hooks.PrintNextSteps(subCommand.ErrOrStderr(), nextSteps) } func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []string { if ctx.Err() != nil { return nil } pluginsCfg := cfg.Plugins if pluginsCfg == nil { return nil } pluginDirs := getPluginDirs(cfg) nextSteps := make([]string, 0, len(pluginsCfg)) tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (messages []string, ok bool, err error) { match, matched := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage) if !matched { return nil, false, nil } p, err := getPlugin(pluginName, pluginDirs, rootCmd) if err != nil { return nil, false, err } resp, err := p.RunHook(ctx, hooks.Request{ RootCmd: match, Flags: flags, CommandError: cmdErrorMessage, }) if err != nil { return nil, false, err } var message hooks.Response if err := json.Unmarshal(resp, &message); err != nil { return nil, false, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err) } // currently the only hook type if message.Type != hooks.NextSteps { return nil, false, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type))) } messages, err = hooks.ParseTemplate(message.Template, subCmd) if err != nil { return nil, false, err } return messages, true, nil } for pluginName, pluginCfg := range pluginsCfg { messages, ok, 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 !ok { continue } var appended bool nextSteps, appended = appendNextSteps(nextSteps, messages) if !appended { logrus.WithFields(logrus.Fields{ "plugin": pluginName, }).Debug("Plugin responded with an empty hook message; ignoring") } } return nextSteps } // appendNextSteps appends the processed hook output to the nextSteps slice. // If the processed hook output is empty, it is not appended. // Empty lines are not stripped if there's at least one non-empty line. func appendNextSteps(nextSteps []string, processed []string) ([]string, bool) { empty := true for _, l := range processed { if strings.TrimSpace(l) != "" { empty = false break } } if empty { return nextSteps, false } return append(nextSteps, processed...), true } // 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 }