-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1564 from ijc/plugins
Basic framework for writing and running CLI plugins
- Loading branch information
Showing
54 changed files
with
1,906 additions
and
78 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
package main | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
|
||
"github.com/docker/cli/cli-plugins/manager" | ||
"github.com/docker/cli/cli-plugins/plugin" | ||
"github.com/docker/cli/cli/command" | ||
"github.com/spf13/cobra" | ||
) | ||
|
||
func main() { | ||
plugin.Run(func(dockerCli command.Cli) *cobra.Command { | ||
goodbye := &cobra.Command{ | ||
Use: "goodbye", | ||
Short: "Say Goodbye instead of Hello", | ||
Run: func(cmd *cobra.Command, _ []string) { | ||
fmt.Fprintln(dockerCli.Out(), "Goodbye World!") | ||
}, | ||
} | ||
apiversion := &cobra.Command{ | ||
Use: "apiversion", | ||
Short: "Print the API version of the server", | ||
RunE: func(_ *cobra.Command, _ []string) error { | ||
cli := dockerCli.Client() | ||
ping, err := cli.Ping(context.Background()) | ||
if err != nil { | ||
return err | ||
} | ||
fmt.Println(ping.APIVersion) | ||
return nil | ||
}, | ||
} | ||
|
||
var who string | ||
cmd := &cobra.Command{ | ||
Use: "helloworld", | ||
Short: "A basic Hello World plugin for tests", | ||
// This is redundant but included to exercise | ||
// the path where a plugin overrides this | ||
// hook. | ||
PersistentPreRunE: plugin.PersistentPreRunE, | ||
Run: func(cmd *cobra.Command, args []string) { | ||
fmt.Fprintf(dockerCli.Out(), "Hello %s!\n", who) | ||
}, | ||
} | ||
flags := cmd.Flags() | ||
flags.StringVar(&who, "who", "World", "Who are we addressing?") | ||
|
||
cmd.AddCommand(goodbye, apiversion) | ||
return cmd | ||
}, | ||
manager.Metadata{ | ||
SchemaVersion: "0.1.0", | ||
Vendor: "Docker Inc.", | ||
Version: "testing", | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
package manager | ||
|
||
import ( | ||
"fmt" | ||
"reflect" | ||
"strings" | ||
"testing" | ||
|
||
"github.com/spf13/cobra" | ||
"gotest.tools/assert" | ||
"gotest.tools/assert/cmp" | ||
) | ||
|
||
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.Assert(t, cmp.ErrorType(p.Err, reflect.TypeOf(&pluginError{}))) | ||
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()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
package manager | ||
|
||
import ( | ||
"github.com/docker/cli/cli/command" | ||
"github.com/spf13/cobra" | ||
) | ||
|
||
const ( | ||
// CommandAnnotationPlugin is added to every stub command added by | ||
// AddPluginCommandStubs with the value "true" and so can be | ||
// used to distinguish plugin stubs from regular commands. | ||
CommandAnnotationPlugin = "com.docker.cli.plugin" | ||
|
||
// CommandAnnotationPluginVendor is added to every stub command | ||
// added by AddPluginCommandStubs and contains the vendor of | ||
// that plugin. | ||
CommandAnnotationPluginVendor = "com.docker.cli.plugin.vendor" | ||
|
||
// CommandAnnotationPluginInvalid is added to any stub command | ||
// added by AddPluginCommandStubs for an invalid command (that | ||
// is, one which failed it's candidate test) and contains the | ||
// reason for the failure. | ||
CommandAnnotationPluginInvalid = "com.docker.cli.plugin-invalid" | ||
) | ||
|
||
// AddPluginCommandStubs adds a stub cobra.Commands for each valid and invalid | ||
// plugin. The command stubs will have several annotations added, see | ||
// `CommandAnnotationPlugin*`. | ||
func AddPluginCommandStubs(dockerCli command.Cli, cmd *cobra.Command) error { | ||
plugins, err := ListPlugins(dockerCli, cmd) | ||
if err != nil { | ||
return err | ||
} | ||
for _, p := range plugins { | ||
vendor := p.Vendor | ||
if vendor == "" { | ||
vendor = "unknown" | ||
} | ||
annotations := map[string]string{ | ||
CommandAnnotationPlugin: "true", | ||
CommandAnnotationPluginVendor: vendor, | ||
} | ||
if p.Err != nil { | ||
annotations[CommandAnnotationPluginInvalid] = p.Err.Error() | ||
} | ||
cmd.AddCommand(&cobra.Command{ | ||
Use: p.Name, | ||
Short: p.ShortDescription, | ||
Run: func(_ *cobra.Command, _ []string) {}, | ||
Annotations: annotations, | ||
}) | ||
} | ||
return nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
package manager | ||
|
||
import ( | ||
"github.com/pkg/errors" | ||
) | ||
|
||
// pluginError is set as Plugin.Err by NewPlugin if the plugin | ||
// candidate fails one of the candidate tests. This exists primarily | ||
// to implement encoding.TextMarshaller such that rendering a plugin as JSON | ||
// (e.g. for `docker info -f '{{json .CLIPlugins}}'`) renders the Err | ||
// field as a useful string and not just `{}`. See | ||
// https://github.com/golang/go/issues/10748 for some discussion | ||
// around why the builtin error type doesn't implement this. | ||
type pluginError struct { | ||
cause error | ||
} | ||
|
||
// Error satisfies the core error interface for pluginError. | ||
func (e *pluginError) Error() string { | ||
return e.cause.Error() | ||
} | ||
|
||
// Cause satisfies the errors.causer interface for pluginError. | ||
func (e *pluginError) Cause() error { | ||
return e.cause | ||
} | ||
|
||
// MarshalText marshalls the pluginError into a textual form. | ||
func (e *pluginError) MarshalText() (text []byte, err error) { | ||
return []byte(e.cause.Error()), nil | ||
} | ||
|
||
// wrapAsPluginError wraps an error in a pluginError with an | ||
// additional message, analogous to errors.Wrapf. | ||
func wrapAsPluginError(err error, msg string) error { | ||
return &pluginError{cause: errors.Wrap(err, msg)} | ||
} | ||
|
||
// NewPluginError creates a new pluginError, analogous to | ||
// errors.Errorf. | ||
func NewPluginError(msg string, args ...interface{}) error { | ||
return &pluginError{cause: errors.Errorf(msg, args...)} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
package manager | ||
|
||
import ( | ||
"fmt" | ||
"testing" | ||
|
||
"github.com/pkg/errors" | ||
"gopkg.in/yaml.v2" | ||
"gotest.tools/assert" | ||
) | ||
|
||
func TestPluginError(t *testing.T) { | ||
err := NewPluginError("new error") | ||
assert.Error(t, err, "new error") | ||
|
||
inner := fmt.Errorf("testing") | ||
err = wrapAsPluginError(inner, "wrapping") | ||
assert.Error(t, err, "wrapping: testing") | ||
assert.Equal(t, inner, errors.Cause(err)) | ||
|
||
actual, err := yaml.Marshal(err) | ||
assert.NilError(t, err) | ||
assert.Equal(t, "'wrapping: testing'\n", string(actual)) | ||
} |
Oops, something went wrong.