From da0d37a496a6d36b7443ceadff3c4312f5f22d9a Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 20 Apr 2026 10:55:37 +0200 Subject: [PATCH] Add e2e tests for convergence edge cases Add targeted e2e tests covering specific behaviors that must be preserved across convergence refactoring: - TestReplaceLabelOnRecreate (compose_test.go): verify com.docker.compose.replace label is set on recreated containers - TestNetworkConfigChangeReconnectsContainers (networks_test.go): verify network config change recreates the network and reconnects containers to the new network ID - TestUpRecreateVolumesAlsoRecreatContainers (volumes_test.go): verify volume config change with -y also recreates containers - TestDependentContainerReplacedOnRecreate (restart_test.go): verify dependent services with restart:true get a new container when their dependency is recreated - TestUpIdempotent (up_test.go): verify running up twice with no changes produces no recreations and preserves container IDs Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Nicolas De Loof --- pkg/e2e/compose_test.go | 27 ++++++++++++++++++ pkg/e2e/fixtures/replace-label/compose.yaml | 6 ++++ pkg/e2e/networks_test.go | 31 +++++++++++++++++++++ pkg/e2e/restart_test.go | 31 +++++++++++++++++++++ pkg/e2e/up_test.go | 27 ++++++++++++++++++ pkg/e2e/volumes_test.go | 25 +++++++++++++++++ 6 files changed, 147 insertions(+) create mode 100644 pkg/e2e/fixtures/replace-label/compose.yaml diff --git a/pkg/e2e/compose_test.go b/pkg/e2e/compose_test.go index 5d7c78c2295..9e254e23b29 100644 --- a/pkg/e2e/compose_test.go +++ b/pkg/e2e/compose_test.go @@ -406,3 +406,30 @@ func TestUnnecessaryResources(t *testing.T) { c.RunDockerComposeCmd(t, "-f", "./fixtures/external/compose.yaml", "-p", projectName, "up", "-d", "test") // Should not fail as missing external network is not used } + +func TestReplaceLabelOnRecreate(t *testing.T) { + c := NewCLI(t) + const projectName = "replace-label" + t.Cleanup(func() { + c.cleanupWithDown(t, projectName) + }) + + // First up: fresh container has no replace label + c.RunDockerComposeCmd(t, "-f", "./fixtures/replace-label/compose.yaml", + "--project-name", projectName, "up", "-d") + res := c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-svc-1", projectName), + "-f", `{{ index .Config.Labels "com.docker.compose.replace" }}`) + res.Assert(t, icmd.Expected{Out: ""}) + + // Second up with changed env triggers recreate + cli := NewCLI(t, WithEnv("TAG=v2")) + res = cli.RunDockerComposeCmd(t, "-f", "./fixtures/replace-label/compose.yaml", + "--project-name", projectName, "up", "-d") + assert.Assert(t, strings.Contains(res.Stderr(), "Recreated"), res.Stderr()) + + // Recreated container should have replace label pointing to old name + res = c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-svc-1", projectName), + "-f", `{{ index .Config.Labels "com.docker.compose.replace" }}`) + assert.Assert(t, strings.Contains(res.Stdout(), "svc-1"), + "expected replace label with 'svc-1', got: %s", res.Stdout()) +} diff --git a/pkg/e2e/fixtures/replace-label/compose.yaml b/pkg/e2e/fixtures/replace-label/compose.yaml new file mode 100644 index 00000000000..6fc5f528bda --- /dev/null +++ b/pkg/e2e/fixtures/replace-label/compose.yaml @@ -0,0 +1,6 @@ +services: + svc: + image: alpine + command: sleep infinity + environment: + - TAG=${TAG:-v1} diff --git a/pkg/e2e/networks_test.go b/pkg/e2e/networks_test.go index d4fbc36599d..1b643979195 100644 --- a/pkg/e2e/networks_test.go +++ b/pkg/e2e/networks_test.go @@ -201,6 +201,37 @@ func TestInterfaceName(t *testing.T) { res.Assert(t, icmd.Expected{Out: "foobar@"}) } +func TestNetworkConfigChangeReconnectsContainers(t *testing.T) { + c := NewCLI(t) + const projectName = "network_config_reconnect" + t.Cleanup(func() { + c.cleanupWithDown(t, projectName) + }) + + c.RunDockerComposeCmd(t, "-f", "./fixtures/network-recreate/compose.yaml", + "--project-name", projectName, "up", "-d") + + netName := fmt.Sprintf("%s_test", projectName) + res := c.RunDockerCmd(t, "network", "inspect", netName, "-f", "{{ .Id }}") + initialNetID := strings.TrimSpace(res.Stdout()) + + // Change network config (label) -> network recreated + cli := NewCLI(t, WithEnv("FOO=changed")) + cli.RunDockerComposeCmd(t, "-f", "./fixtures/network-recreate/compose.yaml", + "--project-name", projectName, "up", "-d") + + // Network ID must have changed + res = c.RunDockerCmd(t, "network", "inspect", netName, "-f", "{{ .Id }}") + newNetID := strings.TrimSpace(res.Stdout()) + assert.Assert(t, newNetID != initialNetID, "expected network to be recreated with new ID") + + // Container must be connected to the new network + res = c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-web-1", projectName), + "-f", `{{ range .NetworkSettings.Networks }}{{ .NetworkID }}{{ end }}`) + assert.Assert(t, strings.Contains(res.Stdout(), newNetID), + "expected container on new network %s, got: %s", newNetID, res.Stdout()) +} + func TestNetworkRecreate(t *testing.T) { c := NewCLI(t) const projectName = "network_recreate" diff --git a/pkg/e2e/restart_test.go b/pkg/e2e/restart_test.go index c9df28cb21c..20c0cb41bc2 100644 --- a/pkg/e2e/restart_test.go +++ b/pkg/e2e/restart_test.go @@ -98,6 +98,37 @@ func TestRestartWithDependencies(t *testing.T) { assert.Assert(t, strings.Contains(out, fmt.Sprintf("Container e2e-restart-deps-%s-1 Running", depNoRestart)), out) } +func TestDependentContainerRestartedOnRecreate(t *testing.T) { + c := NewCLI(t, WithEnv( + "COMPOSE_PROJECT_NAME=e2e-dep-restart", + )) + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "down", "--remove-orphans") + }) + + // First up + c.RunDockerComposeCmd(t, "-f", "./fixtures/restart-test/compose-depends-on.yaml", "up", "-d") + + // Recreate nginx (change label) → with-restart depends with restart:true + // so it should be stopped then restarted (same container, not replaced) + cli := NewCLI(t, WithEnv( + "COMPOSE_PROJECT_NAME=e2e-dep-restart", + "LABEL=recreate", + )) + res := cli.RunDockerComposeCmd(t, "-f", "./fixtures/restart-test/compose-depends-on.yaml", "up", "-d") + out := res.Combined() + + // The dependent with restart:true should have been stopped then started + assert.Assert(t, strings.Contains(out, "e2e-dep-restart-with-restart-1"), + "expected dependent container in output: %s", out) + + // All 3 containers should be running after convergence + res = c.RunDockerComposeCmd(t, "ps", "--format", "{{.State}}") + for _, line := range strings.Split(strings.TrimSpace(res.Stdout()), "\n") { + assert.Equal(t, strings.TrimSpace(line), "running", "expected all containers running") + } +} + func TestRestartWithProfiles(t *testing.T) { c := NewParallelCLI(t, WithEnv( "COMPOSE_PROJECT_NAME=e2e-restart-profiles", diff --git a/pkg/e2e/up_test.go b/pkg/e2e/up_test.go index 8575be9e375..e67dd6d1c67 100644 --- a/pkg/e2e/up_test.go +++ b/pkg/e2e/up_test.go @@ -153,6 +153,33 @@ func TestScaleDoesntRecreate(t *testing.T) { assert.Check(t, !strings.Contains(res.Combined(), "Recreated")) } +func TestUpIdempotent(t *testing.T) { + c := NewCLI(t) + const projectName = "compose-e2e-idempotent" + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "--project-name", projectName, "down") + }) + + c.RunDockerComposeCmd(t, "-f", "fixtures/simple-composefile/compose.yaml", + "--project-name", projectName, "up", "-d") + + res := c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-simple-1", projectName), "-f", "{{ .Id }}") + initialID := strings.TrimSpace(res.Stdout()) + + // Second up with no changes + res = c.RunDockerComposeCmd(t, "-f", "fixtures/simple-composefile/compose.yaml", + "--project-name", projectName, "up", "-d") + + assert.Assert(t, strings.Contains(res.Stderr(), "Running"), + "expected Running in output: %s", res.Stderr()) + assert.Assert(t, !strings.Contains(res.Stderr(), "Recreated"), + "unexpected Recreated: %s", res.Stderr()) + + // Container ID unchanged + res = c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-simple-1", projectName), "-f", "{{ .Id }}") + assert.Equal(t, strings.TrimSpace(res.Stdout()), initialID) +} + func TestUpWithDependencyNotRequired(t *testing.T) { c := NewCLI(t) const projectName = "compose-e2e-dependency-not-required" diff --git a/pkg/e2e/volumes_test.go b/pkg/e2e/volumes_test.go index d3e5787fa02..c3a20964850 100644 --- a/pkg/e2e/volumes_test.go +++ b/pkg/e2e/volumes_test.go @@ -159,6 +159,31 @@ func TestUpRecreateVolumes(t *testing.T) { res.Assert(t, icmd.Expected{Out: "zot"}) } +func TestUpRecreateVolumesAlsoRecreatesContainers(t *testing.T) { + c := NewCLI(t) + const projectName = "compose-e2e-recreate-vol-ctr" + t.Cleanup(func() { + c.cleanupWithDown(t, projectName) + }) + + c.RunDockerComposeCmd(t, "-f", "./fixtures/recreate-volumes/compose.yaml", + "--project-name", projectName, "up", "-d") + + // Get initial container ID + res := c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-app-1", projectName), "-f", "{{ .Id }}") + initialID := strings.TrimSpace(res.Stdout()) + + // Change volume config + auto-confirm → volume and container recreated + c.RunDockerComposeCmd(t, "-f", "./fixtures/recreate-volumes/compose2.yaml", + "--project-name", projectName, "up", "-d", "-y") + + // Container ID should have changed (container was removed and recreated + // because Docker requires container removal to delete a volume) + res = c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-app-1", projectName), "-f", "{{ .Id }}") + newID := strings.TrimSpace(res.Stdout()) + assert.Assert(t, newID != initialID, "expected container to be recreated after volume change") +} + func TestUpRecreateVolumes_IgnoreBinds(t *testing.T) { c := NewCLI(t) const projectName = "compose-e2e-recreate-volumes"