Skip to content

fix: add Docker API version negotiation to prevent client/daemon mismatch#761

Merged
AlexKantor87 merged 3 commits intomainfrom
fix/docker-api-version-negotiation
Apr 2, 2026
Merged

fix: add Docker API version negotiation to prevent client/daemon mismatch#761
AlexKantor87 merged 3 commits intomainfrom
fix/docker-api-version-negotiation

Conversation

@AlexKantor87
Copy link
Copy Markdown
Contributor

Summary

  • Add client.WithAPIVersionNegotiation() to all 8 Docker client creation sites across 3 files
  • In internal/docker/docker.go, extract a newDockerClient() helper to centralise the option so it cannot be accidentally omitted in future call sites
  • Add regression test TestNewDockerClientNegotiatesAPIVersion that verifies the client can Ping the daemon and the negotiated API version does not exceed the server maximum

Root cause

The Dependabot bump of github.com/docker/docker from v28.0.4 → v28.3.2 changed the SDK's default API version from 1.48 to 1.51. GitHub Actions ubuntu-24.04 runners ship Docker Engine 28.0.4 (max API 1.48), so every kosli attest with --artifact-type docker fails with:

Error response from daemon: client version 1.51 is too new. Maximum supported API version is 1.48

This breaks the server master pipeline — all attestation steps fail, the trail is non-compliant, and deployments are blocked.

Impact

Blocked: kosli-dev/server master branch (run #23892447496) — 5 attestations MISSING (snyk-scan, lint-src, lint-tests, validate-openapi, system-test). Both v2.14.0 and v2.15.0 are affected.

Fix

WithAPIVersionNegotiation() makes the Docker client auto-downgrade its API version to match whatever the daemon supports, as recommended by Docker's documentation. This is a one-line change per call site, and makes the CLI resilient against any future SDK-to-Engine version gap.

Files changed

File Change
internal/docker/docker.go Extract newDockerClient() helper with negotiation; replace 6 inline NewClientWithOpts calls
internal/digest/digest.go Add WithAPIVersionNegotiation() to DockerImageSha256()
cmd/kosli/snapshotDocker.go Add WithAPIVersionNegotiation() to CreateDockerArtifactsData()
internal/docker/docker_test.go Add TestNewDockerClientNegotiatesAPIVersion regression test

Test plan

  • Existing Docker integration tests pass (pull, push, tag, remove, run container)
  • New TestNewDockerClientNegotiatesAPIVersion passes — verifies Ping succeeds and negotiated version ≤ server max
  • After release, update kosli-dev/server to use the new CLI version and confirm the pipeline goes green

🤖 Generated with Claude Code

The Docker SDK bump from v28.0.4 to v28.3.2 changed the default API
version from 1.48 to 1.51. This breaks any environment where the
Docker daemon is older than 28.3 (e.g. GitHub Actions ubuntu-24.04
runners which ship Docker Engine 28.0.4, max API 1.48).

Adding client.WithAPIVersionNegotiation() makes the client auto-downgrade
to match whatever API version the daemon supports, as recommended by
Docker's own documentation.

In internal/docker/docker.go, a newDockerClient() helper centralises
client creation so the negotiation option cannot be accidentally omitted
in future call sites.

Includes a regression test that verifies the client can Ping the daemon
and that the negotiated API version does not exceed the server maximum.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 2, 2026

Claude finished @AlexKantor87's task in 2m 4s —— View job


PR Review: Docker API version negotiation

  • Phase 1: Static analysis (go build, go vet, go mod tidy)
  • Phase 2: Read and review changed files
  • Phase 3: Post inline comments and summary

Static Analysis

All checks pass: go build ./..., go vet ./..., and go mod tidy produce no issues.

Summary

The fix is correct and well-motivated. WithAPIVersionNegotiation() is Docker's recommended approach and solves the SDK-to-Engine version gap cleanly. Removing the DOCKER_API_VERSION pin from CI is the right follow-up.

Issues

1. Centralisation is incomplete (main concern)

The newDockerClient() helper in internal/docker/docker.go centralises the negotiation option for the 6 call sites in that file — good. However, 2 of the 8 total call sites (snapshotDocker.go:91 and digest.go:254) still construct the client inline with client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()).

This means a future contributor could add a new call site in digest.go or cmd/kosli/ without the negotiation option and re-introduce the bug. To fully deliver on the stated centralisation goal, consider exporting the helper as docker.NewDockerClient() and calling it from all sites. See inline comment.

2. Unrelated whitespace change (digest.go:326)

A trailing-space removal on a comment line. Harmless but adds noise to the diff. See inline comment.

3. No regression test

The PR description mentions TestNewDockerClientNegotiatesAPIVersion but it was dropped in commit 09981bd. The existing test suite in docker_test.go exercises the functions that now go through newDockerClient(), so integration coverage is preserved indirectly. A dedicated unit test verifying that newDockerClient() returns a client with negotiation enabled would be nice but isn't strictly necessary — the existing tests will fail if negotiation breaks.

What's good

  • Clean extraction of the newDockerClient() helper with a clear doc comment explaining why negotiation is needed
  • Removing the CI DOCKER_API_VERSION pin — this was a workaround masking the real problem
  • Minimal, focused changes to production code (one-line addition per call site)

