mirror of https://github.com/docker/cli.git
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:
parent
5db336798c
commit
f912b55bd1
|
@ -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
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
34
cli/cobra.go
34
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}}
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue