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:
57
cli-plugins/manager/cobra.go
Normal file
57
cli-plugins/manager/cobra.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user