Skip to content

Improve API version handling - #1187

Open
paperbenni wants to merge 19 commits into
fsouza:mainfrom
paperbenni:main
Open

paperbenni wants to merge 19 commits into
fsouza:mainfrom
paperbenni:main

Conversation

@paperbenni

@paperbenni paperbenni commented Jun 5, 2026 •

Copy link
Copy Markdown
Contributor

The client requests the lowest API version containing all the features used in a request. This used to just provide support for older docker versions if newer features simply weren't used. It causes the client to be rejeceted by new docker versions because old API versions are explicitly no longer supported. This changes API version negotiation to be compatible with newer docker versions

This makes versioning negotiation somewhat more complex, requiring the other changes to make thread safe.
The race conditions were technically preexisting but rarely triggered because checking the server version was skipped most of the time

Closes #1030.

benjamin and others added 13 commits May 18, 2026 10:58
PullImageOptions and BuildImageOptions already expose this field.
The ver struct tags describe the minimum Docker API version required by an
option. They should not decide the request URL version.

Reusing those capability minimums as URL versions can make modern daemons
reject otherwise valid requests. A field introduced in an old API version may
still be valid when sent to a daemon whose minimum supported request version has
moved past that old value.

Keep explicit API versions authoritative, validate them against the option
minimum, and use the negotiated/effective server version when version checks are
enabled.

When version checks are skipped and no explicit version was requested, keep the
path unversioned instead of inventing /v<minimum>/ from a tag.

Add focused pathVersionCheck coverage for explicit, negotiated, skipped,
insufficient, and nil-required-version cases, plus image/build regressions for
the behavior that originally exposed the bug.
- Store serverAPIVersion and expectedAPIVersion in atomic.Value and
  guard updates with a mutex
- Add helpers: getExpectedVersion, getServerVersion, ensureAPIVersion,
  ensureServerVersion, setExpectedVersion,
  ensureServerVersionForCompatibility
- Replace direct version checks with the new helpers in request and
  stream paths
- Use ensureServerVersionForCompatibility to bypass checks when
  requested
- Update tests to align with atomic version storage and negotiated paths

@fsouza fsouza left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this — the approach is right and it does fix #1030 (the ver-tag minimum versions no longer leak into request URLs, and unversioned clients negotiate the server's version instead). A few issues to sort out before this can merge, though. The first three inline comments are the substantive ones (a behavior regression in ExportImages, a silent-failure regression for default NewClient users, and a hang risk in the new probe locking); the fourth is an API-compatibility question; the rest are smaller cleanups.

Comment thread image.go Outdated
} else if c.requestedAPIVersion != nil {
effectiveVersion = c.requestedAPIVersion
}
if effectiveVersion != nil && effectiveVersion.GreaterThanOrEqualTo(apiVersion125) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks multi-image export for negotiating clients. The comma-join format now applies whenever the negotiated server version is >= 1.25, but the daemon's /images/get handler reads r.Form["names"] without splitting on commas — the joined string goes straight to reference.ParseAnyReference, which rejects commas. So ExportImages with two names against a modern daemon, which worked before (repeated names= params), now errors. The "1.25 uses comma-separated" note traces back to a Swagger collectionFormat artifact, not actual daemon behavior. I'd drop the comma-join branch entirely and always send repeated names= params (note TestExportImagesNegotiatedVersionFirstCall currently locks in the broken wire format). Also, if the join does stay for some reason, strings.Join(opts.Names, ",") would replace the manual loop.

Comment thread client.go
return fmt.Sprintf("%s%s?%s", urlStr, basepath, queryStr), nil
// Return only a request path. The caller applies the endpoint and selected
// API version later through getURL.
return fmt.Sprintf("%s?%s", basepath, queryStr), nil

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For SkipServerVersionCheck clients — which is the default for NewClient/NewTLSClient — this now neither pins the URL to requiredAPIVersion nor validates anything: ensureAPIVersion is a no-op under skip, expectedAPIVersion stays nil, and the request goes out unversioned. Before, PullImage with Platform (ver:"1.32") against an old daemon was forced to /v1.32/... and failed loudly; now the old daemon silently ignores the unknown platform param and "successfully" pulls the wrong-architecture image. When skip is on and there's no expected version, I think the required version still needs to be either pinned into the URL (old behavior) or validated somehow, rather than dropped.

Comment thread client.go
}
// Serialize only the first /version request; version reads stay lock-free
// once the server version has been cached.
c.versionMu.Lock()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

versionMu is held across the /version HTTP round-trip, which runs on context.Background() (and defaultClient() sets no http.Client.Timeout). A daemon that accepts connections but stalls on /version hangs the first caller forever and parks every other do()/stream()/hijack() call — plus the unconditional probes in StartContainer/CreateExec/CopyFromContainer — on an uncancellable mutex. And since failures are never cached, a persistently failing /version turns N concurrent calls into N serialized probes. Consider doing the HTTP call outside the lock (e.g. singleflight-style, or accept a rare duplicate probe), threading the caller's context into the probe, and/or caching probe failure.

