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.

Argument validation is moved to RunE to support this, so `noArgs` is removed.

Signed-off-by: Ian Campbell <ijc@docker.com>
  • Loading branch information
Ian Campbell committed Jan 30, 2019
1 parent e962404 commit f1f31ab
Show file tree
Hide file tree
Showing 10 changed files with 380 additions and 11 deletions.
2 changes: 1 addition & 1 deletion cli-plugins/examples/helloworld/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ func main() {
manager.Metadata{
SchemaVersion: "0.1.0",
Vendor: "Docker Inc.",
Version: "0.1.0",
Version: "testing",
})
}
23 changes: 23 additions & 0 deletions cli-plugins/manager/candidate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package manager

import (
"os/exec"
)

// 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, MetadataSubcommandName).Output()
}
90 changes: 90 additions & 0 deletions cli-plugins/manager/candidate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package manager

import (
"fmt"
"strings"
"testing"

"github.com/spf13/cobra"
"gotest.tools/assert"
)

type fakeCandidate struct {
path string
exec bool
meta string
}

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

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

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

builtinName = NamePrefix + "builtin"
builtinAlias = 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, NamePrefix),
Aliases: []string{
strings.TrimPrefix(builtinAlias, NamePrefix),
},
})

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

// 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: &fakeCandidate{path: ""}, err: "plugin candidate path cannot be empty"},
{c: &fakeCandidate{path: badPrefixPath}, err: fmt.Sprintf("does not have %q prefix", NamePrefix)},
{c: &fakeCandidate{path: badNamePath}, invalid: "did not match"},
{c: &fakeCandidate{path: builtinName}, invalid: `plugin "builtin" duplicates builtin command`},
{c: &fakeCandidate{path: builtinAlias}, invalid: `plugin "alias" duplicates an alias of builtin command "builtin"`},
{c: &fakeCandidate{path: goodPluginPath, exec: false}, invalid: fmt.Sprintf("failed to fetch metadata: faked a failure to exec %q", goodPluginPath)},
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `xyzzy`}, invalid: "invalid character"},
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{}`}, invalid: `plugin SchemaVersion "" is not valid`},
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "xyzzy"}`}, invalid: `plugin SchemaVersion "xyzzy" is not valid`},
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0"}`}, invalid: "plugin metadata does not define a vendor"},
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": ""}`}, invalid: "plugin metadata does not define a vendor"},
// This one should work
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing"}`}},
} {
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.Err, tc.invalid)
} else {
assert.NilError(t, err)
assert.Equal(t, NamePrefix+p.Name, goodPluginName)
assert.Equal(t, p.SchemaVersion, "0.1.0")
assert.Equal(t, p.Vendor, "e2e-testing")
}
}
}

func TestCandidatePath(t *testing.T) {
exp := "/some/path"
cand := &candidate{path: exp}
assert.Equal(t, exp, cand.Path())
}
93 changes: 93 additions & 0 deletions cli-plugins/manager/manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package manager

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

"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/config"
"github.com/spf13/cobra"
)

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

func (e errPluginNotFound) NotFound() {}

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

type notFound interface{ NotFound() }

// IsNotFound is true if the given error is due to a plugin not being found.
func IsNotFound(err error) bool {
_, ok := err.(notFound)
return ok
}

var defaultUserPluginDir = config.Path("cli-plugins")

func getPluginDirs(dockerCli command.Cli) []string {
var pluginDirs []string

if cfg := dockerCli.ConfigFile(); cfg != nil {
pluginDirs = append(pluginDirs, cfg.CLIPluginsExtraDirs...)
}
pluginDirs = append(pluginDirs, defaultUserPluginDir)
pluginDirs = append(pluginDirs, defaultSystemPluginDirs...)
return pluginDirs
}

