diff --git a/cli-plugins/examples/helloworld/main.go b/cli-plugins/examples/helloworld/main.go index ba23f4954e..597dc42b01 100644 --- a/cli-plugins/examples/helloworld/main.go +++ b/cli-plugins/examples/helloworld/main.go @@ -101,5 +101,6 @@ func main() { SchemaVersion: "0.1.0", Vendor: "Docker Inc.", Version: "testing", + Experimental: os.Getenv("HELLO_EXPERIMENTAL") != "", }) } diff --git a/cli-plugins/manager/candidate_test.go b/cli-plugins/manager/candidate_test.go index b5ed06453f..b802caaa0d 100644 --- a/cli-plugins/manager/candidate_test.go +++ b/cli-plugins/manager/candidate_test.go @@ -12,9 +12,10 @@ import ( ) type fakeCandidate struct { - path string - exec bool - meta string + path string + exec bool + meta string + allowExperimental bool } func (c *fakeCandidate) Path() string { @@ -67,10 +68,13 @@ func TestValidateCandidate(t *testing.T) { {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"}, + {c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing", "Experimental": true}`}, invalid: "requires experimental CLI"}, // This one should work {c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing"}`}}, + {c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing"}`, allowExperimental: true}}, + {c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing", "Experimental": true}`, allowExperimental: true}}, } { - p, err := newPlugin(tc.c, fakeroot) + p, err := newPlugin(tc.c, fakeroot, tc.c.allowExperimental) if tc.err != "" { assert.ErrorContains(t, err, tc.err) } else if tc.invalid != "" { diff --git a/cli-plugins/manager/manager.go b/cli-plugins/manager/manager.go index e7cd2de40a..e06286e9c4 100644 --- a/cli-plugins/manager/manager.go +++ b/cli-plugins/manager/manager.go @@ -1,6 +1,7 @@ package manager import ( + "fmt" "io/ioutil" "os" "os/exec" @@ -27,10 +28,23 @@ func (e errPluginNotFound) Error() string { return "Error: No such CLI plugin: " + string(e) } +type errPluginRequireExperimental string + +// Note: errPluginRequireExperimental implements notFound so that the plugin +// is skipped when listing the plugins. +func (e errPluginRequireExperimental) NotFound() {} + +func (e errPluginRequireExperimental) Error() string { + return fmt.Sprintf("plugin candidate %q: requires experimental CLI", 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 { + if e, ok := err.(*pluginError); ok { + err = e.Cause() + } _, ok := err.(notFound) return ok } @@ -117,12 +131,14 @@ func ListPlugins(dockerCli command.Cli, rootcmd *cobra.Command) ([]Plugin, error continue } c := &candidate{paths[0]} - p, err := newPlugin(c, rootcmd) + p, err := newPlugin(c, rootcmd, dockerCli.ClientInfo().HasExperimental) if err != nil { return nil, err } - p.ShadowedPaths = paths[1:] - plugins = append(plugins, p) + if !IsNotFound(p.Err) { + p.ShadowedPaths = paths[1:] + plugins = append(plugins, p) + } } return plugins, nil @@ -159,12 +175,19 @@ func PluginRunCommand(dockerCli command.Cli, name string, rootcmd *cobra.Command } c := &candidate{path: path} - plugin, err := newPlugin(c, rootcmd) + plugin, err := newPlugin(c, rootcmd, dockerCli.ClientInfo().HasExperimental) if err != nil { return nil, err } if plugin.Err != nil { // TODO: why are we not returning plugin.Err? + + err := plugin.Err.(*pluginError).Cause() + // if an experimental plugin was invoked directly while experimental mode is off + // provide a more useful error message than "not found". + if err, ok := err.(errPluginRequireExperimental); ok { + return nil, err + } return nil, errPluginNotFound(name) } cmd := exec.Command(plugin.Path, args...) diff --git a/cli-plugins/manager/metadata.go b/cli-plugins/manager/metadata.go index d3de778141..19379034ea 100644 --- a/cli-plugins/manager/metadata.go +++ b/cli-plugins/manager/metadata.go @@ -22,4 +22,7 @@ type Metadata struct { ShortDescription string `json:",omitempty"` // URL is a pointer to the plugin's homepage. URL string `json:",omitempty"` + // Experimental specifies whether the plugin is experimental. + // Experimental plugins are not displayed on non-experimental CLIs. + Experimental bool `json:",omitempty"` } diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go index a8ac4fa3a3..fc3ad693cf 100644 --- a/cli-plugins/manager/plugin.go +++ b/cli-plugins/manager/plugin.go @@ -33,7 +33,9 @@ type Plugin struct { // is set, and is always a `pluginError`, 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) { +// +// nolint: gocyclo +func newPlugin(c Candidate, rootcmd *cobra.Command, allowExperimental bool) (Plugin, error) { path := c.Path() if path == "" { return Plugin{}, errors.New("plugin candidate path cannot be empty") @@ -94,7 +96,10 @@ func newPlugin(c Candidate, rootcmd *cobra.Command) (Plugin, error) { p.Err = wrapAsPluginError(err, "invalid metadata") return p, nil } - + if p.Experimental && !allowExperimental { + p.Err = &pluginError{errPluginRequireExperimental(p.Name)} + return p, nil + } if p.Metadata.SchemaVersion != "0.1.0" { p.Err = NewPluginError("plugin SchemaVersion %q is not valid, must be 0.1.0", p.Metadata.SchemaVersion) return p, nil