diff --git a/cli-plugins/examples/helloworld/main.go b/cli-plugins/examples/helloworld/main.go index a319ee623f6c..e79e32ce5436 100644 --- a/cli-plugins/examples/helloworld/main.go +++ b/cli-plugins/examples/helloworld/main.go @@ -33,6 +33,6 @@ func main() { manager.Metadata{ SchemaVersion: "0.1.0", Vendor: "Docker Inc.", - Version: "0.1.0", + Version: "testing", }) } diff --git a/cli-plugins/manager/candidate.go b/cli-plugins/manager/candidate.go new file mode 100644 index 000000000000..2000e5b142f6 --- /dev/null +++ b/cli-plugins/manager/candidate.go @@ -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() +} diff --git a/cli-plugins/manager/candidate_test.go b/cli-plugins/manager/candidate_test.go new file mode 100644 index 000000000000..11384226a5d4 --- /dev/null +++ b/cli-plugins/manager/candidate_test.go @@ -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()) +} diff --git a/cli-plugins/manager/manager.go b/cli-plugins/manager/manager.go new file mode 100644 index 000000000000..882d2ee2ea98 --- /dev/null +++ b/cli-plugins/manager/manager.go @@ -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) +} diff --git a/cli-plugins/manager/manager_test.go b/cli-plugins/manager/manager_test.go new file mode 100644 index 000000000000..3a62b911fab1 --- /dev/null +++ b/cli-plugins/manager/manager_test.go @@ -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)) +} diff --git a/cli-plugins/manager/manager_unix.go b/cli-plugins/manager/manager_unix.go new file mode 100644 index 000000000000..f586acbd8da2 --- /dev/null +++ b/cli-plugins/manager/manager_unix.go @@ -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", +} diff --git a/cli-plugins/manager/manager_windows.go b/cli-plugins/manager/manager_windows.go new file mode 100644 index 000000000000..b62868580360 --- /dev/null +++ b/cli-plugins/manager/manager_windows.go @@ -0,0 +1,10 @@ +package manager + +import ( + "os" + "path/filepath" +) + +var defaultSystemPluginDirs = []string{ + filepath.Join(os.Getenv("ProgramData"), "Docker", "cli-plugins"), +} diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go new file mode 100644 index 000000000000..8a07c5853726 --- /dev/null +++ b/cli-plugins/manager/plugin.go @@ -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 +} diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index d815570362a8..99ffd47a5fcb 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -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 diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index f8890304ecfb..062cbfdc9b73 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" cliflags "github.com/docker/cli/cli/flags" @@ -30,9 +31,20 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { SilenceUsage: true, SilenceErrors: true, TraverseChildren: true, - Args: noArgs, 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(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 { // 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) } -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() { dockerCli, err := command.NewDockerCli() if err != nil {