Skip to content

Commit 2e5639d

Browse files
Merge pull request #1564 from ijc/plugins
Basic framework for writing and running CLI plugins
2 parents 5486cdd + baabf6e commit 2e5639d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1906
-78
lines changed

Makefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ binary: ## build executable for Linux
3434
@echo "WARNING: binary creates a Linux executable. Use cross for macOS or Windows."
3535
./scripts/build/binary
3636

37+
.PHONY: plugins
38+
plugins: ## build example CLI plugins
39+
./scripts/build/plugins
40+
3741
.PHONY: cross
3842
cross: ## build executable for macOS and Windows
3943
./scripts/build/cross
@@ -42,10 +46,18 @@ cross: ## build executable for macOS and Windows
4246
binary-windows: ## build executable for Windows
4347
./scripts/build/windows
4448

49+
.PHONY: plugins-windows
50+
plugins-windows: ## build example CLI plugins for Windows
51+
./scripts/build/plugins-windows
52+
4553
.PHONY: binary-osx
4654
binary-osx: ## build executable for macOS
4755
./scripts/build/osx
4856

57+
.PHONY: plugins-osx
58+
plugins-osx: ## build example CLI plugins for macOS
59+
./scripts/build/plugins-osx
60+
4961
.PHONY: dynbinary
5062
dynbinary: ## build dynamically linked binary
5163
./scripts/build/dynbinary
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/docker/cli/cli-plugins/manager"
8+
"github.com/docker/cli/cli-plugins/plugin"
9+
"github.com/docker/cli/cli/command"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
func main() {
14+
plugin.Run(func(dockerCli command.Cli) *cobra.Command {
15+
goodbye := &cobra.Command{
16+
Use: "goodbye",
17+
Short: "Say Goodbye instead of Hello",
18+
Run: func(cmd *cobra.Command, _ []string) {
19+
fmt.Fprintln(dockerCli.Out(), "Goodbye World!")
20+
},
21+
}
22+
apiversion := &cobra.Command{
23+
Use: "apiversion",
24+
Short: "Print the API version of the server",
25+
RunE: func(_ *cobra.Command, _ []string) error {
26+
cli := dockerCli.Client()
27+
ping, err := cli.Ping(context.Background())
28+
if err != nil {
29+
return err
30+
}
31+
fmt.Println(ping.APIVersion)
32+
return nil
33+
},
34+
}
35+
36+
var who string
37+
cmd := &cobra.Command{
38+
Use: "helloworld",
39+
Short: "A basic Hello World plugin for tests",
40+
// This is redundant but included to exercise
41+
// the path where a plugin overrides this
42+
// hook.
43+
PersistentPreRunE: plugin.PersistentPreRunE,
44+
Run: func(cmd *cobra.Command, args []string) {
45+
fmt.Fprintf(dockerCli.Out(), "Hello %s!\n", who)
46+
},
47+
}
48+
flags := cmd.Flags()
49+
flags.StringVar(&who, "who", "World", "Who are we addressing?")
50+
51+
cmd.AddCommand(goodbye, apiversion)
52+
return cmd
53+
},
54+
manager.Metadata{
55+
SchemaVersion: "0.1.0",
56+
Vendor: "Docker Inc.",
57+
Version: "testing",
58+
})
59+
}

