Skip to content

Commit fa57dcf

Browse files
author
Ian Campbell
committed
cli-plugins: use docker system dial-stdio to call the daemon
This means that plugins can use whatever methods the monolithic CLI supports, which is good for consistency. This relies on `os.Args[0]` being something which can be executed again to reach the same binary, since it is propagated (via an envvar) to the plugin for this purpose. This essentially requires that the current working directory and path are not modified by the monolithic CLI before it launches the plugin nor by the plugin before it initializes the client. This should be the case. Previously the fake apiclient used by `TestExperimentalCLI` was not being used, since `cli.Initialize` was unconditionally overwriting it with a real one (talking to a real daemon during unit testing, it seems). This wasn't expected nor desirable and no longer happens with the new arrangements, exposing the fact that no `pingFunc` is provided, leading to a panic. Add a `pingFunc` to the fake client to avoid this. Signed-off-by: Ian Campbell <ijc@docker.com>
1 parent 8fa7c57 commit fa57dcf

7 files changed

Lines changed: 139 additions & 23 deletions

File tree

cli-plugins/manager/manager.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import (
1212
"github.com/spf13/cobra"
1313
)
1414

15+
// ReexecEnvvar is the name of an ennvar which is set to the command
16+
// used to originally invoke the docker CLI when executing a
17+
// plugin. Assuming $PATH and $CWD remain unchanged this should allow
18+
// the plugin to re-execute the original CLI.
19+
const ReexecEnvvar = "DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND"
20+
1521
// errPluginNotFound is the error returned when a plugin could not be found.
1622
type errPluginNotFound string
1723

@@ -155,6 +161,9 @@ func PluginRunCommand(dockerCli command.Cli, name string, rootcmd *cobra.Command
155161
cmd.Stdout = os.Stdout
156162
cmd.Stderr = os.Stderr
157163

164+
cmd.Env = os.Environ()
165+
cmd.Env = append(cmd.Env, ReexecEnvvar+"="+os.Args[0])
166+
158167
return cmd, nil
159168
}
160169
return nil, errPluginNotFound(name)

cli-plugins/plugin/plugin.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import (
99
"github.com/docker/cli/cli"
1010
"github.com/docker/cli/cli-plugins/manager"
1111
"github.com/docker/cli/cli/command"
12+
"github.com/docker/cli/cli/connhelper"
1213
cliflags "github.com/docker/cli/cli/flags"
14+
"github.com/docker/docker/client"
1315
"github.com/spf13/cobra"
1416
"github.com/spf13/pflag"
1517
)
@@ -49,6 +51,7 @@ func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) {
4951
// own use of that hook will shadow anything we add to the top-level
5052
// command meaning the CLI is never Initialized.
5153
var options struct {
54+
name string
5255
init, prerun sync.Once
5356
opts *cliflags.ClientOptions
5457
flags *pflag.FlagSet
@@ -71,11 +74,43 @@ func PersistentPreRunE(cmd *cobra.Command, args []string) error {
7174
}
7275
// flags must be the original top-level command flags, not cmd.Flags()
7376
options.opts.Common.SetDefaultOptions(options.flags)
74-
err = options.dockerCli.Initialize(options.opts)
77+
err = options.dockerCli.Initialize(options.opts, withPluginClientConn(options.name))
7578
})
7679
return err
7780
}
7881

