-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Introduce support for CLI plugin hooks #4376
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package hooks | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "io" | ||
|
|
||
| "github.com/morikuni/aec" | ||
| ) | ||
|
|
||
| func PrintNextSteps(out io.Writer, messages []string) { | ||
| if len(messages) == 0 { | ||
| return | ||
| } | ||
| fmt.Fprintln(out, aec.Bold.Apply("\nWhat's next:")) | ||
| for _, n := range messages { | ||
| _, _ = fmt.Fprintf(out, " %s\n", n) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| package hooks | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "testing" | ||
|
|
||
| "github.com/morikuni/aec" | ||
| "gotest.tools/v3/assert" | ||
| ) | ||
|
|
||
| func TestPrintHookMessages(t *testing.T) { | ||
| testCases := []struct { | ||
| messages []string | ||
| expectedOutput string | ||
| }{ | ||
| { | ||
| messages: []string{}, | ||
| expectedOutput: "", | ||
| }, | ||
| { | ||
| messages: []string{"Bork!"}, | ||
| expectedOutput: aec.Bold.Apply("\nWhat's next:") + "\n" + | ||
| " Bork!\n", | ||
| }, | ||
| { | ||
| messages: []string{"Foo", "bar"}, | ||
| expectedOutput: aec.Bold.Apply("\nWhat's next:") + "\n" + | ||
| " Foo\n" + | ||
| " bar\n", | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range testCases { | ||
| w := bytes.Buffer{} | ||
| PrintNextSteps(&w, tc.messages) | ||
| assert.Equal(t, w.String(), tc.expectedOutput) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| package hooks | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "errors" | ||
| "fmt" | ||
| "strconv" | ||
| "text/template" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| type HookType int | ||
|
|
||
| const ( | ||
| NextSteps = iota | ||
| ) | ||
|
|
||
| // HookMessage represents a plugin hook response. Plugins | ||
| // declaring support for CLI hooks need to print a json | ||
| // representation of this type when their hook subcommand | ||
| // is invoked. | ||
| type HookMessage struct { | ||
| Type HookType | ||
| Template string | ||
| } | ||
|
|
||
| // TemplateReplaceSubcommandName returns a hook template string | ||
| // that will be replaced by the CLI subcommand being executed | ||
| // | ||
| // Example: | ||
| // | ||
| // "you ran the subcommand: " + TemplateReplaceSubcommandName() | ||
| // | ||
| // when being executed after the command: | ||
| // `docker run --name "my-container" alpine` | ||
| // will result in the message: | ||
| // `you ran the subcommand: run` | ||
| func TemplateReplaceSubcommandName() string { | ||
| return hookTemplateCommandName | ||
| } | ||
|
|
||
| // TemplateReplaceFlagValue returns a hook template string | ||
| // that will be replaced by the flags value. | ||
| // | ||
| // Example: | ||
| // | ||
| // "you ran a container named: " + TemplateReplaceFlagValue("name") | ||
| // | ||
| // when being executed after the command: | ||
| // `docker run --name "my-container" alpine` | ||
| // will result in the message: | ||
| // `you ran a container named: my-container` | ||
| func TemplateReplaceFlagValue(flag string) string { | ||
| return fmt.Sprintf(hookTemplateFlagValue, flag) | ||
| } | ||
|
|
||
| // TemplateReplaceArg takes an index i and returns a hook | ||
| // template string that the CLI will replace the template with | ||
| // the ith argument, after processing the passed flags. | ||
| // | ||
| // Example: | ||
| // | ||
| // "run this image with `docker run " + TemplateReplaceArg(0) + "`" | ||
| // | ||
| // when being executed after the command: | ||
| // `docker pull alpine` | ||
| // will result in the message: | ||
| // "Run this image with `docker run alpine`" | ||
| func TemplateReplaceArg(i int) string { | ||
| return fmt.Sprintf(hookTemplateArg, strconv.Itoa(i)) | ||
| } | ||
|
|
||
| func ParseTemplate(hookTemplate string, cmd *cobra.Command) (string, error) { | ||
| tmpl := template.New("").Funcs(commandFunctions) | ||
| tmpl, err := tmpl.Parse(hookTemplate) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| b := bytes.Buffer{} | ||
| err = tmpl.Execute(&b, cmd) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| return b.String(), nil | ||
| } | ||
|
|
||
| var ErrHookTemplateParse = errors.New("failed to parse hook template") | ||
|
|
||
| const ( | ||
| hookTemplateCommandName = "{{.Name}}" | ||
| hookTemplateFlagValue = `{{flag . "%s"}}` | ||
| hookTemplateArg = "{{arg . %s}}" | ||
| ) | ||
|
|
||
| var commandFunctions = template.FuncMap{ | ||
| "flag": getFlagValue, | ||
| "arg": getArgValue, | ||
| } | ||
|
|
||
| func getFlagValue(cmd *cobra.Command, flag string) (string, error) { | ||
| cmdFlag := cmd.Flag(flag) | ||
| if cmdFlag == nil { | ||
| return "", ErrHookTemplateParse | ||
| } | ||
| return cmdFlag.Value.String(), nil | ||
| } | ||
|
|
||
| func getArgValue(cmd *cobra.Command, i int) (string, error) { | ||
| flags := cmd.Flags() | ||
| if flags == nil { | ||
| return "", ErrHookTemplateParse | ||
| } | ||
| return flags.Arg(i), nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| package hooks | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| "gotest.tools/v3/assert" | ||
| ) | ||
|
|
||
| func TestParseTemplate(t *testing.T) { | ||
| type testFlag struct { | ||
| name string | ||
| value string | ||
| } | ||
| testCases := []struct { | ||
| template string | ||
| flags []testFlag | ||
| args []string | ||
| expectedOutput string | ||
| }{ | ||
| { | ||
| template: "", | ||
| expectedOutput: "", | ||
| }, | ||
| { | ||
| template: "a plain template message", | ||
| expectedOutput: "a plain template message", | ||
| }, | ||
| { | ||
| template: TemplateReplaceFlagValue("tag"), | ||
| flags: []testFlag{ | ||
| { | ||
| name: "tag", | ||
| value: "my-tag", | ||
| }, | ||
| }, | ||
| expectedOutput: "my-tag", | ||
| }, | ||
| { | ||
| template: TemplateReplaceFlagValue("test-one") + " " + TemplateReplaceFlagValue("test2"), | ||
| flags: []testFlag{ | ||
| { | ||
| name: "test-one", | ||
| value: "value", | ||
| }, | ||
| { | ||
| name: "test2", | ||
| value: "value2", | ||
| }, | ||
| }, | ||
| expectedOutput: "value value2", | ||
| }, | ||
| { | ||
| template: TemplateReplaceArg(0) + " " + TemplateReplaceArg(1), | ||
| args: []string{"zero", "one"}, | ||
| expectedOutput: "zero one", | ||
| }, | ||
| { | ||
| template: "You just pulled " + TemplateReplaceArg(0), | ||
| args: []string{"alpine"}, | ||
| expectedOutput: "You just pulled alpine", | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range testCases { | ||
| testCmd := &cobra.Command{ | ||
| Use: "pull", | ||
| Args: cobra.ExactArgs(len(tc.args)), | ||
| } | ||
| for _, f := range tc.flags { | ||
| _ = testCmd.Flags().String(f.name, "", "") | ||
| err := testCmd.Flag(f.name).Value.Set(f.value) | ||
| assert.NilError(t, err) | ||
| } | ||
| err := testCmd.Flags().Parse(tc.args) | ||
| assert.NilError(t, err) | ||
|
|
||
| out, err := ParseTemplate(tc.template, testCmd) | ||
| assert.NilError(t, err) | ||
| assert.Equal(t, out, tc.expectedOutput) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| package manager | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "strings" | ||
|
|
||
| "github.com/docker/cli/cli-plugins/hooks" | ||
| "github.com/docker/cli/cli/command" | ||
| "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. | ||
| type HookPluginData struct { | ||
| RootCmd string | ||
| Flags map[string]string | ||
| } | ||
laurazard marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // RunPluginHooks calls the hook subcommand for all present | ||
| // CLI plugins that declare support for hooks in their metadata | ||
| // and parses/prints their responses. | ||
| func RunPluginHooks(dockerCli command.Cli, rootCmd, subCommand *cobra.Command, plugin string, args []string) error { | ||
| subCmdName := subCommand.Name() | ||
| if plugin != "" { | ||
| subCmdName = plugin | ||
| } | ||
| var flags map[string]string | ||
| if plugin == "" { | ||
| flags = getCommandFlags(subCommand) | ||
| } else { | ||
| flags = getNaiveFlags(args) | ||
| } | ||
| nextSteps := invokeAndCollectHooks(dockerCli, rootCmd, subCommand, subCmdName, flags) | ||
|
|
||
| hooks.PrintNextSteps(dockerCli.Err(), nextSteps) | ||
| return nil | ||
| } | ||
|
|
||
| func invokeAndCollectHooks(dockerCli command.Cli, rootCmd, subCmd *cobra.Command, hookCmdName string, flags map[string]string) []string { | ||
| pluginsCfg := dockerCli.ConfigFile().Plugins | ||
| if pluginsCfg == nil { | ||
| return nil | ||
| } | ||
|
|
||
| nextSteps := make([]string, 0, len(pluginsCfg)) | ||
| for pluginName, cfg := range pluginsCfg { | ||
| if !registersHook(cfg, hookCmdName) { | ||
| continue | ||
| } | ||
|
|
||
| p, err := GetPlugin(pluginName, dockerCli, rootCmd) | ||
| if err != nil { | ||
| continue | ||
| } | ||
|
|
||
| hookReturn, err := p.RunHook(hookCmdName, flags) | ||
| if err != nil { | ||
| // skip misbehaving plugins, but don't halt execution | ||
| continue | ||
| } | ||
|
|
||
| var hookMessageData hooks.HookMessage | ||
| err = json.Unmarshal(hookReturn, &hookMessageData) | ||
| if err != nil { | ||
| continue | ||
| } | ||
|
|
||
| // currently the only hook type | ||
| if hookMessageData.Type != hooks.NextSteps { | ||
| continue | ||
| } | ||
|
|
||
| processedHook, err := hooks.ParseTemplate(hookMessageData.Template, subCmd) | ||
| if err != nil { | ||
| continue | ||
| } | ||
| nextSteps = append(nextSteps, processedHook) | ||
| } | ||
| return nextSteps | ||
| } | ||
|
|
||
| func registersHook(pluginCfg map[string]string, subCmdName string) bool { | ||
| hookCmdStr, ok := pluginCfg["hooks"] | ||
| if !ok { | ||
| return false | ||
| } | ||
| commands := strings.Split(hookCmdStr, ",") | ||
| for _, hookCmd := range commands { | ||
| if hookCmd == subCmdName { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we expect a single invocation of the hook to return a single message, or could there be situations where we want multiple to be returned? (And in that case, should we have some struct with a slice of HookMessages to be returned?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1 hook message per invocation, for now. We don't have any instance where more is needed to satisfy current usecases. Imo it's fine for us to stick to this for a first iteration, but I could see the argument the other way too.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just to say it's quite common inside docker scout cli to display multiple hint messages from a single command. It's different as it's not in the main CLI, but just to share the idea that multiple hints can be interesting at some point.