Skip to content

Commit 237bdbf

Browse files
Merge pull request #1722 from ijc/plugins-global-arg-clash
Allow plugins to have --flags which are the same as the top-level
2 parents d6a2306 + e824bc8 commit 237bdbf

File tree

8 files changed

+408
-162
lines changed

8 files changed

+408
-162
lines changed

cli-plugins/examples/helloworld/main.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,26 @@ func main() {
4444
},
4545
}
4646

47-
var who string
47+
var (
48+
who, context string
49+
debug bool
50+
)
4851
cmd := &cobra.Command{
4952
Use: "helloworld",
5053
Short: "A basic Hello World plugin for tests",
51-
// This is redundant but included to exercise
52-
// the path where a plugin overrides this
53-
// hook.
54-
PersistentPreRunE: plugin.PersistentPreRunE,
5554
RunE: func(cmd *cobra.Command, args []string) error {
55+
if debug {
56+
fmt.Fprintf(dockerCli.Err(), "Plugin debug mode enabled")
57+
}
58+
59+
switch context {
60+
case "Christmas":
61+
fmt.Fprintf(dockerCli.Out(), "Merry Christmas!\n")
62+
return nil
63+
case "":
64+
// nothing
65+
}
66+
5667
if who == "" {
5768
who, _ = dockerCli.ConfigFile().PluginConfig("helloworld", "who")
5869
}
@@ -68,6 +79,10 @@ func main() {
6879

6980
flags := cmd.Flags()
7081
flags.StringVar(&who, "who", "", "Who are we addressing?")
82+
// These are intended to deliberately clash with the CLIs own top
83+
// level arguments.
84+
flags.BoolVarP(&debug, "debug", "D", false, "Enable debug")
85+
flags.StringVarP(&context, "context", "c", "", "Is it Christmas?")
7186

7287
cmd.AddCommand(goodbye, apiversion, exitStatus2)
7388
return cmd

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: 75 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,79 @@ 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. We
128+
// create our own FlagSet so that we can configure it
129+
// (e.g. `SetInterspersed` below) in an idempotent way.
130+
flags := pflag.NewFlagSet(cmd.Name(), pflag.ContinueOnError)
131+
132+
// We need !interspersed to ensure we stop at the first
133+
// potential command instead of accumulating it into
134+
// flags.Args() and then continuing on and finding other
135+
// arguments which we try and treat as globals (when they are
136+
// actually arguments to the subcommand).
137+
flags.SetInterspersed(false)
138+
139+
// We need the single parse to see both sets of flags.
140+
flags.AddFlagSet(cmd.Flags())
141+
flags.AddFlagSet(cmd.PersistentFlags())
142+
// Now parse the global flags, up to (but not including) the
143+
// first command. The result will be that all the remaining
144+
// arguments are in `flags.Args()`.
145+
if err := flags.Parse(tcmd.args); err != nil {
146+
// Our FlagErrorFunc uses the cli, make sure it is initialized
147+
if err := tcmd.Initialize(); err != nil {
148+
return nil, nil, err
149+
}
150+
return nil, nil, cmd.FlagErrorFunc()(cmd, err)
151+
}
152+
153+
return cmd, flags.Args(), nil
154+
}
155+
156+
// Initialize finalises global option parsing and initializes the docker client.
157+
func (tcmd *TopLevelCommand) Initialize(ops ...command.InitializeOpt) error {
158+
tcmd.opts.Common.SetDefaultOptions(tcmd.flags)
159+
return tcmd.dockerCli.Initialize(tcmd.opts, ops...)
160+
}
161+
87162
// VisitAll will traverse all commands from the root.
88163
// This is different from the VisitAll of cobra.Command where only parents
89164
// are checked.

0 commit comments

Comments
 (0)