// 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.
func PluginRunCommand(dockerCli command.Cli, 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.
args := os.Args[1:]
if !pluginNameRe.MatchString(name) {
// We treat this as "not found" so that callers will
// fallback to their "invalid" command path.
return nil, errPluginNotFound(name)
}
exename := NamePrefix + name
if runtime.GOOS == "windows" {
exename = exename + ".exe"
}
for _, d := range getPluginDirs(dockerCli) {
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
}

c := &candidate{path: path}
plugin, err := newPlugin(c, rootcmd)
if err != nil {
return nil, err
}
if plugin.Err != nil {
return nil, errPluginNotFound(name)
}
cmd := exec.Command(plugin.Path, args...)
// 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.
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

return cmd, nil
}
return nil, errPluginNotFound(name)
}
36 changes: 36 additions & 0 deletions cli-plugins/manager/manager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package manager

import (
"strings"
"testing"

"github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/internal/test"
"gotest.tools/assert"
)

func TestErrPluginNotFound(t *testing.T) {
var err error = errPluginNotFound("test")
err.(errPluginNotFound).NotFound()
assert.Error(t, err, "Error: No such CLI plugin: test")
assert.Assert(t, IsNotFound(err))
assert.Assert(t, !IsNotFound(nil))
}

func TestGetPluginDirs(t *testing.T) {
cli := test.NewFakeCli(nil)

expected := []string{defaultUserPluginDir}
expected = append(expected, defaultSystemPluginDirs...)

assert.Equal(t, strings.Join(expected, ":"), strings.Join(getPluginDirs(cli), ":"))

extras := []string{
"foo", "bar", "baz",
}
expected = append(extras, expected...)
cli.SetConfigFile(&configfile.ConfigFile{
CLIPluginsExtraDirs: extras,
})
assert.DeepEqual(t, expected, getPluginDirs(cli))
}
8 changes: 8 additions & 0 deletions cli-plugins/manager/manager_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// +build !windows

package manager

var defaultSystemPluginDirs = []string{
"/usr/local/lib/docker/cli-plugins", "/usr/local/libexec/docker/cli-plugins",
"/usr/lib/docker/cli-plugins", "/usr/libexec/docker/cli-plugins",
}
10 changes: 10 additions & 0 deletions cli-plugins/manager/manager_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package manager

import (
"os"
"path/filepath"
)

var defaultSystemPluginDirs = []string{
filepath.Join(os.Getenv("ProgramData"), "Docker", "cli-plugins"),
}
104 changes: 104 additions & 0 deletions cli-plugins/manager/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package manager

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

"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 {
Metadata

Name string
Path string

// Err is non-nil if the plugin failed one of the candidate tests.
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, NamePrefix) {
return Plugin{}, errors.Errorf("plugin candidate %q does not have %q prefix", path, NamePrefix)
}

p := Plugin{
Name: strings.TrimPrefix(fullname, 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 {
p.Err = errors.Errorf("plugin %q duplicates builtin command", p.Name)
return p, nil
}
if cmd.HasAlias(p.Name) {
p.Err = errors.Errorf("plugin %q duplicates an alias of builtin command %q", p.Name, cmd.Name())
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
}

if p.Metadata.SchemaVersion != "0.1.0" {
p.Err = errors.Errorf("plugin SchemaVersion %q is not valid, must be 0.1.0", p.Metadata.SchemaVersion)
return p, nil
}
if p.Metadata.Vendor == "" {
p.Err = errors.Errorf("plugin metadata does not define a vendor")
return p, nil
}
return p, nil
}
1 change: 1 addition & 0 deletions cli/config/configfile/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type ConfigFile struct {
StackOrchestrator string `json:"stackOrchestrator,omitempty"`
Kubernetes *KubernetesConfig `json:"kubernetes,omitempty"`
CurrentContext string `json:"currentContext,omitempty"`
CLIPluginsExtraDirs []string `json:"cliPluginsExtraDirs,omitempty"`
}

// ProxyConfig contains proxy configuration settings
Expand Down

0 comments on commit f1f31ab

Please sign in to comment.