Skip to content

Commit 6da77d4

Browse files
ndeloofclaude
andcommitted
Add e2e tests for reconciliation behavior
Test coverage for behaviors identified during the reconciliation engine refactoring: - TestReconcileReplaceLabel: verify com.docker.compose.replace label is set on recreated containers - TestReconcileNetworkConfigChange: verify network config change triggers network recreation and container reconnection - TestReconcileVolumeConfigChange: verify volume config change with -y triggers volume recreation and container recreation - TestReconcileDependentRestart: verify dependent services with restart:true are stopped and restarted when dependency is recreated - TestReconcileIdempotent: verify running up twice with no changes produces no recreations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
1 parent baaaaa3 commit 6da77d4

2 files changed

Lines changed: 248 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
services:
2+
web:
3+
image: nginx:alpine
4+
environment:
5+
- LABEL=${LABEL:-default}
6+
networks:
7+
- frontend
8+
volumes:
9+
- data:/data
10+
depends_on:
11+
db:
12+
condition: service_started
13+
restart: true
14+
15+
db:
16+
image: alpine
17+
command: sleep infinity
18+
networks:
19+
- frontend
20+
volumes:
21+
- data:/data
22+
23+
networks:
24+
frontend:
25+
labels:
26+
- config=${NET_LABEL:-v1}
27+
28+
volumes:
29+
data:
30+
labels:
31+
config: ${VOL_LABEL:-v1}

