cli-plugins: Introduce support for hooks
Signed-off-by: Laura Brehm <laurabrehm@hey.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user