mirror of https://github.com/docker/cli.git
Merge pull request #1898 from tiborvass/plugin_experimental
cli-plugins: add concept of experimental plugin, only enabled in experimental mode
This commit is contained in:
commit
ab688a9a79
|
@ -101,5 +101,6 @@ func main() {
|
||||||
SchemaVersion: "0.1.0",
|
SchemaVersion: "0.1.0",
|
||||||
Vendor: "Docker Inc.",
|
Vendor: "Docker Inc.",
|
||||||
Version: "testing",
|
Version: "testing",
|
||||||
|
Experimental: os.Getenv("HELLO_EXPERIMENTAL") != "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ type fakeCandidate struct {
|
||||||
path string
|
path string
|
||||||
exec bool
|
exec bool
|
||||||
meta string
|
meta string
|
||||||
|
allowExperimental bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *fakeCandidate) Path() string {
|
func (c *fakeCandidate) Path() string {
|
||||||
|
@ -38,6 +39,7 @@ func TestValidateCandidate(t *testing.T) {
|
||||||
badPrefixPath = "/usr/local/libexec/cli-plugins/wobble"
|
badPrefixPath = "/usr/local/libexec/cli-plugins/wobble"
|
||||||
badNamePath = "/usr/local/libexec/cli-plugins/docker-123456"
|
badNamePath = "/usr/local/libexec/cli-plugins/docker-123456"
|
||||||
goodPluginPath = "/usr/local/libexec/cli-plugins/" + goodPluginName
|
goodPluginPath = "/usr/local/libexec/cli-plugins/" + goodPluginName
|
||||||
|
metaExperimental = `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing", "Experimental": true}`
|
||||||
)
|
)
|
||||||
|
|
||||||
fakeroot := &cobra.Command{Use: "docker"}
|
fakeroot := &cobra.Command{Use: "docker"}
|
||||||
|
@ -49,6 +51,7 @@ func TestValidateCandidate(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
c *fakeCandidate
|
c *fakeCandidate
|
||||||
|
|
||||||
// Either err or invalid may be non-empty, but not both (both can be empty for a good plugin).
|
// Either err or invalid may be non-empty, but not both (both can be empty for a good plugin).
|
||||||
|
@ -56,21 +59,25 @@ func TestValidateCandidate(t *testing.T) {
|
||||||
invalid string
|
invalid string
|
||||||
}{
|
}{
|
||||||
/* Each failing one of the tests */
|
/* Each failing one of the tests */
|
||||||
{c: &fakeCandidate{path: ""}, err: "plugin candidate path cannot be empty"},
|
{name: "empty path", c: &fakeCandidate{path: ""}, err: "plugin candidate path cannot be empty"},
|
||||||
{c: &fakeCandidate{path: badPrefixPath}, err: fmt.Sprintf("does not have %q prefix", NamePrefix)},
|
{name: "bad prefix", c: &fakeCandidate{path: badPrefixPath}, err: fmt.Sprintf("does not have %q prefix", NamePrefix)},
|
||||||
{c: &fakeCandidate{path: badNamePath}, invalid: "did not match"},
|
{name: "bad path", c: &fakeCandidate{path: badNamePath}, invalid: "did not match"},
|
||||||
{c: &fakeCandidate{path: builtinName}, invalid: `plugin "builtin" duplicates builtin command`},
|
{name: "builtin command", c: &fakeCandidate{path: builtinName}, invalid: `plugin "builtin" duplicates builtin command`},
|
||||||
{c: &fakeCandidate{path: builtinAlias}, invalid: `plugin "alias" duplicates an alias of builtin command "builtin"`},
|
{name: "builtin alias", 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)},
|
{name: "fetch failure", 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"},
|
{name: "metadata not json", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `xyzzy`}, invalid: "invalid character"},
|
||||||
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{}`}, invalid: `plugin SchemaVersion "" is not valid`},
|
{name: "empty schemaversion", 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`},
|
{name: "invalid schemaversion", 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"},
|
{name: "no vendor", 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"},
|
{name: "empty vendor", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": ""}`}, invalid: "plugin metadata does not define a vendor"},
|
||||||
|
{name: "experimental required", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: metaExperimental}, invalid: "requires experimental CLI"},
|
||||||
// This one should work
|
// This one should work
|
||||||
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing"}`}},
|
{name: "valid", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing"}`}},
|
||||||
|
{name: "valid + allowing experimental", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing"}`, allowExperimental: true}},
|
||||||
|
{name: "experimental + allowing experimental", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: metaExperimental, allowExperimental: true}},
|
||||||
} {
|
} {
|
||||||
p, err := newPlugin(tc.c, fakeroot)
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
p, err := newPlugin(tc.c, fakeroot, tc.c.allowExperimental)
|
||||||
if tc.err != "" {
|
if tc.err != "" {
|
||||||
assert.ErrorContains(t, err, tc.err)
|
assert.ErrorContains(t, err, tc.err)
|
||||||
} else if tc.invalid != "" {
|
} else if tc.invalid != "" {
|
||||||
|
@ -83,6 +90,7 @@ func TestValidateCandidate(t *testing.T) {
|
||||||
assert.Equal(t, p.SchemaVersion, "0.1.0")
|
assert.Equal(t, p.SchemaVersion, "0.1.0")
|
||||||
assert.Equal(t, p.Vendor, "e2e-testing")
|
assert.Equal(t, p.Vendor, "e2e-testing")
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package manager
|
package manager
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
@ -27,10 +28,23 @@ func (e errPluginNotFound) Error() string {
|
||||||
return "Error: No such CLI plugin: " + string(e)
|
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() }
|
type notFound interface{ NotFound() }
|
||||||
|
|
||||||
// IsNotFound is true if the given error is due to a plugin not being found.
|
// IsNotFound is true if the given error is due to a plugin not being found.
|
||||||
func IsNotFound(err error) bool {
|
func IsNotFound(err error) bool {
|
||||||
|
if e, ok := err.(*pluginError); ok {
|
||||||
|
err = e.Cause()
|
||||||
|
}
|
||||||
_, ok := err.(notFound)
|
_, ok := err.(notFound)
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
@ -117,13 +131,15 @@ func ListPlugins(dockerCli command.Cli, rootcmd *cobra.Command) ([]Plugin, error
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c := &candidate{paths[0]}
|
c := &candidate{paths[0]}
|
||||||
p, err := newPlugin(c, rootcmd)
|
p, err := newPlugin(c, rootcmd, dockerCli.ClientInfo().HasExperimental)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if !IsNotFound(p.Err) {
|
||||||
p.ShadowedPaths = paths[1:]
|
p.ShadowedPaths = paths[1:]
|
||||||
plugins = append(plugins, p)
|
plugins = append(plugins, p)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return plugins, nil
|
return plugins, nil
|
||||||
}
|
}
|
||||||
|
@ -159,12 +175,19 @@ func PluginRunCommand(dockerCli command.Cli, name string, rootcmd *cobra.Command
|
||||||
}
|
}
|
||||||
|
|
||||||
c := &candidate{path: path}
|
c := &candidate{path: path}
|
||||||
plugin, err := newPlugin(c, rootcmd)
|
plugin, err := newPlugin(c, rootcmd, dockerCli.ClientInfo().HasExperimental)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if plugin.Err != nil {
|
if plugin.Err != nil {
|
||||||
// TODO: why are we not returning plugin.Err?
|
// 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)
|
return nil, errPluginNotFound(name)
|
||||||
}
|
}
|
||||||
cmd := exec.Command(plugin.Path, args...)
|
cmd := exec.Command(plugin.Path, args...)
|
||||||
|
|
|
@ -22,4 +22,7 @@ type Metadata struct {
|
||||||
ShortDescription string `json:",omitempty"`
|
ShortDescription string `json:",omitempty"`
|
||||||
// URL is a pointer to the plugin's homepage.
|
// URL is a pointer to the plugin's homepage.
|
||||||
URL string `json:",omitempty"`
|
URL string `json:",omitempty"`
|
||||||
|
// Experimental specifies whether the plugin is experimental.
|
||||||
|
// Experimental plugins are not displayed on non-experimental CLIs.
|
||||||
|
Experimental bool `json:",omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,9 @@ type Plugin struct {
|
||||||
// is set, and is always a `pluginError`, but the `Plugin` is still
|
// is set, and is always a `pluginError`, but the `Plugin` is still
|
||||||
// returned with no error. An error is only returned due to a
|
// returned with no error. An error is only returned due to a
|
||||||
// non-recoverable error.
|
// 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()
|
path := c.Path()
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return Plugin{}, errors.New("plugin candidate path cannot be empty")
|
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")
|
p.Err = wrapAsPluginError(err, "invalid metadata")
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
if p.Experimental && !allowExperimental {
|
||||||
|
p.Err = &pluginError{errPluginRequireExperimental(p.Name)}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
if p.Metadata.SchemaVersion != "0.1.0" {
|
if p.Metadata.SchemaVersion != "0.1.0" {
|
||||||
p.Err = NewPluginError("plugin SchemaVersion %q is not valid, must be 0.1.0", p.Metadata.SchemaVersion)
|
p.Err = NewPluginError("plugin SchemaVersion %q is not valid, must be 0.1.0", p.Metadata.SchemaVersion)
|
||||||
return p, nil
|
return p, nil
|
||||||
|
|
Loading…
Reference in New Issue