Comment thread client.go
expectedAPIVersion APIVersion
// serverAPIVersion and expectedAPIVersion are read while requests are built,
// so keep access lock-free after the first version probe completes.
serverAPIVersion atomicAPIVersion

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a sync.Mutex and atomic.Pointer-based fields makes the exported Client struct non-copyable: c2 := *client was vet-clean before and now trips copylocks (this PR itself had to change newTestClient from returning Client to *Client). Downstream code that copies Client by value will start failing vet-gated CI, and a runtime copy forks the version cache or carries a locked mutex. Maybe unavoidable for the thread-safety goal, but worth an explicit decision — and a mention in the changelog if we accept it.

Comment thread image.go
return err
}
return c.streamURL(http.MethodGet, exporturl, streamOptions{
return c.stream(http.MethodGet, exportpath, streamOptions{

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opts.Context never reaches the HTTP request: it's copied into the inner options struct (where it has no effect on the query string) but not set as context: in these streamOptions, so stream substitutes context.Background() and a cancelled/deadline context can't abort a hung export. Pre-existing omission, but since this function is being rewritten anyway, worth adding context: opts.Context here.

Comment thread client.go
if c.endpointURL.Scheme == unixProtocol || c.endpointURL.Scheme == namedPipeProtocol {
urlStr = ""
}
if expected := c.expectedAPIVersion.Load(); expected != nil {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effective-version precedence rule (expectedAPIVersion, else requestedAPIVersion) is now hand-copied in getURL, getFakeNativeURL, and ExportImages, while pathVersionCheck implements a fourth variant that checks requestedAPIVersion first. They agree today only because setExpectedVersion mirrors requestedAPIVersion verbatim — the moment negotiation gets smarter (e.g. clamping a requested version to the server's max, as moby does), all four sites must change in lockstep or validation and URL-building diverge. A single c.effectiveAPIVersion() accessor used everywhere would remove that risk.

Comment thread client.go
if path != "/version" && !c.SkipServerVersionCheck && c.expectedAPIVersion == nil {
err := c.checkAPIVersion()
if err != nil {
if path != "/version" {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This literal path != "/version" guard (repeated in do, stream, and hijack) is now the only thing preventing the probe's own do("/version", ...) from re-entering probeServerVersion while the non-reentrant versionMu is held — i.e. it's a deadlock guard, but nothing documents that. Any future change that makes the probe path not byte-equal to "/version" (switching to /_ping, adding a query param) silently self-deadlocks the first call on a fresh client. An explicit doOptions flag (e.g. skipVersionNegotiation) set by getServerAPIVersionString would encode the invariant where it belongs.

Comment thread exec.go Outdated
c.checkAPIVersion()
}
if len(opts.Env) > 0 && c.serverAPIVersion.LessThan(apiVersion125) {
c.probeServerVersion()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Best-effort probing is scattered as bare c.probeServerVersion() calls with ignored errors here and in container_copy.go, container_start.go, and image.go (versionedAuthConfigs), and each site has a different nil-version policy: exec treats nil as "too old, hard error", container_copy proceeds, container_start picks the new wire format. Also note probeServerVersion itself never checks SkipServerVersionCheck, so default NewClient users now pay a real /version round-trip on every one of these calls while the endpoint is failing (nothing is negatively cached), serialized under versionMu. A single helper expressing the best-effort contract — including the skip check and ideally failure caching — would fix both.

Comment thread image.go

// if the caller elected to skip checking the server's version, assume it's the latest
if c.SkipServerVersionCheck || c.expectedAPIVersion.GreaterThanOrEqualTo(apiVersion112) {
if c.SkipServerVersionCheck {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SkipServerVersionCheck branch and the >= 1.12 branch below have identical bodies (decode resp.Body into image); the original expressed this once behind a combined condition. Something like v := c.expectedAPIVersion.Load(); if c.SkipServerVersionCheck || (v != nil && v.GreaterThanOrEqualTo(apiVersion112)) { ...decode... } else { ...legacy re-request... } keeps the semantics without the duplication.

Comment thread client.go

// setExpectedVersion records the version used in request URLs. It may differ
// from the server version when the caller requested an explicit API version.
func (c *Client) setExpectedVersion(serverVersion APIVersion) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor structural point: setExpectedVersion re-checks the exact guard ensureAPIVersion just evaluated and is called from three separate points inside probeServerVersion, so a reader has to trace all three to learn which call actually stores. Simpler shape: probeServerVersion only caches serverAPIVersion, and ensureAPIVersion performs the single expected-version store after a successful probe. (Similarly, both success branches of pathVersionCheck end in the identical Sprintf — that can collapse to one shared return after the error checks.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

With the Latest Docker Engine v25.x the Version Selection Magic Based on the Call Option 'ver` Tags Results in Failing Calls

2 participants