-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for running a CLI plugin
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
Showing
10 changed files
with
380 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"), | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.