From c772d43884e0db222b81d7aeac21868e9782a49b Mon Sep 17 00:00:00 2001 From: Mahesh Thakur Date: Mon, 19 Jan 2026 21:47:02 +0530 Subject: [PATCH 1/6] emit container status events after network reconnection (fixes #13524) Signed-off-by: Mahesh Thakur --- pkg/compose/create.go | 17 +++++++++ pkg/compose/create_test.go | 72 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/pkg/compose/create.go b/pkg/compose/create.go index e4293cefbd..30971bf75d 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -1420,6 +1420,23 @@ func (s *composeService) resolveOrCreateNetwork(ctx context.Context, project *ty return "", err } + // Emit container status events for containers that may have been restarted by the engine + for _, c := range dangledContainers { + inspected, err := s.apiClient().ContainerInspect(ctx, c.ID) + if err != nil { + // ignore inspect errors + continue + } + name := "Container " + strings.TrimPrefix(inspected.Name, "/") + if inspected.State != nil && inspected.State.Running { + s.events.On(runningEvent(name)) + } else if inspected.State != nil && inspected.State.Status == container.StateCreated { + s.events.On(createdEvent(name)) + } else if inspected.State != nil && inspected.State.Status == container.StateExited { + s.events.On(stoppedEvent(name)) + } + } + return resp.ID, nil } diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go index a3cc540692..423cf5ffc2 100644 --- a/pkg/compose/create_test.go +++ b/pkg/compose/create_test.go @@ -455,3 +455,75 @@ volumes: }) } } + +func TestResolveOrCreateNetworkEmitsRunningEvent(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockApi, cli := prepareMocks(mockCtrl) + s := composeService{dockerCli: cli} + + project := composetypes.Project{ + Name: "testproject", + Networks: composetypes.Networks(map[string]composetypes.NetworkConfig{ + "default": {Name: "testproject_default"}, + }), + Services: composetypes.Services{ + "svc": {Name: "svc", Networks: map[string]*composetypes.ServiceNetworkConfig{"default": {}}}, + }, + } + nVal := project.Networks["default"] + n := &nVal + + // network exists but has a different config hash -> removeDivergedNetwork should be triggered + netInspect := network.Inspect{ + ID: "netid", + Name: n.Name, + Labels: map[string]string{api.NetworkLabel: "default", api.ProjectLabel: project.Name, api.ConfigHashLabel: "oldhash"}, + } + mockApi.EXPECT().NetworkInspect(gomock.Any(), n.Name, gomock.Any()).Return(netInspect, nil) + + // stop will be called (ContainerStop during removeDivergedNetwork) + mockApi.EXPECT().ContainerStop(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + // getContainers should return the container that was stopped + cs := container.Summary{ID: "ctrid", Names: []string{"/testproject_svc_1"}, Labels: map[string]string{api.ServiceLabel: "svc"}} + mockApi.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(Containers{cs}, nil).AnyTimes() + + mockApi.EXPECT().NetworkDisconnect(gomock.Any(), n.Name, "ctrid", true).Return(nil) + mockApi.EXPECT().NetworkRemove(gomock.Any(), n.Name).Return(nil) + + mockApi.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return([]network.Summary{}, nil) + mockApi.EXPECT().NetworkCreate(gomock.Any(), n.Name, gomock.Any()).Return(network.CreateResponse{ID: "newnet"}, nil) + + mockApi.EXPECT().NetworkConnect(gomock.Any(), n.Name, "ctrid", gomock.Any()).Return(nil) + + // After reconnect, engine restarted container -> ContainerInspect returns running + runningInspect := container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{Name: "/testproject_svc_1", State: &container.State{Running: true, Status: container.StateRunning}}} + mockApi.EXPECT().ContainerInspect(gomock.Any(), "ctrid").Return(runningInspect, nil) + + // set event recorder + rec := &testEventProcessor{} + s.events = rec + + id, err := s.resolveOrCreateNetwork(context.Background(), &project, "default", n) + assert.NilError(t, err) + assert.Equal(t, id, "newnet") + + found := false + for _, ev := range rec.events { + if ev.ID == "Container testproject_svc_1" && ev.Text == api.StatusRunning { + found = true + break + } + } + assert.Assert(t, found) +} + +// testEventProcessor collects events for assertions +type testEventProcessor struct { + events []api.Resource +} + +func (t *testEventProcessor) Start(ctx context.Context, operation string) {} +func (t *testEventProcessor) Done(operation string, success bool) {} +func (t *testEventProcessor) On(events ...api.Resource) { t.events = append(t.events, events...) } From d92364b9408ce6c9d102f92be23548a7d8b76cf4 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 19 Jan 2026 16:28:34 +0100 Subject: [PATCH 2/6] Bump compose to v2.10.1 Signed-off-by: Nicolas De Loof Signed-off-by: Mahesh Thakur --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f1f341f415..90517e7024 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/buger/goterm v1.0.4 - github.com/compose-spec/compose-go/v2 v2.10.0 + github.com/compose-spec/compose-go/v2 v2.10.1 github.com/containerd/console v1.0.5 github.com/containerd/containerd/v2 v2.2.1 github.com/containerd/errdefs v1.0.0 diff --git a/go.sum b/go.sum index ff01a5ce48..45e35c62d0 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/compose-spec/compose-go/v2 v2.10.0 h1:K2C5LQ3KXvkYpy5N/SG6kIYB90iiAirA9btoTh/gB0Y= -github.com/compose-spec/compose-go/v2 v2.10.0/go.mod h1:Ohac1SzhO/4fXXrzWIztIVB6ckmKBv1Nt5Z5mGVESUg= +github.com/compose-spec/compose-go/v2 v2.10.1 h1:mFbXobojGRFIVi1UknrvaDAZ+PkJfyjqkA1yseh+vAU= +github.com/compose-spec/compose-go/v2 v2.10.1/go.mod h1:Ohac1SzhO/4fXXrzWIztIVB6ckmKBv1Nt5Z5mGVESUg= github.com/containerd/cgroups/v3 v3.1.2 h1:OSosXMtkhI6Qove637tg1XgK4q+DhR0mX8Wi8EhrHa4= github.com/containerd/cgroups/v3 v3.1.2/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= From c8a17ac30f44021a7bb22e8e9885f32baa6f7dcc Mon Sep 17 00:00:00 2001 From: Mahesh Thakur Date: Mon, 19 Jan 2026 22:59:27 +0530 Subject: [PATCH 3/6] use canonical container name and status for post-reconnect events Signed-off-by: Mahesh Thakur --- pkg/compose/create.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 30971bf75d..f619440096 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -1427,8 +1427,10 @@ func (s *composeService) resolveOrCreateNetwork(ctx context.Context, project *ty // ignore inspect errors continue } - name := "Container " + strings.TrimPrefix(inspected.Name, "/") - if inspected.State != nil && inspected.State.Running { + // Build a temporary summary so we can compute the canonical container name + tmp := container.Summary{ID: inspected.ID, Names: []string{inspected.Name}} + name := getContainerProgressName(tmp) + if inspected.State != nil && inspected.State.Status == container.StateRunning { s.events.On(runningEvent(name)) } else if inspected.State != nil && inspected.State.Status == container.StateCreated { s.events.On(createdEvent(name)) From fb0973020040c9be51baef5e338a3469ed475887 Mon Sep 17 00:00:00 2001 From: Mahesh Thakur Date: Mon, 19 Jan 2026 23:07:15 +0530 Subject: [PATCH 4/6] e2e: verify network IPAM change reports container started after recreate Signed-off-by: Mahesh Thakur --- pkg/e2e/networks_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pkg/e2e/networks_test.go b/pkg/e2e/networks_test.go index c9b882b911..adda4c1d82 100644 --- a/pkg/e2e/networks_test.go +++ b/pkg/e2e/networks_test.go @@ -220,3 +220,24 @@ func TestNetworkRecreate(t *testing.T) { Container network_recreate-web-1 Starting Container network_recreate-web-1 Started`}) } + +func TestNetworkIPAMChangeReportsStarted(t *testing.T) { + c := NewCLI(t) + const projectName = "ipam-change-e2e" + defer c.cleanupWithDown(t, projectName) + + // Start with default subnet + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/network-test/compose.subnet.yaml", "--project-name", projectName, "up", "-d") + res.Assert(t, icmd.Success) + + // Change SUBNET and run up again; progress in plain should print container events + cmd := c.NewCmdWithEnv([]string{"SUBNET=192.168.0.0/16"}, "docker", "compose", "-f", "./fixtures/network-test/compose.subnet.yaml", "--project-name", projectName, "--progress=plain", "up", "-d") + res = icmd.RunCmd(cmd) + res.Assert(t, icmd.Success) + + out := res.Combined() + // We expect to see the container transition reported: Stopped then Started (or Recreated) + if !strings.Contains(out, "Stopped") || !(strings.Contains(out, "Started") || strings.Contains(out, "Recreated")) { + t.Fatalf("expected Stopped then Started/Recreated in output, got:\n%s", out) + } +} From 4da1dc29e153d374153b93bd98602a790ce90c76 Mon Sep 17 00:00:00 2001 From: Mahesh Thakur Date: Tue, 20 Jan 2026 18:35:45 +0530 Subject: [PATCH 5/6] Fix: correctly display container status events in detached mode - Revert changes to pkg/compose/create.go - Update UI layer (tty.go, compose.go) to receive detached flag - Allow StatusStarted and StatusStarting events when running in detached mode Signed-off-by: Mahesh Thakur --- cmd/compose/compose.go | 5 +- cmd/display/tty.go | 16 +- pkg/compose/create.go | 19 -- pkg/compose/create_test.go | 529 ------------------------------------- 4 files changed, 12 insertions(+), 557 deletions(-) delete mode 100644 pkg/compose/create_test.go diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 300164ca52..238acdd602 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -505,6 +505,7 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C display.Mode = display.ModeTTY } + detached, _ := cmd.Flags().GetBool("detach") var ep api.EventProcessor switch opts.Progress { case "", display.ModeAuto: @@ -513,7 +514,7 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C display.Mode = display.ModePlain ep = display.Plain(dockerCli.Err()) case dockerCli.Out().IsTerminal(): - ep = display.Full(dockerCli.Err(), stdinfo(dockerCli)) + ep = display.Full(dockerCli.Err(), stdinfo(dockerCli), detached) default: ep = display.Plain(dockerCli.Err()) } @@ -522,7 +523,7 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C return fmt.Errorf("can't use --progress tty while ANSI support is disabled") } display.Mode = display.ModeTTY - ep = display.Full(dockerCli.Err(), stdinfo(dockerCli)) + ep = display.Full(dockerCli.Err(), stdinfo(dockerCli), detached) case display.ModePlain: if ansi == "always" { diff --git a/cmd/display/tty.go b/cmd/display/tty.go index dd45ffd70a..3a69ebaadc 100644 --- a/cmd/display/tty.go +++ b/cmd/display/tty.go @@ -37,13 +37,14 @@ import ( // Full creates an EventProcessor that render advanced UI within a terminal. // On Start, TUI lists task with a progress timer -func Full(out io.Writer, info io.Writer) api.EventProcessor { +func Full(out io.Writer, info io.Writer, detached bool) api.EventProcessor { return &ttyWriter{ - out: out, - info: info, - tasks: map[string]*task{}, - done: make(chan bool), - mtx: &sync.Mutex{}, + out: out, + info: info, + tasks: map[string]*task{}, + done: make(chan bool), + mtx: &sync.Mutex{}, + detached: detached, } } @@ -60,6 +61,7 @@ type ttyWriter struct { ticker *time.Ticker suspended bool info io.Writer + detached bool } type task struct { @@ -190,7 +192,7 @@ func (w *ttyWriter) On(events ...api.Resource) { continue } - if w.operation != "start" && (e.Text == api.StatusStarted || e.Text == api.StatusStarting) { + if w.operation != "start" && (e.Text == api.StatusStarted || e.Text == api.StatusStarting) && !w.detached { // skip those events to avoid mix with container logs continue } diff --git a/pkg/compose/create.go b/pkg/compose/create.go index f619440096..e4293cefbd 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -1420,25 +1420,6 @@ func (s *composeService) resolveOrCreateNetwork(ctx context.Context, project *ty return "", err } - // Emit container status events for containers that may have been restarted by the engine - for _, c := range dangledContainers { - inspected, err := s.apiClient().ContainerInspect(ctx, c.ID) - if err != nil { - // ignore inspect errors - continue - } - // Build a temporary summary so we can compute the canonical container name - tmp := container.Summary{ID: inspected.ID, Names: []string{inspected.Name}} - name := getContainerProgressName(tmp) - if inspected.State != nil && inspected.State.Status == container.StateRunning { - s.events.On(runningEvent(name)) - } else if inspected.State != nil && inspected.State.Status == container.StateCreated { - s.events.On(createdEvent(name)) - } else if inspected.State != nil && inspected.State.Status == container.StateExited { - s.events.On(stoppedEvent(name)) - } - } - return resp.ID, nil } diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go deleted file mode 100644 index 423cf5ffc2..0000000000 --- a/pkg/compose/create_test.go +++ /dev/null @@ -1,529 +0,0 @@ -/* - Copyright 2020 Docker Compose CLI authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -package compose - -import ( - "context" - "os" - "path/filepath" - "sort" - "testing" - - composeloader "github.com/compose-spec/compose-go/v2/loader" - composetypes "github.com/compose-spec/compose-go/v2/types" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/image" - mountTypes "github.com/docker/docker/api/types/mount" - "github.com/docker/docker/api/types/network" - "go.uber.org/mock/gomock" - "gotest.tools/v3/assert" - "gotest.tools/v3/assert/cmp" - - "github.com/docker/compose/v5/pkg/api" -) - -func TestBuildBindMount(t *testing.T) { - project := composetypes.Project{} - volume := composetypes.ServiceVolumeConfig{ - Type: composetypes.VolumeTypeBind, - Source: "", - Target: "/data", - } - mount, err := buildMount(project, volume) - assert.NilError(t, err) - assert.Assert(t, filepath.IsAbs(mount.Source)) - _, err = os.Stat(mount.Source) - assert.NilError(t, err) - assert.Equal(t, mount.Type, mountTypes.TypeBind) -} - -func TestBuildNamedPipeMount(t *testing.T) { - project := composetypes.Project{} - volume := composetypes.ServiceVolumeConfig{ - Type: composetypes.VolumeTypeNamedPipe, - Source: "\\\\.\\pipe\\docker_engine_windows", - Target: "\\\\.\\pipe\\docker_engine", - } - mount, err := buildMount(project, volume) - assert.NilError(t, err) - assert.Equal(t, mount.Type, mountTypes.TypeNamedPipe) -} - -func TestBuildVolumeMount(t *testing.T) { - project := composetypes.Project{ - Name: "myProject", - Volumes: composetypes.Volumes(map[string]composetypes.VolumeConfig{ - "myVolume": { - Name: "myProject_myVolume", - }, - }), - } - volume := composetypes.ServiceVolumeConfig{ - Type: composetypes.VolumeTypeVolume, - Source: "myVolume", - Target: "/data", - } - mount, err := buildMount(project, volume) - assert.NilError(t, err) - assert.Equal(t, mount.Source, "myProject_myVolume") - assert.Equal(t, mount.Type, mountTypes.TypeVolume) -} - -func TestServiceImageName(t *testing.T) { - assert.Equal(t, api.GetImageNameOrDefault(composetypes.ServiceConfig{Image: "myImage"}, "myProject"), "myImage") - assert.Equal(t, api.GetImageNameOrDefault(composetypes.ServiceConfig{Name: "aService"}, "myProject"), "myProject-aService") -} - -func TestPrepareNetworkLabels(t *testing.T) { - project := composetypes.Project{ - Name: "myProject", - Networks: composetypes.Networks(map[string]composetypes.NetworkConfig{"skynet": {}}), - } - prepareNetworks(&project) - assert.DeepEqual(t, project.Networks["skynet"].CustomLabels, composetypes.Labels(map[string]string{ - "com.docker.compose.network": "skynet", - "com.docker.compose.project": "myProject", - "com.docker.compose.version": api.ComposeVersion, - })) -} - -func TestBuildContainerMountOptions(t *testing.T) { - project := composetypes.Project{ - Name: "myProject", - Services: composetypes.Services{ - "myService": { - Name: "myService", - Volumes: []composetypes.ServiceVolumeConfig{ - { - Type: composetypes.VolumeTypeVolume, - Target: "/var/myvolume1", - }, - { - Type: composetypes.VolumeTypeVolume, - Target: "/var/myvolume2", - }, - { - Type: composetypes.VolumeTypeVolume, - Source: "myVolume3", - Target: "/var/myvolume3", - Volume: &composetypes.ServiceVolumeVolume{ - Subpath: "etc", - }, - }, - { - Type: composetypes.VolumeTypeNamedPipe, - Source: "\\\\.\\pipe\\docker_engine_windows", - Target: "\\\\.\\pipe\\docker_engine", - }, - }, - }, - }, - Volumes: composetypes.Volumes(map[string]composetypes.VolumeConfig{ - "myVolume1": { - Name: "myProject_myVolume1", - }, - "myVolume2": { - Name: "myProject_myVolume2", - }, - }), - } - - inherit := &container.Summary{ - Mounts: []container.MountPoint{ - { - Type: composetypes.VolumeTypeVolume, - Destination: "/var/myvolume1", - }, - { - Type: composetypes.VolumeTypeVolume, - Destination: "/var/myvolume2", - }, - }, - } - - mockCtrl := gomock.NewController(t) - defer mockCtrl.Finish() - - mock, cli := prepareMocks(mockCtrl) - s := composeService{ - dockerCli: cli, - } - mock.EXPECT().ImageInspect(gomock.Any(), "myProject-myService").AnyTimes().Return(image.InspectResponse{}, nil) - - mounts, err := s.buildContainerMountOptions(context.TODO(), project, project.Services["myService"], inherit) - sort.Slice(mounts, func(i, j int) bool { - return mounts[i].Target < mounts[j].Target - }) - assert.NilError(t, err) - assert.Assert(t, len(mounts) == 4) - assert.Equal(t, mounts[0].Target, "/var/myvolume1") - assert.Equal(t, mounts[1].Target, "/var/myvolume2") - assert.Equal(t, mounts[2].Target, "/var/myvolume3") - assert.Equal(t, mounts[2].VolumeOptions.Subpath, "etc") - assert.Equal(t, mounts[3].Target, "\\\\.\\pipe\\docker_engine") - - mounts, err = s.buildContainerMountOptions(context.TODO(), project, project.Services["myService"], inherit) - sort.Slice(mounts, func(i, j int) bool { - return mounts[i].Target < mounts[j].Target - }) - assert.NilError(t, err) - assert.Assert(t, len(mounts) == 4) - assert.Equal(t, mounts[0].Target, "/var/myvolume1") - assert.Equal(t, mounts[1].Target, "/var/myvolume2") - assert.Equal(t, mounts[2].Target, "/var/myvolume3") - assert.Equal(t, mounts[2].VolumeOptions.Subpath, "etc") - assert.Equal(t, mounts[3].Target, "\\\\.\\pipe\\docker_engine") -} - -func TestDefaultNetworkSettings(t *testing.T) { - t.Run("returns the network with the highest priority when service has multiple networks", 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 default network when service has no networks", func(t *testing.T) { - service := composetypes.ServiceConfig{ - Name: "myService", - } - 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", - }, - "default": { - Name: "myProject_default", - }, - }), - } - - networkMode, networkConfig, err := defaultNetworkSettings(&project, service, 1, nil, true, "1.43") - assert.NilError(t, err) - assert.Equal(t, string(networkMode), "myProject_default") - assert.Check(t, cmp.Len(networkConfig.EndpointsConfig, 1)) - assert.Check(t, cmp.Contains(networkConfig.EndpointsConfig, "myProject_default")) - }) - - t.Run("returns none if project has no networks", func(t *testing.T) { - service := composetypes.ServiceConfig{ - Name: "myService", - } - project := composetypes.Project{ - Name: "myProject", - Services: composetypes.Services{ - "myService": service, - }, - } - - networkMode, networkConfig, err := defaultNetworkSettings(&project, service, 1, nil, true, "1.43") - assert.NilError(t, err) - assert.Equal(t, string(networkMode), "none") - assert.Check(t, cmp.Nil(networkConfig)) - }) - - t.Run("returns defined network mode if explicitly set", func(t *testing.T) { - service := composetypes.ServiceConfig{ - Name: "myService", - NetworkMode: "host", - } - project := composetypes.Project{ - Name: "myProject", - Services: composetypes.Services{"myService": service}, - Networks: composetypes.Networks(map[string]composetypes.NetworkConfig{ - "default": { - Name: "myProject_default", - }, - }), - } - - networkMode, networkConfig, err := defaultNetworkSettings(&project, service, 1, nil, true, "1.43") - assert.NilError(t, err) - assert.Equal(t, string(networkMode), "host") - assert.Check(t, cmp.Nil(networkConfig)) - }) -} - -func TestCreateEndpointSettings(t *testing.T) { - eps := createEndpointSettings(&composetypes.Project{ - Name: "projName", - }, composetypes.ServiceConfig{ - Name: "serviceName", - ContainerName: "containerName", - Networks: map[string]*composetypes.ServiceNetworkConfig{ - "netName": { - Priority: 100, - Aliases: []string{"alias1", "alias2"}, - Ipv4Address: "10.16.17.18", - Ipv6Address: "fdb4:7a7f:373a:3f0c::42", - LinkLocalIPs: []string{"169.254.10.20"}, - MacAddress: "10:00:00:00:01", - DriverOpts: composetypes.Options{ - "driverOpt1": "optval1", - "driverOpt2": "optval2", - }, - }, - }, - }, 0, "netName", []string{"link1", "link2"}, true) - assert.Check(t, cmp.DeepEqual(eps, &network.EndpointSettings{ - IPAMConfig: &network.EndpointIPAMConfig{ - IPv4Address: "10.16.17.18", - IPv6Address: "fdb4:7a7f:373a:3f0c::42", - LinkLocalIPs: []string{"169.254.10.20"}, - }, - Links: []string{"link1", "link2"}, - Aliases: []string{"containerName", "serviceName", "alias1", "alias2"}, - MacAddress: "10:00:00:00:01", - DriverOpts: map[string]string{ - "driverOpt1": "optval1", - "driverOpt2": "optval2", - }, - - // FIXME(robmry) - IPAddress and IPv6Gateway are "operational data" fields... - // - The IPv6 address here is the container's address, not the gateway. - // - Both fields will be cleared by the daemon, but they could be removed from - // the request. - IPAddress: "10.16.17.18", - IPv6Gateway: "fdb4:7a7f:373a:3f0c::42", - })) -} - -func Test_buildContainerVolumes(t *testing.T) { - pwd, err := os.Getwd() - assert.NilError(t, err) - - tests := []struct { - name string - yaml string - binds []string - mounts []mountTypes.Mount - }{ - { - name: "bind mount local path", - yaml: ` -services: - test: - volumes: - - ./data:/data -`, - binds: []string{filepath.Join(pwd, "data") + ":/data:rw"}, - mounts: nil, - }, - { - name: "bind mount, not create host path", - yaml: ` -services: - test: - volumes: - - type: bind - source: ./data - target: /data - bind: - create_host_path: false -`, - binds: nil, - mounts: []mountTypes.Mount{ - { - Type: "bind", - Source: filepath.Join(pwd, "data"), - Target: "/data", - BindOptions: &mountTypes.BindOptions{CreateMountpoint: false}, - }, - }, - }, - { - name: "mount volume", - yaml: ` -services: - test: - volumes: - - data:/data -volumes: - data: - name: my_volume -`, - binds: []string{"my_volume:/data:rw"}, - mounts: nil, - }, - { - name: "mount volume, readonly", - yaml: ` -services: - test: - volumes: - - data:/data:ro -volumes: - data: - name: my_volume -`, - binds: []string{"my_volume:/data:ro"}, - mounts: nil, - }, - { - name: "mount volume subpath", - yaml: ` -services: - test: - volumes: - - type: volume - source: data - target: /data - volume: - subpath: test/ -volumes: - data: - name: my_volume -`, - binds: nil, - mounts: []mountTypes.Mount{ - { - Type: "volume", - Source: "my_volume", - Target: "/data", - VolumeOptions: &mountTypes.VolumeOptions{Subpath: "test/"}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p, err := composeloader.LoadWithContext(context.TODO(), composetypes.ConfigDetails{ - ConfigFiles: []composetypes.ConfigFile{ - { - Filename: "test", - Content: []byte(tt.yaml), - }, - }, - }, func(options *composeloader.Options) { - options.SkipValidation = true - options.SkipConsistencyCheck = true - }) - assert.NilError(t, err) - s := &composeService{} - binds, mounts, err := s.buildContainerVolumes(context.TODO(), *p, p.Services["test"], nil) - assert.NilError(t, err) - assert.DeepEqual(t, tt.binds, binds) - assert.DeepEqual(t, tt.mounts, mounts) - }) - } -} - -func TestResolveOrCreateNetworkEmitsRunningEvent(t *testing.T) { - mockCtrl := gomock.NewController(t) - defer mockCtrl.Finish() - mockApi, cli := prepareMocks(mockCtrl) - s := composeService{dockerCli: cli} - - project := composetypes.Project{ - Name: "testproject", - Networks: composetypes.Networks(map[string]composetypes.NetworkConfig{ - "default": {Name: "testproject_default"}, - }), - Services: composetypes.Services{ - "svc": {Name: "svc", Networks: map[string]*composetypes.ServiceNetworkConfig{"default": {}}}, - }, - } - nVal := project.Networks["default"] - n := &nVal - - // network exists but has a different config hash -> removeDivergedNetwork should be triggered - netInspect := network.Inspect{ - ID: "netid", - Name: n.Name, - Labels: map[string]string{api.NetworkLabel: "default", api.ProjectLabel: project.Name, api.ConfigHashLabel: "oldhash"}, - } - mockApi.EXPECT().NetworkInspect(gomock.Any(), n.Name, gomock.Any()).Return(netInspect, nil) - - // stop will be called (ContainerStop during removeDivergedNetwork) - mockApi.EXPECT().ContainerStop(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) - - // getContainers should return the container that was stopped - cs := container.Summary{ID: "ctrid", Names: []string{"/testproject_svc_1"}, Labels: map[string]string{api.ServiceLabel: "svc"}} - mockApi.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(Containers{cs}, nil).AnyTimes() - - mockApi.EXPECT().NetworkDisconnect(gomock.Any(), n.Name, "ctrid", true).Return(nil) - mockApi.EXPECT().NetworkRemove(gomock.Any(), n.Name).Return(nil) - - mockApi.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return([]network.Summary{}, nil) - mockApi.EXPECT().NetworkCreate(gomock.Any(), n.Name, gomock.Any()).Return(network.CreateResponse{ID: "newnet"}, nil) - - mockApi.EXPECT().NetworkConnect(gomock.Any(), n.Name, "ctrid", gomock.Any()).Return(nil) - - // After reconnect, engine restarted container -> ContainerInspect returns running - runningInspect := container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{Name: "/testproject_svc_1", State: &container.State{Running: true, Status: container.StateRunning}}} - mockApi.EXPECT().ContainerInspect(gomock.Any(), "ctrid").Return(runningInspect, nil) - - // set event recorder - rec := &testEventProcessor{} - s.events = rec - - id, err := s.resolveOrCreateNetwork(context.Background(), &project, "default", n) - assert.NilError(t, err) - assert.Equal(t, id, "newnet") - - found := false - for _, ev := range rec.events { - if ev.ID == "Container testproject_svc_1" && ev.Text == api.StatusRunning { - found = true - break - } - } - assert.Assert(t, found) -} - -// testEventProcessor collects events for assertions -type testEventProcessor struct { - events []api.Resource -} - -func (t *testEventProcessor) Start(ctx context.Context, operation string) {} -func (t *testEventProcessor) Done(operation string, success bool) {} -func (t *testEventProcessor) On(events ...api.Resource) { t.events = append(t.events, events...) } From 58afa5081ff2af8a666adc843c4e030810c33d86 Mon Sep 17 00:00:00 2001 From: Mahesh Thakur Date: Tue, 20 Jan 2026 21:52:57 +0530 Subject: [PATCH 6/6] Restore pkg/compose/create_test.go from main Signed-off-by: Mahesh Thakur --- pkg/compose/create_test.go | 456 +++++++++++++++++++++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 pkg/compose/create_test.go diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go new file mode 100644 index 0000000000..41cd3bcb8a --- /dev/null +++ b/pkg/compose/create_test.go @@ -0,0 +1,456 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "os" + "path/filepath" + "sort" + "testing" + + composeloader "github.com/compose-spec/compose-go/v2/loader" + composetypes "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + mountTypes "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/network" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + "gotest.tools/v3/assert/cmp" + + "github.com/docker/compose/v5/pkg/api" +) + +func TestBuildBindMount(t *testing.T) { + project := composetypes.Project{} + volume := composetypes.ServiceVolumeConfig{ + Type: composetypes.VolumeTypeBind, + Source: "", + Target: "/data", + } + mount, err := buildMount(project, volume) + assert.NilError(t, err) + assert.Assert(t, filepath.IsAbs(mount.Source)) + _, err = os.Stat(mount.Source) + assert.NilError(t, err) + assert.Equal(t, mount.Type, mountTypes.TypeBind) +} + +func TestBuildNamedPipeMount(t *testing.T) { + project := composetypes.Project{} + volume := composetypes.ServiceVolumeConfig{ + Type: composetypes.VolumeTypeNamedPipe, + Source: "\\\\.\\pipe\\docker_engine_windows", + Target: "\\\\.\\pipe\\docker_engine", + } + mount, err := buildMount(project, volume) + assert.NilError(t, err) + assert.Equal(t, mount.Type, mountTypes.TypeNamedPipe) +} + +func TestBuildVolumeMount(t *testing.T) { + project := composetypes.Project{ + Name: "myProject", + Volumes: composetypes.Volumes(map[string]composetypes.VolumeConfig{ + "myVolume": { + Name: "myProject_myVolume", + }, + }), + } + volume := composetypes.ServiceVolumeConfig{ + Type: composetypes.VolumeTypeVolume, + Source: "myVolume", + Target: "/data", + } + mount, err := buildMount(project, volume) + assert.NilError(t, err) + assert.Equal(t, mount.Source, "myProject_myVolume") + assert.Equal(t, mount.Type, mountTypes.TypeVolume) +} + +func TestServiceImageName(t *testing.T) { + assert.Equal(t, api.GetImageNameOrDefault(composetypes.ServiceConfig{Image: "myImage"}, "myProject"), "myImage") + assert.Equal(t, api.GetImageNameOrDefault(composetypes.ServiceConfig{Name: "aService"}, "myProject"), "myProject-aService") +} + +func TestPrepareNetworkLabels(t *testing.T) { + project := composetypes.Project{ + Name: "myProject", + Networks: composetypes.Networks(map[string]composetypes.NetworkConfig{"skynet": {}}), + } + prepareNetworks(&project) + assert.DeepEqual(t, project.Networks["skynet"].CustomLabels, composetypes.Labels(map[string]string{ + "com.docker.compose.network": "skynet", + "com.docker.compose.project": "myProject", + "com.docker.compose.version": api.ComposeVersion, + })) +} + +func TestBuildContainerMountOptions(t *testing.T) { + project := composetypes.Project{ + Name: "myProject", + Services: composetypes.Services{ + "myService": { + Name: "myService", + Volumes: []composetypes.ServiceVolumeConfig{ + { + Type: composetypes.VolumeTypeVolume, + Target: "/var/myvolume1", + }, + { + Type: composetypes.VolumeTypeVolume, + Target: "/var/myvolume2", + }, + { + Type: composetypes.VolumeTypeVolume, + Source: "myVolume3", + Target: "/var/myvolume3", + Volume: &composetypes.ServiceVolumeVolume{ + Subpath: "etc", + }, + }, + { + Type: composetypes.VolumeTypeNamedPipe, + Source: "\\\\.\\pipe\\docker_engine_windows", + Target: "\\\\.\\pipe\\docker_engine", + }, + }, + }, + }, + Volumes: composetypes.Volumes(map[string]composetypes.VolumeConfig{ + "myVolume1": { + Name: "myProject_myVolume1", + }, + "myVolume2": { + Name: "myProject_myVolume2", + }, + }), + } + + inherit := &container.Summary{ + Mounts: []container.MountPoint{ + { + Type: composetypes.VolumeTypeVolume, + Destination: "/var/myvolume1", + }, + { + Type: composetypes.VolumeTypeVolume, + Destination: "/var/myvolume2", + }, + }, + } + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mock, cli := prepareMocks(mockCtrl) + s := composeService{ + dockerCli: cli, + } + mock.EXPECT().ImageInspect(gomock.Any(), "myProject-myService").AnyTimes().Return(image.InspectResponse{}, nil) + + mounts, err := s.buildContainerMountOptions(t.Context(), project, project.Services["myService"], inherit) + sort.Slice(mounts, func(i, j int) bool { + return mounts[i].Target < mounts[j].Target + }) + assert.NilError(t, err) + assert.Assert(t, len(mounts) == 4) + assert.Equal(t, mounts[0].Target, "/var/myvolume1") + assert.Equal(t, mounts[1].Target, "/var/myvolume2") + assert.Equal(t, mounts[2].Target, "/var/myvolume3") + assert.Equal(t, mounts[2].VolumeOptions.Subpath, "etc") + assert.Equal(t, mounts[3].Target, "\\\\.\\pipe\\docker_engine") + + mounts, err = s.buildContainerMountOptions(t.Context(), project, project.Services["myService"], inherit) + sort.Slice(mounts, func(i, j int) bool { + return mounts[i].Target < mounts[j].Target + }) + assert.NilError(t, err) + assert.Assert(t, len(mounts) == 4) + assert.Equal(t, mounts[0].Target, "/var/myvolume1") + assert.Equal(t, mounts[1].Target, "/var/myvolume2") + assert.Equal(t, mounts[2].Target, "/var/myvolume3") + assert.Equal(t, mounts[2].VolumeOptions.Subpath, "etc") + assert.Equal(t, mounts[3].Target, "\\\\.\\pipe\\docker_engine") +} + +func TestDefaultNetworkSettings(t *testing.T) { + t.Run("returns the network with the highest priority when service has multiple networks", 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 default network when service has no networks", func(t *testing.T) { + service := composetypes.ServiceConfig{ + Name: "myService", + } + 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", + }, + "default": { + Name: "myProject_default", + }, + }), + } + + networkMode, networkConfig, err := defaultNetworkSettings(&project, service, 1, nil, true, "1.43") + assert.NilError(t, err) + assert.Equal(t, string(networkMode), "myProject_default") + assert.Check(t, cmp.Len(networkConfig.EndpointsConfig, 1)) + assert.Check(t, cmp.Contains(networkConfig.EndpointsConfig, "myProject_default")) + }) + + t.Run("returns none if project has no networks", func(t *testing.T) { + service := composetypes.ServiceConfig{ + Name: "myService", + } + project := composetypes.Project{ + Name: "myProject", + Services: composetypes.Services{ + "myService": service, + }, + } + + networkMode, networkConfig, err := defaultNetworkSettings(&project, service, 1, nil, true, "1.43") + assert.NilError(t, err) + assert.Equal(t, string(networkMode), "none") + assert.Check(t, cmp.Nil(networkConfig)) + }) + + t.Run("returns defined network mode if explicitly set", func(t *testing.T) { + service := composetypes.ServiceConfig{ + Name: "myService", + NetworkMode: "host", + } + project := composetypes.Project{ + Name: "myProject", + Services: composetypes.Services{"myService": service}, + Networks: composetypes.Networks(map[string]composetypes.NetworkConfig{ + "default": { + Name: "myProject_default", + }, + }), + } + + networkMode, networkConfig, err := defaultNetworkSettings(&project, service, 1, nil, true, "1.43") + assert.NilError(t, err) + assert.Equal(t, string(networkMode), "host") + assert.Check(t, cmp.Nil(networkConfig)) + }) +} + +func TestCreateEndpointSettings(t *testing.T) { + eps := createEndpointSettings(&composetypes.Project{ + Name: "projName", + }, composetypes.ServiceConfig{ + Name: "serviceName", + ContainerName: "containerName", + Networks: map[string]*composetypes.ServiceNetworkConfig{ + "netName": { + Priority: 100, + Aliases: []string{"alias1", "alias2"}, + Ipv4Address: "10.16.17.18", + Ipv6Address: "fdb4:7a7f:373a:3f0c::42", + LinkLocalIPs: []string{"169.254.10.20"}, + MacAddress: "10:00:00:00:01", + DriverOpts: composetypes.Options{ + "driverOpt1": "optval1", + "driverOpt2": "optval2", + }, + }, + }, + }, 0, "netName", []string{"link1", "link2"}, true) + assert.Check(t, cmp.DeepEqual(eps, &network.EndpointSettings{ + IPAMConfig: &network.EndpointIPAMConfig{ + IPv4Address: "10.16.17.18", + IPv6Address: "fdb4:7a7f:373a:3f0c::42", + LinkLocalIPs: []string{"169.254.10.20"}, + }, + Links: []string{"link1", "link2"}, + Aliases: []string{"containerName", "serviceName", "alias1", "alias2"}, + MacAddress: "10:00:00:00:01", + DriverOpts: map[string]string{ + "driverOpt1": "optval1", + "driverOpt2": "optval2", + }, + + // FIXME(robmry) - IPAddress and IPv6Gateway are "operational data" fields... + // - The IPv6 address here is the container's address, not the gateway. + // - Both fields will be cleared by the daemon, but they could be removed from + // the request. + IPAddress: "10.16.17.18", + IPv6Gateway: "fdb4:7a7f:373a:3f0c::42", + })) +} + +func Test_buildContainerVolumes(t *testing.T) { + pwd, err := os.Getwd() + assert.NilError(t, err) + + tests := []struct { + name string + yaml string + binds []string + mounts []mountTypes.Mount + }{ + { + name: "bind mount local path", + yaml: ` +services: + test: + volumes: + - ./data:/data +`, + binds: []string{filepath.Join(pwd, "data") + ":/data:rw"}, + mounts: nil, + }, + { + name: "bind mount, not create host path", + yaml: ` +services: + test: + volumes: + - type: bind + source: ./data + target: /data + bind: + create_host_path: false +`, + binds: nil, + mounts: []mountTypes.Mount{ + { + Type: "bind", + Source: filepath.Join(pwd, "data"), + Target: "/data", + BindOptions: &mountTypes.BindOptions{CreateMountpoint: false}, + }, + }, + }, + { + name: "mount volume", + yaml: ` +services: + test: + volumes: + - data:/data +volumes: + data: + name: my_volume +`, + binds: []string{"my_volume:/data:rw"}, + mounts: nil, + }, + { + name: "mount volume, readonly", + yaml: ` +services: + test: + volumes: + - data:/data:ro +volumes: + data: + name: my_volume +`, + binds: []string{"my_volume:/data:ro"}, + mounts: nil, + }, + { + name: "mount volume subpath", + yaml: ` +services: + test: + volumes: + - type: volume + source: data + target: /data + volume: + subpath: test/ +volumes: + data: + name: my_volume +`, + binds: nil, + mounts: []mountTypes.Mount{ + { + Type: "volume", + Source: "my_volume", + Target: "/data", + VolumeOptions: &mountTypes.VolumeOptions{Subpath: "test/"}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := composeloader.LoadWithContext(t.Context(), composetypes.ConfigDetails{ + ConfigFiles: []composetypes.ConfigFile{ + { + Filename: "test", + Content: []byte(tt.yaml), + }, + }, + }, func(options *composeloader.Options) { + options.SkipValidation = true + options.SkipConsistencyCheck = true + }) + assert.NilError(t, err) + s := &composeService{} + binds, mounts, err := s.buildContainerVolumes(t.Context(), *p, p.Services["test"], nil) + assert.NilError(t, err) + assert.DeepEqual(t, tt.binds, binds) + assert.DeepEqual(t, tt.mounts, mounts) + }) + } +}