From a4d6b6831372d75b8f56c635c5a7ad2867a22e0d Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Tue, 11 Dec 2018 14:03:47 +0000 Subject: [PATCH] Add support for running a CLI plugin Also includes the scaffolding for finding a validating plugin candidates. Signed-off-by: Ian Campbell --- cli-plugins/manager/candidate.go | 25 +++++++ cli-plugins/manager/candidate_test.go | 81 ++++++++++++++++++++ cli-plugins/manager/manager.go | 104 ++++++++++++++++++++++++++ cli-plugins/manager/plugin.go | 98 ++++++++++++++++++++++++ cmd/docker/docker.go | 36 ++++++--- 5 files changed, 334 insertions(+), 10 deletions(-) create mode 100644 cli-plugins/manager/candidate.go create mode 100644 cli-plugins/manager/candidate_test.go create mode 100644 cli-plugins/manager/manager.go create mode 100644 cli-plugins/manager/plugin.go diff --git a/cli-plugins/manager/candidate.go b/cli-plugins/manager/candidate.go new file mode 100644 index 000000000000..1d0fd401ccbd --- /dev/null +++ b/cli-plugins/manager/candidate.go @@ -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() +} diff --git a/cli-plugins/manager/candidate_test.go b/cli-plugins/manager/candidate_test.go new file mode 100644 index 000000000000..491b9d7a27e9 --- /dev/null +++ b/cli-plugins/manager/candidate_test.go @@ -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) + } + } +} diff --git a/cli-plugins/manager/manager.go b/cli-plugins/manager/manager.go new file mode 100644 index 000000000000..b31968d8bd08 --- /dev/null +++ b/cli-plugins/manager/manager.go @@ -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:]) +} diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go new file mode 100644 index 000000000000..16d4658fe540 --- /dev/null +++ b/cli-plugins/manager/plugin.go @@ -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 +} diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 0a3e015c01b8..0bad5f31ecb8 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -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" @@ -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() @@ -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()