Integrate CLI plugins into `docker help` output.

To do this we add a stub `cobra.Command` for each installed plugin (only when
invoking `help`, not for normal running).

This requires a function to list all available plugins so that is added here.

Signed-off-by: Ian Campbell <ijc@docker.com>
This commit is contained in:
Ian Campbell 2018-12-11 14:50:04 +00:00
parent 5db336798c
commit f912b55bd1
8 changed files with 351 additions and 11 deletions

View File

@ -0,0 +1,57 @@
package manager
import (
"github.com/docker/cli/cli/command"
"github.com/spf13/cobra"
)
const (
// CommandAnnotationPlugin is added to every stub command added by
// AddPluginCommandStubs with the value "true" and so can be
// used to distinguish plugin stubs from regular commands.
CommandAnnotationPlugin = "com.docker.cli.plugin"
// CommandAnnotationPluginVendor is added to every stub command
// added by AddPluginCommandStubs and contains the vendor of
// that plugin.
CommandAnnotationPluginVendor = "com.docker.cli.plugin.vendor"
// CommandAnnotationPluginInvalid is added to any stub command
// added by AddPluginCommandStubs for an invalid command (that
// is, one which failed it's candidate test) and contains the
// reason for the failure.
CommandAnnotationPluginInvalid = "com.docker.cli.plugin-invalid"
)
// AddPluginCommandStubs adds a stub cobra.Commands for each plugin
// (optionally including invalid ones). The command stubs will have
// several annotations added, see `CommandAnnotationPlugin*`.
func AddPluginCommandStubs(dockerCli command.Cli, cmd *cobra.Command, includeInvalid bool) error {
plugins, err := ListPlugins(dockerCli, cmd)
if err != nil {
return err
}
for _, p := range plugins {
if !includeInvalid && p.Err != nil {
continue
}
vendor := p.Vendor
if vendor == "" {
vendor = "unknown"
}
annotations := map[string]string{
CommandAnnotationPlugin: "true",
CommandAnnotationPluginVendor: vendor,
}
if p.Err != nil {
annotations[CommandAnnotationPluginInvalid] = p.Err.Error()
}
cmd.AddCommand(&cobra.Command{
Use: p.Name,
Short: p.ShortDescription,
Run: func(_ *cobra.Command, _ []string) {},
Annotations: annotations,
})
}
return nil
}

View File

