From f912b55bd13bd414e4619c4804424d3c3ddb5b15 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Tue, 11 Dec 2018 14:50:04 +0000 Subject: [PATCH] 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 --- cli-plugins/manager/cobra.go | 57 +++++++++++++++++++++ cli-plugins/manager/manager.go | 77 +++++++++++++++++++++++++++++ cli-plugins/manager/manager_test.go | 71 ++++++++++++++++++++++++++ cli-plugins/manager/plugin.go | 6 +++ cli/cobra.go | 34 ++++++++++--- cli/cobra_test.go | 27 ++++++++++ cmd/docker/docker.go | 36 ++++++++++++-- e2e/cli-plugins/help_test.go | 54 ++++++++++++++++++++ 8 files changed, 351 insertions(+), 11 deletions(-) create mode 100644 cli-plugins/manager/cobra.go create mode 100644 e2e/cli-plugins/help_test.go diff --git a/cli-plugins/manager/cobra.go b/cli-plugins/manager/cobra.go new file mode 100644 index 0000000000..302d338a1c --- /dev/null +++ b/cli-plugins/manager/cobra.go @@ -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 +} diff --git a/cli-plugins/manager/manager.go b/cli-plugins/manager/manager.go index 882d2ee2ea..b6ab943595 100644 --- a/cli-plugins/manager/manager.go +++ b/cli-plugins/manager/manager.go @@ -1,10 +1,12 @@ package manager import ( + "io/ioutil" "os" "os/exec" "path/filepath" "runtime" + "strings" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/config" @@ -41,6 +43,81 @@ func getPluginDirs(dockerCli command.Cli) []string { 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. // 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. diff --git a/cli-plugins/manager/manager_test.go b/cli-plugins/manager/manager_test.go index 3a62b911fa..450ae61200 100644 --- a/cli-plugins/manager/manager_test.go +++ b/cli-plugins/manager/manager_test.go @@ -7,8 +7,79 @@ import ( "github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/internal/test" "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) { var err error = errPluginNotFound("test") err.(errPluginNotFound).NotFound() diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go index 8a07c58537..cc7e312db8 100644 --- a/cli-plugins/manager/plugin.go +++ b/cli-plugins/manager/plugin.go @@ -69,6 +69,12 @@ func newPlugin(c Candidate, rootcmd *cobra.Command) (Plugin, error) { if rootcmd != nil { 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 { p.Err = errors.Errorf("plugin %q duplicates builtin command", p.Name) return p, nil diff --git a/cli/cobra.go b/cli/cobra.go index 8acce94783..6a75110117 100644 --- a/cli/cobra.go +++ b/cli/cobra.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + pluginmanager "github.com/docker/cli/cli-plugins/manager" cliconfig "github.com/docker/cli/cli/config" cliflags "github.com/docker/cli/cli/flags" "github.com/docker/docker/pkg/term" @@ -14,7 +15,7 @@ import ( // setupCommonRootCommand contains the setup common to // 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() flags := rootCmd.Flags() @@ -26,19 +27,21 @@ func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *p cobra.AddTemplateFunc("operationSubCommands", operationSubCommands) cobra.AddTemplateFunc("managementSubCommands", managementSubCommands) 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.SetHelpTemplate(helpTemplate) rootCmd.SetFlagErrorFunc(FlagErrorFunc) rootCmd.SetHelpCommand(helpCommand) - return opts, flags + return opts, flags, helpCommand } // SetupRootCommand sets default usage, help, and error handling for the // root command. -func SetupRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet) { - opts, flags := setupCommonRootCommand(rootCmd) +func SetupRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet, *cobra.Command) { + opts, flags, helpCmd := setupCommonRootCommand(rootCmd) 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().Lookup("help").Hidden = true - return opts, flags + return opts, flags, helpCmd } // SetupPluginRootCommand sets default usage, help and error handling for a plugin root command. 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().Lookup("help").Hidden = true @@ -138,6 +141,21 @@ func wrappedFlagUsages(cmd *cobra.Command) string { 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 { cmds := []*cobra.Command{} for _, sub := range cmd.Commands() { @@ -178,7 +196,7 @@ Options: Management Commands: {{- range managementSubCommands . }} - {{rpad .Name .NamePadding }} {{.Short}} + {{rpad .Name .NamePadding }} {{ if isFirstLevelCommand .}}{{commandVendor .}} {{ end}}{{.Short}} {{- end}} {{- end}} @@ -187,7 +205,7 @@ Management Commands: Commands: {{- range operationSubCommands . }} - {{rpad .Name .NamePadding }} {{.Short}} + {{rpad .Name .NamePadding }} {{ if isFirstLevelCommand .}}{{commandVendor .}} {{ end}}{{.Short}} {{- end}} {{- end}} diff --git a/cli/cobra_test.go b/cli/cobra_test.go index 8c9cf1b19f..99744e0670 100644 --- a/cli/cobra_test.go +++ b/cli/cobra_test.go @@ -3,6 +3,7 @@ package cli import ( "testing" + pluginmanager "github.com/docker/cli/cli-plugins/manager" "github.com/spf13/cobra" "gotest.tools/assert" ) @@ -28,3 +29,29 @@ func TestVisitAll(t *testing.T) { expected := []string{"sub1sub1", "sub1sub2", "sub1", "sub2", "root"} 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) + }) + } +} diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 062cbfdc9b..b9734453ff 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -21,8 +21,9 @@ import ( func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { var ( - opts *cliflags.ClientOptions - flags *pflag.FlagSet + opts *cliflags.ClientOptions + flags *pflag.FlagSet + helpCmd *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), DisableFlagsInUseLine: true, } - opts, flags = cli.SetupRootCommand(cmd) + opts, flags, helpCmd = cli.SetupRootCommand(cmd) flags.BoolP("version", "v", false, "Print version information and quit") setFlagErrorFunc(dockerCli, cmd, flags, opts) + setupHelpCommand(dockerCli, cmd, helpCmd, flags, opts) setHelpFunc(dockerCli, cmd, flags, opts) 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) { defaultHelpFunc := cmd.HelpFunc() cmd.SetHelpFunc(func(ccmd *cobra.Command, args []string) { diff --git a/e2e/cli-plugins/help_test.go b/e2e/cli-plugins/help_test.go new file mode 100644 index 0000000000..c66dfacc0f --- /dev/null +++ b/e2e/cli-plugins/help_test.go @@ -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) + } +}