Browse code

Merge pull request #52167 from thaJeztah/client_ua

client: set default user-agent based on module version

Sebastiaan van Stijn authored on 2026/03/17 18:45:54
Showing 9 changed files
... ...
@@ -59,6 +59,7 @@ import (
59 59
 	"net/http"
60 60
 	"net/url"
61 61
 	"path"
62
+	"runtime"
62 63
 	"slices"
63 64
 	"strings"
64 65
 	"sync"
... ...
@@ -67,6 +68,7 @@ import (
67 67
 
68 68
 	cerrdefs "github.com/containerd/errdefs"
69 69
 	"github.com/docker/go-connections/sockets"
70
+	"github.com/moby/moby/client/internal/mod"
70 71
 	"github.com/moby/moby/client/pkg/versions"
71 72
 	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
72 73
 )
... ...
@@ -113,6 +115,10 @@ const MaxAPIVersion = "1.54"
113 113
 // below this version are not considered when performing API-version negotiation.
114 114
 const MinAPIVersion = "1.40"
115 115
 
116
+// defaultUserAgent returns the default User-Agent to use if none is set.
117
+// It defaults to "moby-client/<module version> os/arch"
118
+var defaultUserAgent = sync.OnceValue(userAgent)
119
+
116 120
 // Ensure that Client always implements APIClient.
117 121
 var _ APIClient = &Client{}
118 122
 
... ...
@@ -431,3 +437,14 @@ func (cli *Client) dialer() func(context.Context) (net.Conn, error) {
431 431
 		}
432 432
 	}
433 433
 }
434
+
435
+func userAgent() string {
436
+	const defaultVersion = "v0.0.0+unknown"
437
+	const moduleName = "github.com/moby/moby/client"
438
+
439
+	version := defaultVersion
440
+	if v := mod.Version(moduleName); v != "" {
441
+		version = v
442
+	}
443
+	return "moby-client/" + version + " " + runtime.GOOS + "/" + runtime.GOARCH
444
+}
... ...
@@ -344,7 +344,7 @@ func TestWithUserAgent(t *testing.T) {
344 344
 		c, err := New(
345 345
 			WithHTTPHeaders(map[string]string{"Other-Header": "hello-world"}),
346 346
 			WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
347
-				assert.Check(t, is.Equal(req.Header.Get("User-Agent"), ""))
347
+				assert.Check(t, is.Equal(req.Header.Get("User-Agent"), defaultUserAgent()))
348 348
 				assert.Check(t, is.Equal(req.Header.Get("Other-Header"), "hello-world"))
349 349
 				return &http.Response{StatusCode: http.StatusOK}, nil
350 350
 			}),