cli-plugins/manager/candidate.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package manager
2+
3+
import (
4+
"os/exec"
5+
)
6+
7+
// Candidate represents a possible plugin candidate, for mocking purposes
8+
type Candidate interface {
9+
Path() string
10+
Metadata() ([]byte, error)
11+
}
12+
13+
type candidate struct {
14+
path string
15+
}
16+
17+
func (c *candidate) Path() string {
18+
return c.path
19+
}
20+
21+
func (c *candidate) Metadata() ([]byte, error) {
22+
return exec.Command(c.path, MetadataSubcommandName).Output()
23+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package manager
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"strings"
7+
"testing"
8+
9+
"github.com/spf13/cobra"
10+
"gotest.tools/assert"
11+
"gotest.tools/assert/cmp"
12+
)
13+
14+
type fakeCandidate struct {
15+
path string
16+
exec bool
17+
meta string
18+
}
19+
20+
func (c *fakeCandidate) Path() string {
21+
return c.path
22+
}
23+
24+
func (c *fakeCandidate) Metadata() ([]byte, error) {
25+
if !c.exec {
26+
return nil, fmt.Errorf("faked a failure to exec %q", c.path)
27+
}
28+
return []byte(c.meta), nil
29+
}
30+
31+
func TestValidateCandidate(t *testing.T) {
32+
var (
33+
goodPluginName = NamePrefix + "goodplugin"
34+
35+
builtinName = NamePrefix + "builtin"
36+
builtinAlias = NamePrefix + "alias"
37+
38+
badPrefixPath = "/usr/local/libexec/cli-plugins/wobble"
39+
badNamePath = "/usr/local/libexec/cli-plugins/docker-123456"
40+
goodPluginPath = "/usr/local/libexec/cli-plugins/" + goodPluginName
41+
)
42+
43+
fakeroot := &cobra.Command{Use: "docker"}
44+
fakeroot.AddCommand(&cobra.Command{
45+
Use: strings.TrimPrefix(builtinName, NamePrefix),
46+
Aliases: []string{
47+
strings.TrimPrefix(builtinAlias, NamePrefix),
48+
},
49+
})
50+
51+
for _, tc := range []struct {
52+
c *fakeCandidate
53+
54+
// Either err or invalid may be non-empty, but not both (both can be empty for a good plugin).
55+
err string
56+
invalid string
57+
}{
58+
/* Each failing one of the tests */
59+
{c: &fakeCandidate{path: ""}, err: "plugin candidate path cannot be empty"},
60+
{c: &fakeCandidate{path: badPrefixPath}, err: fmt.Sprintf("does not have %q prefix", NamePrefix)},
61+
{c: &fakeCandidate{path: badNamePath}, invalid: "did not match"},
62+
{c: &fakeCandidate{path: builtinName}, invalid: `plugin "builtin" duplicates builtin command`},
63+
{c: &fakeCandidate{path: builtinAlias}, invalid: `plugin "alias" duplicates an alias of builtin command "builtin"`},
64+
{c: &fakeCandidate{path: goodPluginPath, exec: false}, invalid: fmt.Sprintf("failed to fetch metadata: faked a failure to exec %q", goodPluginPath)},
65+
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `xyzzy`}, invalid: "invalid character"},
66+
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{}`}, invalid: `plugin SchemaVersion "" is not valid`},
67+
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "xyzzy"}`}, invalid: `plugin SchemaVersion "xyzzy" is not valid`},
68+
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0"}`}, invalid: "plugin metadata does not define a vendor"},
69+
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": ""}`}, invalid: "plugin metadata does not define a vendor"},
70+
// This one should work
71+
{c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing"}`}},
72+
} {
73+
p, err := newPlugin(tc.c, fakeroot)
74+
if tc.err != "" {
75+
assert.ErrorContains(t, err, tc.err)
76+
} else if tc.invalid != "" {
77+
assert.NilError(t, err)
78+
assert.Assert(t, cmp.ErrorType(p.Err, reflect.TypeOf(&pluginError{})))
79+
assert.ErrorContains(t, p.Err, tc.invalid)
80+
} else {
81+
assert.NilError(t, err)
82+
assert.Equal(t, NamePrefix+p.Name, goodPluginName)
83+
assert.Equal(t, p.SchemaVersion, "0.1.0")
84+
assert.Equal(t, p.Vendor, "e2e-testing")
85+
}
86+
}
87+
}
88+
89+
func TestCandidatePath(t *testing.T) {
90+
exp := "/some/path"
91+
cand := &candidate{path: exp}
92+
assert.Equal(t, exp, cand.Path())
93+
}

