Skip to content

Commit

Permalink
Add support for running a CLI plugin
Browse files Browse the repository at this point in the history
Also includes the  scaffolding for finding a validating plugin candidates.

Signed-off-by: Ian Campbell <ijc@docker.com>
  • Loading branch information
Ian Campbell committed Dec 13, 2018
1 parent f6cde75 commit a4d6b68
Show file tree
Hide file tree
Showing 5 changed files with 334 additions and 10 deletions.
25 changes: 25 additions & 0 deletions cli-plugins/manager/candidate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package manager

import (
"os/exec"

cliplugins "github.com/docker/cli/cli-plugins"
)

// Candidate represents a possible plugin candidate, for mocking purposes
type Candidate interface {
Path() string
Metadata() ([]byte, error)
}

type candidate struct {
path string
}

func (c *candidate) Path() string {
return c.path
}

func (c *candidate) Metadata() ([]byte, error) {
return exec.Command(c.path, cliplugins.MetadataSubcommandName).Output()
}
81 changes: 81 additions & 0 deletions cli-plugins/manager/candidate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package manager

import (
"fmt"
"strings"
"testing"

cliplugins "github.com/docker/cli/cli-plugins"
"github.com/spf13/cobra"
"gotest.tools/assert"
)

type mockCandidate struct {
path string
exec bool
meta string
}

func (c *mockCandidate) Path() string {
return c.path
}

func (c *mockCandidate) Metadata() ([]byte, error) {
if !c.exec {
return nil, fmt.Errorf("mocked failure to exec %q", c.path)
}
return []byte(c.meta), nil
}

func TestValidateCandidate(t *testing.T) {
var (
goodPluginName = cliplugins.NamePrefix + "goodplugin"
goodVersion = "0.1.0"

builtinName = cliplugins.NamePrefix + "builtin"
builtinAlias = cliplugins.NamePrefix + "alias"

badPrefixPath = "/usr/local/libexec/cli-plugins/wobble"
badNamePath = "/usr/local/libexec/cli-plugins/docker-123456"
goodPluginPath = "/usr/local/libexec/cli-plugins/" + goodPluginName
)

fakeroot := &cobra.Command{Use: "docker"}
fakeroot.AddCommand(&cobra.Command{
Use: strings.TrimPrefix(builtinName, cliplugins.NamePrefix),
Aliases: []string{
strings.TrimPrefix(builtinAlias, cliplugins.NamePrefix),
},
})

for _, tc := range []struct {
c *mockCandidate

// Either err or invalid may be non-empty, but not both (both can be empty for a good plugin).
err string
invalid string
}{
/* Each failing one of the tests */
{c: &mockCandidate{path: ""}, err: "plugin candidate path cannot be empty"},
{c: &mockCandidate{path: badPrefixPath}, err: fmt.Sprintf("does not have %q prefix", cliplugins.NamePrefix)},
{c: &mockCandidate{path: badNamePath}, invalid: "did not match"},
{c: &mockCandidate{path: builtinName}, invalid: "plugin duplicates builtin command"},
{c: &mockCandidate{path: builtinAlias}, invalid: "plugin duplicates builtin command"},
{c: &mockCandidate{path: goodPluginPath, exec: false}, invalid: fmt.Sprintf("failed to fetch metadata: mocked failure to exec %q", goodPluginPath)},
{c: &mockCandidate{path: goodPluginPath, exec: true, meta: `xyzzy`}, invalid: "invalid character"},
// This one should work
{c: &mockCandidate{path: goodPluginPath, exec: true, meta: fmt.Sprintf(`{"Version": %q}`, goodVersion)}},
} {
p, err := NewPlugin(tc.c, fakeroot)
if tc.err != "" {
assert.ErrorContains(t, err, tc.err)
} else if tc.invalid != "" {
assert.NilError(t, err)
assert.ErrorContains(t, p.IsValid(), tc.invalid)
} else {
assert.NilError(t, err)
assert.Equal(t, cliplugins.NamePrefix+p.Name, goodPluginName)
assert.Equal(t, p.Version, goodVersion)
}
}
}
104 changes: 104 additions & 0 deletions cli-plugins/manager/manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package manager

