Skip to content
Merged
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
18 changes: 18 additions & 0 deletions cli-plugins/hooks/printer.go
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)
}
}
38 changes: 38 additions & 0 deletions cli-plugins/hooks/printer_test.go
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)
}
}
115 changes: 115 additions & 0 deletions cli-plugins/hooks/template.go
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
}
Comment on lines +23 to +26
Copy link
Member

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?

Copy link
Collaborator Author

@laurazard laurazard Mar 11, 2024

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.

Copy link

@eunomie eunomie Mar 13, 2024

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.


// 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
}
82 changes: 82 additions & 0 deletions cli-plugins/hooks/template_test.go
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)
}
}
3 changes: 3 additions & 0 deletions cli-plugins/manager/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ func (e *pluginError) MarshalText() (text []byte, err error) {
// wrapAsPluginError wraps an error in a pluginError with an
// additional message, analogous to errors.Wrapf.
func wrapAsPluginError(err error, msg string) error {
if err == nil {
return nil
}
return &pluginError{cause: errors.Wrap(err, msg)}
}

Expand Down
127 changes: 127 additions & 0 deletions cli-plugins/manager/hooks.go
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
}

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