Skip to content

Commit

Permalink
Merge pull request #1564 from ijc/plugins
Browse files Browse the repository at this point in the history
Basic framework for writing and running CLI plugins
  • Loading branch information
silvin-lubecki committed Jan 31, 2019
2 parents 5486cdd + baabf6e commit 2e5639d
Show file tree
Hide file tree
Showing 54 changed files with 1,906 additions and 78 deletions.
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ binary: ## build executable for Linux
@echo "WARNING: binary creates a Linux executable. Use cross for macOS or Windows."
./scripts/build/binary

.PHONY: plugins
plugins: ## build example CLI plugins
./scripts/build/plugins

.PHONY: cross
cross: ## build executable for macOS and Windows
./scripts/build/cross
Expand All @@ -42,10 +46,18 @@ cross: ## build executable for macOS and Windows
binary-windows: ## build executable for Windows
./scripts/build/windows

.PHONY: plugins-windows
plugins-windows: ## build example CLI plugins for Windows
./scripts/build/plugins-windows

.PHONY: binary-osx
binary-osx: ## build executable for macOS
./scripts/build/osx

.PHONY: plugins-osx
plugins-osx: ## build example CLI plugins for macOS
./scripts/build/plugins-osx

.PHONY: dynbinary
dynbinary: ## build dynamically linked binary
./scripts/build/dynbinary
Expand Down
59 changes: 59 additions & 0 deletions cli-plugins/examples/helloworld/main.go
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",
})
}
23 changes: 23 additions & 0 deletions cli-plugins/manager/candidate.go
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()
}
93 changes: 93 additions & 0 deletions cli-plugins/manager/candidate_test.go
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())
}
54 changes: 54 additions & 0 deletions cli-plugins/manager/cobra.go
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
}
43 changes: 43 additions & 0 deletions cli-plugins/manager/error.go
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...)}
}
24 changes: 24 additions & 0 deletions cli-plugins/manager/error_test.go
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))
}
Loading

0 comments on commit 2e5639d

Please sign in to comment.