82+
func withPluginClientConn(name string) command.WithInitializeOpt {
83+
return command.WithInitilizeClient(func(dockerCli *command.DockerCli) (client.APIClient, error) {
84+
cmd := "docker"
85+
if x := os.Getenv(manager.ReexecEnvvar); x != "" {
86+
cmd = x
87+
}
88+
var flags []string
89+
90+
// Accumulate all the global arguements, that is those
91+
// up to (but not including) the plugin's name. This
92+
// ensures that `docker system dial-stdio` is
93+
// evaluating the same set of `--config`, `--tls*` etc
94+
// global options as the plugin was called with, which
95+
// in turn is the same as what the original docker
96+
// invocation was passed.
97+
for _, a := range os.Args[1:] {
98+
if a == name {
99+
break
100+
}
101+
flags = append(flags, a)
102+
}
103+
flags = append(flags, "system", "dial-stdio")
104+
105+
helper, err := connhelper.GetCommandConnectionHelper(cmd, flags...)
106+
if err != nil {
107+
return nil, err
108+
}
109+
110+
return client.NewClientWithOpts(client.WithDialContext(helper.Dialer))
111+
})
112+
}
113+
79114
func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager.Metadata) *cobra.Command {
80115
name := plugin.Name()
81116
fullname := manager.NamePrefix + name
@@ -101,6 +136,7 @@ func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta
101136
cli.DisableFlagsInUseLine(cmd)
102137

103138
options.init.Do(func() {
139+
options.name = name
104140
options.opts = opts
105141
options.flags = flags
106142
options.dockerCli = dockerCli
@@ -115,6 +151,8 @@ func newMetadataSubcommand(plugin *cobra.Command, meta manager.Metadata) *cobra.
115151
cmd := &cobra.Command{
116152
Use: manager.MetadataSubcommandName,
117153
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) {},
118156
RunE: func(cmd *cobra.Command, args []string) error {
119157
enc := json.NewEncoder(os.Stdout)
120158
enc.SetEscapeHTML(false)

cli/command/cli.go

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,24 @@ func (cli *DockerCli) RegistryClient(allowInsecure bool) registryclient.Registry
175175
return registryclient.NewRegistryClient(resolver, UserAgent(), allowInsecure)
176176
}
177177

178+
type WithInitializeOpt func(dockerCli *DockerCli) error
179+
180+
func WithInitilizeClient(makeClient func(dockerCli *DockerCli) (client.APIClient, error)) WithInitializeOpt {
181+
return func(dockerCli *DockerCli) error {
182+
var err error
183+
dockerCli.client, err = makeClient(dockerCli)
184+
return err
185+
}
186+
}
187+
178188
// Initialize the dockerCli runs initialization that must happen after command
179189
// line flags are parsed.
180-
func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error {
190+
func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions, ops ...WithInitializeOpt) error {
191+
for _, o := range ops {
192+
if err := o(cli); err != nil {
193+
return err
194+
}
195+
}
181196
cliflags.SetLogLevel(opts.Common.LogLevel)
182197

183198
if opts.ConfigDir != "" {
@@ -189,29 +204,32 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error {
189204
}
190205

191206
cli.configFile = cliconfig.LoadDefaultConfigFile(cli.err)
192-
var err error
193-
cli.contextStore = store.New(cliconfig.ContextStoreDir(), cli.contextStoreConfig)
194-
cli.currentContext, err = resolveContextName(opts.Common, cli.configFile, cli.contextStore)
195-
if err != nil {
196-
return err
197-
}
198-
endpoint, err := resolveDockerEndpoint(cli.contextStore, cli.currentContext, opts.Common)
199-
if err != nil {
200-
return errors.Wrap(err, "unable to resolve docker endpoint")
201-
}
202-
cli.dockerEndpoint = endpoint
203207

204-
cli.client, err = newAPIClientFromEndpoint(endpoint, cli.configFile)
205-
if tlsconfig.IsErrEncryptedKey(err) {
206-
passRetriever := passphrase.PromptRetrieverWithInOut(cli.In(), cli.Out(), nil)
207-
newClient := func(password string) (client.APIClient, error) {
208-
endpoint.TLSPassword = password
209-
return newAPIClientFromEndpoint(endpoint, cli.configFile)
208+
if cli.client == nil {
209+
var err error
210+
cli.contextStore = store.New(cliconfig.ContextStoreDir(), cli.contextStoreConfig)
211+
cli.currentContext, err = resolveContextName(opts.Common, cli.configFile, cli.contextStore)
212+
if err != nil {
213+
return err
214+
}
215+
endpoint, err := resolveDockerEndpoint(cli.contextStore, cli.currentContext, opts.Common)
216+
if err != nil {
217+
return errors.Wrap(err, "unable to resolve docker endpoint")
218+
}
219+
cli.dockerEndpoint = endpoint
220+
221+
cli.client, err = newAPIClientFromEndpoint(endpoint, cli.configFile)
222+
if tlsconfig.IsErrEncryptedKey(err) {
223+
passRetriever := passphrase.PromptRetrieverWithInOut(cli.In(), cli.Out(), nil)
224+
newClient := func(password string) (client.APIClient, error) {
225+
endpoint.TLSPassword = password
226+
return newAPIClientFromEndpoint(endpoint, cli.configFile)
227+
}
228+
cli.client, err = getClientWithPassword(passRetriever, newClient)
229+
}
230+
if err != nil {
231+
return err
210232
}
211-
cli.client, err = getClientWithPassword(passRetriever, newClient)
212-
}
213-
if err != nil {
214-
return err
215233
}
216234
var experimentalValue string
217235
// Environment variable always overrides configuration

cli/command/cli_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ func TestExperimentalCLI(t *testing.T) {
175175
defer dir.Remove()
176176
apiclient := &fakeClient{
177177
version: defaultVersion,
178+
pingFunc: func() (types.Ping, error) {
179+
return types.Ping{Experimental: true, OSType: "linux", APIVersion: defaultVersion}, nil
180+
},
178181
}
179182

180183
cli := &DockerCli{client: apiclient, err: os.Stderr}

cli/connhelper/connhelper.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ func GetConnectionHelper(daemonURL string) (*ConnectionHelper, error) {
5353
return nil, err
5454
}
5555

56+
func GetCommandConnectionHelper(cmd string, flags ...string) (*ConnectionHelper, error) {
57+
return &ConnectionHelper{
58+
Dialer: func(ctx context.Context, network, addr string) (net.Conn, error) {
59+
return newCommandConn(ctx, cmd, flags...)
60+
},
61+
Host: "http://docker",
62+
}, nil
63+
}
64+
5665
func newCommandConn(ctx context.Context, cmd string, args ...string) (net.Conn, error) {
5766
var (
5867
c commandConn

docs/extend/cli_plugins.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ A plugin is required to support all of the global options of the
7575
top-level CLI, i.e. those listed by `man docker 1` with the exception
7676
of `-v`.
7777

78+
## Connecting to the docker engine
79+
80+
For consistency plugins should prefer to dial the engine by using the
81+
`system dial-stdio` subcommand of the main Docker CLI binary.
82+
83+
To facilitate this plugins will be executed with the
84+
`$DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND` environment variable
85+
pointing back to the main Docker CLI binary.
86+
87+
All global options (everything from after the binary name up to, but
88+
not including, the primary entry point subcommand name) should be
89+
passed back to the CLI.
90+
7891
## Installation
7992

8093
Plugins distributed in packages for system wide installation on

e2e/cli-plugins/dial_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package cliplugins
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/docker/cli/cli-plugins/manager"
9+
"gotest.tools/assert"
10+
is "gotest.tools/assert/cmp"
11+
"gotest.tools/icmd"
12+
)
13+
14+
func TestDialStdio(t *testing.T) {
15+
// Run the helloworld plugin forcing /bin/true as the `system
16+
// dial-stdio` target. It should be passed all arguments from
17+
// before the `helloworld` arg, but not the --who=foo which
18+
// follows. We observe this from the debug level logging from
19+
// the connhelper stuff.
20+
helloworld := filepath.Join(os.Getenv("DOCKER_CLI_E2E_PLUGINS_EXTRA_DIRS"), "docker-helloworld")
21+
cmd := icmd.Command(helloworld, "--config=blah", "--tls", "--log-level", "debug", "helloworld", "--who=foo")
22+
res := icmd.RunCmd(cmd, icmd.WithEnv(manager.ReexecEnvvar+"=/bin/true"))
23+
res.Assert(t, icmd.Success)
24+
assert.Assert(t, is.Contains(res.Stderr(), `msg="connhelper: starting /bin/true with [--config=blah --tls --log-level debug system dial-stdio]"`))
25+
assert.Assert(t, is.Equal(res.Stdout(), "Hello foo!\n"))
26+
}

0 commit comments

Comments
 (0)