pkg/e2e/reconciliation_test.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/*
2+
Copyright 2020 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package e2e
18+
19+
import (
20+
"fmt"
21+
"strings"
22+
"testing"
23+
24+
"gotest.tools/v3/assert"
25+
"gotest.tools/v3/icmd"
26+
)
27+
28+
// TestReconcileReplaceLabel verifies that when a container is recreated (due to
29+
// config change), the new container gets the com.docker.compose.replace label
30+
// pointing to the replaced container's service name and number.
31+
func TestReconcileReplaceLabel(t *testing.T) {
32+
c := NewCLI(t)
33+
const projectName = "reconcile-replace-label"
34+
t.Cleanup(func() {
35+
c.cleanupWithDown(t, projectName)
36+
})
37+
38+
// First up: create containers
39+
c.RunDockerComposeCmd(t, "-f", "./fixtures/reconciliation-test/compose.yaml",
40+
"--project-name", projectName, "up", "-d")
41+
42+
// Verify no replace label on fresh containers
43+
res := c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-db-1", projectName),
44+
"-f", `{{ index .Config.Labels "com.docker.compose.replace" }}`)
45+
res.Assert(t, icmd.Expected{Out: ""})
46+
47+
// Second up with changed env to trigger recreate
48+
cli := NewCLI(t, WithEnv("LABEL=changed"))
49+
res = cli.RunDockerComposeCmd(t, "-f", "./fixtures/reconciliation-test/compose.yaml",
50+
"--project-name", projectName, "up", "-d")
51+
assert.Assert(t, strings.Contains(res.Stderr(), "Recreated"), "expected Recreated in output: %s", res.Stderr())
52+
53+
// Verify replace label is set on the recreated container
54+
res = c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-db-1", projectName),
55+
"-f", `{{ index .Config.Labels "com.docker.compose.replace" }}`)
56+
assert.Assert(t, strings.Contains(res.Stdout(), "db-1"),
57+
"expected replace label to contain 'db-1', got: %s", res.Stdout())
58+
}
59+
60+
// TestReconcileNetworkConfigChange verifies that when a network's configuration
61+
// changes (e.g. labels), the network is recreated and containers are reconnected
62+
// to the new network.
63+
func TestReconcileNetworkConfigChange(t *testing.T) {
64+
c := NewCLI(t)
65+
const projectName = "reconcile-net-change"
66+
t.Cleanup(func() {
67+
c.cleanupWithDown(t, projectName)
68+
})
69+
70+
// First up: create with default network labels
71+
c.RunDockerComposeCmd(t, "-f", "./fixtures/reconciliation-test/compose.yaml",
72+
"--project-name", projectName, "up", "-d")
73+
74+
// Check network has v1 label
75+
netName := fmt.Sprintf("%s_frontend", projectName)
76+
res := c.RunDockerCmd(t, "network", "inspect", netName, "-f", `{{ index .Labels "config" }}`)
77+
res.Assert(t, icmd.Expected{Out: "v1"})
78+
79+
// Get the initial network ID
80+
res = c.RunDockerCmd(t, "network", "inspect", netName, "-f", "{{ .Id }}")
81+
initialNetID := strings.TrimSpace(res.Stdout())
82+
83+
// Second up with changed network label -> network should be recreated
84+
cli := NewCLI(t, WithEnv("NET_LABEL=v2"))
85+
res = cli.RunDockerComposeCmd(t, "-f", "./fixtures/reconciliation-test/compose.yaml",
86+
"--project-name", projectName, "up", "-d")
87+
assert.Assert(t, strings.Contains(res.Stderr(), "Recreat"), "expected recreate events: %s", res.Stderr())
88+
89+
// Network should have v2 label
90+
res = c.RunDockerCmd(t, "network", "inspect", netName, "-f", `{{ index .Labels "config" }}`)
91+
res.Assert(t, icmd.Expected{Out: "v2"})
92+
93+
// Network ID should have changed (recreated, not just updated)
94+
res = c.RunDockerCmd(t, "network", "inspect", netName, "-f", "{{ .Id }}")
95+
newNetID := strings.TrimSpace(res.Stdout())
96+
assert.Assert(t, newNetID != initialNetID, "expected network ID to change after recreate")
97+
98+
// Containers should be connected to the new network (running with connectivity)
99+
res = c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-db-1", projectName),
100+
"-f", `{{ range .NetworkSettings.Networks }}{{ .NetworkID }}{{ end }}`)
101+
assert.Assert(t, strings.Contains(res.Stdout(), newNetID),
102+
"expected container connected to new network %s, got: %s", newNetID, res.Stdout())
103+
}
104+
105+
// TestReconcileVolumeConfigChange verifies that when a volume's configuration
106+
// changes, the user is prompted, and if confirmed, the volume is recreated and
107+
// containers are also recreated to use the new volume.
108+
func TestReconcileVolumeConfigChange(t *testing.T) {
109+
c := NewCLI(t)
110+
const projectName = "reconcile-vol-change"
111+
t.Cleanup(func() {
112+
c.cleanupWithDown(t, projectName)
113+
})
114+
115+
// First up: create with default volume labels
116+
c.RunDockerComposeCmd(t, "-f", "./fixtures/reconciliation-test/compose.yaml",
117+
"--project-name", projectName, "up", "-d")
118+
119+
// Check volume has v1 label
120+
volName := fmt.Sprintf("%s_data", projectName)
121+
res := c.RunDockerCmd(t, "volume", "inspect", volName, "-f", `{{ index .Labels "config" }}`)
122+
res.Assert(t, icmd.Expected{Out: "v1"})
123+
124+
// Second up with changed volume label + -y to auto-confirm
125+
cli := NewCLI(t, WithEnv("VOL_LABEL=v2"))
126+
res = cli.RunDockerComposeCmd(t, "-f", "./fixtures/reconciliation-test/compose.yaml",
127+
"--project-name", projectName, "up", "-d", "-y")
128+
129+
// Volume should have v2 label (recreated)
130+
res = c.RunDockerCmd(t, "volume", "inspect", volName, "-f", `{{ index .Labels "config" }}`)
131+
res.Assert(t, icmd.Expected{Out: "v2"})
132+
133+
// Containers should be running (recreated to mount the new volume)
134+
res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "--format", "json")
135+
assert.Assert(t, strings.Contains(res.Stdout(), `"State":"running"`),
136+
"expected running containers after volume recreate")
137+
}
138+
139+
// TestReconcileDependentRestart verifies that when a service is recreated,
140+
// dependent services with restart:true are stopped and restarted.
141+
func TestReconcileDependentRestart(t *testing.T) {
142+
c := NewCLI(t)
143+
const projectName = "reconcile-dep-restart"
144+
t.Cleanup(func() {
145+
c.cleanupWithDown(t, projectName)
146+
})
147+
148+
// First up: create all services
149+
c.RunDockerComposeCmd(t, "-f", "./fixtures/reconciliation-test/compose.yaml",
150+
"--project-name", projectName, "up", "-d")
151+
152+
// Get initial web container ID
153+
res := c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-web-1", projectName), "-f", "{{ .Id }}")
154+
initialWebID := strings.TrimSpace(res.Stdout())
155+
156+
// Recreate db (by changing env) — web depends on db with restart:true
157+
// so web should be stopped and restarted
158+
cli := NewCLI(t, WithEnv("LABEL=trigger-recreate"))
159+
res = cli.RunDockerComposeCmd(t, "-f", "./fixtures/reconciliation-test/compose.yaml",
160+
"--project-name", projectName, "up", "-d")
161+
162+
out := res.Stderr()
163+
// db should be recreated
164+
assert.Assert(t, strings.Contains(out, "Recreated") || strings.Contains(out, "Recreate"),
165+
"expected db recreate: %s", out)
166+
167+
// web should have been stopped (due to restart:true dependency)
168+
// It may be Stopped+Started or Recreated depending on whether its config also changed
169+
assert.Assert(t, strings.Contains(out, fmt.Sprintf("%s-web-1", projectName)),
170+
"expected web container in output: %s", out)
171+
172+
// Both containers should be running
173+
res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "-q")
174+
lines := strings.Split(strings.TrimSpace(res.Stdout()), "\n")
175+
assert.Equal(t, len(lines), 2, "expected 2 running containers")
176+
177+
// web container should have been replaced (new ID)
178+
res = c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-web-1", projectName), "-f", "{{ .Id }}")
179+
newWebID := strings.TrimSpace(res.Stdout())
180+
assert.Assert(t, newWebID != initialWebID, "expected web container to be replaced")
181+
}
182+
183+
// TestReconcileIdempotent verifies that running "up -d" twice with no changes
184+
// produces no recreations — containers should show as "Running".
185+
func TestReconcileIdempotent(t *testing.T) {
186+
c := NewCLI(t)
187+
const projectName = "reconcile-idempotent"
188+
t.Cleanup(func() {
189+
c.cleanupWithDown(t, projectName)
190+
})
191+
192+
// First up
193+
c.RunDockerComposeCmd(t, "-f", "./fixtures/reconciliation-test/compose.yaml",
194+
"--project-name", projectName, "up", "-d")
195+
196+
// Get container IDs
197+
res := c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-db-1", projectName), "-f", "{{ .Id }}")
198+
dbID := strings.TrimSpace(res.Stdout())
199+
res = c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-web-1", projectName), "-f", "{{ .Id }}")
200+
webID := strings.TrimSpace(res.Stdout())
201+
202+
// Second up with no changes
203+
res = c.RunDockerComposeCmd(t, "-f", "./fixtures/reconciliation-test/compose.yaml",
204+
"--project-name", projectName, "up", "-d")
205+
out := res.Stderr()
206+
207+
// Should show "Running" not "Recreated" or "Created"
208+
assert.Assert(t, strings.Contains(out, "Running"), "expected Running in output: %s", out)
209+
assert.Assert(t, !strings.Contains(out, "Recreated"), "unexpected Recreated in output: %s", out)
210+
assert.Assert(t, !strings.Contains(out, "Created"), "unexpected Created in output: %s", out)
211+
212+
// Container IDs should be unchanged
213+
res = c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-db-1", projectName), "-f", "{{ .Id }}")
214+
assert.Equal(t, strings.TrimSpace(res.Stdout()), dbID)
215+
res = c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-web-1", projectName), "-f", "{{ .Id }}")
216+
assert.Equal(t, strings.TrimSpace(res.Stdout()), webID)
217+
}

0 commit comments

Comments
 (0)