fix(deploy) synchronous proxy cache invalidation to prevent 503s during zero-downtime deploy
The proxy cache invalidation was happening asynchronously via the event bus while the old container was stopped synchronously. Requests between the old container stop and the event bus processing hit a dead target, causing 503s. Add a ProxyCacheInvalidator boundary interface and call it synchronously in Deploy() before the drain grace period and old container cleanup. This ensures the proxy routes to the new container before the old one is killed.
- commit
- 1838ab2c751041da60c6f8705d0bed1865e22f27
- author
- brice · · 2026-02-07 08:33 UTC
- committer
- brice · · 2026-02-07 19:59 UTC
- parent
- 711b2ac
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +312 -13
M internal/app/run.go +4 -0
| @@ -460,6 +460,10 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 460 | 460 | svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | |
| 461 | 461 | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig) | |
| 462 | 462 | ||
| 463 | + | // Wire synchronous proxy cache invalidation for zero-downtime deployments. | |
| 464 | + | // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget(). | |
| 465 | + | svc.containerSvc.SetProxyCacheInvalidator(svc.proxySvc) | |
| 466 | + | ||
| 463 | 467 | // Create token handler for registry token endpoint | |
| 464 | 468 | if svc.authSvc != nil { | |
| 465 | 469 | internalAuth := authhandler.InternalAuth{ | |
| @@ -460,6 +460,10 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 460 | svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | 460 | svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | ||
| 461 | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig) | 461 | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig) | ||
| 462 | 462 | ||||
| 463 | + | // Wire synchronous proxy cache invalidation for zero-downtime deployments. | |||
| 464 | + | // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget(). | |||
| 465 | + | svc.containerSvc.SetProxyCacheInvalidator(svc.proxySvc) | |||
| 466 | + | ||||
| 463 | // Create token handler for registry token endpoint | 467 | // Create token handler for registry token endpoint | ||
| 464 | if svc.authSvc != nil { | 468 | if svc.authSvc != nil { | ||
| 465 | internalAuth := authhandler.InternalAuth{ | 469 | internalAuth := authhandler.InternalAuth{ | ||
A internal/boundaries/out/mocks/mock_proxy_cache_invalidator.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // NewMockProxyCacheInvalidator creates a new instance of MockProxyCacheInvalidator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | + | // The first argument is typically a *testing.T value. | |
| 15 | + | func NewMockProxyCacheInvalidator(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *MockProxyCacheInvalidator { | |
| 19 | + | mock := &MockProxyCacheInvalidator{} | |
| 20 | + | mock.Mock.Test(t) | |
| 21 | + | ||
| 22 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 23 | + | ||
| 24 | + | return mock | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // MockProxyCacheInvalidator is an autogenerated mock type for the ProxyCacheInvalidator type | |
| 28 | + | type MockProxyCacheInvalidator struct { | |
| 29 | + | mock.Mock | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type MockProxyCacheInvalidator_Expecter struct { | |
| 33 | + | mock *mock.Mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (_m *MockProxyCacheInvalidator) EXPECT() *MockProxyCacheInvalidator_Expecter { | |
| 37 | + | return &MockProxyCacheInvalidator_Expecter{mock: &_m.Mock} | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // InvalidateTarget provides a mock function for the type MockProxyCacheInvalidator | |
| 41 | + | func (_mock *MockProxyCacheInvalidator) InvalidateTarget(ctx context.Context, domainName string) { | |
| 42 | + | _mock.Called(ctx, domainName) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // MockProxyCacheInvalidator_InvalidateTarget_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InvalidateTarget' | |
| 46 | + | type MockProxyCacheInvalidator_InvalidateTarget_Call struct { | |
| 47 | + | *mock.Call | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // InvalidateTarget is a helper method to define mock.On call | |
| 51 | + | // - ctx context.Context | |
| 52 | + | // - domainName string | |
| 53 | + | func (_e *MockProxyCacheInvalidator_Expecter) InvalidateTarget(ctx any, domainName any) *MockProxyCacheInvalidator_InvalidateTarget_Call { | |
| 54 | + | return &MockProxyCacheInvalidator_InvalidateTarget_Call{Call: _e.mock.On("InvalidateTarget", ctx, domainName)} | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (_c *MockProxyCacheInvalidator_InvalidateTarget_Call) Run(run func(ctx context.Context, domainName string)) *MockProxyCacheInvalidator_InvalidateTarget_Call { | |
| 58 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 59 | + | var arg0 context.Context | |
| 60 | + | if args[0] != nil { | |
| 61 | + | arg0 = args[0].(context.Context) | |
| 62 | + | } | |
| 63 | + | var arg1 string | |
| 64 | + | if args[1] != nil { | |
| 65 | + | arg1 = args[1].(string) | |
| 66 | + | } | |
| 67 | + | run(arg0, arg1) | |
| 68 | + | }) | |
| 69 | + | return _c | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (_c *MockProxyCacheInvalidator_InvalidateTarget_Call) Return() *MockProxyCacheInvalidator_InvalidateTarget_Call { | |
| 73 | + | _c.Call.Return() | |
| 74 | + | return _c | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (_c *MockProxyCacheInvalidator_InvalidateTarget_Call) RunAndReturn(run func(ctx context.Context, domainName string)) *MockProxyCacheInvalidator_InvalidateTarget_Call { | |
| 78 | + | _c.Run(run) | |
| 79 | + | return _c | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // NewMockProxyCacheInvalidator creates a new instance of MockProxyCacheInvalidator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | + | // The first argument is typically a *testing.T value. | |||
| 15 | + | func NewMockProxyCacheInvalidator(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *MockProxyCacheInvalidator { | |||
| 19 | + | mock := &MockProxyCacheInvalidator{} | |||
| 20 | + | mock.Mock.Test(t) | |||
| 21 | + | ||||
| 22 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 23 | + | ||||
| 24 | + | return mock | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // MockProxyCacheInvalidator is an autogenerated mock type for the ProxyCacheInvalidator type | |||
| 28 | + | type MockProxyCacheInvalidator struct { | |||
| 29 | + | mock.Mock | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | type MockProxyCacheInvalidator_Expecter struct { | |||
| 33 | + | mock *mock.Mock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (_m *MockProxyCacheInvalidator) EXPECT() *MockProxyCacheInvalidator_Expecter { | |||
| 37 | + | return &MockProxyCacheInvalidator_Expecter{mock: &_m.Mock} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // InvalidateTarget provides a mock function for the type MockProxyCacheInvalidator | |||
| 41 | + | func (_mock *MockProxyCacheInvalidator) InvalidateTarget(ctx context.Context, domainName string) { | |||
| 42 | + | _mock.Called(ctx, domainName) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // MockProxyCacheInvalidator_InvalidateTarget_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InvalidateTarget' | |||
| 46 | + | type MockProxyCacheInvalidator_InvalidateTarget_Call struct { | |||
| 47 | + | *mock.Call | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // InvalidateTarget is a helper method to define mock.On call | |||
| 51 | + | // - ctx context.Context | |||
| 52 | + | // - domainName string | |||
| 53 | + | func (_e *MockProxyCacheInvalidator_Expecter) InvalidateTarget(ctx any, domainName any) *MockProxyCacheInvalidator_InvalidateTarget_Call { | |||
| 54 | + | return &MockProxyCacheInvalidator_InvalidateTarget_Call{Call: _e.mock.On("InvalidateTarget", ctx, domainName)} | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (_c *MockProxyCacheInvalidator_InvalidateTarget_Call) Run(run func(ctx context.Context, domainName string)) *MockProxyCacheInvalidator_InvalidateTarget_Call { | |||
| 58 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 59 | + | var arg0 context.Context | |||
| 60 | + | if args[0] != nil { | |||
| 61 | + | arg0 = args[0].(context.Context) | |||
| 62 | + | } | |||
| 63 | + | var arg1 string | |||
| 64 | + | if args[1] != nil { | |||
| 65 | + | arg1 = args[1].(string) | |||
| 66 | + | } | |||
| 67 | + | run(arg0, arg1) | |||
| 68 | + | }) | |||
| 69 | + | return _c | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (_c *MockProxyCacheInvalidator_InvalidateTarget_Call) Return() *MockProxyCacheInvalidator_InvalidateTarget_Call { | |||
| 73 | + | _c.Call.Return() | |||
| 74 | + | return _c | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (_c *MockProxyCacheInvalidator_InvalidateTarget_Call) RunAndReturn(run func(ctx context.Context, domainName string)) *MockProxyCacheInvalidator_InvalidateTarget_Call { | |||
| 78 | + | _c.Run(run) | |||
| 79 | + | return _c | |||
| 80 | + | } | |||
A internal/boundaries/out/proxy_cache.go +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | package out | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // ProxyCacheInvalidator defines the contract for synchronously invalidating | |
| 6 | + | // proxy target cache entries. This is used during zero-downtime deployments | |
| 7 | + | // to ensure the proxy stops routing to an old container before it is stopped. | |
| 8 | + | type ProxyCacheInvalidator interface { | |
| 9 | + | InvalidateTarget(ctx context.Context, domainName string) | |
| 10 | + | } | |
| @@ -0,0 +1,10 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | // ProxyCacheInvalidator defines the contract for synchronously invalidating | |||
| 6 | + | // proxy target cache entries. This is used during zero-downtime deployments | |||
| 7 | + | // to ensure the proxy stops routing to an old container before it is stopped. | |||
| 8 | + | type ProxyCacheInvalidator interface { | |||
| 9 | + | InvalidateTarget(ctx context.Context, domainName string) | |||
| 10 | + | } | |||
M internal/usecase/container/service.go +45 -11
| @@ -36,6 +36,7 @@ type Config struct { | |||
| 36 | 36 | NetworkGroups map[string][]string | |
| 37 | 37 | Attachments map[string][]string | |
| 38 | 38 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | |
| 39 | + | DrainDelay time.Duration // Grace period after cache invalidation before stopping old container | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 41 | 42 | const ( | |
| @@ -52,15 +53,16 @@ const ( | |||
| 52 | 53 | ||
| 53 | 54 | // Service implements the ContainerService interface. | |
| 54 | 55 | type Service struct { | |
| 55 | - | runtime out.ContainerRuntime | |
| 56 | - | envLoader out.EnvLoader | |
| 57 | - | eventBus out.EventPublisher | |
| 58 | - | logWriter out.ContainerLogWriter | |
| 59 | - | config Config | |
| 60 | - | containers map[string]*domain.Container | |
| 61 | - | attachments map[string][]string // ownerDomain → []containerIDs | |
| 62 | - | mu sync.RWMutex | |
| 63 | - | deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock) | |
| 56 | + | runtime out.ContainerRuntime | |
| 57 | + | envLoader out.EnvLoader | |
| 58 | + | eventBus out.EventPublisher | |
| 59 | + | logWriter out.ContainerLogWriter | |
| 60 | + | cacheInvalidator out.ProxyCacheInvalidator | |
| 61 | + | config Config | |
| 62 | + | containers map[string]*domain.Container | |
| 63 | + | attachments map[string][]string // ownerDomain → []containerIDs | |
| 64 | + | mu sync.RWMutex | |
| 65 | + | deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock) | |
| 64 | 66 | } | |
| 65 | 67 | ||
| 66 | 68 | // domainDeployLock is a context-aware mutex using a buffered channel. | |
| @@ -126,6 +128,16 @@ func NewService( | |||
| 126 | 128 | } | |
| 127 | 129 | } | |
| 128 | 130 | ||
| 131 | + | // SetProxyCacheInvalidator sets the proxy cache invalidator for synchronous | |
| 132 | + | // cache invalidation during zero-downtime deployments. This must be called | |
| 133 | + | // after construction because the proxy service is created after the container | |
| 134 | + | // service during application initialization. | |
| 135 | + | func (s *Service) SetProxyCacheInvalidator(inv out.ProxyCacheInvalidator) { | |
| 136 | + | s.mu.Lock() | |
| 137 | + | s.cacheInvalidator = inv | |
| 138 | + | s.mu.Unlock() | |
| 139 | + | } | |
| 140 | + | ||
| 129 | 141 | // domainDeployMu returns a per-domain lock for serializing deploy operations. | |
| 130 | 142 | func (s *Service) domainDeployMu(domain string) *domainDeployLock { | |
| 131 | 143 | v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock()) | |
| @@ -289,10 +301,32 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 289 | 301 | s.containers[route.Domain] = newContainer | |
| 290 | 302 | s.mu.Unlock() | |
| 291 | 303 | ||
| 292 | - | // Publish container deployed event (proxy will invalidate cache) | |
| 304 | + | // Publish container deployed event (other subscribers) | |
| 293 | 305 | s.publishContainerDeployed(ctx, route.Domain, newContainer.ID) | |
| 294 | 306 | ||
| 295 | - | // NOW stop and remove old container (traffic already going to new one) | |
| 307 | + | // Synchronous cache invalidation: ensure the proxy stops routing to the | |
| 308 | + | // old container immediately, before we stop it. This fixes the race where | |
| 309 | + | // the async event bus invalidation arrives after the old container is killed. | |
| 310 | + | s.mu.RLock() | |
| 311 | + | inv := s.cacheInvalidator | |
| 312 | + | s.mu.RUnlock() | |
| 313 | + | if inv != nil { | |
| 314 | + | inv.InvalidateTarget(ctx, route.Domain) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // Grace period: let in-flight requests to the old container finish draining. | |
| 318 | + | if hasExisting { | |
| 319 | + | drainDelay := s.config.DrainDelay | |
| 320 | + | if drainDelay == 0 { | |
| 321 | + | drainDelay = 2 * time.Second | |
| 322 | + | } | |
| 323 | + | select { | |
| 324 | + | case <-time.After(drainDelay): | |
| 325 | + | case <-ctx.Done(): | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // NOW safe to stop and remove old container | |
| 296 | 330 | if hasExisting { | |
| 297 | 331 | s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain) | |
| 298 | 332 | } | |
| @@ -36,6 +36,7 @@ type Config struct { | |||||
| 36 | NetworkGroups map[string][]string | 36 | NetworkGroups map[string][]string | ||
| 37 | Attachments map[string][]string | 37 | Attachments map[string][]string | ||
| 38 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | 38 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | ||
| 39 | + | DrainDelay time.Duration // Grace period after cache invalidation before stopping old container | |||
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| 41 | const ( | 42 | const ( | ||
| @@ -52,15 +53,16 @@ const ( | |||||
| 52 | 53 | ||||
| 53 | // Service implements the ContainerService interface. | 54 | // Service implements the ContainerService interface. | ||
| 54 | type Service struct { | 55 | type Service struct { | ||
| 55 | - | runtime out.ContainerRuntime | 56 | + | runtime out.ContainerRuntime |
| 56 | - | envLoader out.EnvLoader | 57 | + | envLoader out.EnvLoader |
| 57 | - | eventBus out.EventPublisher | 58 | + | eventBus out.EventPublisher |
| 58 | - | logWriter out.ContainerLogWriter | 59 | + | logWriter out.ContainerLogWriter |
| 59 | - | config Config | 60 | + | cacheInvalidator out.ProxyCacheInvalidator |
| 60 | - | containers map[string]*domain.Container | 61 | + | config Config |
| 61 | - | attachments map[string][]string // ownerDomain → []containerIDs | 62 | + | containers map[string]*domain.Container |
| 62 | - | mu sync.RWMutex | 63 | + | attachments map[string][]string // ownerDomain → []containerIDs |
| 63 | - | deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock) | 64 | + | mu sync.RWMutex |
| 65 | + | deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock) | |||
| 64 | } | 66 | } | ||
| 65 | 67 | ||||
| 66 | // domainDeployLock is a context-aware mutex using a buffered channel. | 68 | // domainDeployLock is a context-aware mutex using a buffered channel. | ||
| @@ -126,6 +128,16 @@ func NewService( | |||||
| 126 | } | 128 | } | ||
| 127 | } | 129 | } | ||
| 128 | 130 | ||||
| 131 | + | // SetProxyCacheInvalidator sets the proxy cache invalidator for synchronous | |||
| 132 | + | // cache invalidation during zero-downtime deployments. This must be called | |||
| 133 | + | // after construction because the proxy service is created after the container | |||
| 134 | + | // service during application initialization. | |||
| 135 | + | func (s *Service) SetProxyCacheInvalidator(inv out.ProxyCacheInvalidator) { | |||
| 136 | + | s.mu.Lock() | |||
| 137 | + | s.cacheInvalidator = inv | |||
| 138 | + | s.mu.Unlock() | |||
| 139 | + | } | |||
| 140 | + | ||||
| 129 | // domainDeployMu returns a per-domain lock for serializing deploy operations. | 141 | // domainDeployMu returns a per-domain lock for serializing deploy operations. | ||
| 130 | func (s *Service) domainDeployMu(domain string) *domainDeployLock { | 142 | func (s *Service) domainDeployMu(domain string) *domainDeployLock { | ||
| 131 | v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock()) | 143 | v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock()) | ||
| @@ -289,10 +301,32 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||||
| 289 | s.containers[route.Domain] = newContainer | 301 | s.containers[route.Domain] = newContainer | ||
| 290 | s.mu.Unlock() | 302 | s.mu.Unlock() | ||
| 291 | 303 | ||||
| 292 | - | // Publish container deployed event (proxy will invalidate cache) | 304 | + | // Publish container deployed event (other subscribers) |
| 293 | s.publishContainerDeployed(ctx, route.Domain, newContainer.ID) | 305 | s.publishContainerDeployed(ctx, route.Domain, newContainer.ID) | ||
| 294 | 306 | ||||
| 295 | - | // NOW stop and remove old container (traffic already going to new one) | 307 | + | // Synchronous cache invalidation: ensure the proxy stops routing to the |
| 308 | + | // old container immediately, before we stop it. This fixes the race where | |||
| 309 | + | // the async event bus invalidation arrives after the old container is killed. | |||
| 310 | + | s.mu.RLock() | |||
| 311 | + | inv := s.cacheInvalidator | |||
| 312 | + | s.mu.RUnlock() | |||
| 313 | + | if inv != nil { | |||
| 314 | + | inv.InvalidateTarget(ctx, route.Domain) | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // Grace period: let in-flight requests to the old container finish draining. | |||
| 318 | + | if hasExisting { | |||
| 319 | + | drainDelay := s.config.DrainDelay | |||
| 320 | + | if drainDelay == 0 { | |||
| 321 | + | drainDelay = 2 * time.Second | |||
| 322 | + | } | |||
| 323 | + | select { | |||
| 324 | + | case <-time.After(drainDelay): | |||
| 325 | + | case <-ctx.Done(): | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | // NOW safe to stop and remove old container | |||
| 296 | if hasExisting { | 330 | if hasExisting { | ||
| 297 | s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain) | 331 | s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain) | ||
| 298 | } | 332 | } | ||
M internal/usecase/container/service_test.go +173 -2
| @@ -29,6 +29,7 @@ func TestService_Deploy_Success(t *testing.T) { | |||
| 29 | 29 | NetworkIsolation: false, | |
| 30 | 30 | VolumeAutoCreate: false, | |
| 31 | 31 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 32 | + | DrainDelay: time.Millisecond, | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| @@ -105,6 +106,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T | |||
| 105 | 106 | NetworkIsolation: false, | |
| 106 | 107 | VolumeAutoCreate: false, | |
| 107 | 108 | ReadinessDelay: time.Millisecond, | |
| 109 | + | DrainDelay: time.Millisecond, | |
| 108 | 110 | } | |
| 109 | 111 | ||
| 110 | 112 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| @@ -243,11 +245,14 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 243 | 245 | runtime := mocks.NewMockContainerRuntime(t) | |
| 244 | 246 | envLoader := mocks.NewMockEnvLoader(t) | |
| 245 | 247 | eventBus := mocks.NewMockEventPublisher(t) | |
| 248 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 246 | 249 | ||
| 247 | 250 | config := Config{ | |
| 248 | 251 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 252 | + | DrainDelay: time.Millisecond, | |
| 249 | 253 | } | |
| 250 | 254 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 255 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 251 | 256 | ctx := testContext() | |
| 252 | 257 | ||
| 253 | 258 | // Pre-populate with existing container | |
| @@ -294,7 +299,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 294 | 299 | // Publish event | |
| 295 | 300 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 296 | 301 | ||
| 297 | - | // Now cleanup old container (after new one is ready) | |
| 302 | + | // Synchronous cache invalidation before old container cleanup | |
| 303 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |
| 304 | + | ||
| 305 | + | // Now cleanup old container (after new one is ready + cache invalidated + drain delay) | |
| 298 | 306 | runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil) | |
| 299 | 307 | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | |
| 300 | 308 | ||
| @@ -321,6 +329,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||
| 321 | 329 | NetworkIsolation: true, | |
| 322 | 330 | NetworkPrefix: "gordon", | |
| 323 | 331 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 332 | + | DrainDelay: time.Millisecond, | |
| 324 | 333 | } | |
| 325 | 334 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 326 | 335 | ctx := testContext() | |
| @@ -372,6 +381,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||
| 372 | 381 | VolumeAutoCreate: true, | |
| 373 | 382 | VolumePrefix: "gordon", | |
| 374 | 383 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 384 | + | DrainDelay: time.Millisecond, | |
| 375 | 385 | } | |
| 376 | 386 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 377 | 387 | ctx := testContext() | |
| @@ -1107,6 +1117,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |||
| 1107 | 1117 | InternalRegistryUsername: "internal", | |
| 1108 | 1118 | InternalRegistryPassword: "secret", | |
| 1109 | 1119 | ReadinessDelay: time.Millisecond, | |
| 1120 | + | DrainDelay: time.Millisecond, | |
| 1110 | 1121 | } | |
| 1111 | 1122 | ||
| 1112 | 1123 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| @@ -1175,6 +1186,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||
| 1175 | 1186 | ||
| 1176 | 1187 | config := Config{ | |
| 1177 | 1188 | ReadinessDelay: time.Millisecond, | |
| 1189 | + | DrainDelay: time.Millisecond, | |
| 1178 | 1190 | } | |
| 1179 | 1191 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1180 | 1192 | ctx := testContext() | |
| @@ -1218,6 +1230,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||
| 1218 | 1230 | ||
| 1219 | 1231 | config := Config{ | |
| 1220 | 1232 | ReadinessDelay: time.Millisecond, | |
| 1233 | + | DrainDelay: time.Millisecond, | |
| 1221 | 1234 | } | |
| 1222 | 1235 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1223 | 1236 | ctx := testContext() | |
| @@ -1288,6 +1301,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||
| 1288 | 1301 | RegistryDomain: "reg.example.com", | |
| 1289 | 1302 | RegistryPort: 5000, | |
| 1290 | 1303 | ReadinessDelay: time.Millisecond, | |
| 1304 | + | DrainDelay: time.Millisecond, | |
| 1291 | 1305 | } | |
| 1292 | 1306 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1293 | 1307 | ||
| @@ -1329,11 +1343,14 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||
| 1329 | 1343 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1330 | 1344 | envLoader := mocks.NewMockEnvLoader(t) | |
| 1331 | 1345 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1346 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1332 | 1347 | ||
| 1333 | 1348 | config := Config{ | |
| 1334 | 1349 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 1350 | + | DrainDelay: time.Millisecond, | |
| 1335 | 1351 | } | |
| 1336 | 1352 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1353 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 1337 | 1354 | ctx := testContext() | |
| 1338 | 1355 | ||
| 1339 | 1356 | // Pre-populate with existing tracked container | |
| @@ -1388,6 +1405,9 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||
| 1388 | 1405 | // Publish event | |
| 1389 | 1406 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 1390 | 1407 | ||
| 1408 | + | // Synchronous cache invalidation | |
| 1409 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |
| 1410 | + | ||
| 1391 | 1411 | // NOW (after new container is ready) the old container should be stopped and removed | |
| 1392 | 1412 | // This is the correct zero-downtime sequence - not during orphan cleanup | |
| 1393 | 1413 | runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil) | |
| @@ -1416,6 +1436,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |||
| 1416 | 1436 | ||
| 1417 | 1437 | config := Config{ | |
| 1418 | 1438 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 1439 | + | DrainDelay: time.Millisecond, | |
| 1419 | 1440 | } | |
| 1420 | 1441 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1421 | 1442 | ctx := testContext() | |
| @@ -1485,11 +1506,14 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||
| 1485 | 1506 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1486 | 1507 | envLoader := mocks.NewMockEnvLoader(t) | |
| 1487 | 1508 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1509 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1488 | 1510 | ||
| 1489 | 1511 | config := Config{ | |
| 1490 | 1512 | ReadinessDelay: time.Millisecond, | |
| 1513 | + | DrainDelay: time.Millisecond, | |
| 1491 | 1514 | } | |
| 1492 | 1515 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1516 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 1493 | 1517 | ctx := testContext() | |
| 1494 | 1518 | ||
| 1495 | 1519 | // Tracked canonical container exists, but stale -new container should be removed. | |
| @@ -1537,6 +1561,9 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||
| 1537 | 1561 | }, nil) | |
| 1538 | 1562 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 1539 | 1563 | ||
| 1564 | + | // Synchronous cache invalidation | |
| 1565 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |
| 1566 | + | ||
| 1540 | 1567 | runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil) | |
| 1541 | 1568 | runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil) | |
| 1542 | 1569 | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| @@ -1551,11 +1578,14 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) | |||
| 1551 | 1578 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1552 | 1579 | envLoader := mocks.NewMockEnvLoader(t) | |
| 1553 | 1580 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1581 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1554 | 1582 | ||
| 1555 | 1583 | config := Config{ | |
| 1556 | 1584 | ReadinessDelay: time.Millisecond, | |
| 1585 | + | DrainDelay: time.Millisecond, | |
| 1557 | 1586 | } | |
| 1558 | 1587 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1588 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 1559 | 1589 | ctx := testContext() | |
| 1560 | 1590 | ||
| 1561 | 1591 | // Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name. | |
| @@ -1596,6 +1626,9 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) | |||
| 1596 | 1626 | }, nil) | |
| 1597 | 1627 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 1598 | 1628 | ||
| 1629 | + | // Synchronous cache invalidation | |
| 1630 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |
| 1631 | + | ||
| 1599 | 1632 | runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil) | |
| 1600 | 1633 | runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil) | |
| 1601 | 1634 | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| @@ -1734,11 +1767,14 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||
| 1734 | 1767 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1735 | 1768 | envLoader := mocks.NewMockEnvLoader(t) | |
| 1736 | 1769 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1770 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1737 | 1771 | ||
| 1738 | 1772 | config := Config{ | |
| 1739 | 1773 | ReadinessDelay: time.Millisecond, | |
| 1774 | + | DrainDelay: time.Millisecond, | |
| 1740 | 1775 | } | |
| 1741 | 1776 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1777 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 1742 | 1778 | ctx := testContext() | |
| 1743 | 1779 | ||
| 1744 | 1780 | route := domain.Route{ | |
| @@ -1800,7 +1836,9 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||
| 1800 | 1836 | ||
| 1801 | 1837 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2) | |
| 1802 | 1838 | ||
| 1803 | - | // The second deploy will see the first container and do zero-downtime swap, | |
| 1839 | + | // Both deploys call InvalidateTarget (to ensure proxy picks up the new container). | |
| 1840 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2) | |
| 1841 | + | ||
| 1804 | 1842 | // which means it will also stop+remove+rename the old container. | |
| 1805 | 1843 | runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once() | |
| 1806 | 1844 | runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once() | |
| @@ -1840,6 +1878,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) { | |||
| 1840 | 1878 | ||
| 1841 | 1879 | config := Config{ | |
| 1842 | 1880 | ReadinessDelay: time.Millisecond, | |
| 1881 | + | DrainDelay: time.Millisecond, | |
| 1843 | 1882 | } | |
| 1844 | 1883 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1845 | 1884 | ||
| @@ -1856,3 +1895,135 @@ func TestService_Deploy_ContextCancellation(t *testing.T) { | |||
| 1856 | 1895 | assert.Error(t, err, "deploy should fail with cancelled context") | |
| 1857 | 1896 | assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled") | |
| 1858 | 1897 | } | |
| 1898 | + | ||
| 1899 | + | // TestService_Deploy_CacheInvalidationBeforeOldContainerStop verifies the fix for | |
| 1900 | + | // the proxy cache race condition: InvalidateTarget must be called synchronously | |
| 1901 | + | // BEFORE the old container is stopped, preventing 503 errors during deployment. | |
| 1902 | + | func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) { | |
| 1903 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 1904 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 1905 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1906 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1907 | + | ||
| 1908 | + | config := Config{ | |
| 1909 | + | ReadinessDelay: time.Millisecond, | |
| 1910 | + | DrainDelay: time.Millisecond, | |
| 1911 | + | } | |
| 1912 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1913 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 1914 | + | ctx := testContext() | |
| 1915 | + | ||
| 1916 | + | // Pre-populate with existing container | |
| 1917 | + | existingContainer := &domain.Container{ | |
| 1918 | + | ID: "old-container", | |
| 1919 | + | Name: "gordon-test.example.com", | |
| 1920 | + | Status: "running", | |
| 1921 | + | } | |
| 1922 | + | svc.containers["test.example.com"] = existingContainer | |
| 1923 | + | ||
| 1924 | + | route := domain.Route{ | |
| 1925 | + | Domain: "test.example.com", | |
| 1926 | + | Image: "myapp:v2", | |
| 1927 | + | } | |
| 1928 | + | ||
| 1929 | + | // Track ordering of cache invalidation and container stop | |
| 1930 | + | var callOrder []string | |
| 1931 | + | var orderMu sync.Mutex | |
| 1932 | + | ||
| 1933 | + | // Standard deploy mocks | |
| 1934 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 1935 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil) | |
| 1936 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 1937 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 1938 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 1939 | + | ||
| 1940 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 1941 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | |
| 1942 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 1943 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 1944 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 1945 | + | ID: "new-container", Status: "running", | |
| 1946 | + | }, nil) | |
| 1947 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 1948 | + | ||
| 1949 | + | // Track: cache invalidation should happen first | |
| 1950 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com"). | |
| 1951 | + | Run(func(_ context.Context, _ string) { | |
| 1952 | + | orderMu.Lock() | |
| 1953 | + | callOrder = append(callOrder, "invalidate_cache") | |
| 1954 | + | orderMu.Unlock() | |
| 1955 | + | }).Return() | |
| 1956 | + | ||
| 1957 | + | // Track: stop should happen after invalidation | |
| 1958 | + | runtime.EXPECT().StopContainer(mock.Anything, "old-container"). | |
| 1959 | + | RunAndReturn(func(_ context.Context, _ string) error { | |
| 1960 | + | orderMu.Lock() | |
| 1961 | + | callOrder = append(callOrder, "stop_old_container") | |
| 1962 | + | orderMu.Unlock() | |
| 1963 | + | return nil | |
| 1964 | + | }) | |
| 1965 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | |
| 1966 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| 1967 | + | ||
| 1968 | + | result, err := svc.Deploy(ctx, route) | |
| 1969 | + | ||
| 1970 | + | assert.NoError(t, err) | |
| 1971 | + | assert.Equal(t, "new-container", result.ID) | |
| 1972 | + | ||
| 1973 | + | // Verify ordering: cache invalidation MUST happen before old container stop | |
| 1974 | + | orderMu.Lock() | |
| 1975 | + | defer orderMu.Unlock() | |
| 1976 | + | assert.Equal(t, []string{"invalidate_cache", "stop_old_container"}, callOrder, | |
| 1977 | + | "InvalidateTarget must be called before StopContainer to prevent 503 errors") | |
| 1978 | + | } | |
| 1979 | + | ||
| 1980 | + | // TestService_Deploy_NilCacheInvalidator verifies that deploy works gracefully | |
| 1981 | + | // when no cache invalidator is set (e.g., in tests or minimal configurations). | |
| 1982 | + | func TestService_Deploy_NilCacheInvalidator(t *testing.T) { | |
| 1983 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 1984 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 1985 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1986 | + | ||
| 1987 | + | config := Config{ | |
| 1988 | + | ReadinessDelay: time.Millisecond, | |
| 1989 | + | DrainDelay: time.Millisecond, | |
| 1990 | + | } | |
| 1991 | + | // Intentionally NOT setting cache invalidator | |
| 1992 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1993 | + | ctx := testContext() | |
| 1994 | + | ||
| 1995 | + | // Pre-populate with existing container | |
| 1996 | + | svc.containers["test.example.com"] = &domain.Container{ | |
| 1997 | + | ID: "old-container", | |
| 1998 | + | Name: "gordon-test.example.com", | |
| 1999 | + | Status: "running", | |
| 2000 | + | } | |
| 2001 | + | ||
| 2002 | + | route := domain.Route{ | |
| 2003 | + | Domain: "test.example.com", | |
| 2004 | + | Image: "myapp:v2", | |
| 2005 | + | } | |
| 2006 | + | ||
| 2007 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 2008 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil) | |
| 2009 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 2010 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2011 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 2012 | + | ||
| 2013 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2014 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | |
| 2015 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2016 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 2017 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2018 | + | ID: "new-container", Status: "running", | |
| 2019 | + | }, nil) | |
| 2020 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 2021 | + | runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil) | |
| 2022 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | |
| 2023 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| 2024 | + | ||
| 2025 | + | result, err := svc.Deploy(ctx, route) | |
| 2026 | + | ||
| 2027 | + | assert.NoError(t, err) | |
| 2028 | + | assert.Equal(t, "new-container", result.ID) | |
| 2029 | + | } | |
| @@ -29,6 +29,7 @@ func TestService_Deploy_Success(t *testing.T) { | |||||
| 29 | NetworkIsolation: false, | 29 | NetworkIsolation: false, | ||
| 30 | VolumeAutoCreate: false, | 30 | VolumeAutoCreate: false, | ||
| 31 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 31 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | ||
| 32 | + | DrainDelay: time.Millisecond, | |||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 35 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| @@ -105,6 +106,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T | |||||
| 105 | NetworkIsolation: false, | 106 | NetworkIsolation: false, | ||
| 106 | VolumeAutoCreate: false, | 107 | VolumeAutoCreate: false, | ||
| 107 | ReadinessDelay: time.Millisecond, | 108 | ReadinessDelay: time.Millisecond, | ||
| 109 | + | DrainDelay: time.Millisecond, | |||
| 108 | } | 110 | } | ||
| 109 | 111 | ||||
| 110 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 112 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| @@ -243,11 +245,14 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||||
| 243 | runtime := mocks.NewMockContainerRuntime(t) | 245 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 244 | envLoader := mocks.NewMockEnvLoader(t) | 246 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 245 | eventBus := mocks.NewMockEventPublisher(t) | 247 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 248 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 246 | 249 | ||||
| 247 | config := Config{ | 250 | config := Config{ | ||
| 248 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 251 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | ||
| 252 | + | DrainDelay: time.Millisecond, | |||
| 249 | } | 253 | } | ||
| 250 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 254 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 255 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |||
| 251 | ctx := testContext() | 256 | ctx := testContext() | ||
| 252 | 257 | ||||
| 253 | // Pre-populate with existing container | 258 | // Pre-populate with existing container | ||
| @@ -294,7 +299,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||||
| 294 | // Publish event | 299 | // Publish event | ||
| 295 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | 300 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | ||
| 296 | 301 | ||||
| 297 | - | // Now cleanup old container (after new one is ready) | 302 | + | // Synchronous cache invalidation before old container cleanup |
| 303 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |||
| 304 | + | ||||
| 305 | + | // Now cleanup old container (after new one is ready + cache invalidated + drain delay) | |||
| 298 | runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil) | 306 | runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil) | ||
| 299 | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | 307 | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | ||
| 300 | 308 | ||||
| @@ -321,6 +329,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||||
| 321 | NetworkIsolation: true, | 329 | NetworkIsolation: true, | ||
| 322 | NetworkPrefix: "gordon", | 330 | NetworkPrefix: "gordon", | ||
| 323 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 331 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | ||
| 332 | + | DrainDelay: time.Millisecond, | |||
| 324 | } | 333 | } | ||
| 325 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 334 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 326 | ctx := testContext() | 335 | ctx := testContext() | ||
| @@ -372,6 +381,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||||
| 372 | VolumeAutoCreate: true, | 381 | VolumeAutoCreate: true, | ||
| 373 | VolumePrefix: "gordon", | 382 | VolumePrefix: "gordon", | ||
| 374 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 383 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | ||
| 384 | + | DrainDelay: time.Millisecond, | |||
| 375 | } | 385 | } | ||
| 376 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 386 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 377 | ctx := testContext() | 387 | ctx := testContext() | ||
| @@ -1107,6 +1117,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |||||
| 1107 | InternalRegistryUsername: "internal", | 1117 | InternalRegistryUsername: "internal", | ||
| 1108 | InternalRegistryPassword: "secret", | 1118 | InternalRegistryPassword: "secret", | ||
| 1109 | ReadinessDelay: time.Millisecond, | 1119 | ReadinessDelay: time.Millisecond, | ||
| 1120 | + | DrainDelay: time.Millisecond, | |||
| 1110 | } | 1121 | } | ||
| 1111 | 1122 | ||||
| 1112 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1123 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| @@ -1175,6 +1186,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||||
| 1175 | 1186 | ||||
| 1176 | config := Config{ | 1187 | config := Config{ | ||
| 1177 | ReadinessDelay: time.Millisecond, | 1188 | ReadinessDelay: time.Millisecond, | ||
| 1189 | + | DrainDelay: time.Millisecond, | |||
| 1178 | } | 1190 | } | ||
| 1179 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1191 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1180 | ctx := testContext() | 1192 | ctx := testContext() | ||
| @@ -1218,6 +1230,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||||
| 1218 | 1230 | ||||
| 1219 | config := Config{ | 1231 | config := Config{ | ||
| 1220 | ReadinessDelay: time.Millisecond, | 1232 | ReadinessDelay: time.Millisecond, | ||
| 1233 | + | DrainDelay: time.Millisecond, | |||
| 1221 | } | 1234 | } | ||
| 1222 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1235 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1223 | ctx := testContext() | 1236 | ctx := testContext() | ||
| @@ -1288,6 +1301,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||||
| 1288 | RegistryDomain: "reg.example.com", | 1301 | RegistryDomain: "reg.example.com", | ||
| 1289 | RegistryPort: 5000, | 1302 | RegistryPort: 5000, | ||
| 1290 | ReadinessDelay: time.Millisecond, | 1303 | ReadinessDelay: time.Millisecond, | ||
| 1304 | + | DrainDelay: time.Millisecond, | |||
| 1291 | } | 1305 | } | ||
| 1292 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1306 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1293 | 1307 | ||||
| @@ -1329,11 +1343,14 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||||
| 1329 | runtime := mocks.NewMockContainerRuntime(t) | 1343 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 1330 | envLoader := mocks.NewMockEnvLoader(t) | 1344 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 1331 | eventBus := mocks.NewMockEventPublisher(t) | 1345 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1346 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 1332 | 1347 | ||||
| 1333 | config := Config{ | 1348 | config := Config{ | ||
| 1334 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 1349 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | ||
| 1350 | + | DrainDelay: time.Millisecond, | |||
| 1335 | } | 1351 | } | ||
| 1336 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1352 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1353 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |||
| 1337 | ctx := testContext() | 1354 | ctx := testContext() | ||
| 1338 | 1355 | ||||
| 1339 | // Pre-populate with existing tracked container | 1356 | // Pre-populate with existing tracked container | ||
| @@ -1388,6 +1405,9 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||||
| 1388 | // Publish event | 1405 | // Publish event | ||
| 1389 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | 1406 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | ||
| 1390 | 1407 | ||||
| 1408 | + | // Synchronous cache invalidation | |||
| 1409 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |||
| 1410 | + | ||||
| 1391 | // NOW (after new container is ready) the old container should be stopped and removed | 1411 | // NOW (after new container is ready) the old container should be stopped and removed | ||
| 1392 | // This is the correct zero-downtime sequence - not during orphan cleanup | 1412 | // This is the correct zero-downtime sequence - not during orphan cleanup | ||
| 1393 | runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil) | 1413 | runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil) | ||
| @@ -1416,6 +1436,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |||||
| 1416 | 1436 | ||||
| 1417 | config := Config{ | 1437 | config := Config{ | ||
| 1418 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 1438 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | ||
| 1439 | + | DrainDelay: time.Millisecond, | |||
| 1419 | } | 1440 | } | ||
| 1420 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1441 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1421 | ctx := testContext() | 1442 | ctx := testContext() | ||
| @@ -1485,11 +1506,14 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||||
| 1485 | runtime := mocks.NewMockContainerRuntime(t) | 1506 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 1486 | envLoader := mocks.NewMockEnvLoader(t) | 1507 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 1487 | eventBus := mocks.NewMockEventPublisher(t) | 1508 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1509 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 1488 | 1510 | ||||
| 1489 | config := Config{ | 1511 | config := Config{ | ||
| 1490 | ReadinessDelay: time.Millisecond, | 1512 | ReadinessDelay: time.Millisecond, | ||
| 1513 | + | DrainDelay: time.Millisecond, | |||
| 1491 | } | 1514 | } | ||
| 1492 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1515 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1516 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |||
| 1493 | ctx := testContext() | 1517 | ctx := testContext() | ||
| 1494 | 1518 | ||||
| 1495 | // Tracked canonical container exists, but stale -new container should be removed. | 1519 | // Tracked canonical container exists, but stale -new container should be removed. | ||
| @@ -1537,6 +1561,9 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||||
| 1537 | }, nil) | 1561 | }, nil) | ||
| 1538 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | 1562 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | ||
| 1539 | 1563 | ||||
| 1564 | + | // Synchronous cache invalidation | |||
| 1565 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |||
| 1566 | + | ||||
| 1540 | runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil) | 1567 | runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil) | ||
| 1541 | runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil) | 1568 | runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil) | ||
| 1542 | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | 1569 | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | ||
| @@ -1551,11 +1578,14 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) | |||||
| 1551 | runtime := mocks.NewMockContainerRuntime(t) | 1578 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 1552 | envLoader := mocks.NewMockEnvLoader(t) | 1579 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 1553 | eventBus := mocks.NewMockEventPublisher(t) | 1580 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1581 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 1554 | 1582 | ||||
| 1555 | config := Config{ | 1583 | config := Config{ | ||
| 1556 | ReadinessDelay: time.Millisecond, | 1584 | ReadinessDelay: time.Millisecond, | ||
| 1585 | + | DrainDelay: time.Millisecond, | |||
| 1557 | } | 1586 | } | ||
| 1558 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1587 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1588 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |||
| 1559 | ctx := testContext() | 1589 | ctx := testContext() | ||
| 1560 | 1590 | ||||
| 1561 | // Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name. | 1591 | // Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name. | ||
| @@ -1596,6 +1626,9 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) | |||||
| 1596 | }, nil) | 1626 | }, nil) | ||
| 1597 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | 1627 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | ||
| 1598 | 1628 | ||||
| 1629 | + | // Synchronous cache invalidation | |||
| 1630 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |||
| 1631 | + | ||||
| 1599 | runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil) | 1632 | runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil) | ||
| 1600 | runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil) | 1633 | runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil) | ||
| 1601 | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | 1634 | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | ||
| @@ -1734,11 +1767,14 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||||
| 1734 | runtime := mocks.NewMockContainerRuntime(t) | 1767 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 1735 | envLoader := mocks.NewMockEnvLoader(t) | 1768 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 1736 | eventBus := mocks.NewMockEventPublisher(t) | 1769 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1770 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 1737 | 1771 | ||||
| 1738 | config := Config{ | 1772 | config := Config{ | ||
| 1739 | ReadinessDelay: time.Millisecond, | 1773 | ReadinessDelay: time.Millisecond, | ||
| 1774 | + | DrainDelay: time.Millisecond, | |||
| 1740 | } | 1775 | } | ||
| 1741 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1776 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1777 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |||
| 1742 | ctx := testContext() | 1778 | ctx := testContext() | ||
| 1743 | 1779 | ||||
| 1744 | route := domain.Route{ | 1780 | route := domain.Route{ | ||
| @@ -1800,7 +1836,9 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||||
| 1800 | 1836 | ||||
| 1801 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2) | 1837 | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2) | ||
| 1802 | 1838 | ||||
| 1803 | - | // The second deploy will see the first container and do zero-downtime swap, | 1839 | + | // Both deploys call InvalidateTarget (to ensure proxy picks up the new container). |
| 1840 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2) | |||
| 1841 | + | ||||
| 1804 | // which means it will also stop+remove+rename the old container. | 1842 | // which means it will also stop+remove+rename the old container. | ||
| 1805 | runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once() | 1843 | runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once() | ||
| 1806 | runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once() | 1844 | runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once() | ||
| @@ -1840,6 +1878,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) { | |||||
| 1840 | 1878 | ||||
| 1841 | config := Config{ | 1879 | config := Config{ | ||
| 1842 | ReadinessDelay: time.Millisecond, | 1880 | ReadinessDelay: time.Millisecond, | ||
| 1881 | + | DrainDelay: time.Millisecond, | |||
| 1843 | } | 1882 | } | ||
| 1844 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1883 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1845 | 1884 | ||||
| @@ -1856,3 +1895,135 @@ func TestService_Deploy_ContextCancellation(t *testing.T) { | |||||
| 1856 | assert.Error(t, err, "deploy should fail with cancelled context") | 1895 | assert.Error(t, err, "deploy should fail with cancelled context") | ||
| 1857 | assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled") | 1896 | assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled") | ||
| 1858 | } | 1897 | } | ||
| 1898 | + | ||||
| 1899 | + | // TestService_Deploy_CacheInvalidationBeforeOldContainerStop verifies the fix for | |||
| 1900 | + | // the proxy cache race condition: InvalidateTarget must be called synchronously | |||
| 1901 | + | // BEFORE the old container is stopped, preventing 503 errors during deployment. | |||
| 1902 | + | func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) { | |||
| 1903 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 1904 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 1905 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1906 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 1907 | + | ||||
| 1908 | + | config := Config{ | |||
| 1909 | + | ReadinessDelay: time.Millisecond, | |||
| 1910 | + | DrainDelay: time.Millisecond, | |||
| 1911 | + | } | |||
| 1912 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 1913 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |||
| 1914 | + | ctx := testContext() | |||
| 1915 | + | ||||
| 1916 | + | // Pre-populate with existing container | |||
| 1917 | + | existingContainer := &domain.Container{ | |||
| 1918 | + | ID: "old-container", | |||
| 1919 | + | Name: "gordon-test.example.com", | |||
| 1920 | + | Status: "running", | |||
| 1921 | + | } | |||
| 1922 | + | svc.containers["test.example.com"] = existingContainer | |||
| 1923 | + | ||||
| 1924 | + | route := domain.Route{ | |||
| 1925 | + | Domain: "test.example.com", | |||
| 1926 | + | Image: "myapp:v2", | |||
| 1927 | + | } | |||
| 1928 | + | ||||
| 1929 | + | // Track ordering of cache invalidation and container stop | |||
| 1930 | + | var callOrder []string | |||
| 1931 | + | var orderMu sync.Mutex | |||
| 1932 | + | ||||
| 1933 | + | // Standard deploy mocks | |||
| 1934 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 1935 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil) | |||
| 1936 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 1937 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 1938 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 1939 | + | ||||
| 1940 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 1941 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | |||
| 1942 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 1943 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |||
| 1944 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 1945 | + | ID: "new-container", Status: "running", | |||
| 1946 | + | }, nil) | |||
| 1947 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 1948 | + | ||||
| 1949 | + | // Track: cache invalidation should happen first | |||
| 1950 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com"). | |||
| 1951 | + | Run(func(_ context.Context, _ string) { | |||
| 1952 | + | orderMu.Lock() | |||
| 1953 | + | callOrder = append(callOrder, "invalidate_cache") | |||
| 1954 | + | orderMu.Unlock() | |||
| 1955 | + | }).Return() | |||
| 1956 | + | ||||
| 1957 | + | // Track: stop should happen after invalidation | |||
| 1958 | + | runtime.EXPECT().StopContainer(mock.Anything, "old-container"). | |||
| 1959 | + | RunAndReturn(func(_ context.Context, _ string) error { | |||
| 1960 | + | orderMu.Lock() | |||
| 1961 | + | callOrder = append(callOrder, "stop_old_container") | |||
| 1962 | + | orderMu.Unlock() | |||
| 1963 | + | return nil | |||
| 1964 | + | }) | |||
| 1965 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | |||
| 1966 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |||
| 1967 | + | ||||
| 1968 | + | result, err := svc.Deploy(ctx, route) | |||
| 1969 | + | ||||
| 1970 | + | assert.NoError(t, err) | |||
| 1971 | + | assert.Equal(t, "new-container", result.ID) | |||
| 1972 | + | ||||
| 1973 | + | // Verify ordering: cache invalidation MUST happen before old container stop | |||
| 1974 | + | orderMu.Lock() | |||
| 1975 | + | defer orderMu.Unlock() | |||
| 1976 | + | assert.Equal(t, []string{"invalidate_cache", "stop_old_container"}, callOrder, | |||
| 1977 | + | "InvalidateTarget must be called before StopContainer to prevent 503 errors") | |||
| 1978 | + | } | |||
| 1979 | + | ||||
| 1980 | + | // TestService_Deploy_NilCacheInvalidator verifies that deploy works gracefully | |||
| 1981 | + | // when no cache invalidator is set (e.g., in tests or minimal configurations). | |||
| 1982 | + | func TestService_Deploy_NilCacheInvalidator(t *testing.T) { | |||
| 1983 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 1984 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 1985 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1986 | + | ||||
| 1987 | + | config := Config{ | |||
| 1988 | + | ReadinessDelay: time.Millisecond, | |||
| 1989 | + | DrainDelay: time.Millisecond, | |||
| 1990 | + | } | |||
| 1991 | + | // Intentionally NOT setting cache invalidator | |||
| 1992 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 1993 | + | ctx := testContext() | |||
| 1994 | + | ||||
| 1995 | + | // Pre-populate with existing container | |||
| 1996 | + | svc.containers["test.example.com"] = &domain.Container{ | |||
| 1997 | + | ID: "old-container", | |||
| 1998 | + | Name: "gordon-test.example.com", | |||
| 1999 | + | Status: "running", | |||
| 2000 | + | } | |||
| 2001 | + | ||||
| 2002 | + | route := domain.Route{ | |||
| 2003 | + | Domain: "test.example.com", | |||
| 2004 | + | Image: "myapp:v2", | |||
| 2005 | + | } | |||
| 2006 | + | ||||
| 2007 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 2008 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil) | |||
| 2009 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 2010 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2011 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 2012 | + | ||||
| 2013 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 2014 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | |||
| 2015 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 2016 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |||
| 2017 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 2018 | + | ID: "new-container", Status: "running", | |||
| 2019 | + | }, nil) | |||
| 2020 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 2021 | + | runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil) | |||
| 2022 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | |||
| 2023 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |||
| 2024 | + | ||||
| 2025 | + | result, err := svc.Deploy(ctx, route) | |||
| 2026 | + | ||||
| 2027 | + | assert.NoError(t, err) | |||
| 2028 | + | assert.Equal(t, "new-container", result.ID) | |||
| 2029 | + | } | |||