client: set default user-agent based on module version
| ... | ... |
@@ -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 |