detached 1838ab2

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
460460 svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
461461 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)
462462
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+
463467 // Create token handler for registry token endpoint
464468 if svc.authSvc != nil {
465469 internalAuth := authhandler.InternalAuth{
@@ -460,6 +460,10 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
460 svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize460 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 endpoint467 // 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 {
3636 NetworkGroups map[string][]string
3737 Attachments map[string][]string
3838 ReadinessDelay time.Duration // Delay after container starts before considering it ready
39+ DrainDelay time.Duration // Grace period after cache invalidation before stopping old container
3940 }
4041
4142 const (
@@ -52,15 +53,16 @@ const (
5253
5354 // Service implements the ContainerService interface.
5455 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)
6466 }
6567
6668 // domainDeployLock is a context-aware mutex using a buffered channel.
@@ -126,6 +128,16 @@ func NewService(
126128 }
127129 }
128130
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+
129141 // domainDeployMu returns a per-domain lock for serializing deploy operations.
130142 func (s *Service) domainDeployMu(domain string) *domainDeployLock {
131143 v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock())
@@ -289,10 +301,32 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
289301 s.containers[route.Domain] = newContainer
290302 s.mu.Unlock()
291303
292- // Publish container deployed event (proxy will invalidate cache)
304+ // Publish container deployed event (other subscribers)
293305 s.publishContainerDeployed(ctx, route.Domain, newContainer.ID)
294306
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
296330 if hasExisting {
297331 s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain)
298332 }
@@ -36,6 +36,7 @@ type Config struct {
36 NetworkGroups map[string][]string36 NetworkGroups map[string][]string
37 Attachments map[string][]string37 Attachments map[string][]string
38 ReadinessDelay time.Duration // Delay after container starts before considering it ready38 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.ContainerRuntime56+ runtime out.ContainerRuntime
56- envLoader out.EnvLoader57+ envLoader out.EnvLoader
57- eventBus out.EventPublisher58+ eventBus out.EventPublisher
58- logWriter out.ContainerLogWriter59+ logWriter out.ContainerLogWriter
59- config Config60+ cacheInvalidator out.ProxyCacheInvalidator
60- containers map[string]*domain.Container61+ config Config
61- attachments map[string][]string // ownerDomain → []containerIDs62+ containers map[string]*domain.Container
62- mu sync.RWMutex63+ 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] = newContainer301 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) {
2929 NetworkIsolation: false,
3030 VolumeAutoCreate: false,
3131 ReadinessDelay: time.Millisecond, // Minimal delay for tests
32+ DrainDelay: time.Millisecond,
3233 }
3334
3435 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -105,6 +106,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
105106 NetworkIsolation: false,
106107 VolumeAutoCreate: false,
107108 ReadinessDelay: time.Millisecond,
109+ DrainDelay: time.Millisecond,
108110 }
109111
110112 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -243,11 +245,14 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
243245 runtime := mocks.NewMockContainerRuntime(t)
244246 envLoader := mocks.NewMockEnvLoader(t)
245247 eventBus := mocks.NewMockEventPublisher(t)
248+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
246249
247250 config := Config{
248251 ReadinessDelay: time.Millisecond, // Minimal delay for tests
252+ DrainDelay: time.Millisecond,
249253 }
250254 svc := NewService(runtime, envLoader, eventBus, nil, config)
255+ svc.SetProxyCacheInvalidator(cacheInvalidator)
251256 ctx := testContext()
252257
253258 // Pre-populate with existing container
@@ -294,7 +299,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
294299 // Publish event
295300 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
296301
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)
298306 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
299307 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
300308
@@ -321,6 +329,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
321329 NetworkIsolation: true,
322330 NetworkPrefix: "gordon",
323331 ReadinessDelay: time.Millisecond, // Minimal delay for tests
332+ DrainDelay: time.Millisecond,
324333 }
325334 svc := NewService(runtime, envLoader, eventBus, nil, config)
326335 ctx := testContext()
@@ -372,6 +381,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
372381 VolumeAutoCreate: true,
373382 VolumePrefix: "gordon",
374383 ReadinessDelay: time.Millisecond, // Minimal delay for tests
384+ DrainDelay: time.Millisecond,
375385 }
376386 svc := NewService(runtime, envLoader, eventBus, nil, config)
377387 ctx := testContext()
@@ -1107,6 +1117,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
11071117 InternalRegistryUsername: "internal",
11081118 InternalRegistryPassword: "secret",
11091119 ReadinessDelay: time.Millisecond,
1120+ DrainDelay: time.Millisecond,
11101121 }
11111122
11121123 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -1175,6 +1186,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
11751186
11761187 config := Config{
11771188 ReadinessDelay: time.Millisecond,
1189+ DrainDelay: time.Millisecond,
11781190 }
11791191 svc := NewService(runtime, envLoader, eventBus, nil, config)
11801192 ctx := testContext()
@@ -1218,6 +1230,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
12181230
12191231 config := Config{
12201232 ReadinessDelay: time.Millisecond,
1233+ DrainDelay: time.Millisecond,
12211234 }
12221235 svc := NewService(runtime, envLoader, eventBus, nil, config)
12231236 ctx := testContext()
@@ -1288,6 +1301,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
12881301 RegistryDomain: "reg.example.com",
12891302 RegistryPort: 5000,
12901303 ReadinessDelay: time.Millisecond,
1304+ DrainDelay: time.Millisecond,
12911305 }
12921306 svc := NewService(runtime, envLoader, eventBus, nil, config)
12931307
@@ -1329,11 +1343,14 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
13291343 runtime := mocks.NewMockContainerRuntime(t)
13301344 envLoader := mocks.NewMockEnvLoader(t)
13311345 eventBus := mocks.NewMockEventPublisher(t)
1346+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
13321347
13331348 config := Config{
13341349 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1350+ DrainDelay: time.Millisecond,
13351351 }
13361352 svc := NewService(runtime, envLoader, eventBus, nil, config)
1353+ svc.SetProxyCacheInvalidator(cacheInvalidator)
13371354 ctx := testContext()
13381355
13391356 // Pre-populate with existing tracked container
@@ -1388,6 +1405,9 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
13881405 // Publish event
13891406 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
13901407
1408+ // Synchronous cache invalidation
1409+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1410+
13911411 // NOW (after new container is ready) the old container should be stopped and removed
13921412 // This is the correct zero-downtime sequence - not during orphan cleanup
13931413 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
@@ -1416,6 +1436,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
14161436
14171437 config := Config{
14181438 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1439+ DrainDelay: time.Millisecond,
14191440 }
14201441 svc := NewService(runtime, envLoader, eventBus, nil, config)
14211442 ctx := testContext()
@@ -1485,11 +1506,14 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
14851506 runtime := mocks.NewMockContainerRuntime(t)
14861507 envLoader := mocks.NewMockEnvLoader(t)
14871508 eventBus := mocks.NewMockEventPublisher(t)
1509+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
14881510
14891511 config := Config{
14901512 ReadinessDelay: time.Millisecond,
1513+ DrainDelay: time.Millisecond,
14911514 }
14921515 svc := NewService(runtime, envLoader, eventBus, nil, config)
1516+ svc.SetProxyCacheInvalidator(cacheInvalidator)
14931517 ctx := testContext()
14941518
14951519 // Tracked canonical container exists, but stale -new container should be removed.
@@ -1537,6 +1561,9 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
15371561 }, nil)
15381562 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
15391563
1564+ // Synchronous cache invalidation
1565+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1566+
15401567 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
15411568 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
15421569 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
@@ -1551,11 +1578,14 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
15511578 runtime := mocks.NewMockContainerRuntime(t)
15521579 envLoader := mocks.NewMockEnvLoader(t)
15531580 eventBus := mocks.NewMockEventPublisher(t)
1581+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
15541582
15551583 config := Config{
15561584 ReadinessDelay: time.Millisecond,
1585+ DrainDelay: time.Millisecond,
15571586 }
15581587 svc := NewService(runtime, envLoader, eventBus, nil, config)
1588+ svc.SetProxyCacheInvalidator(cacheInvalidator)
15591589 ctx := testContext()
15601590
15611591 // 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)
15961626 }, nil)
15971627 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
15981628
1629+ // Synchronous cache invalidation
1630+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1631+
15991632 runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil)
16001633 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil)
16011634 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
@@ -1734,11 +1767,14 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
17341767 runtime := mocks.NewMockContainerRuntime(t)
17351768 envLoader := mocks.NewMockEnvLoader(t)
17361769 eventBus := mocks.NewMockEventPublisher(t)
1770+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
17371771
17381772 config := Config{
17391773 ReadinessDelay: time.Millisecond,
1774+ DrainDelay: time.Millisecond,
17401775 }
17411776 svc := NewService(runtime, envLoader, eventBus, nil, config)
1777+ svc.SetProxyCacheInvalidator(cacheInvalidator)
17421778 ctx := testContext()
17431779
17441780 route := domain.Route{
@@ -1800,7 +1836,9 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
18001836
18011837 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)
18021838
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+
18041842 // which means it will also stop+remove+rename the old container.
18051843 runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
18061844 runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
@@ -1840,6 +1878,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
18401878
18411879 config := Config{
18421880 ReadinessDelay: time.Millisecond,
1881+ DrainDelay: time.Millisecond,
18431882 }
18441883 svc := NewService(runtime, envLoader, eventBus, nil, config)
18451884
@@ -1856,3 +1895,135 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
18561895 assert.Error(t, err, "deploy should fail with cancelled context")
18571896 assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
18581897 }
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 tests31 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 tests251 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 container258 // Pre-populate with existing container
@@ -294,7 +299,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
294 // Publish event299 // 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 tests331 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 tests383 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 tests1349 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 container1356 // Pre-populate with existing tracked container
@@ -1388,6 +1405,9 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1388 // Publish event1405 // 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 removed1411 // 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 cleanup1412 // 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 tests1438 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+}

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close