Merge pull request #71 from bnema/fix/skip-redundant-deploy
fix(container): skip redundant deploy when container already runs the same image
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 | |||
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | 307 | 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, | |
| 314 | 315 | }) | |
| 315 | 316 | } | |
| 316 | 317 | ||
| @@ -352,6 +353,7 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do | |||
| 352 | 353 | return &domain.Container{ | |
| 353 | 354 | ID: resp.ID, | |
| 354 | 355 | Image: resp.Config.Image, | |
| 356 | + | ImageID: resp.Image, | |
| 355 | 357 | Name: name, | |
| 356 | 358 | Status: resp.State.Status, | |
| 357 | 359 | ExitCode: resp.State.ExitCode, | |
| @@ -1103,6 +1105,24 @@ func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[stri | |||
| 1103 | 1105 | 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 | 1126 | // CreateNetwork creates a new Docker network. | |
| 1107 | 1127 | func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[string]string) error { | |
| 1108 | 1128 | 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, nil | 1105 | 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( | |||
| 994 | 994 | 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 | 1063 | // GetImageLabels provides a mock function for the type MockContainerRuntime | |
| 998 | 1064 | func (_mock *MockContainerRuntime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) { | |
| 999 | 1065 | ret := _mock.Called(ctx, imageRef) | |
| @@ -994,6 +994,72 @@ func (_c *MockContainerRuntime_GetImageExposedPorts_Call) RunAndReturn(run func( | |||||
| 994 | return _c | 994 | 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 MockContainerRuntime | 1063 | // 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 { | |||
| 61 | 61 | // Label inspection | |
| 62 | 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 | 67 | // In-container operations | |
| 65 | 68 | ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error) | |
| 66 | 69 | CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) | |
| @@ -61,6 +61,9 @@ type ContainerRuntime interface { | |||||
| 61 | // Label inspection | 61 | // 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 operations | 67 | // 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 | |||
| 6 | 6 | type Container struct { | |
| 7 | 7 | ID string | |
| 8 | 8 | Image string | |
| 9 | + | ImageID string // Docker image ID (sha256 digest) used to detect redundant deploys | |
| 9 | 10 | Name string | |
| 10 | 11 | Status string | |
| 11 | 12 | ExitCode int | |
| @@ -6,6 +6,7 @@ package domain | |||||
| 6 | type Container struct { | 6 | type Container struct { | ||
| 7 | ID string | 7 | ID string | ||
| 8 | Image string | 8 | Image string | ||
| 9 | + | ImageID string // Docker image ID (sha256 digest) used to detect redundant deploys | |||
| 9 | Name string | 10 | Name string | ||
| 10 | Status string | 11 | Status string | ||
| 11 | ExitCode int | 12 | 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 | |||
| 239 | 239 | 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 | 252 | newContainer, err := s.createStartedContainer(ctx, route, existing, resources) | |
| 243 | 253 | if err != nil { | |
| 244 | 254 | return nil, err | |
| @@ -261,6 +271,38 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 261 | 271 | 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 | 306 | type deployResources struct { | |
| 265 | 307 | networkName string | |
| 266 | 308 | actualImageRef string | |
| @@ -239,6 +239,16 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||||
| 239 | return nil, err | 239 | 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, err | 254 | return nil, err | ||
| @@ -261,6 +271,38 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||||
| 261 | return newContainer, nil | 271 | 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 string | 307 | networkName string | ||
| 266 | actualImageRef string | 308 | actualImageRef string | ||
M internal/usecase/container/service_test.go +232 -3
| @@ -314,12 +314,70 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 314 | 314 | assert.NoError(t, err) | |
| 315 | 315 | assert.Equal(t, "new-container", result.ID) | |
| 316 | 316 | ||
| 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) | |
| 320 | 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 | 381 | func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |
| 324 | 382 | runtime := mocks.NewMockContainerRuntime(t) | |
| 325 | 383 | envLoader := mocks.NewMockEnvLoader(t) | |
| @@ -2003,3 +2061,174 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) { | |||
| 2003 | 2061 | assert.NoError(t, err) | |
| 2004 | 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 | + | } | |
| @@ -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 replaced | 317 | + | // 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 | + | } | |||