diff --git a/Makefile b/Makefile index 6f0ff4b00890..aaf5317fe60e 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 diff --git a/cli-plugins/examples/helloworld/main.go b/cli-plugins/examples/helloworld/main.go new file mode 100644 index 000000000000..cbe015937f96 --- /dev/null +++ b/cli-plugins/examples/helloworld/main.go @@ -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", + }) +} diff --git a/cli-plugins/manager/candidate.go b/cli-plugins/manager/candidate.go new file mode 100644 index 000000000000..2000e5b142f6 --- /dev/null +++ b/cli-plugins/manager/candidate.go @@ -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() +} diff --git a/cli-plugins/manager/candidate_test.go b/cli-plugins/manager/candidate_test.go new file mode 100644 index 000000000000..b5ed06453f76 --- /dev/null +++ b/cli-plugins/manager/candidate_test.go @@ -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()) +} diff --git a/cli-plugins/manager/cobra.go b/cli-plugins/manager/cobra.go new file mode 100644 index 000000000000..692de7fdb108 --- /dev/null +++ b/cli-plugins/manager/cobra.go @@ -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 +} diff --git a/cli-plugins/manager/error.go b/cli-plugins/manager/error.go new file mode 100644 index 000000000000..1ad28678695a --- /dev/null +++ b/cli-plugins/manager/error.go @@ -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...)} +} diff --git a/cli-plugins/manager/error_test.go b/cli-plugins/manager/error_test.go new file mode 100644 index 000000000000..04614e24ff47 --- /dev/null +++ b/cli-plugins/manager/error_test.go @@ -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)) +} diff --git a/cli-plugins/manager/manager.go b/cli-plugins/manager/manager.go new file mode 100644 index 000000000000..6fdc582c97d0 --- /dev/null +++ b/cli-plugins/manager/manager.go @@ -0,0 +1,161 @@ +package manager + +import ( + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/config" + "github.com/spf13/cobra" +) + +// errPluginNotFound is the error returned when a plugin could not be found. +type errPluginNotFound string + +func (e errPluginNotFound) NotFound() {} + +func (e errPluginNotFound) Error() string { + return "Error: No such CLI plugin: " + string(e) +} + +type notFound interface{ NotFound() } + +// IsNotFound is true if the given error is due to a plugin not being found. +func IsNotFound(err error) bool { + _, ok := err.(notFound) + return ok +} + +func getPluginDirs(dockerCli command.Cli) []string { + var pluginDirs []string + + if cfg := dockerCli.ConfigFile(); cfg != nil { + pluginDirs = append(pluginDirs, cfg.CLIPluginsExtraDirs...) + } + pluginDirs = append(pluginDirs, config.Path("cli-plugins")) + pluginDirs = append(pluginDirs, defaultSystemPluginDirs...) + return pluginDirs +} + +func addPluginCandidatesFromDir(res map[string][]string, d string) error { + dentries, err := ioutil.ReadDir(d) + if err != nil { + return err + } + for _, dentry := range dentries { + switch dentry.Mode() & os.ModeType { + case 0, os.ModeSymlink: + // Regular file or symlink, keep going + default: + // Something else, ignore. + continue + } + name := dentry.Name() + if !strings.HasPrefix(name, NamePrefix) { + continue + } + name = strings.TrimPrefix(name, NamePrefix) + var err error + if name, err = trimExeSuffix(name); err != nil { + continue + } + res[name] = append(res[name], filepath.Join(d, dentry.Name())) + } + return nil +} + +// listPluginCandidates returns a map from plugin name to the list of (unvalidated) Candidates. The list is in descending order of priority. +func listPluginCandidates(dirs []string) (map[string][]string, error) { + result := make(map[string][]string) + for _, d := range dirs { + // Silently ignore any directories which we cannot + // Stat (e.g. due to permissions or anything else) or + // which is not a directory. + if fi, err := os.Stat(d); err != nil || !fi.IsDir() { + continue + } + if err := addPluginCandidatesFromDir(result, d); err != nil { + // Silently ignore paths which don't exist. + if os.IsNotExist(err) { + continue + } + return nil, err // Or return partial result? + } + } + return result, nil +} + +// ListPlugins produces a list of the plugins available on the system +func ListPlugins(dockerCli command.Cli, rootcmd *cobra.Command) ([]Plugin, error) { + candidates, err := listPluginCandidates(getPluginDirs(dockerCli)) + if err != nil { + return nil, err + } + + var plugins []Plugin + for _, paths := range candidates { + if len(paths) == 0 { + continue + } + c := &candidate{paths[0]} + p, err := newPlugin(c, rootcmd) + if err != nil { + return nil, err + } + p.ShadowedPaths = paths[1:] + plugins = append(plugins, p) + } + + return plugins, nil +} + +// PluginRunCommand returns an "os/exec".Cmd which when .Run() will execute the named plugin. +// The rootcmd argument is referenced to determine the set of builtin commands in order to detect conficts. +// The error returned satisfies the IsNotFound() predicate if no plugin was found or if the first candidate plugin was invalid somehow. +func PluginRunCommand(dockerCli command.Cli, name string, rootcmd *cobra.Command) (*exec.Cmd, error) { + // This uses the full original args, not the args which may + // have been provided by cobra to our caller. This is because + // they lack e.g. global options which we must propagate here. + args := os.Args[1:] + if !pluginNameRe.MatchString(name) { + // We treat this as "not found" so that callers will + // fallback to their "invalid" command path. + return nil, errPluginNotFound(name) + } + exename := addExeSuffix(NamePrefix + name) + for _, d := range getPluginDirs(dockerCli) { + path := filepath.Join(d, exename) + + // We stat here rather than letting the exec tell us + // ENOENT because the latter does not distinguish a + // file not existing from its dynamic loader or one of + // its libraries not existing. + if _, err := os.Stat(path); os.IsNotExist(err) { + continue + } + + c := &candidate{path: path} + plugin, err := newPlugin(c, rootcmd) + if err != nil { + return nil, err + } + if plugin.Err != nil { + return nil, errPluginNotFound(name) + } + cmd := exec.Command(plugin.Path, args...) + // Using dockerCli.{In,Out,Err}() here results in a hang until something is input. + // See: - https://github.com/golang/go/issues/10338 + // - https://github.com/golang/go/commit/d000e8742a173aa0659584aa01b7ba2834ba28ab + // os.Stdin is a *os.File which avoids this behaviour. We don't need the functionality + // of the wrappers here anyway. + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd, nil + } + return nil, errPluginNotFound(name) +} diff --git a/cli-plugins/manager/manager_test.go b/cli-plugins/manager/manager_test.go new file mode 100644 index 000000000000..14176e57ab1c --- /dev/null +++ b/cli-plugins/manager/manager_test.go @@ -0,0 +1,108 @@ +package manager + +import ( + "strings" + "testing" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/configfile" + "github.com/docker/cli/internal/test" + "gotest.tools/assert" + "gotest.tools/fs" +) + +func TestListPluginCandidates(t *testing.T) { + // Populate a selection of directories with various shadowed and bogus/obscure plugin candidates. + // For the purposes of this test no contents is required and permissions are irrelevant. + dir := fs.NewDir(t, t.Name(), + fs.WithDir( + "plugins1", + fs.WithFile("docker-plugin1", ""), // This appears in each directory + fs.WithFile("not-a-plugin", ""), // Should be ignored + fs.WithFile("docker-symlinked1", ""), // This and ... + fs.WithSymlink("docker-symlinked2", "docker-symlinked1"), // ... this should both appear + fs.WithDir("ignored1"), // A directory should be ignored + ), + fs.WithDir( + "plugins2", + fs.WithFile("docker-plugin1", ""), + fs.WithFile("also-not-a-plugin", ""), + fs.WithFile("docker-hardlink1", ""), // This and ... + fs.WithHardlink("docker-hardlink2", "docker-hardlink1"), // ... this should both appear + fs.WithDir("ignored2"), + ), + fs.WithDir( + "plugins3-target", // Will be referenced as a symlink from below + fs.WithFile("docker-plugin1", ""), + fs.WithDir("ignored3"), + fs.WithSymlink("docker-brokensymlink", "broken"), // A broken symlink is still a candidate (but would fail tests later) + fs.WithFile("non-plugin-symlinked", ""), // This shouldn't appear, but ... + fs.WithSymlink("docker-symlinked", "non-plugin-symlinked"), // ... this link to it should. + ), + fs.WithSymlink("plugins3", "plugins3-target"), + fs.WithFile("/plugins4", ""), + fs.WithSymlink("plugins5", "plugins5-nonexistent-target"), + ) + defer dir.Remove() + + var dirs []string + for _, d := range []string{"plugins1", "nonexistent", "plugins2", "plugins3", "plugins4", "plugins5"} { + dirs = append(dirs, dir.Join(d)) + } + + candidates, err := listPluginCandidates(dirs) + assert.NilError(t, err) + exp := map[string][]string{ + "plugin1": { + dir.Join("plugins1", "docker-plugin1"), + dir.Join("plugins2", "docker-plugin1"), + dir.Join("plugins3", "docker-plugin1"), + }, + "symlinked1": { + dir.Join("plugins1", "docker-symlinked1"), + }, + "symlinked2": { + dir.Join("plugins1", "docker-symlinked2"), + }, + "hardlink1": { + dir.Join("plugins2", "docker-hardlink1"), + }, + "hardlink2": { + dir.Join("plugins2", "docker-hardlink2"), + }, + "brokensymlink": { + dir.Join("plugins3", "docker-brokensymlink"), + }, + "symlinked": { + dir.Join("plugins3", "docker-symlinked"), + }, + } + + assert.DeepEqual(t, candidates, exp) +} + +func TestErrPluginNotFound(t *testing.T) { + var err error = errPluginNotFound("test") + err.(errPluginNotFound).NotFound() + assert.Error(t, err, "Error: No such CLI plugin: test") + assert.Assert(t, IsNotFound(err)) + assert.Assert(t, !IsNotFound(nil)) +} + +func TestGetPluginDirs(t *testing.T) { + cli := test.NewFakeCli(nil) + + expected := []string{config.Path("cli-plugins")} + expected = append(expected, defaultSystemPluginDirs...) + + assert.Equal(t, strings.Join(expected, ":"), strings.Join(getPluginDirs(cli), ":")) + + extras := []string{ + "foo", "bar", "baz", + } + expected = append(extras, expected...) + cli.SetConfigFile(&configfile.ConfigFile{ + CLIPluginsExtraDirs: extras, + }) + assert.DeepEqual(t, expected, getPluginDirs(cli)) +} diff --git a/cli-plugins/manager/manager_unix.go b/cli-plugins/manager/manager_unix.go new file mode 100644 index 000000000000..f586acbd8da2 --- /dev/null +++ b/cli-plugins/manager/manager_unix.go @@ -0,0 +1,8 @@ +// +build !windows + +package manager + +var defaultSystemPluginDirs = []string{ + "/usr/local/lib/docker/cli-plugins", "/usr/local/libexec/docker/cli-plugins", + "/usr/lib/docker/cli-plugins", "/usr/libexec/docker/cli-plugins", +} diff --git a/cli-plugins/manager/manager_windows.go b/cli-plugins/manager/manager_windows.go new file mode 100644 index 000000000000..b62868580360 --- /dev/null +++ b/cli-plugins/manager/manager_windows.go @@ -0,0 +1,10 @@ +package manager + +import ( + "os" + "path/filepath" +) + +var defaultSystemPluginDirs = []string{ + filepath.Join(os.Getenv("ProgramData"), "Docker", "cli-plugins"), +} diff --git a/cli-plugins/manager/metadata.go b/cli-plugins/manager/metadata.go new file mode 100644 index 000000000000..d3de778141f6 --- /dev/null +++ b/cli-plugins/manager/metadata.go @@ -0,0 +1,25 @@ +package manager + +const ( + // NamePrefix is the prefix required on all plugin binary names + NamePrefix = "docker-" + + // MetadataSubcommandName is the name of the plugin subcommand + // which must be supported by every plugin and returns the + // plugin metadata. + MetadataSubcommandName = "docker-cli-plugin-metadata" +) + +// Metadata provided by the plugin. See docs/extend/cli_plugins.md for canonical information. +type Metadata struct { + // SchemaVersion describes the version of this struct. Mandatory, must be "0.1.0" + SchemaVersion string `json:",omitempty"` + // Vendor is the name of the plugin vendor. Mandatory + Vendor string `json:",omitempty"` + // Version is the optional version of this plugin. + Version string `json:",omitempty"` + // ShortDescription should be suitable for a single line help message. + ShortDescription string `json:",omitempty"` + // URL is a pointer to the plugin's homepage. + URL string `json:",omitempty"` +} diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go new file mode 100644 index 000000000000..a8ac4fa3a399 --- /dev/null +++ b/cli-plugins/manager/plugin.go @@ -0,0 +1,107 @@ +package manager + +import ( + "encoding/json" + "path/filepath" + "regexp" + "strings" + + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +var ( + pluginNameRe = regexp.MustCompile("^[a-z][a-z0-9]*$") +) + +// Plugin represents a potential plugin with all it's metadata. +type Plugin struct { + Metadata + + Name string `json:",omitempty"` + Path string `json:",omitempty"` + + // Err is non-nil if the plugin failed one of the candidate tests. + Err error `json:",omitempty"` + + // ShadowedPaths contains the paths of any other plugins which this plugin takes precedence over. + ShadowedPaths []string `json:",omitempty"` +} + +// newPlugin determines if the given candidate is valid and returns a +// Plugin. If the candidate fails one of the tests then `Plugin.Err` +// is set, and is always a `pluginError`, but the `Plugin` is still +// returned with no error. An error is only returned due to a +// non-recoverable error. +func newPlugin(c Candidate, rootcmd *cobra.Command) (Plugin, error) { + path := c.Path() + if path == "" { + return Plugin{}, errors.New("plugin candidate path cannot be empty") + } + + // The candidate listing process should have skipped anything + // which would fail here, so there are all real errors. + fullname := filepath.Base(path) + if fullname == "." { + return Plugin{}, errors.Errorf("unable to determine basename of plugin candidate %q", path) + } + var err error + if fullname, err = trimExeSuffix(fullname); err != nil { + return Plugin{}, errors.Wrapf(err, "plugin candidate %q", path) + } + if !strings.HasPrefix(fullname, NamePrefix) { + return Plugin{}, errors.Errorf("plugin candidate %q: does not have %q prefix", path, NamePrefix) + } + + p := Plugin{ + Name: strings.TrimPrefix(fullname, NamePrefix), + Path: path, + } + + // Now apply the candidate tests, so these update p.Err. + if !pluginNameRe.MatchString(p.Name) { + p.Err = NewPluginError("plugin candidate %q did not match %q", p.Name, pluginNameRe.String()) + return p, nil + } + + if rootcmd != nil { + for _, cmd := range rootcmd.Commands() { + // Ignore conflicts with commands which are + // just plugin stubs (i.e. from a previous + // call to AddPluginCommandStubs). + if p := cmd.Annotations[CommandAnnotationPlugin]; p == "true" { + continue + } + if cmd.Name() == p.Name { + p.Err = NewPluginError("plugin %q duplicates builtin command", p.Name) + return p, nil + } + if cmd.HasAlias(p.Name) { + p.Err = NewPluginError("plugin %q duplicates an alias of builtin command %q", p.Name, cmd.Name()) + return p, nil + } + } + } + + // We are supposed to check for relevant execute permissions here. Instead we rely on an attempt to execute. + meta, err := c.Metadata() + if err != nil { + p.Err = wrapAsPluginError(err, "failed to fetch metadata") + return p, nil + } + + if err := json.Unmarshal(meta, &p.Metadata); err != nil { + p.Err = wrapAsPluginError(err, "invalid metadata") + return p, nil + } + + if p.Metadata.SchemaVersion != "0.1.0" { + p.Err = NewPluginError("plugin SchemaVersion %q is not valid, must be 0.1.0", p.Metadata.SchemaVersion) + return p, nil + } + if p.Metadata.Vendor == "" { + p.Err = NewPluginError("plugin metadata does not define a vendor") + return p, nil + } + return p, nil +} diff --git a/cli-plugins/manager/suffix_unix.go b/cli-plugins/manager/suffix_unix.go new file mode 100644 index 000000000000..14f0903f40b7 --- /dev/null +++ b/cli-plugins/manager/suffix_unix.go @@ -0,0 +1,10 @@ +// +build !windows + +package manager + +func trimExeSuffix(s string) (string, error) { + return s, nil +} +func addExeSuffix(s string) string { + return s +} diff --git a/cli-plugins/manager/suffix_windows.go b/cli-plugins/manager/suffix_windows.go new file mode 100644 index 000000000000..53b507c87dc9 --- /dev/null +++ b/cli-plugins/manager/suffix_windows.go @@ -0,0 +1,26 @@ +package manager + +import ( + "path/filepath" + "strings" + + "github.com/pkg/errors" +) + +// This is made slightly more complex due to needing to be case insensitive. +func trimExeSuffix(s string) (string, error) { + ext := filepath.Ext(s) + if ext == "" { + return "", errors.Errorf("path %q lacks required file extension", s) + } + + exe := ".exe" + if !strings.EqualFold(ext, exe) { + return "", errors.Errorf("path %q lacks required %q suffix", s, exe) + } + return strings.TrimSuffix(s, ext), nil +} + +func addExeSuffix(s string) string { + return s + ".exe" +} diff --git a/cli-plugins/plugin/plugin.go b/cli-plugins/plugin/plugin.go new file mode 100644 index 000000000000..c72a68bb7eb0 --- /dev/null +++ b/cli-plugins/plugin/plugin.go @@ -0,0 +1,126 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" + "sync" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli-plugins/manager" + "github.com/docker/cli/cli/command" + cliflags "github.com/docker/cli/cli/flags" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Run is the top-level entry point to the CLI plugin framework. It should be called from your plugin's `main()` function. +func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) { + dockerCli, err := command.NewDockerCli() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + plugin := makeCmd(dockerCli) + + cmd := newPluginCommand(dockerCli, plugin, meta) + + if err := cmd.Execute(); err != nil { + if sterr, ok := err.(cli.StatusError); ok { + if sterr.Status != "" { + fmt.Fprintln(dockerCli.Err(), sterr.Status) + } + // StatusError should only be used for errors, and all errors should + // have a non-zero exit status, so never exit with 0 + if sterr.StatusCode == 0 { + os.Exit(1) + } + os.Exit(sterr.StatusCode) + } + fmt.Fprintln(dockerCli.Err(), err) + os.Exit(1) + } +} + +// options encapsulates the ClientOptions and FlagSet constructed by +// `newPluginCommand` such that they can be finalized by our +// `PersistentPreRunE`. This is necessary because otherwise a plugin's +// own use of that hook will shadow anything we add to the top-level +// command meaning the CLI is never Initialized. +var options struct { + init, prerun sync.Once + opts *cliflags.ClientOptions + flags *pflag.FlagSet + dockerCli *command.DockerCli +} + +// PersistentPreRunE must be called by any plugin command (or +// subcommand) which uses the cobra `PersistentPreRun*` hook. Plugins +// which do not make use of `PersistentPreRun*` do not need to call +// this (although it remains safe to do so). Plugins are recommended +// to use `PersistenPreRunE` to enable the error to be +// returned. Should not be called outside of a commands +// PersistentPreRunE hook and must not be run unless Run has been +// called. +func PersistentPreRunE(cmd *cobra.Command, args []string) error { + var err error + options.prerun.Do(func() { + if options.opts == nil || options.flags == nil || options.dockerCli == nil { + panic("PersistentPreRunE called without Run successfully called first") + } + // flags must be the original top-level command flags, not cmd.Flags() + options.opts.Common.SetDefaultOptions(options.flags) + err = options.dockerCli.Initialize(options.opts) + }) + return err +} + +func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager.Metadata) *cobra.Command { + name := plugin.Use + fullname := manager.NamePrefix + name + + cmd := &cobra.Command{ + Use: fmt.Sprintf("docker [OPTIONS] %s [ARG...]", name), + Short: fullname + " is a Docker CLI plugin", + SilenceUsage: true, + SilenceErrors: true, + TraverseChildren: true, + PersistentPreRunE: PersistentPreRunE, + DisableFlagsInUseLine: true, + } + opts, flags := cli.SetupPluginRootCommand(cmd) + + cmd.SetOutput(dockerCli.Out()) + + cmd.AddCommand( + plugin, + newMetadataSubcommand(plugin, meta), + ) + + cli.DisableFlagsInUseLine(cmd) + + options.init.Do(func() { + options.opts = opts + options.flags = flags + options.dockerCli = dockerCli + }) + return cmd +} + +func newMetadataSubcommand(plugin *cobra.Command, meta manager.Metadata) *cobra.Command { + if meta.ShortDescription == "" { + meta.ShortDescription = plugin.Short + } + cmd := &cobra.Command{ + Use: manager.MetadataSubcommandName, + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + return enc.Encode(meta) + }, + } + return cmd +} diff --git a/cli/cobra.go b/cli/cobra.go index a7431a08766c..cea1a9be4e85 100644 --- a/cli/cobra.go +++ b/cli/cobra.go @@ -4,29 +4,65 @@ import ( "fmt" "strings" + pluginmanager "github.com/docker/cli/cli-plugins/manager" + cliconfig "github.com/docker/cli/cli/config" + cliflags "github.com/docker/cli/cli/flags" "github.com/docker/docker/pkg/term" "github.com/pkg/errors" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) -// SetupRootCommand sets default usage, help, and error handling for the -// root command. -func SetupRootCommand(rootCmd *cobra.Command) { +// setupCommonRootCommand contains the setup common to +// SetupRootCommand and SetupPluginRootCommand. +func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet, *cobra.Command) { + opts := cliflags.NewClientOptions() + flags := rootCmd.Flags() + + flags.StringVar(&opts.ConfigDir, "config", cliconfig.Dir(), "Location of client config files") + opts.Common.InstallFlags(flags) + cobra.AddTemplateFunc("hasSubCommands", hasSubCommands) cobra.AddTemplateFunc("hasManagementSubCommands", hasManagementSubCommands) + cobra.AddTemplateFunc("hasInvalidPlugins", hasInvalidPlugins) cobra.AddTemplateFunc("operationSubCommands", operationSubCommands) cobra.AddTemplateFunc("managementSubCommands", managementSubCommands) + cobra.AddTemplateFunc("invalidPlugins", invalidPlugins) cobra.AddTemplateFunc("wrappedFlagUsages", wrappedFlagUsages) + cobra.AddTemplateFunc("commandVendor", commandVendor) + cobra.AddTemplateFunc("isFirstLevelCommand", isFirstLevelCommand) // is it an immediate sub-command of the root + cobra.AddTemplateFunc("invalidPluginReason", invalidPluginReason) rootCmd.SetUsageTemplate(usageTemplate) rootCmd.SetHelpTemplate(helpTemplate) rootCmd.SetFlagErrorFunc(FlagErrorFunc) rootCmd.SetHelpCommand(helpCommand) + + return opts, flags, helpCommand +} + +// SetupRootCommand sets default usage, help, and error handling for the +// root command. +func SetupRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet, *cobra.Command) { + opts, flags, helpCmd := setupCommonRootCommand(rootCmd) + rootCmd.SetVersionTemplate("Docker version {{.Version}}\n") rootCmd.PersistentFlags().BoolP("help", "h", false, "Print usage") rootCmd.PersistentFlags().MarkShorthandDeprecated("help", "please use --help") rootCmd.PersistentFlags().Lookup("help").Hidden = true + + return opts, flags, helpCmd +} + +// SetupPluginRootCommand sets default usage, help and error handling for a plugin root command. +func SetupPluginRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet) { + opts, flags, _ := setupCommonRootCommand(rootCmd) + + rootCmd.PersistentFlags().BoolP("help", "", false, "Print usage") + rootCmd.PersistentFlags().Lookup("help").Hidden = true + + return opts, flags } // FlagErrorFunc prints an error message which matches the format of the @@ -46,6 +82,25 @@ func FlagErrorFunc(cmd *cobra.Command, err error) error { } } +// VisitAll will traverse all commands from the root. +// This is different from the VisitAll of cobra.Command where only parents +// are checked. +func VisitAll(root *cobra.Command, fn func(*cobra.Command)) { + for _, cmd := range root.Commands() { + VisitAll(cmd, fn) + } + fn(root) +} + +// DisableFlagsInUseLine sets the DisableFlagsInUseLine flag on all +// commands within the tree rooted at cmd. +func DisableFlagsInUseLine(cmd *cobra.Command) { + VisitAll(cmd, func(ccmd *cobra.Command) { + // do not add a `[flags]` to the end of the usage line. + ccmd.DisableFlagsInUseLine = true + }) +} + var helpCommand = &cobra.Command{ Use: "help [command]", Short: "Help about the command", @@ -63,6 +118,10 @@ var helpCommand = &cobra.Command{ }, } +func isPlugin(cmd *cobra.Command) bool { + return cmd.Annotations[pluginmanager.CommandAnnotationPlugin] == "true" +} + func hasSubCommands(cmd *cobra.Command) bool { return len(operationSubCommands(cmd)) > 0 } @@ -71,9 +130,16 @@ func hasManagementSubCommands(cmd *cobra.Command) bool { return len(managementSubCommands(cmd)) > 0 } +func hasInvalidPlugins(cmd *cobra.Command) bool { + return len(invalidPlugins(cmd)) > 0 +} + func operationSubCommands(cmd *cobra.Command) []*cobra.Command { cmds := []*cobra.Command{} for _, sub := range cmd.Commands() { + if isPlugin(sub) && invalidPluginReason(sub) != "" { + continue + } if sub.IsAvailableCommand() && !sub.HasSubCommands() { cmds = append(cmds, sub) } @@ -89,9 +155,27 @@ func wrappedFlagUsages(cmd *cobra.Command) string { return cmd.Flags().FlagUsagesWrapped(width - 1) } +func isFirstLevelCommand(cmd *cobra.Command) bool { + return cmd.Parent() == cmd.Root() +} + +func commandVendor(cmd *cobra.Command) string { + width := 13 + if v, ok := cmd.Annotations[pluginmanager.CommandAnnotationPluginVendor]; ok { + if len(v) > width-2 { + v = v[:width-3] + "…" + } + return fmt.Sprintf("%-*s", width, "("+v+")") + } + return strings.Repeat(" ", width) +} + func managementSubCommands(cmd *cobra.Command) []*cobra.Command { cmds := []*cobra.Command{} for _, sub := range cmd.Commands() { + if isPlugin(sub) && invalidPluginReason(sub) != "" { + continue + } if sub.IsAvailableCommand() && sub.HasSubCommands() { cmds = append(cmds, sub) } @@ -99,6 +183,23 @@ func managementSubCommands(cmd *cobra.Command) []*cobra.Command { return cmds } +func invalidPlugins(cmd *cobra.Command) []*cobra.Command { + cmds := []*cobra.Command{} + for _, sub := range cmd.Commands() { + if !isPlugin(sub) { + continue + } + if invalidPluginReason(sub) != "" { + cmds = append(cmds, sub) + } + } + return cmds +} + +func invalidPluginReason(cmd *cobra.Command) string { + return cmd.Annotations[pluginmanager.CommandAnnotationPluginInvalid] +} + var usageTemplate = `Usage: {{- if not .HasSubCommands}} {{.UseLine}}{{end}} @@ -129,7 +230,7 @@ Options: Management Commands: {{- range managementSubCommands . }} - {{rpad .Name .NamePadding }} {{.Short}} + {{rpad .Name .NamePadding }} {{ if isFirstLevelCommand .}}{{commandVendor .}} {{ end}}{{.Short}} {{- end}} {{- end}} @@ -138,10 +239,20 @@ Management Commands: Commands: {{- range operationSubCommands . }} - {{rpad .Name .NamePadding }} {{.Short}} + {{rpad .Name .NamePadding }} {{ if isFirstLevelCommand .}}{{commandVendor .}} {{ end}}{{.Short}} {{- end}} {{- end}} +{{- if hasInvalidPlugins . }} + +Invalid Plugins: + +{{- range invalidPlugins . }} + {{rpad .Name .NamePadding }} {{invalidPluginReason .}} +{{- end}} + +{{- end}} + {{- if .HasSubCommands }} Run '{{.CommandPath}} COMMAND --help' for more information on a command. diff --git a/cli/cobra_test.go b/cli/cobra_test.go new file mode 100644 index 000000000000..a9d943e678bf --- /dev/null +++ b/cli/cobra_test.go @@ -0,0 +1,78 @@ +package cli + +import ( + "testing" + + pluginmanager "github.com/docker/cli/cli-plugins/manager" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/cobra" + "gotest.tools/assert" + is "gotest.tools/assert/cmp" +) + +func TestVisitAll(t *testing.T) { + root := &cobra.Command{Use: "root"} + sub1 := &cobra.Command{Use: "sub1"} + sub1sub1 := &cobra.Command{Use: "sub1sub1"} + sub1sub2 := &cobra.Command{Use: "sub1sub2"} + sub2 := &cobra.Command{Use: "sub2"} + + root.AddCommand(sub1, sub2) + sub1.AddCommand(sub1sub1, sub1sub2) + + // Take the opportunity to test DisableFlagsInUseLine too + DisableFlagsInUseLine(root) + + var visited []string + VisitAll(root, func(ccmd *cobra.Command) { + visited = append(visited, ccmd.Name()) + assert.Assert(t, ccmd.DisableFlagsInUseLine, "DisableFlagsInUseLine not set on %q", ccmd.Name()) + }) + expected := []string{"sub1sub1", "sub1sub2", "sub1", "sub2", "root"} + assert.DeepEqual(t, expected, visited) +} + +func TestCommandVendor(t *testing.T) { + // Non plugin. + assert.Equal(t, commandVendor(&cobra.Command{Use: "test"}), " ") + + // Plugins with various lengths of vendor. + for _, tc := range []struct { + vendor string + expected string + }{ + {vendor: "vendor", expected: "(vendor) "}, + {vendor: "vendor12345", expected: "(vendor12345)"}, + {vendor: "vendor123456", expected: "(vendor1234…)"}, + {vendor: "vendor1234567", expected: "(vendor1234…)"}, + } { + t.Run(tc.vendor, func(t *testing.T) { + cmd := &cobra.Command{ + Use: "test", + Annotations: map[string]string{ + pluginmanager.CommandAnnotationPluginVendor: tc.vendor, + }, + } + assert.Equal(t, commandVendor(cmd), tc.expected) + }) + } +} + +func TestInvalidPlugin(t *testing.T) { + root := &cobra.Command{Use: "root"} + sub1 := &cobra.Command{Use: "sub1"} + sub1sub1 := &cobra.Command{Use: "sub1sub1"} + sub1sub2 := &cobra.Command{Use: "sub1sub2"} + sub2 := &cobra.Command{Use: "sub2"} + + assert.Assert(t, is.Len(invalidPlugins(root), 0)) + + sub1.Annotations = map[string]string{ + pluginmanager.CommandAnnotationPlugin: "true", + pluginmanager.CommandAnnotationPluginInvalid: "foo", + } + root.AddCommand(sub1, sub2) + sub1.AddCommand(sub1sub1, sub1sub2) + + assert.DeepEqual(t, invalidPlugins(root), []*cobra.Command{sub1}, cmpopts.IgnoreUnexported(cobra.Command{})) +} diff --git a/cli/command/cli.go b/cli/command/cli.go index fc557635c483..28936fc3bf56 100644 --- a/cli/command/cli.go +++ b/cli/command/cli.go @@ -8,7 +8,6 @@ import ( "runtime" "strconv" - "github.com/docker/cli/cli" "github.com/docker/cli/cli/config" cliconfig "github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config/configfile" @@ -16,11 +15,13 @@ import ( "github.com/docker/cli/cli/context/docker" kubcontext "github.com/docker/cli/cli/context/kubernetes" "github.com/docker/cli/cli/context/store" + "github.com/docker/cli/cli/debug" cliflags "github.com/docker/cli/cli/flags" manifeststore "github.com/docker/cli/cli/manifest/store" registryclient "github.com/docker/cli/cli/registry/client" "github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/trust" + "github.com/docker/cli/cli/version" "github.com/docker/cli/internal/containerizedengine" dopts "github.com/docker/cli/opts" clitypes "github.com/docker/cli/types" @@ -177,6 +178,16 @@ func (cli *DockerCli) RegistryClient(allowInsecure bool) registryclient.Registry // Initialize the dockerCli runs initialization that must happen after command // line flags are parsed. func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error { + cliflags.SetLogLevel(opts.Common.LogLevel) + + if opts.ConfigDir != "" { + cliconfig.SetDir(opts.ConfigDir) + } + + if opts.Common.Debug { + debug.Enable() + } + cli.configFile = cliconfig.LoadDefaultConfigFile(cli.err) var err error cli.contextStore = store.New(cliconfig.ContextStoreDir(), cli.contextStoreConfig) @@ -461,7 +472,7 @@ func getServerHost(hosts []string, tlsOptions *tlsconfig.Options) (string, error // UserAgent returns the user agent string used for making API requests func UserAgent() string { - return "Docker-Client/" + cli.Version + " (" + runtime.GOOS + ")" + return "Docker-Client/" + version.Version + " (" + runtime.GOOS + ")" } // resolveContextName resolves the current context name with the following rules: diff --git a/cli/command/cli_options_test.go b/cli/command/cli_options_test.go new file mode 100644 index 000000000000..10dcad8b3dbc --- /dev/null +++ b/cli/command/cli_options_test.go @@ -0,0 +1,37 @@ +package command + +import ( + "os" + "testing" + + "gotest.tools/assert" +) + +func contentTrustEnabled(t *testing.T) bool { + var cli DockerCli + assert.NilError(t, WithContentTrustFromEnv()(&cli)) + return cli.contentTrust +} + +// NB: Do not t.Parallel() this test -- it messes with the process environment. +func TestWithContentTrustFromEnv(t *testing.T) { + envvar := "DOCKER_CONTENT_TRUST" + if orig, ok := os.LookupEnv(envvar); ok { + defer func() { + os.Setenv(envvar, orig) + }() + } else { + defer func() { + os.Unsetenv(envvar) + }() + } + + os.Setenv(envvar, "true") + assert.Assert(t, contentTrustEnabled(t)) + os.Setenv(envvar, "false") + assert.Assert(t, !contentTrustEnabled(t)) + os.Setenv(envvar, "invalid") + assert.Assert(t, contentTrustEnabled(t)) + os.Unsetenv(envvar) + assert.Assert(t, !contentTrustEnabled(t)) +} diff --git a/cli/command/system/info.go b/cli/command/system/info.go index 29380ea1a3e5..c86c04993cd3 100644 --- a/cli/command/system/info.go +++ b/cli/command/system/info.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/docker/cli/cli" + pluginmanager "github.com/docker/cli/cli-plugins/manager" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/debug" "github.com/docker/cli/templates" @@ -23,6 +24,7 @@ type infoOptions struct { type clientInfo struct { Debug bool + Plugins []pluginmanager.Plugin Warnings []string } @@ -47,7 +49,7 @@ func NewInfoCommand(dockerCli command.Cli) *cobra.Command { Short: "Display system-wide information", Args: cli.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return runInfo(dockerCli, &opts) + return runInfo(cmd, dockerCli, &opts) }, } @@ -58,7 +60,7 @@ func NewInfoCommand(dockerCli command.Cli) *cobra.Command { return cmd } -func runInfo(dockerCli command.Cli, opts *infoOptions) error { +func runInfo(cmd *cobra.Command, dockerCli command.Cli, opts *infoOptions) error { var info info ctx := context.Background() @@ -71,6 +73,11 @@ func runInfo(dockerCli command.Cli, opts *infoOptions) error { info.ClientInfo = &clientInfo{ Debug: debug.IsEnabled(), } + if plugins, err := pluginmanager.ListPlugins(dockerCli, cmd.Root()); err == nil { + info.ClientInfo.Plugins = plugins + } else { + info.ClientErrors = append(info.ClientErrors, err.Error()) + } if opts.format == "" { return prettyPrintInfo(dockerCli, info) @@ -109,6 +116,17 @@ func prettyPrintInfo(dockerCli command.Cli, info info) error { func prettyPrintClientInfo(dockerCli command.Cli, info clientInfo) error { fmt.Fprintln(dockerCli.Out(), " Debug Mode:", info.Debug) + if len(info.Plugins) > 0 { + fmt.Fprintln(dockerCli.Out(), " Plugins:") + for _, p := range info.Plugins { + if p.Err == nil { + fmt.Fprintf(dockerCli.Out(), " %s: (%s, %s) %s\n", p.Name, p.Version, p.Vendor, p.ShortDescription) + } else { + info.Warnings = append(info.Warnings, fmt.Sprintf("WARNING: Plugin %q is not valid: %s", p.Path, p.Err)) + } + } + } + if len(info.Warnings) > 0 { fmt.Fprintln(dockerCli.Err(), strings.Join(info.Warnings, "\n")) } @@ -447,6 +465,11 @@ func getBackingFs(info types.Info) string { } func formatInfo(dockerCli command.Cli, info info, format string) error { + // Ensure slice/array fields render as `[]` not `null` + if info.ClientInfo != nil && info.ClientInfo.Plugins == nil { + info.ClientInfo.Plugins = make([]pluginmanager.Plugin, 0) + } + tmpl, err := templates.Parse(format) if err != nil { return cli.StatusError{StatusCode: 64, diff --git a/cli/command/system/info_test.go b/cli/command/system/info_test.go index d3bcd7e02af6..cb46715e5340 100644 --- a/cli/command/system/info_test.go +++ b/cli/command/system/info_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + pluginmanager "github.com/docker/cli/cli-plugins/manager" "github.com/docker/cli/internal/test" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/registry" @@ -192,6 +193,24 @@ PQQDAgNIADBFAiEAo9fTQNM5DP9bHVcTJYfl2Cay1bFu1E+lnpmN+EYJfeACIGKH }, } +var samplePluginsInfo = []pluginmanager.Plugin{ + { + Name: "goodplugin", + Path: "/path/to/docker-goodplugin", + Metadata: pluginmanager.Metadata{ + SchemaVersion: "0.1.0", + ShortDescription: "unit test is good", + Vendor: "ACME Corp", + Version: "0.1.0", + }, + }, + { + Name: "badplugin", + Path: "/path/to/docker-badplugin", + Err: pluginmanager.NewPluginError("something wrong"), + }, +} + func TestPrettyPrintInfo(t *testing.T) { infoWithSwarm := sampleInfoNoSwarm infoWithSwarm.Swarm = sampleSwarmInfo @@ -228,8 +247,9 @@ func TestPrettyPrintInfo(t *testing.T) { sampleInfoBadSecurity.SecurityOptions = []string{"foo="} for _, tc := range []struct { - doc string - dockerInfo info + doc string + dockerInfo info + prettyGolden string warningsGolden string jsonGolden string @@ -245,6 +265,19 @@ func TestPrettyPrintInfo(t *testing.T) { jsonGolden: "docker-info-no-swarm", }, { + doc: "info with plugins", + dockerInfo: info{ + Info: &sampleInfoNoSwarm, + ClientInfo: &clientInfo{ + Plugins: samplePluginsInfo, + }, + }, + prettyGolden: "docker-info-plugins", + jsonGolden: "docker-info-plugins", + warningsGolden: "docker-info-plugins-warnings", + }, + { + doc: "info with swarm", dockerInfo: info{ Info: &infoWithSwarm, diff --git a/cli/command/system/testdata/docker-info-badsec.json.golden b/cli/command/system/testdata/docker-info-badsec.json.golden index 4b15ac022239..3f3eea239385 100644 --- a/cli/command/system/testdata/docker-info-badsec.json.golden +++ b/cli/command/system/testdata/docker-info-badsec.json.golden @@ -1 +1 @@ -{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["foo="],"Warnings":null,"ServerErrors":["an error happened"],"ClientInfo":{"Debug":false,"Warnings":null}} +{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["foo="],"Warnings":null,"ServerErrors":["an error happened"],"ClientInfo":{"Debug":false,"Plugins":[],"Warnings":null}} diff --git a/cli/command/system/testdata/docker-info-daemon-warnings.json.golden b/cli/command/system/testdata/docker-info-daemon-warnings.json.golden index 504939799d6e..0a387bb51072 100644 --- a/cli/command/system/testdata/docker-info-daemon-warnings.json.golden +++ b/cli/command/system/testdata/docker-info-daemon-warnings.json.golden @@ -1 +1 @@ -{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":["WARNING: No memory limit support","WARNING: No swap limit support","WARNING: No kernel memory limit support","WARNING: No oom kill disable support","WARNING: No cpu cfs quota support","WARNING: No cpu cfs period support","WARNING: No cpu shares support","WARNING: No cpuset support","WARNING: IPv4 forwarding is disabled","WARNING: bridge-nf-call-iptables is disabled","WARNING: bridge-nf-call-ip6tables is disabled"],"ClientInfo":{"Debug":true,"Warnings":null}} +{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":["WARNING: No memory limit support","WARNING: No swap limit support","WARNING: No kernel memory limit support","WARNING: No oom kill disable support","WARNING: No cpu cfs quota support","WARNING: No cpu cfs period support","WARNING: No cpu shares support","WARNING: No cpuset support","WARNING: IPv4 forwarding is disabled","WARNING: bridge-nf-call-iptables is disabled","WARNING: bridge-nf-call-ip6tables is disabled"],"ClientInfo":{"Debug":true,"Plugins":[],"Warnings":null}} diff --git a/cli/command/system/testdata/docker-info-legacy-warnings.json.golden b/cli/command/system/testdata/docker-info-legacy-warnings.json.golden index 489f700eb3e6..1996dd6d359a 100644 --- a/cli/command/system/testdata/docker-info-legacy-warnings.json.golden +++ b/cli/command/system/testdata/docker-info-legacy-warnings.json.golden @@ -1 +1 @@ -{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":false,"SwapLimit":false,"KernelMemory":false,"KernelMemoryTCP":false,"CpuCfsPeriod":false,"CpuCfsQuota":false,"CPUShares":false,"CPUSet":false,"IPv4Forwarding":false,"BridgeNfIptables":false,"BridgeNfIp6tables":false,"Debug":true,"NFd":33,"OomKillDisable":false,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":true,"Warnings":null}} +{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":false,"SwapLimit":false,"KernelMemory":false,"KernelMemoryTCP":false,"CpuCfsPeriod":false,"CpuCfsQuota":false,"CPUShares":false,"CPUSet":false,"IPv4Forwarding":false,"BridgeNfIptables":false,"BridgeNfIp6tables":false,"Debug":true,"NFd":33,"OomKillDisable":false,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":true,"Plugins":[],"Warnings":null}} diff --git a/cli/command/system/testdata/docker-info-no-swarm.json.golden b/cli/command/system/testdata/docker-info-no-swarm.json.golden index b99808753108..7dd6c0eb2a57 100644 --- a/cli/command/system/testdata/docker-info-no-swarm.json.golden +++ b/cli/command/system/testdata/docker-info-no-swarm.json.golden @@ -1 +1 @@ -{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":true,"Warnings":null}} +{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":true,"Plugins":[],"Warnings":null}} diff --git a/cli/command/system/testdata/docker-info-plugins-warnings.golden b/cli/command/system/testdata/docker-info-plugins-warnings.golden new file mode 100644 index 000000000000..be6c83426af8 --- /dev/null +++ b/cli/command/system/testdata/docker-info-plugins-warnings.golden @@ -0,0 +1 @@ +WARNING: Plugin "/path/to/docker-badplugin" is not valid: something wrong diff --git a/cli/command/system/testdata/docker-info-plugins.golden b/cli/command/system/testdata/docker-info-plugins.golden new file mode 100644 index 000000000000..8a419965a263 --- /dev/null +++ b/cli/command/system/testdata/docker-info-plugins.golden @@ -0,0 +1,56 @@ +Client: + Debug Mode: false + Plugins: + goodplugin: (0.1.0, ACME Corp) unit test is good + +Server: + Containers: 0 + Running: 0 + Paused: 0 + Stopped: 0 + Images: 0 + Server Version: 17.06.1-ce + Storage Driver: aufs + Root Dir: /var/lib/docker/aufs + Backing Filesystem: extfs + Dirs: 0 + Dirperm1 Supported: true + Logging Driver: json-file + Cgroup Driver: cgroupfs + Plugins: + Volume: local + Network: bridge host macvlan null overlay + Log: awslogs fluentd gcplogs gelf journald json-file logentries splunk syslog + Swarm: inactive + Runtimes: runc + Default Runtime: runc + Init Binary: docker-init + containerd version: 6e23458c129b551d5c9871e5174f6b1b7f6d1170 + runc version: 810190ceaa507aa2727d7ae6f4790c76ec150bd2 + init version: 949e6fa + Security Options: + apparmor + seccomp + Profile: default + Kernel Version: 4.4.0-87-generic + Operating System: Ubuntu 16.04.3 LTS + OSType: linux + Architecture: x86_64 + CPUs: 2 + Total Memory: 1.953GiB + Name: system-sample + ID: EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX + Docker Root Dir: /var/lib/docker + Debug Mode: true + File Descriptors: 33 + Goroutines: 135 + System Time: 2017-08-24T17:44:34.077811894Z + EventsListeners: 0 + Registry: https://index.docker.io/v1/ + Labels: + provider=digitalocean + Experimental: false + Insecure Registries: + 127.0.0.0/8 + Live Restore Enabled: false + diff --git a/cli/command/system/testdata/docker-info-plugins.json.golden b/cli/command/system/testdata/docker-info-plugins.json.golden new file mode 100644 index 000000000000..f7c8435b3b10 --- /dev/null +++ b/cli/command/system/testdata/docker-info-plugins.json.golden @@ -0,0 +1 @@ +{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":false,"Plugins":[{"SchemaVersion":"0.1.0","Vendor":"ACME Corp","Version":"0.1.0","ShortDescription":"unit test is good","Name":"goodplugin","Path":"/path/to/docker-goodplugin"},{"Name":"badplugin","Path":"/path/to/docker-badplugin","Err":"something wrong"}],"Warnings":null}} diff --git a/cli/command/system/testdata/docker-info-with-swarm.json.golden b/cli/command/system/testdata/docker-info-with-swarm.json.golden index 58ff6ea2b2fc..da49769c8d64 100644 --- a/cli/command/system/testdata/docker-info-with-swarm.json.golden +++ b/cli/command/system/testdata/docker-info-with-swarm.json.golden @@ -1 +1 @@ -{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"qo2dfdig9mmxqkawulggepdih","NodeAddr":"165.227.107.89","LocalNodeState":"active","ControlAvailable":true,"Error":"","RemoteManagers":[{"NodeID":"qo2dfdig9mmxqkawulggepdih","Addr":"165.227.107.89:2377"}],"Nodes":1,"Managers":1,"Cluster":{"ID":"9vs5ygs0gguyyec4iqf2314c0","Version":{"Index":11},"CreatedAt":"2017-08-24T17:34:19.278062352Z","UpdatedAt":"2017-08-24T17:34:42.398815481Z","Spec":{"Name":"default","Labels":null,"Orchestration":{"TaskHistoryRetentionLimit":5},"Raft":{"SnapshotInterval":10000,"KeepOldSnapshots":0,"LogEntriesForSlowFollowers":500,"ElectionTick":3,"HeartbeatTick":1},"Dispatcher":{"HeartbeatPeriod":5000000000},"CAConfig":{"NodeCertExpiry":7776000000000000},"TaskDefaults":{},"EncryptionConfig":{"AutoLockManagers":true}},"TLSInfo":{"TrustRoot":"\n-----BEGIN CERTIFICATE-----\nMIIBajCCARCgAwIBAgIUaFCW5xsq8eyiJ+Pmcv3MCflMLnMwCgYIKoZIzj0EAwIw\nEzERMA8GA1UEAxMIc3dhcm0tY2EwHhcNMTcwODI0MTcyOTAwWhcNMzcwODE5MTcy\nOTAwWjATMREwDwYDVQQDEwhzd2FybS1jYTBZMBMGByqGSM49AgEGCCqGSM49AwEH\nA0IABDy7NebyUJyUjWJDBUdnZoV6GBxEGKO4TZPNDwnxDxJcUdLVaB7WGa4/DLrW\nUfsVgh1JGik2VTiLuTMA1tLlNPOjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMB\nAf8EBTADAQH/MB0GA1UdDgQWBBQl16XFtaaXiUAwEuJptJlDjfKskDAKBggqhkjO\nPQQDAgNIADBFAiEAo9fTQNM5DP9bHVcTJYfl2Cay1bFu1E+lnpmN+EYJfeACIGKH\n1pCUkZ+D0IB6CiEZGWSHyLuXPM1rlP+I5KuS7sB8\n-----END CERTIFICATE-----\n","CertIssuerSubject":"MBMxETAPBgNVBAMTCHN3YXJtLWNh","CertIssuerPublicKey":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPLs15vJQnJSNYkMFR2dmhXoYHEQYo7hNk80PCfEPElxR0tVoHtYZrj8MutZR+xWCHUkaKTZVOIu5MwDW0uU08w=="},"RootRotationInProgress":false,"DefaultAddrPool":null,"SubnetSize":0,"DataPathPort":0}},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":false,"Warnings":null}} +{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"qo2dfdig9mmxqkawulggepdih","NodeAddr":"165.227.107.89","LocalNodeState":"active","ControlAvailable":true,"Error":"","RemoteManagers":[{"NodeID":"qo2dfdig9mmxqkawulggepdih","Addr":"165.227.107.89:2377"}],"Nodes":1,"Managers":1,"Cluster":{"ID":"9vs5ygs0gguyyec4iqf2314c0","Version":{"Index":11},"CreatedAt":"2017-08-24T17:34:19.278062352Z","UpdatedAt":"2017-08-24T17:34:42.398815481Z","Spec":{"Name":"default","Labels":null,"Orchestration":{"TaskHistoryRetentionLimit":5},"Raft":{"SnapshotInterval":10000,"KeepOldSnapshots":0,"LogEntriesForSlowFollowers":500,"ElectionTick":3,"HeartbeatTick":1},"Dispatcher":{"HeartbeatPeriod":5000000000},"CAConfig":{"NodeCertExpiry":7776000000000000},"TaskDefaults":{},"EncryptionConfig":{"AutoLockManagers":true}},"TLSInfo":{"TrustRoot":"\n-----BEGIN CERTIFICATE-----\nMIIBajCCARCgAwIBAgIUaFCW5xsq8eyiJ+Pmcv3MCflMLnMwCgYIKoZIzj0EAwIw\nEzERMA8GA1UEAxMIc3dhcm0tY2EwHhcNMTcwODI0MTcyOTAwWhcNMzcwODE5MTcy\nOTAwWjATMREwDwYDVQQDEwhzd2FybS1jYTBZMBMGByqGSM49AgEGCCqGSM49AwEH\nA0IABDy7NebyUJyUjWJDBUdnZoV6GBxEGKO4TZPNDwnxDxJcUdLVaB7WGa4/DLrW\nUfsVgh1JGik2VTiLuTMA1tLlNPOjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMB\nAf8EBTADAQH/MB0GA1UdDgQWBBQl16XFtaaXiUAwEuJptJlDjfKskDAKBggqhkjO\nPQQDAgNIADBFAiEAo9fTQNM5DP9bHVcTJYfl2Cay1bFu1E+lnpmN+EYJfeACIGKH\n1pCUkZ+D0IB6CiEZGWSHyLuXPM1rlP+I5KuS7sB8\n-----END CERTIFICATE-----\n","CertIssuerSubject":"MBMxETAPBgNVBAMTCHN3YXJtLWNh","CertIssuerPublicKey":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPLs15vJQnJSNYkMFR2dmhXoYHEQYo7hNk80PCfEPElxR0tVoHtYZrj8MutZR+xWCHUkaKTZVOIu5MwDW0uU08w=="},"RootRotationInProgress":false,"DefaultAddrPool":null,"SubnetSize":0,"DataPathPort":0}},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":false,"Plugins":[],"Warnings":null}} diff --git a/cli/command/system/version.go b/cli/command/system/version.go index a8f0beb1fdc0..2e2ae2ee3acc 100644 --- a/cli/command/system/version.go +++ b/cli/command/system/version.go @@ -12,6 +12,7 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" kubecontext "github.com/docker/cli/cli/context/kubernetes" + "github.com/docker/cli/cli/version" "github.com/docker/cli/kubernetes" "github.com/docker/cli/templates" "github.com/docker/docker/api/types" @@ -135,13 +136,13 @@ func runVersion(dockerCli command.Cli, opts *versionOptions) error { vd := versionInfo{ Client: clientVersion{ - Platform: struct{ Name string }{cli.PlatformName}, - Version: cli.Version, + Platform: struct{ Name string }{version.PlatformName}, + Version: version.Version, APIVersion: dockerCli.Client().ClientVersion(), DefaultAPIVersion: dockerCli.DefaultVersion(), GoVersion: runtime.Version(), - GitCommit: cli.GitCommit, - BuildTime: reformatDate(cli.BuildTime), + GitCommit: version.GitCommit, + BuildTime: reformatDate(version.BuildTime), Os: runtime.GOOS, Arch: runtime.GOARCH, Experimental: dockerCli.ClientInfo().HasExperimental, diff --git a/cli/config/config.go b/cli/config/config.go index 64f8d3b49c67..f773abee4091 100644 --- a/cli/config/config.go +++ b/cli/config/config.go @@ -46,6 +46,11 @@ func SetDir(dir string) { configDir = dir } +// Path returns the path to a file relative to the config dir +func Path(p ...string) string { + return filepath.Join(append([]string{Dir()}, p...)...) +} + // LegacyLoadFromReader is a convenience function that creates a ConfigFile object from // a non-nested reader func LegacyLoadFromReader(configData io.Reader) (*configfile.ConfigFile, error) { diff --git a/cli/config/config_test.go b/cli/config/config_test.go index 11c8dd6de5f2..85288291b461 100644 --- a/cli/config/config_test.go +++ b/cli/config/config_test.go @@ -548,3 +548,17 @@ func TestLoadDefaultConfigFile(t *testing.T) { assert.Check(t, is.DeepEqual(expected, configFile)) } + +func TestConfigPath(t *testing.T) { + oldDir := Dir() + + SetDir("dummy1") + f1 := Path("a", "b") + assert.Equal(t, f1, filepath.Join("dummy1", "a", "b")) + + SetDir("dummy2") + f2 := Path("c", "d") + assert.Equal(t, f2, filepath.Join("dummy2", "c", "d")) + + SetDir(oldDir) +} diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index d815570362a8..99ffd47a5fcb 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -49,6 +49,7 @@ type ConfigFile struct { StackOrchestrator string `json:"stackOrchestrator,omitempty"` Kubernetes *KubernetesConfig `json:"kubernetes,omitempty"` CurrentContext string `json:"currentContext,omitempty"` + CLIPluginsExtraDirs []string `json:"cliPluginsExtraDirs,omitempty"` } // ProxyConfig contains proxy configuration settings diff --git a/cli/version.go b/cli/version/version.go similarity index 92% rename from cli/version.go rename to cli/version/version.go index c4120b9585fb..a263b9a73133 100644 --- a/cli/version.go +++ b/cli/version/version.go @@ -1,4 +1,4 @@ -package cli +package version // Default build-time variable. // These values are overridden via ldflags diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 5909953be3db..5cccfe2df11f 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -7,11 +7,11 @@ import ( "strings" "github.com/docker/cli/cli" + pluginmanager "github.com/docker/cli/cli-plugins/manager" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/commands" - cliconfig "github.com/docker/cli/cli/config" - "github.com/docker/cli/cli/debug" cliflags "github.com/docker/cli/cli/flags" + "github.com/docker/cli/cli/version" "github.com/docker/docker/api/types/versions" "github.com/docker/docker/client" "github.com/sirupsen/logrus" @@ -20,8 +20,11 @@ import ( ) func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { - opts := cliflags.NewClientOptions() - var flags *pflag.FlagSet + var ( + opts *cliflags.ClientOptions + flags *pflag.FlagSet + helpCmd *cobra.Command + ) cmd := &cobra.Command{ Use: "docker [OPTIONS] COMMAND [ARG...]", @@ -29,49 +32,59 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { SilenceUsage: true, SilenceErrors: true, TraverseChildren: true, - Args: noArgs, + FParseErrWhitelist: cobra.FParseErrWhitelist{ + // UnknownFlags ignores any unknown + // --arguments on the top-level docker command + // only. This is necessary to allow passing + // --arguments to plugins otherwise + // e.g. `docker plugin --foo` is caught here + // in the monolithic CLI and `foo` is reported + // as an unknown argument. + UnknownFlags: true, + }, RunE: func(cmd *cobra.Command, args []string) error { - return command.ShowHelp(dockerCli.Err())(cmd, args) + if len(args) == 0 { + return command.ShowHelp(dockerCli.Err())(cmd, args) + } + plugincmd, err := pluginmanager.PluginRunCommand(dockerCli, args[0], cmd) + if pluginmanager.IsNotFound(err) { + return fmt.Errorf( + "docker: '%s' is not a docker command.\nSee 'docker --help'", args[0]) + } + if err != nil { + return err + } + + return plugincmd.Run() }, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // flags must be the top-level command flags, not cmd.Flags() opts.Common.SetDefaultOptions(flags) - dockerPreRun(opts) if err := dockerCli.Initialize(opts); err != nil { return err } return isSupported(cmd, dockerCli) }, - Version: fmt.Sprintf("%s, build %s", cli.Version, cli.GitCommit), + Version: fmt.Sprintf("%s, build %s", version.Version, version.GitCommit), DisableFlagsInUseLine: true, } - cli.SetupRootCommand(cmd) - - flags = cmd.Flags() + opts, flags, helpCmd = cli.SetupRootCommand(cmd) flags.BoolP("version", "v", false, "Print version information and quit") - flags.StringVar(&opts.ConfigDir, "config", cliconfig.Dir(), "Location of client config files") - opts.Common.InstallFlags(flags) setFlagErrorFunc(dockerCli, cmd, flags, opts) + setupHelpCommand(dockerCli, cmd, helpCmd, flags, opts) setHelpFunc(dockerCli, cmd, flags, opts) cmd.SetOutput(dockerCli.Out()) commands.AddCommands(cmd, dockerCli) - disableFlagsInUseLine(cmd) + cli.DisableFlagsInUseLine(cmd) setValidateArgs(dockerCli, cmd, flags, opts) return cmd } -func disableFlagsInUseLine(cmd *cobra.Command) { - visitAll(cmd, func(ccmd *cobra.Command) { - // do not add a `[flags]` to the end of the usage line. - ccmd.DisableFlagsInUseLine = true - }) -} - func setFlagErrorFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag.FlagSet, opts *cliflags.ClientOptions) { // When invoking `docker stack --nonsense`, we need to make sure FlagErrorFunc return appropriate // output if the feature is not supported. @@ -89,6 +102,51 @@ func setFlagErrorFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *p }) } +func setupHelpCommand(dockerCli *command.DockerCli, rootCmd, helpCmd *cobra.Command, flags *pflag.FlagSet, opts *cliflags.ClientOptions) { + origRun := helpCmd.Run + origRunE := helpCmd.RunE + + helpCmd.Run = nil + helpCmd.RunE = func(c *cobra.Command, args []string) error { + // No Persistent* hooks are called for help, so we must initialize here. + if err := initializeDockerCli(dockerCli, flags, opts); err != nil { + return err + } + + if len(args) > 0 { + helpcmd, err := pluginmanager.PluginRunCommand(dockerCli, args[0], rootCmd) + if err == nil { + err = helpcmd.Run() + if err != nil { + return err + } + } + if !pluginmanager.IsNotFound(err) { + return err + } + } + if origRunE != nil { + return origRunE(c, args) + } + origRun(c, args) + return nil + } +} + +func tryRunPluginHelp(dockerCli command.Cli, ccmd *cobra.Command, cargs []string) error { + root := ccmd.Root() + + cmd, _, err := root.Traverse(cargs) + if err != nil { + return err + } + helpcmd, err := pluginmanager.PluginRunCommand(dockerCli, cmd.Name(), root) + if err != nil { + return err + } + return helpcmd.Run() +} + func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag.FlagSet, opts *cliflags.ClientOptions) { defaultHelpFunc := cmd.HelpFunc() cmd.SetHelpFunc(func(ccmd *cobra.Command, args []string) { @@ -96,6 +154,28 @@ func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag. ccmd.Println(err) return } + + // Add a stub entry for every plugin so they are + // included in the help output and so that + // `tryRunPluginHelp` can find them or if we fall + // through they will be included in the default help + // output. + if err := pluginmanager.AddPluginCommandStubs(dockerCli, ccmd.Root()); err != nil { + ccmd.Println(err) + return + } + + if len(args) >= 1 { + err := tryRunPluginHelp(dockerCli, ccmd, args) + if err == nil { // Successfully ran the plugin + return + } + if !pluginmanager.IsNotFound(err) { + ccmd.Println(err) + return + } + } + if err := isSupported(ccmd, dockerCli); err != nil { ccmd.Println(err) return @@ -104,6 +184,7 @@ func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag. ccmd.Println(err) return } + defaultHelpFunc(ccmd, args) }) } @@ -113,7 +194,7 @@ func setValidateArgs(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pf // As a result, here we replace the existing Args validation func to a wrapper, // where the wrapper will check to see if the feature is supported or not. // The Args validation error will only be returned if the feature is supported. - visitAll(cmd, func(ccmd *cobra.Command) { + cli.VisitAll(cmd, func(ccmd *cobra.Command) { // if there is no tags for a command or any of its parent, // there is no need to wrap the Args validation. if !hasTags(ccmd) { @@ -144,28 +225,9 @@ func initializeDockerCli(dockerCli *command.DockerCli, flags *pflag.FlagSet, opt // when using --help, PersistentPreRun is not called, so initialization is needed. // flags must be the top-level command flags, not cmd.Flags() opts.Common.SetDefaultOptions(flags) - dockerPreRun(opts) return dockerCli.Initialize(opts) } -// visitAll will traverse all commands from the root. -// This is different from the VisitAll of cobra.Command where only parents -// are checked. -func visitAll(root *cobra.Command, fn func(*cobra.Command)) { - for _, cmd := range root.Commands() { - visitAll(cmd, fn) - } - fn(root) -} - -func noArgs(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return nil - } - return fmt.Errorf( - "docker: '%s' is not a docker command.\nSee 'docker --help'", args[0]) -} - func main() { dockerCli, err := command.NewDockerCli() if err != nil { @@ -193,18 +255,6 @@ func main() { } } -func dockerPreRun(opts *cliflags.ClientOptions) { - cliflags.SetLogLevel(opts.Common.LogLevel) - - if opts.ConfigDir != "" { - cliconfig.SetDir(opts.ConfigDir) - } - - if opts.Common.Debug { - debug.Enable() - } -} - type versionDetails interface { Client() client.APIClient ClientInfo() command.ClientInfo diff --git a/docker.Makefile b/docker.Makefile index 19bbe02b6b2d..6de7683c645b 100644 --- a/docker.Makefile +++ b/docker.Makefile @@ -56,6 +56,9 @@ binary: build_binary_native_image ## build the CLI build: binary ## alias for binary +plugins: build_binary_native_image ## build the CLI plugin examples + docker run --rm $(ENVVARS) $(MOUNTS) $(BINARY_NATIVE_IMAGE_NAME) ./scripts/build/plugins + .PHONY: clean clean: build_docker_image ## clean build artifacts docker run --rm $(ENVVARS) $(MOUNTS) $(DEV_DOCKER_IMAGE_NAME) make clean @@ -76,10 +79,18 @@ cross: build_cross_image ## build the CLI for macOS and Windows binary-windows: build_cross_image ## build the CLI for Windows docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ +.PHONY: plugins-windows +plugins-windows: build_cross_image ## build the example CLI plugins for Windows + docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ + .PHONY: binary-osx binary-osx: build_cross_image ## build the CLI for macOS docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ +.PHONY: plugins-osx +plugins-osx: build_cross_image ## build the example CLI plugins for macOS + docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ + .PHONY: dev dev: build_docker_image ## start a build container in interactive mode for in-container development docker run -ti --rm $(ENVVARS) $(MOUNTS) \ diff --git a/dockerfiles/Dockerfile.e2e b/dockerfiles/Dockerfile.e2e index 68368c097029..eedda3b7f59d 100644 --- a/dockerfiles/Dockerfile.e2e +++ b/dockerfiles/Dockerfile.e2e @@ -38,5 +38,6 @@ ARG VERSION ARG GITCOMMIT ENV VERSION=${VERSION} GITCOMMIT=${GITCOMMIT} RUN ./scripts/build/binary +RUN ./scripts/build/plugins e2e/cli-plugins/plugins/* CMD ./scripts/test/e2e/entry diff --git a/docs/extend/cli_plugins.md b/docs/extend/cli_plugins.md new file mode 100644 index 000000000000..a99204ca583d --- /dev/null +++ b/docs/extend/cli_plugins.md @@ -0,0 +1,98 @@ +--- +description: "Writing Docker CLI Plugins" +keywords: "docker, cli plugin" +--- + + + +# Docker CLI Plugin Spec + +The `docker` CLI supports adding additional top-level subcommands as +additional out-of-process commands which can be installed +independently. These plugins run on the client side and should not be +confused with "plugins" which run on the server. + +This document contains information for authors of such plugins. + +## Requirements for CLI Plugins + +### Naming + +A valid CLI plugin name consists only of lower case letters `a-z` +and the digits `0-9`. The leading character must be a letter. A valid +name therefore would match the regex `^[a-z][a-z0-9]*$`. + +The binary implementing a plugin must be named `docker-$name` where +`$name` is the name of the plugin. On Windows a `.exe` suffix is +mandatory. + +## Required sub-commands + +A CLI plugin must support being invoked in at least these two ways: + +* `docker-$name docker-cli-plugin-metadata` -- outputs metadata about + the plugin. +* `docker-$name [GLOBAL OPTIONS] $name [OPTIONS AND FURTHER SUB + COMMANDS]` -- the primary entry point to the plugin's functionality. + +A plugin may implement other subcommands but these will never be +invoked by the current Docker CLI. However doing so is strongly +discouraged: new subcommands may be added in the future without +consideration for additional non-specified subcommands which may be +used by plugins in the field. + +### The `docker-cli-plugin-metadata` subcommand + +When invoked in this manner the plugin must produce a JSON object +(and nothing else) on its standard output and exit success (0). + +The JSON object has the following defined keys: +* `SchemaVersion` (_string_) mandatory: must contain precisely "0.1.0". +* `Vendor` (_string_) mandatory: contains the name of the plugin vendor/author. May be truncated to 11 characters in some display contexts. +* `ShortDescription` (_string_) optional: a short description of the plugin, suitable for a single line help message. +* `Version` (_string_) optional: the version of the plugin, this is considered to be an opaque string by the core and therefore has no restrictions on its syntax. +* `URL` (_string_) optional: a pointer to the plugin's web page. + +A binary which does not correctly output the metadata +(e.g. syntactically invalid, missing mandatory keys etc) is not +considered a valid CLI plugin and will not be run. + +### The primary entry point subcommand + +This is the entry point for actually running the plugin. It maybe have +options or further subcommands. + +#### Required global options + +A plugin is required to support all of the global options of the +top-level CLI, i.e. those listed by `man docker 1` with the exception +of `-v`. + +## Installation + +Plugins distributed in packages for system wide installation on +Unix(-like) systems should be installed in either +`/usr/lib/docker/cli-plugins` or `/usr/libexec/docker/cli-plugins` +depending on which of `/usr/lib` and `/usr/libexec` is usual on that +system. System Administrators may also choose to manually install into +the `/usr/local/lib` or `/usr/local/libexec` equivalents but packages +should not do so. + +Plugins distributed on Windows for system wide installation should be +installed in `%PROGRAMDATA%\Docker\cli-plugins`. + +User's may on all systems install plugins into `~/.docker/cli-plugins`. + +## Implementing a plugin in Go + +When writing a plugin in Go the easiest way to meet the above +requirements is to simply call the +`github.com/docker/cli/cli-plugins/plugin.Run` method from your `main` +function to instantiate the plugin. diff --git a/e2e/cli-plugins/help_test.go b/e2e/cli-plugins/help_test.go new file mode 100644 index 000000000000..6ecdd4c7f087 --- /dev/null +++ b/e2e/cli-plugins/help_test.go @@ -0,0 +1,91 @@ +package cliplugins + +import ( + "bufio" + "regexp" + "strings" + "testing" + + "gotest.tools/assert" + is "gotest.tools/assert/cmp" + "gotest.tools/icmd" +) + +// TestGlobalHelp ensures correct behaviour when running `docker help` +func TestGlobalHelp(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("help")) + res.Assert(t, icmd.Expected{ + ExitCode: 0, + }) + assert.Assert(t, is.Equal(res.Stderr(), "")) + scanner := bufio.NewScanner(strings.NewReader(res.Stdout())) + + // Instead of baking in the full current output of `docker + // help`, which can be expected to change regularly, bake in + // some checkpoints. Key things we are looking for: + // + // - The top-level description + // - Each of the main headings + // - Some builtin commands under the main headings + // - The `helloworld` plugin in the appropriate place + // - The `badmeta` plugin under the "Invalid Plugins" heading. + // + // Regexps are needed because the width depends on `unix.TIOCGWINSZ` or similar. + helloworldre := regexp.MustCompile(`^ helloworld\s+\(Docker Inc\.\)\s+A basic Hello World plugin for tests$`) + badmetare := regexp.MustCompile(`^ badmeta\s+invalid metadata: invalid character 'i' looking for beginning of object key string$`) + var helloworldcount, badmetacount int + for _, expected := range []*regexp.Regexp{ + regexp.MustCompile(`^A self-sufficient runtime for containers$`), + regexp.MustCompile(`^Management Commands:$`), + regexp.MustCompile(`^ container\s+Manage containers$`), + regexp.MustCompile(`^Commands:$`), + regexp.MustCompile(`^ create\s+Create a new container$`), + helloworldre, + regexp.MustCompile(`^ ps\s+List containers$`), + regexp.MustCompile(`^Invalid Plugins:$`), + badmetare, + nil, // scan to end of input rather than stopping at badmetare + } { + var found bool + for scanner.Scan() { + text := scanner.Text() + if helloworldre.MatchString(text) { + helloworldcount++ + } + if badmetare.MatchString(text) { + badmetacount++ + } + + if expected != nil && expected.MatchString(text) { + found = true + break + } + } + assert.Assert(t, expected == nil || found, "Did not find match for %q in `docker help` output", expected) + } + // We successfully scanned all the input + assert.Assert(t, !scanner.Scan()) + assert.NilError(t, scanner.Err()) + // Plugins should only be listed once. + assert.Assert(t, is.Equal(helloworldcount, 1)) + assert.Assert(t, is.Equal(badmetacount, 1)) + + // Running with `--help` should produce the same. + res2 := icmd.RunCmd(run("--help")) + res2.Assert(t, icmd.Expected{ + ExitCode: 0, + }) + assert.Assert(t, is.Equal(res2.Stdout(), res.Stdout())) + assert.Assert(t, is.Equal(res2.Stderr(), "")) + + // Running just `docker` (without `help` nor `--help`) should produce the same thing, except on Stderr. + res2 = icmd.RunCmd(run()) + res2.Assert(t, icmd.Expected{ + ExitCode: 0, + }) + assert.Assert(t, is.Equal(res2.Stdout(), "")) + assert.Assert(t, is.Equal(res2.Stderr(), res.Stdout())) +} diff --git a/e2e/cli-plugins/plugins/badmeta/main.go b/e2e/cli-plugins/plugins/badmeta/main.go new file mode 100644 index 000000000000..10bd0eff7263 --- /dev/null +++ b/e2e/cli-plugins/plugins/badmeta/main.go @@ -0,0 +1,19 @@ +package main + +// This is not a real plugin, but just returns malformated metadata +// from the subcommand and otherwise exits with failure. + +import ( + "fmt" + "os" + + "github.com/docker/cli/cli-plugins/manager" +) + +func main() { + if len(os.Args) == 2 && os.Args[1] == manager.MetadataSubcommandName { + fmt.Println(`{invalid-json}`) + os.Exit(0) + } + os.Exit(1) +} diff --git a/e2e/cli-plugins/run_test.go b/e2e/cli-plugins/run_test.go new file mode 100644 index 000000000000..e12c51025d72 --- /dev/null +++ b/e2e/cli-plugins/run_test.go @@ -0,0 +1,196 @@ +package cliplugins + +import ( + "testing" + + "gotest.tools/assert" + is "gotest.tools/assert/cmp" + "gotest.tools/golden" + "gotest.tools/icmd" +) + +// TestRunNonexisting ensures correct behaviour when running a nonexistent plugin. +func TestRunNonexisting(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("nonexistent")) + res.Assert(t, icmd.Expected{ + ExitCode: 1, + }) + assert.Assert(t, is.Equal(res.Stdout(), "")) + golden.Assert(t, res.Stderr(), "docker-nonexistent-err.golden") +} + +// TestHelpNonexisting ensures correct behaviour when invoking help on a nonexistent plugin. +func TestHelpNonexisting(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("help", "nonexistent")) + res.Assert(t, icmd.Expected{ + ExitCode: 1, + }) + assert.Assert(t, is.Equal(res.Stdout(), "")) + golden.Assert(t, res.Stderr(), "docker-help-nonexistent-err.golden") +} + +// TestNonexistingHelp ensures correct behaviour when invoking a +// nonexistent plugin with `--help`. +func TestNonexistingHelp(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("nonexistent", "--help")) + res.Assert(t, icmd.Expected{ + ExitCode: 0, + // This should actually be the whole docker help + // output, so spot check instead having of a golden + // with everything in, which will change too frequently. + Out: "Usage: docker [OPTIONS] COMMAND\n\nA self-sufficient runtime for containers", + }) +} + +// TestRunBad ensures correct behaviour when running an existent but invalid plugin +func TestRunBad(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("badmeta")) + res.Assert(t, icmd.Expected{ + ExitCode: 1, + }) + assert.Assert(t, is.Equal(res.Stdout(), "")) + golden.Assert(t, res.Stderr(), "docker-badmeta-err.golden") +} + +// TestHelpBad ensures correct behaviour when invoking help on a existent but invalid plugin. +func TestHelpBad(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("help", "badmeta")) + res.Assert(t, icmd.Expected{ + ExitCode: 1, + }) + assert.Assert(t, is.Equal(res.Stdout(), "")) + golden.Assert(t, res.Stderr(), "docker-help-badmeta-err.golden") +} + +// TestBadHelp ensures correct behaviour when invoking an +// existent but invalid plugin with `--help`. +func TestBadHelp(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("badmeta", "--help")) + res.Assert(t, icmd.Expected{ + ExitCode: 0, + // This should be literally the whole docker help + // output, so spot check instead of a golden with + // everything in which will change all the time. + Out: "Usage: docker [OPTIONS] COMMAND\n\nA self-sufficient runtime for containers", + }) +} + +// TestRunGood ensures correct behaviour when running a valid plugin +func TestRunGood(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("helloworld")) + res.Assert(t, icmd.Expected{ + ExitCode: 0, + Out: "Hello World!", + }) +} + +// TestHelpGood ensures correct behaviour when invoking help on a +// valid plugin. A global argument is included to ensure it does not +// interfere. +func TestHelpGood(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("-D", "help", "helloworld")) + res.Assert(t, icmd.Success) + golden.Assert(t, res.Stdout(), "docker-help-helloworld.golden") + assert.Assert(t, is.Equal(res.Stderr(), "")) +} + +// TestGoodHelp ensures correct behaviour when calling a valid plugin +// with `--help`. A global argument is used to ensure it does not +// interfere. +func TestGoodHelp(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("-D", "helloworld", "--help")) + res.Assert(t, icmd.Success) + // This is the same golden file as `TestHelpGood`, above. + golden.Assert(t, res.Stdout(), "docker-help-helloworld.golden") + assert.Assert(t, is.Equal(res.Stderr(), "")) +} + +// TestRunGoodSubcommand ensures correct behaviour when running a valid plugin with a subcommand +func TestRunGoodSubcommand(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("helloworld", "goodbye")) + res.Assert(t, icmd.Expected{ + ExitCode: 0, + Out: "Goodbye World!", + }) +} + +// TestRunGoodArgument ensures correct behaviour when running a valid plugin with an `--argument`. +func TestRunGoodArgument(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("helloworld", "--who", "Cleveland")) + res.Assert(t, icmd.Expected{ + ExitCode: 0, + Out: "Hello Cleveland!", + }) +} + +// TestHelpGoodSubcommand ensures correct behaviour when invoking help on a +// valid plugin subcommand. A global argument is included to ensure it does not +// interfere. +func TestHelpGoodSubcommand(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("-D", "help", "helloworld", "goodbye")) + res.Assert(t, icmd.Success) + golden.Assert(t, res.Stdout(), "docker-help-helloworld-goodbye.golden") + assert.Assert(t, is.Equal(res.Stderr(), "")) +} + +// TestGoodSubcommandHelp ensures correct behaviour when calling a valid plugin +// with a subcommand and `--help`. A global argument is used to ensure it does not +// interfere. +func TestGoodSubcommandHelp(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("-D", "helloworld", "goodbye", "--help")) + res.Assert(t, icmd.Success) + // This is the same golden file as `TestHelpGoodSubcommand`, above. + golden.Assert(t, res.Stdout(), "docker-help-helloworld-goodbye.golden") + assert.Assert(t, is.Equal(res.Stderr(), "")) +} + +// TestCliInitialized tests the code paths which ensure that the Cli +// object is initialized even if the plugin uses PersistentRunE +func TestCliInitialized(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("helloworld", "apiversion")) + res.Assert(t, icmd.Success) + assert.Assert(t, res.Stdout() != "") + assert.Assert(t, is.Equal(res.Stderr(), "")) +} diff --git a/e2e/cli-plugins/testdata/docker-badmeta-err.golden b/e2e/cli-plugins/testdata/docker-badmeta-err.golden new file mode 100644 index 000000000000..df2344638af7 --- /dev/null +++ b/e2e/cli-plugins/testdata/docker-badmeta-err.golden @@ -0,0 +1,2 @@ +docker: 'badmeta' is not a docker command. +See 'docker --help' diff --git a/e2e/cli-plugins/testdata/docker-help-badmeta-err.golden b/e2e/cli-plugins/testdata/docker-help-badmeta-err.golden new file mode 100644 index 000000000000..13a827f4064e --- /dev/null +++ b/e2e/cli-plugins/testdata/docker-help-badmeta-err.golden @@ -0,0 +1 @@ +unknown help topic: badmeta diff --git a/e2e/cli-plugins/testdata/docker-help-helloworld-goodbye.golden b/e2e/cli-plugins/testdata/docker-help-helloworld-goodbye.golden new file mode 100644 index 000000000000..d789f7997f78 --- /dev/null +++ b/e2e/cli-plugins/testdata/docker-help-helloworld-goodbye.golden @@ -0,0 +1,4 @@ + +Usage: docker helloworld goodbye + +Say Goodbye instead of Hello diff --git a/e2e/cli-plugins/testdata/docker-help-helloworld.golden b/e2e/cli-plugins/testdata/docker-help-helloworld.golden new file mode 100644 index 000000000000..6ff36bc64eae --- /dev/null +++ b/e2e/cli-plugins/testdata/docker-help-helloworld.golden @@ -0,0 +1,13 @@ + +Usage: docker helloworld [OPTIONS] COMMAND + +A basic Hello World plugin for tests + +Options: + --who string Who are we addressing? (default "World") + +Commands: + apiversion Print the API version of the server + goodbye Say Goodbye instead of Hello + +Run 'docker helloworld COMMAND --help' for more information on a command. diff --git a/e2e/cli-plugins/testdata/docker-help-nonexistent-err.golden b/e2e/cli-plugins/testdata/docker-help-nonexistent-err.golden new file mode 100644 index 000000000000..7a151caa3eb5 --- /dev/null +++ b/e2e/cli-plugins/testdata/docker-help-nonexistent-err.golden @@ -0,0 +1 @@ +unknown help topic: nonexistent diff --git a/e2e/cli-plugins/testdata/docker-nonexistent-err.golden b/e2e/cli-plugins/testdata/docker-nonexistent-err.golden new file mode 100644 index 000000000000..f2265f6b9ecd --- /dev/null +++ b/e2e/cli-plugins/testdata/docker-nonexistent-err.golden @@ -0,0 +1,2 @@ +docker: 'nonexistent' is not a docker command. +See 'docker --help' diff --git a/e2e/cli-plugins/util_test.go b/e2e/cli-plugins/util_test.go new file mode 100644 index 000000000000..0ab1aa956687 --- /dev/null +++ b/e2e/cli-plugins/util_test.go @@ -0,0 +1,24 @@ +package cliplugins + +import ( + "fmt" + "os" + "testing" + + "gotest.tools/fs" + "gotest.tools/icmd" +) + +func prepare(t *testing.T) (func(args ...string) icmd.Cmd, func()) { + cfg := fs.NewDir(t, "plugin-test", + fs.WithFile("config.json", fmt.Sprintf(`{"cliPluginsExtraDirs": [%q]}`, os.Getenv("DOCKER_CLI_E2E_PLUGINS_EXTRA_DIRS"))), + ) + run := func(args ...string) icmd.Cmd { + return icmd.Command("docker", append([]string{"--config", cfg.Path()}, args...)...) + } + cleanup := func() { + cfg.Remove() + } + return run, cleanup + +} diff --git a/scripts/build/.variables b/scripts/build/.variables index 208f44c3164d..8b13cd55c86c 100755 --- a/scripts/build/.variables +++ b/scripts/build/.variables @@ -8,15 +8,15 @@ BUILDTIME=${BUILDTIME:-$(date --utc --rfc-3339 ns 2> /dev/null | sed -e 's/ /T/' PLATFORM_LDFLAGS= if test -n "${PLATFORM}"; then - PLATFORM_LDFLAGS="-X \"github.com/docker/cli/cli.PlatformName=${PLATFORM}\"" + PLATFORM_LDFLAGS="-X \"github.com/docker/cli/cli/version.PlatformName=${PLATFORM}\"" fi export LDFLAGS="\ -w \ ${PLATFORM_LDFLAGS} \ - -X \"github.com/docker/cli/cli.GitCommit=${GITCOMMIT}\" \ - -X \"github.com/docker/cli/cli.BuildTime=${BUILDTIME}\" \ - -X \"github.com/docker/cli/cli.Version=${VERSION}\" \ + -X \"github.com/docker/cli/cli/version.GitCommit=${GITCOMMIT}\" \ + -X \"github.com/docker/cli/cli/version.BuildTime=${BUILDTIME}\" \ + -X \"github.com/docker/cli/cli/version.Version=${VERSION}\" \ ${LDFLAGS:-} \ " diff --git a/scripts/build/plugins b/scripts/build/plugins new file mode 100755 index 000000000000..fce2689cdc25 --- /dev/null +++ b/scripts/build/plugins @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# +# Build a static binary for the host OS/ARCH +# + +set -eu -o pipefail + +source ./scripts/build/.variables + +mkdir -p "build/plugins-${GOOS}-${GOARCH}" +for p in cli-plugins/examples/* "$@" ; do + [ -d "$p" ] || continue + + n=$(basename "$p") + + TARGET="build/plugins-${GOOS}-${GOARCH}/docker-${n}" + + echo "Building statically linked $TARGET" + export CGO_ENABLED=0 + go build -o "${TARGET}" --ldflags "${LDFLAGS}" "github.com/docker/cli/${p}" +done diff --git a/scripts/build/plugins-osx b/scripts/build/plugins-osx new file mode 100755 index 000000000000..8e870f4f05bb --- /dev/null +++ b/scripts/build/plugins-osx @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# +# Build a static binary for the host OS/ARCH +# + +set -eu -o pipefail + +source ./scripts/build/.variables + +export CGO_ENABLED=1 +export GOOS=darwin +export GOARCH=amd64 +export CC=o64-clang +export CXX=o64-clang++ +export LDFLAGS="$LDFLAGS -linkmode external -s" +export LDFLAGS_STATIC_DOCKER='-extld='${CC} + +source ./scripts/build/plugins diff --git a/scripts/build/plugins-windows b/scripts/build/plugins-windows new file mode 100755 index 000000000000..607ad6dc12a7 --- /dev/null +++ b/scripts/build/plugins-windows @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# Build a static binary for the host OS/ARCH +# + +set -eu -o pipefail + +source ./scripts/build/.variables +export CC=x86_64-w64-mingw32-gcc +export CGO_ENABLED=1 +export GOOS=windows +export GOARCH=amd64 + +source ./scripts/build/plugins diff --git a/scripts/test/e2e/run b/scripts/test/e2e/run index 7a401db9ce1c..d494019419fb 100755 --- a/scripts/test/e2e/run +++ b/scripts/test/e2e/run @@ -69,6 +69,7 @@ function runtests { TEST_SKIP_PLUGIN_TESTS="${SKIP_PLUGIN_TESTS-}" \ GOPATH="$GOPATH" \ PATH="$PWD/build/:/usr/bin" \ + DOCKER_CLI_E2E_PLUGINS_EXTRA_DIRS="$PWD/build/plugins-linux-amd64" \ "$(which go)" test -v ./e2e/... ${TESTFLAGS-} }