Skip to content

Commit

Permalink
allow plugins to have argument which match a top-level flag.
Browse files Browse the repository at this point in the history
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 docker#1699, and also, as a side-effect, the first item in docker#1661.

Signed-off-by: Ian Campbell <ijc@docker.com>
  • Loading branch information
Ian Campbell committed Mar 11, 2019
1 parent 2de40f3 commit 0411695
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 168 deletions.
4 changes: 0 additions & 4 deletions cli-plugins/examples/helloworld/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ func main() {
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,
RunE: func(cmd *cobra.Command, args []string) error {
if debug {
fmt.Fprintf(dockerCli.Err(), "Plugin debug mode enabled")
Expand Down
79 changes: 24 additions & 55 deletions cli-plugins/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,36 @@ import (
"fmt"
"os"
"runtime"
"sync"

"github.com/docker/cli/cli"
"github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/connhelper"
cliflags "github.com/docker/cli/cli/flags"
"github.com/docker/docker/client"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

func runPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager.Metadata) error {
tcmd := newPluginCommand(dockerCli, plugin, meta)

// Doing this here avoids also calling it for the metadata
// command which needlessly initializes the client and tries
// to connect to the daemon.
plugin.PersistentPreRunE = func(_ *cobra.Command, _ []string) error {
var initopts []command.InitializeOpt
if runtime.GOOS != "windows" {
initopts = append(initopts, withPluginClientConn(plugin.Name()))
}
return tcmd.Initialize(initopts...)
}

cmd, _, err := tcmd.HandleGlobalFlags()
if err != nil {
return err
}
return cmd.Execute()
}

// 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()
Expand All @@ -27,9 +45,7 @@ func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) {

plugin := makeCmd(dockerCli)

cmd := newPluginCommand(dockerCli, plugin, meta)

if err := cmd.Execute(); err != nil {
if err := runPlugin(dockerCli, plugin, meta); err != nil {
if sterr, ok := err.(cli.StatusError); ok {
if sterr.Status != "" {
fmt.Fprintln(dockerCli.Err(), sterr.Status)
Expand All @@ -46,44 +62,6 @@ func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) {
}
}

// 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 {
name string
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)
var initopts []command.InitializeOpt
if runtime.GOOS != "windows" {
initopts = append(initopts, withPluginClientConn(options.name))
}
err = options.dockerCli.Initialize(options.opts, initopts...)
})
return err
}

func withPluginClientConn(name string) command.InitializeOpt {
return command.WithInitializeClient(func(dockerCli *command.DockerCli) (client.APIClient, error) {
cmd := "docker"
Expand Down Expand Up @@ -116,7 +94,7 @@ func withPluginClientConn(name string) command.InitializeOpt {
})
}

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

Expand All @@ -126,7 +104,6 @@ func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta
SilenceUsage: true,
SilenceErrors: true,
TraverseChildren: true,
PersistentPreRunE: PersistentPreRunE,
DisableFlagsInUseLine: true,
}
opts, flags := cli.SetupPluginRootCommand(cmd)
Expand All @@ -140,13 +117,7 @@ func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta

cli.DisableFlagsInUseLine(cmd)

options.init.Do(func() {
options.name = name
options.opts = opts
options.flags = flags
options.dockerCli = dockerCli
})
return cmd
return cli.NewTopLevelCommand(cmd, dockerCli, opts, flags)
}

func newMetadataSubcommand(plugin *cobra.Command, meta manager.Metadata) *cobra.Command {
Expand All @@ -156,8 +127,6 @@ func newMetadataSubcommand(plugin *cobra.Command, meta manager.Metadata) *cobra.
cmd := &cobra.Command{
Use: manager.MetadataSubcommandName,
Hidden: true,
// Suppress the global/parent PersistentPreRunE, which needlessly initializes the client and tries to connect to the daemon.
PersistentPreRun: func(cmd *cobra.Command, args []string) {},
RunE: func(cmd *cobra.Command, args []string) error {
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
Expand Down
73 changes: 73 additions & 0 deletions cli/cobra.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package cli

import (
"fmt"
"os"
"strings"

pluginmanager "github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli/command"
cliconfig "github.com/docker/cli/cli/config"
cliflags "github.com/docker/cli/cli/flags"
"github.com/docker/docker/pkg/term"
Expand Down Expand Up @@ -84,6 +86,77 @@ func FlagErrorFunc(cmd *cobra.Command, err error) error {
}
}

// TopLevelCommand encapsulates a top-level cobra command (either
// docker CLI or a plugin) and global flag handling logic necessary
// for plugins.
type TopLevelCommand struct {
cmd *cobra.Command
dockerCli *command.DockerCli
opts *cliflags.ClientOptions
flags *pflag.FlagSet
args []string
}

// NewTopLevelCommand returns a new TopLevelCommand object
func NewTopLevelCommand(cmd *cobra.Command, dockerCli *command.DockerCli, opts *cliflags.ClientOptions, flags *pflag.FlagSet) *TopLevelCommand {
return &TopLevelCommand{cmd, dockerCli, opts, flags, os.Args[1:]}
}

// SetArgs sets the args (default os.Args[:1] used to invoke the command
func (tcmd *TopLevelCommand) SetArgs(args []string) {
tcmd.args = args
tcmd.cmd.SetArgs(args)
}

// SetFlag sets a flag in the local flag set of the top-level command
func (tcmd *TopLevelCommand) SetFlag(name, value string) {
tcmd.cmd.Flags().Set(name, value)
}

// HandleGlobalFlags takes care of parsing global flags defined on the
// command, it returns the underlying cobra command and the args it
// will be called with (or an error).
//
// On success the caller is responsible for calling Initialize()
// before calling `Execute` on the returned command.
func (tcmd *TopLevelCommand) HandleGlobalFlags() (*cobra.Command, []string, error) {
cmd := tcmd.cmd

// We manually parse the global arguments and find the
// subcommand in order to properly deal with plugins. We rely
// on the root command never having any non-flag arguments.
flags := cmd.Flags()

// We need !interspersed to ensure we stop at the first
// potential command instead of accumulating it into
// flags.Args() and then continuing on and finding other
// arguments which we try and treat as globals (when they are
// actually arguments to the subcommand).
flags.SetInterspersed(false)
defer flags.SetInterspersed(true) // Undo, any subsequent cmd.Execute() in the caller expects this.

// We need the single parse to see both sets of flags.
cmd.Flags().AddFlagSet(cmd.PersistentFlags())
// Now parse the global flags, up to (but not including) the
// first command. The result will be that all the remaining
// arguments are in `flags.Args()`.
if err := flags.Parse(tcmd.args); err != nil {
// Our FlagErrorFunc uses the cli, make sure it is initialized
if err := tcmd.Initialize(); err != nil {
return nil, nil, err
}
return nil, nil, cmd.FlagErrorFunc()(cmd, err)
}

return cmd, flags.Args(), nil
}

// Initialize finalises global option parsing and initializes the docker client.
func (tcmd *TopLevelCommand) Initialize(ops ...command.InitializeOpt) error {
tcmd.opts.Common.SetDefaultOptions(tcmd.flags)
return tcmd.dockerCli.Initialize(tcmd.opts, ops...)
}

// VisitAll will traverse all commands from the root.
// This is different from the VisitAll of cobra.Command where only parents
// are checked.
Expand Down
Loading

0 comments on commit 0411695

Please sign in to comment.