import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"

cliplugins "github.com/docker/cli/cli-plugins"
"github.com/spf13/cobra"
)

// ErrPluginNotFound is the error returned when a plugin could not be found.
type ErrPluginNotFound string

func (e ErrPluginNotFound) Error() string {
return "Error: No such CLI plugin: " + string(e)
}

var (
pluginDirs []string
pluginDirsOnce sync.Once
)

func getPluginDirs() []string {
pluginDirsOnce.Do(func() {
// Mostly for test.
if ds := os.Getenv("DOCKER_CLI_PLUGIN_EXTRA_DIRS"); ds != "" {
pluginDirs = append(pluginDirs, strings.Split(ds, ":")...)
}

pluginDirs = append(pluginDirs,
filepath.Join(os.Getenv("HOME"), ".docker/cli-plugins"),
"/usr/local/lib/docker/cli-plugins", "/usr/local/libexec/docker/cli-plugins",
"/usr/lib/docker/cli-plugins", "/usr/libexec/docker/cli-plugins",
)
})
return pluginDirs
}

// 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) {
// We treat this as "not found" so that callers will
// fallback to their "invalid" command path.
return Plugin{}, ErrPluginNotFound(name)
}
exename := cliplugins.NamePrefix + name
if runtime.GOOS == "windows" {
exename = exename + ".exe"
}
var plugin Plugin
for _, d := range getPluginDirs() {
path := filepath.Join(d, exename)

// We stat here rather than letting the exec tell us
// ENOENT because the latter does not distinguish a
// file not existing from its dynamic loader or one of
// its libraries not existing.
if _, err := os.Stat(path); os.IsNotExist(err) {
continue
}

if plugin.Path == "" {
c := &candidate{path: path}
var err error
if plugin, err = NewPlugin(c, rootcmd); err != nil {
return Plugin{}, err
}
if !includeShadowed {
return plugin, nil
}
} else {
plugin.ShadowedPaths = append(plugin.ShadowedPaths, path)
}
}
if plugin.Path == "" {
return Plugin{}, ErrPluginNotFound(name)
}
return plugin, nil
}

