Skip to content

Commit

Permalink
Integrate CLI plugins into docker help output.
Browse files Browse the repository at this point in the history
This requires a function to list all available plugins so that is added here.

Signed-off-by: Ian Campbell <[email protected]>
  • Loading branch information
Ian Campbell committed Dec 12, 2018
1 parent 12e2c63 commit 6f09985
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 2 deletions.
79 changes: 79 additions & 0 deletions cli-plugins/manager/manager.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package manager

import (
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"

cliplugins "github.com/docker/cli/cli-plugins"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -40,6 +42,83 @@ func getPluginDirs() []string {
return pluginDirs
}

func addPluginCandidatesFromDir(res map[string][]string, d string) error {
dentries, err := ioutil.ReadDir(d)
if err != nil {
if os.IsNotExist(err) {
return nil
}
// Portable? This seems to be as good as it gets :-/
if serr, ok := err.(*os.SyscallError); ok && serr.Err == syscall.ENOTDIR {
return 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, cliplugins.NamePrefix) {
continue
}
name = strings.TrimPrefix(name, cliplugins.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 allows the dirs to be specified for testing purposes.
func listPluginCandidates(dirs []string) (map[string][]string, error) {
result := make(map[string][]string)
for _, d := range dirs {
if err := addPluginCandidatesFromDir(result, d); err != nil {
return nil, err // Or return partial result?
}
}
return result, nil
}

// ListPluginCandidates returns a map from plugin name to the list of (unvalidated) Candidates. The list is in descending order of priority.
func ListPluginCandidates() (map[string][]string, error) {
return listPluginCandidates(getPluginDirs())
}

// ListPlugins produces a list of the plugins available on the system
func ListPlugins(rootcmd *cobra.Command) ([]Plugin, error) {
candidates, err := ListPluginCandidates()
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
}

// FindPlugin finds a valid plugin, if the first candidate is invalid then returns an error
func FindPlugin(name string, rootcmd *cobra.Command, includeShadowed bool) (Plugin, error) {
if !pluginNameRe.MatchString(name) {
Expand Down
77 changes: 77 additions & 0 deletions cli-plugins/manager/manager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package manager

import (
"path/filepath"
"testing"

"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",
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.WithFile("/plugins4", ""),
)
defer dir.Remove()

var dirs []string
for _, d := range []string{"plugins1", "nonexistent", "plugins2", "plugins3", "plugins4"} {
dirs = append(dirs, filepath.Join(dir.Path(), d))
}

candidates, err := listPluginCandidates(dirs)
assert.NilError(t, err)
exp := map[string][]string{
"plugin1": {
filepath.Join(dir.Path(), "plugins1", "docker-plugin1"),
filepath.Join(dir.Path(), "plugins2", "docker-plugin1"),
filepath.Join(dir.Path(), "plugins3", "docker-plugin1"),
},
"symlinked1": {
filepath.Join(dir.Path(), "plugins1", "docker-symlinked1"),
},
"symlinked2": {
filepath.Join(dir.Path(), "plugins1", "docker-symlinked2"),
},
"hardlink1": {
filepath.Join(dir.Path(), "plugins2", "docker-hardlink1"),
},
"hardlink2": {
filepath.Join(dir.Path(), "plugins2", "docker-hardlink2"),
},
"brokensymlink": {
filepath.Join(dir.Path(), "plugins3", "docker-brokensymlink"),
},
"symlinked": {
filepath.Join(dir.Path(), "plugins3", "docker-symlinked"),
},
}

assert.DeepEqual(t, candidates, exp)
}
45 changes: 43 additions & 2 deletions cli/cobra.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"strings"

pluginmanager "github.com/docker/cli/cli-plugins/manager"
"github.com/docker/docker/pkg/term"
"github.com/pkg/errors"
"github.com/spf13/cobra"
Expand All @@ -17,6 +18,8 @@ func SetupRootCommand(rootCmd *cobra.Command) {
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)
Expand Down Expand Up @@ -57,6 +60,29 @@ var helpCommand = &cobra.Command{
return errors.Errorf("unknown help topic: %v", strings.Join(args, " "))
}

plugins, err := pluginmanager.ListPlugins(c.Root())
if err != nil {
return err
}
// Add a stub entry for every plugin so they are included in the help output
for _, p := range plugins {
if err := p.IsValid(); err != nil {
continue
}
vendor := p.Vendor
if vendor == "" {
vendor = "unknown"
}
c.Root().AddCommand(&cobra.Command{
Use: p.Name,
Short: p.ShortDescription,
Run: func(_ *cobra.Command, _ []string) {},
Annotations: map[string]string{
"com.docker.cli.plugin-vendor": vendor,
},
})
}

helpFunc := cmd.HelpFunc()
helpFunc(cmd, args)
return nil
Expand Down Expand Up @@ -89,6 +115,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["com.docker.cli.plugin-vendor"]; 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() {
Expand Down Expand Up @@ -129,7 +170,7 @@ Options:
Management Commands:
{{- range managementSubCommands . }}
{{rpad .Name .NamePadding }} {{.Short}}
{{rpad .Name .NamePadding }} {{ if isFirstLevelCommand .}}{{commandVendor .}} {{ end}}{{.Short}}
{{- end}}
{{- end}}
Expand All @@ -138,7 +179,7 @@ Management Commands:
Commands:
{{- range operationSubCommands . }}
{{rpad .Name .NamePadding }} {{.Short}}
{{rpad .Name .NamePadding }} {{ if isFirstLevelCommand .}}{{commandVendor .}} {{ end}}{{.Short}}
{{- end}}
{{- end}}
Expand Down

0 comments on commit 6f09985

Please sign in to comment.