-
Notifications
You must be signed in to change notification settings - Fork 2k
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. Signed-off-by: Ian Campbell <ijc@docker.com>
- Loading branch information
Ian Campbell
committed
Dec 13, 2018
1 parent
f6cde75
commit a4d6b68
Showing
5 changed files
with
334 additions
and
10 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
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() | ||
} |
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,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) | ||
} | ||
} | ||
} |
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 ( | ||
"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:]) | ||
} |
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,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 | ||
} |
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