Merge pull request #1564 from ijc/plugins

Basic framework for writing and running CLI plugins
This commit is contained in:
Silvin Lubecki 2019-01-31 17:44:46 +01:00 committed by GitHub
commit 2e5639da02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1906 additions and 78 deletions

View File

@ -34,6 +34,10 @@ binary: ## build executable for Linux
@echo "WARNING: binary creates a Linux executable. Use cross for macOS or Windows." @echo "WARNING: binary creates a Linux executable. Use cross for macOS or Windows."
./scripts/build/binary ./scripts/build/binary
.PHONY: plugins
plugins: ## build example CLI plugins
./scripts/build/plugins
.PHONY: cross .PHONY: cross
cross: ## build executable for macOS and Windows cross: ## build executable for macOS and Windows
./scripts/build/cross ./scripts/build/cross
@ -42,10 +46,18 @@ cross: ## build executable for macOS and Windows
binary-windows: ## build executable for Windows binary-windows: ## build executable for Windows
./scripts/build/windows ./scripts/build/windows
.PHONY: plugins-windows
plugins-windows: ## build example CLI plugins for Windows
./scripts/build/plugins-windows
.PHONY: binary-osx .PHONY: binary-osx
binary-osx: ## build executable for macOS binary-osx: ## build executable for macOS
./scripts/build/osx ./scripts/build/osx
.PHONY: plugins-osx
plugins-osx: ## build example CLI plugins for macOS
./scripts/build/plugins-osx
.PHONY: dynbinary .PHONY: dynbinary
dynbinary: ## build dynamically linked binary dynbinary: ## build dynamically linked binary
./scripts/build/dynbinary ./scripts/build/dynbinary

View File

@ -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",
})
}

View File

@ -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()
}

View File

@ -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())
}

View File

@ -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
}

View File

@ -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...)}
}

View File

@ -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))
}

View File

@ -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)
}

View File

@ -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))
}

View File

@ -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",
}

View File

@ -0,0 +1,10 @@
package manager
import (
"os"
"path/filepath"
)
var defaultSystemPluginDirs = []string{
filepath.Join(os.Getenv("ProgramData"), "Docker", "cli-plugins"),
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -0,0 +1,10 @@
// +build !windows
package manager
func trimExeSuffix(s string) (string, error) {
return s, nil
}
func addExeSuffix(s string) string {
return s
}

View File

@ -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"
}

View File

@ -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
}

View File

