Skip to content

Commit 06b837a

Browse files
authored
Merge pull request #1654 from ijc/plugins-dial-stdio
cli-plugins: use system dial-stdio to contact the engine.
2 parents cfe12f4 + 891b3d9 commit 06b837a

7 files changed

Lines changed: 144 additions & 24 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: 40 additions & 2 deletions
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,13 +74,45 @@ 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.InitializeOpt {
83+
return command.WithInitializeClient(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 arguments, 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 {
80-
name := plugin.Use
115+
name := plugin.Name()
81116
fullname := manager.NamePrefix + name
82117

83118
cmd := &cobra.Command{
@@ -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: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,28 @@ func (cli *DockerCli) RegistryClient(allowInsecure bool) registryclient.Registry
175175
return registryclient.NewRegistryClient(resolver, UserAgent(), allowInsecure)
176176
}
177177

178+
// InitializeOpt is the type of the functional options passed to DockerCli.Initialize
179+
type InitializeOpt func(dockerCli *DockerCli) error
180+
181+
// WithInitializeClient is passed to DockerCli.Initialize by callers who wish to set a particular API Client for use by the CLI.
182+
func WithInitializeClient(makeClient func(dockerCli *DockerCli) (client.APIClient, error)) InitializeOpt {
183+
return func(dockerCli *DockerCli) error {
184+
var err error
185+
dockerCli.client, err = makeClient(dockerCli)
186+
return err
187+
}
188+
}
189+
178190
// Initialize the dockerCli runs initialization that must happen after command
179191
// line flags are parsed.
180-
func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error {
192+
func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions, ops ...InitializeOpt) error {
193+
var err error
194+
195+
for _, o := range ops {
196+
if err := o(cli); err != nil {
197+
return err
198+
}
199+
}
181200
cliflags.SetLogLevel(opts.Common.LogLevel)
182201

183202
if opts.ConfigDir != "" {
@@ -189,29 +208,31 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error {
189208
}
190209

191210
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
203211

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)
212+
if cli.client == nil {
213+
cli.contextStore = store.New(cliconfig.ContextStoreDir(), cli.contextStoreConfig)
214+
cli.currentContext, err = resolveContextName(opts.Common, cli.configFile, cli.contextStore)
215+
if err != nil {
216+
return err
217+
}
218+
endpoint, err := resolveDockerEndpoint(cli.contextStore, cli.currentContext, opts.Common)
219+
if err != nil {
220+
return errors.Wrap(err, "unable to resolve docker endpoint")
221+
}
222+
cli.dockerEndpoint = endpoint
223+
224+
cli.client, err = newAPIClientFromEndpoint(endpoint, cli.configFile)
225+
if tlsconfig.IsErrEncryptedKey(err) {
226+
passRetriever := passphrase.PromptRetrieverWithInOut(cli.In(), cli.Out(), nil)
227+
newClient := func(password string) (client.APIClient, error) {
228+
endpoint.TLSPassword = password
229+
return newAPIClientFromEndpoint(endpoint, cli.configFile)
230+
}
231+
cli.client, err = getClientWithPassword(passRetriever, newClient)
232+
}
233+
if err != nil {
234+
return err
210235
}
211-
cli.client, err = getClientWithPassword(passRetriever, newClient)
212-
}
213-
if err != nil {
214-
return err
215236
}
216237
var experimentalValue string
217238
// 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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ func GetConnectionHelper(daemonURL string) (*ConnectionHelper, error) {
5353
return nil, err
5454
}
5555

56+
// GetCommandConnectionHelper returns a ConnectionHelp constructed from an arbitrary command.
57+
func GetCommandConnectionHelper(cmd string, flags ...string) (*ConnectionHelper, error) {
58+
return &ConnectionHelper{
59+
Dialer: func(ctx context.Context, network, addr string) (net.Conn, error) {
60+
return newCommandConn(ctx, cmd, flags...)
61+
},
62+
Host: "http://docker",
63+
}, nil
64+
}
65+
5666
func newCommandConn(ctx context.Context, cmd string, args ...string) (net.Conn, error) {
5767
var (
5868
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)