Skip to content

Commit 9cab439

Browse files
gloursclaude
authored andcommitted
refactor: merge RuntimeVersion and CurrentAPIVersion into RuntimeAPIVersion
After API negotiation, Compose should only rely on the negotiated version and never use the daemon's raw max version for request shaping. Merge both functions into a single RuntimeAPIVersion that negotiates via Ping and returns ClientVersion, erroring if the client reports an empty version instead of silently falling back to ServerVersion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
1 parent ef83685 commit 9cab439

4 files changed

Lines changed: 30 additions & 81 deletions

File tree

pkg/compose/compose.go

Lines changed: 16 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,7 @@ type composeService struct {
216216
maxConcurrency int
217217
dryRun bool
218218

219-
runtimeVersion runtimeVersionCache
220-
currentAPIVersion runtimeVersionCache
219+
runtimeAPIVersion runtimeVersionCache
221220
}
222221

223222
// Close releases any connections/resources held by the underlying clients.
@@ -502,34 +501,18 @@ type runtimeVersionCache struct {
502501
val string
503502
}
504503

505-
// RuntimeVersion returns the raw API version reported by the daemon.
506-
// Callers that need the negotiated/effective client API version should use
507-
// CurrentAPIVersion instead.
508-
func (s *composeService) RuntimeVersion(ctx context.Context) (string, error) {
509-
s.runtimeVersion.mu.Lock()
510-
defer s.runtimeVersion.mu.Unlock()
511-
if s.runtimeVersion.val != "" {
512-
return s.runtimeVersion.val, nil
513-
}
514-
version, err := s.apiClient().ServerVersion(ctx, client.ServerVersionOptions{})
515-
if err != nil {
516-
return "", err
517-
}
518-
s.runtimeVersion.val = version.APIVersion
519-
return s.runtimeVersion.val, nil
520-
}
521-
522-
// CurrentAPIVersion returns the API version currently used by the Docker client.
523-
// Trigger negotiation first so version-gated request shaping matches the version
524-
// that subsequent API calls will actually use.
504+
// RuntimeAPIVersion returns the negotiated API version that will be used for
505+
// requests to the Docker daemon. It triggers version negotiation via Ping so
506+
// that version-gated request shaping matches the version subsequent API calls
507+
// will actually use.
525508
//
526-
// Lock ordering: currentAPIVersion.mu must be acquired before runtimeVersion.mu
527-
// (via the RuntimeVersion fallback). No code path should reverse this order.
528-
func (s *composeService) CurrentAPIVersion(ctx context.Context) (string, error) {
529-
s.currentAPIVersion.mu.Lock()
530-
defer s.currentAPIVersion.mu.Unlock()
531-
if s.currentAPIVersion.val != "" {
532-
return s.currentAPIVersion.val, nil
509+
// After negotiation, Compose should never rely on features or request attributes
510+
// not defined by this API version, even if the daemon's raw version is higher.
511+
func (s *composeService) RuntimeAPIVersion(ctx context.Context) (string, error) {
512+
s.runtimeAPIVersion.mu.Lock()
513+
defer s.runtimeAPIVersion.mu.Unlock()
514+
if s.runtimeAPIVersion.val != "" {
515+
return s.runtimeAPIVersion.val, nil
533516
}
534517

535518
cli := s.apiClient()
@@ -539,17 +522,10 @@ func (s *composeService) CurrentAPIVersion(ctx context.Context) (string, error)
539522
}
540523

541524
version := cli.ClientVersion()
542-
if version != "" {
543-
s.currentAPIVersion.val = version
544-
return s.currentAPIVersion.val, nil
525+
if version == "" {
526+
return "", fmt.Errorf("docker client returned empty version after successful API negotiation")
545527
}
546528

547-
// Defensive fallback for unexpected client implementations or mocks that
548-
// do not populate ClientVersion after a successful negotiated ping.
549-
val, err := s.RuntimeVersion(ctx)
550-
if err != nil {
551-
return "", err
552-
}
553-
s.currentAPIVersion.val = val
554-
return s.currentAPIVersion.val, nil
529+
s.runtimeAPIVersion.val = version
530+
return s.runtimeAPIVersion.val, nil
555531
}

pkg/compose/convergence_test.go

Lines changed: 11 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ func TestCreateMobyContainer(t *testing.T) {
498498
assert.NilError(t, err)
499499
}
500500

501-
func TestCurrentAPIVersionCachesNegotiation(t *testing.T) {
501+
func TestRuntimeAPIVersionCachesNegotiation(t *testing.T) {
502502
mockCtrl := gomock.NewController(t)
503503
defer mockCtrl.Finish()
504504

@@ -508,52 +508,25 @@ func TestCurrentAPIVersionCachesNegotiation(t *testing.T) {
508508

509509
cli.EXPECT().Client().Return(apiClient).AnyTimes()
510510

511+
// Ping reports the server's max API version (1.44), but after negotiation
512+
// the client may settle on a lower version (1.43) — e.g. when the client
513+
// SDK caps at an older version. RuntimeAPIVersion must return the negotiated
514+
// ClientVersion, not the server's raw APIVersion.
511515
apiClient.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}).Return(client.PingResult{
512516
APIVersion: "1.44",
513517
}, nil).Times(1)
514518
apiClient.EXPECT().ClientVersion().Return("1.43").Times(1)
515519

516-
version, err := tested.CurrentAPIVersion(t.Context())
520+
version, err := tested.RuntimeAPIVersion(t.Context())
517521
assert.NilError(t, err)
518522
assert.Equal(t, version, "1.43")
519523

520-
version, err = tested.CurrentAPIVersion(t.Context())
524+
version, err = tested.RuntimeAPIVersion(t.Context())
521525
assert.NilError(t, err)
522526
assert.Equal(t, version, "1.43")
523527
}
524528

525-
func TestRuntimeVersionRetriesOnTransientError(t *testing.T) {
526-
mockCtrl := gomock.NewController(t)
527-
defer mockCtrl.Finish()
528-
529-
apiClient := mocks.NewMockAPIClient(mockCtrl)
530-
cli := mocks.NewMockCli(mockCtrl)
531-
tested := &composeService{dockerCli: cli}
532-
533-
cli.EXPECT().Client().Return(apiClient).AnyTimes()
534-
535-
// First call: ServerVersion fails with a transient error
536-
firstCall := apiClient.EXPECT().ServerVersion(gomock.Any(), gomock.Any()).
537-
Return(client.ServerVersionResult{}, context.DeadlineExceeded).Times(1)
538-
539-
// Second call: succeeds
540-
apiClient.EXPECT().ServerVersion(gomock.Any(), gomock.Any()).
541-
Return(client.ServerVersionResult{APIVersion: "1.48"}, nil).Times(1).After(firstCall)
542-
543-
_, err := tested.RuntimeVersion(t.Context())
544-
assert.ErrorIs(t, err, context.DeadlineExceeded)
545-
546-
version, err := tested.RuntimeVersion(t.Context())
547-
assert.NilError(t, err)
548-
assert.Equal(t, version, "1.48")
549-
550-
// Third call returns cached value
551-
version, err = tested.RuntimeVersion(t.Context())
552-
assert.NilError(t, err)
553-
assert.Equal(t, version, "1.48")
554-
}
555-
556-
func TestCurrentAPIVersionRetriesOnTransientError(t *testing.T) {
529+
func TestRuntimeAPIVersionRetriesOnTransientError(t *testing.T) {
557530
mockCtrl := gomock.NewController(t)
558531
defer mockCtrl.Finish()
559532

@@ -573,16 +546,16 @@ func TestCurrentAPIVersionRetriesOnTransientError(t *testing.T) {
573546
apiClient.EXPECT().ClientVersion().Return("1.44").Times(1)
574547

575548
// First call should return the transient error
576-
_, err := tested.CurrentAPIVersion(t.Context())
549+
_, err := tested.RuntimeAPIVersion(t.Context())
577550
assert.ErrorIs(t, err, context.DeadlineExceeded)
578551

579552
// Second call should succeed — error was not cached
580-
version, err := tested.CurrentAPIVersion(t.Context())
553+
version, err := tested.RuntimeAPIVersion(t.Context())
581554
assert.NilError(t, err)
582555
assert.Equal(t, version, "1.44")
583556

584557
// Third call should return the cached value without calling Ping again
585-
version, err = tested.CurrentAPIVersion(t.Context())
558+
version, err = tested.RuntimeAPIVersion(t.Context())
586559
assert.NilError(t, err)
587560
assert.Equal(t, version, "1.44")
588561
}

pkg/compose/create.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ func (s *composeService) getCreateConfigs(ctx context.Context,
252252
if err != nil {
253253
return createConfigs{}, err
254254
}
255-
apiVersion, err := s.CurrentAPIVersion(ctx)
255+
apiVersion, err := s.RuntimeAPIVersion(ctx)
256256
if err != nil {
257257
return createConfigs{}, err
258258
}
@@ -899,7 +899,7 @@ func (s *composeService) buildContainerVolumes(
899899
case mount.TypeImage:
900900
// The daemon validates image mounts against the negotiated API version
901901
// from the request path, not the server's own max version.
902-
version, err := s.CurrentAPIVersion(ctx)
902+
version, err := s.RuntimeAPIVersion(ctx)
903903
if err != nil {
904904
return nil, nil, err
905905
}

pkg/compose/images.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ func (s *composeService) Images(ctx context.Context, projectName string, options
5858

5959
// The daemon validates the platform field in ImageInspect against the
6060
// negotiated API version from the request path, not the server's own max version.
61-
version, err := s.CurrentAPIVersion(ctx)
61+
version, err := s.RuntimeAPIVersion(ctx)
6262
if err != nil {
6363
return nil, err
6464
}

0 commit comments

Comments
 (0)