Skip to content

Commit e585336

Browse files
author
Ian Campbell
committed
allow plugins to have argument which match a top-level flag.
The issue with plugin options clashing with globals is that when cobra is parsing the command line and it comes across an argument which doesn't start with a `-` it (in the absence of plugins) distinguishes between "argument to current command" and "new subcommand" based on the list of registered sub commands. Plugins breaks that model. When presented with `docker -D plugin -c foo` cobra parses up to the `plugin`, sees it isn't a registered sub-command of the top-level docker (because it isn't, it's a plugin) so it accumulates it as an argument to the top-level `docker` command. Then it sees the `-c`, and thinks it is the global `-c` (for AKA `--context`) option and tries to treat it as that, which fails. In the specific case of the top-level `docker` subcommand we know that it has no arguments which aren't `--flags` (or `-f` short flags) and so anything which doesn't start with a `-` must either be a (known) subcommand or an attempt to execute a plugin. We could simply scan for and register all installed plugins at start of day, so that cobra can do the right thing, but we want to avoid that since it would involve executing each plugin to fetch the metadata, even if the command wasn't going to end up hitting a plugin. Instead we can parse the initial set of global arguments separately before hitting the main cobra `Execute` path, which works here exactly because we know that the top-level has no non-flag arguments. One slight wrinkle is that the top-level `PersistentPreRunE` is no longer called on the plugins path (since it no longer goes via `Execute`), so we arrange for the initialisation done there (which has to be done after global flags are parsed to handle e.g. `--config`) to happen explictly after the global flags are parsed. Rather than make `newDockerCommand` return the complicated set of results needed to make this happen, instead return a closure which achieves this. The new functionality is introduced via a common `TopLevelCommand` abstraction which lets us adjust the plugin entrypoint to use the same strategy for parsing the global arguments. This isn't strictly required (in this case the stuff in cobra's `Execute` works fine) but doing it this way avoids the possibility of subtle differences in behaviour. Fixes #1699, and also, as a side-effect, the first item in #1661. Signed-off-by: Ian Campbell <[email protected]>
1 parent e11bdd7 commit e585336

File tree

5 files changed

+173
-139
lines changed

5 files changed

+173
-139
lines changed

cli-plugins/examples/helloworld/main.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,6 @@ func main() {
5151
cmd := &cobra.Command{
5252
Use: "helloworld",
5353
Short: "A basic Hello World plugin for tests",
54-
// This is redundant but included to exercise
55-
// the path where a plugin overrides this
56-
// hook.
57-
PersistentPreRunE: plugin.PersistentPreRunE,
5854
RunE: func(cmd *cobra.Command, args []string) error {
5955
if debug {
6056
fmt.Fprintf(dockerCli.Err(), "Plugin debug mode enabled")

cli-plugins/plugin/plugin.go

Lines changed: 20 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,32 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7-
"sync"
87

98
"github.com/docker/cli/cli"
109
"github.com/docker/cli/cli-plugins/manager"
1110
"github.com/docker/cli/cli/command"
1211
"github.com/docker/cli/cli/connhelper"
13-
cliflags "github.com/docker/cli/cli/flags"
1412
"github.com/docker/docker/client"
1513
"github.com/spf13/cobra"
16-
"github.com/spf13/pflag"
1714
)
1815

16+
func runPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager.Metadata) error {
17+
tcmd := newPluginCommand(dockerCli, plugin, meta)
18+
19+
// Doing this here avoids also calling it for the metadata
20+
// command which needlessly initializes the client and tries
21+
// to connect to the daemon.
22+
plugin.PersistentPreRunE = func(_ *cobra.Command, _ []string) error {
23+
return tcmd.Initialize(withPluginClientConn(plugin.Name()))
24+
}
25+
26+
cmd, _, err := tcmd.HandleGlobalFlags()
27+
if err != nil {
28+
return err
29+
}
30+
return cmd.Execute()
31+
}
32+
1933
// Run is the top-level entry point to the CLI plugin framework. It should be called from your plugin's `main()` function.
2034
func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) {
2135
dockerCli, err := command.NewDockerCli()
@@ -26,9 +40,7 @@ func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) {
2640

2741
plugin := makeCmd(dockerCli)
2842

29-
cmd := newPluginCommand(dockerCli, plugin, meta)
30-
31-
if err := cmd.Execute(); err != nil {
43+
if err := runPlugin(dockerCli, plugin, meta); err != nil {
3244
if sterr, ok := err.(cli.StatusError); ok {
3345
if sterr.Status != "" {
3446
fmt.Fprintln(dockerCli.Err(), sterr.Status)
@@ -45,40 +57,6 @@ func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) {
4557
}
4658
}
4759

48-
// options encapsulates the ClientOptions and FlagSet constructed by
49-
// `newPluginCommand` such that they can be finalized by our
50-
// `PersistentPreRunE`. This is necessary because otherwise a plugin's
51-
// own use of that hook will shadow anything we add to the top-level
52-
// command meaning the CLI is never Initialized.
53-
var options struct {
54-
name string
55-
init, prerun sync.Once
56-
opts *cliflags.ClientOptions
57-
flags *pflag.FlagSet
58-
dockerCli *command.DockerCli
59-
}
60-
61-
// PersistentPreRunE must be called by any plugin command (or
62-
// subcommand) which uses the cobra `PersistentPreRun*` hook. Plugins
63-
// which do not make use of `PersistentPreRun*` do not need to call
64-
// this (although it remains safe to do so). Plugins are recommended
65-
// to use `PersistenPreRunE` to enable the error to be
66-
// returned. Should not be called outside of a commands
67-
// PersistentPreRunE hook and must not be run unless Run has been
68-
// called.
69-
func PersistentPreRunE(cmd *cobra.Command, args []string) error {
70-
var err error
71-
options.prerun.Do(func() {
72-
if options.opts == nil || options.flags == nil || options.dockerCli == nil {
73-
panic("PersistentPreRunE called without Run successfully called first")
74-
}
75-
// flags must be the original top-level command flags, not cmd.Flags()
76-
options.opts.Common.SetDefaultOptions(options.flags)
77-
err = options.dockerCli.Initialize(options.opts, withPluginClientConn(options.name))
78-
})
79-
return err
80-
}
81-
8260
func withPluginClientConn(name string) command.InitializeOpt {
8361
return command.WithInitializeClient(func(dockerCli *command.DockerCli) (client.APIClient, error) {
8462
cmd := "docker"
@@ -111,7 +89,7 @@ func withPluginClientConn(name string) command.InitializeOpt {
11189
})
11290
}
11391

114-
func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager.Metadata) *cobra.Command {
92+
func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager.Metadata) *cli.TopLevelCommand {
11593
name := plugin.Name()
11694
fullname := manager.NamePrefix + name
11795

@@ -121,7 +99,6 @@ func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta
12199
SilenceUsage: true,
122100
SilenceErrors: true,
123101
TraverseChildren: true,
124-
PersistentPreRunE: PersistentPreRunE,
125102
DisableFlagsInUseLine: true,
126103
}
127104
opts, flags := cli.SetupPluginRootCommand(cmd)
@@ -135,13 +112,7 @@ func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta
135112

136113
cli.DisableFlagsInUseLine(cmd)
137114

138-
options.init.Do(func() {
139-
options.name = name
140-
options.opts = opts
141-
options.flags = flags
142-
options.dockerCli = dockerCli
143-
})
144-
return cmd
115+
return cli.NewTopLevelCommand(cmd, dockerCli, opts, flags)
145116
}
146117

147118
func newMetadataSubcommand(plugin *cobra.Command, meta manager.Metadata) *cobra.Command {
@@ -151,8 +122,6 @@ func newMetadataSubcommand(plugin *cobra.Command, meta manager.Metadata) *cobra.
151122
cmd := &cobra.Command{
152123
Use: manager.MetadataSubcommandName,
153124
Hidden: true,
154-
// Suppress the global/parent PersistentPreRunE, which needlessly initializes the client and tries to connect to the daemon.
155-
PersistentPreRun: func(cmd *cobra.Command, args []string) {},
156125
RunE: func(cmd *cobra.Command, args []string) error {
157126
enc := json.NewEncoder(os.Stdout)
158127
enc.SetEscapeHTML(false)

cli/cobra.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package cli
22

33
import (
44
"fmt"
5+
"os"
56
"strings"
67

78
pluginmanager "github.com/docker/cli/cli-plugins/manager"
9+
"github.com/docker/cli/cli/command"
810
cliconfig "github.com/docker/cli/cli/config"
911
cliflags "github.com/docker/cli/cli/flags"
1012
"github.com/docker/docker/pkg/term"
@@ -84,6 +86,77 @@ func FlagErrorFunc(cmd *cobra.Command, err error) error {
8486
}
8587
}
8688

89+
// TopLevelCommand encapsulates a top-level cobra command (either
90+
// docker CLI or a plugin) and global flag handling logic necessary
91+
// for plugins.
92+
type TopLevelCommand struct {
93+
cmd *cobra.Command
94+
dockerCli *command.DockerCli
95+
opts *cliflags.ClientOptions
96+
flags *pflag.FlagSet
97+
args []string
98+
}
99+
100+
// NewTopLevelCommand returns a new TopLevelCommand object
101+
func NewTopLevelCommand(cmd *cobra.Command, dockerCli *command.DockerCli, opts *cliflags.ClientOptions, flags *pflag.FlagSet) *TopLevelCommand {
102+
return &TopLevelCommand{cmd, dockerCli, opts, flags, os.Args[1:]}
103+
}
104+
105+
// SetArgs sets the args (default os.Args[:1] used to invoke the command
106+
func (tcmd *TopLevelCommand) SetArgs(args []string) {
107+
tcmd.args = args
108+
tcmd.cmd.SetArgs(args)
109+
}
110+
111+
// SetFlag sets a flag in the local flag set of the top-level command
112+
func (tcmd *TopLevelCommand) SetFlag(name, value string) {
113+
tcmd.cmd.Flags().Set(name, value)
114+
}
115+
116+
// HandleGlobalFlags takes care of parsing global flags defined on the
117+
// command, it returns the underlying cobra command and the args it
118+
// will be called with (or an error).
119+
//
120+
// On success the caller is responsible for calling Initialize()
121+
// before calling `Execute` on the returned command.
122+
func (tcmd *TopLevelCommand) HandleGlobalFlags() (*cobra.Command, []string, error) {
123+
cmd := tcmd.cmd
124+
125+
// We manually parse the global arguments and find the
126+
// subcommand in order to properly deal with plugins. We rely
127+
// on the root command never having any non-flag arguments.
128+
flags := cmd.Flags()
129+
130+
// We need !interspersed to ensure we stop at the first
131+
// potential command instead of accumulating it into
132+
// flags.Args() and then continuing on and finding other
133+
// arguments which we try and treat as globals (when they are
134+
// actually arguments to the subcommand).
135+
flags.SetInterspersed(false)
136+
defer flags.SetInterspersed(true) // Undo, any subsequent cmd.Execute() in the caller expects this.
137+
138+
// We need the single parse to see both sets of flags.
139+
cmd.Flags().AddFlagSet(cmd.PersistentFlags())
140+
// Now parse the global flags, up to (but not including) the
141+
// first command. The result will be that all the remaining
142+
// arguments are in `flags.Args()`.
143+
if err := flags.Parse(tcmd.args); err != nil {
144+
// Our FlagErrorFunc uses the cli, make sure it is initialized
145+
if err := tcmd.Initialize(); err != nil {
146+
return nil, nil, err
147+
}
148+
return nil, nil, cmd.FlagErrorFunc()(cmd, err)
149+
}
150+
151+
return cmd, flags.Args(), nil
152+
}
153+
154+
// Initialize finalises global option parsing and initializes the docker client.
155+
func (tcmd *TopLevelCommand) Initialize(ops ...command.InitializeOpt) error {
156+
tcmd.opts.Common.SetDefaultOptions(tcmd.flags)
157+
return tcmd.dockerCli.Initialize(tcmd.opts, ops...)
158+
}
159+
87160
// VisitAll will traverse all commands from the root.
88161
// This is different from the VisitAll of cobra.Command where only parents
89162
// are checked.

0 commit comments

Comments
 (0)