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
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.

View File

@ -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()

View File

@ -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

View File

@ -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}}

View File

@ -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)
})
}
}

View File

@ -23,6 +23,7 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command {
var (
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) {

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)
}
}