mirror of https://github.com/docker/cli.git
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>
This commit is contained in:
parent
e96240427f
commit
f1f31abbe5
|
@ -33,6 +33,6 @@ func main() {
|
||||||
manager.Metadata{
|
manager.Metadata{
|
||||||
SchemaVersion: "0.1.0",
|
SchemaVersion: "0.1.0",
|
||||||
Vendor: "Docker Inc.",
|
Vendor: "Docker Inc.",
|
||||||
Version: "0.1.0",
|
Version: "testing",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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",
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultSystemPluginDirs = []string{
|
||||||
|
filepath.Join(os.Getenv("ProgramData"), "Docker", "cli-plugins"),
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -49,6 +49,7 @@ type ConfigFile struct {
|
||||||
StackOrchestrator string `json:"stackOrchestrator,omitempty"`
|
StackOrchestrator string `json:"stackOrchestrator,omitempty"`
|
||||||
Kubernetes *KubernetesConfig `json:"kubernetes,omitempty"`
|
Kubernetes *KubernetesConfig `json:"kubernetes,omitempty"`
|
||||||
CurrentContext string `json:"currentContext,omitempty"`
|
CurrentContext string `json:"currentContext,omitempty"`
|
||||||
|
CLIPluginsExtraDirs []string `json:"cliPluginsExtraDirs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyConfig contains proxy configuration settings
|
// ProxyConfig contains proxy configuration settings
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/cli/cli"
|
"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"
|
||||||
"github.com/docker/cli/cli/command/commands"
|
"github.com/docker/cli/cli/command/commands"
|
||||||
cliflags "github.com/docker/cli/cli/flags"
|
cliflags "github.com/docker/cli/cli/flags"
|
||||||
|
@ -30,9 +31,20 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command {
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
TraverseChildren: true,
|
TraverseChildren: true,
|
||||||
Args: noArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
return command.ShowHelp(dockerCli.Err())(cmd, args)
|
return command.ShowHelp(dockerCli.Err())(cmd, args)
|
||||||
|
}
|
||||||
|
plugincmd, err := pluginmanager.PluginRunCommand(dockerCli, args[0], cmd)
|
||||||
|
if pluginmanager.IsNotFound(err) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"docker: '%s' is not a docker command.\nSee 'docker --help'", args[0])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugincmd.Run()
|
||||||
},
|
},
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
// flags must be the top-level command flags, not cmd.Flags()
|
// flags must be the top-level command flags, not cmd.Flags()
|
||||||
|
@ -136,14 +148,6 @@ func initializeDockerCli(dockerCli *command.DockerCli, flags *pflag.FlagSet, opt
|
||||||
return dockerCli.Initialize(opts)
|
return dockerCli.Initialize(opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
func main() {
|
||||||
dockerCli, err := command.NewDockerCli()
|
dockerCli, err := command.NewDockerCli()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
Loading…
Reference in New Issue