test: add strict zero-downtime ordering tests
Verify old container is never stopped before new readiness completes. Verify readiness failure leaves old container untouched. Verify first deploy works without existing container.
- commit
- 815f4feaa1e592eaf30a1bdeed811d79b242d74a
- author
- brice · · 2026-03-02 17:21 UTC
- parent
- 547a2f4
- signature
- unsigned
1 file · +251 -0
M internal/usecase/container/service_test.go +251 -0
| @@ -2904,3 +2904,254 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) { | |||
| 2904 | 2904 | assert.True(t, existsSS) | |
| 2905 | 2905 | assert.Equal(t, "new-container", trackedSS.ID) | |
| 2906 | 2906 | } | |
| 2907 | + | ||
| 2908 | + | // TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady verifies the strict | |
| 2909 | + | // ordering invariant: StopContainer("old-container") must NEVER happen before | |
| 2910 | + | // InspectContainer("new-container") completes (the last step of readiness). | |
| 2911 | + | // Uses testify's NotBefore() to enforce mock call ordering. | |
| 2912 | + | func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) { | |
| 2913 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2914 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2915 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2916 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 2917 | + | ||
| 2918 | + | config := Config{ | |
| 2919 | + | ReadinessDelay: time.Millisecond, | |
| 2920 | + | DrainDelay: time.Millisecond, | |
| 2921 | + | DrainDelayConfigured: true, | |
| 2922 | + | StabilizationDelay: time.Millisecond, | |
| 2923 | + | } | |
| 2924 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2925 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 2926 | + | ctx := testContext() | |
| 2927 | + | ||
| 2928 | + | // Pre-populate with existing tracked container | |
| 2929 | + | existingContainer := &domain.Container{ | |
| 2930 | + | ID: "old-container", | |
| 2931 | + | Name: "gordon-test.example.com", | |
| 2932 | + | Status: "running", | |
| 2933 | + | } | |
| 2934 | + | svc.containers["test.example.com"] = existingContainer | |
| 2935 | + | ||
| 2936 | + | route := domain.Route{ | |
| 2937 | + | Domain: "test.example.com", | |
| 2938 | + | Image: "myapp:v2", | |
| 2939 | + | } | |
| 2940 | + | ||
| 2941 | + | // Cleanup orphans | |
| 2942 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 2943 | + | ||
| 2944 | + | // Image operations | |
| 2945 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 2946 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 2947 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 2948 | + | ||
| 2949 | + | // Environment | |
| 2950 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2951 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 2952 | + | ||
| 2953 | + | // Create new container with -new suffix for zero-downtime | |
| 2954 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2955 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 2956 | + | return cfg.Name == "gordon-test.example.com-new" | |
| 2957 | + | })).Return(newContainer, nil) | |
| 2958 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2959 | + | ||
| 2960 | + | // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call) | |
| 2961 | + | // Stabilization check (1 call) | |
| 2962 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 2963 | + | ||
| 2964 | + | // KEY ORDERING CONSTRAINT: InspectContainer("new-container") is the last step | |
| 2965 | + | // of readiness inside createStartedContainer. StopContainer("old-container") | |
| 2966 | + | // must happen AFTER this call completes. | |
| 2967 | + | inspectCall := runtime.EXPECT().InspectContainer(mock.Anything, "new-container"). | |
| 2968 | + | Return(&domain.Container{ | |
| 2969 | + | ID: "new-container", | |
| 2970 | + | Name: "gordon-test.example.com-new", | |
| 2971 | + | Status: "running", | |
| 2972 | + | }, nil) | |
| 2973 | + | ||
| 2974 | + | // Publish event | |
| 2975 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 2976 | + | ||
| 2977 | + | // Synchronous cache invalidation | |
| 2978 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |
| 2979 | + | ||
| 2980 | + | // STRICT ORDERING: StopContainer on old container must NOT happen before | |
| 2981 | + | // InspectContainer on new container (readiness completion) | |
| 2982 | + | runtime.EXPECT().StopContainer(mock.Anything, "old-container"). | |
| 2983 | + | Return(nil). | |
| 2984 | + | NotBefore(inspectCall.Call) | |
| 2985 | + | ||
| 2986 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | |
| 2987 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| 2988 | + | ||
| 2989 | + | result, err := svc.Deploy(ctx, route) | |
| 2990 | + | ||
| 2991 | + | assert.NoError(t, err) | |
| 2992 | + | assert.NotNil(t, result) | |
| 2993 | + | assert.Equal(t, "new-container", result.ID) | |
| 2994 | + | ||
| 2995 | + | // Verify new container is tracked | |
| 2996 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |
| 2997 | + | assert.True(t, exists) | |
| 2998 | + | assert.Equal(t, "new-container", tracked.ID) | |
| 2999 | + | } | |
| 3000 | + | ||
| 3001 | + | // TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched verifies that when a | |
| 3002 | + | // new container fails readiness (IsContainerRunning returns false), the old container | |
| 3003 | + | // is completely untouched: never stopped, never removed. Deploy returns an error and | |
| 3004 | + | // the old container remains as the tracked container. | |
| 3005 | + | func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) { | |
| 3006 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 3007 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 3008 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 3009 | + | ||
| 3010 | + | config := Config{ | |
| 3011 | + | ReadinessDelay: time.Millisecond, | |
| 3012 | + | DrainDelay: time.Millisecond, | |
| 3013 | + | DrainDelayConfigured: true, | |
| 3014 | + | StabilizationDelay: time.Millisecond, | |
| 3015 | + | } | |
| 3016 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 3017 | + | ctx := testContext() | |
| 3018 | + | ||
| 3019 | + | // Pre-populate with existing tracked container | |
| 3020 | + | existingContainer := &domain.Container{ | |
| 3021 | + | ID: "old-container", | |
| 3022 | + | Name: "gordon-test.example.com", | |
| 3023 | + | Status: "running", | |
| 3024 | + | } | |
| 3025 | + | svc.containers["test.example.com"] = existingContainer | |
| 3026 | + | ||
| 3027 | + | route := domain.Route{ | |
| 3028 | + | Domain: "test.example.com", | |
| 3029 | + | Image: "myapp:v2", | |
| 3030 | + | } | |
| 3031 | + | ||
| 3032 | + | // Cleanup orphans | |
| 3033 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 3034 | + | ||
| 3035 | + | // Image operations | |
| 3036 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 3037 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 3038 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 3039 | + | ||
| 3040 | + | // Environment | |
| 3041 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 3042 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 3043 | + | ||
| 3044 | + | // Create new container | |
| 3045 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 3046 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 3047 | + | return cfg.Name == "gordon-test.example.com-new" | |
| 3048 | + | })).Return(newContainer, nil) | |
| 3049 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 3050 | + | ||
| 3051 | + | // Readiness FAILS: pollContainerRunning succeeds (container starts) but | |
| 3052 | + | // waitForReadyByDelay verification finds it not running. | |
| 3053 | + | // pollContainerRunning: returns true (container initially starts) | |
| 3054 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once() | |
| 3055 | + | // waitForReadyByDelay post-delay verification: returns false (container crashed) | |
| 3056 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once() | |
| 3057 | + | // Recovery window poll: return error to fail fast instead of waiting 30s | |
| 3058 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container"). | |
| 3059 | + | Return(false, errors.New("container exited")).Once() | |
| 3060 | + | ||
| 3061 | + | // cleanupFailedContainer: stop and remove the failed new container | |
| 3062 | + | runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil) | |
| 3063 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil) | |
| 3064 | + | ||
| 3065 | + | // NOTE: StopContainer("old-container") and RemoveContainer("old-container", true) | |
| 3066 | + | // are intentionally NOT mocked. If either is called, testify will panic with | |
| 3067 | + | // "unexpected method call" — verifying the old container is never touched. | |
| 3068 | + | ||
| 3069 | + | result, err := svc.Deploy(ctx, route) | |
| 3070 | + | ||
| 3071 | + | // Deploy should return an error (readiness failure) | |
| 3072 | + | assert.Error(t, err) | |
| 3073 | + | assert.Nil(t, result) | |
| 3074 | + | assert.Contains(t, err.Error(), "readiness check") | |
| 3075 | + | ||
| 3076 | + | // Old container must still be tracked — completely untouched | |
| 3077 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |
| 3078 | + | assert.True(t, exists, "old container should still be tracked after readiness failure") | |
| 3079 | + | assert.Equal(t, "old-container", tracked.ID, "old container ID should be unchanged") | |
| 3080 | + | } | |
| 3081 | + | ||
| 3082 | + | // TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy is a sanity check: | |
| 3083 | + | // first deploy with no existing container should work normally without any | |
| 3084 | + | // finalize/drain logic being triggered. | |
| 3085 | + | func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) { | |
| 3086 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 3087 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 3088 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 3089 | + | ||
| 3090 | + | config := Config{ | |
| 3091 | + | ReadinessDelay: time.Millisecond, | |
| 3092 | + | DrainDelay: time.Millisecond, | |
| 3093 | + | DrainDelayConfigured: true, | |
| 3094 | + | StabilizationDelay: time.Millisecond, | |
| 3095 | + | } | |
| 3096 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 3097 | + | ctx := testContext() | |
| 3098 | + | ||
| 3099 | + | // NO existing container — this is a first deploy | |
| 3100 | + | ||
| 3101 | + | route := domain.Route{ | |
| 3102 | + | Domain: "test.example.com", | |
| 3103 | + | Image: "myapp:v2", | |
| 3104 | + | } | |
| 3105 | + | ||
| 3106 | + | // resolveExistingContainer: no container in memory, runtime returns nothing | |
| 3107 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 3108 | + | ||
| 3109 | + | // Cleanup orphans — nothing found | |
| 3110 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 3111 | + | ||
| 3112 | + | // Image operations | |
| 3113 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 3114 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 3115 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 3116 | + | ||
| 3117 | + | // Environment | |
| 3118 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 3119 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 3120 | + | ||
| 3121 | + | // Create container — canonical name (no -new suffix since no existing container) | |
| 3122 | + | newContainer := &domain.Container{ID: "first-container", Name: "gordon-test.example.com", Status: "created"} | |
| 3123 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 3124 | + | return cfg.Name == "gordon-test.example.com" | |
| 3125 | + | })).Return(newContainer, nil) | |
| 3126 | + | runtime.EXPECT().StartContainer(mock.Anything, "first-container").Return(nil) | |
| 3127 | + | ||
| 3128 | + | // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call) | |
| 3129 | + | // No stabilization check for first deploy (hasExisting is false) | |
| 3130 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "first-container").Return(true, nil).Times(2) | |
| 3131 | + | ||
| 3132 | + | // Inspect after readiness | |
| 3133 | + | runtime.EXPECT().InspectContainer(mock.Anything, "first-container").Return(&domain.Container{ | |
| 3134 | + | ID: "first-container", | |
| 3135 | + | Name: "gordon-test.example.com", | |
| 3136 | + | Status: "running", | |
| 3137 | + | Ports: []int{8080}, | |
| 3138 | + | }, nil) | |
| 3139 | + | ||
| 3140 | + | // Publish event | |
| 3141 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 3142 | + | ||
| 3143 | + | // NOTE: No StopContainer, RemoveContainer, or RenameContainer expectations — | |
| 3144 | + | // first deploy has no previous container to finalize, and no stabilization check. | |
| 3145 | + | ||
| 3146 | + | result, err := svc.Deploy(ctx, route) | |
| 3147 | + | ||
| 3148 | + | assert.NoError(t, err) | |
| 3149 | + | assert.NotNil(t, result) | |
| 3150 | + | assert.Equal(t, "first-container", result.ID) | |
| 3151 | + | assert.Equal(t, "running", result.Status) | |
| 3152 | + | ||
| 3153 | + | // Verify container is tracked | |
| 3154 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |
| 3155 | + | assert.True(t, exists) | |
| 3156 | + | assert.Equal(t, "first-container", tracked.ID) | |
| 3157 | + | } | |
| @@ -2904,3 +2904,254 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) { | |||||
| 2904 | assert.True(t, existsSS) | 2904 | assert.True(t, existsSS) | ||
| 2905 | assert.Equal(t, "new-container", trackedSS.ID) | 2905 | assert.Equal(t, "new-container", trackedSS.ID) | ||
| 2906 | } | 2906 | } | ||
| 2907 | + | ||||
| 2908 | + | // TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady verifies the strict | |||
| 2909 | + | // ordering invariant: StopContainer("old-container") must NEVER happen before | |||
| 2910 | + | // InspectContainer("new-container") completes (the last step of readiness). | |||
| 2911 | + | // Uses testify's NotBefore() to enforce mock call ordering. | |||
| 2912 | + | func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) { | |||
| 2913 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2914 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2915 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2916 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 2917 | + | ||||
| 2918 | + | config := Config{ | |||
| 2919 | + | ReadinessDelay: time.Millisecond, | |||
| 2920 | + | DrainDelay: time.Millisecond, | |||
| 2921 | + | DrainDelayConfigured: true, | |||
| 2922 | + | StabilizationDelay: time.Millisecond, | |||
| 2923 | + | } | |||
| 2924 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 2925 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |||
| 2926 | + | ctx := testContext() | |||
| 2927 | + | ||||
| 2928 | + | // Pre-populate with existing tracked container | |||
| 2929 | + | existingContainer := &domain.Container{ | |||
| 2930 | + | ID: "old-container", | |||
| 2931 | + | Name: "gordon-test.example.com", | |||
| 2932 | + | Status: "running", | |||
| 2933 | + | } | |||
| 2934 | + | svc.containers["test.example.com"] = existingContainer | |||
| 2935 | + | ||||
| 2936 | + | route := domain.Route{ | |||
| 2937 | + | Domain: "test.example.com", | |||
| 2938 | + | Image: "myapp:v2", | |||
| 2939 | + | } | |||
| 2940 | + | ||||
| 2941 | + | // Cleanup orphans | |||
| 2942 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 2943 | + | ||||
| 2944 | + | // Image operations | |||
| 2945 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 2946 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 2947 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 2948 | + | ||||
| 2949 | + | // Environment | |||
| 2950 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2951 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 2952 | + | ||||
| 2953 | + | // Create new container with -new suffix for zero-downtime | |||
| 2954 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 2955 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 2956 | + | return cfg.Name == "gordon-test.example.com-new" | |||
| 2957 | + | })).Return(newContainer, nil) | |||
| 2958 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 2959 | + | ||||
| 2960 | + | // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call) | |||
| 2961 | + | // Stabilization check (1 call) | |||
| 2962 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 2963 | + | ||||
| 2964 | + | // KEY ORDERING CONSTRAINT: InspectContainer("new-container") is the last step | |||
| 2965 | + | // of readiness inside createStartedContainer. StopContainer("old-container") | |||
| 2966 | + | // must happen AFTER this call completes. | |||
| 2967 | + | inspectCall := runtime.EXPECT().InspectContainer(mock.Anything, "new-container"). | |||
| 2968 | + | Return(&domain.Container{ | |||
| 2969 | + | ID: "new-container", | |||
| 2970 | + | Name: "gordon-test.example.com-new", | |||
| 2971 | + | Status: "running", | |||
| 2972 | + | }, nil) | |||
| 2973 | + | ||||
| 2974 | + | // Publish event | |||
| 2975 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 2976 | + | ||||
| 2977 | + | // Synchronous cache invalidation | |||
| 2978 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |||
| 2979 | + | ||||
| 2980 | + | // STRICT ORDERING: StopContainer on old container must NOT happen before | |||
| 2981 | + | // InspectContainer on new container (readiness completion) | |||
| 2982 | + | runtime.EXPECT().StopContainer(mock.Anything, "old-container"). | |||
| 2983 | + | Return(nil). | |||
| 2984 | + | NotBefore(inspectCall.Call) | |||
| 2985 | + | ||||
| 2986 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | |||
| 2987 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |||
| 2988 | + | ||||
| 2989 | + | result, err := svc.Deploy(ctx, route) | |||
| 2990 | + | ||||
| 2991 | + | assert.NoError(t, err) | |||
| 2992 | + | assert.NotNil(t, result) | |||
| 2993 | + | assert.Equal(t, "new-container", result.ID) | |||
| 2994 | + | ||||
| 2995 | + | // Verify new container is tracked | |||
| 2996 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |||
| 2997 | + | assert.True(t, exists) | |||
| 2998 | + | assert.Equal(t, "new-container", tracked.ID) | |||
| 2999 | + | } | |||
| 3000 | + | ||||
| 3001 | + | // TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched verifies that when a | |||
| 3002 | + | // new container fails readiness (IsContainerRunning returns false), the old container | |||
| 3003 | + | // is completely untouched: never stopped, never removed. Deploy returns an error and | |||
| 3004 | + | // the old container remains as the tracked container. | |||
| 3005 | + | func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) { | |||
| 3006 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 3007 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 3008 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 3009 | + | ||||
| 3010 | + | config := Config{ | |||
| 3011 | + | ReadinessDelay: time.Millisecond, | |||
| 3012 | + | DrainDelay: time.Millisecond, | |||
| 3013 | + | DrainDelayConfigured: true, | |||
| 3014 | + | StabilizationDelay: time.Millisecond, | |||
| 3015 | + | } | |||
| 3016 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 3017 | + | ctx := testContext() | |||
| 3018 | + | ||||
| 3019 | + | // Pre-populate with existing tracked container | |||
| 3020 | + | existingContainer := &domain.Container{ | |||
| 3021 | + | ID: "old-container", | |||
| 3022 | + | Name: "gordon-test.example.com", | |||
| 3023 | + | Status: "running", | |||
| 3024 | + | } | |||
| 3025 | + | svc.containers["test.example.com"] = existingContainer | |||
| 3026 | + | ||||
| 3027 | + | route := domain.Route{ | |||
| 3028 | + | Domain: "test.example.com", | |||
| 3029 | + | Image: "myapp:v2", | |||
| 3030 | + | } | |||
| 3031 | + | ||||
| 3032 | + | // Cleanup orphans | |||
| 3033 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 3034 | + | ||||
| 3035 | + | // Image operations | |||
| 3036 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 3037 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 3038 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 3039 | + | ||||
| 3040 | + | // Environment | |||
| 3041 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 3042 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 3043 | + | ||||
| 3044 | + | // Create new container | |||
| 3045 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 3046 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 3047 | + | return cfg.Name == "gordon-test.example.com-new" | |||
| 3048 | + | })).Return(newContainer, nil) | |||
| 3049 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 3050 | + | ||||
| 3051 | + | // Readiness FAILS: pollContainerRunning succeeds (container starts) but | |||
| 3052 | + | // waitForReadyByDelay verification finds it not running. | |||
| 3053 | + | // pollContainerRunning: returns true (container initially starts) | |||
| 3054 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once() | |||
| 3055 | + | // waitForReadyByDelay post-delay verification: returns false (container crashed) | |||
| 3056 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once() | |||
| 3057 | + | // Recovery window poll: return error to fail fast instead of waiting 30s | |||
| 3058 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container"). | |||
| 3059 | + | Return(false, errors.New("container exited")).Once() | |||
| 3060 | + | ||||
| 3061 | + | // cleanupFailedContainer: stop and remove the failed new container | |||
| 3062 | + | runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil) | |||
| 3063 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil) | |||
| 3064 | + | ||||
| 3065 | + | // NOTE: StopContainer("old-container") and RemoveContainer("old-container", true) | |||
| 3066 | + | // are intentionally NOT mocked. If either is called, testify will panic with | |||
| 3067 | + | // "unexpected method call" — verifying the old container is never touched. | |||
| 3068 | + | ||||
| 3069 | + | result, err := svc.Deploy(ctx, route) | |||
| 3070 | + | ||||
| 3071 | + | // Deploy should return an error (readiness failure) | |||
| 3072 | + | assert.Error(t, err) | |||
| 3073 | + | assert.Nil(t, result) | |||
| 3074 | + | assert.Contains(t, err.Error(), "readiness check") | |||
| 3075 | + | ||||
| 3076 | + | // Old container must still be tracked — completely untouched | |||
| 3077 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |||
| 3078 | + | assert.True(t, exists, "old container should still be tracked after readiness failure") | |||
| 3079 | + | assert.Equal(t, "old-container", tracked.ID, "old container ID should be unchanged") | |||
| 3080 | + | } | |||
| 3081 | + | ||||
| 3082 | + | // TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy is a sanity check: | |||
| 3083 | + | // first deploy with no existing container should work normally without any | |||
| 3084 | + | // finalize/drain logic being triggered. | |||
| 3085 | + | func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) { | |||
| 3086 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 3087 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 3088 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 3089 | + | ||||
| 3090 | + | config := Config{ | |||
| 3091 | + | ReadinessDelay: time.Millisecond, | |||
| 3092 | + | DrainDelay: time.Millisecond, | |||
| 3093 | + | DrainDelayConfigured: true, | |||
| 3094 | + | StabilizationDelay: time.Millisecond, | |||
| 3095 | + | } | |||
| 3096 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 3097 | + | ctx := testContext() | |||
| 3098 | + | ||||
| 3099 | + | // NO existing container — this is a first deploy | |||
| 3100 | + | ||||
| 3101 | + | route := domain.Route{ | |||
| 3102 | + | Domain: "test.example.com", | |||
| 3103 | + | Image: "myapp:v2", | |||
| 3104 | + | } | |||
| 3105 | + | ||||
| 3106 | + | // resolveExistingContainer: no container in memory, runtime returns nothing | |||
| 3107 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 3108 | + | ||||
| 3109 | + | // Cleanup orphans — nothing found | |||
| 3110 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 3111 | + | ||||
| 3112 | + | // Image operations | |||
| 3113 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 3114 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 3115 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 3116 | + | ||||
| 3117 | + | // Environment | |||
| 3118 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 3119 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 3120 | + | ||||
| 3121 | + | // Create container — canonical name (no -new suffix since no existing container) | |||
| 3122 | + | newContainer := &domain.Container{ID: "first-container", Name: "gordon-test.example.com", Status: "created"} | |||
| 3123 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 3124 | + | return cfg.Name == "gordon-test.example.com" | |||
| 3125 | + | })).Return(newContainer, nil) | |||
| 3126 | + | runtime.EXPECT().StartContainer(mock.Anything, "first-container").Return(nil) | |||
| 3127 | + | ||||
| 3128 | + | // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call) | |||
| 3129 | + | // No stabilization check for first deploy (hasExisting is false) | |||
| 3130 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "first-container").Return(true, nil).Times(2) | |||
| 3131 | + | ||||
| 3132 | + | // Inspect after readiness | |||
| 3133 | + | runtime.EXPECT().InspectContainer(mock.Anything, "first-container").Return(&domain.Container{ | |||
| 3134 | + | ID: "first-container", | |||
| 3135 | + | Name: "gordon-test.example.com", | |||
| 3136 | + | Status: "running", | |||
| 3137 | + | Ports: []int{8080}, | |||
| 3138 | + | }, nil) | |||
| 3139 | + | ||||
| 3140 | + | // Publish event | |||
| 3141 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 3142 | + | ||||
| 3143 | + | // NOTE: No StopContainer, RemoveContainer, or RenameContainer expectations — | |||
| 3144 | + | // first deploy has no previous container to finalize, and no stabilization check. | |||
| 3145 | + | ||||
| 3146 | + | result, err := svc.Deploy(ctx, route) | |||
| 3147 | + | ||||
| 3148 | + | assert.NoError(t, err) | |||
| 3149 | + | assert.NotNil(t, result) | |||
| 3150 | + | assert.Equal(t, "first-container", result.ID) | |||
| 3151 | + | assert.Equal(t, "running", result.Status) | |||
| 3152 | + | ||||
| 3153 | + | // Verify container is tracked | |||
| 3154 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |||
| 3155 | + | assert.True(t, exists) | |||
| 3156 | + | assert.Equal(t, "first-container", tracked.ID) | |||
| 3157 | + | } | |||