detached 815f4fe

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) {
29042904 assert.True(t, existsSS)
29052905 assert.Equal(t, "new-container", trackedSS.ID)
29062906 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close