func runPluginCommand(name string, rootcmd *cobra.Command, args []string) (*exec.Cmd, error) {
plugin, err := FindPlugin(name, rootcmd, false)
if err != nil {
return nil, err
}
if err := plugin.IsValid(); err != nil {
return nil, ErrPluginNotFound(name)
}
return exec.Command(plugin.Path, args...), 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 is an ErrPluginNotFound if no plugin was found or if the first candidate plugin was invalid somehow.
func PluginRunCommand(name string, rootcmd *cobra.Command) (*exec.Cmd, error) {
// This uses the full original args, not the args which may
// have been provided by cobra to our caller. This is because
// they lack e.g. global options which we must propagate here.
return runPluginCommand(name, rootcmd, os.Args[1:])
}
98 changes: 98 additions & 0 deletions cli-plugins/manager/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package manager

import (
"encoding/json"
"path/filepath"
"regexp"
"runtime"
"strings"

cliplugins "github.com/docker/cli/cli-plugins"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)

var (
pluginNameRe = regexp.MustCompile("^[a-z][a-z0-9]*$")
)

// Plugin represents a potential plugin with all it's metadata.
type Plugin struct {
cliplugins.Metadata

Name string
Path string

// err is non-nil if the plugin failed one of the candidate tests. Use IsValid()
err error `json:",omitempty"`

// ShadowedPaths contains the paths of any other plugins which this plugin takes precedence over.
ShadowedPaths []string `json:",omitempty"`
}

// NewPlugin determines if the given candidate is valid and returns a
// Plugin. If the candidate fails one of the tests then `Plugin.Err`
// is set, but the `Plugin` is still returned with no error. An error
// is only returned due to a non-recoverable error.
func NewPlugin(c Candidate, rootcmd *cobra.Command) (Plugin, error) {
path := c.Path()
if path == "" {
return Plugin{}, errors.New("plugin candidate path cannot be empty")
}

// The candidate listing process should have skipped anything
// which would fail here, so there are all real errors.
fullname := filepath.Base(path)
if fullname == "." {
return Plugin{}, errors.Errorf("unable to determine basename of plugin candidate %q", path)
}
if runtime.GOOS == "windows" {
exe := ".exe"
if !strings.HasSuffix(fullname, exe) {
return Plugin{}, errors.Errorf("plugin candidate %q lacks required %q suffix", path, exe)
}
fullname = strings.TrimSuffix(fullname, exe)
}
if !strings.HasPrefix(fullname, cliplugins.NamePrefix) {
return Plugin{}, errors.Errorf("plugin candidate %q does not have %q prefix", path, cliplugins.NamePrefix)
}

p := Plugin{
Name: strings.TrimPrefix(fullname, cliplugins.NamePrefix),
Path: path,
}

// Now apply the candidate tests, so these update p.err.
if !pluginNameRe.MatchString(p.Name) {
p.err = errors.Errorf("plugin candidate %q did not match %q", p.Name, pluginNameRe.String())
return p, nil
}

if rootcmd != nil {
for _, cmd := range rootcmd.Commands() {
if cmd.Name() == p.Name || cmd.HasAlias(p.Name) {
p.err = errors.New("plugin duplicates builtin command")
return p, nil
}
}
}

// We are supposed to check for relevant execute permissions here. Instead we rely on an attempt to execute.
meta, err := c.Metadata()
if err != nil {
p.err = errors.Wrap(err, "failed to fetch metadata")
return p, nil
}

if err := json.Unmarshal(meta, &p.Metadata); err != nil {
p.err = errors.Wrap(err, "invalid metadata")
return p, nil
}

return p, nil
}

// IsValid returns non-nil if the plugin is invalid/unusable.
func (p *Plugin) IsValid() error {
return p.err
}
36 changes: 26 additions & 10 deletions cmd/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"

"github.com/docker/cli/cli"
pluginmanager "github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/commands"
cliconfig "github.com/docker/cli/cli/config"
Expand All @@ -30,9 +31,32 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command {
SilenceUsage: true,
SilenceErrors: true,
TraverseChildren: true,
Args: noArgs,
Args: func(_ *cobra.Command, _ []string) error {
// arg validation is handled in RunE to support external commands
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return command.ShowHelp(dockerCli.Err())(cmd, args)
if len(args) == 0 {
return command.ShowHelp(dockerCli.Err())(cmd, args)
}
plugincmd, err := pluginmanager.PluginRunCommand(args[0], cmd)
if _, ok := err.(pluginmanager.ErrPluginNotFound); ok {
return fmt.Errorf(
"docker: '%s' is not a docker command.\nSee 'docker --help'", args[0])
}
if err != nil {
return err
}

// Using dockerCli.{In,Out,Err}() here results in a hang until something is input.
// See: - https://github.com/golang/go/issues/10338
// - https://github.com/golang/go/commit/d000e8742a173aa0659584aa01b7ba2834ba28ab
// os.Stdin is a *os.File which avoids this behaviour. We don't need the functionality
// of the wrappers here anyway.
plugincmd.Stdin = os.Stdin
plugincmd.Stdout = os.Stdout
plugincmd.Stderr = os.Stderr
return plugincmd.Run()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// flags must be the top-level command flags, not cmd.Flags()
Expand Down Expand Up @@ -157,14 +181,6 @@ func visitAll(root *cobra.Command, fn func(*cobra.Command)) {
fn(root)
}

func noArgs(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return nil
}
return fmt.Errorf(
"docker: '%s' is not a docker command.\nSee 'docker --help'", args[0])
}

func main() {
// Set terminal emulation based on platform as required.
stdin, stdout, stderr := term.StdStreams()
Expand Down

0 comments on commit a4d6b68

Please sign in to comment.