From 4f14ab839c64350702b44de3afe568414a449f70 Mon Sep 17 00:00:00 2001 From: jarek Date: Mon, 9 Mar 2026 16:33:38 +0100 Subject: [PATCH 1/2] fix: restore post-connect fallback for multi-network stacks on API < 1.44 Docker Compose 5.1.0 broke multi-network stacks on Docker daemons older than API 1.44 (Docker < 23.0, e.g. Synology DSM 7.1/7.2) with error: "Container cannot be connected to network endpoints" Root cause: defaultNetworkSettings() was refactored to unconditionally include ALL networks in EndpointsConfig for ContainerCreate, without the API version guard that limited this to daemons >= 1.44. The post-connect NetworkConnect() fallback that handled old daemons was also removed. Fix: - In defaultNetworkSettings() (create.go): only add extra networks to EndpointsConfig when apiVersion >= 1.44 - In createMobyContainer() (convergence.go): restore the post-connect fallback that calls NetworkConnect() for each extra network when apiVersion < 1.44 Fixes #13628 Signed-off-by: jarek --- pkg/compose/api_versions.go | 10 ++++++++++ pkg/compose/convergence.go | 33 +++++++++++++++++++++++++++++++++ pkg/compose/create.go | 16 ++++++++++------ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/pkg/compose/api_versions.go b/pkg/compose/api_versions.go index bccb4ad277d..8bc60d78582 100644 --- a/pkg/compose/api_versions.go +++ b/pkg/compose/api_versions.go @@ -19,6 +19,16 @@ package compose // Docker Engine API version constants. // These versions correspond to specific Docker Engine releases and their features. const ( + // apiVersion144 represents Docker Engine API version 1.44 (Engine v23.0). + // + // New features in this version: + // - ContainerCreate API accepts multiple EndpointsConfig entries + // + // Before this version: + // - Only a single EndpointsConfig entry was accepted in ContainerCreate + // - Extra networks must be connected individually after container creation via NetworkConnect + apiVersion144 = "1.44" + // apiVersion148 represents Docker Engine API version 1.48 (Engine v28.0). // // New features in this version: diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index b480b6be9d8..be36d8bba0a 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -33,6 +33,7 @@ import ( "github.com/moby/moby/api/types/container" mmount "github.com/moby/moby/api/types/mount" "github.com/moby/moby/client" + "github.com/moby/moby/client/pkg/versions" specs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" @@ -745,6 +746,38 @@ func (s *composeService) createMobyContainer(ctx context.Context, project *types }, } + // Starting API version 1.44, the ContainerCreate API call takes multiple networks + // so we include all configurations there and can skip the one-by-one calls here. + // For older API versions (e.g. Docker 20.10/API 1.41, Synology DSM 7.1/7.2), + // extra networks must be connected individually after creation via NetworkConnect. + apiVersion, err := s.RuntimeVersion(ctx) + if err != nil { + return created, err + } + if versions.LessThan(apiVersion, apiVersion144) { + // The highest-priority network is the primary and is already included in the + // ContainerCreate API call via NetworkMode & NetworkingConfig. + // Any remaining networks are connected one-by-one here after creation (but before start). + serviceNetworks := service.NetworksByPriority() + for _, networkKey := range serviceNetworks { + mobyNetworkName := project.Networks[networkKey].Name + if string(cfgs.Host.NetworkMode) == mobyNetworkName { + // primary network already configured as part of ContainerCreate + continue + } + epSettings, err := createEndpointSettings(project, service, number, networkKey, cfgs.Links, opts.UseNetworkAliases) + if err != nil { + return created, err + } + if _, err := s.apiClient().NetworkConnect(ctx, mobyNetworkName, client.NetworkConnectOptions{ + Container: created.ID, + EndpointConfig: epSettings, + }); err != nil { + return created, err + } + } + } + return created, nil } diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 23cba9f5d19..6a018fef20e 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -563,13 +563,17 @@ func defaultNetworkSettings(project *types.Project, // so we can pass all the extra networks we want the container to be connected to // in the network configuration instead of connecting the container to each extra // network individually after creation. - for _, networkKey := range serviceNetworks { - epSettings, err := createEndpointSettings(project, service, serviceIndex, networkKey, links, useNetworkAliases) - if err != nil { - return "", nil, err + // For older API versions, extra networks are connected via NetworkConnect after + // container creation (see createMobyContainer in convergence.go). + if !versions.LessThan(version, apiVersion144) { + for _, networkKey := range serviceNetworks { + epSettings, err := createEndpointSettings(project, service, serviceIndex, networkKey, links, useNetworkAliases) + if err != nil { + return "", nil, err + } + mobyNetworkName := project.Networks[networkKey].Name + endpointsConfig[mobyNetworkName] = epSettings } - mobyNetworkName := project.Networks[networkKey].Name - endpointsConfig[mobyNetworkName] = epSettings } networkConfig := &network.NetworkingConfig{ From 7895a5a4a661a851d35711ff4ed825767c78057f Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Mon, 9 Mar 2026 17:44:22 +0100 Subject: [PATCH 2/2] fix: pass APIVersion through createConfigs, reorder inspect - Add APIVersion field to createConfigs so createMobyContainer can use the version already fetched by getCreateConfigs instead of calling RuntimeVersion a second time. - Move ContainerInspect after the NetworkConnect loop so the returned container.Summary includes all attached networks. - Add unit tests for the < 1.44 code path in both defaultNetworkSettings and createMobyContainer. Signed-off-by: Jarek Krochmalski --- pkg/compose/convergence.go | 33 ++++++------ pkg/compose/convergence_test.go | 95 +++++++++++++++++++++++++++++++++ pkg/compose/create.go | 18 ++++--- pkg/compose/create_test.go | 34 ++++++++++++ 4 files changed, 154 insertions(+), 26 deletions(-) diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index be36d8bba0a..80c6d3c0060 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -733,28 +733,12 @@ func (s *composeService) createMobyContainer(ctx context.Context, project *types Text: warning, }) } - res, err := s.apiClient().ContainerInspect(ctx, response.ID, client.ContainerInspectOptions{}) - if err != nil { - return created, err - } - created = container.Summary{ - ID: res.Container.ID, - Labels: res.Container.Config.Labels, - Names: []string{res.Container.Name}, - NetworkSettings: &container.NetworkSettingsSummary{ - Networks: res.Container.NetworkSettings.Networks, - }, - } // Starting API version 1.44, the ContainerCreate API call takes multiple networks // so we include all configurations there and can skip the one-by-one calls here. // For older API versions (e.g. Docker 20.10/API 1.41, Synology DSM 7.1/7.2), // extra networks must be connected individually after creation via NetworkConnect. - apiVersion, err := s.RuntimeVersion(ctx) - if err != nil { - return created, err - } - if versions.LessThan(apiVersion, apiVersion144) { + if versions.LessThan(cfgs.APIVersion, apiVersion144) { // The highest-priority network is the primary and is already included in the // ContainerCreate API call via NetworkMode & NetworkingConfig. // Any remaining networks are connected one-by-one here after creation (but before start). @@ -770,7 +754,7 @@ func (s *composeService) createMobyContainer(ctx context.Context, project *types return created, err } if _, err := s.apiClient().NetworkConnect(ctx, mobyNetworkName, client.NetworkConnectOptions{ - Container: created.ID, + Container: response.ID, EndpointConfig: epSettings, }); err != nil { return created, err @@ -778,6 +762,19 @@ func (s *composeService) createMobyContainer(ctx context.Context, project *types } } + res, err := s.apiClient().ContainerInspect(ctx, response.ID, client.ContainerInspectOptions{}) + if err != nil { + return created, err + } + created = container.Summary{ + ID: res.Container.ID, + Labels: res.Container.Config.Labels, + Names: []string{res.Container.Name}, + NetworkSettings: &container.NetworkSettingsSummary{ + Networks: res.Container.NetworkSettings.Networks, + }, + } + return created, nil } diff --git a/pkg/compose/convergence_test.go b/pkg/compose/convergence_test.go index 901f43ccea3..6124b00c057 100644 --- a/pkg/compose/convergence_test.go +++ b/pkg/compose/convergence_test.go @@ -498,3 +498,98 @@ func TestCreateMobyContainer(t *testing.T) { assert.DeepEqual(t, want, got, cmpopts.EquateComparable(netip.Addr{}), cmpopts.EquateEmpty()) assert.NilError(t, err) } + +func TestCreateMobyContainerLegacyAPI(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + apiClient := mocks.NewMockAPIClient(mockCtrl) + cli := mocks.NewMockCli(mockCtrl) + tested, err := NewComposeService(cli) + assert.NilError(t, err) + cli.EXPECT().Client().Return(apiClient).AnyTimes() + cli.EXPECT().ConfigFile().Return(&configfile.ConfigFile{}).AnyTimes() + apiClient.EXPECT().DaemonHost().Return("").AnyTimes() + apiClient.EXPECT().ImageInspect(anyCancellableContext(), gomock.Any()).Return(client.ImageInspectResult{}, nil).AnyTimes() + + // force `RuntimeVersion` to return a pre-1.44 API version + runtimeVersion = runtimeVersionCache{} + apiClient.EXPECT().ServerVersion(gomock.Any(), gomock.Any()).Return(client.ServerVersionResult{ + APIVersion: "1.43", + }, nil).AnyTimes() + + service := types.ServiceConfig{ + Name: "test", + Networks: map[string]*types.ServiceNetworkConfig{ + "a": { + Priority: 10, + }, + "b": { + Priority: 100, + }, + }, + } + project := types.Project{ + Name: "bork", + Services: types.Services{ + "test": service, + }, + Networks: types.Networks{ + "a": types.NetworkConfig{ + Name: "a-moby-name", + }, + "b": types.NetworkConfig{ + Name: "b-moby-name", + }, + }, + } + + var gotCreate client.ContainerCreateOptions + apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, opts client.ContainerCreateOptions) (client.ContainerCreateResult, error) { + gotCreate = opts + return client.ContainerCreateResult{ID: "an-id"}, nil + }) + + // For API < 1.44, the secondary network "a" should be connected via NetworkConnect. + // Using gomock.InOrder to verify NetworkConnect is called before ContainerInspect. + var gotConnect client.NetworkConnectOptions + connectCall := apiClient.EXPECT().NetworkConnect(gomock.Any(), gomock.Eq("a-moby-name"), gomock.Any()).DoAndReturn(func(_ context.Context, _ string, opts client.NetworkConnectOptions) (client.NetworkConnectResult, error) { + gotConnect = opts + return client.NetworkConnectResult{}, nil + }) + + apiClient.EXPECT().ContainerInspect(gomock.Any(), gomock.Eq("an-id"), gomock.Any()).Times(1).After(connectCall).Return(client.ContainerInspectResult{ + Container: container.InspectResponse{ + ID: "an-id", + Name: "a-name", + Config: &container.Config{}, + NetworkSettings: &container.NetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "b-moby-name": { + IPAMConfig: &network.EndpointIPAMConfig{}, + Aliases: []string{"bork-test-0"}, + }, + "a-moby-name": { + IPAMConfig: &network.EndpointIPAMConfig{}, + Aliases: []string{"bork-test-0"}, + }, + }, + }, + }, + }, nil) + + _, err = tested.(*composeService).createMobyContainer(t.Context(), &project, service, "test", 0, nil, createOptions{ + Labels: make(types.Labels), + UseNetworkAliases: true, + }) + assert.NilError(t, err) + + // ContainerCreate should only have the primary network (b, highest priority) in EndpointsConfig + assert.Check(t, gotCreate.NetworkingConfig != nil) + assert.Equal(t, len(gotCreate.NetworkingConfig.EndpointsConfig), 1) + _, hasPrimary := gotCreate.NetworkingConfig.EndpointsConfig["b-moby-name"] + assert.Check(t, hasPrimary, "primary network b-moby-name should be in ContainerCreate EndpointsConfig") + + // NetworkConnect should have been called for the secondary network "a" + assert.Equal(t, gotConnect.Container, "an-id") + assert.Check(t, gotConnect.EndpointConfig != nil) +} diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 6a018fef20e..8bf33de3612 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -53,10 +53,11 @@ type createOptions struct { } type createConfigs struct { - Container *container.Config - Host *container.HostConfig - Network *network.NetworkingConfig - Links []string + Container *container.Config + Host *container.HostConfig + Network *network.NetworkingConfig + Links []string + APIVersion string } func (s *composeService) Create(ctx context.Context, project *types.Project, createOpts api.CreateOptions) error { @@ -332,10 +333,11 @@ func (s *composeService) getCreateConfigs(ctx context.Context, } cfgs := createConfigs{ - Container: &containerConfig, - Host: &hostConfig, - Network: networkingConfig, - Links: links, + Container: &containerConfig, + Host: &hostConfig, + Network: networkingConfig, + Links: links, + APIVersion: apiVersion, } return cfgs, nil } diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go index 2ac5c392bec..bd86f4785a1 100644 --- a/pkg/compose/create_test.go +++ b/pkg/compose/create_test.go @@ -273,6 +273,40 @@ func TestDefaultNetworkSettings(t *testing.T) { assert.Check(t, cmp.Nil(networkConfig)) }) + t.Run("returns only primary network in EndpointsConfig for API < 1.44", func(t *testing.T) { + service := composetypes.ServiceConfig{ + Name: "myService", + Networks: map[string]*composetypes.ServiceNetworkConfig{ + "myNetwork1": { + Priority: 10, + }, + "myNetwork2": { + Priority: 1000, + }, + }, + } + project := composetypes.Project{ + Name: "myProject", + Services: composetypes.Services{ + "myService": service, + }, + Networks: composetypes.Networks(map[string]composetypes.NetworkConfig{ + "myNetwork1": { + Name: "myProject_myNetwork1", + }, + "myNetwork2": { + Name: "myProject_myNetwork2", + }, + }), + } + + networkMode, networkConfig, err := defaultNetworkSettings(&project, service, 1, nil, true, "1.43") + assert.NilError(t, err) + assert.Equal(t, string(networkMode), "myProject_myNetwork2") + assert.Check(t, cmp.Len(networkConfig.EndpointsConfig, 1)) + assert.Check(t, cmp.Contains(networkConfig.EndpointsConfig, "myProject_myNetwork2")) + }) + t.Run("returns defined network mode if explicitly set", func(t *testing.T) { service := composetypes.ServiceConfig{ Name: "myService",