351 351
new file mode 100644
... ...
@@ -0,0 +1,226 @@
0
+// Package mod provides a small helper to extract a module's version
1
+// from [debug.BuildInfo] without depending on [golang.org/x/mod].
2
+//
3
+// [golang.org/x/mod]: https://pkg.go.dev/golang.org/x/mod
4
+package mod
5
+
6
+import (
7
+	"fmt"
8
+	"runtime/debug"
9
+	"strconv"
10
+	"strings"
11
+	"sync"
12
+)
13
+
14
+var readBuildInfo = sync.OnceValues(debug.ReadBuildInfo)
15
+
16
+// Version returns a best-effort version string for the given module path,
17
+// similar to [mod.Version] in the daemon.
18
+//
19
+// If the module is present in [debug.BuildInfo] dependencies, its version
20
+// is returned. Tagged versions are returned as-is (with "+incompatible"
21
+// stripped). [Pseudo-versions] are normalized to:
22
+//
23
+//	<base>+<revision>[+meta...][+dirty]
24
+//
25
+// Where "<base>" matches the behavior of [module.PseudoVersionBase] (i.e.,
26
+// downgrade to the previous tag for non-prerelease Pseudo-versions).
27
+//
28
+// If the module is replaced (for example via go.work or replace directives),
29
+// or no usable version information is available, Version returns an empty string.
30
+//
31
+// The returned value is intended for display purposes (e.g., in a default
32
+// User-Agent), not for version comparison.
33
+//
34
+// [mod.Version]: https://pkg.go.dev/github.com/moby/moby/v2@v2.0.0-beta.7/daemon/internal/builder-next/worker/mod#Version
35
+// [module.PseudoVersionBase]: https://pkg.go.dev/golang.org/x/mod@v0.34.0/module#PseudoVersionBase
36
+// [Pseudo-versions]: https://cs.opensource.google/go/x/mod/+/refs/tags/v0.34.0:module/pseudo.go;l=5-33
37
+func Version(name string) string {
38
+	bi, ok := readBuildInfo()
39
+	if !ok || bi == nil {
40
+		return ""
41
+	}
42
+	return moduleVersion(name, bi)
43
+}
44
+
45
+func moduleVersion(name string, bi *debug.BuildInfo) (modVersion string) {
46
+	if bi == nil {
47
+		return ""
48
+	}
49
+
50
+	// Check if we're the main module.
51
+	if ok, v := getVersion(name, &bi.Main); ok {
52
+		return v
53
+	}
54
+
55
+	// iterate over all dependencies and find name
56
+	for _, dep := range bi.Deps {
57
+		if ok, v := getVersion(name, dep); ok {
58
+			return v
59
+		}
60
+	}
61
+
62
+	return ""
63
+}
64
+
65
+func getVersion(name string, dep *debug.Module) (bool, string) {
66
+	if dep == nil || dep.Path != name {
67
+		return false, ""
68
+	}
69
+
70
+	v := dep.Version
71
+	if dep.Replace != nil && dep.Replace.Version != "" {
72
+		v = dep.Replace.Version
73
+	}
74
+	if v == "" || v == "(devel)" {
75
+		return true, ""
76
+	}
77
+
78
+	return true, normalize(v)
79
+}
80
+
81
+// normalize converts a Go module version into a display-friendly form:
82
+//
83
+//   - strips "+incompatible" unconditionally
84
+//   - if pseudo: vX.Y.Z[-pre][+rev][+meta...][+dirty]
85
+//   - if tagged: vX.Y.Z[-pre][+meta...][+dirty]
86
+func normalize(v string) string {
87
+	base, metas, dirty := splitMetadata(v)
88
+
89
+	out := base
90
+	if base2, rev, undoPatch, ok := splitPseudo(base); ok {
91
+		if undoPatch {
92
+			// Downgrade the patch version that was raised by pseudo-versions:
93
+			//
94
+			//	(2) vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456
95
+			if major, minor, patch, ok := parseSemVer(base2); ok && patch > 0 {
96
+				patch--
97
+				base2 = fmt.Sprintf("v%d.%d.%d", major, minor, patch)
98
+			}
99
+		}
100
+		// Go pseudo rev is typically 12, but be defensive.
101
+		if len(rev) > 12 {
102
+			rev = rev[:12]
103
+		}
104
+		out = base2 + "+" + rev
105
+	}
106
+
107
+	// Preserve other metadata (except for "+incompatible").
108
+	for _, m := range metas {
109
+		out += m
110
+	}
111
+	if dirty {
112
+		// +dirty goes last
113
+		out += "+dirty"
114
+	}
115
+	return out
116
+}
117
+
118
+func splitMetadata(v string) (base string, metas []string, dirty bool) {
119
+	base, meta, ok := strings.Cut(v, "+")
120
+	if !ok || meta == "" {
121
+		return base, nil, false
122
+	}
123
+	for m := range strings.SplitSeq(meta, "+") {
124
+		// drop incompatible, extract dirty, preserve everything else.
125
+		switch m {
126
+		case "incompatible", "":
127
+			// drop "+incompatible" and empty strings
128
+		case "dirty":
129
+			dirty = true
130
+		default:
131
+			metas = append(metas, "+"+m)
132
+		}
133
+	}
134
+
135
+	return base, metas, dirty
136
+}
137
+
138
+// splitPseudo splits a pseudo-version into base + revision, and reports whether
139
+// it is a (Z+1) pseudo that needs patch undo.
140
+//
141
+// Supported (after stripping +incompatible/+dirty metadata):
142
+//
143
+// (1) vX.0.0-yyyymmddhhmmss-abcdef123456
144
+// (2) vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456
145
+// (4) vX.Y.Z-pre.0.yyyymmddhhmmss-abcdef123456
146
+func splitPseudo(v string) (base, rev string, undoPatch bool, ok bool) {
147
+	// Split off revision at the last '-'.
148
+	last := strings.LastIndexByte(v, '-')
149
+	if last < 0 || last+1 >= len(v) {
150
+		return "", "", false, false
151
+	}
152
+	rev = v[last+1:]
153
+	left := v[:last]
154
+
155
+	// First try the dot-joined timestamp forms:
156
+	//   ...-0.<ts>   (release pseudo; undoPatch)
157
+	//   ....0.<ts>   (prerelease pseudo; preserve prerelease)
158
+	if dot := strings.LastIndexByte(left, '.'); dot > 0 && dot+1 < len(left) {
159
+		ts := left[dot+1:]
160
+		if isTimestamp(ts) {
161
+			prefix := left[:dot] // ends with "-0" or ".0" for forms (2)/(4)
162
+			switch {
163
+			case strings.HasSuffix(prefix, "-0"):
164
+				// (2) vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456
165
+				return prefix[:len(prefix)-2], rev, true, true
166
+			case strings.HasSuffix(prefix, ".0"):
167
+				// (4) vX.Y.Z-pre.0.yyyymmddhhmmss-abcdef123456
168
+				return prefix[:len(prefix)-2], rev, false, true
169
+			}
170
+		}
171
+	}
172
+
173
+	// Fall back to form (1): ...-<ts>-<rev>
174
+	//
175
+	// (1) vX.0.0-yyyymmddhhmmss-abcdef123456
176
+	if dash := strings.LastIndexByte(left, '-'); dash > 0 && dash+1 < len(left) {
177
+		ts := left[dash+1:]
178
+		if isTimestamp(ts) {
179
+			return left[:dash], rev, false, true
180
+		}
181
+	}
182
+
183
+	return "", "", false, false
184
+}
185
+
186
+// isTimestamp checks whether s is a timestamp ("yyyymmddhhmmss")
187
+// component in a module version (vX.0.0-yyyymmddhhmmss-abcdef123456).
188
+func isTimestamp(s string) bool {
189
+	if len(s) != 14 {
190
+		return false
191
+	}
192
+	for i := range len(s) {
193
+		c := s[i]
194
+		if c < '0' || c > '9' {
195
+			return false
196
+		}
197
+	}
198
+	return true
199
+}
200
+
201
+// parseSemVer parses "vX.Y.Z" into numeric components.
202
+// It intentionally handles only the strict three-segment core form.
203
+func parseSemVer(v string) (major, minor, patch int, ok bool) {
204
+	if len(v) < 2 || v[0] != 'v' {
205
+		return 0, 0, 0, false
206
+	}
207
+	parts := strings.Split(v[1:], ".")
208
+	if len(parts) != 3 {
209
+		return 0, 0, 0, false
210
+	}
211
+	var err error
212
+	major, err = strconv.Atoi(parts[0])
213
+	if err != nil {
214
+		return 0, 0, 0, false
215
+	}
216
+	minor, err = strconv.Atoi(parts[1])
217
+	if err != nil {
218
+		return 0, 0, 0, false
219
+	}
220
+	patch, err = strconv.Atoi(parts[2])
221
+	if err != nil {
222
+		return 0, 0, 0, false
223
+	}
224
+	return major, minor, patch, true
225
+}
0 226
new file mode 100644
... ...
@@ -0,0 +1,156 @@
0
+package mod
1
+
2
+import (
3
+	"runtime/debug"
4
+	"testing"
5
+)
6
+
7
+func TestModuleVersion(t *testing.T) {
8
+	tests := []struct {
9
+		name        string
10
+		module      string
11
+		biContent   string
12
+		wantVersion string
13
+	}{
14
+		{
15
+			name: "main module in devel mode returns empty string",
16
+			biContent: `
17
+go	go1.20.3
18
+path	github.com/moby/moby/v2/daemon/internal/builder-next/worker
19
+mod	github.com/moby/moby/v2	(devel)
20
+dep	github.com/moby/buildkit	v0.11.5	h1:JZvvWzulcnA2G4c/gJiSIqKDUoBjctYw2WMuS+XJexU=
21
+=>	github.com/moby/buildkit	v0.12.0	h1:3YO8J4RtmG7elEgaWMb4HgmpS2CfY1QlaOz9nwB+ZSs=
22
+			`,
23
+			module:      "github.com/moby/moby/v2",
24
+			wantVersion: "",
25
+		},
26
+		{
27
+			name: "main module returns tagged version",
28
+			biContent: `
29
+go	go1.25.8
30
+path	github.com/moby/moby/v2/daemon/internal/mod/gen
31
+mod	github.com/moby/moby/v2	v2.0.0-beta.7
32
+build	-buildmode=exe
33
+build	-compiler=gc
34
+build	CGO_ENABLED=1
35
+build	CGO_CFLAGS=
36
+build	CGO_CPPFLAGS=
37
+build	CGO_CXXFLAGS=
38
+build	CGO_LDFLAGS=
39
+build	GOARCH=arm64
40
+build	GOOS=linux
41
+build	GOARM64=v8.0
42
+build	vcs=git
43
+build	vcs.revision=83bca512aa7ffc1bb4f37ce1107e0d3e3489ad43
44
+build	vcs.time=2026-03-05T14:05:47Z
45
+build	vcs.modified=false
46
+			`,
47
+			module:      "github.com/moby/moby/v2",
48
+			wantVersion: "v2.0.0-beta.7",
49
+		},
50
+		{
51
+			name: "main module returns the base version of pseudo version",
52
+			biContent: `
53
+go	go1.25.8
54
+path	github.com/moby/moby/v2/daemon/internal/mod/gen
55
+mod	github.com/moby/moby/v2	v2.0.0-beta.7.0.20260312170906-aac47873cb5c
56
+build	-buildmode=exe
57
+build	-compiler=gc
58
+build	CGO_ENABLED=1
59
+build	CGO_CFLAGS=
60
+build	CGO_CPPFLAGS=
61
+build	CGO_CXXFLAGS=
62
+build	CGO_LDFLAGS=
63
+build	GOARCH=arm64
64
+build	GOOS=linux
65
+build	GOARM64=v8.0
66
+build	vcs=git
67
+build	vcs.revision=aac47873cb5c31561169c069dba48193ddcbd45c
68
+build	vcs.time=2026-03-12T17:09:06Z
69
+build	vcs.modified=false
70
+`,
71
+			module:      "github.com/moby/moby/v2",
72
+			wantVersion: "v2.0.0-beta.7+aac47873cb5c",
73
+		},
74
+		{
75
+			name: "main module git dirty",
76
+			biContent: `
77
+go	go1.25.8
78
+path	github.com/moby/moby/v2/daemon/internal/mod/gen
79
+mod	github.com/moby/moby/v2	v2.0.0-beta.7.0.20260312170906-aac47873cb5c+dirty
80
+build	-buildmode=exe
81
+build	-compiler=gc
82
+build	CGO_ENABLED=1
83
+build	CGO_CFLAGS=
84
+build	CGO_CPPFLAGS=
85
+build	CGO_CXXFLAGS=
86
+build	CGO_LDFLAGS=
87
+build	GOARCH=arm64
88
+build	GOOS=linux
89
+build	GOARM64=v8.0
90
+build	vcs=git
91
+build	vcs.revision=aac47873cb5c31561169c069dba48193ddcbd45c
92
+build	vcs.time=2026-03-12T17:09:06Z
93
+build	vcs.modified=true
94
+`,
95
+			module:      "github.com/moby/moby/v2",
96
+			wantVersion: "v2.0.0-beta.7+aac47873cb5c+dirty",
97
+		},
98
+		{
99
+			name: "returns empty string if build information not available",
100
+			biContent: `
101
+go	go1.20.3
102
+path	github.com/moby/moby/v2/daemon/internal/builder-next/worker
103
+mod	github.com/moby/moby/v2	(devel)
104
+			`,
105
+			module:      "github.com/moby/buildkit",
106
+			wantVersion: "",
107
+		},
108
+		{
109
+			name: "returns the version of buildkit dependency",
110
+			biContent: `
111
+go	go1.20.3
112
+path	github.com/moby/moby/v2/daemon/internal/builder-next/worker
113
+mod	github.com/moby/moby/v2	(devel)
114
+dep	github.com/moby/buildkit	v0.11.5	h1:JZvvWzulcnA2G4c/gJiSIqKDUoBjctYw2WMuS+XJexU=
115
+			`,
116
+			module:      "github.com/moby/buildkit",
117
+			wantVersion: "v0.11.5",
118
+		},
119
+		{
120
+			name: "returns the replaced version of buildkit dependency",
121
+			biContent: `
122
+go	go1.20.3
123
+path	github.com/moby/moby/v2/daemon/internal/builder-next/worker
124
+mod	github.com/moby/moby/v2	(devel)
125
+dep	github.com/moby/buildkit	v0.11.5	h1:JZvvWzulcnA2G4c/gJiSIqKDUoBjctYw2WMuS+XJexU=
126
+=>	github.com/moby/buildkit	v0.12.0	h1:3YO8J4RtmG7elEgaWMb4HgmpS2CfY1QlaOz9nwB+ZSs=
127
+			`,
128
+			module:      "github.com/moby/buildkit",
129
+			wantVersion: "v0.12.0",
130
+		},
131
+		{
132
+			name: "returns the base version of pseudo version",
133
+			biContent: `
134
+go	go1.20.3
135
+path	github.com/moby/moby/v2/daemon/internal/builder-next/worker
136
+mod	github.com/moby/moby/v2	(devel)
137
+dep	github.com/moby/buildkit	v0.10.7-0.20230306143919-70f2ad56d3e5	h1:JZvvWzulcnA2G4c/gJiSIqKDUoBjctYw2WMuS+XJexU=
138
+			`,
139
+			module:      "github.com/moby/buildkit",
140
+			wantVersion: "v0.10.6+70f2ad56d3e5",
141
+		},
142
+	}
143
+
144
+	for _, tc := range tests {
145
+		t.Run(tc.name, func(t *testing.T) {
146
+			bi, err := debug.ParseBuildInfo(tc.biContent)
147
+			if err != nil {
148
+				t.Fatalf("failed to parse build info: %v", err)
149
+			}
150
+			if gotVersion := moduleVersion(tc.module, bi); gotVersion != tc.wantVersion {
151
+				t.Errorf("moduleVersion() = %v, want %v", gotVersion, tc.wantVersion)
152
+			}
153
+		})
154
+	}
155
+}
... ...
@@ -317,12 +317,17 @@ func (cli *Client) addHeaders(req *http.Request, headers http.Header) *http.Requ
317 317
 		req.Header[http.CanonicalHeaderKey(k)] = v