// remote docker registry
func RemoteDockerImageSha256(imageName, imageTag, registryEndPoint, registryToken string, logger *logger.Logger) (string, error) {
// Some docker images have Manifest list, aka fat manifest which combines
// Some docker images have Manifest list, aka "fat manifest" which combines
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this line only has a trailing-whitespace change (removed a trailing space from the comment). Not harmful, but it's unrelated to the fix — consider dropping it to keep the diff focused on the version-negotiation change.

func CreateDockerArtifactsData() ([]*server.ServerData, error) {
result := []*server.ServerData{}
cli, err := client.NewClientWithOpts(client.FromEnv)
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This call site and DockerImageSha256 in internal/digest/digest.go still use client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) directly instead of going through the new newDockerClient() helper.

The PR description says the helper was introduced to "centralise the option so it cannot be accidentally omitted in future call sites," but these two call sites don't use it. Consider one of:

  1. Make newDockerClient() exported (NewDockerClient()) and call it from here and digest.go.
  2. Or accept the duplication and document the pattern in a comment.

Option 1 would fully deliver on the stated goal of centralisation.

Suggested change
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
cli, err := docker.NewDockerClient()

// After negotiation the client version must not exceed the server's max.
suite.Assert().NotEmpty(ping.APIVersion,
"server should report its API version in the ping response")
suite.Assert().LessOrEqual(cli.ClientVersion(), ping.APIVersion,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: LessOrEqual does a lexicographic comparison on strings like "1.48" vs "1.51". This works for the current version scheme (single-digit major, two-digit minor) but would break if versions ever reach "1.100" (lexicographically "1.100" < "1.48").

Not urgent — Docker API versions have been incrementing slowly — but worth a comment or a numeric comparison for robustness.

require.NoError(suite.T(), err)
require.NotEmpty(suite.T(), id)
err = RemoveDockerContainer(id)
func (suite *DockerTestSuite) TestRunAndRemoveDockerContainer() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The old test also verified the error case (RunDockerContainer("not-existing-image")), which has been dropped. Consider adding it back as a table-driven subtest to maintain coverage of the error path.

Restores the original docker_test.go and only adds the new
TestNewDockerClientNegotiatesAPIVersion regression test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@AlexKantor87 AlexKantor87 enabled auto-merge (squash) April 2, 2026 09:34
func CreateDockerArtifactsData() ([]*server.ServerData, error) {
result := []*server.ServerData{}
cli, err := client.NewClientWithOpts(client.FromEnv)
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This call site and DockerImageSha256 in internal/digest/digest.go still construct the client inline rather than going through the newDockerClient() helper introduced in internal/docker/docker.go.

The PR description says the helper was introduced to "centralise the option so it cannot be accidentally omitted in future call sites," but two out of eight call sites don't use it. Consider exporting the helper as docker.NewDockerClient() and calling it from here and digest.go to fully deliver on that centralisation goal.

Suggested change
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
cli, err := docker.NewDockerClient()

// After negotiation the client version must not exceed the server's max.
suite.Assert().NotEmpty(ping.APIVersion,
"server should report its API version in the ping response")
suite.Assert().LessOrEqual(cli.ClientVersion(), ping.APIVersion,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: LessOrEqual on strings does a lexicographic comparison. This works for current Docker API versions (e.g. "1.48" vs "1.51") but would give a wrong result if versions ever reach "1.100" ("1.100" < "1.48" lexicographically). Not urgent since Docker API versions increment slowly, but worth a brief comment noting the assumption.

// remote docker registry
func RemoteDockerImageSha256(imageName, imageTag, registryEndPoint, registryToken string, logger *logger.Logger) (string, error) {
// Some docker images have Manifest list, aka fat manifest which combines
// Some docker images have Manifest list, aka "fat manifest" which combines
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this line is a whitespace-only change (trailing space removed from the comment). Not harmful, but unrelated to the fix — consider dropping it to keep the diff focused.

The DOCKER_API_VERSION env var was masking version mismatch issues in CI
by forcing all Docker clients to use API 1.45 regardless of the SDK
default. Removing it means the existing Docker integration tests will
naturally fail if any call site is missing WithAPIVersionNegotiation(),
making the custom negotiation test redundant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
func CreateDockerArtifactsData() ([]*server.ServerData, error) {
result := []*server.ServerData{}
cli, err := client.NewClientWithOpts(client.FromEnv)
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This call site still constructs the Docker client inline rather than going through the newDockerClient() helper introduced in internal/docker/docker.go.

The PR description says the helper was introduced to "centralise the option so it cannot be accidentally omitted in future call sites," but this site (and DockerImageSha256 in digest.go) don't use it. Consider exporting the helper as docker.NewDockerClient() and calling it from both locations:

Suggested change
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
cli, err := docker.NewDockerClient()

This would require adding the import and renaming/exporting the helper, but it fully delivers on the centralisation goal and prevents future drift.

// remote docker registry
func RemoteDockerImageSha256(imageName, imageTag, registryEndPoint, registryToken string, logger *logger.Logger) (string, error) {
// Some docker images have Manifest list, aka fat manifest which combines
// Some docker images have Manifest list, aka "fat manifest" which combines
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this line is a whitespace-only change (trailing space removed from the comment). Not harmful, but unrelated to the fix — consider dropping it to keep the diff focused.

@AlexKantor87 AlexKantor87 merged commit a0c7dd6 into main Apr 2, 2026
20 checks passed
@AlexKantor87 AlexKantor87 deleted the fix/docker-api-version-negotiation branch April 2, 2026 09:57
github-actions bot pushed a commit that referenced this pull request Apr 2, 2026
…761)

The Docker SDK bump (v28.0.4 → v28.3.2) changed the default API version
from 1.48 to 1.51, breaking Docker operations on hosts with older daemons.

- Add WithAPIVersionNegotiation() to all 8 Docker client creation sites
- Remove DOCKER_API_VERSION pin from test.yml so CI naturally catches
  missing negotiation in future

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

3 participants