-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
cli-plugins: Introduce support for hooks
Signed-off-by: Laura Brehm <laurabrehm@hey.com>
- Loading branch information
Showing
13 changed files
with
557 additions
and
6 deletions.
There are no files selected for viewing
This file contains 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 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 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 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 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 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,128 @@ | ||
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 != "" { | ||
// TODO: handle plugin alias (build = buildx) | ||
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.