Skip to content

Commit 0bfe13b

Browse files
committed
cli-plugins: include plugin metadata in User-Agent
Add support to the `cli/command` package to accept a custom User Agent to pass to the underlying client. Use said support to automatically append CLI plugin-specific info (name & version) to the `User-Agent` value. For example, for a hypothetical CLI plugin named `mycmd` with version `1.0.0` would become `docker-cli-plugin-mycmd/1.0.0`. The `docker-cli-plugin-` prefix is added automatically for clarity. This is used as the `UpstreamClient` portion of the `User-Agent` when the Moby daemon makes requests. For example, pushing and pulling images with Compose might result in the registry seeing a `User-Agent` value of: ``` docker/24.0.7 go/go1.20.10 git-commit/311b9ff kernel/6.5.13-linuxkit os/linux arch/arm64 UpstreamClient(docker-cli-plugin-compose/v2.24.0) ``` Signed-off-by: Milas Bowman <milas.bowman@docker.com>
1 parent 8812e0a commit 0bfe13b

File tree

5 files changed

+101
-8
lines changed

5 files changed

+101
-8
lines changed

cli-plugins/plugin/plugin.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager
8383
cmd.SetContext(ctx)
8484
closeOnCLISocketClose(cancel)
8585

86-
var opts []command.CLIOption
86+
opts := []command.CLIOption{
87+
command.WithUserAgent(pluginUserAgent(plugin.Name(), meta.Version)),
88+
}
8789
if os.Getenv("DOCKER_CLI_PLUGIN_USE_DIAL_STDIO") != "" {
8890
opts = append(opts, withPluginClientConn(plugin.Name()))
8991
}
@@ -226,3 +228,18 @@ func RunningStandalone() bool {
226228
}
227229
return len(os.Args) < 2 || os.Args[1] != manager.MetadataSubcommandName
228230
}
231+
232+
func pluginUserAgent(pluginName string, version string) string {
233+
if pluginName == "" {
234+
pluginName = "unknown"
235+
}
236+
237+
// because the plugin name comes from the `docker` subcommand, add a
238+
// distinguishing prefix, e.g. `docker compose` -> `docker-cli-plugin-compose`
239+
pluginName = "docker-cli-plugin-" + pluginName
240+
241+
if version == "" {
242+
version = "unknown"
243+
}
244+
return pluginName + "/" + version
245+
}

cli-plugins/plugin/plugin_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package plugin
2+
3+
import (
4+
"testing"
5+
6+
"gotest.tools/v3/assert"
7+
)
8+
9+
func TestUserAgent(t *testing.T) {
10+
tcs := []struct {
11+
expected string
12+
name string
13+
version string
14+
}{
15+
{
16+
expected: "docker-cli-plugin-whalesay/0.0.1",
17+
name: "whalesay",
18+
version: "0.0.1",
19+
},
20+
{
21+
expected: "docker-cli-plugin-whalesay/unknown",
22+
name: "whalesay",
23+
},
24+
{
25+
expected: "docker-cli-plugin-unknown/unknown",
26+
},
27+
}
28+
for _, tc := range tcs {
29+
t.Run(tc.expected, func(t *testing.T) {
30+
ua := pluginUserAgent(tc.name, tc.version)
31+
assert.Equal(t, ua, tc.expected)
32+
})
33+
}
34+
}

cli/command/cli.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ type DockerCli struct {
8585
dockerEndpoint docker.Endpoint
8686
contextStoreConfig store.Config
8787
initTimeout time.Duration
88+
userAgent string
8889

8990
// baseCtx is the base context used for internal operations. In the future
9091
// this may be replaced by explicitly passing a context to functions that
@@ -199,7 +200,7 @@ func (cli *DockerCli) RegistryClient(allowInsecure bool) registryclient.Registry
199200
resolver := func(ctx context.Context, index *registry.IndexInfo) registry.AuthConfig {
200201
return ResolveAuthConfig(cli.ConfigFile(), index)
201202
}
202-
return registryclient.NewRegistryClient(resolver, UserAgent(), allowInsecure)
203+
return registryclient.NewRegistryClient(resolver, cli.userAgent, allowInsecure)
203204
}
204205

205206
// WithInitializeClient is passed to DockerCli.Initialize by callers who wish to set a particular API Client for use by the CLI.
@@ -261,18 +262,18 @@ func NewAPIClientFromFlags(opts *cliflags.ClientOptions, configFile *configfile.
261262
if err != nil {
262263
return nil, errors.Wrap(err, "unable to resolve docker endpoint")
263264
}
264-
return newAPIClientFromEndpoint(endpoint, configFile)
265+
return newAPIClientFromEndpoint(endpoint, configFile, client.WithUserAgent(UserAgent()))
265266
}
266267