cli-plugins/manager/cobra.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package manager
2+
3+
import (
4+
"github.com/docker/cli/cli/command"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
const (
9+
// CommandAnnotationPlugin is added to every stub command added by
10+
// AddPluginCommandStubs with the value "true" and so can be
11+
// used to distinguish plugin stubs from regular commands.
12+
CommandAnnotationPlugin = "com.docker.cli.plugin"
13+
14+
// CommandAnnotationPluginVendor is added to every stub command
15+
// added by AddPluginCommandStubs and contains the vendor of
16+
// that plugin.
17+
CommandAnnotationPluginVendor = "com.docker.cli.plugin.vendor"
18+
19+
// CommandAnnotationPluginInvalid is added to any stub command
20+
// added by AddPluginCommandStubs for an invalid command (that
21+
// is, one which failed it's candidate test) and contains the
22+
// reason for the failure.
23+
CommandAnnotationPluginInvalid = "com.docker.cli.plugin-invalid"
24+
)
25+
26+
// AddPluginCommandStubs adds a stub cobra.Commands for each valid and invalid
27+
// plugin. The command stubs will have several annotations added, see
28+
// `CommandAnnotationPlugin*`.
29+
func AddPluginCommandStubs(dockerCli command.Cli, cmd *cobra.Command) error {
30+
plugins, err := ListPlugins(dockerCli, cmd)
31+
if err != nil {
32+
return err
33+
}
34+
for _, p := range plugins {
35+
vendor := p.Vendor
36+
if vendor == "" {
37+
vendor = "unknown"
38+
}
39+
annotations := map[string]string{
40+
CommandAnnotationPlugin: "true",
41+
CommandAnnotationPluginVendor: vendor,
42+
}
43+
if p.Err != nil {
44+
annotations[CommandAnnotationPluginInvalid] = p.Err.Error()
45+
}
46+
cmd.AddCommand(&cobra.Command{
47+
Use: p.Name,
48+
Short: p.ShortDescription,
49+
Run: func(_ *cobra.Command, _ []string) {},
50+
Annotations: annotations,
51+
})
52+
}
53+
return nil
54+
}

cli-plugins/manager/error.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package manager
2+
3+
import (
4+
"github.com/pkg/errors"
5+
)
6+
7+
// pluginError is set as Plugin.Err by NewPlugin if the plugin
8+
// candidate fails one of the candidate tests. This exists primarily
9+
// to implement encoding.TextMarshaller such that rendering a plugin as JSON
10+
// (e.g. for `docker info -f '{{json .CLIPlugins}}'`) renders the Err
11+
// field as a useful string and not just `{}`. See
12+
// https://github.com/golang/go/issues/10748 for some discussion
13+
// around why the builtin error type doesn't implement this.
14+
type pluginError struct {
15+
cause error
16+
}
17+
18+
// Error satisfies the core error interface for pluginError.
19+
func (e *pluginError) Error() string {
20+
return e.cause.Error()
21+
}
22+
23+
// Cause satisfies the errors.causer interface for pluginError.
24+
func (e *pluginError) Cause() error {
25+
return e.cause
26+
}
27+
28+
// MarshalText marshalls the pluginError into a textual form.
29+
func (e *pluginError) MarshalText() (text []byte, err error) {
30+
return []byte(e.cause.Error()), nil
31+
}
32+
33+
// wrapAsPluginError wraps an error in a pluginError with an
34+
// additional message, analogous to errors.Wrapf.
35+
func wrapAsPluginError(err error, msg string) error {
36+
return &pluginError{cause: errors.Wrap(err, msg)}
37+
}
38+
39+
// NewPluginError creates a new pluginError, analogous to
40+
// errors.Errorf.
41+
func NewPluginError(msg string, args ...interface{}) error {
42+
return &pluginError{cause: errors.Errorf(msg, args...)}
43+
}

cli-plugins/manager/error_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package manager
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/pkg/errors"
8+
"gopkg.in/yaml.v2"
9+
"gotest.tools/assert"
10+
)
11+
12+
func TestPluginError(t *testing.T) {
13+
err := NewPluginError("new error")
14+
assert.Error(t, err, "new error")
15+
16+
inner := fmt.Errorf("testing")
17+
err = wrapAsPluginError(inner, "wrapping")
18+
assert.Error(t, err, "wrapping: testing")
19+
assert.Equal(t, inner, errors.Cause(err))
20+
21+
actual, err := yaml.Marshal(err)
22+
assert.NilError(t, err)
23+
assert.Equal(t, "'wrapping: testing'\n", string(actual))
24+
}

0 commit comments

Comments
 (0)