| // 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 | |
| } |