From 158a7668865c97c44bce6b164b45a11cdaac3edc Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Mon, 10 Dec 2018 14:41:22 +0000 Subject: [PATCH 01/23] Fold `dockerPreRun` into `DockerCli.Initialize` All of the current callers follow the pattern: dockerPreRun(opts) err := dockerCli.Initialize(opts) ... So there is no semantic change into merging the content of `dockerPreRun` into the head of `Initialize`. I'm about to add a new caller outside of the `cmd/docker` package and this seems preferable exporting `DockerPreRun`. Signed-off-by: Ian Campbell --- cli/command/cli.go | 11 +++++++++++ cmd/docker/docker.go | 15 --------------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/cli/command/cli.go b/cli/command/cli.go index fc557635c483..a8711de70c9a 100644 --- a/cli/command/cli.go +++ b/cli/command/cli.go @@ -16,6 +16,7 @@ import ( "github.com/docker/cli/cli/context/docker" kubcontext "github.com/docker/cli/cli/context/kubernetes" "github.com/docker/cli/cli/context/store" + "github.com/docker/cli/cli/debug" cliflags "github.com/docker/cli/cli/flags" manifeststore "github.com/docker/cli/cli/manifest/store" registryclient "github.com/docker/cli/cli/registry/client" @@ -177,6 +178,16 @@ func (cli *DockerCli) RegistryClient(allowInsecure bool) registryclient.Registry // Initialize the dockerCli runs initialization that must happen after command // line flags are parsed. func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error { + cliflags.SetLogLevel(opts.Common.LogLevel) + + if opts.ConfigDir != "" { + cliconfig.SetDir(opts.ConfigDir) + } + + if opts.Common.Debug { + debug.Enable() + } + cli.configFile = cliconfig.LoadDefaultConfigFile(cli.err) var err error cli.contextStore = store.New(cliconfig.ContextStoreDir(), cli.contextStoreConfig) diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 5909953be3db..b16b3ec19476 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -10,7 +10,6 @@ import ( "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/commands" cliconfig "github.com/docker/cli/cli/config" - "github.com/docker/cli/cli/debug" cliflags "github.com/docker/cli/cli/flags" "github.com/docker/docker/api/types/versions" "github.com/docker/docker/client" @@ -36,7 +35,6 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // flags must be the top-level command flags, not cmd.Flags() opts.Common.SetDefaultOptions(flags) - dockerPreRun(opts) if err := dockerCli.Initialize(opts); err != nil { return err } @@ -144,7 +142,6 @@ func initializeDockerCli(dockerCli *command.DockerCli, flags *pflag.FlagSet, opt // when using --help, PersistentPreRun is not called, so initialization is needed. // flags must be the top-level command flags, not cmd.Flags() opts.Common.SetDefaultOptions(flags) - dockerPreRun(opts) return dockerCli.Initialize(opts) } @@ -193,18 +190,6 @@ func main() { } } -func dockerPreRun(opts *cliflags.ClientOptions) { - cliflags.SetLogLevel(opts.Common.LogLevel) - - if opts.ConfigDir != "" { - cliconfig.SetDir(opts.ConfigDir) - } - - if opts.Common.Debug { - debug.Enable() - } -} - type versionDetails interface { Client() client.APIClient ClientInfo() command.ClientInfo From ccef1598b198ab53049cd50968cf7976803b96be Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Mon, 17 Dec 2018 16:59:11 +0000 Subject: [PATCH 02/23] Move `disableFlagsInUseLine` from `main` into our `cli` library ... and expose. I would like to use this from another site. This implies also moving (and exposing) the `visitAll` helper. Unit test them while I'm here. Signed-off-by: Ian Campbell --- cli/cobra.go | 19 +++++++++++++++++++ cli/cobra_test.go | 30 ++++++++++++++++++++++++++++++ cmd/docker/docker.go | 21 ++------------------- 3 files changed, 51 insertions(+), 19 deletions(-) create mode 100644 cli/cobra_test.go diff --git a/cli/cobra.go b/cli/cobra.go index a7431a08766c..b0c50a2aa9f4 100644 --- a/cli/cobra.go +++ b/cli/cobra.go @@ -46,6 +46,25 @@ func FlagErrorFunc(cmd *cobra.Command, err error) error { } } +// VisitAll will traverse all commands from the root. +// This is different from the VisitAll of cobra.Command where only parents +// are checked. +func VisitAll(root *cobra.Command, fn func(*cobra.Command)) { + for _, cmd := range root.Commands() { + VisitAll(cmd, fn) + } + fn(root) +} + +// DisableFlagsInUseLine sets the DisableFlagsInUseLine flag on all +// commands within the tree rooted at cmd. +func DisableFlagsInUseLine(cmd *cobra.Command) { + VisitAll(cmd, func(ccmd *cobra.Command) { + // do not add a `[flags]` to the end of the usage line. + ccmd.DisableFlagsInUseLine = true + }) +} + var helpCommand = &cobra.Command{ Use: "help [command]", Short: "Help about the command", diff --git a/cli/cobra_test.go b/cli/cobra_test.go new file mode 100644 index 000000000000..8c9cf1b19fc6 --- /dev/null +++ b/cli/cobra_test.go @@ -0,0 +1,30 @@ +package cli + +import ( + "testing" + + "github.com/spf13/cobra" + "gotest.tools/assert" +) + +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) +} diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index b16b3ec19476..69d773aa2ef4 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -57,19 +57,12 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { cmd.SetOutput(dockerCli.Out()) commands.AddCommands(cmd, dockerCli) - disableFlagsInUseLine(cmd) + cli.DisableFlagsInUseLine(cmd) setValidateArgs(dockerCli, cmd, flags, opts) return cmd } -func disableFlagsInUseLine(cmd *cobra.Command) { - visitAll(cmd, func(ccmd *cobra.Command) { - // do not add a `[flags]` to the end of the usage line. - ccmd.DisableFlagsInUseLine = true - }) -} - func setFlagErrorFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag.FlagSet, opts *cliflags.ClientOptions) { // When invoking `docker stack --nonsense`, we need to make sure FlagErrorFunc return appropriate // output if the feature is not supported. @@ -111,7 +104,7 @@ func setValidateArgs(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pf // As a result, here we replace the existing Args validation func to a wrapper, // where the wrapper will check to see if the feature is supported or not. // The Args validation error will only be returned if the feature is supported. - visitAll(cmd, func(ccmd *cobra.Command) { + cli.VisitAll(cmd, func(ccmd *cobra.Command) { // if there is no tags for a command or any of its parent, // there is no need to wrap the Args validation. if !hasTags(ccmd) { @@ -145,16 +138,6 @@ func initializeDockerCli(dockerCli *command.DockerCli, flags *pflag.FlagSet, opt 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 From 38645ca44a6a7f44978de42a42238bd2be63590e Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Tue, 18 Dec 2018 10:02:47 +0000 Subject: [PATCH 03/23] Refactor common bits of `SetupRootCommand` I'm shortly going to add a second user to setup plugins, which will want to reuse the common bits. Signed-off-by: Ian Campbell --- cli/cobra.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/cli/cobra.go b/cli/cobra.go index b0c50a2aa9f4..1e83a0ef4efa 100644 --- a/cli/cobra.go +++ b/cli/cobra.go @@ -9,9 +9,7 @@ import ( "github.com/spf13/cobra" ) -// SetupRootCommand sets default usage, help, and error handling for the -// root command. -func SetupRootCommand(rootCmd *cobra.Command) { +func setupCommonRootCommand(rootCmd *cobra.Command) { cobra.AddTemplateFunc("hasSubCommands", hasSubCommands) cobra.AddTemplateFunc("hasManagementSubCommands", hasManagementSubCommands) cobra.AddTemplateFunc("operationSubCommands", operationSubCommands) @@ -22,6 +20,13 @@ func SetupRootCommand(rootCmd *cobra.Command) { rootCmd.SetHelpTemplate(helpTemplate) rootCmd.SetFlagErrorFunc(FlagErrorFunc) rootCmd.SetHelpCommand(helpCommand) +} + +// SetupRootCommand sets default usage, help, and error handling for the +// root command. +func SetupRootCommand(rootCmd *cobra.Command) { + setupCommonRootCommand(rootCmd) + rootCmd.SetVersionTemplate("Docker version {{.Version}}\n") rootCmd.PersistentFlags().BoolP("help", "h", false, "Print usage") From c5168117af97d951013b2baf0948157fb59faf7e Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Tue, 18 Dec 2018 10:16:52 +0000 Subject: [PATCH 04/23] Push setup of opts and default flagset into SetupRootCommand I'm shortly going to add a second user (plugins) which want to share some behaviour. Signed-off-by: Ian Campbell --- cli/cobra.go | 19 ++++++++++++++++--- cmd/docker/docker.go | 13 +++++-------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/cli/cobra.go b/cli/cobra.go index 1e83a0ef4efa..ce4284a0fded 100644 --- a/cli/cobra.go +++ b/cli/cobra.go @@ -4,12 +4,21 @@ import ( "fmt" "strings" + cliconfig "github.com/docker/cli/cli/config" + cliflags "github.com/docker/cli/cli/flags" "github.com/docker/docker/pkg/term" "github.com/pkg/errors" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) -func setupCommonRootCommand(rootCmd *cobra.Command) { +func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet) { + opts := cliflags.NewClientOptions() + flags := rootCmd.Flags() + + flags.StringVar(&opts.ConfigDir, "config", cliconfig.Dir(), "Location of client config files") + opts.Common.InstallFlags(flags) + cobra.AddTemplateFunc("hasSubCommands", hasSubCommands) cobra.AddTemplateFunc("hasManagementSubCommands", hasManagementSubCommands) cobra.AddTemplateFunc("operationSubCommands", operationSubCommands) @@ -20,18 +29,22 @@ func setupCommonRootCommand(rootCmd *cobra.Command) { rootCmd.SetHelpTemplate(helpTemplate) rootCmd.SetFlagErrorFunc(FlagErrorFunc) rootCmd.SetHelpCommand(helpCommand) + + return opts, flags } // SetupRootCommand sets default usage, help, and error handling for the // root command. -func SetupRootCommand(rootCmd *cobra.Command) { - setupCommonRootCommand(rootCmd) +func SetupRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet) { + opts, flags := setupCommonRootCommand(rootCmd) rootCmd.SetVersionTemplate("Docker version {{.Version}}\n") rootCmd.PersistentFlags().BoolP("help", "h", false, "Print usage") rootCmd.PersistentFlags().MarkShorthandDeprecated("help", "please use --help") rootCmd.PersistentFlags().Lookup("help").Hidden = true + + return opts, flags } // FlagErrorFunc prints an error message which matches the format of the diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 69d773aa2ef4..e64fe2afd8cf 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -9,7 +9,6 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/commands" - cliconfig "github.com/docker/cli/cli/config" cliflags "github.com/docker/cli/cli/flags" "github.com/docker/docker/api/types/versions" "github.com/docker/docker/client" @@ -19,8 +18,10 @@ import ( ) func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { - opts := cliflags.NewClientOptions() - var flags *pflag.FlagSet + var ( + opts *cliflags.ClientOptions + flags *pflag.FlagSet + ) cmd := &cobra.Command{ Use: "docker [OPTIONS] COMMAND [ARG...]", @@ -43,12 +44,8 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { Version: fmt.Sprintf("%s, build %s", cli.Version, cli.GitCommit), DisableFlagsInUseLine: true, } - cli.SetupRootCommand(cmd) - - flags = cmd.Flags() + opts, flags = cli.SetupRootCommand(cmd) flags.BoolP("version", "v", false, "Print version information and quit") - flags.StringVar(&opts.ConfigDir, "config", cliconfig.Dir(), "Location of client config files") - opts.Common.InstallFlags(flags) setFlagErrorFunc(dockerCli, cmd, flags, opts) From 20c19830a95455e8562551aad52c715ad0807cc6 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Tue, 8 Jan 2019 15:03:51 +0000 Subject: [PATCH 05/23] Move versioning variables to a separate package. This helps to avoid circular includes, by separating the pure data out from the actual functionality in the cli subpackage, allowing other code which is imported to access the data. Signed-off-by: Ian Campbell --- cli/command/cli.go | 4 ++-- cli/command/system/version.go | 9 +++++---- cli/{ => version}/version.go | 2 +- cmd/docker/docker.go | 3 ++- scripts/build/.variables | 8 ++++---- 5 files changed, 14 insertions(+), 12 deletions(-) rename cli/{ => version}/version.go (92%) diff --git a/cli/command/cli.go b/cli/command/cli.go index a8711de70c9a..28936fc3bf56 100644 --- a/cli/command/cli.go +++ b/cli/command/cli.go @@ -8,7 +8,6 @@ import ( "runtime" "strconv" - "github.com/docker/cli/cli" "github.com/docker/cli/cli/config" cliconfig "github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config/configfile" @@ -22,6 +21,7 @@ import ( registryclient "github.com/docker/cli/cli/registry/client" "github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/trust" + "github.com/docker/cli/cli/version" "github.com/docker/cli/internal/containerizedengine" dopts "github.com/docker/cli/opts" clitypes "github.com/docker/cli/types" @@ -472,7 +472,7 @@ func getServerHost(hosts []string, tlsOptions *tlsconfig.Options) (string, error // UserAgent returns the user agent string used for making API requests func UserAgent() string { - return "Docker-Client/" + cli.Version + " (" + runtime.GOOS + ")" + return "Docker-Client/" + version.Version + " (" + runtime.GOOS + ")" } // resolveContextName resolves the current context name with the following rules: diff --git a/cli/command/system/version.go b/cli/command/system/version.go index a8f0beb1fdc0..2e2ae2ee3acc 100644 --- a/cli/command/system/version.go +++ b/cli/command/system/version.go @@ -12,6 +12,7 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" kubecontext "github.com/docker/cli/cli/context/kubernetes" + "github.com/docker/cli/cli/version" "github.com/docker/cli/kubernetes" "github.com/docker/cli/templates" "github.com/docker/docker/api/types" @@ -135,13 +136,13 @@ func runVersion(dockerCli command.Cli, opts *versionOptions) error { vd := versionInfo{ Client: clientVersion{ - Platform: struct{ Name string }{cli.PlatformName}, - Version: cli.Version, + Platform: struct{ Name string }{version.PlatformName}, + Version: version.Version, APIVersion: dockerCli.Client().ClientVersion(), DefaultAPIVersion: dockerCli.DefaultVersion(), GoVersion: runtime.Version(), - GitCommit: cli.GitCommit, - BuildTime: reformatDate(cli.BuildTime), + GitCommit: version.GitCommit, + BuildTime: reformatDate(version.BuildTime), Os: runtime.GOOS, Arch: runtime.GOARCH, Experimental: dockerCli.ClientInfo().HasExperimental, diff --git a/cli/version.go b/cli/version/version.go similarity index 92% rename from cli/version.go rename to cli/version/version.go index c4120b9585fb..a263b9a73133 100644 --- a/cli/version.go +++ b/cli/version/version.go @@ -1,4 +1,4 @@ -package cli +package version // Default build-time variable. // These values are overridden via ldflags diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index e64fe2afd8cf..f8890304ecfb 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -10,6 +10,7 @@ import ( "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/commands" cliflags "github.com/docker/cli/cli/flags" + "github.com/docker/cli/cli/version" "github.com/docker/docker/api/types/versions" "github.com/docker/docker/client" "github.com/sirupsen/logrus" @@ -41,7 +42,7 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { } 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, } opts, flags = cli.SetupRootCommand(cmd) diff --git a/scripts/build/.variables b/scripts/build/.variables index 208f44c3164d..8b13cd55c86c 100755 --- a/scripts/build/.variables +++ b/scripts/build/.variables @@ -8,15 +8,15 @@ BUILDTIME=${BUILDTIME:-$(date --utc --rfc-3339 ns 2> /dev/null | sed -e 's/ /T/' PLATFORM_LDFLAGS= if test -n "${PLATFORM}"; then - PLATFORM_LDFLAGS="-X \"github.com/docker/cli/cli.PlatformName=${PLATFORM}\"" + PLATFORM_LDFLAGS="-X \"github.com/docker/cli/cli/version.PlatformName=${PLATFORM}\"" fi export LDFLAGS="\ -w \ ${PLATFORM_LDFLAGS} \ - -X \"github.com/docker/cli/cli.GitCommit=${GITCOMMIT}\" \ - -X \"github.com/docker/cli/cli.BuildTime=${BUILDTIME}\" \ - -X \"github.com/docker/cli/cli.Version=${VERSION}\" \ + -X \"github.com/docker/cli/cli/version.GitCommit=${GITCOMMIT}\" \ + -X \"github.com/docker/cli/cli/version.BuildTime=${BUILDTIME}\" \ + -X \"github.com/docker/cli/cli/version.Version=${VERSION}\" \ ${LDFLAGS:-} \ " From eab40a5974206421705faed08e538c958806acbf Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Thu, 10 Jan 2019 15:49:06 +0000 Subject: [PATCH 06/23] cli/config: Add a helper to resolve a file within the config dir Signed-off-by: Ian Campbell --- cli/config/config.go | 5 +++++ cli/config/config_test.go | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/cli/config/config.go b/cli/config/config.go index 64f8d3b49c67..f773abee4091 100644 --- a/cli/config/config.go +++ b/cli/config/config.go @@ -46,6 +46,11 @@ func SetDir(dir string) { configDir = dir } +// Path returns the path to a file relative to the config dir +func Path(p ...string) string { + return filepath.Join(append([]string{Dir()}, p...)...) +} + // LegacyLoadFromReader is a convenience function that creates a ConfigFile object from // a non-nested reader func LegacyLoadFromReader(configData io.Reader) (*configfile.ConfigFile, error) { diff --git a/cli/config/config_test.go b/cli/config/config_test.go index 11c8dd6de5f2..85288291b461 100644 --- a/cli/config/config_test.go +++ b/cli/config/config_test.go @@ -548,3 +548,17 @@ func TestLoadDefaultConfigFile(t *testing.T) { assert.Check(t, is.DeepEqual(expected, configFile)) } + +func TestConfigPath(t *testing.T) { + oldDir := Dir() + + SetDir("dummy1") + f1 := Path("a", "b") + assert.Equal(t, f1, filepath.Join("dummy1", "a", "b")) + + SetDir("dummy2") + f2 := Path("c", "d") + assert.Equal(t, f2, filepath.Join("dummy2", "c", "d")) + + SetDir(oldDir) +} From 8cf946d1bcbe847d02312eb6d472b6b25bb9628e Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Mon, 28 Jan 2019 17:38:33 +0000 Subject: [PATCH 07/23] Unit test for WithContentTrustFromEnv I authored this for `contentTrustEnabled` prior to 7f207f3f957e, so this now tests the funcation argument version. Signed-off-by: Ian Campbell --- cli/command/cli_options_test.go | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 cli/command/cli_options_test.go diff --git a/cli/command/cli_options_test.go b/cli/command/cli_options_test.go new file mode 100644 index 000000000000..10dcad8b3dbc --- /dev/null +++ b/cli/command/cli_options_test.go @@ -0,0 +1,37 @@ +package command + +import ( + "os" + "testing" + + "gotest.tools/assert" +) + +func contentTrustEnabled(t *testing.T) bool { + var cli DockerCli + assert.NilError(t, WithContentTrustFromEnv()(&cli)) + return cli.contentTrust +} + +// NB: Do not t.Parallel() this test -- it messes with the process environment. +func TestWithContentTrustFromEnv(t *testing.T) { + envvar := "DOCKER_CONTENT_TRUST" + if orig, ok := os.LookupEnv(envvar); ok { + defer func() { + os.Setenv(envvar, orig) + }() + } else { + defer func() { + os.Unsetenv(envvar) + }() + } + + os.Setenv(envvar, "true") + assert.Assert(t, contentTrustEnabled(t)) + os.Setenv(envvar, "false") + assert.Assert(t, !contentTrustEnabled(t)) + os.Setenv(envvar, "invalid") + assert.Assert(t, contentTrustEnabled(t)) + os.Unsetenv(envvar) + assert.Assert(t, !contentTrustEnabled(t)) +} From e96240427f94c0b9473f8d4aeaed81938cf15610 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Mon, 10 Dec 2018 15:30:19 +0000 Subject: [PATCH 08/23] Add basic framework for writing a CLI plugin That is, the helper to be used from the plugin's `main`. Also add a `helloworld` plugin example and build integration. Signed-off-by: Ian Campbell --- Makefile | 12 ++++ cli-plugins/examples/helloworld/main.go | 38 ++++++++++ cli-plugins/manager/metadata.go | 25 +++++++ cli-plugins/plugin/plugin.go | 96 +++++++++++++++++++++++++ cli/cobra.go | 12 ++++ docker.Makefile | 11 +++ dockerfiles/Dockerfile.e2e | 1 + scripts/build/plugins | 21 ++++++ scripts/build/plugins-osx | 18 +++++ scripts/build/plugins-windows | 14 ++++ 10 files changed, 248 insertions(+) create mode 100644 cli-plugins/examples/helloworld/main.go create mode 100644 cli-plugins/manager/metadata.go create mode 100644 cli-plugins/plugin/plugin.go create mode 100755 scripts/build/plugins create mode 100755 scripts/build/plugins-osx create mode 100755 scripts/build/plugins-windows diff --git a/Makefile b/Makefile index 6f0ff4b00890..aaf5317fe60e 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,10 @@ binary: ## build executable for Linux @echo "WARNING: binary creates a Linux executable. Use cross for macOS or Windows." ./scripts/build/binary +.PHONY: plugins +plugins: ## build example CLI plugins + ./scripts/build/plugins + .PHONY: cross cross: ## build executable for macOS and Windows ./scripts/build/cross @@ -42,10 +46,18 @@ cross: ## build executable for macOS and Windows binary-windows: ## build executable for Windows ./scripts/build/windows +.PHONY: plugins-windows +plugins-windows: ## build example CLI plugins for Windows + ./scripts/build/plugins-windows + .PHONY: binary-osx binary-osx: ## build executable for macOS ./scripts/build/osx +.PHONY: plugins-osx +plugins-osx: ## build example CLI plugins for macOS + ./scripts/build/plugins-osx + .PHONY: dynbinary dynbinary: ## build dynamically linked binary ./scripts/build/dynbinary diff --git a/cli-plugins/examples/helloworld/main.go b/cli-plugins/examples/helloworld/main.go new file mode 100644 index 000000000000..a319ee623f6c --- /dev/null +++ b/cli-plugins/examples/helloworld/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "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!") + }, + } + + cmd := &cobra.Command{ + Use: "helloworld", + Short: "A basic Hello World plugin for tests", + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintln(dockerCli.Out(), "Hello World!") + }, + } + + cmd.AddCommand(goodbye) + return cmd + }, + manager.Metadata{ + SchemaVersion: "0.1.0", + Vendor: "Docker Inc.", + Version: "0.1.0", + }) +} diff --git a/cli-plugins/manager/metadata.go b/cli-plugins/manager/metadata.go new file mode 100644 index 000000000000..2d5734e8682c --- /dev/null +++ b/cli-plugins/manager/metadata.go @@ -0,0 +1,25 @@ +package manager + +const ( + // NamePrefix is the prefix required on all plugin binary names + NamePrefix = "docker-" + + // MetadataSubcommandName is the name of the plugin subcommand + // which must be supported by every plugin and returns the + // plugin metadata. + MetadataSubcommandName = "docker-cli-plugin-metadata" +) + +// Metadata provided by the plugin +type Metadata struct { + // SchemaVersion describes the version of this struct. Mandatory, must be "0.1.0" + SchemaVersion string + // Vendor is the name of the plugin vendor. Mandatory + Vendor string + // Version is the optional version of this plugin. + Version string + // ShortDescription should be suitable for a single line help message. + ShortDescription string + // URL is a pointer to the plugin's homepage. + URL string +} diff --git a/cli-plugins/plugin/plugin.go b/cli-plugins/plugin/plugin.go new file mode 100644 index 000000000000..ce2bd0bd6f50 --- /dev/null +++ b/cli-plugins/plugin/plugin.go @@ -0,0 +1,96 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" + + "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) + } +} + +func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager.Metadata) *cobra.Command { + var ( + opts *cliflags.ClientOptions + flags *pflag.FlagSet + ) + + 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: func(cmd *cobra.Command, args []string) error { + // flags must be the top-level command flags, not cmd.Flags() + opts.Common.SetDefaultOptions(flags) + return dockerCli.Initialize(opts) + }, + DisableFlagsInUseLine: true, + } + opts, flags = cli.SetupPluginRootCommand(cmd) + + cmd.SetOutput(dockerCli.Out()) + + cmd.AddCommand( + plugin, + newMetadataSubcommand(plugin, meta), + ) + + cli.DisableFlagsInUseLine(cmd) + + return cmd +} + +func newMetadataSubcommand(plugin *cobra.Command, meta manager.Metadata) *cobra.Command { + if meta.ShortDescription == "" { + meta.ShortDescription = plugin.Short + } + cmd := &cobra.Command{ + Use: manager.MetadataSubcommandName, + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + return enc.Encode(meta) + }, + } + return cmd +} diff --git a/cli/cobra.go b/cli/cobra.go index ce4284a0fded..8acce9478392 100644 --- a/cli/cobra.go +++ b/cli/cobra.go @@ -12,6 +12,8 @@ import ( "github.com/spf13/pflag" ) +// setupCommonRootCommand contains the setup common to +// SetupRootCommand and SetupPluginRootCommand. func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet) { opts := cliflags.NewClientOptions() flags := rootCmd.Flags() @@ -47,6 +49,16 @@ func SetupRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.F return opts, flags } +// 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 // docker/cli/cli error messages func FlagErrorFunc(cmd *cobra.Command, err error) error { diff --git a/docker.Makefile b/docker.Makefile index 19bbe02b6b2d..6de7683c645b 100644 --- a/docker.Makefile +++ b/docker.Makefile @@ -56,6 +56,9 @@ binary: build_binary_native_image ## build the CLI build: binary ## alias for binary +plugins: build_binary_native_image ## build the CLI plugin examples + docker run --rm $(ENVVARS) $(MOUNTS) $(BINARY_NATIVE_IMAGE_NAME) ./scripts/build/plugins + .PHONY: clean clean: build_docker_image ## clean build artifacts docker run --rm $(ENVVARS) $(MOUNTS) $(DEV_DOCKER_IMAGE_NAME) make clean @@ -76,10 +79,18 @@ cross: build_cross_image ## build the CLI for macOS and Windows binary-windows: build_cross_image ## build the CLI for Windows docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ +.PHONY: plugins-windows +plugins-windows: build_cross_image ## build the example CLI plugins for Windows + docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ + .PHONY: binary-osx binary-osx: build_cross_image ## build the CLI for macOS docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ +.PHONY: plugins-osx +plugins-osx: build_cross_image ## build the example CLI plugins for macOS + docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ + .PHONY: dev dev: build_docker_image ## start a build container in interactive mode for in-container development docker run -ti --rm $(ENVVARS) $(MOUNTS) \ diff --git a/dockerfiles/Dockerfile.e2e b/dockerfiles/Dockerfile.e2e index 68368c097029..b2f57e0a1069 100644 --- a/dockerfiles/Dockerfile.e2e +++ b/dockerfiles/Dockerfile.e2e @@ -38,5 +38,6 @@ ARG VERSION ARG GITCOMMIT ENV VERSION=${VERSION} GITCOMMIT=${GITCOMMIT} RUN ./scripts/build/binary +RUN ./scripts/build/plugins CMD ./scripts/test/e2e/entry diff --git a/scripts/build/plugins b/scripts/build/plugins new file mode 100755 index 000000000000..bc14748b921f --- /dev/null +++ b/scripts/build/plugins @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# +# Build a static binary for the host OS/ARCH +# + +set -eu -o pipefail + +source ./scripts/build/.variables + +mkdir -p "build/plugins-${GOOS}-${GOARCH}" +for p in cli-plugins/examples/* ; do + [ -d "$p" ] || continue + + n=$(basename "$p") + + TARGET="build/plugins-${GOOS}-${GOARCH}/docker-${n}" + + echo "Building statically linked $TARGET" + export CGO_ENABLED=0 + go build -o "${TARGET}" --ldflags "${LDFLAGS}" "github.com/docker/cli/${p}" +done diff --git a/scripts/build/plugins-osx b/scripts/build/plugins-osx new file mode 100755 index 000000000000..8e870f4f05bb --- /dev/null +++ b/scripts/build/plugins-osx @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# +# Build a static binary for the host OS/ARCH +# + +set -eu -o pipefail + +source ./scripts/build/.variables + +export CGO_ENABLED=1 +export GOOS=darwin +export GOARCH=amd64 +export CC=o64-clang +export CXX=o64-clang++ +export LDFLAGS="$LDFLAGS -linkmode external -s" +export LDFLAGS_STATIC_DOCKER='-extld='${CC} + +source ./scripts/build/plugins diff --git a/scripts/build/plugins-windows b/scripts/build/plugins-windows new file mode 100755 index 000000000000..607ad6dc12a7 --- /dev/null +++ b/scripts/build/plugins-windows @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# Build a static binary for the host OS/ARCH +# + +set -eu -o pipefail + +source ./scripts/build/.variables +export CC=x86_64-w64-mingw32-gcc +export CGO_ENABLED=1 +export GOOS=windows +export GOARCH=amd64 + +source ./scripts/build/plugins From f1f31abbe5c69e258095118f3e8060b82b445d09 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Tue, 11 Dec 2018 14:03:47 +0000 Subject: [PATCH 09/23] Add support for running a CLI plugin Also includes the scaffolding for finding a validating plugin candidates. Argument validation is moved to RunE to support this, so `noArgs` is removed. Signed-off-by: Ian Campbell --- cli-plugins/examples/helloworld/main.go | 2 +- cli-plugins/manager/candidate.go | 23 ++++++ cli-plugins/manager/candidate_test.go | 90 ++++++++++++++++++++ cli-plugins/manager/manager.go | 93 +++++++++++++++++++++ cli-plugins/manager/manager_test.go | 36 ++++++++ cli-plugins/manager/manager_unix.go | 8 ++ cli-plugins/manager/manager_windows.go | 10 +++ cli-plugins/manager/plugin.go | 104 ++++++++++++++++++++++++ cli/config/configfile/file.go | 1 + cmd/docker/docker.go | 24 +++--- 10 files changed, 380 insertions(+), 11 deletions(-) create mode 100644 cli-plugins/manager/candidate.go create mode 100644 cli-plugins/manager/candidate_test.go create mode 100644 cli-plugins/manager/manager.go create mode 100644 cli-plugins/manager/manager_test.go create mode 100644 cli-plugins/manager/manager_unix.go create mode 100644 cli-plugins/manager/manager_windows.go create mode 100644 cli-plugins/manager/plugin.go diff --git a/cli-plugins/examples/helloworld/main.go b/cli-plugins/examples/helloworld/main.go index a319ee623f6c..e79e32ce5436 100644 --- a/cli-plugins/examples/helloworld/main.go +++ b/cli-plugins/examples/helloworld/main.go @@ -33,6 +33,6 @@ func main() { manager.Metadata{ SchemaVersion: "0.1.0", Vendor: "Docker Inc.", - Version: "0.1.0", + Version: "testing", }) } diff --git a/cli-plugins/manager/candidate.go b/cli-plugins/manager/candidate.go new file mode 100644 index 000000000000..2000e5b142f6 --- /dev/null +++ b/cli-plugins/manager/candidate.go @@ -0,0 +1,23 @@ +package manager + +import ( + "os/exec" +) + +// Candidate represents a possible plugin candidate, for mocking purposes +type Candidate interface { + Path() string + Metadata() ([]byte, error) +} + +type candidate struct { + path string +} + +func (c *candidate) Path() string { + return c.path +} + +func (c *candidate) Metadata() ([]byte, error) { + return exec.Command(c.path, MetadataSubcommandName).Output() +} diff --git a/cli-plugins/manager/candidate_test.go b/cli-plugins/manager/candidate_test.go new file mode 100644 index 000000000000..11384226a5d4 --- /dev/null +++ b/cli-plugins/manager/candidate_test.go @@ -0,0 +1,90 @@ +package manager + +import ( + "fmt" + "strings" + "testing" + + "github.com/spf13/cobra" + "gotest.tools/assert" +) + +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.ErrorContains(t, p.Err, tc.invalid) + } else { + assert.NilError(t, err) + assert.Equal(t, NamePrefix+p.Name, goodPluginName) + assert.Equal(t, p.SchemaVersion, "0.1.0") + assert.Equal(t, p.Vendor, "e2e-testing") + } + } +} + +func TestCandidatePath(t *testing.T) { + exp := "/some/path" + cand := &candidate{path: exp} + assert.Equal(t, exp, cand.Path()) +} diff --git a/cli-plugins/manager/manager.go b/cli-plugins/manager/manager.go new file mode 100644 index 000000000000..882d2ee2ea98 --- /dev/null +++ b/cli-plugins/manager/manager.go @@ -0,0 +1,93 @@ +package manager + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + + "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 +} + +var defaultUserPluginDir = config.Path("cli-plugins") + +func getPluginDirs(dockerCli command.Cli) []string { + var pluginDirs []string + + if cfg := dockerCli.ConfigFile(); cfg != nil { + pluginDirs = append(pluginDirs, cfg.CLIPluginsExtraDirs...) + } + pluginDirs = append(pluginDirs, defaultUserPluginDir) + pluginDirs = append(pluginDirs, defaultSystemPluginDirs...) + return pluginDirs +} + +// 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 := NamePrefix + name + if runtime.GOOS == "windows" { + exename = exename + ".exe" + } + for _, d := range getPluginDirs(dockerCli) { + path := filepath.Join(d, exename) + + // We stat here rather than letting the exec tell us + // ENOENT because the latter does not distinguish a + // file not existing from its dynamic loader or one of + // its libraries not existing. + if _, err := os.Stat(path); os.IsNotExist(err) { + continue + } + + c := &candidate{path: path} + plugin, err := newPlugin(c, rootcmd) + if err != nil { + return nil, err + } + if plugin.Err != nil { + return nil, errPluginNotFound(name) + } + cmd := exec.Command(plugin.Path, args...) + // Using dockerCli.{In,Out,Err}() here results in a hang until something is input. + // See: - https://github.com/golang/go/issues/10338 + // - https://github.com/golang/go/commit/d000e8742a173aa0659584aa01b7ba2834ba28ab + // os.Stdin is a *os.File which avoids this behaviour. We don't need the functionality + // of the wrappers here anyway. + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd, nil + } + return nil, errPluginNotFound(name) +} diff --git a/cli-plugins/manager/manager_test.go b/cli-plugins/manager/manager_test.go new file mode 100644 index 000000000000..3a62b911fab1 --- /dev/null +++ b/cli-plugins/manager/manager_test.go @@ -0,0 +1,36 @@ +package manager + +import ( + "strings" + "testing" + + "github.com/docker/cli/cli/config/configfile" + "github.com/docker/cli/internal/test" + "gotest.tools/assert" +) + +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{defaultUserPluginDir} + expected = append(expected, defaultSystemPluginDirs...) + + assert.Equal(t, strings.Join(expected, ":"), strings.Join(getPluginDirs(cli), ":")) + + extras := []string{ + "foo", "bar", "baz", + } + expected = append(extras, expected...) + cli.SetConfigFile(&configfile.ConfigFile{ + CLIPluginsExtraDirs: extras, + }) + assert.DeepEqual(t, expected, getPluginDirs(cli)) +} diff --git a/cli-plugins/manager/manager_unix.go b/cli-plugins/manager/manager_unix.go new file mode 100644 index 000000000000..f586acbd8da2 --- /dev/null +++ b/cli-plugins/manager/manager_unix.go @@ -0,0 +1,8 @@ +// +build !windows + +package manager + +var defaultSystemPluginDirs = []string{ + "/usr/local/lib/docker/cli-plugins", "/usr/local/libexec/docker/cli-plugins", + "/usr/lib/docker/cli-plugins", "/usr/libexec/docker/cli-plugins", +} diff --git a/cli-plugins/manager/manager_windows.go b/cli-plugins/manager/manager_windows.go new file mode 100644 index 000000000000..b62868580360 --- /dev/null +++ b/cli-plugins/manager/manager_windows.go @@ -0,0 +1,10 @@ +package manager + +import ( + "os" + "path/filepath" +) + +var defaultSystemPluginDirs = []string{ + filepath.Join(os.Getenv("ProgramData"), "Docker", "cli-plugins"), +} diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go new file mode 100644 index 000000000000..8a07c5853726 --- /dev/null +++ b/cli-plugins/manager/plugin.go @@ -0,0 +1,104 @@ +package manager + +import ( + "encoding/json" + "path/filepath" + "regexp" + "runtime" + "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 + Path string + + // 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, 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) + } + if runtime.GOOS == "windows" { + exe := ".exe" + if !strings.HasSuffix(fullname, exe) { + return Plugin{}, errors.Errorf("plugin candidate %q lacks required %q suffix", path, exe) + } + fullname = strings.TrimSuffix(fullname, exe) + } + 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 = errors.Errorf("plugin candidate %q did not match %q", p.Name, pluginNameRe.String()) + return p, nil + } + + if rootcmd != nil { + for _, cmd := range rootcmd.Commands() { + if cmd.Name() == p.Name { + p.Err = errors.Errorf("plugin %q duplicates builtin command", p.Name) + return p, nil + } + if cmd.HasAlias(p.Name) { + p.Err = errors.Errorf("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 = errors.Wrap(err, "failed to fetch metadata") + return p, nil + } + + if err := json.Unmarshal(meta, &p.Metadata); err != nil { + p.Err = errors.Wrap(err, "invalid metadata") + return p, nil + } + + if p.Metadata.SchemaVersion != "0.1.0" { + p.Err = errors.Errorf("plugin SchemaVersion %q is not valid, must be 0.1.0", p.Metadata.SchemaVersion) + return p, nil + } + if p.Metadata.Vendor == "" { + p.Err = errors.Errorf("plugin metadata does not define a vendor") + return p, nil + } + return p, nil +} diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index d815570362a8..99ffd47a5fcb 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -49,6 +49,7 @@ type ConfigFile struct { StackOrchestrator string `json:"stackOrchestrator,omitempty"` Kubernetes *KubernetesConfig `json:"kubernetes,omitempty"` CurrentContext string `json:"currentContext,omitempty"` + CLIPluginsExtraDirs []string `json:"cliPluginsExtraDirs,omitempty"` } // ProxyConfig contains proxy configuration settings diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index f8890304ecfb..062cbfdc9b73 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/docker/cli/cli" + pluginmanager "github.com/docker/cli/cli-plugins/manager" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/commands" cliflags "github.com/docker/cli/cli/flags" @@ -30,9 +31,20 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { SilenceUsage: true, SilenceErrors: true, TraverseChildren: true, - Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { - return command.ShowHelp(dockerCli.Err())(cmd, args) + if len(args) == 0 { + return command.ShowHelp(dockerCli.Err())(cmd, args) + } + plugincmd, err := pluginmanager.PluginRunCommand(dockerCli, args[0], cmd) + if pluginmanager.IsNotFound(err) { + return fmt.Errorf( + "docker: '%s' is not a docker command.\nSee 'docker --help'", args[0]) + } + if err != nil { + return err + } + + return plugincmd.Run() }, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // flags must be the top-level command flags, not cmd.Flags() @@ -136,14 +148,6 @@ func initializeDockerCli(dockerCli *command.DockerCli, flags *pflag.FlagSet, opt return dockerCli.Initialize(opts) } -func noArgs(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return nil - } - return fmt.Errorf( - "docker: '%s' is not a docker command.\nSee 'docker --help'", args[0]) -} - func main() { dockerCli, err := command.NewDockerCli() if err != nil { From 5db336798c2fc17eb20f049508bb291db8a69d71 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Tue, 11 Dec 2018 14:15:04 +0000 Subject: [PATCH 10/23] Add some simple e2e tests for executing CLI plugins To help with this add a bad plugin which produces invalid metadata and arrange for it to be built in the e2e container. Signed-off-by: Ian Campbell --- dockerfiles/Dockerfile.e2e | 2 +- e2e/cli-plugins/plugins/badmeta/main.go | 19 ++++++ e2e/cli-plugins/run_test.go | 60 +++++++++++++++++++ .../testdata/docker-badmeta-err.golden | 2 + .../testdata/docker-nonexistent-err.golden | 2 + e2e/cli-plugins/util_test.go | 24 ++++++++ scripts/build/plugins | 2 +- scripts/test/e2e/run | 1 + 8 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 e2e/cli-plugins/plugins/badmeta/main.go create mode 100644 e2e/cli-plugins/run_test.go create mode 100644 e2e/cli-plugins/testdata/docker-badmeta-err.golden create mode 100644 e2e/cli-plugins/testdata/docker-nonexistent-err.golden create mode 100644 e2e/cli-plugins/util_test.go diff --git a/dockerfiles/Dockerfile.e2e b/dockerfiles/Dockerfile.e2e index b2f57e0a1069..eedda3b7f59d 100644 --- a/dockerfiles/Dockerfile.e2e +++ b/dockerfiles/Dockerfile.e2e @@ -38,6 +38,6 @@ ARG VERSION ARG GITCOMMIT ENV VERSION=${VERSION} GITCOMMIT=${GITCOMMIT} RUN ./scripts/build/binary -RUN ./scripts/build/plugins +RUN ./scripts/build/plugins e2e/cli-plugins/plugins/* CMD ./scripts/test/e2e/entry diff --git a/e2e/cli-plugins/plugins/badmeta/main.go b/e2e/cli-plugins/plugins/badmeta/main.go new file mode 100644 index 000000000000..10bd0eff7263 --- /dev/null +++ b/e2e/cli-plugins/plugins/badmeta/main.go @@ -0,0 +1,19 @@ +package main + +// This is not a real plugin, but just returns malformated metadata +// from the subcommand and otherwise exits with failure. + +import ( + "fmt" + "os" + + "github.com/docker/cli/cli-plugins/manager" +) + +func main() { + if len(os.Args) == 2 && os.Args[1] == manager.MetadataSubcommandName { + fmt.Println(`{invalid-json}`) + os.Exit(0) + } + os.Exit(1) +} diff --git a/e2e/cli-plugins/run_test.go b/e2e/cli-plugins/run_test.go new file mode 100644 index 000000000000..e4cd94d59ad6 --- /dev/null +++ b/e2e/cli-plugins/run_test.go @@ -0,0 +1,60 @@ +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") +} + +// 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") +} + +// 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!", + }) +} + +// 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!", + }) +} diff --git a/e2e/cli-plugins/testdata/docker-badmeta-err.golden b/e2e/cli-plugins/testdata/docker-badmeta-err.golden new file mode 100644 index 000000000000..df2344638af7 --- /dev/null +++ b/e2e/cli-plugins/testdata/docker-badmeta-err.golden @@ -0,0 +1,2 @@ +docker: 'badmeta' is not a docker command. +See 'docker --help' diff --git a/e2e/cli-plugins/testdata/docker-nonexistent-err.golden b/e2e/cli-plugins/testdata/docker-nonexistent-err.golden new file mode 100644 index 000000000000..f2265f6b9ecd --- /dev/null +++ b/e2e/cli-plugins/testdata/docker-nonexistent-err.golden @@ -0,0 +1,2 @@ +docker: 'nonexistent' is not a docker command. +See 'docker --help' diff --git a/e2e/cli-plugins/util_test.go b/e2e/cli-plugins/util_test.go new file mode 100644 index 000000000000..0ab1aa956687 --- /dev/null +++ b/e2e/cli-plugins/util_test.go @@ -0,0 +1,24 @@ +package cliplugins + +import ( + "fmt" + "os" + "testing" + + "gotest.tools/fs" + "gotest.tools/icmd" +) + +func prepare(t *testing.T) (func(args ...string) icmd.Cmd, func()) { + cfg := fs.NewDir(t, "plugin-test", + fs.WithFile("config.json", fmt.Sprintf(`{"cliPluginsExtraDirs": [%q]}`, os.Getenv("DOCKER_CLI_E2E_PLUGINS_EXTRA_DIRS"))), + ) + run := func(args ...string) icmd.Cmd { + return icmd.Command("docker", append([]string{"--config", cfg.Path()}, args...)...) + } + cleanup := func() { + cfg.Remove() + } + return run, cleanup + +} diff --git a/scripts/build/plugins b/scripts/build/plugins index bc14748b921f..fce2689cdc25 100755 --- a/scripts/build/plugins +++ b/scripts/build/plugins @@ -8,7 +8,7 @@ set -eu -o pipefail source ./scripts/build/.variables mkdir -p "build/plugins-${GOOS}-${GOARCH}" -for p in cli-plugins/examples/* ; do +for p in cli-plugins/examples/* "$@" ; do [ -d "$p" ] || continue n=$(basename "$p") diff --git a/scripts/test/e2e/run b/scripts/test/e2e/run index 7a401db9ce1c..d494019419fb 100755 --- a/scripts/test/e2e/run +++ b/scripts/test/e2e/run @@ -69,6 +69,7 @@ function runtests { TEST_SKIP_PLUGIN_TESTS="${SKIP_PLUGIN_TESTS-}" \ GOPATH="$GOPATH" \ PATH="$PWD/build/:/usr/bin" \ + DOCKER_CLI_E2E_PLUGINS_EXTRA_DIRS="$PWD/build/plugins-linux-amd64" \ "$(which go)" test -v ./e2e/... ${TESTFLAGS-} } From f912b55bd13bd414e4619c4804424d3c3ddb5b15 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Tue, 11 Dec 2018 14:50:04 +0000 Subject: [PATCH 11/23] Integrate CLI plugins into `docker help` output. To do this we add a stub `cobra.Command` for each installed plugin (only when invoking `help`, not for normal running). This requires a function to list all available plugins so that is added here. Signed-off-by: Ian Campbell --- cli-plugins/manager/cobra.go | 57 +++++++++++++++++++++ cli-plugins/manager/manager.go | 77 +++++++++++++++++++++++++++++ cli-plugins/manager/manager_test.go | 71 ++++++++++++++++++++++++++ cli-plugins/manager/plugin.go | 6 +++ cli/cobra.go | 34 ++++++++++--- cli/cobra_test.go | 27 ++++++++++ cmd/docker/docker.go | 36 ++++++++++++-- e2e/cli-plugins/help_test.go | 54 ++++++++++++++++++++ 8 files changed, 351 insertions(+), 11 deletions(-) create mode 100644 cli-plugins/manager/cobra.go create mode 100644 e2e/cli-plugins/help_test.go diff --git a/cli-plugins/manager/cobra.go b/cli-plugins/manager/cobra.go new file mode 100644 index 000000000000..302d338a1c99 --- /dev/null +++ b/cli-plugins/manager/cobra.go @@ -0,0 +1,57 @@ +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 plugin +// (optionally including invalid ones). The command stubs will have +// several annotations added, see `CommandAnnotationPlugin*`. +func AddPluginCommandStubs(dockerCli command.Cli, cmd *cobra.Command, includeInvalid bool) error { + plugins, err := ListPlugins(dockerCli, cmd) + if err != nil { + return err + } + for _, p := range plugins { + if !includeInvalid && p.Err != nil { + continue + } + vendor := p.Vendor + if vendor == "" { + vendor = "unknown" + } + annotations := map[string]string{ + CommandAnnotationPlugin: "true", + CommandAnnotationPluginVendor: vendor, + } + if p.Err != nil { + annotations[CommandAnnotationPluginInvalid] = p.Err.Error() + } + cmd.AddCommand(&cobra.Command{ + Use: p.Name, + Short: p.ShortDescription, + Run: func(_ *cobra.Command, _ []string) {}, + Annotations: annotations, + }) + } + return nil +} diff --git a/cli-plugins/manager/manager.go b/cli-plugins/manager/manager.go index 882d2ee2ea98..b6ab943595e7 100644 --- a/cli-plugins/manager/manager.go +++ b/cli-plugins/manager/manager.go @@ -1,10 +1,12 @@ package manager import ( + "io/ioutil" "os" "os/exec" "path/filepath" "runtime" + "strings" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/config" @@ -41,6 +43,81 @@ func getPluginDirs(dockerCli command.Cli) []string { 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) + if runtime.GOOS == "windows" { + exe := ".exe" + if !strings.HasSuffix(name, exe) { + continue + } + name = strings.TrimSuffix(name, exe) + } + 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. diff --git a/cli-plugins/manager/manager_test.go b/cli-plugins/manager/manager_test.go index 3a62b911fab1..450ae6120071 100644 --- a/cli-plugins/manager/manager_test.go +++ b/cli-plugins/manager/manager_test.go @@ -7,8 +7,79 @@ import ( "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() diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go index 8a07c5853726..cc7e312db88d 100644 --- a/cli-plugins/manager/plugin.go +++ b/cli-plugins/manager/plugin.go @@ -69,6 +69,12 @@ func newPlugin(c Candidate, rootcmd *cobra.Command) (Plugin, error) { 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 = errors.Errorf("plugin %q duplicates builtin command", p.Name) return p, nil diff --git a/cli/cobra.go b/cli/cobra.go index 8acce9478392..6a7511011746 100644 --- a/cli/cobra.go +++ b/cli/cobra.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + pluginmanager "github.com/docker/cli/cli-plugins/manager" cliconfig "github.com/docker/cli/cli/config" cliflags "github.com/docker/cli/cli/flags" "github.com/docker/docker/pkg/term" @@ -14,7 +15,7 @@ import ( // setupCommonRootCommand contains the setup common to // SetupRootCommand and SetupPluginRootCommand. -func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet) { +func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet, *cobra.Command) { opts := cliflags.NewClientOptions() flags := rootCmd.Flags() @@ -26,19 +27,21 @@ func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *p cobra.AddTemplateFunc("operationSubCommands", operationSubCommands) cobra.AddTemplateFunc("managementSubCommands", managementSubCommands) cobra.AddTemplateFunc("wrappedFlagUsages", wrappedFlagUsages) + cobra.AddTemplateFunc("commandVendor", commandVendor) + cobra.AddTemplateFunc("isFirstLevelCommand", isFirstLevelCommand) // is it an immediate sub-command of the root rootCmd.SetUsageTemplate(usageTemplate) rootCmd.SetHelpTemplate(helpTemplate) rootCmd.SetFlagErrorFunc(FlagErrorFunc) rootCmd.SetHelpCommand(helpCommand) - return opts, flags + 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) { - opts, flags := setupCommonRootCommand(rootCmd) +func SetupRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet, *cobra.Command) { + opts, flags, helpCmd := setupCommonRootCommand(rootCmd) rootCmd.SetVersionTemplate("Docker version {{.Version}}\n") @@ -46,12 +49,12 @@ func SetupRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.F rootCmd.PersistentFlags().MarkShorthandDeprecated("help", "please use --help") rootCmd.PersistentFlags().Lookup("help").Hidden = true - return opts, flags + 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) + opts, flags, _ := setupCommonRootCommand(rootCmd) rootCmd.PersistentFlags().BoolP("help", "", false, "Print usage") rootCmd.PersistentFlags().Lookup("help").Hidden = true @@ -138,6 +141,21 @@ func wrappedFlagUsages(cmd *cobra.Command) string { return cmd.Flags().FlagUsagesWrapped(width - 1) } +func isFirstLevelCommand(cmd *cobra.Command) bool { + return cmd.Parent() == cmd.Root() +} + +func commandVendor(cmd *cobra.Command) string { + width := 13 + if v, ok := cmd.Annotations[pluginmanager.CommandAnnotationPluginVendor]; ok { + if len(v) > width-2 { + v = v[:width-3] + "…" + } + return fmt.Sprintf("%-*s", width, "("+v+")") + } + return strings.Repeat(" ", width) +} + func managementSubCommands(cmd *cobra.Command) []*cobra.Command { cmds := []*cobra.Command{} for _, sub := range cmd.Commands() { @@ -178,7 +196,7 @@ Options: Management Commands: {{- range managementSubCommands . }} - {{rpad .Name .NamePadding }} {{.Short}} + {{rpad .Name .NamePadding }} {{ if isFirstLevelCommand .}}{{commandVendor .}} {{ end}}{{.Short}} {{- end}} {{- end}} @@ -187,7 +205,7 @@ Management Commands: Commands: {{- range operationSubCommands . }} - {{rpad .Name .NamePadding }} {{.Short}} + {{rpad .Name .NamePadding }} {{ if isFirstLevelCommand .}}{{commandVendor .}} {{ end}}{{.Short}} {{- end}} {{- end}} diff --git a/cli/cobra_test.go b/cli/cobra_test.go index 8c9cf1b19fc6..99744e0670e8 100644 --- a/cli/cobra_test.go +++ b/cli/cobra_test.go @@ -3,6 +3,7 @@ package cli import ( "testing" + pluginmanager "github.com/docker/cli/cli-plugins/manager" "github.com/spf13/cobra" "gotest.tools/assert" ) @@ -28,3 +29,29 @@ func TestVisitAll(t *testing.T) { 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) + }) + } +} diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 062cbfdc9b73..b9734453ffbd 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -21,8 +21,9 @@ import ( func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { var ( - opts *cliflags.ClientOptions - flags *pflag.FlagSet + opts *cliflags.ClientOptions + flags *pflag.FlagSet + helpCmd *cobra.Command ) cmd := &cobra.Command{ @@ -57,11 +58,12 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { Version: fmt.Sprintf("%s, build %s", version.Version, version.GitCommit), DisableFlagsInUseLine: true, } - opts, flags = cli.SetupRootCommand(cmd) + opts, flags, helpCmd = cli.SetupRootCommand(cmd) flags.BoolP("version", "v", false, "Print version information and quit") setFlagErrorFunc(dockerCli, cmd, flags, opts) + setupHelpCommand(dockerCli, cmd, helpCmd, flags, opts) setHelpFunc(dockerCli, cmd, flags, opts) cmd.SetOutput(dockerCli.Out()) @@ -90,6 +92,34 @@ 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 + } + + // Add a stub entry for every plugin so they are + // included in the help output. If we have no args + // then this is being used for `docker help` and we + // want to include broken plugins, otherwise this is + // `help «foo»` and we do not. + if err := pluginmanager.AddPluginCommandStubs(dockerCli, rootCmd, len(args) == 0); err != nil { + return err + } + + if origRunE != nil { + return origRunE(c, args) + } + origRun(c, args) + return nil + } +} + func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag.FlagSet, opts *cliflags.ClientOptions) { defaultHelpFunc := cmd.HelpFunc() cmd.SetHelpFunc(func(ccmd *cobra.Command, args []string) { diff --git a/e2e/cli-plugins/help_test.go b/e2e/cli-plugins/help_test.go new file mode 100644 index 000000000000..c66dfacc0f4c --- /dev/null +++ b/e2e/cli-plugins/help_test.go @@ -0,0 +1,54 @@ +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 + // + // Regexps are needed because the width depends on `unix.TIOCGWINSZ` or similar. + 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$`), + regexp.MustCompile(`^ helloworld\s+\(Docker Inc\.\)\s+A basic Hello World plugin for tests$`), + regexp.MustCompile(`^ ps\s+List containers$`), + } { + var found bool + for scanner.Scan() { + if expected.MatchString(scanner.Text()) { + found = true + break + } + } + assert.Assert(t, found, "Did not find match for %q in `docker help` output", expected) + } +} From c43da091888d6b2a427bab7b6c917107894ecbcd Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Tue, 22 Jan 2019 13:44:39 +0000 Subject: [PATCH 12/23] Add stubs when calling help due to no arguments e.g. the `docker` case which should act as `docker help`. Signed-off-by: Ian Campbell --- cmd/docker/docker.go | 20 +++++++++++--------- e2e/cli-plugins/help_test.go | 8 ++++++++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index b9734453ffbd..d781b688ca42 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -103,15 +103,6 @@ func setupHelpCommand(dockerCli *command.DockerCli, rootCmd, helpCmd *cobra.Comm return err } - // Add a stub entry for every plugin so they are - // included in the help output. If we have no args - // then this is being used for `docker help` and we - // want to include broken plugins, otherwise this is - // `help «foo»` and we do not. - if err := pluginmanager.AddPluginCommandStubs(dockerCli, rootCmd, len(args) == 0); err != nil { - return err - } - if origRunE != nil { return origRunE(c, args) } @@ -135,6 +126,17 @@ func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag. ccmd.Println(err) return } + + // Add a stub entry for every plugin so they are + // included in the help output. If we have no args + // then this is being used for `docker help` and we + // want to include broken plugins, otherwise this is + // `help «foo»` and we do not. + if err := pluginmanager.AddPluginCommandStubs(dockerCli, ccmd.Root(), len(args) == 0); err != nil { + ccmd.Println(err) + return + } + defaultHelpFunc(ccmd, args) }) } diff --git a/e2e/cli-plugins/help_test.go b/e2e/cli-plugins/help_test.go index c66dfacc0f4c..f9690fe50324 100644 --- a/e2e/cli-plugins/help_test.go +++ b/e2e/cli-plugins/help_test.go @@ -51,4 +51,12 @@ func TestGlobalHelp(t *testing.T) { } assert.Assert(t, found, "Did not find match for %q in `docker help` output", expected) } + + // Running just `docker` (without 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())) } From 20a284721cc59563627e5d927344735a1fa0a2b1 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Tue, 11 Dec 2018 14:52:59 +0000 Subject: [PATCH 13/23] =?UTF-8?q?Integrate=20CLI=20plugins=20with=20`docke?= =?UTF-8?q?r=20help=20=C2=ABfoo=C2=BB`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ian Campbell --- cmd/docker/docker.go | 12 +++++ e2e/cli-plugins/run_test.go | 52 +++++++++++++++++++ .../testdata/docker-help-badmeta-err.golden | 1 + .../docker-help-helloworld-goodbye.golden | 4 ++ .../testdata/docker-help-helloworld.golden | 9 ++++ .../docker-help-nonexistent-err.golden | 1 + 6 files changed, 79 insertions(+) create mode 100644 e2e/cli-plugins/testdata/docker-help-badmeta-err.golden create mode 100644 e2e/cli-plugins/testdata/docker-help-helloworld-goodbye.golden create mode 100644 e2e/cli-plugins/testdata/docker-help-helloworld.golden create mode 100644 e2e/cli-plugins/testdata/docker-help-nonexistent-err.golden diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index d781b688ca42..f4b94652ecbb 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -103,6 +103,18 @@ func setupHelpCommand(dockerCli *command.DockerCli, rootCmd, helpCmd *cobra.Comm 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) } diff --git a/e2e/cli-plugins/run_test.go b/e2e/cli-plugins/run_test.go index e4cd94d59ad6..34bd84c9a863 100644 --- a/e2e/cli-plugins/run_test.go +++ b/e2e/cli-plugins/run_test.go @@ -22,6 +22,19 @@ func TestRunNonexisting(t *testing.T) { 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") +} + // TestRunBad ensures correct behaviour when running an existent but invalid plugin func TestRunBad(t *testing.T) { run, cleanup := prepare(t) @@ -35,6 +48,19 @@ func TestRunBad(t *testing.T) { 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") +} + // TestRunGood ensures correct behaviour when running a valid plugin func TestRunGood(t *testing.T) { run, cleanup := prepare(t) @@ -47,6 +73,19 @@ func TestRunGood(t *testing.T) { }) } +// 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(), "")) +} + // TestRunGoodSubcommand ensures correct behaviour when running a valid plugin with a subcommand func TestRunGoodSubcommand(t *testing.T) { run, cleanup := prepare(t) @@ -58,3 +97,16 @@ func TestRunGoodSubcommand(t *testing.T) { Out: "Goodbye World!", }) } + +// 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(), "")) +} diff --git a/e2e/cli-plugins/testdata/docker-help-badmeta-err.golden b/e2e/cli-plugins/testdata/docker-help-badmeta-err.golden new file mode 100644 index 000000000000..13a827f4064e --- /dev/null +++ b/e2e/cli-plugins/testdata/docker-help-badmeta-err.golden @@ -0,0 +1 @@ +unknown help topic: badmeta diff --git a/e2e/cli-plugins/testdata/docker-help-helloworld-goodbye.golden b/e2e/cli-plugins/testdata/docker-help-helloworld-goodbye.golden new file mode 100644 index 000000000000..d789f7997f78 --- /dev/null +++ b/e2e/cli-plugins/testdata/docker-help-helloworld-goodbye.golden @@ -0,0 +1,4 @@ + +Usage: docker helloworld goodbye + +Say Goodbye instead of Hello diff --git a/e2e/cli-plugins/testdata/docker-help-helloworld.golden b/e2e/cli-plugins/testdata/docker-help-helloworld.golden new file mode 100644 index 000000000000..67390e6e442d --- /dev/null +++ b/e2e/cli-plugins/testdata/docker-help-helloworld.golden @@ -0,0 +1,9 @@ + +Usage: docker helloworld COMMAND + +A basic Hello World plugin for tests + +Commands: + goodbye Say Goodbye instead of Hello + +Run 'docker helloworld COMMAND --help' for more information on a command. diff --git a/e2e/cli-plugins/testdata/docker-help-nonexistent-err.golden b/e2e/cli-plugins/testdata/docker-help-nonexistent-err.golden new file mode 100644 index 000000000000..7a151caa3eb5 --- /dev/null +++ b/e2e/cli-plugins/testdata/docker-help-nonexistent-err.golden @@ -0,0 +1 @@ +unknown help topic: nonexistent From 53f018120ae8d93531245ba56d4acdfa4bd837ba Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Mon, 17 Dec 2018 16:23:55 +0000 Subject: [PATCH 14/23] =?UTF-8?q?Integrate=20CLI=20plugins=20with=20`docke?= =?UTF-8?q?r=20=C2=ABplugin=C2=BB=20--help`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To achieve this we hook in at the beginning of our custom `HelpFunc` and detect the plugin case by adding stub commands. Signed-off-by: Ian Campbell --- cmd/docker/docker.go | 26 ++++++++++++++++ e2e/cli-plugins/run_test.go | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index f4b94652ecbb..1ebf30a89d25 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -123,6 +123,21 @@ func setupHelpCommand(dockerCli *command.DockerCli, rootCmd, helpCmd *cobra.Comm } } +func tryRunPluginHelp(dockerCli command.Cli, ccmd *cobra.Command, cargs []string) error { + root := ccmd.Root() + pluginmanager.AddPluginCommandStubs(dockerCli, root, false) + + cmd, _, err := root.Traverse(cargs) + if err != nil { + return err + } + helpcmd, err := pluginmanager.PluginRunCommand(dockerCli, cmd.Name(), root) + if err != nil { + return err + } + return helpcmd.Run() +} + func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag.FlagSet, opts *cliflags.ClientOptions) { defaultHelpFunc := cmd.HelpFunc() cmd.SetHelpFunc(func(ccmd *cobra.Command, args []string) { @@ -130,6 +145,17 @@ func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag. ccmd.Println(err) return } + if len(args) >= 1 { + err := tryRunPluginHelp(dockerCli, ccmd, args) + if err == nil { // Successfully ran the plugin + return + } + if !pluginmanager.IsNotFound(err) { + ccmd.Println(err) + return + } + } + if err := isSupported(ccmd, dockerCli); err != nil { ccmd.Println(err) return diff --git a/e2e/cli-plugins/run_test.go b/e2e/cli-plugins/run_test.go index 34bd84c9a863..30a13cf79e2e 100644 --- a/e2e/cli-plugins/run_test.go +++ b/e2e/cli-plugins/run_test.go @@ -35,6 +35,22 @@ func TestHelpNonexisting(t *testing.T) { 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) @@ -61,6 +77,22 @@ func TestHelpBad(t *testing.T) { 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) @@ -86,6 +118,20 @@ func TestHelpGood(t *testing.T) { 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) @@ -110,3 +156,17 @@ func TestHelpGoodSubcommand(t *testing.T) { 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(), "")) +} From e5e578abc7d8cb507c08cdca56386d1a37f041ae Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Mon, 17 Dec 2018 15:55:38 +0000 Subject: [PATCH 15/23] Allow plugins to make use of the cobra `PersistentPreRun` hooks. Previously a plugin which used these hooks would overwrite the top-level plugin command's use of this hook, resulting in the dockerCli object not being fully initialised. Provide a function which plugins can use to chain to the required behaviour. This required some fairly ugly arrangements to preserve state (which was previously in-scope in `newPluginCOmmand`) to be used by the new function. Signed-off-by: Ian Campbell --- cli-plugins/examples/helloworld/main.go | 20 +++++- cli-plugins/plugin/plugin.go | 62 ++++++++++++++----- e2e/cli-plugins/run_test.go | 12 ++++ .../testdata/docker-help-helloworld.golden | 1 + 4 files changed, 78 insertions(+), 17 deletions(-) diff --git a/cli-plugins/examples/helloworld/main.go b/cli-plugins/examples/helloworld/main.go index e79e32ce5436..9a511591ec96 100644 --- a/cli-plugins/examples/helloworld/main.go +++ b/cli-plugins/examples/helloworld/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "github.com/docker/cli/cli-plugins/manager" @@ -18,16 +19,33 @@ func main() { 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 + }, + } 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.Fprintln(dockerCli.Out(), "Hello World!") }, } - cmd.AddCommand(goodbye) + cmd.AddCommand(goodbye, apiversion) return cmd }, manager.Metadata{ diff --git a/cli-plugins/plugin/plugin.go b/cli-plugins/plugin/plugin.go index ce2bd0bd6f50..c72a68bb7eb0 100644 --- a/cli-plugins/plugin/plugin.go +++ b/cli-plugins/plugin/plugin.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "sync" "github.com/docker/cli/cli" "github.com/docker/cli/cli-plugins/manager" @@ -42,29 +43,53 @@ func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) { } } -func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager.Metadata) *cobra.Command { - var ( - opts *cliflags.ClientOptions - flags *pflag.FlagSet - ) +// 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: func(cmd *cobra.Command, args []string) error { - // flags must be the top-level command flags, not cmd.Flags() - opts.Common.SetDefaultOptions(flags) - return dockerCli.Initialize(opts) - }, + 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) + opts, flags := cli.SetupPluginRootCommand(cmd) cmd.SetOutput(dockerCli.Out()) @@ -75,6 +100,11 @@ func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta cli.DisableFlagsInUseLine(cmd) + options.init.Do(func() { + options.opts = opts + options.flags = flags + options.dockerCli = dockerCli + }) return cmd } diff --git a/e2e/cli-plugins/run_test.go b/e2e/cli-plugins/run_test.go index 30a13cf79e2e..d16bf3bb8be9 100644 --- a/e2e/cli-plugins/run_test.go +++ b/e2e/cli-plugins/run_test.go @@ -170,3 +170,15 @@ func TestGoodSubcommandHelp(t *testing.T) { golden.Assert(t, res.Stdout(), "docker-help-helloworld-goodbye.golden") assert.Assert(t, is.Equal(res.Stderr(), "")) } + +// TestCliInitialized tests the code paths which ensure that the Cli +// object is initialized even if the plugin uses PersistentRunE +func TestCliInitialized(t *testing.T) { + run, cleanup := prepare(t) + defer cleanup() + + res := icmd.RunCmd(run("helloworld", "apiversion")) + res.Assert(t, icmd.Success) + assert.Assert(t, res.Stdout() != "") + assert.Assert(t, is.Equal(res.Stderr(), "")) +} diff --git a/e2e/cli-plugins/testdata/docker-help-helloworld.golden b/e2e/cli-plugins/testdata/docker-help-helloworld.golden index 67390e6e442d..e7252bf2d016 100644 --- a/e2e/cli-plugins/testdata/docker-help-helloworld.golden +++ b/e2e/cli-plugins/testdata/docker-help-helloworld.golden @@ -4,6 +4,7 @@ Usage: docker helloworld COMMAND A basic Hello World plugin for tests 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. From 0ab8ec0e4c1226cc176cdbcef9a8083d6995dc89 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Wed, 19 Dec 2018 11:29:01 +0000 Subject: [PATCH 16/23] Output broken CLI plugins in `help` output. Signed-off-by: Ian Campbell --- cli/cobra.go | 44 ++++++++++++++++++++++++++++++++++++ cli/cobra_test.go | 21 +++++++++++++++++ e2e/cli-plugins/help_test.go | 3 +++ 3 files changed, 68 insertions(+) diff --git a/cli/cobra.go b/cli/cobra.go index 6a7511011746..cea1a9be4e85 100644 --- a/cli/cobra.go +++ b/cli/cobra.go @@ -24,11 +24,14 @@ func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *p cobra.AddTemplateFunc("hasSubCommands", hasSubCommands) cobra.AddTemplateFunc("hasManagementSubCommands", hasManagementSubCommands) + cobra.AddTemplateFunc("hasInvalidPlugins", hasInvalidPlugins) cobra.AddTemplateFunc("operationSubCommands", operationSubCommands) cobra.AddTemplateFunc("managementSubCommands", managementSubCommands) + cobra.AddTemplateFunc("invalidPlugins", invalidPlugins) cobra.AddTemplateFunc("wrappedFlagUsages", wrappedFlagUsages) cobra.AddTemplateFunc("commandVendor", commandVendor) cobra.AddTemplateFunc("isFirstLevelCommand", isFirstLevelCommand) // is it an immediate sub-command of the root + cobra.AddTemplateFunc("invalidPluginReason", invalidPluginReason) rootCmd.SetUsageTemplate(usageTemplate) rootCmd.SetHelpTemplate(helpTemplate) @@ -115,6 +118,10 @@ var helpCommand = &cobra.Command{ }, } +func isPlugin(cmd *cobra.Command) bool { + return cmd.Annotations[pluginmanager.CommandAnnotationPlugin] == "true" +} + func hasSubCommands(cmd *cobra.Command) bool { return len(operationSubCommands(cmd)) > 0 } @@ -123,9 +130,16 @@ func hasManagementSubCommands(cmd *cobra.Command) bool { return len(managementSubCommands(cmd)) > 0 } +func hasInvalidPlugins(cmd *cobra.Command) bool { + return len(invalidPlugins(cmd)) > 0 +} + func operationSubCommands(cmd *cobra.Command) []*cobra.Command { cmds := []*cobra.Command{} for _, sub := range cmd.Commands() { + if isPlugin(sub) && invalidPluginReason(sub) != "" { + continue + } if sub.IsAvailableCommand() && !sub.HasSubCommands() { cmds = append(cmds, sub) } @@ -159,6 +173,9 @@ func commandVendor(cmd *cobra.Command) string { func managementSubCommands(cmd *cobra.Command) []*cobra.Command { cmds := []*cobra.Command{} for _, sub := range cmd.Commands() { + if isPlugin(sub) && invalidPluginReason(sub) != "" { + continue + } if sub.IsAvailableCommand() && sub.HasSubCommands() { cmds = append(cmds, sub) } @@ -166,6 +183,23 @@ func managementSubCommands(cmd *cobra.Command) []*cobra.Command { return cmds } +func invalidPlugins(cmd *cobra.Command) []*cobra.Command { + cmds := []*cobra.Command{} + for _, sub := range cmd.Commands() { + if !isPlugin(sub) { + continue + } + if invalidPluginReason(sub) != "" { + cmds = append(cmds, sub) + } + } + return cmds +} + +func invalidPluginReason(cmd *cobra.Command) string { + return cmd.Annotations[pluginmanager.CommandAnnotationPluginInvalid] +} + var usageTemplate = `Usage: {{- if not .HasSubCommands}} {{.UseLine}}{{end}} @@ -209,6 +243,16 @@ Commands: {{- end}} {{- end}} +{{- if hasInvalidPlugins . }} + +Invalid Plugins: + +{{- range invalidPlugins . }} + {{rpad .Name .NamePadding }} {{invalidPluginReason .}} +{{- end}} + +{{- end}} + {{- if .HasSubCommands }} Run '{{.CommandPath}} COMMAND --help' for more information on a command. diff --git a/cli/cobra_test.go b/cli/cobra_test.go index 99744e0670e8..a9d943e678bf 100644 --- a/cli/cobra_test.go +++ b/cli/cobra_test.go @@ -4,8 +4,10 @@ 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) { @@ -55,3 +57,22 @@ func TestCommandVendor(t *testing.T) { }) } } + +func TestInvalidPlugin(t *testing.T) { + root := &cobra.Command{Use: "root"} + sub1 := &cobra.Command{Use: "sub1"} + sub1sub1 := &cobra.Command{Use: "sub1sub1"} + sub1sub2 := &cobra.Command{Use: "sub1sub2"} + sub2 := &cobra.Command{Use: "sub2"} + + assert.Assert(t, is.Len(invalidPlugins(root), 0)) + + sub1.Annotations = map[string]string{ + pluginmanager.CommandAnnotationPlugin: "true", + pluginmanager.CommandAnnotationPluginInvalid: "foo", + } + root.AddCommand(sub1, sub2) + sub1.AddCommand(sub1sub1, sub1sub2) + + assert.DeepEqual(t, invalidPlugins(root), []*cobra.Command{sub1}, cmpopts.IgnoreUnexported(cobra.Command{})) +} diff --git a/e2e/cli-plugins/help_test.go b/e2e/cli-plugins/help_test.go index f9690fe50324..f82959dbab05 100644 --- a/e2e/cli-plugins/help_test.go +++ b/e2e/cli-plugins/help_test.go @@ -31,6 +31,7 @@ func TestGlobalHelp(t *testing.T) { // - 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. for _, expected := range []*regexp.Regexp{ @@ -41,6 +42,8 @@ func TestGlobalHelp(t *testing.T) { regexp.MustCompile(`^ create\s+Create a new container$`), regexp.MustCompile(`^ helloworld\s+\(Docker Inc\.\)\s+A basic Hello World plugin for tests$`), regexp.MustCompile(`^ ps\s+List containers$`), + regexp.MustCompile(`^Invalid Plugins:$`), + regexp.MustCompile(`^ badmeta\s+invalid metadata: invalid character 'i' looking for beginning of object key string$`), } { var found bool for scanner.Scan() { From 1c576e90435283451051056bb6987b28212122f3 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Wed, 19 Dec 2018 14:49:20 +0000 Subject: [PATCH 17/23] Integrate CLI plugins into `docker info` Fairly straight forward. It became necessary to wrap `Plugin.Err` with a type which implements `encoding.MarshalText` in order to have that field rendered properly in the `docker info -f '{{json}}'` output. Since I changed the type somewhat I also added a unit test for `formatInfo`. Signed-off-by: Ian Campbell --- cli-plugins/manager/candidate_test.go | 3 + cli-plugins/manager/error.go | 43 ++++++++++++++ cli-plugins/manager/error_test.go | 24 ++++++++ cli-plugins/manager/metadata.go | 10 ++-- cli-plugins/manager/plugin.go | 23 ++++---- cli/command/system/info.go | 27 ++++++++- cli/command/system/info_test.go | 37 +++++++++++- .../testdata/docker-info-badsec.json.golden | 2 +- .../docker-info-daemon-warnings.json.golden | 2 +- .../docker-info-legacy-warnings.json.golden | 2 +- .../testdata/docker-info-no-swarm.json.golden | 2 +- .../docker-info-plugins-warnings.golden | 1 + .../testdata/docker-info-plugins.golden | 56 +++++++++++++++++++ .../testdata/docker-info-plugins.json.golden | 1 + .../docker-info-with-swarm.json.golden | 2 +- 15 files changed, 210 insertions(+), 25 deletions(-) create mode 100644 cli-plugins/manager/error.go create mode 100644 cli-plugins/manager/error_test.go create mode 100644 cli/command/system/testdata/docker-info-plugins-warnings.golden create mode 100644 cli/command/system/testdata/docker-info-plugins.golden create mode 100644 cli/command/system/testdata/docker-info-plugins.json.golden diff --git a/cli-plugins/manager/candidate_test.go b/cli-plugins/manager/candidate_test.go index 11384226a5d4..b5ed06453f76 100644 --- a/cli-plugins/manager/candidate_test.go +++ b/cli-plugins/manager/candidate_test.go @@ -2,11 +2,13 @@ package manager import ( "fmt" + "reflect" "strings" "testing" "github.com/spf13/cobra" "gotest.tools/assert" + "gotest.tools/assert/cmp" ) type fakeCandidate struct { @@ -73,6 +75,7 @@ func TestValidateCandidate(t *testing.T) { 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) diff --git a/cli-plugins/manager/error.go b/cli-plugins/manager/error.go new file mode 100644 index 000000000000..1ad28678695a --- /dev/null +++ b/cli-plugins/manager/error.go @@ -0,0 +1,43 @@ +package manager + +import ( + "github.com/pkg/errors" +) + +// pluginError is set as Plugin.Err by NewPlugin if the plugin +// candidate fails one of the candidate tests. This exists primarily +// to implement encoding.TextMarshaller such that rendering a plugin as JSON +// (e.g. for `docker info -f '{{json .CLIPlugins}}'`) renders the Err +// field as a useful string and not just `{}`. See +// https://github.com/golang/go/issues/10748 for some discussion +// around why the builtin error type doesn't implement this. +type pluginError struct { + cause error +} + +// Error satisfies the core error interface for pluginError. +func (e *pluginError) Error() string { + return e.cause.Error() +} + +// Cause satisfies the errors.causer interface for pluginError. +func (e *pluginError) Cause() error { + return e.cause +} + +// MarshalText marshalls the pluginError into a textual form. +func (e *pluginError) MarshalText() (text []byte, err error) { + return []byte(e.cause.Error()), nil +} + +// wrapAsPluginError wraps an error in a pluginError with an +// additional message, analogous to errors.Wrapf. +func wrapAsPluginError(err error, msg string) error { + return &pluginError{cause: errors.Wrap(err, msg)} +} + +// NewPluginError creates a new pluginError, analogous to +// errors.Errorf. +func NewPluginError(msg string, args ...interface{}) error { + return &pluginError{cause: errors.Errorf(msg, args...)} +} diff --git a/cli-plugins/manager/error_test.go b/cli-plugins/manager/error_test.go new file mode 100644 index 000000000000..04614e24ff47 --- /dev/null +++ b/cli-plugins/manager/error_test.go @@ -0,0 +1,24 @@ +package manager + +import ( + "fmt" + "testing" + + "github.com/pkg/errors" + "gopkg.in/yaml.v2" + "gotest.tools/assert" +) + +func TestPluginError(t *testing.T) { + err := NewPluginError("new error") + assert.Error(t, err, "new error") + + inner := fmt.Errorf("testing") + err = wrapAsPluginError(inner, "wrapping") + assert.Error(t, err, "wrapping: testing") + assert.Equal(t, inner, errors.Cause(err)) + + actual, err := yaml.Marshal(err) + assert.NilError(t, err) + assert.Equal(t, "'wrapping: testing'\n", string(actual)) +} diff --git a/cli-plugins/manager/metadata.go b/cli-plugins/manager/metadata.go index 2d5734e8682c..1df3c8e08fde 100644 --- a/cli-plugins/manager/metadata.go +++ b/cli-plugins/manager/metadata.go @@ -13,13 +13,13 @@ const ( // Metadata provided by the plugin type Metadata struct { // SchemaVersion describes the version of this struct. Mandatory, must be "0.1.0" - SchemaVersion string + SchemaVersion string `json:",omitempty"` // Vendor is the name of the plugin vendor. Mandatory - Vendor string + Vendor string `json:",omitempty"` // Version is the optional version of this plugin. - Version string + Version string `json:",omitempty"` // ShortDescription should be suitable for a single line help message. - ShortDescription string + ShortDescription string `json:",omitempty"` // URL is a pointer to the plugin's homepage. - URL string + URL string `json:",omitempty"` } diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go index cc7e312db88d..06a0ba8345ed 100644 --- a/cli-plugins/manager/plugin.go +++ b/cli-plugins/manager/plugin.go @@ -19,8 +19,8 @@ var ( type Plugin struct { Metadata - Name string - Path string + 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"` @@ -31,8 +31,9 @@ type Plugin struct { // 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, but the `Plugin` is still returned with no error. An error -// is only returned due to a non-recoverable error. +// 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 == "" { @@ -63,7 +64,7 @@ func newPlugin(c Candidate, rootcmd *cobra.Command) (Plugin, error) { // Now apply the candidate tests, so these update p.Err. if !pluginNameRe.MatchString(p.Name) { - p.Err = errors.Errorf("plugin candidate %q did not match %q", p.Name, pluginNameRe.String()) + p.Err = NewPluginError("plugin candidate %q did not match %q", p.Name, pluginNameRe.String()) return p, nil } @@ -76,11 +77,11 @@ func newPlugin(c Candidate, rootcmd *cobra.Command) (Plugin, error) { continue } if cmd.Name() == p.Name { - p.Err = errors.Errorf("plugin %q duplicates builtin command", p.Name) + p.Err = NewPluginError("plugin %q duplicates builtin command", p.Name) return p, nil } if cmd.HasAlias(p.Name) { - p.Err = errors.Errorf("plugin %q duplicates an alias of builtin command %q", p.Name, cmd.Name()) + p.Err = NewPluginError("plugin %q duplicates an alias of builtin command %q", p.Name, cmd.Name()) return p, nil } } @@ -89,21 +90,21 @@ func newPlugin(c Candidate, rootcmd *cobra.Command) (Plugin, error) { // 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 = errors.Wrap(err, "failed to fetch metadata") + p.Err = wrapAsPluginError(err, "failed to fetch metadata") return p, nil } if err := json.Unmarshal(meta, &p.Metadata); err != nil { - p.Err = errors.Wrap(err, "invalid metadata") + p.Err = wrapAsPluginError(err, "invalid metadata") return p, nil } if p.Metadata.SchemaVersion != "0.1.0" { - p.Err = errors.Errorf("plugin SchemaVersion %q is not valid, must be 0.1.0", p.Metadata.SchemaVersion) + 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 = errors.Errorf("plugin metadata does not define a vendor") + p.Err = NewPluginError("plugin metadata does not define a vendor") return p, nil } return p, nil diff --git a/cli/command/system/info.go b/cli/command/system/info.go index 29380ea1a3e5..c86c04993cd3 100644 --- a/cli/command/system/info.go +++ b/cli/command/system/info.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/docker/cli/cli" + pluginmanager "github.com/docker/cli/cli-plugins/manager" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/debug" "github.com/docker/cli/templates" @@ -23,6 +24,7 @@ type infoOptions struct { type clientInfo struct { Debug bool + Plugins []pluginmanager.Plugin Warnings []string } @@ -47,7 +49,7 @@ func NewInfoCommand(dockerCli command.Cli) *cobra.Command { Short: "Display system-wide information", Args: cli.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return runInfo(dockerCli, &opts) + return runInfo(cmd, dockerCli, &opts) }, } @@ -58,7 +60,7 @@ func NewInfoCommand(dockerCli command.Cli) *cobra.Command { return cmd } -func runInfo(dockerCli command.Cli, opts *infoOptions) error { +func runInfo(cmd *cobra.Command, dockerCli command.Cli, opts *infoOptions) error { var info info ctx := context.Background() @@ -71,6 +73,11 @@ func runInfo(dockerCli command.Cli, opts *infoOptions) error { info.ClientInfo = &clientInfo{ Debug: debug.IsEnabled(), } + if plugins, err := pluginmanager.ListPlugins(dockerCli, cmd.Root()); err == nil { + info.ClientInfo.Plugins = plugins + } else { + info.ClientErrors = append(info.ClientErrors, err.Error()) + } if opts.format == "" { return prettyPrintInfo(dockerCli, info) @@ -109,6 +116,17 @@ func prettyPrintInfo(dockerCli command.Cli, info info) error { func prettyPrintClientInfo(dockerCli command.Cli, info clientInfo) error { fmt.Fprintln(dockerCli.Out(), " Debug Mode:", info.Debug) + if len(info.Plugins) > 0 { + fmt.Fprintln(dockerCli.Out(), " Plugins:") + for _, p := range info.Plugins { + if p.Err == nil { + fmt.Fprintf(dockerCli.Out(), " %s: (%s, %s) %s\n", p.Name, p.Version, p.Vendor, p.ShortDescription) + } else { + info.Warnings = append(info.Warnings, fmt.Sprintf("WARNING: Plugin %q is not valid: %s", p.Path, p.Err)) + } + } + } + if len(info.Warnings) > 0 { fmt.Fprintln(dockerCli.Err(), strings.Join(info.Warnings, "\n")) } @@ -447,6 +465,11 @@ func getBackingFs(info types.Info) string { } func formatInfo(dockerCli command.Cli, info info, format string) error { + // Ensure slice/array fields render as `[]` not `null` + if info.ClientInfo != nil && info.ClientInfo.Plugins == nil { + info.ClientInfo.Plugins = make([]pluginmanager.Plugin, 0) + } + tmpl, err := templates.Parse(format) if err != nil { return cli.StatusError{StatusCode: 64, diff --git a/cli/command/system/info_test.go b/cli/command/system/info_test.go index d3bcd7e02af6..cb46715e5340 100644 --- a/cli/command/system/info_test.go +++ b/cli/command/system/info_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + pluginmanager "github.com/docker/cli/cli-plugins/manager" "github.com/docker/cli/internal/test" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/registry" @@ -192,6 +193,24 @@ PQQDAgNIADBFAiEAo9fTQNM5DP9bHVcTJYfl2Cay1bFu1E+lnpmN+EYJfeACIGKH }, } +var samplePluginsInfo = []pluginmanager.Plugin{ + { + Name: "goodplugin", + Path: "/path/to/docker-goodplugin", + Metadata: pluginmanager.Metadata{ + SchemaVersion: "0.1.0", + ShortDescription: "unit test is good", + Vendor: "ACME Corp", + Version: "0.1.0", + }, + }, + { + Name: "badplugin", + Path: "/path/to/docker-badplugin", + Err: pluginmanager.NewPluginError("something wrong"), + }, +} + func TestPrettyPrintInfo(t *testing.T) { infoWithSwarm := sampleInfoNoSwarm infoWithSwarm.Swarm = sampleSwarmInfo @@ -228,8 +247,9 @@ func TestPrettyPrintInfo(t *testing.T) { sampleInfoBadSecurity.SecurityOptions = []string{"foo="} for _, tc := range []struct { - doc string - dockerInfo info + doc string + dockerInfo info + prettyGolden string warningsGolden string jsonGolden string @@ -245,6 +265,19 @@ func TestPrettyPrintInfo(t *testing.T) { jsonGolden: "docker-info-no-swarm", }, { + doc: "info with plugins", + dockerInfo: info{ + Info: &sampleInfoNoSwarm, + ClientInfo: &clientInfo{ + Plugins: samplePluginsInfo, + }, + }, + prettyGolden: "docker-info-plugins", + jsonGolden: "docker-info-plugins", + warningsGolden: "docker-info-plugins-warnings", + }, + { + doc: "info with swarm", dockerInfo: info{ Info: &infoWithSwarm, diff --git a/cli/command/system/testdata/docker-info-badsec.json.golden b/cli/command/system/testdata/docker-info-badsec.json.golden index 4b15ac022239..3f3eea239385 100644 --- a/cli/command/system/testdata/docker-info-badsec.json.golden +++ b/cli/command/system/testdata/docker-info-badsec.json.golden @@ -1 +1 @@ -{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["foo="],"Warnings":null,"ServerErrors":["an error happened"],"ClientInfo":{"Debug":false,"Warnings":null}} +{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["foo="],"Warnings":null,"ServerErrors":["an error happened"],"ClientInfo":{"Debug":false,"Plugins":[],"Warnings":null}} diff --git a/cli/command/system/testdata/docker-info-daemon-warnings.json.golden b/cli/command/system/testdata/docker-info-daemon-warnings.json.golden index 504939799d6e..0a387bb51072 100644 --- a/cli/command/system/testdata/docker-info-daemon-warnings.json.golden +++ b/cli/command/system/testdata/docker-info-daemon-warnings.json.golden @@ -1 +1 @@ -{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":["WARNING: No memory limit support","WARNING: No swap limit support","WARNING: No kernel memory limit support","WARNING: No oom kill disable support","WARNING: No cpu cfs quota support","WARNING: No cpu cfs period support","WARNING: No cpu shares support","WARNING: No cpuset support","WARNING: IPv4 forwarding is disabled","WARNING: bridge-nf-call-iptables is disabled","WARNING: bridge-nf-call-ip6tables is disabled"],"ClientInfo":{"Debug":true,"Warnings":null}} +{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":["WARNING: No memory limit support","WARNING: No swap limit support","WARNING: No kernel memory limit support","WARNING: No oom kill disable support","WARNING: No cpu cfs quota support","WARNING: No cpu cfs period support","WARNING: No cpu shares support","WARNING: No cpuset support","WARNING: IPv4 forwarding is disabled","WARNING: bridge-nf-call-iptables is disabled","WARNING: bridge-nf-call-ip6tables is disabled"],"ClientInfo":{"Debug":true,"Plugins":[],"Warnings":null}} diff --git a/cli/command/system/testdata/docker-info-legacy-warnings.json.golden b/cli/command/system/testdata/docker-info-legacy-warnings.json.golden index 489f700eb3e6..1996dd6d359a 100644 --- a/cli/command/system/testdata/docker-info-legacy-warnings.json.golden +++ b/cli/command/system/testdata/docker-info-legacy-warnings.json.golden @@ -1 +1 @@ -{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":false,"SwapLimit":false,"KernelMemory":false,"KernelMemoryTCP":false,"CpuCfsPeriod":false,"CpuCfsQuota":false,"CPUShares":false,"CPUSet":false,"IPv4Forwarding":false,"BridgeNfIptables":false,"BridgeNfIp6tables":false,"Debug":true,"NFd":33,"OomKillDisable":false,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":true,"Warnings":null}} +{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":false,"SwapLimit":false,"KernelMemory":false,"KernelMemoryTCP":false,"CpuCfsPeriod":false,"CpuCfsQuota":false,"CPUShares":false,"CPUSet":false,"IPv4Forwarding":false,"BridgeNfIptables":false,"BridgeNfIp6tables":false,"Debug":true,"NFd":33,"OomKillDisable":false,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":true,"Plugins":[],"Warnings":null}} diff --git a/cli/command/system/testdata/docker-info-no-swarm.json.golden b/cli/command/system/testdata/docker-info-no-swarm.json.golden index b99808753108..7dd6c0eb2a57 100644 --- a/cli/command/system/testdata/docker-info-no-swarm.json.golden +++ b/cli/command/system/testdata/docker-info-no-swarm.json.golden @@ -1 +1 @@ -{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":true,"Warnings":null}} +{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":true,"Plugins":[],"Warnings":null}} diff --git a/cli/command/system/testdata/docker-info-plugins-warnings.golden b/cli/command/system/testdata/docker-info-plugins-warnings.golden new file mode 100644 index 000000000000..be6c83426af8 --- /dev/null +++ b/cli/command/system/testdata/docker-info-plugins-warnings.golden @@ -0,0 +1 @@ +WARNING: Plugin "/path/to/docker-badplugin" is not valid: something wrong diff --git a/cli/command/system/testdata/docker-info-plugins.golden b/cli/command/system/testdata/docker-info-plugins.golden new file mode 100644 index 000000000000..8a419965a263 --- /dev/null +++ b/cli/command/system/testdata/docker-info-plugins.golden @@ -0,0 +1,56 @@ +Client: + Debug Mode: false + Plugins: + goodplugin: (0.1.0, ACME Corp) unit test is good + +Server: + Containers: 0 + Running: 0 + Paused: 0 + Stopped: 0 + Images: 0 + Server Version: 17.06.1-ce + Storage Driver: aufs + Root Dir: /var/lib/docker/aufs + Backing Filesystem: extfs + Dirs: 0 + Dirperm1 Supported: true + Logging Driver: json-file + Cgroup Driver: cgroupfs + Plugins: + Volume: local + Network: bridge host macvlan null overlay + Log: awslogs fluentd gcplogs gelf journald json-file logentries splunk syslog + Swarm: inactive + Runtimes: runc + Default Runtime: runc + Init Binary: docker-init + containerd version: 6e23458c129b551d5c9871e5174f6b1b7f6d1170 + runc version: 810190ceaa507aa2727d7ae6f4790c76ec150bd2 + init version: 949e6fa + Security Options: + apparmor + seccomp + Profile: default + Kernel Version: 4.4.0-87-generic + Operating System: Ubuntu 16.04.3 LTS + OSType: linux + Architecture: x86_64 + CPUs: 2 + Total Memory: 1.953GiB + Name: system-sample + ID: EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX + Docker Root Dir: /var/lib/docker + Debug Mode: true + File Descriptors: 33 + Goroutines: 135 + System Time: 2017-08-24T17:44:34.077811894Z + EventsListeners: 0 + Registry: https://index.docker.io/v1/ + Labels: + provider=digitalocean + Experimental: false + Insecure Registries: + 127.0.0.0/8 + Live Restore Enabled: false + diff --git a/cli/command/system/testdata/docker-info-plugins.json.golden b/cli/command/system/testdata/docker-info-plugins.json.golden new file mode 100644 index 000000000000..f7c8435b3b10 --- /dev/null +++ b/cli/command/system/testdata/docker-info-plugins.json.golden @@ -0,0 +1 @@ +{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"","NodeAddr":"","LocalNodeState":"inactive","ControlAvailable":false,"Error":"","RemoteManagers":null},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":false,"Plugins":[{"SchemaVersion":"0.1.0","Vendor":"ACME Corp","Version":"0.1.0","ShortDescription":"unit test is good","Name":"goodplugin","Path":"/path/to/docker-goodplugin"},{"Name":"badplugin","Path":"/path/to/docker-badplugin","Err":"something wrong"}],"Warnings":null}} diff --git a/cli/command/system/testdata/docker-info-with-swarm.json.golden b/cli/command/system/testdata/docker-info-with-swarm.json.golden index 58ff6ea2b2fc..da49769c8d64 100644 --- a/cli/command/system/testdata/docker-info-with-swarm.json.golden +++ b/cli/command/system/testdata/docker-info-with-swarm.json.golden @@ -1 +1 @@ -{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"qo2dfdig9mmxqkawulggepdih","NodeAddr":"165.227.107.89","LocalNodeState":"active","ControlAvailable":true,"Error":"","RemoteManagers":[{"NodeID":"qo2dfdig9mmxqkawulggepdih","Addr":"165.227.107.89:2377"}],"Nodes":1,"Managers":1,"Cluster":{"ID":"9vs5ygs0gguyyec4iqf2314c0","Version":{"Index":11},"CreatedAt":"2017-08-24T17:34:19.278062352Z","UpdatedAt":"2017-08-24T17:34:42.398815481Z","Spec":{"Name":"default","Labels":null,"Orchestration":{"TaskHistoryRetentionLimit":5},"Raft":{"SnapshotInterval":10000,"KeepOldSnapshots":0,"LogEntriesForSlowFollowers":500,"ElectionTick":3,"HeartbeatTick":1},"Dispatcher":{"HeartbeatPeriod":5000000000},"CAConfig":{"NodeCertExpiry":7776000000000000},"TaskDefaults":{},"EncryptionConfig":{"AutoLockManagers":true}},"TLSInfo":{"TrustRoot":"\n-----BEGIN CERTIFICATE-----\nMIIBajCCARCgAwIBAgIUaFCW5xsq8eyiJ+Pmcv3MCflMLnMwCgYIKoZIzj0EAwIw\nEzERMA8GA1UEAxMIc3dhcm0tY2EwHhcNMTcwODI0MTcyOTAwWhcNMzcwODE5MTcy\nOTAwWjATMREwDwYDVQQDEwhzd2FybS1jYTBZMBMGByqGSM49AgEGCCqGSM49AwEH\nA0IABDy7NebyUJyUjWJDBUdnZoV6GBxEGKO4TZPNDwnxDxJcUdLVaB7WGa4/DLrW\nUfsVgh1JGik2VTiLuTMA1tLlNPOjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMB\nAf8EBTADAQH/MB0GA1UdDgQWBBQl16XFtaaXiUAwEuJptJlDjfKskDAKBggqhkjO\nPQQDAgNIADBFAiEAo9fTQNM5DP9bHVcTJYfl2Cay1bFu1E+lnpmN+EYJfeACIGKH\n1pCUkZ+D0IB6CiEZGWSHyLuXPM1rlP+I5KuS7sB8\n-----END CERTIFICATE-----\n","CertIssuerSubject":"MBMxETAPBgNVBAMTCHN3YXJtLWNh","CertIssuerPublicKey":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPLs15vJQnJSNYkMFR2dmhXoYHEQYo7hNk80PCfEPElxR0tVoHtYZrj8MutZR+xWCHUkaKTZVOIu5MwDW0uU08w=="},"RootRotationInProgress":false,"DefaultAddrPool":null,"SubnetSize":0,"DataPathPort":0}},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":false,"Warnings":null}} +{"ID":"EKHL:QDUU:QZ7U:MKGD:VDXK:S27Q:GIPU:24B7:R7VT:DGN6:QCSF:2UBX","Containers":0,"ContainersRunning":0,"ContainersPaused":0,"ContainersStopped":0,"Images":0,"Driver":"aufs","DriverStatus":[["Root Dir","/var/lib/docker/aufs"],["Backing Filesystem","extfs"],["Dirs","0"],["Dirperm1 Supported","true"]],"SystemStatus":null,"Plugins":{"Volume":["local"],"Network":["bridge","host","macvlan","null","overlay"],"Authorization":null,"Log":["awslogs","fluentd","gcplogs","gelf","journald","json-file","logentries","splunk","syslog"]},"MemoryLimit":true,"SwapLimit":true,"KernelMemory":true,"KernelMemoryTCP":false,"CpuCfsPeriod":true,"CpuCfsQuota":true,"CPUShares":true,"CPUSet":true,"IPv4Forwarding":true,"BridgeNfIptables":true,"BridgeNfIp6tables":true,"Debug":true,"NFd":33,"OomKillDisable":true,"NGoroutines":135,"SystemTime":"2017-08-24T17:44:34.077811894Z","LoggingDriver":"json-file","CgroupDriver":"cgroupfs","NEventsListener":0,"KernelVersion":"4.4.0-87-generic","OperatingSystem":"Ubuntu 16.04.3 LTS","OSType":"linux","Architecture":"x86_64","IndexServerAddress":"https://index.docker.io/v1/","RegistryConfig":{"AllowNondistributableArtifactsCIDRs":null,"AllowNondistributableArtifactsHostnames":null,"InsecureRegistryCIDRs":["127.0.0.0/8"],"IndexConfigs":{"docker.io":{"Name":"docker.io","Mirrors":null,"Secure":true,"Official":true}},"Mirrors":null},"NCPU":2,"MemTotal":2097356800,"GenericResources":null,"DockerRootDir":"/var/lib/docker","HttpProxy":"","HttpsProxy":"","NoProxy":"","Name":"system-sample","Labels":["provider=digitalocean"],"ExperimentalBuild":false,"ServerVersion":"17.06.1-ce","ClusterStore":"","ClusterAdvertise":"","Runtimes":{"runc":{"path":"docker-runc"}},"DefaultRuntime":"runc","Swarm":{"NodeID":"qo2dfdig9mmxqkawulggepdih","NodeAddr":"165.227.107.89","LocalNodeState":"active","ControlAvailable":true,"Error":"","RemoteManagers":[{"NodeID":"qo2dfdig9mmxqkawulggepdih","Addr":"165.227.107.89:2377"}],"Nodes":1,"Managers":1,"Cluster":{"ID":"9vs5ygs0gguyyec4iqf2314c0","Version":{"Index":11},"CreatedAt":"2017-08-24T17:34:19.278062352Z","UpdatedAt":"2017-08-24T17:34:42.398815481Z","Spec":{"Name":"default","Labels":null,"Orchestration":{"TaskHistoryRetentionLimit":5},"Raft":{"SnapshotInterval":10000,"KeepOldSnapshots":0,"LogEntriesForSlowFollowers":500,"ElectionTick":3,"HeartbeatTick":1},"Dispatcher":{"HeartbeatPeriod":5000000000},"CAConfig":{"NodeCertExpiry":7776000000000000},"TaskDefaults":{},"EncryptionConfig":{"AutoLockManagers":true}},"TLSInfo":{"TrustRoot":"\n-----BEGIN CERTIFICATE-----\nMIIBajCCARCgAwIBAgIUaFCW5xsq8eyiJ+Pmcv3MCflMLnMwCgYIKoZIzj0EAwIw\nEzERMA8GA1UEAxMIc3dhcm0tY2EwHhcNMTcwODI0MTcyOTAwWhcNMzcwODE5MTcy\nOTAwWjATMREwDwYDVQQDEwhzd2FybS1jYTBZMBMGByqGSM49AgEGCCqGSM49AwEH\nA0IABDy7NebyUJyUjWJDBUdnZoV6GBxEGKO4TZPNDwnxDxJcUdLVaB7WGa4/DLrW\nUfsVgh1JGik2VTiLuTMA1tLlNPOjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMB\nAf8EBTADAQH/MB0GA1UdDgQWBBQl16XFtaaXiUAwEuJptJlDjfKskDAKBggqhkjO\nPQQDAgNIADBFAiEAo9fTQNM5DP9bHVcTJYfl2Cay1bFu1E+lnpmN+EYJfeACIGKH\n1pCUkZ+D0IB6CiEZGWSHyLuXPM1rlP+I5KuS7sB8\n-----END CERTIFICATE-----\n","CertIssuerSubject":"MBMxETAPBgNVBAMTCHN3YXJtLWNh","CertIssuerPublicKey":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPLs15vJQnJSNYkMFR2dmhXoYHEQYo7hNk80PCfEPElxR0tVoHtYZrj8MutZR+xWCHUkaKTZVOIu5MwDW0uU08w=="},"RootRotationInProgress":false,"DefaultAddrPool":null,"SubnetSize":0,"DataPathPort":0}},"LiveRestoreEnabled":false,"Isolation":"","InitBinary":"docker-init","ContainerdCommit":{"ID":"6e23458c129b551d5c9871e5174f6b1b7f6d1170","Expected":"6e23458c129b551d5c9871e5174f6b1b7f6d1170"},"RuncCommit":{"ID":"810190ceaa507aa2727d7ae6f4790c76ec150bd2","Expected":"810190ceaa507aa2727d7ae6f4790c76ec150bd2"},"InitCommit":{"ID":"949e6fa","Expected":"949e6fa"},"SecurityOptions":["name=apparmor","name=seccomp,profile=default"],"Warnings":null,"ClientInfo":{"Debug":false,"Plugins":[],"Warnings":null}} From 609dcb91527d13948606916539f1d51184304ea8 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Wed, 9 Jan 2019 11:18:25 +0000 Subject: [PATCH 18/23] Documentation on writing a plugin Signed-off-by: Ian Campbell --- cli-plugins/manager/metadata.go | 2 +- docs/extend/cli_plugins.md | 98 +++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 docs/extend/cli_plugins.md diff --git a/cli-plugins/manager/metadata.go b/cli-plugins/manager/metadata.go index 1df3c8e08fde..d3de778141f6 100644 --- a/cli-plugins/manager/metadata.go +++ b/cli-plugins/manager/metadata.go @@ -10,7 +10,7 @@ const ( MetadataSubcommandName = "docker-cli-plugin-metadata" ) -// Metadata provided by the plugin +// 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"` diff --git a/docs/extend/cli_plugins.md b/docs/extend/cli_plugins.md new file mode 100644 index 000000000000..a99204ca583d --- /dev/null +++ b/docs/extend/cli_plugins.md @@ -0,0 +1,98 @@ +--- +description: "Writing Docker CLI Plugins" +keywords: "docker, cli plugin" +--- + + + +# Docker CLI Plugin Spec + +The `docker` CLI supports adding additional top-level subcommands as +additional out-of-process commands which can be installed +independently. These plugins run on the client side and should not be +confused with "plugins" which run on the server. + +This document contains information for authors of such plugins. + +## Requirements for CLI Plugins + +### Naming + +A valid CLI plugin name consists only of lower case letters `a-z` +and the digits `0-9`. The leading character must be a letter. A valid +name therefore would match the regex `^[a-z][a-z0-9]*$`. + +The binary implementing a plugin must be named `docker-$name` where +`$name` is the name of the plugin. On Windows a `.exe` suffix is +mandatory. + +## Required sub-commands + +A CLI plugin must support being invoked in at least these two ways: + +* `docker-$name docker-cli-plugin-metadata` -- outputs metadata about + the plugin. +* `docker-$name [GLOBAL OPTIONS] $name [OPTIONS AND FURTHER SUB + COMMANDS]` -- the primary entry point to the plugin's functionality. + +A plugin may implement other subcommands but these will never be +invoked by the current Docker CLI. However doing so is strongly +discouraged: new subcommands may be added in the future without +consideration for additional non-specified subcommands which may be +used by plugins in the field. + +### The `docker-cli-plugin-metadata` subcommand + +When invoked in this manner the plugin must produce a JSON object +(and nothing else) on its standard output and exit success (0). + +The JSON object has the following defined keys: +* `SchemaVersion` (_string_) mandatory: must contain precisely "0.1.0". +* `Vendor` (_string_) mandatory: contains the name of the plugin vendor/author. May be truncated to 11 characters in some display contexts. +* `ShortDescription` (_string_) optional: a short description of the plugin, suitable for a single line help message. +* `Version` (_string_) optional: the version of the plugin, this is considered to be an opaque string by the core and therefore has no restrictions on its syntax. +* `URL` (_string_) optional: a pointer to the plugin's web page. + +A binary which does not correctly output the metadata +(e.g. syntactically invalid, missing mandatory keys etc) is not +considered a valid CLI plugin and will not be run. + +### The primary entry point subcommand + +This is the entry point for actually running the plugin. It maybe have +options or further subcommands. + +#### Required global options + +A plugin is required to support all of the global options of the +top-level CLI, i.e. those listed by `man docker 1` with the exception +of `-v`. + +## Installation + +Plugins distributed in packages for system wide installation on +Unix(-like) systems should be installed in either +`/usr/lib/docker/cli-plugins` or `/usr/libexec/docker/cli-plugins` +depending on which of `/usr/lib` and `/usr/libexec` is usual on that +system. System Administrators may also choose to manually install into +the `/usr/local/lib` or `/usr/local/libexec` equivalents but packages +should not do so. + +Plugins distributed on Windows for system wide installation should be +installed in `%PROGRAMDATA%\Docker\cli-plugins`. + +User's may on all systems install plugins into `~/.docker/cli-plugins`. + +## Implementing a plugin in Go + +When writing a plugin in Go the easiest way to meet the above +requirements is to simply call the +`github.com/docker/cli/cli-plugins/plugin.Run` method from your `main` +function to instantiate the plugin. From 63f3ad181bd2c6020953ac84256512a9b135c04a Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Mon, 14 Jan 2019 17:53:19 +0000 Subject: [PATCH 19/23] Refactor code which deals with Windows' `.exe` suffix Signed-off-by: Ian Campbell --- cli-plugins/manager/manager.go | 15 ++++----------- cli-plugins/manager/plugin.go | 12 ++++-------- cli-plugins/manager/suffix_unix.go | 10 ++++++++++ cli-plugins/manager/suffix_windows.go | 19 +++++++++++++++++++ 4 files changed, 37 insertions(+), 19 deletions(-) create mode 100644 cli-plugins/manager/suffix_unix.go create mode 100644 cli-plugins/manager/suffix_windows.go diff --git a/cli-plugins/manager/manager.go b/cli-plugins/manager/manager.go index b6ab943595e7..02c8748630d0 100644 --- a/cli-plugins/manager/manager.go +++ b/cli-plugins/manager/manager.go @@ -5,7 +5,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "github.com/docker/cli/cli/command" @@ -61,12 +60,9 @@ func addPluginCandidatesFromDir(res map[string][]string, d string) error { continue } name = strings.TrimPrefix(name, NamePrefix) - if runtime.GOOS == "windows" { - exe := ".exe" - if !strings.HasSuffix(name, exe) { - continue - } - name = strings.TrimSuffix(name, exe) + var err error + if name, err = trimExeSuffix(name); err != nil { + continue } res[name] = append(res[name], filepath.Join(d, dentry.Name())) } @@ -131,10 +127,7 @@ func PluginRunCommand(dockerCli command.Cli, name string, rootcmd *cobra.Command // fallback to their "invalid" command path. return nil, errPluginNotFound(name) } - exename := NamePrefix + name - if runtime.GOOS == "windows" { - exename = exename + ".exe" - } + exename := addExeSuffix(NamePrefix + name) for _, d := range getPluginDirs(dockerCli) { path := filepath.Join(d, exename) diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go index 06a0ba8345ed..a8ac4fa3a399 100644 --- a/cli-plugins/manager/plugin.go +++ b/cli-plugins/manager/plugin.go @@ -4,7 +4,6 @@ import ( "encoding/json" "path/filepath" "regexp" - "runtime" "strings" "github.com/pkg/errors" @@ -46,15 +45,12 @@ func newPlugin(c Candidate, rootcmd *cobra.Command) (Plugin, error) { if fullname == "." { return Plugin{}, errors.Errorf("unable to determine basename of plugin candidate %q", path) } - if runtime.GOOS == "windows" { - exe := ".exe" - if !strings.HasSuffix(fullname, exe) { - return Plugin{}, errors.Errorf("plugin candidate %q lacks required %q suffix", path, exe) - } - fullname = strings.TrimSuffix(fullname, exe) + 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) + return Plugin{}, errors.Errorf("plugin candidate %q: does not have %q prefix", path, NamePrefix) } p := Plugin{ diff --git a/cli-plugins/manager/suffix_unix.go b/cli-plugins/manager/suffix_unix.go new file mode 100644 index 000000000000..14f0903f40b7 --- /dev/null +++ b/cli-plugins/manager/suffix_unix.go @@ -0,0 +1,10 @@ +// +build !windows + +package manager + +func trimExeSuffix(s string) (string, error) { + return s, nil +} +func addExeSuffix(s string) string { + return s +} diff --git a/cli-plugins/manager/suffix_windows.go b/cli-plugins/manager/suffix_windows.go new file mode 100644 index 000000000000..d39a6e410d8d --- /dev/null +++ b/cli-plugins/manager/suffix_windows.go @@ -0,0 +1,19 @@ +package manager + +import ( + "strings" + + "github.com/pkg/errors" +) + +func trimExeSuffix(s string) (string, error) { + exe := ".exe" + if !strings.HasSuffix(s, exe) { + return "", errors.Errorf("lacks required %q suffix", exe) + } + return strings.TrimSuffix(s, exe), nil +} + +func addExeSuffix(s string) string { + return s + ".exe" +} From 1337895751efda0a928dcf40a58db6577513c513 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Mon, 28 Jan 2019 17:50:05 +0000 Subject: [PATCH 20/23] Check for `.exe` case insensitively On Windows `foo.exe`, `foo.eXe` and `foo.EXE` are equally executable. Signed-off-by: Ian Campbell --- cli-plugins/manager/suffix_windows.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/cli-plugins/manager/suffix_windows.go b/cli-plugins/manager/suffix_windows.go index d39a6e410d8d..53b507c87dc9 100644 --- a/cli-plugins/manager/suffix_windows.go +++ b/cli-plugins/manager/suffix_windows.go @@ -1,17 +1,24 @@ 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.HasSuffix(s, exe) { - return "", errors.Errorf("lacks required %q suffix", exe) + if !strings.EqualFold(ext, exe) { + return "", errors.Errorf("path %q lacks required %q suffix", s, exe) } - return strings.TrimSuffix(s, exe), nil + return strings.TrimSuffix(s, ext), nil } func addExeSuffix(s string) string { From 935d47bbe967808f69f5b7c72fc848a08f447c2d Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Thu, 17 Jan 2019 13:38:38 +0000 Subject: [PATCH 21/23] Ignore unknown arguments on the top-level command. This allows passing argument to plugins, otherwise they are caught by the parse loop, since cobra does not know about each plugin at this stage (to avoid having to always scan for all plugins) this means that e.g. `docker plugin --foo` would accumulate `plugin` as an arg to the `docker` command, then choke on the unknown `--foo`. This allows unknown global args only, unknown arguments on subcommands (e.g. `docker ps --foo`) are still correctly caught. Add an e2e test covering this case. Signed-off-by: Ian Campbell --- cli-plugins/examples/helloworld/main.go | 5 ++++- cmd/docker/docker.go | 10 ++++++++++ e2e/cli-plugins/run_test.go | 12 ++++++++++++ .../testdata/docker-help-helloworld.golden | 5 ++++- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/cli-plugins/examples/helloworld/main.go b/cli-plugins/examples/helloworld/main.go index 9a511591ec96..cbe015937f96 100644 --- a/cli-plugins/examples/helloworld/main.go +++ b/cli-plugins/examples/helloworld/main.go @@ -33,6 +33,7 @@ func main() { }, } + var who string cmd := &cobra.Command{ Use: "helloworld", Short: "A basic Hello World plugin for tests", @@ -41,9 +42,11 @@ func main() { // hook. PersistentPreRunE: plugin.PersistentPreRunE, Run: func(cmd *cobra.Command, args []string) { - fmt.Fprintln(dockerCli.Out(), "Hello World!") + 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 diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 1ebf30a89d25..74fab46dcc34 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -32,6 +32,16 @@ func newDockerCommand(dockerCli *command.DockerCli) *cobra.Command { SilenceUsage: true, SilenceErrors: true, TraverseChildren: true, + 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 { if len(args) == 0 { return command.ShowHelp(dockerCli.Err())(cmd, args) diff --git a/e2e/cli-plugins/run_test.go b/e2e/cli-plugins/run_test.go index d16bf3bb8be9..e12c51025d72 100644 --- a/e2e/cli-plugins/run_test.go +++ b/e2e/cli-plugins/run_test.go @@ -144,6 +144,18 @@ func TestRunGoodSubcommand(t *testing.T) { }) } +// 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. diff --git a/e2e/cli-plugins/testdata/docker-help-helloworld.golden b/e2e/cli-plugins/testdata/docker-help-helloworld.golden index e7252bf2d016..6ff36bc64eae 100644 --- a/e2e/cli-plugins/testdata/docker-help-helloworld.golden +++ b/e2e/cli-plugins/testdata/docker-help-helloworld.golden @@ -1,8 +1,11 @@ -Usage: docker helloworld COMMAND +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 From 0a89eb554b662446cbdaf26c8c7916b0ce304460 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Tue, 29 Jan 2019 09:28:43 +0000 Subject: [PATCH 22/23] Ensure plugins default search path obeys `--config` A static global initialiser happens before the arguments are parsed, so we need to calculate the path later. Signed-off-by: Ian Campbell --- cli-plugins/manager/manager.go | 4 +--- cli-plugins/manager/manager_test.go | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cli-plugins/manager/manager.go b/cli-plugins/manager/manager.go index 02c8748630d0..6fdc582c97d0 100644 --- a/cli-plugins/manager/manager.go +++ b/cli-plugins/manager/manager.go @@ -29,15 +29,13 @@ func IsNotFound(err error) bool { return ok } -var defaultUserPluginDir = config.Path("cli-plugins") - func getPluginDirs(dockerCli command.Cli) []string { var pluginDirs []string if cfg := dockerCli.ConfigFile(); cfg != nil { pluginDirs = append(pluginDirs, cfg.CLIPluginsExtraDirs...) } - pluginDirs = append(pluginDirs, defaultUserPluginDir) + pluginDirs = append(pluginDirs, config.Path("cli-plugins")) pluginDirs = append(pluginDirs, defaultSystemPluginDirs...) return pluginDirs } diff --git a/cli-plugins/manager/manager_test.go b/cli-plugins/manager/manager_test.go index 450ae6120071..14176e57ab1c 100644 --- a/cli-plugins/manager/manager_test.go +++ b/cli-plugins/manager/manager_test.go @@ -4,6 +4,7 @@ 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" @@ -91,7 +92,7 @@ func TestErrPluginNotFound(t *testing.T) { func TestGetPluginDirs(t *testing.T) { cli := test.NewFakeCli(nil) - expected := []string{defaultUserPluginDir} + expected := []string{config.Path("cli-plugins")} expected = append(expected, defaultSystemPluginDirs...) assert.Equal(t, strings.Join(expected, ":"), strings.Join(getPluginDirs(cli), ":")) From baabf6e8ad4c2a89211160ce6379546bbfb5fdd6 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Wed, 30 Jan 2019 10:46:31 +0000 Subject: [PATCH 23/23] Ensure that plugins are only listed once in help outputs. They were listed twice in `docker --help` (but not `docker help`), since the stubs were added in both `tryRunPluginHelp` and the `setHelpFunc` closure. Calling `AddPluginStubCommands` earlier in `setHelpFunc` before the call to `tryRunPluginHelp` is sufficient. Also it is no longer necessary to add just valid plugins (`tryRunPluginHelp` handles invalid plugins correctly) so remove that logic (which was in any case broken for e.g. `docker --help`). Update the e2e test to check for duplicate entries and also to test `docker --help` which was previously missed. Signed-off-by: Ian Campbell --- cli-plugins/manager/cobra.go | 11 ++++------- cmd/docker/docker.go | 22 ++++++++++----------- e2e/cli-plugins/help_test.go | 38 ++++++++++++++++++++++++++++++------ 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/cli-plugins/manager/cobra.go b/cli-plugins/manager/cobra.go index 302d338a1c99..692de7fdb108 100644 --- a/cli-plugins/manager/cobra.go +++ b/cli-plugins/manager/cobra.go @@ -23,18 +23,15 @@ const ( CommandAnnotationPluginInvalid = "com.docker.cli.plugin-invalid" ) -// AddPluginCommandStubs adds a stub cobra.Commands for each plugin -// (optionally including invalid ones). The command stubs will have -// several annotations added, see `CommandAnnotationPlugin*`. -func AddPluginCommandStubs(dockerCli command.Cli, cmd *cobra.Command, includeInvalid bool) error { +// 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 { - if !includeInvalid && p.Err != nil { - continue - } vendor := p.Vendor if vendor == "" { vendor = "unknown" diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 74fab46dcc34..5cccfe2df11f 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -135,7 +135,6 @@ func setupHelpCommand(dockerCli *command.DockerCli, rootCmd, helpCmd *cobra.Comm func tryRunPluginHelp(dockerCli command.Cli, ccmd *cobra.Command, cargs []string) error { root := ccmd.Root() - pluginmanager.AddPluginCommandStubs(dockerCli, root, false) cmd, _, err := root.Traverse(cargs) if err != nil { @@ -155,6 +154,17 @@ func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag. ccmd.Println(err) return } + + // Add a stub entry for every plugin so they are + // included in the help output and so that + // `tryRunPluginHelp` can find them or if we fall + // through they will be included in the default help + // output. + if err := pluginmanager.AddPluginCommandStubs(dockerCli, ccmd.Root()); err != nil { + ccmd.Println(err) + return + } + if len(args) >= 1 { err := tryRunPluginHelp(dockerCli, ccmd, args) if err == nil { // Successfully ran the plugin @@ -175,16 +185,6 @@ func setHelpFunc(dockerCli *command.DockerCli, cmd *cobra.Command, flags *pflag. return } - // Add a stub entry for every plugin so they are - // included in the help output. If we have no args - // then this is being used for `docker help` and we - // want to include broken plugins, otherwise this is - // `help «foo»` and we do not. - if err := pluginmanager.AddPluginCommandStubs(dockerCli, ccmd.Root(), len(args) == 0); err != nil { - ccmd.Println(err) - return - } - defaultHelpFunc(ccmd, args) }) } diff --git a/e2e/cli-plugins/help_test.go b/e2e/cli-plugins/help_test.go index f82959dbab05..6ecdd4c7f087 100644 --- a/e2e/cli-plugins/help_test.go +++ b/e2e/cli-plugins/help_test.go @@ -34,29 +34,55 @@ func TestGlobalHelp(t *testing.T) { // - 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$`), - regexp.MustCompile(`^ helloworld\s+\(Docker Inc\.\)\s+A basic Hello World plugin for tests$`), + helloworldre, regexp.MustCompile(`^ ps\s+List containers$`), regexp.MustCompile(`^Invalid Plugins:$`), - regexp.MustCompile(`^ badmeta\s+invalid metadata: invalid character 'i' looking for beginning of object key string$`), + badmetare, + nil, // scan to end of input rather than stopping at badmetare } { var found bool for scanner.Scan() { - if expected.MatchString(scanner.Text()) { + 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, found, "Did not find match for %q in `docker help` output", expected) + 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) should produce the same thing, except on Stderr - res2 := icmd.RunCmd(run()) + // 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, })