Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic framework for writing and running CLI plugins #1564

Merged
merged 23 commits into from
Jan 31, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
158a766
Fold `dockerPreRun` into `DockerCli.Initialize`
Dec 10, 2018
ccef159
Move `disableFlagsInUseLine` from `main` into our `cli` library
Dec 17, 2018
38645ca
Refactor common bits of `SetupRootCommand`
Dec 18, 2018
c516811
Push setup of opts and default flagset into SetupRootCommand
Dec 18, 2018
20c1983
Move versioning variables to a separate package.
Jan 8, 2019
eab40a5
cli/config: Add a helper to resolve a file within the config dir
Jan 10, 2019
8cf946d
Unit test for WithContentTrustFromEnv
Jan 28, 2019
e962404
Add basic framework for writing a CLI plugin
Dec 10, 2018
f1f31ab
Add support for running a CLI plugin
Dec 11, 2018
5db3367
Add some simple e2e tests for executing CLI plugins
Dec 11, 2018
f912b55
Integrate CLI plugins into `docker help` output.
Dec 11, 2018
c43da09
Add stubs when calling help due to no arguments
Jan 22, 2019
20a2847
Integrate CLI plugins with `docker help «foo»`
Dec 11, 2018
53f0181
Integrate CLI plugins with `docker «plugin» --help`.
Dec 17, 2018
e5e578a
Allow plugins to make use of the cobra `PersistentPreRun` hooks.
Dec 17, 2018
0ab8ec0
Output broken CLI plugins in `help` output.
Dec 19, 2018
1c576e9
Integrate CLI plugins into `docker info`
Dec 19, 2018
609dcb9
Documentation on writing a plugin
Jan 9, 2019
63f3ad1
Refactor code which deals with Windows' `.exe` suffix
Jan 14, 2019
1337895
Check for `.exe` case insensitively
Jan 28, 2019
935d47b
Ignore unknown arguments on the top-level command.
Jan 17, 2019
0a89eb5
Ensure plugins default search path obeys `--config`
Jan 29, 2019
baabf6e
Ensure that plugins are only listed once in help outputs.
Jan 30, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
ijc marked this conversation as resolved.
Show resolved Hide resolved
"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())
thaJeztah marked this conversation as resolved.
Show resolved Hide resolved
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{})))
ijc marked this conversation as resolved.
Show resolved Hide resolved
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