@ -4,29 +4,65 @@ import (
"fmt" "fmt"
"strings" "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/docker/docker/pkg/term"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
// SetupRootCommand sets default usage, help, and error handling for the // setupCommonRootCommand contains the setup common to
// root command. // SetupRootCommand and SetupPluginRootCommand.
func SetupRootCommand(rootCmd *cobra.Command) { 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("hasSubCommands", hasSubCommands)
cobra.AddTemplateFunc("hasManagementSubCommands", hasManagementSubCommands) cobra.AddTemplateFunc("hasManagementSubCommands", hasManagementSubCommands)
cobra.AddTemplateFunc("hasInvalidPlugins", hasInvalidPlugins)
cobra.AddTemplateFunc("operationSubCommands", operationSubCommands) cobra.AddTemplateFunc("operationSubCommands", operationSubCommands)
cobra.AddTemplateFunc("managementSubCommands", managementSubCommands) cobra.AddTemplateFunc("managementSubCommands", managementSubCommands)
cobra.AddTemplateFunc("invalidPlugins", invalidPlugins)
cobra.AddTemplateFunc("wrappedFlagUsages", wrappedFlagUsages) 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.SetUsageTemplate(usageTemplate)
rootCmd.SetHelpTemplate(helpTemplate) rootCmd.SetHelpTemplate(helpTemplate)
rootCmd.SetFlagErrorFunc(FlagErrorFunc) rootCmd.SetFlagErrorFunc(FlagErrorFunc)
rootCmd.SetHelpCommand(helpCommand) 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.SetVersionTemplate("Docker version {{.Version}}\n")
rootCmd.PersistentFlags().BoolP("help", "h", false, "Print usage") rootCmd.PersistentFlags().BoolP("help", "h", false, "Print usage")
rootCmd.PersistentFlags().MarkShorthandDeprecated("help", "please use --help") rootCmd.PersistentFlags().MarkShorthandDeprecated("help", "please use --help")
rootCmd.PersistentFlags().Lookup("help").Hidden = true 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 // 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{ var helpCommand = &cobra.Command{
Use: "help [command]", Use: "help [command]",
Short: "Help about the 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 { func hasSubCommands(cmd *cobra.Command) bool {
return len(operationSubCommands(cmd)) > 0 return len(operationSubCommands(cmd)) > 0
} }
@ -71,9 +130,16 @@ func hasManagementSubCommands(cmd *cobra.Command) bool {
return len(managementSubCommands(cmd)) > 0 return len(managementSubCommands(cmd)) > 0
} }
func hasInvalidPlugins(cmd *cobra.Command) bool {
return len(invalidPlugins(cmd)) > 0
}
func operationSubCommands(cmd *cobra.Command) []*cobra.Command { func operationSubCommands(cmd *cobra.Command) []*cobra.Command {
cmds := []*cobra.Command{} cmds := []*cobra.Command{}
for _, sub := range cmd.Commands() { for _, sub := range cmd.Commands() {
if isPlugin(sub) && invalidPluginReason(sub) != "" {
continue
}
if sub.IsAvailableCommand() && !sub.HasSubCommands() { if sub.IsAvailableCommand() && !sub.HasSubCommands() {
cmds = append(cmds, sub) cmds = append(cmds, sub)
} }
@ -89,9 +155,27 @@ func wrappedFlagUsages(cmd *cobra.Command) string {
return cmd.Flags().FlagUsagesWrapped(width - 1) 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 { func managementSubCommands(cmd *cobra.Command) []*cobra.Command {
cmds := []*cobra.Command{} cmds := []*cobra.Command{}
for _, sub := range cmd.Commands() { for _, sub := range cmd.Commands() {
if isPlugin(sub) && invalidPluginReason(sub) != "" {
continue
}
if sub.IsAvailableCommand() && sub.HasSubCommands() { if sub.IsAvailableCommand() && sub.HasSubCommands() {
cmds = append(cmds, sub) cmds = append(cmds, sub)
} }
@ -99,6 +183,23 @@ func managementSubCommands(cmd *cobra.Command) []*cobra.Command {
return cmds 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: var usageTemplate = `Usage:
{{- if not .HasSubCommands}} {{.UseLine}}{{end}} {{- if not .HasSubCommands}} {{.UseLine}}{{end}}
@ -129,7 +230,7 @@ Options:
Management Commands: Management Commands:
{{- range managementSubCommands . }} {{- range managementSubCommands . }}
{{rpad .Name .NamePadding }} {{.Short}} {{rpad .Name .NamePadding }} {{ if isFirstLevelCommand .}}{{commandVendor .}} {{ end}}{{.Short}}
{{- end}} {{- end}}
{{- end}} {{- end}}
@ -138,10 +239,20 @@ Management Commands:
Commands: Commands:
{{- range operationSubCommands . }} {{- range operationSubCommands . }}
{{rpad .Name .NamePadding }} {{.Short}} {{rpad .Name .NamePadding }} {{ if isFirstLevelCommand .}}{{commandVendor .}} {{ end}}{{.Short}}
{{- end}} {{- end}}
{{- end}} {{- end}}
{{- if hasInvalidPlugins . }}
Invalid Plugins:
{{- range invalidPlugins . }}
{{rpad .Name .NamePadding }} {{invalidPluginReason .}}
{{- end}}
{{- end}}
{{- if .HasSubCommands }} {{- if .HasSubCommands }}
Run '{{.CommandPath}} COMMAND --help' for more information on a command. Run '{{.CommandPath}} COMMAND --help' for more information on a command.

78
cli/cobra_test.go Normal file
View File

@ -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{}))
}

View File

@ -8,7 +8,6 @@ import (
"runtime" "runtime"
"strconv" "strconv"
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config"
cliconfig "github.com/docker/cli/cli/config" cliconfig "github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/config/configfile"
@ -16,11 +15,13 @@ import (
"github.com/docker/cli/cli/context/docker" "github.com/docker/cli/cli/context/docker"
kubcontext "github.com/docker/cli/cli/context/kubernetes" kubcontext "github.com/docker/cli/cli/context/kubernetes"
"github.com/docker/cli/cli/context/store" "github.com/docker/cli/cli/context/store"
"github.com/docker/cli/cli/debug"
cliflags "github.com/docker/cli/cli/flags" cliflags "github.com/docker/cli/cli/flags"
manifeststore "github.com/docker/cli/cli/manifest/store" manifeststore "github.com/docker/cli/cli/manifest/store"
registryclient "github.com/docker/cli/cli/registry/client" registryclient "github.com/docker/cli/cli/registry/client"
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"github.com/docker/cli/cli/trust" "github.com/docker/cli/cli/trust"
"github.com/docker/cli/cli/version"
"github.com/docker/cli/internal/containerizedengine" "github.com/docker/cli/internal/containerizedengine"
dopts "github.com/docker/cli/opts" dopts "github.com/docker/cli/opts"
clitypes "github.com/docker/cli/types" 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 // Initialize the dockerCli runs initialization that must happen after command
// line flags are parsed. // line flags are parsed.
func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error { 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) cli.configFile = cliconfig.LoadDefaultConfigFile(cli.err)
var err error var err error
cli.contextStore = store.New(cliconfig.ContextStoreDir(), cli.contextStoreConfig) 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 // UserAgent returns the user agent string used for making API requests
func UserAgent() string { 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: // resolveContextName resolves the current context name with the following rules:

View File

@ -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))
}

View File

@ -8,6 +8,7 @@ import (
"strings" "strings"
"github.com/docker/cli/cli" "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"
"github.com/docker/cli/cli/debug" "github.com/docker/cli/cli/debug"
"github.com/docker/cli/templates" "github.com/docker/cli/templates"
@ -23,6 +24,7 @@ type infoOptions struct {
type clientInfo struct { type clientInfo struct {
Debug bool Debug bool
Plugins []pluginmanager.Plugin
Warnings []string Warnings []string
} }
@ -47,7 +49,7 @@ func NewInfoCommand(dockerCli command.Cli) *cobra.Command {
Short: "Display system-wide information", Short: "Display system-wide information",
Args: cli.NoArgs, Args: cli.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { 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 return cmd
} }
func runInfo(dockerCli command.Cli, opts *infoOptions) error { func runInfo(cmd *cobra.Command, dockerCli command.Cli, opts *infoOptions) error {
var info info var info info
ctx := context.Background() ctx := context.Background()
@ -71,6 +73,11 @@ func runInfo(dockerCli command.Cli, opts *infoOptions) error {
info.ClientInfo = &clientInfo{ info.ClientInfo = &clientInfo{
Debug: debug.IsEnabled(), 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 == "" { if opts.format == "" {
return prettyPrintInfo(dockerCli, info) return prettyPrintInfo(dockerCli, info)
@ -109,6 +116,17 @@ func prettyPrintInfo(dockerCli command.Cli, info info) error {
func prettyPrintClientInfo(dockerCli command.Cli, info clientInfo) error { func prettyPrintClientInfo(dockerCli command.Cli, info clientInfo) error {
fmt.Fprintln(dockerCli.Out(), " Debug Mode:", info.Debug) 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 { if len(info.Warnings) > 0 {
fmt.Fprintln(dockerCli.Err(), strings.Join(info.Warnings, "\n")) 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 { 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) tmpl, err := templates.Parse(format)
if err != nil { if err != nil {
return cli.StatusError{StatusCode: 64, return cli.StatusError{StatusCode: 64,

View File

@ -6,6 +6,7 @@ import (
"testing" "testing"
"time" "time"
pluginmanager "github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/registry" "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) { func TestPrettyPrintInfo(t *testing.T) {
infoWithSwarm := sampleInfoNoSwarm infoWithSwarm := sampleInfoNoSwarm
infoWithSwarm.Swarm = sampleSwarmInfo infoWithSwarm.Swarm = sampleSwarmInfo
@ -228,8 +247,9 @@ func TestPrettyPrintInfo(t *testing.T) {
sampleInfoBadSecurity.SecurityOptions = []string{"foo="} sampleInfoBadSecurity.SecurityOptions = []string{"foo="}
for _, tc := range []struct { for _, tc := range []struct {
doc string doc string
dockerInfo info dockerInfo info
prettyGolden string prettyGolden string
warningsGolden string warningsGolden string
jsonGolden string jsonGolden string
@ -245,6 +265,19 @@ func TestPrettyPrintInfo(t *testing.T) {
jsonGolden: "docker-info-no-swarm", 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", doc: "info with swarm",
dockerInfo: info{ dockerInfo: info{
Info: &infoWithSwarm, Info: &infoWithSwarm,

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

View File

@ -0,0 +1 @@
WARNING: Plugin "/path/to/docker-badplugin" is not valid: something wrong

View File

@ -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

View File

@ -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}}

View File

@ -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}}

View File

@ -12,6 +12,7 @@ import (
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
kubecontext "github.com/docker/cli/cli/context/kubernetes" kubecontext "github.com/docker/cli/cli/context/kubernetes"
"github.com/docker/cli/cli/version"
"github.com/docker/cli/kubernetes" "github.com/docker/cli/kubernetes"
"github.com/docker/cli/templates" "github.com/docker/cli/templates"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
@ -135,13 +136,13 @@ func runVersion(dockerCli command.Cli, opts *versionOptions) error {
vd := versionInfo{ vd := versionInfo{
Client: clientVersion{ Client: clientVersion{
Platform: struct{ Name string }{cli.PlatformName}, Platform: struct{ Name string }{version.PlatformName},
Version: cli.Version, Version: version.Version,
APIVersion: dockerCli.Client().ClientVersion(), APIVersion: dockerCli.Client().ClientVersion(),
DefaultAPIVersion: dockerCli.DefaultVersion(), DefaultAPIVersion: dockerCli.DefaultVersion(),
GoVersion: runtime.Version(), GoVersion: runtime.Version(),
GitCommit: cli.GitCommit, GitCommit: version.GitCommit,
BuildTime: reformatDate(cli.BuildTime), BuildTime: reformatDate(version.BuildTime),
Os: runtime.GOOS, Os: runtime.GOOS,
Arch: runtime.GOARCH, Arch: runtime.GOARCH,
Experimental: dockerCli.ClientInfo().HasExperimental, Experimental: dockerCli.ClientInfo().HasExperimental,

View File

@ -46,6 +46,11 @@ func SetDir(dir string) {
configDir = dir 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 // LegacyLoadFromReader is a convenience function that creates a ConfigFile object from
// a non-nested reader // a non-nested reader
func LegacyLoadFromReader(configData io.Reader) (*configfile.ConfigFile, error) { func LegacyLoadFromReader(configData io.Reader) (*configfile.ConfigFile, error) {

View File

@ -548,3 +548,17 @@ func TestLoadDefaultConfigFile(t *testing.T) {
assert.Check(t, is.DeepEqual(expected, configFile)) 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)
}

View File

@ -49,6 +49,7 @@ type ConfigFile struct {
StackOrchestrator string `json:"stackOrchestrator,omitempty"` StackOrchestrator string `json:"stackOrchestrator,omitempty"`
Kubernetes *KubernetesConfig `json:"kubernetes,omitempty"` Kubernetes *KubernetesConfig `json:"kubernetes,omitempty"`
CurrentContext string `json:"currentContext,omitempty"` CurrentContext string `json:"currentContext,omitempty"`
CLIPluginsExtraDirs []string `json:"cliPluginsExtraDirs,omitempty"`
} }
// ProxyConfig contains proxy configuration settings // ProxyConfig contains proxy configuration settings

View File

@ -1,4 +1,4 @@
package cli package version
// Default build-time variable. // Default build-time variable.
// These values are overridden via ldflags // These values are overridden via ldflags

View File

@ -7,11 +7,11 @@ import (
"strings" "strings"
"github.com/docker/cli/cli" "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"
"github.com/docker/cli/cli/command/commands" "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" cliflags "github.com/docker/cli/cli/flags"
"github.com/docker/cli/cli/version"
"github.com/docker/docker/api/types/versions" "github.com/docker/docker/api/types/versions"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -20,8 +20,11 @@ import (
) )
func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command {
opts := cliflags.NewClientOptions() var (
var flags *pflag.FlagSet opts *cliflags.ClientOptions
flags *pflag.FlagSet
helpCmd *cobra.Command
)
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "docker [OPTIONS] COMMAND [ARG...]", Use: "docker [OPTIONS] COMMAND [ARG...]",
@ -29,49 +32,59 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command {
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,
TraverseChildren: 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 { 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 { PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// flags must be the top-level command flags, not cmd.Flags() // flags must be the top-level command flags, not cmd.Flags()
opts.Common.SetDefaultOptions(flags) opts.Common.SetDefaultOptions(flags)
dockerPreRun(opts)
if err := dockerCli.Initialize(opts); err != nil { if err := dockerCli.Initialize(opts); err != nil {
return err return err
} }
return isSupported(cmd, dockerCli) 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, DisableFlagsInUseLine: true,
} }
cli.SetupRootCommand(cmd) opts, flags, helpCmd = cli.SetupRootCommand(cmd)
flags = cmd.Flags()
flags.BoolP("version", "v", false, "Print version information and quit") 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) setFlagErrorFunc(dockerCli, cmd, flags, opts)
setupHelpCommand(dockerCli, cmd, helpCmd, flags, opts)
setHelpFunc(dockerCli, cmd, flags, opts) setHelpFunc(dockerCli, cmd, flags, opts)
cmd.SetOutput(dockerCli.Out()) cmd.SetOutput(dockerCli.Out())
commands.AddCommands(cmd, dockerCli) commands.AddCommands(cmd, dockerCli)
disableFlagsInUseLine(cmd) cli.DisableFlagsInUseLine(cmd)
setValidateArgs(dockerCli, cmd, flags, opts) setValidateArgs(dockerCli, cmd, flags, opts)
return cmd 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) { 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 // When invoking `docker stack --nonsense`, we need to make sure FlagErrorFunc return appropriate
// output if the feature is not supported. // 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) { func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag.FlagSet, opts *cliflags.ClientOptions) {
defaultHelpFunc := cmd.HelpFunc() defaultHelpFunc := cmd.HelpFunc()
cmd.SetHelpFunc(func(ccmd *cobra.Command, args []string) { 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) ccmd.Println(err)
return 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 { if err := isSupported(ccmd, dockerCli); err != nil {
ccmd.Println(err) ccmd.Println(err)
return return
@ -104,6 +184,7 @@ func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag.
ccmd.Println(err) ccmd.Println(err)
return return
} }
defaultHelpFunc(ccmd, args) 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, // 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. // 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. // 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, // if there is no tags for a command or any of its parent,
// there is no need to wrap the Args validation. // there is no need to wrap the Args validation.
if !hasTags(ccmd) { 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. // when using --help, PersistentPreRun is not called, so initialization is needed.
// flags must be the top-level command flags, not cmd.Flags() // flags must be the top-level command flags, not cmd.Flags()
opts.Common.SetDefaultOptions(flags) opts.Common.SetDefaultOptions(flags)
dockerPreRun(opts)
return dockerCli.Initialize(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() { func main() {
dockerCli, err := command.NewDockerCli() dockerCli, err := command.NewDockerCli()
if err != nil { 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 { type versionDetails interface {
Client() client.APIClient Client() client.APIClient
ClientInfo() command.ClientInfo ClientInfo() command.ClientInfo

View File

@ -56,6 +56,9 @@ binary: build_binary_native_image ## build the CLI
build: binary ## alias for binary 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 .PHONY: clean
clean: build_docker_image ## clean build artifacts clean: build_docker_image ## clean build artifacts
docker run --rm $(ENVVARS) $(MOUNTS) $(DEV_DOCKER_IMAGE_NAME) make clean 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 binary-windows: build_cross_image ## build the CLI for Windows
docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ 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 .PHONY: binary-osx
binary-osx: build_cross_image ## build the CLI for macOS binary-osx: build_cross_image ## build the CLI for macOS
docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ 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 .PHONY: dev
dev: build_docker_image ## start a build container in interactive mode for in-container development dev: build_docker_image ## start a build container in interactive mode for in-container development
docker run -ti --rm $(ENVVARS) $(MOUNTS) \ docker run -ti --rm $(ENVVARS) $(MOUNTS) \

View File

@ -38,5 +38,6 @@ ARG VERSION
ARG GITCOMMIT ARG GITCOMMIT
ENV VERSION=${VERSION} GITCOMMIT=${GITCOMMIT} ENV VERSION=${VERSION} GITCOMMIT=${GITCOMMIT}
RUN ./scripts/build/binary RUN ./scripts/build/binary
RUN ./scripts/build/plugins e2e/cli-plugins/plugins/*
CMD ./scripts/test/e2e/entry CMD ./scripts/test/e2e/entry

View File

@ -0,0 +1,98 @@
---
description: "Writing Docker CLI Plugins"
keywords: "docker, cli plugin"
---
<!-- This file is maintained within the docker/cli GitHub
repository at https://github.com/docker/cli/. Make all
pull requests against that repo. If you see this file in
another repository, consider it read-only there, as it will
periodically be overwritten by the definitive file. Pull
requests which include edits to this file in other repositories
will be rejected.
-->
# 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.

View File

@ -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()))
}

View File

@ -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)
}

196
e2e/cli-plugins/run_test.go Normal file
View File

@ -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(), ""))
}

View File

@ -0,0 +1,2 @@
docker: 'badmeta' is not a docker command.
See 'docker --help'

View File

@ -0,0 +1 @@
unknown help topic: badmeta

View File

@ -0,0 +1,4 @@
Usage: docker helloworld goodbye
Say Goodbye instead of Hello

View File

@ -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.

View File

@ -0,0 +1 @@
unknown help topic: nonexistent

View File

@ -0,0 +1,2 @@
docker: 'nonexistent' is not a docker command.
See 'docker --help'

View File

@ -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
}

View File

@ -8,15 +8,15 @@ BUILDTIME=${BUILDTIME:-$(date --utc --rfc-3339 ns 2> /dev/null | sed -e 's/ /T/'
PLATFORM_LDFLAGS= PLATFORM_LDFLAGS=
if test -n "${PLATFORM}"; then 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 fi
export LDFLAGS="\ export LDFLAGS="\
-w \ -w \
${PLATFORM_LDFLAGS} \ ${PLATFORM_LDFLAGS} \
-X \"github.com/docker/cli/cli.GitCommit=${GITCOMMIT}\" \ -X \"github.com/docker/cli/cli/version.GitCommit=${GITCOMMIT}\" \
-X \"github.com/docker/cli/cli.BuildTime=${BUILDTIME}\" \ -X \"github.com/docker/cli/cli/version.BuildTime=${BUILDTIME}\" \
-X \"github.com/docker/cli/cli.Version=${VERSION}\" \ -X \"github.com/docker/cli/cli/version.Version=${VERSION}\" \
${LDFLAGS:-} \ ${LDFLAGS:-} \
" "

21
scripts/build/plugins Executable file
View File

@ -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

18
scripts/build/plugins-osx Executable file
View File

@ -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

14
scripts/build/plugins-windows Executable file
View File

@ -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

View File

@ -69,6 +69,7 @@ function runtests {
TEST_SKIP_PLUGIN_TESTS="${SKIP_PLUGIN_TESTS-}" \ TEST_SKIP_PLUGIN_TESTS="${SKIP_PLUGIN_TESTS-}" \
GOPATH="$GOPATH" \ GOPATH="$GOPATH" \
PATH="$PWD/build/:/usr/bin" \ PATH="$PWD/build/:/usr/bin" \
DOCKER_CLI_E2E_PLUGINS_EXTRA_DIRS="$PWD/build/plugins-linux-amd64" \
"$(which go)" test -v ./e2e/... ${TESTFLAGS-} "$(which go)" test -v ./e2e/... ${TESTFLAGS-}
} }