318 318
 	}
319 319
 
320
-	if cli.userAgent != nil {
321
-		if *cli.userAgent == "" {
322
-			req.Header.Del("User-Agent")
323
-		} else {
324
-			req.Header.Set("User-Agent", *cli.userAgent)
320
+	if cli.userAgent == nil {
321
+		// No custom User-Agent set: use the default.
322
+		if req.Header.Get("User-Agent") == "" {
323
+			req.Header.Set("User-Agent", defaultUserAgent())
325 324
 		}
325
+	} else if *cli.userAgent == "" {
326
+		// User-Agent set to empty value; remove User-Agent.
327
+		req.Header.Del("User-Agent")
328
+	} else {
329
+		// Custom User-Agent set.
330
+		req.Header.Set("User-Agent", *cli.userAgent)
326 331
 	}
327 332
 	return req
328 333
 }
... ...
@@ -59,6 +59,7 @@ import (
59 59
 	"net/http"
60 60
 	"net/url"
61 61
 	"path"
62
+	"runtime"
62 63
 	"slices"
63 64
 	"strings"
64 65
 	"sync"
... ...
@@ -67,6 +68,7 @@ import (
67 67
 
68 68
 	cerrdefs "github.com/containerd/errdefs"
69 69
 	"github.com/docker/go-connections/sockets"
70
+	"github.com/moby/moby/client/internal/mod"
70 71
 	"github.com/moby/moby/client/pkg/versions"
71 72
 	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
72 73
 )
... ...
@@ -113,6 +115,10 @@ const MaxAPIVersion = "1.54"
113 113
 // below this version are not considered when performing API-version negotiation.
114 114
 const MinAPIVersion = "1.40"
115 115
 
116
+// defaultUserAgent returns the default User-Agent to use if none is set.
117
+// It defaults to "moby-client/<module version> os/arch"
118
+var defaultUserAgent = sync.OnceValue(userAgent)
119
+
116 120
 // Ensure that Client always implements APIClient.
117 121
 var _ APIClient = &Client{}
118 122
 
... ...
@@ -431,3 +437,14 @@ func (cli *Client) dialer() func(context.Context) (net.Conn, error) {
431 431
 		}
432 432
 	}
433 433
 }
