From c5016c6d5ba4d8c6a4e258a926d46fe420b476f2 Mon Sep 17 00:00:00 2001 From: Laura Brehm Date: Thu, 20 Jul 2023 16:25:36 +0100 Subject: [PATCH] cli-plugins: Introduce support for hooks Signed-off-by: Laura Brehm --- cli-plugins/hooks/printer.go | 18 ++++ cli-plugins/hooks/printer_test.go | 38 +++++++++ cli-plugins/hooks/template.go | 115 ++++++++++++++++++++++++++ cli-plugins/hooks/template_test.go | 82 +++++++++++++++++++ cli-plugins/manager/error.go | 3 + cli-plugins/manager/hooks.go | 127 +++++++++++++++++++++++++++++ cli-plugins/manager/hooks_test.go | 38 +++++++++ cli-plugins/manager/metadata.go | 5 ++ cli-plugins/manager/plugin.go | 20 +++++ cli/command/cli.go | 30 +++++++ cli/command/cli_test.go | 53 ++++++++++++ cli/config/configfile/file.go | 1 + cmd/docker/docker.go | 28 ++++++- 13 files changed, 554 insertions(+), 4 deletions(-) create mode 100644 cli-plugins/hooks/printer.go create mode 100644 cli-plugins/hooks/printer_test.go create mode 100644 cli-plugins/hooks/template.go create mode 100644 cli-plugins/hooks/template_test.go create mode 100644 cli-plugins/manager/hooks.go create mode 100644 cli-plugins/manager/hooks_test.go diff --git a/cli-plugins/hooks/printer.go b/cli-plugins/hooks/printer.go new file mode 100644 index 0000000000..bedc87f929 --- /dev/null +++ b/cli-plugins/hooks/printer.go @@ -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) + } +} diff --git a/cli-plugins/hooks/printer_test.go b/cli-plugins/hooks/printer_test.go new file mode 100644 index 0000000000..efe1fe5989 --- /dev/null +++ b/cli-plugins/hooks/printer_test.go @@ -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) + } +} diff --git a/cli-plugins/hooks/template.go b/cli-plugins/hooks/template.go new file mode 100644 index 0000000000..d7e114e1cd --- /dev/null +++ b/cli-plugins/hooks/template.go @@ -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 +} diff --git a/cli-plugins/hooks/template_test.go b/cli-plugins/hooks/template_test.go new file mode 100644 index 0000000000..fbf86ae97f --- /dev/null +++ b/cli-plugins/hooks/template_test.go @@ -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) + } +} diff --git a/cli-plugins/manager/error.go b/cli-plugins/manager/error.go index 4e1c3a2914..f802da1c5c 100644 --- a/cli-plugins/manager/error.go +++ b/cli-plugins/manager/error.go @@ -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)} } diff --git a/cli-plugins/manager/hooks.go b/cli-plugins/manager/hooks.go new file mode 100644 index 0000000000..b4f8d16ddf --- /dev/null +++ b/cli-plugins/manager/hooks.go @@ -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 +} diff --git a/cli-plugins/manager/hooks_test.go b/cli-plugins/manager/hooks_test.go new file mode 100644 index 0000000000..d3ef24dabf --- /dev/null +++ b/cli-plugins/manager/hooks_test.go @@ -0,0 +1,38 @@ +package manager + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestGetNaiveFlags(t *testing.T) { + testCases := []struct { + args []string + expectedFlags map[string]string + }{ + { + args: []string{"docker"}, + expectedFlags: map[string]string{}, + }, + { + args: []string{"docker", "build", "-q", "--file", "test.Dockerfile", "."}, + expectedFlags: map[string]string{ + "q": "", + "file": "", + }, + }, + { + args: []string{"docker", "--context", "a-context", "pull", "-q", "--progress", "auto", "alpine"}, + expectedFlags: map[string]string{ + "context": "", + "q": "", + "progress": "", + }, + }, + } + + for _, tc := range testCases { + assert.DeepEqual(t, getNaiveFlags(tc.args), tc.expectedFlags) + } +} diff --git a/cli-plugins/manager/metadata.go b/cli-plugins/manager/metadata.go index 2f24438638..f7aac06fe9 100644 --- a/cli-plugins/manager/metadata.go +++ b/cli-plugins/manager/metadata.go @@ -8,6 +8,11 @@ const ( // which must be supported by every plugin and returns the // plugin metadata. MetadataSubcommandName = "docker-cli-plugin-metadata" + + // HookSubcommandName is the name of the plugin subcommand + // which must be implemented by plugins declaring support + // for hooks in their metadata. + HookSubcommandName = "docker-cli-plugin-hooks" ) // Metadata provided by the plugin. diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go index 58ed6db72c..88600f4e59 100644 --- a/cli-plugins/manager/plugin.go +++ b/cli-plugins/manager/plugin.go @@ -2,6 +2,7 @@ package manager import ( "encoding/json" + "os/exec" "path/filepath" "regexp" "strings" @@ -100,3 +101,22 @@ func newPlugin(c Candidate, cmds []*cobra.Command) (Plugin, error) { } return p, nil } + +// RunHook executes the plugin's hooks command +// and returns its unprocessed output. +func (p *Plugin) RunHook(cmdName string, flags map[string]string) ([]byte, error) { + hDataBytes, err := json.Marshal(HookPluginData{ + RootCmd: cmdName, + Flags: flags, + }) + if err != nil { + return nil, wrapAsPluginError(err, "failed to marshall hook data") + } + + hookCmdOutput, err := exec.Command(p.Path, p.Name, HookSubcommandName, string(hDataBytes)).Output() + if err != nil { + return nil, wrapAsPluginError(err, "failed to execute plugin hook subcommand") + } + + return hookCmdOutput, nil +} diff --git a/cli/command/cli.go b/cli/command/cli.go index aa3b28d581..8a473227be 100644 --- a/cli/command/cli.go +++ b/cli/command/cli.go @@ -187,6 +187,36 @@ func (cli *DockerCli) BuildKitEnabled() (bool, error) { return cli.ServerInfo().OSType != "windows", nil } +// HooksEnabled returns whether plugin hooks are enabled. +func (cli *DockerCli) HooksEnabled() bool { + // legacy support DOCKER_CLI_HINTS env var + if v := os.Getenv("DOCKER_CLI_HINTS"); v != "" { + enabled, err := strconv.ParseBool(v) + if err != nil { + return false + } + return enabled + } + // use DOCKER_CLI_HOOKS env var value if set and not empty + if v := os.Getenv("DOCKER_CLI_HOOKS"); v != "" { + enabled, err := strconv.ParseBool(v) + if err != nil { + return false + } + return enabled + } + featuresMap := cli.ConfigFile().Features + if v, ok := featuresMap["hooks"]; ok { + enabled, err := strconv.ParseBool(v) + if err != nil { + return false + } + return enabled + } + // default to false + return false +} + // ManifestStore returns a store for local manifests func (cli *DockerCli) ManifestStore() manifeststore.Store { // TODO: support override default location from config file diff --git a/cli/command/cli_test.go b/cli/command/cli_test.go index 7a0b4e727e..d0456cddc5 100644 --- a/cli/command/cli_test.go +++ b/cli/command/cli_test.go @@ -307,3 +307,56 @@ func TestInitializeShouldAlwaysCreateTheContextStore(t *testing.T) { }))) assert.Check(t, cli.ContextStore() != nil) } + +func TestHooksEnabled(t *testing.T) { + t.Run("disabled by default", func(t *testing.T) { + cli, err := NewDockerCli() + assert.NilError(t, err) + + assert.Check(t, !cli.HooksEnabled()) + }) + + t.Run("enabled in configFile", func(t *testing.T) { + configFile := `{ + "features": { + "hooks": "true" + }}` + dir := fs.NewDir(t, "", fs.WithFile("config.json", configFile)) + defer dir.Remove() + cli, err := NewDockerCli() + assert.NilError(t, err) + config.SetDir(dir.Path()) + + assert.Check(t, cli.HooksEnabled()) + }) + + t.Run("env var overrides configFile", func(t *testing.T) { + configFile := `{ + "features": { + "hooks": "true" + }}` + t.Setenv("DOCKER_CLI_HOOKS", "false") + dir := fs.NewDir(t, "", fs.WithFile("config.json", configFile)) + defer dir.Remove() + cli, err := NewDockerCli() + assert.NilError(t, err) + config.SetDir(dir.Path()) + + assert.Check(t, !cli.HooksEnabled()) + }) + + t.Run("legacy env var overrides configFile", func(t *testing.T) { + configFile := `{ + "features": { + "hooks": "true" + }}` + t.Setenv("DOCKER_CLI_HINTS", "false") + dir := fs.NewDir(t, "", fs.WithFile("config.json", configFile)) + defer dir.Remove() + cli, err := NewDockerCli() + assert.NilError(t, err) + config.SetDir(dir.Path()) + + assert.Check(t, !cli.HooksEnabled()) + }) +} diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index 442c31110b..ba9bc9d1d0 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -41,6 +41,7 @@ type ConfigFile struct { CLIPluginsExtraDirs []string `json:"cliPluginsExtraDirs,omitempty"` Plugins map[string]map[string]string `json:"plugins,omitempty"` Aliases map[string]string `json:"aliases,omitempty"` + Features map[string]string `json:"features,omitempty"` } // ProxyConfig contains proxy configuration settings diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index cfc53a6fa1..5a182a121c 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -278,6 +278,7 @@ func tryPluginRun(dockerCli command.Cli, cmd *cobra.Command, subcommand string, return nil } +//nolint:gocyclo func runDocker(dockerCli *command.DockerCli) error { tcmd := newDockerCommand(dockerCli) @@ -306,23 +307,42 @@ func runDocker(dockerCli *command.DockerCli) error { } } + var subCommand *cobra.Command if len(args) > 0 { ccmd, _, err := cmd.Find(args) + subCommand = ccmd if err != nil || pluginmanager.IsPluginCommand(ccmd) { err := tryPluginRun(dockerCli, cmd, args[0], envs) + if err == nil { + if dockerCli.HooksEnabled() && dockerCli.Out().IsTerminal() && ccmd != nil { + _ = pluginmanager.RunPluginHooks(dockerCli, cmd, ccmd, args[0], args) + } + return nil + } if !pluginmanager.IsNotFound(err) { + // For plugin not found we fall through to + // cmd.Execute() which deals with reporting + // "command not found" in a consistent way. return err } - // For plugin not found we fall through to - // cmd.Execute() which deals with reporting - // "command not found" in a consistent way. } } // We've parsed global args already, so reset args to those // which remain. cmd.SetArgs(args) - return cmd.Execute() + err = cmd.Execute() + if err != nil { + return err + } + + // If the command is being executed in an interactive terminal, + // run the plugin hooks (but don't throw an error if something misbehaves) + if dockerCli.HooksEnabled() && dockerCli.Out().IsTerminal() && subCommand != nil { + _ = pluginmanager.RunPluginHooks(dockerCli, cmd, subCommand, "", args) + } + + return nil } type versionDetails interface {