Skip to content

Commit 5a0014a

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 (vendor, name, version) to the `User-Agent` value. For example, for a hypothetical CLI plugin from `vendor` named `plugin` with version `1.0.0` would have a user agent of something like: ``` Docker-Client/25.0.0 (darwin) vendor-plugin/1.0.0 ``` Plugins must provide their name via the metadata by populating the field and raising their schema version from 0.1.0 to 0.2.0. Signed-off-by: Milas Bowman <milas.bowman@docker.com>
1 parent 0e70f1b commit 5a0014a

9 files changed

Lines changed: 199 additions & 20 deletions

File tree

cli-plugins/manager/candidate_test.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,13 @@ func TestValidateCandidate(t *testing.T) {
6565
{name: "builtin alias", c: &fakeCandidate{path: builtinAlias}, invalid: `plugin "alias" duplicates an alias of builtin command "builtin"`},
6666
{name: "fetch failure", c: &fakeCandidate{path: goodPluginPath, exec: false}, invalid: fmt.Sprintf("failed to fetch metadata: faked a failure to exec %q", goodPluginPath)},
6767
{name: "metadata not json", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `xyzzy`}, invalid: "invalid character"},
68-
{name: "empty schemaversion", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{}`}, invalid: `plugin SchemaVersion "" is not valid`},
69-
{name: "invalid schemaversion", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "xyzzy"}`}, invalid: `plugin SchemaVersion "xyzzy" is not valid`},
70-
{name: "no vendor", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0"}`}, invalid: "plugin metadata does not define a vendor"},
71-
{name: "empty vendor", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": ""}`}, invalid: "plugin metadata does not define a vendor"},
68+
{name: "empty schemaversion", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{}`}, invalid: `plugin metadata failed validation: "SchemaVersion" field is required`},
69+
{name: "invalid schemaversion", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "xyzzy"}`}, invalid: `plugin metadata failed validation: unknown schema version: xyzzy`},
70+
{name: "no vendor", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0"}`}, invalid: `plugin metadata failed validation: "Vendor" field is required`},
71+
{
72+
name: "empty vendor", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": ""}`},
73+
invalid: `plugin metadata failed validation: "Vendor" field is required`,
74+
},
7275
// This one should work
7376
{name: "valid", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing"}`}},
7477
{name: "experimental + allowing experimental", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: metaExperimental}},

cli-plugins/manager/metadata.go

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package manager
22

3+
import "fmt"
4+
35
const (
46
// NamePrefix is the prefix required on all plugin binary names
57
NamePrefix = "docker-"
@@ -12,9 +14,13 @@ const (
1214

1315
// Metadata provided by the plugin.
1416
type Metadata struct {
15-
// SchemaVersion describes the version of this struct. Mandatory, must be "0.1.0"
17+
// SchemaVersion describes the version of this struct. Mandatory.
1618
SchemaVersion string `json:",omitempty"`
17-
// Vendor is the name of the plugin vendor. Mandatory
19+
// Name of the plugin.
20+
//
21+
// Mandatory if SchemaVersion >= 0.2.0.
22+
Name string `json:",omitempty"`
23+
// Vendor is the name of the plugin vendor. Mandatory.
1824
Vendor string `json:",omitempty"`
1925
// Version is the optional version of this plugin.
2026
Version string `json:",omitempty"`
@@ -23,3 +29,29 @@ type Metadata struct {
2329
// URL is a pointer to the plugin's homepage.
2430
URL string `json:",omitempty"`
2531
}
32+
33+
// validateMetadata returns an error if any fields are missing or invalid for the
34+
// specified SchemaVersion.
35+
func validateMetadata(meta *Metadata) error {
36+
if meta.SchemaVersion == "" {
37+
return fmt.Errorf("%q field is required", "SchemaVersion")
38+
}
39+
40+
switch meta.SchemaVersion {
41+
case "0.1.0":
42+
// clear fields not supported by version
43+
meta.Name = ""
44+
case "0.2.0":
45+
if meta.Name == "" {
46+
return fmt.Errorf("%q field is required", "Name")
47+
}
48+
default:
49+
return NewPluginError("unknown schema version: %s", meta.SchemaVersion)
50+
}
51+
52+
if meta.Vendor == "" {
53+
return fmt.Errorf("%q field is required", "Vendor")
54+
}
55+
56+
return nil
57+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package manager
2+
3+
import (
4+
"testing"
5+
6+
"gotest.tools/v3/assert"
7+
)
8+
9+
func TestValidateMetadata(t *testing.T) {
10+
tcs := []struct {
11+
name string
12+
meta Metadata
13+
expectedErr string
14+
}{
15+
{
16+
name: "empty",
17+
meta: Metadata{},
18+
expectedErr: `"SchemaVersion" field is required`,
19+
},
20+
{
21+
name: "invalid schema",
22+
meta: Metadata{SchemaVersion: "fake-schema", Vendor: "fake-vendor"},
23+
expectedErr: "unknown schema version: fake-schema",
24+
},
25+
{
26+
name: "missing vendor",
27+
meta: Metadata{SchemaVersion: "0.1.0"},
28+
expectedErr: `"Vendor" field is required`,
29+
},
30+
{
31+
name: "no name - 0.1.0",
32+
meta: Metadata{SchemaVersion: "0.1.0", Vendor: "fake-vendor"},
33+
expectedErr: "",
34+
},
35+
{
36+
name: "no name - 0.2.0",
37+
meta: Metadata{SchemaVersion: "0.2.0", Vendor: "fake-vendor"},
38+
expectedErr: `"Name" field is required`,
39+
},
40+
}
41+
for _, tc := range tcs {
42+
t.Run(tc.name, func(t *testing.T) {
43+
err := validateMetadata(&tc.meta)
44+
if tc.expectedErr == "" {
45+
assert.NilError(t, err)
46+
} else {
47+
assert.Error(t, err, tc.expectedErr)
48+
}
49+
})
50+
}
51+
}

cli-plugins/manager/plugin.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,11 @@ func newPlugin(c Candidate, cmds []*cobra.Command) (Plugin, error) {
9090
p.Err = wrapAsPluginError(err, "invalid metadata")
9191
return p, nil
9292
}
93-
if p.Metadata.SchemaVersion != "0.1.0" {
94-
p.Err = NewPluginError("plugin SchemaVersion %q is not valid, must be 0.1.0", p.Metadata.SchemaVersion)
95-
return p, nil
96-
}
97-
if p.Metadata.Vendor == "" {
98-
p.Err = NewPluginError("plugin metadata does not define a vendor")
93+
94+
if err := validateMetadata(&p.Metadata); err != nil {
95+
p.Err = wrapAsPluginError(err, "plugin metadata failed validation")
9996
return p, nil
10097
}
98+
10199
return p, nil
102100
}

cli-plugins/plugin/plugin.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,12 @@ func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager
5353

5454
// Run is the top-level entry point to the CLI plugin framework. It should be called from your plugin's `main()` function.
5555
func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) {
56-
dockerCli, err := command.NewDockerCli()
56+
var dockerCliOpts []command.DockerCliOption
57+
if ua, ok := userAgent(meta); ok {
58+
dockerCliOpts = append(dockerCliOpts, command.WithUserAgent(ua))
59+
}
60+
61+
dockerCli, err := command.NewDockerCli(dockerCliOpts...)
5762
if err != nil {
5863
fmt.Fprintln(os.Stderr, err)
5964
os.Exit(1)
@@ -175,3 +180,16 @@ func RunningStandalone() bool {
175180
}
176181
return len(os.Args) < 2 || os.Args[1] != manager.MetadataSubcommandName
177182
}
183+
184+
func userAgent(meta manager.Metadata) (string, bool) {
185+
if meta.Name == "" {
186+
return "", false
187+
}
188+
189+
// Docker-Client/1.0 (linux) SomeVendor-SomePlugin/1.0
190+
ua := command.UserAgent() + " " + meta.Vendor + "-" + meta.Name
191+
if meta.Version != "" {
192+
ua += "/" + meta.Version
193+
}
194+
return ua, true
195+
}

cli-plugins/plugin/plugin_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package plugin
2+
3+
import (
4+
"testing"
5+
6+
"github.com/docker/cli/cli-plugins/manager"
7+
"github.com/docker/cli/cli/command"
8+
"gotest.tools/v3/assert"
9+
"gotest.tools/v3/assert/cmp"
10+
)
11+
12+
func TestUserAgent(t *testing.T) {
13+
tcs := []struct {
14+
expected string
15+
meta manager.Metadata
16+
}{
17+
{
18+
expected: "vendor-plugin/0.0.1",
19+
meta: manager.Metadata{Name: "plugin", Vendor: "vendor", Version: "0.0.1"},
20+
},
21+
{
22+
expected: "docker-fake",
23+
meta: manager.Metadata{Name: "fake", Vendor: "docker"},
24+
},
25+
}
26+
for _, tc := range tcs {
27+
t.Run(tc.expected, func(t *testing.T) {
28+
ua, ok := userAgent(tc.meta)
29+
if tc.expected == "" {
30+
assert.Equal(t, ok, false)
31+
assert.Equal(t, ua, "")
32+
} else {
33+
assert.Equal(t, ok, true)
34+
assert.Assert(t, cmp.Contains(ua, tc.expected))
35+
// the default user agent portion should still be present
36+
assert.Assert(t, cmp.Contains(ua, command.UserAgent()))
37+
}
38+
})
39+
}
40+
}

cli/command/cli.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ type DockerCli struct {
8282
dockerEndpoint docker.Endpoint
8383
contextStoreConfig store.Config
8484
initTimeout time.Duration
85+
userAgent string
8586
}
8687

8788
// DefaultVersion returns api.defaultVersion.
@@ -191,7 +192,7 @@ func (cli *DockerCli) RegistryClient(allowInsecure bool) registryclient.Registry
191192
resolver := func(ctx context.Context, index *registry.IndexInfo) registry.AuthConfig {
192193
return ResolveAuthConfig(cli.ConfigFile(), index)
193194
}
194-
return registryclient.NewRegistryClient(resolver, UserAgent(), allowInsecure)
195+
return registryclient.NewRegistryClient(resolver, cli.userAgent, allowInsecure)
195196
}
196197

197198
// InitializeOpt is the type of the functional options passed to DockerCli.Initialize
@@ -256,18 +257,18 @@ func NewAPIClientFromFlags(opts *cliflags.ClientOptions, configFile *configfile.
256257
if err != nil {
257258
return nil, errors.Wrap(err, "unable to resolve docker endpoint")
258259
}
259-
return newAPIClientFromEndpoint(endpoint, configFile)
260+
return newAPIClientFromEndpoint(endpoint, configFile, UserAgent())
260261
}
261262

262-
func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigFile) (client.APIClient, error) {
263+
func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigFile, userAgent string) (client.APIClient, error) {
263264
opts, err := ep.ClientOpts()
264265
if err != nil {
265266
return nil, err
266267
}
267268
if len(configFile.HTTPHeaders) > 0 {
268269
opts = append(opts, client.WithHTTPHeaders(configFile.HTTPHeaders))
269270
}
270-
opts = append(opts, client.WithUserAgent(UserAgent()))
271+
opts = append(opts, client.WithUserAgent(userAgent))
271272
return client.NewClientWithOpts(opts...)
272273
}
273274

@@ -349,7 +350,7 @@ func (cli *DockerCli) initializeFromClient() {
349350

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

355356
// ContextStore returns the ContextStore
@@ -440,7 +441,7 @@ func (cli *DockerCli) initialize() error {
440441
return
441442
}
442443
if cli.client == nil {
443-
if cli.client, cli.initErr = newAPIClientFromEndpoint(cli.dockerEndpoint, cli.configFile); cli.initErr != nil {
444+
if cli.client, cli.initErr = newAPIClientFromEndpoint(cli.dockerEndpoint, cli.configFile, cli.userAgent); cli.initErr != nil {
444445
return
445446
}
446447
}
@@ -484,6 +485,7 @@ func NewDockerCli(ops ...DockerCliOption) (*DockerCli, error) {
484485
WithContentTrustFromEnv(),
485486
WithDefaultContextStoreConfig(),
486487
WithStandardStreams(),
488+
WithUserAgent(UserAgent()),
487489
}
488490
ops = append(defaultOps, ops...)
489491

@@ -508,7 +510,7 @@ func getServerHost(hosts []string, tlsOptions *tlsconfig.Options) (string, error
508510
return dopts.ParseHost(tlsOptions != nil, host)
509511
}
510512

511-
// UserAgent returns the user agent string used for making API requests
513+
// UserAgent returns the default user agent string used for making API requests.
512514
func UserAgent() string {
513515
return "Docker-Client/" + version.Version + " (" + runtime.GOOS + ")"
514516
}

cli/command/cli_options.go

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

33
import (
4+
"errors"
45
"io"
56
"os"
67
"strconv"
@@ -95,3 +96,14 @@ func WithAPIClient(c client.APIClient) DockerCliOption {
9596
return nil
9697
}
9798
}
99+
100+
// WithUserAgent configures the User-Agent string for cli HTTP requests.
101+
func WithUserAgent(userAgent string) DockerCliOption {
102+
return func(cli *DockerCli) error {
103+
if userAgent == "" {
104+
return errors.New("user agent cannot be blank")
105+
}
106+
cli.userAgent = userAgent
107+
return nil
108+
}
109+
}

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)