434
+
435
+func userAgent() string {
436
+	const defaultVersion = "v0.0.0+unknown"
437
+	const moduleName = "github.com/moby/moby/client"
438
+
439
+	version := defaultVersion
440
+	if v := mod.Version(moduleName); v != "" {
441
+		version = v
442
+	}
443
+	return "moby-client/" + version + " " + runtime.GOOS + "/" + runtime.GOARCH
444
+}
434 445
new file mode 100644
... ...
@@ -0,0 +1,226 @@
0
+// Package mod provides a small helper to extract a module's version
1
+// from [debug.BuildInfo] without depending on [golang.org/x/mod].
2
+//
3
+// [golang.org/x/mod]: https://pkg.go.dev/golang.org/x/mod
4
+package mod
5
+
6
+import (
7
+	"fmt"
8
+	"runtime/debug"
9
+	"strconv"
10
+	"strings"
11
+	"sync"
12
+)
13
+
14
+var readBuildInfo = sync.OnceValues(debug.ReadBuildInfo)
15
+
16
+// Version returns a best-effort version string for the given module path,
17
+// similar to [mod.Version] in the daemon.
18
+//
19
+// If the module is present in [debug.BuildInfo] dependencies, its version
20
+// is returned. Tagged versions are returned as-is (with "+incompatible"
21
+// stripped). [Pseudo-versions] are normalized to:
22
+//
23
+//	<base>+<revision>[+meta...][+dirty]
24
+//
25
+// Where "<base>" matches the behavior of [module.PseudoVersionBase] (i.e.,
26
+// downgrade to the previous tag for non-prerelease Pseudo-versions).
27
+//
28
+// If the module is replaced (for example via go.work or replace directives),
29
+// or no usable version information is available, Version returns an empty string.
30
+//
31
+// The returned value is intended for display purposes (e.g., in a default
32
+// User-Agent), not for version comparison.
33
+//
34
+// [mod.Version]: https://pkg.go.dev/github.com/moby/moby/v2@v2.0.0-beta.7/daemon/internal/builder-next/worker/mod#Version
35
+// [module.PseudoVersionBase]: https://pkg.go.dev/golang.org/x/mod@v0.34.0/module#PseudoVersionBase
36
+// [Pseudo-versions]: https://cs.opensource.google/go/x/mod/+/refs/tags/v0.34.0:module/pseudo.go;l=5-33
37
+func Version(name string) string {
38
+	bi, ok := readBuildInfo()
39
+	if !ok || bi == nil {
40
+		return ""
41
+	}
42
+	return moduleVersion(name, bi)
43
+}
44
+
45
+func moduleVersion(name string, bi *debug.BuildInfo) (modVersion string) {
46
+	if bi == nil {
47
+		return ""
48
+	}
49
+
50
+	// Check if we're the main module.
51
+	if ok, v := getVersion(name, &bi.Main); ok {
52
+		return v
53
+	}
54
+
55
+	// iterate over all dependencies and find name
56
+	for _, dep := range bi.Deps {
57
+		if ok, v := getVersion(name, dep); ok {
58
+			return v
59
+		}
60
+	}
61
+
62
+	return ""
63
+}
64
+
65
+func getVersion(name string, dep *debug.Module) (bool, string) {
66
+	if dep == nil || dep.Path != name {
67
+		return false, ""
68
+	}
69
+
70
+	v := dep.Version
71
+	if dep.Replace != nil && dep.Replace.Version != "" {
72
+		v = dep.Replace.Version
73
+	}
74
+	if v == "" || v == "(devel)" {
75
+		return true, ""
76
+	}
77
+
78
+	return true, normalize(v)
79
+}
80
+
81
+// normalize converts a Go module version into a display-friendly form:
82
+//
83
+//   - strips "+incompatible" unconditionally
84
+//   - if pseudo: vX.Y.Z[-pre][+rev][+meta...][+dirty]
85
+//   - if tagged: vX.Y.Z[-pre][+meta...][+dirty]
86
+func normalize(v string) string {
87
+	base, metas, dirty := splitMetadata(v)
88
+
89
+	out := base
90
+	if base2, rev, undoPatch, ok := splitPseudo(base); ok {
91
+		if undoPatch {
92
+			// Downgrade the patch version that was raised by pseudo-versions:
93
+			//
94
+			//	(2) vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456
95
+			if major, minor, patch, ok := parseSemVer(base2); ok && patch > 0 {
96
+				patch--
97
+				base2 = fmt.Sprintf("v%d.%d.%d", major, minor, patch)
98
+			}
99
+		}
100
+		// Go pseudo rev is typically 12, but be defensive.
101
+		if len(rev) > 12 {
102
+			rev = rev[:12]
103
+		}
104
+		out = base2 + "+" + rev
105
+	}
106
+
107
+	// Preserve other metadata (except for "+incompatible").
108
+	for _, m := range metas {
109
+		out += m
110
+	}
111
+	if dirty {
112
+		// +dirty goes last
113
+		out += "+dirty"
114
+	}
115
+	return out
116
+}
117
+
118
+func splitMetadata(v string) (base string, metas []string, dirty bool) {
119
+	base, meta, ok := strings.Cut(v, "+")
120
+	if !ok || meta == "" {
121
+		return base, nil, false
122
+	}
123
+	for m := range strings.SplitSeq(meta, "+") {
124
+		// drop incompatible, extract dirty, preserve everything else.
125
+		switch m {
126
+		case "incompatible", "":
127
+			// drop "+incompatible" and empty strings
128
+		case "dirty":
129
+			dirty = true
130
+		default:
131
+			metas = append(metas, "+"+m)
132
+		}
133
+	}
134
+
135
+	return base, metas, dirty
136
+}
137
+
138
+// splitPseudo splits a pseudo-version into base + revision, and reports whether
139
+// it is a (Z+1) pseudo that needs patch undo.
140
+//
141
+// Supported (after stripping +incompatible/+dirty metadata):
142
+//
143
+// (1) vX.0.0-yyyymmddhhmmss-abcdef123456
144
+// (2) vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456
145
+// (4) vX.Y.Z-pre.0.yyyymmddhhmmss-abcdef123456
146
+func splitPseudo(v string) (base, rev string, undoPatch bool, ok bool) {
147
+	// Split off revision at the last '-'.
148
+	last := strings.LastIndexByte(v, '-')
149
+	if last < 0 || last+1 >= len(v) {
150
+		return "", "", false, false
151
+	}
152
+	rev = v[last+1:]
153
+	left := v[:last]
154
+
155
+	// First try the dot-joined timestamp forms:
156
+	//   ...-0.<ts>   (release pseudo; undoPatch)
157
+	//   ....0.<ts>   (prerelease pseudo; preserve prerelease)
158
+	if dot := strings.LastIndexByte(left, '.'); dot > 0 && dot+1 < len(left) {
159
+		ts := left[dot+1:]
160
+		if isTimestamp(ts) {
161
+			prefix := left[:dot] // ends with "-0" or ".0" for forms (2)/(4)
162
+			switch {
163
+			case strings.HasSuffix(prefix, "-0"):
164
+				// (2) vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456
165
+				return prefix[:len(prefix)-2], rev, true, true
166
+			case strings.HasSuffix(prefix, ".0"):
167
+				// (4) vX.Y.Z-pre.0.yyyymmddhhmmss-abcdef123456
168
+				return prefix[:len(prefix)-2], rev, false, true
169
+			}
170
+		}
171
+	}
172
+
173
+	// Fall back to form (1): ...-<ts>-<rev>
174
+	//
175
+	// (1) vX.0.0-yyyymmddhhmmss-abcdef123456
176
+	if dash := strings.LastIndexByte(left, '-'); dash > 0 && dash+1 < len(left) {
177
+		ts := left[dash+1:]
178
+		if isTimestamp(ts) {
179
+			return left[:dash], rev, false, true
180
+		}
181
+	}
182
+
183
+	return "", "", false, false
184
+}
185
+
186
+// isTimestamp checks whether s is a timestamp ("yyyymmddhhmmss")
187
+// component in a module version (vX.0.0-yyyymmddhhmmss-abcdef123456).
188
+func isTimestamp(s string) bool {
189
+	if len(s) != 14 {
190
+		return false
191
+	}
192
+	for i := range len(s) {
193
+		c := s[i]
194
+		if c < '0' || c > '9' {
195
+			return false
196
+		}
197
+	}
198
+	return true
199
+}
200
+
201
+// parseSemVer parses "vX.Y.Z" into numeric components.
202
+// It intentionally handles only the strict three-segment core form.
203
+func parseSemVer(v string) (major, minor, patch int, ok bool) {
204
+	if len(v) < 2 || v[0] != 'v' {
205
+		return 0, 0, 0, false
206
+	}
207
+	parts := strings.Split(v[1:], ".")
208
+	if len(parts) != 3 {
209
+		return 0, 0, 0, false
210
+	}
211
+	var err error
212
+	major, err = strconv.Atoi(parts[0])
213
+	if err != nil {
214
+		return 0, 0, 0, false
215
+	}
216
+	minor, err = strconv.Atoi(parts[1])
217
+	if err != nil {
218
+		return 0, 0, 0, false
219
+	}
220
+	patch, err = strconv.Atoi(parts[2])
221
+	if err != nil {
222
+		return 0, 0, 0, false
223
+	}
224
+	return major, minor, patch, true
225
+}
... ...
@@ -317,12 +317,17 @@ func (cli *Client) addHeaders(req *http.Request, headers http.Header) *http.Requ
317 317
 		req.Header[http.CanonicalHeaderKey(k)] = v
