fix: orphan cleanup never kills running canonical container
Running containers with the canonical name are now preserved during orphan cleanup even if not tracked in memory. Only stopped canonical containers and temp (-new/-next) containers are cleaned up.
- commit
- 867c2558fced85aa546f09e465274bc02e2c7415
- author
- brice · · 2026-03-02 16:20 UTC
- parent
- 3efe7b9
- signature
- unsigned
2 files · +109 -14
M internal/usecase/container/service.go +12 -0
| @@ -1656,6 +1656,18 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri | |||
| 1656 | 1656 | ||
| 1657 | 1657 | for _, c := range allContainers { | |
| 1658 | 1658 | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | |
| 1659 | + | // Never kill a running container with the canonical name — it may be | |
| 1660 | + | // the active container serving traffic. Only temp (-new/-next) containers | |
| 1661 | + | // and stopped canonical containers are safe to remove. | |
| 1662 | + | isCanonical := c.Name == expectedName | |
| 1663 | + | if isCanonical && c.Status == "running" { | |
| 1664 | + | log.Debug(). | |
| 1665 | + | Str(zerowrap.FieldEntityID, c.ID). | |
| 1666 | + | Str(zerowrap.FieldStatus, c.Status). | |
| 1667 | + | Msg("skipping running canonical container during orphan cleanup") | |
| 1668 | + | continue | |
| 1669 | + | } | |
| 1670 | + | ||
| 1659 | 1671 | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | |
| 1660 | 1672 | ||
| 1661 | 1673 | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | |
| @@ -1656,6 +1656,18 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri | |||||
| 1656 | 1656 | ||||
| 1657 | for _, c := range allContainers { | 1657 | for _, c := range allContainers { | ||
| 1658 | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | 1658 | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | ||
| 1659 | + | // Never kill a running container with the canonical name — it may be | |||
| 1660 | + | // the active container serving traffic. Only temp (-new/-next) containers | |||
| 1661 | + | // and stopped canonical containers are safe to remove. | |||
| 1662 | + | isCanonical := c.Name == expectedName | |||
| 1663 | + | if isCanonical && c.Status == "running" { | |||
| 1664 | + | log.Debug(). | |||
| 1665 | + | Str(zerowrap.FieldEntityID, c.ID). | |||
| 1666 | + | Str(zerowrap.FieldStatus, c.Status). | |||
| 1667 | + | Msg("skipping running canonical container during orphan cleanup") | |||
| 1668 | + | continue | |||
| 1669 | + | } | |||
| 1670 | + | ||||
| 1659 | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | 1671 | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | ||
| 1660 | 1672 | ||||
| 1661 | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | 1673 | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | ||
M internal/usecase/container/service_test.go +97 -14
| @@ -1748,8 +1748,9 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||
| 1748 | 1748 | assert.Equal(t, "new-container", tracked.ID) | |
| 1749 | 1749 | } | |
| 1750 | 1750 | ||
| 1751 | - | // TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers | |
| 1752 | - | // with the same name but NOT tracked are properly removed as orphans. | |
| 1751 | + | // TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that stopped canonical | |
| 1752 | + | // containers with the same name but NOT tracked are properly removed as orphans. | |
| 1753 | + | // Running canonical containers are preserved (see NeverKillsRunningCanonical test). | |
| 1753 | 1754 | func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |
| 1754 | 1755 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1755 | 1756 | envLoader := mocks.NewMockEnvLoader(t) | |
| @@ -1770,26 +1771,20 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |||
| 1770 | 1771 | } | |
| 1771 | 1772 | ||
| 1772 | 1773 | // No container in memory — runtime resolution finds no managed container | |
| 1773 | - | // (the orphan lacks LabelManaged so it won't match) | |
| 1774 | - | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |
| 1775 | - | { | |
| 1776 | - | ID: "orphan-container", | |
| 1777 | - | Name: "gordon-test.example.com", | |
| 1778 | - | Status: "running", | |
| 1779 | - | }, | |
| 1780 | - | }, nil) | |
| 1774 | + | // (the orphan is stopped so it won't appear in running-only query) | |
| 1775 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 1781 | 1776 | ||
| 1782 | - | // ListContainers returns an orphaned container (same name, but not tracked) | |
| 1783 | - | // This could happen if Gordon crashed and restarted, or container was created manually | |
| 1777 | + | // ListContainers(true) returns a STOPPED orphaned container (same name, but not tracked) | |
| 1778 | + | // Stopped canonical containers are safe to remove during orphan cleanup | |
| 1784 | 1779 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |
| 1785 | 1780 | { | |
| 1786 | 1781 | ID: "orphan-container", | |
| 1787 | 1782 | Name: "gordon-test.example.com", | |
| 1788 | - | Status: "running", | |
| 1783 | + | Status: "exited", | |
| 1789 | 1784 | }, | |
| 1790 | 1785 | }, nil) | |
| 1791 | 1786 | ||
| 1792 | - | // Orphan should be stopped and removed BEFORE we proceed | |
| 1787 | + | // Stopped orphan should be stopped and removed BEFORE we proceed | |
| 1793 | 1788 | runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil) | |
| 1794 | 1789 | runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil) | |
| 1795 | 1790 | ||
| @@ -2629,3 +2624,91 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||
| 2629 | 2624 | assert.NoError(t, err) | |
| 2630 | 2625 | assert.Equal(t, "new-container", result.ID) | |
| 2631 | 2626 | } | |
| 2627 | + | ||
| 2628 | + | // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan | |
| 2629 | + | // cleanup never stops a running container with the canonical name, even if it is not | |
| 2630 | + | // tracked in memory and was not resolved by resolveExistingContainer. Only stopped | |
| 2631 | + | // canonical containers and temp containers (-new/-next) should be cleaned up. | |
| 2632 | + | func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) { | |
| 2633 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2634 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2635 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2636 | + | ||
| 2637 | + | config := Config{ | |
| 2638 | + | ReadinessDelay: time.Millisecond, | |
| 2639 | + | DrainDelay: time.Millisecond, | |
| 2640 | + | } | |
| 2641 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2642 | + | ctx := testContext() | |
| 2643 | + | ||
| 2644 | + | // NO tracked container in memory. resolveExistingContainer will NOT find it either | |
| 2645 | + | // because the running container lacks the managed label in this edge case. | |
| 2646 | + | ||
| 2647 | + | route := domain.Route{ | |
| 2648 | + | Domain: "test.example.com", | |
| 2649 | + | Image: "myapp:v2", | |
| 2650 | + | } | |
| 2651 | + | ||
| 2652 | + | // resolveExistingContainer: ListContainers(ctx, false) returns empty — | |
| 2653 | + | // the canonical container is running but not discovered (e.g., missing label) | |
| 2654 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 2655 | + | ||
| 2656 | + | // cleanupOrphanedContainers: ListContainers(ctx, true) finds both the running | |
| 2657 | + | // canonical container AND a stale -new leftover. The canonical container should | |
| 2658 | + | // NOT be killed because it's running and has the canonical name. | |
| 2659 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |
| 2660 | + | { | |
| 2661 | + | ID: "active-canonical", | |
| 2662 | + | Name: "gordon-test.example.com", | |
| 2663 | + | Status: "running", | |
| 2664 | + | }, | |
| 2665 | + | { | |
| 2666 | + | ID: "stale-leftover", | |
| 2667 | + | Name: "gordon-test.example.com-new", | |
| 2668 | + | Status: "exited", | |
| 2669 | + | }, | |
| 2670 | + | }, nil) | |
| 2671 | + | ||
| 2672 | + | // Only the stale -new container should be stopped and removed during orphan cleanup | |
| 2673 | + | runtime.EXPECT().StopContainer(mock.Anything, "stale-leftover").Return(nil).Once() | |
| 2674 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "stale-leftover", true).Return(nil).Once() | |
| 2675 | + | ||
| 2676 | + | // Image operations | |
| 2677 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 2678 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 2679 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 2680 | + | ||
| 2681 | + | // Environment | |
| 2682 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2683 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 2684 | + | ||
| 2685 | + | // Create container — no existing tracked, so canonical name is used | |
| 2686 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"} | |
| 2687 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 2688 | + | return cfg.Name == "gordon-test.example.com" | |
| 2689 | + | })).Return(newContainer, nil) | |
| 2690 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2691 | + | ||
| 2692 | + | // Wait for ready | |
| 2693 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 2694 | + | ||
| 2695 | + | // Inspect after ready | |
| 2696 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2697 | + | ID: "new-container", | |
| 2698 | + | Status: "running", | |
| 2699 | + | Ports: []int{8080}, | |
| 2700 | + | }, nil) | |
| 2701 | + | ||
| 2702 | + | // Publish event | |
| 2703 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 2704 | + | ||
| 2705 | + | result, err := svc.Deploy(ctx, route) | |
| 2706 | + | ||
| 2707 | + | assert.NoError(t, err) | |
| 2708 | + | assert.Equal(t, "new-container", result.ID) | |
| 2709 | + | ||
| 2710 | + | // Verify new container is tracked | |
| 2711 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |
| 2712 | + | assert.True(t, exists) | |
| 2713 | + | assert.Equal(t, "new-container", tracked.ID) | |
| 2714 | + | } | |
| @@ -1748,8 +1748,9 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||||
| 1748 | assert.Equal(t, "new-container", tracked.ID) | 1748 | assert.Equal(t, "new-container", tracked.ID) | ||
| 1749 | } | 1749 | } | ||
| 1750 | 1750 | ||||
| 1751 | - | // TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers | 1751 | + | // TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that stopped canonical |
| 1752 | - | // with the same name but NOT tracked are properly removed as orphans. | 1752 | + | // containers with the same name but NOT tracked are properly removed as orphans. |
| 1753 | + | // Running canonical containers are preserved (see NeverKillsRunningCanonical test). | |||
| 1753 | func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | 1754 | func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | ||
| 1754 | runtime := mocks.NewMockContainerRuntime(t) | 1755 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 1755 | envLoader := mocks.NewMockEnvLoader(t) | 1756 | envLoader := mocks.NewMockEnvLoader(t) | ||
| @@ -1770,26 +1771,20 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |||||
| 1770 | } | 1771 | } | ||
| 1771 | 1772 | ||||
| 1772 | // No container in memory — runtime resolution finds no managed container | 1773 | // No container in memory — runtime resolution finds no managed container | ||
| 1773 | - | // (the orphan lacks LabelManaged so it won't match) | 1774 | + | // (the orphan is stopped so it won't appear in running-only query) |
| 1774 | - | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | 1775 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) |
| 1775 | - | { | |||
| 1776 | - | ID: "orphan-container", | |||
| 1777 | - | Name: "gordon-test.example.com", | |||
| 1778 | - | Status: "running", | |||
| 1779 | - | }, | |||
| 1780 | - | }, nil) | |||
| 1781 | 1776 | ||||
| 1782 | - | // ListContainers returns an orphaned container (same name, but not tracked) | 1777 | + | // ListContainers(true) returns a STOPPED orphaned container (same name, but not tracked) |
| 1783 | - | // This could happen if Gordon crashed and restarted, or container was created manually | 1778 | + | // Stopped canonical containers are safe to remove during orphan cleanup |
| 1784 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | 1779 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | ||
| 1785 | { | 1780 | { | ||
| 1786 | ID: "orphan-container", | 1781 | ID: "orphan-container", | ||
| 1787 | Name: "gordon-test.example.com", | 1782 | Name: "gordon-test.example.com", | ||
| 1788 | - | Status: "running", | 1783 | + | Status: "exited", |
| 1789 | }, | 1784 | }, | ||
| 1790 | }, nil) | 1785 | }, nil) | ||
| 1791 | 1786 | ||||
| 1792 | - | // Orphan should be stopped and removed BEFORE we proceed | 1787 | + | // Stopped orphan should be stopped and removed BEFORE we proceed |
| 1793 | runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil) | 1788 | runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil) | ||
| 1794 | runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil) | 1789 | runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil) | ||
| 1795 | 1790 | ||||
| @@ -2629,3 +2624,91 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||||
| 2629 | assert.NoError(t, err) | 2624 | assert.NoError(t, err) | ||
| 2630 | assert.Equal(t, "new-container", result.ID) | 2625 | assert.Equal(t, "new-container", result.ID) | ||
| 2631 | } | 2626 | } | ||
| 2627 | + | ||||
| 2628 | + | // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan | |||
| 2629 | + | // cleanup never stops a running container with the canonical name, even if it is not | |||
| 2630 | + | // tracked in memory and was not resolved by resolveExistingContainer. Only stopped | |||
| 2631 | + | // canonical containers and temp containers (-new/-next) should be cleaned up. | |||
| 2632 | + | func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) { | |||
| 2633 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2634 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2635 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2636 | + | ||||
| 2637 | + | config := Config{ | |||
| 2638 | + | ReadinessDelay: time.Millisecond, | |||
| 2639 | + | DrainDelay: time.Millisecond, | |||
| 2640 | + | } | |||
| 2641 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 2642 | + | ctx := testContext() | |||
| 2643 | + | ||||
| 2644 | + | // NO tracked container in memory. resolveExistingContainer will NOT find it either | |||
| 2645 | + | // because the running container lacks the managed label in this edge case. | |||
| 2646 | + | ||||
| 2647 | + | route := domain.Route{ | |||
| 2648 | + | Domain: "test.example.com", | |||
| 2649 | + | Image: "myapp:v2", | |||
| 2650 | + | } | |||
| 2651 | + | ||||
| 2652 | + | // resolveExistingContainer: ListContainers(ctx, false) returns empty — | |||
| 2653 | + | // the canonical container is running but not discovered (e.g., missing label) | |||
| 2654 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 2655 | + | ||||
| 2656 | + | // cleanupOrphanedContainers: ListContainers(ctx, true) finds both the running | |||
| 2657 | + | // canonical container AND a stale -new leftover. The canonical container should | |||
| 2658 | + | // NOT be killed because it's running and has the canonical name. | |||
| 2659 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |||
| 2660 | + | { | |||
| 2661 | + | ID: "active-canonical", | |||
| 2662 | + | Name: "gordon-test.example.com", | |||
| 2663 | + | Status: "running", | |||
| 2664 | + | }, | |||
| 2665 | + | { | |||
| 2666 | + | ID: "stale-leftover", | |||
| 2667 | + | Name: "gordon-test.example.com-new", | |||
| 2668 | + | Status: "exited", | |||
| 2669 | + | }, | |||
| 2670 | + | }, nil) | |||
| 2671 | + | ||||
| 2672 | + | // Only the stale -new container should be stopped and removed during orphan cleanup | |||
| 2673 | + | runtime.EXPECT().StopContainer(mock.Anything, "stale-leftover").Return(nil).Once() | |||
| 2674 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "stale-leftover", true).Return(nil).Once() | |||
| 2675 | + | ||||
| 2676 | + | // Image operations | |||
| 2677 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 2678 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 2679 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 2680 | + | ||||
| 2681 | + | // Environment | |||
| 2682 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2683 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 2684 | + | ||||
| 2685 | + | // Create container — no existing tracked, so canonical name is used | |||
| 2686 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"} | |||
| 2687 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 2688 | + | return cfg.Name == "gordon-test.example.com" | |||
| 2689 | + | })).Return(newContainer, nil) | |||
| 2690 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 2691 | + | ||||
| 2692 | + | // Wait for ready | |||
| 2693 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |||
| 2694 | + | ||||
| 2695 | + | // Inspect after ready | |||
| 2696 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 2697 | + | ID: "new-container", | |||
| 2698 | + | Status: "running", | |||
| 2699 | + | Ports: []int{8080}, | |||
| 2700 | + | }, nil) | |||
| 2701 | + | ||||
| 2702 | + | // Publish event | |||
| 2703 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 2704 | + | ||||
| 2705 | + | result, err := svc.Deploy(ctx, route) | |||
| 2706 | + | ||||
| 2707 | + | assert.NoError(t, err) | |||
| 2708 | + | assert.Equal(t, "new-container", result.ID) | |||
| 2709 | + | ||||
| 2710 | + | // Verify new container is tracked | |||
| 2711 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |||
| 2712 | + | assert.True(t, exists) | |||
| 2713 | + | assert.Equal(t, "new-container", tracked.ID) | |||
| 2714 | + | } | |||