267-
func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigFile) (client.APIClient, error) {
268+
func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigFile, extraOpts ...client.Opt) (client.APIClient, error) {
268269
opts, err := ep.ClientOpts()
269270
if err != nil {
270271
return nil, err
271272
}
272273
if len(configFile.HTTPHeaders) > 0 {
273274
opts = append(opts, client.WithHTTPHeaders(configFile.HTTPHeaders))
274275
}
275-
opts = append(opts, client.WithUserAgent(UserAgent()))
276+
opts = append(opts, extraOpts...)
276277
return client.NewClientWithOpts(opts...)
277278
}
278279

@@ -353,7 +354,7 @@ func (cli *DockerCli) initializeFromClient() {
353354

354355
// NotaryClient provides a Notary Repository to interact with signed metadata for an image
355356
func (cli *DockerCli) NotaryClient(imgRefAndAuth trust.ImageRefAndAuth, actions []string) (notaryclient.Repository, error) {
356-
return trust.GetNotaryRepository(cli.In(), cli.Out(), UserAgent(), imgRefAndAuth.RepoInfo(), imgRefAndAuth.AuthConfig(), actions...)
357+
return trust.GetNotaryRepository(cli.In(), cli.Out(), cli.userAgent, imgRefAndAuth.RepoInfo(), imgRefAndAuth.AuthConfig(), actions...)
357358
}
358359

359360
// ContextStore returns the ContextStore
@@ -444,7 +445,12 @@ func (cli *DockerCli) initialize() error {
444445
return
445446
}
446447
if cli.client == nil {
447-
if cli.client, cli.initErr = newAPIClientFromEndpoint(cli.dockerEndpoint, cli.configFile); cli.initErr != nil {
448+
cli.client, cli.initErr = newAPIClientFromEndpoint(
449+
cli.dockerEndpoint,
450+
cli.configFile,
451+
client.WithUserAgent(cli.userAgent),
452+
)
453+
if cli.initErr != nil {
448454
return
449455
}
450456
}
@@ -491,6 +497,7 @@ func NewDockerCli(ops ...CLIOption) (*DockerCli, error) {
491497
WithContentTrustFromEnv(),
492498
WithDefaultContextStoreConfig(),
493499
WithStandardStreams(),
500+
WithUserAgent(UserAgent()),
494501
}
495502
ops = append(defaultOps, ops...)
496503

@@ -515,7 +522,7 @@ func getServerHost(hosts []string, tlsOptions *tlsconfig.Options) (string, error
515522
return dopts.ParseHost(tlsOptions != nil, host)
516523
}
517524

518-
// UserAgent returns the user agent string used for making API requests
525+
// UserAgent returns the default user agent string used for making API requests.
519526
func UserAgent() string {
520527
return "Docker-Client/" + version.Version + " (" + runtime.GOOS + ")"
521528
}

cli/command/cli_options.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package command
22

33
import (
44
"context"
5+
"errors"
56
"io"
67
"os"
78
"strconv"
@@ -115,3 +116,14 @@ func WithAPIClient(c client.APIClient) CLIOption {
115116
return nil
116117
}
117118
}
119+
120+
// WithUserAgent configures the User-Agent string for cli HTTP requests.
121+
func WithUserAgent(userAgent string) CLIOption {
122+
return func(cli *DockerCli) error {
123+
if userAgent == "" {
124+
return errors.New("user agent cannot be blank")
125+
}
126+
cli.userAgent = userAgent
127+
return nil
128+
}
129+
}

cli/command/cli_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,3 +307,26 @@ func TestInitializeShouldAlwaysCreateTheContextStore(t *testing.T) {
307307
})))
308308
assert.Check(t, cli.ContextStore() != nil)
309309
}
310+
311+
func TestNewDockerCliWithCustomUserAgent(t *testing.T) {
312+
var received string
313+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
314+
received = r.UserAgent()
315+
w.WriteHeader(http.StatusOK)
316+
}))
317+
defer ts.Close()
318+
host := strings.Replace(ts.URL, "http://", "tcp://", 1)
319+
opts := &flags.ClientOptions{Hosts: []string{host}}
320+
321+
cli, err := NewDockerCli(
322+
WithUserAgent("fake-agent/0.0.1"),
323+
)
324+
assert.NilError(t, err)
325+
cli.currentContext = DefaultContextName
326+
cli.options = opts
327+
cli.configFile = &configfile.ConfigFile{}
328+
329+
_, err = cli.Client().Ping(context.Background())
330+
assert.NilError(t, err)
331+
assert.DeepEqual(t, received, "fake-agent/0.0.1")
332+
}

0 commit comments

Comments
 (0)