318 318
 	}
319 319
 
320
-	if cli.userAgent != nil {
321
-		if *cli.userAgent == "" {
322
-			req.Header.Del("User-Agent")
323
-		} else {
324
-			req.Header.Set("User-Agent", *cli.userAgent)
320
+	if cli.userAgent == nil {
321
+		// No custom User-Agent set: use the default.
322
+		if req.Header.Get("User-Agent") == "" {
323
+			req.Header.Set("User-Agent", defaultUserAgent())
325 324
 		}
325
+	} else if *cli.userAgent == "" {
326
+		// User-Agent set to empty value; remove User-Agent.
327
+		req.Header.Del("User-Agent")
328
+	} else {
329
+		// Custom User-Agent set.
330
+		req.Header.Set("User-Agent", *cli.userAgent)
326 331
 	}
327 332
 	return req
328 333
 }
... ...
@@ -1192,6 +1192,7 @@ github.com/moby/moby/api/types/volume
1192 1192
 ## explicit; go 1.24
1193 1193
 github.com/moby/moby/client
1194 1194
 github.com/moby/moby/client/internal
1195
+github.com/moby/moby/client/internal/mod
1195 1196
 github.com/moby/moby/client/internal/timestamp
1196 1197
 github.com/moby/moby/client/pkg/jsonmessage
1197 1198
 github.com/moby/moby/client/pkg/stringid