detached e1ccc72

Merge pull request #71 from bnema/fix/skip-redundant-deploy

fix(container): skip redundant deploy when container already runs the same image
commit
e1ccc72ed07c3a211c8b5abf700bb4d9b25a0f3f
author
bnema · · 2026-02-09 21:02 UTC
committer
GitHub · · 2026-02-09 21:02 UTC
merge of
c65c4e4 + b6c95ba · diff against the first parent
signature
unsigned
6 files · +370 -9
M internal/adapters/out/docker/runtime.go +26 -6
@@ -305,12 +305,13 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
305305 }
306306
307307 result = append(result, &domain.Container{
308- ID: c.ID,
309- Image: c.Image,
310- Name: name,
311- Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
312- Ports: ports,
313- Labels: c.Labels,
308+ ID: c.ID,
309+ Image: c.Image,
310+ ImageID: c.ImageID,
311+ Name: name,
312+ Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
313+ Ports: ports,
314+ Labels: c.Labels,
314315 })
315316 }
316317
@@ -352,6 +353,7 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
352353 return &domain.Container{
353354 ID: resp.ID,
354355 Image: resp.Config.Image,
356+ ImageID: resp.Image,
355357 Name: name,
356358 Status: resp.State.Status,
357359 ExitCode: resp.State.ExitCode,
@@ -1103,6 +1105,24 @@ func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[stri
11031105 return labels, nil
11041106 }
11051107
1108+// GetImageID returns the unique image ID (sha256 digest) for the given image reference.
1109+func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, error) {
1110+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1111+ zerowrap.FieldLayer: "adapter",
1112+ zerowrap.FieldAdapter: "docker",
1113+ zerowrap.FieldAction: "GetImageID",
1114+ "image": imageRef,
1115+ })
1116+ log := zerowrap.FromCtx(ctx)
1117+
1118+ imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1119+ if err != nil {
1120+ return "", log.WrapErr(err, "failed to inspect image")
1121+ }
1122+
1123+ return imageInspect.ID, nil
1124+}
1125+
11061126 // CreateNetwork creates a new Docker network.
11071127 func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
11081128 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -305,12 +305,13 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
305 }305 }
306 306
307 result = append(result, &domain.Container{307 result = append(result, &domain.Container{
308- ID: c.ID,308+ ID: c.ID,
309- Image: c.Image,309+ Image: c.Image,
310- Name: name,310+ ImageID: c.ImageID,
311- Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")311+ Name: name,
312- Ports: ports,312+ Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
313- Labels: c.Labels,313+ Ports: ports,
314+ Labels: c.Labels,
314 })315 })
315 }316 }
316 317
@@ -352,6 +353,7 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
352 return &domain.Container{353 return &domain.Container{
353 ID: resp.ID,354 ID: resp.ID,
354 Image: resp.Config.Image,355 Image: resp.Config.Image,
356+ ImageID: resp.Image,
355 Name: name,357 Name: name,
356 Status: resp.State.Status,358 Status: resp.State.Status,
357 ExitCode: resp.State.ExitCode,359 ExitCode: resp.State.ExitCode,
@@ -1103,6 +1105,24 @@ func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[stri
1103 return labels, nil1105 return labels, nil
1104 }1106 }
1105 1107
1108+// GetImageID returns the unique image ID (sha256 digest) for the given image reference.
1109+func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, error) {
1110+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1111+ zerowrap.FieldLayer: "adapter",
1112+ zerowrap.FieldAdapter: "docker",
1113+ zerowrap.FieldAction: "GetImageID",
1114+ "image": imageRef,
1115+ })
1116+ log := zerowrap.FromCtx(ctx)
1117+
1118+ imageInspect, err := r.client.ImageInspect(ctx, imageRef)
1119+ if err != nil {
1120+ return "", log.WrapErr(err, "failed to inspect image")
1121+ }
1122+
1123+ return imageInspect.ID, nil
1124+}
1125+
1106 // CreateNetwork creates a new Docker network.1126 // CreateNetwork creates a new Docker network.
1107 func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {1127 func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
1108 ctx = zerowrap.CtxWithFields(ctx, map[string]any{1128 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/boundaries/out/mocks/mock_container_runtime.go +66 -0
@@ -994,6 +994,72 @@ func (_c *MockContainerRuntime_GetImageExposedPorts_Call) RunAndReturn(run func(
994994 return _c
995995 }
996996
997+// GetImageID provides a mock function for the type MockContainerRuntime
998+func (_mock *MockContainerRuntime) GetImageID(ctx context.Context, imageRef string) (string, error) {
999+ ret := _mock.Called(ctx, imageRef)
1000+
1001+ if len(ret) == 0 {
1002+ panic("no return value specified for GetImageID")
1003+ }
1004+
1005+ var r0 string
1006+ var r1 error
1007+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok {
1008+ return returnFunc(ctx, imageRef)
1009+ }
1010+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
1011+ r0 = returnFunc(ctx, imageRef)
1012+ } else {
1013+ r0 = ret.Get(0).(string)
1014+ }
1015+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
1016+ r1 = returnFunc(ctx, imageRef)
1017+ } else {
1018+ r1 = ret.Error(1)
1019+ }
1020+ return r0, r1
1021+}
1022+
1023+// MockContainerRuntime_GetImageID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetImageID'
1024+type MockContainerRuntime_GetImageID_Call struct {
1025+ *mock.Call
1026+}
1027+
1028+// GetImageID is a helper method to define mock.On call
1029+// - ctx context.Context
1030+// - imageRef string
1031+func (_e *MockContainerRuntime_Expecter) GetImageID(ctx interface{}, imageRef interface{}) *MockContainerRuntime_GetImageID_Call {
1032+ return &MockContainerRuntime_GetImageID_Call{Call: _e.mock.On("GetImageID", ctx, imageRef)}
1033+}
1034+
1035+func (_c *MockContainerRuntime_GetImageID_Call) Run(run func(ctx context.Context, imageRef string)) *MockContainerRuntime_GetImageID_Call {
1036+ _c.Call.Run(func(args mock.Arguments) {
1037+ var arg0 context.Context
1038+ if args[0] != nil {
1039+ arg0 = args[0].(context.Context)
1040+ }
1041+ var arg1 string
1042+ if args[1] != nil {
1043+ arg1 = args[1].(string)
1044+ }
1045+ run(
1046+ arg0,
1047+ arg1,
1048+ )
1049+ })
1050+ return _c
1051+}
1052+
1053+func (_c *MockContainerRuntime_GetImageID_Call) Return(s string, err error) *MockContainerRuntime_GetImageID_Call {
1054+ _c.Call.Return(s, err)
1055+ return _c
1056+}
1057+
1058+func (_c *MockContainerRuntime_GetImageID_Call) RunAndReturn(run func(ctx context.Context, imageRef string) (string, error)) *MockContainerRuntime_GetImageID_Call {
1059+ _c.Call.Return(run)
1060+ return _c
1061+}
1062+
9971063 // GetImageLabels provides a mock function for the type MockContainerRuntime
9981064 func (_mock *MockContainerRuntime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {
9991065 ret := _mock.Called(ctx, imageRef)
@@ -994,6 +994,72 @@ func (_c *MockContainerRuntime_GetImageExposedPorts_Call) RunAndReturn(run func(
994 return _c994 return _c
995 }995 }
996 996
997+// GetImageID provides a mock function for the type MockContainerRuntime
998+func (_mock *MockContainerRuntime) GetImageID(ctx context.Context, imageRef string) (string, error) {
999+ ret := _mock.Called(ctx, imageRef)
1000+
1001+ if len(ret) == 0 {
1002+ panic("no return value specified for GetImageID")
1003+ }
1004+
1005+ var r0 string
1006+ var r1 error
1007+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok {
1008+ return returnFunc(ctx, imageRef)
1009+ }
1010+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
1011+ r0 = returnFunc(ctx, imageRef)
1012+ } else {
1013+ r0 = ret.Get(0).(string)
1014+ }
1015+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
1016+ r1 = returnFunc(ctx, imageRef)
1017+ } else {
1018+ r1 = ret.Error(1)
1019+ }
1020+ return r0, r1
1021+}
1022+
1023+// MockContainerRuntime_GetImageID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetImageID'
1024+type MockContainerRuntime_GetImageID_Call struct {
1025+ *mock.Call
1026+}
1027+
1028+// GetImageID is a helper method to define mock.On call
1029+// - ctx context.Context
1030+// - imageRef string
1031+func (_e *MockContainerRuntime_Expecter) GetImageID(ctx interface{}, imageRef interface{}) *MockContainerRuntime_GetImageID_Call {
1032+ return &MockContainerRuntime_GetImageID_Call{Call: _e.mock.On("GetImageID", ctx, imageRef)}
1033+}
1034+
1035+func (_c *MockContainerRuntime_GetImageID_Call) Run(run func(ctx context.Context, imageRef string)) *MockContainerRuntime_GetImageID_Call {
1036+ _c.Call.Run(func(args mock.Arguments) {
1037+ var arg0 context.Context
1038+ if args[0] != nil {
1039+ arg0 = args[0].(context.Context)
1040+ }
1041+ var arg1 string
1042+ if args[1] != nil {
1043+ arg1 = args[1].(string)
1044+ }
1045+ run(
1046+ arg0,
1047+ arg1,
1048+ )
1049+ })
1050+ return _c
1051+}
1052+
1053+func (_c *MockContainerRuntime_GetImageID_Call) Return(s string, err error) *MockContainerRuntime_GetImageID_Call {
1054+ _c.Call.Return(s, err)
1055+ return _c
1056+}
1057+
1058+func (_c *MockContainerRuntime_GetImageID_Call) RunAndReturn(run func(ctx context.Context, imageRef string) (string, error)) *MockContainerRuntime_GetImageID_Call {
1059+ _c.Call.Return(run)
1060+ return _c
1061+}
1062+
997 // GetImageLabels provides a mock function for the type MockContainerRuntime1063 // GetImageLabels provides a mock function for the type MockContainerRuntime
998 func (_mock *MockContainerRuntime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {1064 func (_mock *MockContainerRuntime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {
999 ret := _mock.Called(ctx, imageRef)1065 ret := _mock.Called(ctx, imageRef)
M internal/boundaries/out/runtime.go +3 -0
@@ -61,6 +61,9 @@ type ContainerRuntime interface {
6161 // Label inspection
6262 GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error)
6363
64+ // Image identity
65+ GetImageID(ctx context.Context, imageRef string) (string, error)
66+
6467 // In-container operations
6568 ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error)
6669 CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error)
@@ -61,6 +61,9 @@ type ContainerRuntime interface {
61 // Label inspection61 // Label inspection
62 GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error)62 GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error)
63 63
64+ // Image identity
65+ GetImageID(ctx context.Context, imageRef string) (string, error)
66+
64 // In-container operations67 // In-container operations
65 ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error)68 ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error)
66 CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error)69 CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error)
M internal/domain/container.go +1 -0
@@ -6,6 +6,7 @@ package domain
66 type Container struct {
77 ID string
88 Image string
9+ ImageID string // Docker image ID (sha256 digest) used to detect redundant deploys
910 Name string
1011 Status string
1112 ExitCode int
@@ -6,6 +6,7 @@ package domain
6 type Container struct {6 type Container struct {
7 ID string7 ID string
8 Image string8 Image string
9+ ImageID string // Docker image ID (sha256 digest) used to detect redundant deploys
9 Name string10 Name string
10 Status string11 Status string
11 ExitCode int12 ExitCode int
M internal/usecase/container/service.go +42 -0
@@ -239,6 +239,16 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
239239 return nil, err
240240 }
241241
242+ // Skip redundant deploy: if the existing container is already running
243+ // the exact same image (by Docker image ID), return it immediately.
244+ // This prevents the double-deploy caused by the event-based deploy
245+ // (triggered by image.pushed) racing with the explicit CLI deploy call.
246+ if hasExisting && existing.ImageID != "" {
247+ if skip, container := s.skipRedundantDeploy(ctx, existing, resources.actualImageRef); skip {
248+ return container, nil
249+ }
250+ }
251+
242252 newContainer, err := s.createStartedContainer(ctx, route, existing, resources)
243253 if err != nil {
244254 return nil, err
@@ -261,6 +271,38 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
261271 return newContainer, nil
262272 }
263273
274+// skipRedundantDeploy checks whether the existing container is already running
275+// the same image (by Docker image ID) as the one we are about to deploy.
276+// When a push triggers both an event-based deploy and an explicit CLI deploy,
277+// the second one arrives after the first has already completed; this avoids
278+// a full redundant create-start-readiness cycle.
279+func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Container, actualImageRef string) (bool, *domain.Container) {
280+ log := zerowrap.FromCtx(ctx)
281+
282+ newImageID, err := s.runtime.GetImageID(ctx, actualImageRef)
283+ if err != nil {
284+ log.Debug().Err(err).Msg("cannot resolve image ID for redundancy check, proceeding with deploy")
285+ return false, nil
286+ }
287+
288+ if existing.ImageID != newImageID {
289+ return false, nil
290+ }
291+
292+ // Verify the container is actually still running before skipping.
293+ running, err := s.runtime.IsContainerRunning(ctx, existing.ID)
294+ if err != nil || !running {
295+ return false, nil
296+ }
297+
298+ log.Info().
299+ Str("container_id", existing.ID).
300+ Str("image_id", newImageID).
301+ Msg("skipping redundant deploy: container already running this image")
302+
303+ return true, existing
304+}
305+
264306 type deployResources struct {
265307 networkName string
266308 actualImageRef string
@@ -239,6 +239,16 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
239 return nil, err239 return nil, err
240 }240 }
241 241
242+ // Skip redundant deploy: if the existing container is already running
243+ // the exact same image (by Docker image ID), return it immediately.
244+ // This prevents the double-deploy caused by the event-based deploy
245+ // (triggered by image.pushed) racing with the explicit CLI deploy call.
246+ if hasExisting && existing.ImageID != "" {
247+ if skip, container := s.skipRedundantDeploy(ctx, existing, resources.actualImageRef); skip {
248+ return container, nil
249+ }
250+ }
251+
242 newContainer, err := s.createStartedContainer(ctx, route, existing, resources)252 newContainer, err := s.createStartedContainer(ctx, route, existing, resources)
243 if err != nil {253 if err != nil {
244 return nil, err254 return nil, err
@@ -261,6 +271,38 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
261 return newContainer, nil271 return newContainer, nil
262 }272 }
263 273
274+// skipRedundantDeploy checks whether the existing container is already running
275+// the same image (by Docker image ID) as the one we are about to deploy.
276+// When a push triggers both an event-based deploy and an explicit CLI deploy,
277+// the second one arrives after the first has already completed; this avoids
278+// a full redundant create-start-readiness cycle.
279+func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Container, actualImageRef string) (bool, *domain.Container) {
280+ log := zerowrap.FromCtx(ctx)
281+
282+ newImageID, err := s.runtime.GetImageID(ctx, actualImageRef)
283+ if err != nil {
284+ log.Debug().Err(err).Msg("cannot resolve image ID for redundancy check, proceeding with deploy")
285+ return false, nil
286+ }
287+
288+ if existing.ImageID != newImageID {
289+ return false, nil
290+ }
291+
292+ // Verify the container is actually still running before skipping.
293+ running, err := s.runtime.IsContainerRunning(ctx, existing.ID)
294+ if err != nil || !running {
295+ return false, nil
296+ }
297+
298+ log.Info().
299+ Str("container_id", existing.ID).
300+ Str("image_id", newImageID).
301+ Msg("skipping redundant deploy: container already running this image")
302+
303+ return true, existing
304+}
305+
264 type deployResources struct {306 type deployResources struct {
265 networkName string307 networkName string
266 actualImageRef string308 actualImageRef string
M internal/usecase/container/service_test.go +232 -3
@@ -314,12 +314,70 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
314314 assert.NoError(t, err)
315315 assert.Equal(t, "new-container", result.ID)
316316
317- // Old container should be replaced
318- tracked, exists := svc.Get(ctx, "test.example.com")
319- assert.True(t, exists)
317+ // Ensure the service's tracked container entry now points to the new container.
318+ tracked, ok := svc.Get(ctx, "test.example.com")
319+ assert.True(t, ok)
320320 assert.Equal(t, "new-container", tracked.ID)
321321 }
322322
323+func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
324+ runtime := mocks.NewMockContainerRuntime(t)
325+ envLoader := mocks.NewMockEnvLoader(t)
326+ eventBus := mocks.NewMockEventPublisher(t)
327+
328+ config := Config{
329+ ReadinessDelay: time.Millisecond,
330+ DrainDelay: time.Millisecond,
331+ }
332+ svc := NewService(runtime, envLoader, eventBus, nil, config)
333+ ctx := testContext()
334+
335+ // Existing container has ImageID, but GetImageID will fail
336+ existingContainer := &domain.Container{
337+ ID: "existing-container",
338+ Name: "gordon-test.example.com",
339+ Image: "myapp:latest",
340+ ImageID: "sha256:abc123",
341+ Status: "running",
342+ }
343+ svc.containers["test.example.com"] = existingContainer
344+
345+ route := domain.Route{
346+ Domain: "test.example.com",
347+ Image: "myapp:latest",
348+ }
349+
350+ // prepareDeployResources
351+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
352+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
353+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
354+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
355+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
356+
357+ // GetImageID fails => deploy proceeds normally (graceful degradation)
358+ runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("", errors.New("image inspect failed"))
359+
360+ // Full deploy proceeds despite the error
361+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
362+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
363+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
364+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
365+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
366+ ID: "new-container", Status: "running",
367+ }, nil)
368+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
369+
370+ // Old container is finalized
371+ runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
372+ runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
373+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
374+
375+ result, err := svc.Deploy(ctx, route)
376+
377+ assert.NoError(t, err)
378+ assert.Equal(t, "new-container", result.ID)
379+}
380+
323381 func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
324382 runtime := mocks.NewMockContainerRuntime(t)
325383 envLoader := mocks.NewMockEnvLoader(t)
@@ -2003,3 +2061,174 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
20032061 assert.NoError(t, err)
20042062 assert.Equal(t, "new-container", result.ID)
20052063 }
2064+
2065+func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
2066+ runtime := mocks.NewMockContainerRuntime(t)
2067+ envLoader := mocks.NewMockEnvLoader(t)
2068+ eventBus := mocks.NewMockEventPublisher(t)
2069+
2070+ config := Config{
2071+ ReadinessDelay: time.Millisecond,
2072+ DrainDelay: time.Millisecond,
2073+ }
2074+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2075+ ctx := testContext()
2076+
2077+ // Pre-populate with existing container that has an ImageID (set by InspectContainer).
2078+ // This simulates the first deploy (from image.pushed event) having already completed.
2079+ existingContainer := &domain.Container{
2080+ ID: "existing-container",
2081+ Name: "gordon-test.example.com",
2082+ Image: "myapp:latest",
2083+ ImageID: "sha256:abc123",
2084+ Status: "running",
2085+ }
2086+ svc.containers["test.example.com"] = existingContainer
2087+
2088+ route := domain.Route{
2089+ Domain: "test.example.com",
2090+ Image: "myapp:latest",
2091+ }
2092+
2093+ // prepareDeployResources will run: orphan cleanup, image pull, etc.
2094+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2095+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2096+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2097+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2098+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2099+
2100+ // skipRedundantDeploy: resolve image ID and compare with existing container
2101+ runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
2102+ runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
2103+
2104+ // No CreateContainer, StartContainer, or readiness calls should happen
2105+
2106+ result, err := svc.Deploy(ctx, route)
2107+
2108+ assert.NoError(t, err)
2109+ assert.NotNil(t, result)
2110+ assert.Equal(t, "existing-container", result.ID)
2111+
2112+ // Container should still be tracked
2113+ tracked, exists := svc.Get(ctx, "test.example.com")
2114+ assert.True(t, exists)
2115+ assert.Equal(t, "existing-container", tracked.ID)
2116+}
2117+
2118+func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
2119+ runtime := mocks.NewMockContainerRuntime(t)
2120+ envLoader := mocks.NewMockEnvLoader(t)
2121+ eventBus := mocks.NewMockEventPublisher(t)
2122+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2123+
2124+ config := Config{
2125+ ReadinessDelay: time.Millisecond,
2126+ DrainDelay: time.Millisecond,
2127+ }
2128+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2129+ svc.SetProxyCacheInvalidator(cacheInvalidator)
2130+ ctx := testContext()
2131+
2132+ // Existing container with a DIFFERENT image ID than what's being deployed
2133+ existingContainer := &domain.Container{
2134+ ID: "old-container",
2135+ Name: "gordon-test.example.com",
2136+ Image: "myapp:latest",
2137+ ImageID: "sha256:old-image",
2138+ Status: "running",
2139+ }
2140+ svc.containers["test.example.com"] = existingContainer
2141+
2142+ route := domain.Route{
2143+ Domain: "test.example.com",
2144+ Image: "myapp:latest",
2145+ }
2146+
2147+ // prepareDeployResources
2148+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2149+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2150+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2151+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2152+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2153+
2154+ // skipRedundantDeploy: image IDs differ, so deploy proceeds
2155+ runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:new-image", nil)
2156+
2157+ // Full deploy proceeds
2158+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2159+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2160+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2161+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2162+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2163+ ID: "new-container", Status: "running",
2164+ }, nil)
2165+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2166+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2167+ runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
2168+ runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2169+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2170+
2171+ result, err := svc.Deploy(ctx, route)
2172+
2173+ assert.NoError(t, err)
2174+ assert.Equal(t, "new-container", result.ID)
2175+}
2176+
2177+func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
2178+ runtime := mocks.NewMockContainerRuntime(t)
2179+ envLoader := mocks.NewMockEnvLoader(t)
2180+ eventBus := mocks.NewMockEventPublisher(t)
2181+
2182+ config := Config{
2183+ ReadinessDelay: time.Millisecond,
2184+ DrainDelay: time.Millisecond,
2185+ }
2186+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2187+ ctx := testContext()
2188+
2189+ // Existing container has same image ID but is NOT running (crashed)
2190+ existingContainer := &domain.Container{
2191+ ID: "existing-container",
2192+ Name: "gordon-test.example.com",
2193+ Image: "myapp:latest",
2194+ ImageID: "sha256:abc123",
2195+ Status: "exited",
2196+ }
2197+ svc.containers["test.example.com"] = existingContainer
2198+
2199+ route := domain.Route{
2200+ Domain: "test.example.com",
2201+ Image: "myapp:latest",
2202+ }
2203+
2204+ // prepareDeployResources
2205+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2206+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2207+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2208+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2209+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2210+
2211+ // skipRedundantDeploy: same image ID but container not running => proceed with deploy
2212+ runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
2213+ runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(false, nil)
2214+
2215+ // Full deploy proceeds (replaces crashed container)
2216+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2217+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2218+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2219+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2220+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2221+ ID: "new-container", Status: "running",
2222+ }, nil)
2223+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2224+
2225+ // Old (exited) container is still finalized
2226+ runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
2227+ runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
2228+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2229+
2230+ result, err := svc.Deploy(ctx, route)
2231+
2232+ assert.NoError(t, err)
2233+ assert.Equal(t, "new-container", result.ID)
2234+}
@@ -314,12 +314,70 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
314 assert.NoError(t, err)314 assert.NoError(t, err)
315 assert.Equal(t, "new-container", result.ID)315 assert.Equal(t, "new-container", result.ID)
316 316
317- // Old container should be replaced317+ // Ensure the service's tracked container entry now points to the new container.
318- tracked, exists := svc.Get(ctx, "test.example.com")318+ tracked, ok := svc.Get(ctx, "test.example.com")
319- assert.True(t, exists)319+ assert.True(t, ok)
320 assert.Equal(t, "new-container", tracked.ID)320 assert.Equal(t, "new-container", tracked.ID)
321 }321 }
322 322
323+func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
324+ runtime := mocks.NewMockContainerRuntime(t)
325+ envLoader := mocks.NewMockEnvLoader(t)
326+ eventBus := mocks.NewMockEventPublisher(t)
327+
328+ config := Config{
329+ ReadinessDelay: time.Millisecond,
330+ DrainDelay: time.Millisecond,
331+ }
332+ svc := NewService(runtime, envLoader, eventBus, nil, config)
333+ ctx := testContext()
334+
335+ // Existing container has ImageID, but GetImageID will fail
336+ existingContainer := &domain.Container{
337+ ID: "existing-container",
338+ Name: "gordon-test.example.com",
339+ Image: "myapp:latest",
340+ ImageID: "sha256:abc123",
341+ Status: "running",
342+ }
343+ svc.containers["test.example.com"] = existingContainer
344+
345+ route := domain.Route{
346+ Domain: "test.example.com",
347+ Image: "myapp:latest",
348+ }
349+
350+ // prepareDeployResources
351+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
352+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
353+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
354+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
355+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
356+
357+ // GetImageID fails => deploy proceeds normally (graceful degradation)
358+ runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("", errors.New("image inspect failed"))
359+
360+ // Full deploy proceeds despite the error
361+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
362+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
363+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
364+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
365+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
366+ ID: "new-container", Status: "running",
367+ }, nil)
368+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
369+
370+ // Old container is finalized
371+ runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
372+ runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
373+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
374+
375+ result, err := svc.Deploy(ctx, route)
376+
377+ assert.NoError(t, err)
378+ assert.Equal(t, "new-container", result.ID)
379+}
380+
323 func TestService_Deploy_WithNetworkIsolation(t *testing.T) {381 func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
324 runtime := mocks.NewMockContainerRuntime(t)382 runtime := mocks.NewMockContainerRuntime(t)
325 envLoader := mocks.NewMockEnvLoader(t)383 envLoader := mocks.NewMockEnvLoader(t)
@@ -2003,3 +2061,174 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
2003 assert.NoError(t, err)2061 assert.NoError(t, err)
2004 assert.Equal(t, "new-container", result.ID)2062 assert.Equal(t, "new-container", result.ID)
2005 }2063 }
2064+
2065+func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
2066+ runtime := mocks.NewMockContainerRuntime(t)
2067+ envLoader := mocks.NewMockEnvLoader(t)
2068+ eventBus := mocks.NewMockEventPublisher(t)
2069+
2070+ config := Config{
2071+ ReadinessDelay: time.Millisecond,
2072+ DrainDelay: time.Millisecond,
2073+ }
2074+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2075+ ctx := testContext()
2076+
2077+ // Pre-populate with existing container that has an ImageID (set by InspectContainer).
2078+ // This simulates the first deploy (from image.pushed event) having already completed.
2079+ existingContainer := &domain.Container{
2080+ ID: "existing-container",
2081+ Name: "gordon-test.example.com",
2082+ Image: "myapp:latest",
2083+ ImageID: "sha256:abc123",
2084+ Status: "running",
2085+ }
2086+ svc.containers["test.example.com"] = existingContainer
2087+
2088+ route := domain.Route{
2089+ Domain: "test.example.com",
2090+ Image: "myapp:latest",
2091+ }
2092+
2093+ // prepareDeployResources will run: orphan cleanup, image pull, etc.
2094+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2095+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2096+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2097+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2098+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2099+
2100+ // skipRedundantDeploy: resolve image ID and compare with existing container
2101+ runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
2102+ runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
2103+
2104+ // No CreateContainer, StartContainer, or readiness calls should happen
2105+
2106+ result, err := svc.Deploy(ctx, route)
2107+
2108+ assert.NoError(t, err)
2109+ assert.NotNil(t, result)
2110+ assert.Equal(t, "existing-container", result.ID)
2111+
2112+ // Container should still be tracked
2113+ tracked, exists := svc.Get(ctx, "test.example.com")
2114+ assert.True(t, exists)
2115+ assert.Equal(t, "existing-container", tracked.ID)
2116+}
2117+
2118+func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
2119+ runtime := mocks.NewMockContainerRuntime(t)
2120+ envLoader := mocks.NewMockEnvLoader(t)
2121+ eventBus := mocks.NewMockEventPublisher(t)
2122+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2123+
2124+ config := Config{
2125+ ReadinessDelay: time.Millisecond,
2126+ DrainDelay: time.Millisecond,
2127+ }
2128+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2129+ svc.SetProxyCacheInvalidator(cacheInvalidator)
2130+ ctx := testContext()
2131+
2132+ // Existing container with a DIFFERENT image ID than what's being deployed
2133+ existingContainer := &domain.Container{
2134+ ID: "old-container",
2135+ Name: "gordon-test.example.com",
2136+ Image: "myapp:latest",
2137+ ImageID: "sha256:old-image",
2138+ Status: "running",
2139+ }
2140+ svc.containers["test.example.com"] = existingContainer
2141+
2142+ route := domain.Route{
2143+ Domain: "test.example.com",
2144+ Image: "myapp:latest",
2145+ }
2146+
2147+ // prepareDeployResources
2148+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2149+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2150+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2151+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2152+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2153+
2154+ // skipRedundantDeploy: image IDs differ, so deploy proceeds
2155+ runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:new-image", nil)
2156+
2157+ // Full deploy proceeds
2158+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2159+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2160+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2161+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2162+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2163+ ID: "new-container", Status: "running",
2164+ }, nil)
2165+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2166+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2167+ runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
2168+ runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2169+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2170+
2171+ result, err := svc.Deploy(ctx, route)
2172+
2173+ assert.NoError(t, err)
2174+ assert.Equal(t, "new-container", result.ID)
2175+}
2176+
2177+func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
2178+ runtime := mocks.NewMockContainerRuntime(t)
2179+ envLoader := mocks.NewMockEnvLoader(t)
2180+ eventBus := mocks.NewMockEventPublisher(t)
2181+
2182+ config := Config{
2183+ ReadinessDelay: time.Millisecond,
2184+ DrainDelay: time.Millisecond,
2185+ }
2186+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2187+ ctx := testContext()
2188+
2189+ // Existing container has same image ID but is NOT running (crashed)
2190+ existingContainer := &domain.Container{
2191+ ID: "existing-container",
2192+ Name: "gordon-test.example.com",
2193+ Image: "myapp:latest",
2194+ ImageID: "sha256:abc123",
2195+ Status: "exited",
2196+ }
2197+ svc.containers["test.example.com"] = existingContainer
2198+
2199+ route := domain.Route{
2200+ Domain: "test.example.com",
2201+ Image: "myapp:latest",
2202+ }
2203+
2204+ // prepareDeployResources
2205+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2206+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2207+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2208+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2209+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2210+
2211+ // skipRedundantDeploy: same image ID but container not running => proceed with deploy
2212+ runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
2213+ runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(false, nil)
2214+
2215+ // Full deploy proceeds (replaces crashed container)
2216+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2217+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2218+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2219+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2220+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2221+ ID: "new-container", Status: "running",
2222+ }, nil)
2223+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2224+
2225+ // Old (exited) container is still finalized
2226+ runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
2227+ runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
2228+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2229+
2230+ result, err := svc.Deploy(ctx, route)
2231+
2232+ assert.NoError(t, err)
2233+ assert.Equal(t, "new-container", result.ID)
2234+}

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 8

Tags 106

↑↓ moveenter openesc close