@ -1,10 +1,12 @@
package manager package manager
import ( import (
"io/ioutil"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config"
@ -41,6 +43,81 @@ func getPluginDirs(dockerCli command.Cli) []string {
return pluginDirs return pluginDirs
} }
func addPluginCandidatesFromDir(res map[string][]string, d string) error {
dentries, err := ioutil.ReadDir(d)
if err != nil {
return err
}
for _, dentry := range dentries {
switch dentry.Mode() & os.ModeType {
case 0, os.ModeSymlink:
// Regular file or symlink, keep going
default:
// Something else, ignore.
continue
}
name := dentry.Name()
if !strings.HasPrefix(name, NamePrefix) {
continue
}
name = strings.TrimPrefix(name, NamePrefix)
if runtime.GOOS == "windows" {
exe := ".exe"
if !strings.HasSuffix(name, exe) {
continue
}
name = strings.TrimSuffix(name, exe)
}
res[name] = append(res[name], filepath.Join(d, dentry.Name()))
}
return nil
}
// listPluginCandidates returns a map from plugin name to the list of (unvalidated) Candidates. The list is in descending order of priority.
func listPluginCandidates(dirs []string) (map[string][]string, error) {
result := make(map[string][]string)
for _, d := range dirs {
// Silently ignore any directories which we cannot
// Stat (e.g. due to permissions or anything else) or
// which is not a directory.
if fi, err := os.Stat(d); err != nil || !fi.IsDir() {
continue
}
if err := addPluginCandidatesFromDir(result, d); err != nil {
// Silently ignore paths which don't exist.
if os.IsNotExist(err) {
continue
}
return nil, err // Or return partial result?
}
}
return result, nil
}
// ListPlugins produces a list of the plugins available on the system
func ListPlugins(dockerCli command.Cli, rootcmd *cobra.Command) ([]Plugin, error) {
candidates, err := listPluginCandidates(getPluginDirs(dockerCli))
if err != nil {
return nil, err
}
var plugins []Plugin
for _, paths := range candidates {
if len(paths) == 0 {
continue
}
c := &candidate{paths[0]}
p, err := newPlugin(c, rootcmd)
if err != nil {
return nil, err
}
p.ShadowedPaths = paths[1:]
plugins = append(plugins, p)
}
return plugins, nil
}
// PluginRunCommand returns an "os/exec".Cmd which when .Run() will execute the named plugin. // PluginRunCommand returns an "os/exec".Cmd which when .Run() will execute the named plugin.
// The rootcmd argument is referenced to determine the set of builtin commands in order to detect conficts. // The rootcmd argument is referenced to determine the set of builtin commands in order to detect conficts.
// The error returned satisfies the IsNotFound() predicate if no plugin was found or if the first candidate plugin was invalid somehow. // The error returned satisfies the IsNotFound() predicate if no plugin was found or if the first candidate plugin was invalid somehow.

View File

@ -7,8 +7,79 @@ import (
"github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"gotest.tools/assert" "gotest.tools/assert"
"gotest.tools/fs"
) )
func TestListPluginCandidates(t *testing.T) {
// Populate a selection of directories with various shadowed and bogus/obscure plugin candidates.
// For the purposes of this test no contents is required and permissions are irrelevant.
dir := fs.NewDir(t, t.Name(),
fs.WithDir(
"plugins1",
fs.WithFile("docker-plugin1", ""), // This appears in each directory
fs.WithFile("not-a-plugin", ""), // Should be ignored
fs.WithFile("docker-symlinked1", ""), // This and ...
fs.WithSymlink("docker-symlinked2", "docker-symlinked1"), // ... this should both appear
fs.WithDir("ignored1"), // A directory should be ignored
),
fs.WithDir(
"plugins2",
fs.WithFile("docker-plugin1", ""),
fs.WithFile("also-not-a-plugin", ""),
fs.WithFile("docker-hardlink1", ""), // This and ...
fs.WithHardlink("docker-hardlink2", "docker-hardlink1"), // ... this should both appear
fs.WithDir("ignored2"),
),
fs.WithDir(
"plugins3-target", // Will be referenced as a symlink from below
fs.WithFile("docker-plugin1", ""),
fs.WithDir("ignored3"),
fs.WithSymlink("docker-brokensymlink", "broken"), // A broken symlink is still a candidate (but would fail tests later)
fs.WithFile("non-plugin-symlinked", ""), // This shouldn't appear, but ...
fs.WithSymlink("docker-symlinked", "non-plugin-symlinked"), // ... this link to it should.
),
fs.WithSymlink("plugins3", "plugins3-target"),
fs.WithFile("/plugins4", ""),
fs.WithSymlink("plugins5", "plugins5-nonexistent-target"),
)
defer dir.Remove()
var dirs []string
for _, d := range []string{"plugins1", "nonexistent", "plugins2", "plugins3", "plugins4", "plugins5"} {
dirs = append(dirs, dir.Join(d))
}
candidates, err := listPluginCandidates(dirs)
assert.NilError(t, err)
exp := map[string][]string{
"plugin1": {
dir.Join("plugins1", "docker-plugin1"),
dir.Join("plugins2", "docker-plugin1"),
dir.Join("plugins3", "docker-plugin1"),
},
"symlinked1": {
dir.Join("plugins1", "docker-symlinked1"),
},
"symlinked2": {
dir.Join("plugins1", "docker-symlinked2"),
},
"hardlink1": {
dir.Join("plugins2", "docker-hardlink1"),
},
"hardlink2": {
dir.Join("plugins2", "docker-hardlink2"),
},
"brokensymlink": {
dir.Join("plugins3", "docker-brokensymlink"),
},
"symlinked": {
dir.Join("plugins3", "docker-symlinked"),
},
}
assert.DeepEqual(t, candidates, exp)
}
func TestErrPluginNotFound(t *testing.T) { func TestErrPluginNotFound(t *testing.T) {
var err error = errPluginNotFound("test") var err error = errPluginNotFound("test")
err.(errPluginNotFound).NotFound() err.(errPluginNotFound).NotFound()

View File

@ -69,6 +69,12 @@ func newPlugin(c Candidate, rootcmd *cobra.Command) (Plugin, error) {
if rootcmd != nil { if rootcmd != nil {
for _, cmd := range rootcmd.Commands() { for _, cmd := range rootcmd.Commands() {
// Ignore conflicts with commands which are
// just plugin stubs (i.e. from a previous
// call to AddPluginCommandStubs).
if p := cmd.Annotations[CommandAnnotationPlugin]; p == "true" {
continue
}
if cmd.Name() == p.Name { if cmd.Name() == p.Name {
p.Err = errors.Errorf("plugin %q duplicates builtin command", p.Name) p.Err = errors.Errorf("plugin %q duplicates builtin command", p.Name)
return p, nil return p, nil

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
pluginmanager "github.com/docker/cli/cli-plugins/manager"
cliconfig "github.com/docker/cli/cli/config" cliconfig "github.com/docker/cli/cli/config"
cliflags "github.com/docker/cli/cli/flags" cliflags "github.com/docker/cli/cli/flags"
"github.com/docker/docker/pkg/term" "github.com/docker/docker/pkg/term"
@ -14,7 +15,7 @@ import (
// setupCommonRootCommand contains the setup common to // setupCommonRootCommand contains the setup common to
// SetupRootCommand and SetupPluginRootCommand. // SetupRootCommand and SetupPluginRootCommand.
func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet) { func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet, *cobra.Command) {
opts := cliflags.NewClientOptions() opts := cliflags.NewClientOptions()
flags := rootCmd.Flags() flags := rootCmd.Flags()
@ -26,19 +27,21 @@ func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *p
cobra.AddTemplateFunc("operationSubCommands", operationSubCommands) cobra.AddTemplateFunc("operationSubCommands", operationSubCommands)
cobra.AddTemplateFunc("managementSubCommands", managementSubCommands) cobra.AddTemplateFunc("managementSubCommands", managementSubCommands)
cobra.AddTemplateFunc("wrappedFlagUsages", wrappedFlagUsages) cobra.AddTemplateFunc("wrappedFlagUsages", wrappedFlagUsages)
cobra.AddTemplateFunc("commandVendor", commandVendor)
cobra.AddTemplateFunc("isFirstLevelCommand", isFirstLevelCommand) // is it an immediate sub-command of the root
rootCmd.SetUsageTemplate(usageTemplate) rootCmd.SetUsageTemplate(usageTemplate)
rootCmd.SetHelpTemplate(helpTemplate) rootCmd.SetHelpTemplate(helpTemplate)
rootCmd.SetFlagErrorFunc(FlagErrorFunc) rootCmd.SetFlagErrorFunc(FlagErrorFunc)
rootCmd.SetHelpCommand(helpCommand) rootCmd.SetHelpCommand(helpCommand)
return opts, flags return opts, flags, helpCommand
} }
// SetupRootCommand sets default usage, help, and error handling for the // SetupRootCommand sets default usage, help, and error handling for the
// root command. // root command.
func SetupRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet) { func SetupRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet, *cobra.Command) {
opts, flags := setupCommonRootCommand(rootCmd) opts, flags, helpCmd := setupCommonRootCommand(rootCmd)
rootCmd.SetVersionTemplate("Docker version {{.Version}}\n") rootCmd.SetVersionTemplate("Docker version {{.Version}}\n")
@ -46,12 +49,12 @@ func SetupRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.F
rootCmd.PersistentFlags().MarkShorthandDeprecated("help", "please use --help") rootCmd.PersistentFlags().MarkShorthandDeprecated("help", "please use --help")
rootCmd.PersistentFlags().Lookup("help").Hidden = true rootCmd.PersistentFlags().Lookup("help").Hidden = true
return opts, flags return opts, flags, helpCmd
} }
// SetupPluginRootCommand sets default usage, help and error handling for a plugin root command. // SetupPluginRootCommand sets default usage, help and error handling for a plugin root command.
func SetupPluginRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet) { func SetupPluginRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet) {
opts, flags := setupCommonRootCommand(rootCmd) opts, flags, _ := setupCommonRootCommand(rootCmd)
rootCmd.PersistentFlags().BoolP("help", "", false, "Print usage") rootCmd.PersistentFlags().BoolP("help", "", false, "Print usage")
rootCmd.PersistentFlags().Lookup("help").Hidden = true rootCmd.PersistentFlags().Lookup("help").Hidden = true
@ -138,6 +141,21 @@ func wrappedFlagUsages(cmd *cobra.Command) string {
return cmd.Flags().FlagUsagesWrapped(width - 1) return cmd.Flags().FlagUsagesWrapped(width - 1)
} }
func isFirstLevelCommand(cmd *cobra.Command) bool {
return cmd.Parent() == cmd.Root()
}
func commandVendor(cmd *cobra.Command) string {
width := 13
if v, ok := cmd.Annotations[pluginmanager.CommandAnnotationPluginVendor]; ok {
if len(v) > width-2 {
v = v[:width-3] + "…"
}
return fmt.Sprintf("%-*s", width, "("+v+")")
}
return strings.Repeat(" ", width)
}
func managementSubCommands(cmd *cobra.Command) []*cobra.Command { func managementSubCommands(cmd *cobra.Command) []*cobra.Command {
cmds := []*cobra.Command{} cmds := []*cobra.Command{}
for _, sub := range cmd.Commands() { for _, sub := range cmd.Commands() {
@ -178,7 +196,7 @@ Options:
Management Commands: Management Commands:
{{- range managementSubCommands . }} {{- range managementSubCommands . }}
{{rpad .Name .NamePadding }} {{.Short}} {{rpad .Name .NamePadding }} {{ if isFirstLevelCommand .}}{{commandVendor .}} {{ end}}{{.Short}}
{{- end}} {{- end}}
{{- end}} {{- end}}
@ -187,7 +205,7 @@ Management Commands:
Commands: Commands:
{{- range operationSubCommands . }} {{- range operationSubCommands . }}
{{rpad .Name .NamePadding }} {{.Short}} {{rpad .Name .NamePadding }} {{ if isFirstLevelCommand .}}{{commandVendor .}} {{ end}}{{.Short}}
{{- end}} {{- end}}
{{- end}} {{- end}}

View File

@ -3,6 +3,7 @@ package cli
import ( import (
"testing" "testing"
pluginmanager "github.com/docker/cli/cli-plugins/manager"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gotest.tools/assert" "gotest.tools/assert"
) )
@ -28,3 +29,29 @@ func TestVisitAll(t *testing.T) {
expected := []string{"sub1sub1", "sub1sub2", "sub1", "sub2", "root"} expected := []string{"sub1sub1", "sub1sub2", "sub1", "sub2", "root"}
assert.DeepEqual(t, expected, visited) assert.DeepEqual(t, expected, visited)
} }
func TestCommandVendor(t *testing.T) {
// Non plugin.
assert.Equal(t, commandVendor(&cobra.Command{Use: "test"}), " ")
// Plugins with various lengths of vendor.
for _, tc := range []struct {
vendor string
expected string
}{
{vendor: "vendor", expected: "(vendor) "},
{vendor: "vendor12345", expected: "(vendor12345)"},
{vendor: "vendor123456", expected: "(vendor1234…)"},
{vendor: "vendor1234567", expected: "(vendor1234…)"},
} {
t.Run(tc.vendor, func(t *testing.T) {
cmd := &cobra.Command{
Use: "test",
Annotations: map[string]string{
pluginmanager.CommandAnnotationPluginVendor: tc.vendor,
},
}
assert.Equal(t, commandVendor(cmd), tc.expected)
})
}
}

View File

@ -21,8 +21,9 @@ import (
func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command {
var ( var (
opts *cliflags.ClientOptions opts *cliflags.ClientOptions
flags *pflag.FlagSet flags *pflag.FlagSet
helpCmd *cobra.Command
) )
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -57,11 +58,12 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command {
Version: fmt.Sprintf("%s, build %s", version.Version, version.GitCommit), Version: fmt.Sprintf("%s, build %s", version.Version, version.GitCommit),
DisableFlagsInUseLine: true, DisableFlagsInUseLine: true,
} }
opts, flags = cli.SetupRootCommand(cmd) opts, flags, helpCmd = cli.SetupRootCommand(cmd)
flags.BoolP("version", "v", false, "Print version information and quit") flags.BoolP("version", "v", false, "Print version information and quit")
setFlagErrorFunc(dockerCli, cmd, flags, opts) setFlagErrorFunc(dockerCli, cmd, flags, opts)
setupHelpCommand(dockerCli, cmd, helpCmd, flags, opts)
setHelpFunc(dockerCli, cmd, flags, opts) setHelpFunc(dockerCli, cmd, flags, opts)
cmd.SetOutput(dockerCli.Out()) cmd.SetOutput(dockerCli.Out())
@ -90,6 +92,34 @@ func setFlagErrorFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *p
}) })
} }
func setupHelpCommand(dockerCli *command.DockerCli, rootCmd, helpCmd *cobra.Command, flags *pflag.FlagSet, opts *cliflags.ClientOptions) {
origRun := helpCmd.Run
origRunE := helpCmd.RunE
helpCmd.Run = nil
helpCmd.RunE = func(c *cobra.Command, args []string) error {
// No Persistent* hooks are called for help, so we must initialize here.
if err := initializeDockerCli(dockerCli, flags, opts); err != nil {
return err
}
// Add a stub entry for every plugin so they are
// included in the help output. If we have no args
// then this is being used for `docker help` and we
// want to include broken plugins, otherwise this is
// `help «foo»` and we do not.
if err := pluginmanager.AddPluginCommandStubs(dockerCli, rootCmd, len(args) == 0); err != nil {
return err
}
if origRunE != nil {
return origRunE(c, args)
}
origRun(c, args)
return nil
}
}
func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag.FlagSet, opts *cliflags.ClientOptions) { func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag.FlagSet, opts *cliflags.ClientOptions) {
defaultHelpFunc := cmd.HelpFunc() defaultHelpFunc := cmd.HelpFunc()
cmd.SetHelpFunc(func(ccmd *cobra.Command, args []string) { cmd.SetHelpFunc(func(ccmd *cobra.Command, args []string) {

View File

@ -0,0 +1,54 @@
package cliplugins
import (
"bufio"
"regexp"
"strings"
"testing"
"gotest.tools/assert"
is "gotest.tools/assert/cmp"
"gotest.tools/icmd"
)
// TestGlobalHelp ensures correct behaviour when running `docker help`
func TestGlobalHelp(t *testing.T) {
run, cleanup := prepare(t)
defer cleanup()
res := icmd.RunCmd(run("help"))
res.Assert(t, icmd.Expected{
ExitCode: 0,
})
assert.Assert(t, is.Equal(res.Stderr(), ""))
scanner := bufio.NewScanner(strings.NewReader(res.Stdout()))
// Instead of baking in the full current output of `docker
// help`, which can be expected to change regularly, bake in
// some checkpoints. Key things we are looking for:
//
// - The top-level description
// - Each of the main headings
// - Some builtin commands under the main headings
// - The `helloworld` plugin in the appropriate place
//
// Regexps are needed because the width depends on `unix.TIOCGWINSZ` or similar.
for _, expected := range []*regexp.Regexp{
regexp.MustCompile(`^A self-sufficient runtime for containers$`),
regexp.MustCompile(`^Management Commands:$`),
regexp.MustCompile(`^ container\s+Manage containers$`),
regexp.MustCompile(`^Commands:$`),
regexp.MustCompile(`^ create\s+Create a new container$`),
regexp.MustCompile(`^ helloworld\s+\(Docker Inc\.\)\s+A basic Hello World plugin for tests$`),
regexp.MustCompile(`^ ps\s+List containers$`),
} {
var found bool
for scanner.Scan() {
if expected.MatchString(scanner.Text()) {
found = true
break
}
}
assert.Assert(t, found, "Did not find match for %q in `docker help` output", expected)
}
}