1package container
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/bnema/zerowrap"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"
14
15 "github.com/bnema/gordon/internal/boundaries/out/mocks"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19func testContext() context.Context {
20 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
21}
22
23func TestService_Deploy_Success(t *testing.T) {
24 runtime := mocks.NewMockContainerRuntime(t)
25 envLoader := mocks.NewMockEnvLoader(t)
26 eventBus := mocks.NewMockEventPublisher(t)
27
28 config := Config{
29 NetworkIsolation: false,
30 VolumeAutoCreate: false,
31 ReadinessDelay: time.Millisecond, // Minimal delay for tests
32 DrainDelay: time.Millisecond,
33 }
34
35 svc := NewService(runtime, envLoader, eventBus, nil, config)
36 ctx := testContext()
37
38 route := domain.Route{
39 Domain: "test.example.com",
40 Image: "myapp:latest",
41 }
42
43 // Setup mocks - no orphaned containers
44 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
45
46 // Image is not found locally, needs to be pulled
47 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
48 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
49
50 // Get exposed ports
51 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
52
53 // Load environment
54 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{"FOO=bar"}, nil)
55
56 // Inspect image env
57 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{"DEFAULT=value"}, nil)
58
59 // No volume auto-create, so no volume calls
60
61 // Create and start container
62 createdContainer := &domain.Container{
63 ID: "container-123",
64 Name: "gordon-test.example.com",
65 Image: "myapp:latest",
66 Status: "created",
67 }
68 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
69 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
70
71 // Wait for ready: IsContainerRunning (first check returns true) + verify after delay
72 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
73
74 // Re-inspect after start
75 runningContainer := &domain.Container{
76 ID: "container-123",
77 Name: "gordon-test.example.com",
78 Image: "myapp:latest",
79 Status: "running",
80 Ports: []int{8080},
81 }
82 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
83
84 // Publish container deployed event
85 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
86
87 result, err := svc.Deploy(ctx, route)
88
89 assert.NoError(t, err)
90 assert.NotNil(t, result)
91 assert.Equal(t, "container-123", result.ID)
92 assert.Equal(t, "running", result.Status)
93
94 // Verify container is tracked
95 tracked, exists := svc.Get(ctx, "test.example.com")
96 assert.True(t, exists)
97 assert.Equal(t, "container-123", tracked.ID)
98}
99
100func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T) {
101 runtime := mocks.NewMockContainerRuntime(t)
102 envLoader := mocks.NewMockEnvLoader(t)
103 eventBus := mocks.NewMockEventPublisher(t)
104
105 config := Config{
106 NetworkIsolation: false,
107 VolumeAutoCreate: false,
108 ReadinessDelay: time.Millisecond,
109 DrainDelay: time.Millisecond,
110 }
111
112 svc := NewService(runtime, envLoader, eventBus, nil, config)
113 ctx := testContext()
114
115 route := domain.Route{
116 Domain: "test.example.com",
117 Image: "myapp:latest",
118 }
119
120 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
121 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
122 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
123 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
124 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
125 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
126
127 createdContainer := &domain.Container{
128 ID: "container-123",
129 Name: "gordon-test.example.com",
130 Image: "myapp:latest",
131 Status: "created",
132 }
133 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
134 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
135
136 // Readiness check sequence:
137 // 1) Initial "started" check => running
138 // 2) Post-delay verification => transient not-running
139 // 3) Recovery-window poll => running again
140 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once()
141 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(false, nil).Once()
142 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once()
143
144 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
145 ID: "container-123",
146 Name: "gordon-test.example.com",
147 Image: "myapp:latest",
148 Status: "running",
149 Ports: []int{8080},
150 }, nil)
151 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
152
153 result, err := svc.Deploy(ctx, route)
154
155 assert.NoError(t, err)
156 assert.NotNil(t, result)
157 assert.Equal(t, "container-123", result.ID)
158}
159
160func TestService_Deploy_ImagePullFailure(t *testing.T) {
161 runtime := mocks.NewMockContainerRuntime(t)
162 envLoader := mocks.NewMockEnvLoader(t)
163 eventBus := mocks.NewMockEventPublisher(t)
164
165 config := Config{}
166 svc := NewService(runtime, envLoader, eventBus, nil, config)
167 ctx := testContext()
168
169 route := domain.Route{
170 Domain: "test.example.com",
171 Image: "myapp:latest",
172 }
173
174 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
175 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
176 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(errors.New("image not found"))
177
178 result, err := svc.Deploy(ctx, route)
179
180 assert.Error(t, err)
181 assert.Nil(t, result)
182 assert.Contains(t, err.Error(), "failed to pull image")
183}
184
185func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
186 runtime := mocks.NewMockContainerRuntime(t)
187
188 config := Config{
189 RegistryAuthEnabled: true,
190 ServiceTokenUsername: "gordon-service",
191 ServiceToken: "service-token",
192 }
193
194 svc := NewService(runtime, nil, nil, nil, config)
195 ctx := testContext()
196
197 runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil)
198
199 err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
200
201 assert.NoError(t, err)
202}
203
204func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
205 runtime := mocks.NewMockContainerRuntime(t)
206
207 config := Config{
208 RegistryAuthEnabled: true,
209 }
210
211 svc := NewService(runtime, nil, nil, nil, config)
212 ctx := testContext()
213
214 err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
215
216 assert.Error(t, err)
217 assert.Contains(t, err.Error(), "registry service token not configured")
218}
219
220func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {
221 runtime := mocks.NewMockContainerRuntime(t)
222
223 config := Config{
224 RegistryAuthEnabled: true,
225 InternalRegistryUsername: "internal",
226 InternalRegistryPassword: "secret",
227 }
228
229 svc := NewService(runtime, nil, nil, nil, config)
230 ctx := testContext()
231
232 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").
233 Return(errors.New("connection refused")).
234 Once()
235 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").
236 Return(nil).
237 Once()
238
239 err := svc.pullImage(ctx, "localhost:5000/myapp:latest", true)
240
241 assert.NoError(t, err)
242}
243
244func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
245 runtime := mocks.NewMockContainerRuntime(t)
246 envLoader := mocks.NewMockEnvLoader(t)
247 eventBus := mocks.NewMockEventPublisher(t)
248 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
249
250 config := Config{
251 ReadinessDelay: time.Millisecond, // Minimal delay for tests
252 DrainDelay: time.Millisecond,
253 }
254 svc := NewService(runtime, envLoader, eventBus, nil, config)
255 svc.SetProxyCacheInvalidator(cacheInvalidator)
256 ctx := testContext()
257
258 // Pre-populate with existing container
259 existingContainer := &domain.Container{
260 ID: "old-container",
261 Name: "gordon-test.example.com",
262 Status: "running",
263 }
264 svc.containers["test.example.com"] = existingContainer
265
266 route := domain.Route{
267 Domain: "test.example.com",
268 Image: "myapp:v2",
269 }
270
271 // Cleanup orphans
272 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
273
274 // Image operations
275 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
276 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
277 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
278
279 // Environment
280 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
281 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
282
283 // Create new container (with -new suffix for zero-downtime)
284 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
285 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
286 return cfg.Name == "gordon-test.example.com-new"
287 })).Return(newContainer, nil)
288 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
289
290 // Wait for ready
291 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
292
293 // Inspect after ready
294 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
295 ID: "new-container",
296 Status: "running",
297 }, nil)
298
299 // Publish event
300 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
301
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)
306 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
307 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
308
309 // Rename new container to canonical name
310 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
311
312 result, err := svc.Deploy(ctx, route)
313
314 assert.NoError(t, err)
315 assert.Equal(t, "new-container", result.ID)
316
317 // Old container should be replaced
318 tracked, exists := svc.Get(ctx, "test.example.com")
319 assert.True(t, exists)
320 assert.Equal(t, "new-container", tracked.ID)
321}
322
323func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
324 runtime := mocks.NewMockContainerRuntime(t)
325 envLoader := mocks.NewMockEnvLoader(t)
326 eventBus := mocks.NewMockEventPublisher(t)
327
328 config := Config{
329 NetworkIsolation: true,
330 NetworkPrefix: "gordon",
331 ReadinessDelay: time.Millisecond, // Minimal delay for tests
332 DrainDelay: time.Millisecond,
333 }
334 svc := NewService(runtime, envLoader, eventBus, nil, config)
335 ctx := testContext()
336
337 route := domain.Route{
338 Domain: "test.example.com",
339 Image: "myapp:latest",
340 }
341
342 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
343 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
344 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
345 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
346 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
347
348 // Network isolation - should check and create network
349 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil)
350 runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", map[string]string{"driver": "bridge"}).Return(nil)
351
352 container := &domain.Container{ID: "container-123", Status: "created"}
353 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
354 return cfg.NetworkMode == "gordon-test-example-com"
355 })).Return(container, nil)
356 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
357
358 // Wait for ready
359 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
360
361 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
362 ID: "container-123",
363 Status: "running",
364 }, nil)
365
366 // Publish event
367 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
368
369 result, err := svc.Deploy(ctx, route)
370
371 assert.NoError(t, err)
372 assert.NotNil(t, result)
373}
374
375func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
376 runtime := mocks.NewMockContainerRuntime(t)
377 envLoader := mocks.NewMockEnvLoader(t)
378 eventBus := mocks.NewMockEventPublisher(t)
379
380 config := Config{
381 VolumeAutoCreate: true,
382 VolumePrefix: "gordon",
383 ReadinessDelay: time.Millisecond, // Minimal delay for tests
384 DrainDelay: time.Millisecond,
385 }
386 svc := NewService(runtime, envLoader, eventBus, nil, config)
387 ctx := testContext()
388
389 route := domain.Route{
390 Domain: "test.example.com",
391 Image: "myapp:latest",
392 }
393
394 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
395 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
396 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
397 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
398 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
399
400 // Volume operations
401 runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data", "/config"}, nil)
402 runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-data").Return(false, nil)
403 runtime.EXPECT().CreateVolume(mock.Anything, "gordon-test-example-com-data").Return(nil)
404 runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-config").Return(true, nil)
405
406 container := &domain.Container{ID: "container-123", Status: "created"}
407 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
408 return len(cfg.Volumes) == 2
409 })).Return(container, nil)
410 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
411
412 // Wait for ready
413 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
414
415 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
416 ID: "container-123",
417 Status: "running",
418 }, nil)
419
420 // Publish event
421 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
422
423 result, err := svc.Deploy(ctx, route)
424
425 assert.NoError(t, err)
426 assert.NotNil(t, result)
427}
428
429func TestService_Stop(t *testing.T) {
430 runtime := mocks.NewMockContainerRuntime(t)
431 envLoader := mocks.NewMockEnvLoader(t)
432 eventBus := mocks.NewMockEventPublisher(t)
433
434 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
435 ctx := testContext()
436
437 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(nil)
438
439 err := svc.Stop(ctx, "container-123")
440
441 assert.NoError(t, err)
442}
443
444func TestService_Stop_Error(t *testing.T) {
445 runtime := mocks.NewMockContainerRuntime(t)
446 envLoader := mocks.NewMockEnvLoader(t)
447 eventBus := mocks.NewMockEventPublisher(t)
448
449 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
450 ctx := testContext()
451
452 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(errors.New("container not found"))
453
454 err := svc.Stop(ctx, "container-123")
455
456 assert.Error(t, err)
457 assert.Contains(t, err.Error(), "failed to stop container")
458}
459
460func TestService_Remove(t *testing.T) {
461 runtime := mocks.NewMockContainerRuntime(t)
462 envLoader := mocks.NewMockEnvLoader(t)
463 eventBus := mocks.NewMockEventPublisher(t)
464
465 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
466 ctx := testContext()
467
468 // Add tracked container
469 svc.containers["test.example.com"] = &domain.Container{
470 ID: "container-123",
471 Name: "gordon-test.example.com",
472 }
473
474 runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)
475
476 err := svc.Remove(ctx, "container-123", true)
477
478 assert.NoError(t, err)
479
480 // Verify container is no longer tracked
481 _, exists := svc.Get(ctx, "test.example.com")
482 assert.False(t, exists)
483}
484
485func TestService_Remove_WithNetworkCleanup(t *testing.T) {
486 runtime := mocks.NewMockContainerRuntime(t)
487 envLoader := mocks.NewMockEnvLoader(t)
488 eventBus := mocks.NewMockEventPublisher(t)
489
490 config := Config{
491 NetworkIsolation: true,
492 NetworkPrefix: "gordon",
493 }
494 svc := NewService(runtime, envLoader, eventBus, nil, config)
495 ctx := testContext()
496
497 svc.containers["test.example.com"] = &domain.Container{
498 ID: "container-123",
499 Name: "gordon-test.example.com",
500 }
501
502 runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)
503 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
504 {Name: "gordon-test-example-com", Containers: []string{}},
505 }, nil)
506 runtime.EXPECT().RemoveNetwork(mock.Anything, "gordon-test-example-com").Return(nil)
507
508 err := svc.Remove(ctx, "container-123", true)
509
510 assert.NoError(t, err)
511}
512
513func TestService_Get(t *testing.T) {
514 runtime := mocks.NewMockContainerRuntime(t)
515 envLoader := mocks.NewMockEnvLoader(t)
516 eventBus := mocks.NewMockEventPublisher(t)
517
518 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
519 ctx := testContext()
520
521 container := &domain.Container{ID: "container-123"}
522 svc.containers["test.example.com"] = container
523
524 result, exists := svc.Get(ctx, "test.example.com")
525
526 assert.True(t, exists)
527 assert.Equal(t, "container-123", result.ID)
528}
529
530func TestService_Get_NotFound(t *testing.T) {
531 runtime := mocks.NewMockContainerRuntime(t)
532 envLoader := mocks.NewMockEnvLoader(t)
533 eventBus := mocks.NewMockEventPublisher(t)
534
535 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
536 ctx := testContext()
537
538 result, exists := svc.Get(ctx, "nonexistent.example.com")
539
540 assert.False(t, exists)
541 assert.Nil(t, result)
542}
543
544func TestService_List(t *testing.T) {
545 runtime := mocks.NewMockContainerRuntime(t)
546 envLoader := mocks.NewMockEnvLoader(t)
547 eventBus := mocks.NewMockEventPublisher(t)
548
549 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
550 ctx := testContext()
551
552 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
553 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
554
555 result := svc.List(ctx)
556
557 assert.Len(t, result, 2)
558 assert.Equal(t, "container-1", result["app1.example.com"].ID)
559 assert.Equal(t, "container-2", result["app2.example.com"].ID)
560}
561
562func TestService_HealthCheck(t *testing.T) {
563 runtime := mocks.NewMockContainerRuntime(t)
564 envLoader := mocks.NewMockEnvLoader(t)
565 eventBus := mocks.NewMockEventPublisher(t)
566
567 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
568 ctx := testContext()
569
570 svc.containers["healthy.example.com"] = &domain.Container{ID: "healthy-container"}
571 svc.containers["unhealthy.example.com"] = &domain.Container{ID: "unhealthy-container"}
572
573 runtime.EXPECT().IsContainerRunning(mock.Anything, "healthy-container").Return(true, nil)
574 runtime.EXPECT().IsContainerRunning(mock.Anything, "unhealthy-container").Return(false, nil)
575
576 result := svc.HealthCheck(ctx)
577
578 assert.True(t, result["healthy.example.com"])
579 assert.False(t, result["unhealthy.example.com"])
580}
581
582func TestService_SyncContainers(t *testing.T) {
583 runtime := mocks.NewMockContainerRuntime(t)
584 envLoader := mocks.NewMockEnvLoader(t)
585 eventBus := mocks.NewMockEventPublisher(t)
586
587 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
588 ctx := testContext()
589
590 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
591 {
592 ID: "container-1",
593 Name: "gordon-app1.example.com",
594 Labels: map[string]string{
595 "gordon.domain": "app1.example.com",
596 "gordon.managed": "true",
597 },
598 },
599 {
600 ID: "container-2",
601 Name: "gordon-app2.example.com",
602 Labels: map[string]string{
603 "gordon.domain": "app2.example.com",
604 "gordon.managed": "true",
605 },
606 },
607 {
608 ID: "unmanaged-container",
609 Name: "some-other-app",
610 Labels: map[string]string{},
611 },
612 }, nil)
613
614 err := svc.SyncContainers(ctx)
615
616 assert.NoError(t, err)
617 assert.Len(t, svc.containers, 2)
618 assert.Contains(t, svc.containers, "app1.example.com")
619 assert.Contains(t, svc.containers, "app2.example.com")
620}
621
622func TestService_Shutdown(t *testing.T) {
623 runtime := mocks.NewMockContainerRuntime(t)
624 envLoader := mocks.NewMockEnvLoader(t)
625 eventBus := mocks.NewMockEventPublisher(t)
626
627 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
628 ctx := testContext()
629
630 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
631 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
632
633 // Shutdown no longer stops containers — they are left running for
634 // SyncContainers + AutoStart to pick back up on next boot.
635 err := svc.Shutdown(ctx)
636
637 assert.NoError(t, err)
638 // Containers remain tracked (not stopped).
639 assert.Len(t, svc.containers, 2)
640}
641
642func TestService_Restart_NotFound(t *testing.T) {
643 runtime := mocks.NewMockContainerRuntime(t)
644 envLoader := mocks.NewMockEnvLoader(t)
645 eventBus := mocks.NewMockEventPublisher(t)
646
647 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
648 ctx := testContext()
649
650 err := svc.Restart(ctx, "nonexistent.example.com", false)
651
652 assert.Error(t, err)
653 assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
654}
655
656func TestService_Restart_Success(t *testing.T) {
657 runtime := mocks.NewMockContainerRuntime(t)
658 envLoader := mocks.NewMockEnvLoader(t)
659 eventBus := mocks.NewMockEventPublisher(t)
660
661 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
662 ctx := testContext()
663
664 svc.containers["test.example.com"] = &domain.Container{
665 ID: "container-123",
666 Name: "gordon-test.example.com",
667 Status: "running",
668 }
669
670 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
671
672 err := svc.Restart(ctx, "test.example.com", false)
673
674 assert.NoError(t, err)
675}
676
677func TestService_Restart_Error(t *testing.T) {
678 runtime := mocks.NewMockContainerRuntime(t)
679 envLoader := mocks.NewMockEnvLoader(t)
680 eventBus := mocks.NewMockEventPublisher(t)
681
682 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
683 ctx := testContext()
684
685 svc.containers["test.example.com"] = &domain.Container{
686 ID: "container-123",
687 Name: "gordon-test.example.com",
688 Status: "running",
689 }
690
691 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(errors.New("restart failed"))
692
693 err := svc.Restart(ctx, "test.example.com", false)
694
695 assert.Error(t, err)
696 assert.Contains(t, err.Error(), "failed to restart container")
697}
698
699func TestService_Restart_ReconcilesStaleContainerID(t *testing.T) {
700 runtime := mocks.NewMockContainerRuntime(t)
701 envLoader := mocks.NewMockEnvLoader(t)
702 eventBus := mocks.NewMockEventPublisher(t)
703
704 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
705 ctx := testContext()
706
707 svc.containers["test.example.com"] = &domain.Container{
708 ID: "stale-container-id",
709 Name: "gordon-test.example.com",
710 Status: "running",
711 }
712
713 runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
714 Return(errors.New("no container with name or ID \"stale-container-id\" found")).
715 Once()
716 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
717 {
718 ID: "fresh-container-id",
719 Name: "gordon-test.example.com",
720 Labels: map[string]string{
721 "gordon.domain": "test.example.com",
722 "gordon.managed": "true",
723 },
724 },
725 }, nil).Once()
726 runtime.EXPECT().RestartContainer(mock.Anything, "fresh-container-id").Return(nil).Once()
727
728 err := svc.Restart(ctx, "test.example.com", false)
729
730 assert.NoError(t, err)
731}
732
733func TestService_Restart_ReconcilesStaleContainerID_NotFoundAfterSync(t *testing.T) {
734 runtime := mocks.NewMockContainerRuntime(t)
735 envLoader := mocks.NewMockEnvLoader(t)
736 eventBus := mocks.NewMockEventPublisher(t)
737
738 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
739 ctx := testContext()
740
741 svc.containers["test.example.com"] = &domain.Container{
742 ID: "stale-container-id",
743 Name: "gordon-test.example.com",
744 Status: "running",
745 }
746
747 runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
748 Return(errors.New("no such container")).
749 Once()
750 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
751
752 err := svc.Restart(ctx, "test.example.com", false)
753
754 assert.Error(t, err)
755 assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
756}
757
758func TestService_Restart_WithAttachments(t *testing.T) {
759 runtime := mocks.NewMockContainerRuntime(t)
760 envLoader := mocks.NewMockEnvLoader(t)
761 eventBus := mocks.NewMockEventPublisher(t)
762
763 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
764 ctx := testContext()
765
766 svc.containers["test.example.com"] = &domain.Container{
767 ID: "container-123",
768 Name: "gordon-test.example.com",
769 Status: "running",
770 }
771 svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"}
772
773 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
774 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
775 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-2").Return(fmt.Errorf("boom"))
776
777 err := svc.Restart(ctx, "test.example.com", true)
778
779 assert.NoError(t, err)
780}
781
782func TestNormalizeImageRef(t *testing.T) {
783 tests := []struct {
784 name string
785 input string
786 expected string
787 }{
788 {
789 name: "simple image",
790 input: "nginx",
791 expected: "docker.io/library/nginx",
792 },
793 {
794 name: "user/repo",
795 input: "user/myapp",
796 expected: "docker.io/user/myapp",
797 },
798 {
799 name: "registry/repo",
800 input: "gcr.io/project/image",
801 expected: "gcr.io/project/image",
802 },
803 {
804 name: "with tag",
805 input: "nginx:latest",
806 expected: "docker.io/library/nginx",
807 },
808 {
809 name: "localhost with port",
810 input: "localhost:5000/myapp:latest",
811 expected: "localhost:5000/myapp",
812 },
813 {
814 name: "localhost with port no tag",
815 input: "localhost:5000/myapp",
816 expected: "localhost:5000/myapp",
817 },
818 {
819 name: "registry domain with port",
820 input: "registry.example.com:5000/image:v1.0",
821 expected: "registry.example.com:5000/image",
822 },
823 }
824
825 for _, tt := range tests {
826 t.Run(tt.name, func(t *testing.T) {
827 result := normalizeImageRef(tt.input)
828 assert.Equal(t, tt.expected, result)
829 })
830 }
831}
832
833func TestGenerateVolumeName(t *testing.T) {
834 tests := []struct {
835 name string
836 prefix string
837 domain string
838 volumePath string
839 expected string
840 }{
841 {
842 name: "simple path",
843 prefix: "gordon",
844 domain: "app.example.com",
845 volumePath: "/data",
846 expected: "gordon-app-example-com-data",
847 },
848 {
849 name: "nested path",
850 prefix: "gordon",
851 domain: "app.example.com",
852 volumePath: "/var/lib/data",
853 expected: "gordon-app-example-com-var-lib-data",
854 },
855 }
856
857 for _, tt := range tests {
858 t.Run(tt.name, func(t *testing.T) {
859 result := generateVolumeName(tt.prefix, tt.domain, tt.volumePath)
860 assert.Equal(t, tt.expected, result)
861 })
862 }
863}
864
865func TestMergeEnvironmentVariables(t *testing.T) {
866 dockerEnv := []string{"FOO=docker", "BAR=docker"}
867 userEnv := []string{"FOO=user", "BAZ=user"}
868
869 result := mergeEnvironmentVariables(dockerEnv, userEnv)
870
871 // User env should override docker env
872 assert.Contains(t, result, "FOO=user")
873 assert.Contains(t, result, "BAR=docker")
874 assert.Contains(t, result, "BAZ=user")
875}
876
877func TestBuildImageRef(t *testing.T) {
878 tests := []struct {
879 name string
880 image string
881 registryAuthEnabled bool
882 registryDomain string
883 wantRef string
884 }{
885 {
886 name: "adds registry domain prefix",
887 image: "myapp:latest",
888 registryAuthEnabled: true,
889 registryDomain: "registry.example.com",
890 wantRef: "registry.example.com/myapp:latest",
891 },
892 {
893 name: "keeps existing registry domain prefix",
894 image: "registry.example.com/myapp:latest",
895 registryAuthEnabled: true,
896 registryDomain: "registry.example.com",
897 wantRef: "registry.example.com/myapp:latest",
898 },
899 {
900 name: "skips external registry images",
901 image: "docker.io/library/nginx:latest",
902 registryAuthEnabled: true,
903 registryDomain: "registry.example.com",
904 wantRef: "docker.io/library/nginx:latest",
905 },
906 {
907 name: "returns original when auth disabled",
908 image: "myapp:latest",
909 registryAuthEnabled: false,
910 registryDomain: "registry.example.com",
911 wantRef: "myapp:latest",
912 },
913 {
914 name: "localhost domain adds prefix",
915 image: "myapp:latest",
916 registryAuthEnabled: true,
917 registryDomain: "localhost:5000",
918 wantRef: "localhost:5000/myapp:latest",
919 },
920 {
921 name: "localhost domain keeps existing prefix",
922 image: "localhost:5000/myapp:latest",
923 registryAuthEnabled: true,
924 registryDomain: "localhost:5000",
925 wantRef: "localhost:5000/myapp:latest",
926 },
927 {
928 name: "explicit host:port different than RegistryDomain",
929 image: "localhost:5001/myapp:latest",
930 registryAuthEnabled: true,
931 registryDomain: "localhost:5000",
932 wantRef: "localhost:5001/myapp:latest",
933 },
934 {
935 name: "ghcr.io external registry",
936 image: "ghcr.io/owner/repo:latest",
937 registryAuthEnabled: true,
938 registryDomain: "localhost:5000",
939 wantRef: "ghcr.io/owner/repo:latest",
940 },
941 {
942 name: "gcr.io external registry",
943 image: "gcr.io/project/image:v1",
944 registryAuthEnabled: true,
945 registryDomain: "registry.example.com",
946 wantRef: "gcr.io/project/image:v1",
947 },
948 {
949 name: "quay.io external registry",
950 image: "quay.io/org/app:tag",
951 registryAuthEnabled: true,
952 registryDomain: "localhost:5000",
953 wantRef: "quay.io/org/app:tag",
954 },
955 {
956 name: "localhost without port keeps existing prefix",
957 image: "localhost/myapp:latest",
958 registryAuthEnabled: true,
959 registryDomain: "localhost:5000",
960 wantRef: "localhost/myapp:latest",
961 },
962 {
963 name: "registry domain with trailing slash",
964 image: "myapp:latest",
965 registryAuthEnabled: true,
966 registryDomain: "registry.example.com/",
967 wantRef: "registry.example.com/myapp:latest",
968 },
969 {
970 name: "ipv6 registry without port",
971 image: "[fd00::1]/myapp:latest",
972 registryAuthEnabled: true,
973 registryDomain: "localhost:5000",
974 wantRef: "[fd00::1]/myapp:latest",
975 },
976 {
977 name: "ipv6 registry with port",
978 image: "[fd00::1]:5000/myapp:latest",
979 registryAuthEnabled: true,
980 registryDomain: "localhost:5000",
981 wantRef: "[fd00::1]:5000/myapp:latest",
982 },
983 }
984
985 for _, tt := range tests {
986 t.Run(tt.name, func(t *testing.T) {
987 svc := &Service{
988 config: Config{
989 RegistryAuthEnabled: tt.registryAuthEnabled,
990 RegistryDomain: tt.registryDomain,
991 },
992 }
993 gotRef := svc.buildImageRef(tt.image)
994 assert.Equal(t, tt.wantRef, gotRef, "unexpected image reference")
995 })
996 }
997}
998
999func TestRewriteToRegistryDomain(t *testing.T) {
1000 tests := []struct {
1001 name string
1002 imageRef string
1003 registryDomain string
1004 wantRef string
1005 }{
1006 {
1007 name: "keeps registry domain prefix",
1008 imageRef: "registry.example.com/myapp:latest",
1009 registryDomain: "registry.example.com",
1010 wantRef: "registry.example.com/myapp:latest",
1011 },
1012 {
1013 name: "prefixes registry domain",
1014 imageRef: "myapp:v1.0",
1015 registryDomain: "registry.example.com",
1016 wantRef: "registry.example.com/myapp:v1.0",
1017 },
1018 {
1019 name: "prefixes external image",
1020 imageRef: "docker.io/library/nginx:latest",
1021 registryDomain: "registry.example.com",
1022 wantRef: "registry.example.com/docker.io/library/nginx:latest",
1023 },
1024 {
1025 name: "empty registry domain returns original",
1026 imageRef: "registry.example.com/myapp:latest",
1027 registryDomain: "",
1028 wantRef: "registry.example.com/myapp:latest",
1029 },
1030 }
1031
1032 for _, tt := range tests {
1033 t.Run(tt.name, func(t *testing.T) {
1034 gotRef := rewriteToRegistryDomain(tt.imageRef, tt.registryDomain)
1035 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
1036 })
1037 }
1038}
1039
1040func TestRewriteToLocalRegistry(t *testing.T) {
1041 tests := []struct {
1042 name string
1043 imageRef string
1044 registryDomain string
1045 registryPort int
1046 wantRef string
1047 }{
1048 {
1049 name: "rewrites registry domain prefix",
1050 imageRef: "registry.example.com/myapp:latest",
1051 registryDomain: "registry.example.com",
1052 registryPort: 5000,
1053 wantRef: "localhost:5000/myapp:latest",
1054 },
1055 {
1056 name: "prefixes local registry when no domain",
1057 imageRef: "myapp:v1.0",
1058 registryDomain: "registry.example.com",
1059 registryPort: 5000,
1060 wantRef: "localhost:5000/myapp:v1.0",
1061 },
1062 {
1063 name: "keeps existing localhost prefix",
1064 imageRef: "localhost:5000/myapp:latest",
1065 registryDomain: "registry.example.com",
1066 registryPort: 5000,
1067 wantRef: "localhost:5000/myapp:latest",
1068 },
1069 {
1070 name: "prefixes external image path",
1071 imageRef: "docker.io/library/nginx:latest",
1072 registryDomain: "registry.example.com",
1073 registryPort: 5000,
1074 wantRef: "localhost:5000/docker.io/library/nginx:latest",
1075 },
1076 }
1077
1078 for _, tt := range tests {
1079 t.Run(tt.name, func(t *testing.T) {
1080 gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)
1081 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
1082 })
1083 }
1084}
1085
1086func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1087 runtime := mocks.NewMockContainerRuntime(t)
1088 envLoader := mocks.NewMockEnvLoader(t)
1089 eventBus := mocks.NewMockEventPublisher(t)
1090
1091 config := Config{
1092 RegistryAuthEnabled: true,
1093 RegistryDomain: "registry.example.com",
1094 RegistryPort: 5000,
1095 InternalRegistryUsername: "internal",
1096 InternalRegistryPassword: "secret",
1097 ReadinessDelay: time.Millisecond,
1098 DrainDelay: time.Millisecond,
1099 }
1100
1101 svc := NewService(runtime, envLoader, eventBus, nil, config)
1102
1103 // Use internal deploy context (simulating image push event)
1104 ctx := domain.WithInternalDeploy(testContext())
1105
1106 route := domain.Route{
1107 Domain: "test.example.com",
1108 Image: "myapp:latest",
1109 }
1110
1111 // Setup mocks - no orphaned containers
1112 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1113
1114 // Even though image exists locally, internal deploy should force pull
1115 // Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check
1116 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
1117
1118 // Tag image to canonical name
1119 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil)
1120 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1121
1122 // Get exposed ports
1123 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
1124
1125 // Load environment
1126 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1127 runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil)
1128
1129 // Create and start container
1130 createdContainer := &domain.Container{
1131 ID: "container-123",
1132 Name: "gordon-test.example.com",
1133 Status: "created",
1134 }
1135 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
1136 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
1137
1138 // Wait for ready
1139 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
1140
1141 // Re-inspect after start
1142 runningContainer := &domain.Container{
1143 ID: "container-123",
1144 Name: "gordon-test.example.com",
1145 Status: "running",
1146 Ports: []int{8080},
1147 }
1148 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
1149
1150 // Publish event
1151 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1152
1153 result, err := svc.Deploy(ctx, route)
1154
1155 assert.NoError(t, err)
1156 assert.NotNil(t, result)
1157 assert.Equal(t, "container-123", result.ID)
1158}
1159
1160func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1161 runtime := mocks.NewMockContainerRuntime(t)
1162 envLoader := mocks.NewMockEnvLoader(t)
1163 eventBus := mocks.NewMockEventPublisher(t)
1164
1165 config := Config{
1166 ReadinessDelay: time.Millisecond,
1167 DrainDelay: time.Millisecond,
1168 }
1169 svc := NewService(runtime, envLoader, eventBus, nil, config)
1170 ctx := testContext()
1171
1172 routes := []domain.Route{
1173 {Domain: "app1.example.com", Image: "myapp1:latest"},
1174 {Domain: "app2.example.com", Image: "myapp2:latest"},
1175 }
1176
1177 // Setup mocks for route deployments
1178 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1179 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
1180 runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
1181 envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1182 runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1183
1184 // Create and start containers — readiness is skipped so no IsContainerRunning calls.
1185 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1186 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1187 }, nil).Once()
1188 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1189 ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
1190 }, nil).Once()
1191 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
1192 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
1193 Status: "running",
1194 }, nil).Times(2)
1195 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2)
1196
1197 err := svc.AutoStart(ctx, routes)
1198
1199 assert.NoError(t, err)
1200 assert.Len(t, svc.containers, 2)
1201}
1202
1203func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1204 runtime := mocks.NewMockContainerRuntime(t)
1205 envLoader := mocks.NewMockEnvLoader(t)
1206 eventBus := mocks.NewMockEventPublisher(t)
1207
1208 config := Config{
1209 ReadinessDelay: time.Millisecond,
1210 DrainDelay: time.Millisecond,
1211 }
1212 svc := NewService(runtime, envLoader, eventBus, nil, config)
1213 ctx := testContext()
1214
1215 // Pre-populate with existing container
1216 svc.containers["app1.example.com"] = &domain.Container{
1217 ID: "existing-container",
1218 }
1219
1220 routes := []domain.Route{
1221 {Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists
1222 {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
1223 }
1224
1225 // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning.
1226 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
1227 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
1228 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
1229 envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
1230 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
1231 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1232 ID: "container-2", Status: "created",
1233 }, nil).Once()
1234 runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
1235 runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
1236 Status: "running",
1237 }, nil).Once()
1238 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
1239
1240 err := svc.AutoStart(ctx, routes)
1241
1242 assert.NoError(t, err)
1243 assert.Len(t, svc.containers, 2)
1244}
1245
1246func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
1247 runtime := mocks.NewMockContainerRuntime(t)
1248 envLoader := mocks.NewMockEnvLoader(t)
1249 eventBus := mocks.NewMockEventPublisher(t)
1250
1251 config := Config{}
1252 svc := NewService(runtime, envLoader, eventBus, nil, config)
1253 ctx := testContext()
1254
1255 routes := []domain.Route{
1256 {Domain: "app1.example.com", Image: "myapp1:latest"},
1257 }
1258
1259 // Setup mocks for failure
1260 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1261 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1262 runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found"))
1263
1264 err := svc.AutoStart(ctx, routes)
1265
1266 // AutoStart should return error when some deployments fail
1267 assert.Error(t, err)
1268 assert.Contains(t, err.Error(), "auto-start completed with 1 errors")
1269}
1270
1271func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1272 runtime := mocks.NewMockContainerRuntime(t)
1273 envLoader := mocks.NewMockEnvLoader(t)
1274 eventBus := mocks.NewMockEventPublisher(t)
1275
1276 config := Config{
1277 RegistryDomain: "reg.example.com",
1278 RegistryPort: 5000,
1279 ReadinessDelay: time.Millisecond,
1280 DrainDelay: time.Millisecond,
1281 }
1282 svc := NewService(runtime, envLoader, eventBus, nil, config)
1283
1284 // Mark context as internal deploy — this is what syncAndAutoStart should do
1285 ctx := domain.WithInternalDeploy(testContext())
1286
1287 routes := []domain.Route{
1288 {Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"},
1289 }
1290
1291 // Key assertion: PullImage should be called with localhost:5000 rewrite,
1292 // NOT the original reg.example.com/myapp:latest.
1293 // Readiness is skipped — no IsContainerRunning calls.
1294 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1295 runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1296 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)
1297 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1298 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "reg.example.com/myapp:latest").Return([]int{8080}, nil)
1299 envLoader.EXPECT().LoadEnv(mock.Anything, "app1.example.com").Return([]string{}, nil)
1300 runtime.EXPECT().InspectImageEnv(mock.Anything, "reg.example.com/myapp:latest").Return([]string{}, nil)
1301 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1302 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1303 }, nil)
1304 runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil)
1305 runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{
1306 Status: "running",
1307 }, nil)
1308 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1309
1310 err := svc.AutoStart(ctx, routes)
1311 assert.NoError(t, err)
1312}
1313
1314// TestService_Deploy_OrphanCleanupSkipsTrackedContainer verifies that the orphan cleanup
1315// does not remove the currently tracked container during zero-downtime deployment.
1316// This is critical for preventing downtime - the old container must stay running
1317// until the new container is ready and traffic is switched.
1318func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1319 runtime := mocks.NewMockContainerRuntime(t)
1320 envLoader := mocks.NewMockEnvLoader(t)
1321 eventBus := mocks.NewMockEventPublisher(t)
1322 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1323
1324 config := Config{
1325 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1326 DrainDelay: time.Millisecond,
1327 }
1328 svc := NewService(runtime, envLoader, eventBus, nil, config)
1329 svc.SetProxyCacheInvalidator(cacheInvalidator)
1330 ctx := testContext()
1331
1332 // Pre-populate with existing tracked container
1333 existingContainer := &domain.Container{
1334 ID: "tracked-container-123",
1335 Name: "gordon-test.example.com",
1336 Status: "running",
1337 }
1338 svc.containers["test.example.com"] = existingContainer
1339
1340 route := domain.Route{
1341 Domain: "test.example.com",
1342 Image: "myapp:v2",
1343 }
1344
1345 // ListContainers returns the tracked container - this simulates the orphan check
1346 // finding a container with the same name. The bug was that it would remove this
1347 // container BEFORE the new one was ready, causing downtime.
1348 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1349 {
1350 ID: "tracked-container-123",
1351 Name: "gordon-test.example.com",
1352 Status: "running",
1353 },
1354 }, nil)
1355
1356 // Image operations
1357 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1358 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1359 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1360
1361 // Environment
1362 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1363 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1364
1365 // Create new container with -new suffix for zero-downtime
1366 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1367 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1368 return cfg.Name == "gordon-test.example.com-new"
1369 })).Return(newContainer, nil)
1370 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1371
1372 // Wait for ready
1373 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1374
1375 // Inspect after ready
1376 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1377 ID: "new-container",
1378 Status: "running",
1379 }, nil)
1380
1381 // Publish event
1382 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1383
1384 // Synchronous cache invalidation
1385 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1386
1387 // NOW (after new container is ready) the old container should be stopped and removed
1388 // This is the correct zero-downtime sequence - not during orphan cleanup
1389 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1390 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1391
1392 // Rename new container to canonical name
1393 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1394
1395 result, err := svc.Deploy(ctx, route)
1396
1397 assert.NoError(t, err)
1398 assert.Equal(t, "new-container", result.ID)
1399
1400 // Verify new container is now tracked
1401 tracked, exists := svc.Get(ctx, "test.example.com")
1402 assert.True(t, exists)
1403 assert.Equal(t, "new-container", tracked.ID)
1404}
1405
1406// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers
1407// with the same name but NOT tracked are properly removed as orphans.
1408func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1409 runtime := mocks.NewMockContainerRuntime(t)
1410 envLoader := mocks.NewMockEnvLoader(t)
1411 eventBus := mocks.NewMockEventPublisher(t)
1412
1413 config := Config{
1414 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1415 DrainDelay: time.Millisecond,
1416 }
1417 svc := NewService(runtime, envLoader, eventBus, nil, config)
1418 ctx := testContext()
1419
1420 // NO tracked container - service is empty for this domain
1421
1422 route := domain.Route{
1423 Domain: "test.example.com",
1424 Image: "myapp:v1",
1425 }
1426
1427 // ListContainers returns an orphaned container (same name, but not tracked)
1428 // This could happen if Gordon crashed and restarted, or container was created manually
1429 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1430 {
1431 ID: "orphan-container",
1432 Name: "gordon-test.example.com",
1433 Status: "running",
1434 },
1435 }, nil)
1436
1437 // Orphan should be stopped and removed BEFORE we proceed
1438 runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
1439 runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
1440
1441 // Image operations
1442 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1443 runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
1444 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
1445
1446 // Environment
1447 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1448 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v1").Return([]string{}, nil)
1449
1450 // Create container (no -new suffix since no existing tracked container)
1451 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
1452 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1453 return cfg.Name == "gordon-test.example.com"
1454 })).Return(newContainer, nil)
1455 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1456
1457 // Wait for ready
1458 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1459
1460 // Inspect after ready
1461 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1462 ID: "new-container",
1463 Status: "running",
1464 Ports: []int{8080},
1465 }, nil)
1466
1467 // Publish event
1468 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1469
1470 result, err := svc.Deploy(ctx, route)
1471
1472 assert.NoError(t, err)
1473 assert.Equal(t, "new-container", result.ID)
1474
1475 // Verify new container is tracked
1476 tracked, exists := svc.Get(ctx, "test.example.com")
1477 assert.True(t, exists)
1478 assert.Equal(t, "new-container", tracked.ID)
1479}
1480
1481func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
1482 runtime := mocks.NewMockContainerRuntime(t)
1483 envLoader := mocks.NewMockEnvLoader(t)
1484 eventBus := mocks.NewMockEventPublisher(t)
1485 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1486
1487 config := Config{
1488 ReadinessDelay: time.Millisecond,
1489 DrainDelay: time.Millisecond,
1490 }
1491 svc := NewService(runtime, envLoader, eventBus, nil, config)
1492 svc.SetProxyCacheInvalidator(cacheInvalidator)
1493 ctx := testContext()
1494
1495 // Tracked canonical container exists, but stale -new container should be removed.
1496 svc.containers["test.example.com"] = &domain.Container{
1497 ID: "tracked-container-123",
1498 Name: "gordon-test.example.com",
1499 Status: "running",
1500 }
1501
1502 route := domain.Route{
1503 Domain: "test.example.com",
1504 Image: "myapp:v2",
1505 }
1506
1507 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1508 {
1509 ID: "tracked-container-123",
1510 Name: "gordon-test.example.com",
1511 Status: "running",
1512 },
1513 {
1514 ID: "stale-new-container",
1515 Name: "gordon-test.example.com-new",
1516 Status: "exited",
1517 },
1518 }, nil)
1519 runtime.EXPECT().StopContainer(mock.Anything, "stale-new-container").Return(nil).Once()
1520 runtime.EXPECT().RemoveContainer(mock.Anything, "stale-new-container", true).Return(nil).Once()
1521
1522 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1523 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1524 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1525 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1526 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1527
1528 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1529 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1530 return cfg.Name == "gordon-test.example.com-new"
1531 })).Return(newContainer, nil)
1532 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1533 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1534 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1535 ID: "new-container",
1536 Status: "running",
1537 }, nil)
1538 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1539
1540 // Synchronous cache invalidation
1541 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1542
1543 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1544 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1545 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1546
1547 result, err := svc.Deploy(ctx, route)
1548 assert.NoError(t, err)
1549 assert.NotNil(t, result)
1550 assert.Equal(t, "new-container", result.ID)
1551}
1552
1553func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) {
1554 runtime := mocks.NewMockContainerRuntime(t)
1555 envLoader := mocks.NewMockEnvLoader(t)
1556 eventBus := mocks.NewMockEventPublisher(t)
1557 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1558
1559 config := Config{
1560 ReadinessDelay: time.Millisecond,
1561 DrainDelay: time.Millisecond,
1562 }
1563 svc := NewService(runtime, envLoader, eventBus, nil, config)
1564 svc.SetProxyCacheInvalidator(cacheInvalidator)
1565 ctx := testContext()
1566
1567 // Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name.
1568 svc.containers["test.example.com"] = &domain.Container{
1569 ID: "tracked-temp-container",
1570 Name: "gordon-test.example.com-new",
1571 Status: "running",
1572 }
1573
1574 route := domain.Route{
1575 Domain: "test.example.com",
1576 Image: "myapp:v2",
1577 }
1578
1579 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1580 {
1581 ID: "tracked-temp-container",
1582 Name: "gordon-test.example.com-new",
1583 Status: "running",
1584 },
1585 }, nil)
1586
1587 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1588 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1589 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1590 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1591 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1592
1593 created := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-next", Status: "created"}
1594 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1595 return cfg.Name == "gordon-test.example.com-next"
1596 })).Return(created, nil)
1597 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1598 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1599 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1600 ID: "new-container",
1601 Status: "running",
1602 }, nil)
1603 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1604
1605 // Synchronous cache invalidation
1606 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1607
1608 runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil)
1609 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil)
1610 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1611
1612 result, err := svc.Deploy(ctx, route)
1613 assert.NoError(t, err)
1614 assert.NotNil(t, result)
1615 assert.Equal(t, "new-container", result.ID)
1616}
1617
1618func TestService_StripRegistryPrefix(t *testing.T) {
1619 tests := []struct {
1620 name string
1621 registryDomain string
1622 image string
1623 expected string
1624 }{
1625 {
1626 name: "strips registry prefix",
1627 registryDomain: "reg.example.com",
1628 image: "reg.example.com/myapp:latest",
1629 expected: "myapp:latest",
1630 },
1631 {
1632 name: "strips registry prefix with trailing slash in domain",
1633 registryDomain: "reg.example.com/",
1634 image: "reg.example.com/myapp:v1.0",
1635 expected: "myapp:v1.0",
1636 },
1637 {
1638 name: "preserves image without registry prefix",
1639 registryDomain: "reg.example.com",
1640 image: "nginx:latest",
1641 expected: "nginx:latest",
1642 },
1643 {
1644 name: "preserves image with different registry",
1645 registryDomain: "reg.example.com",
1646 image: "gcr.io/project/image:latest",
1647 expected: "gcr.io/project/image:latest",
1648 },
1649 {
1650 name: "handles empty registry domain",
1651 registryDomain: "",
1652 image: "reg.example.com/myapp:latest",
1653 expected: "reg.example.com/myapp:latest",
1654 },
1655 {
1656 name: "handles nested paths",
1657 registryDomain: "reg.example.com",
1658 image: "reg.example.com/org/repo/app:latest",
1659 expected: "org/repo/app:latest",
1660 },
1661 }
1662
1663 for _, tt := range tests {
1664 t.Run(tt.name, func(t *testing.T) {
1665 runtime := mocks.NewMockContainerRuntime(t)
1666 envLoader := mocks.NewMockEnvLoader(t)
1667 eventBus := mocks.NewMockEventPublisher(t)
1668
1669 config := Config{
1670 RegistryDomain: tt.registryDomain,
1671 }
1672 svc := NewService(runtime, envLoader, eventBus, nil, config)
1673
1674 result := svc.stripRegistryPrefix(tt.image)
1675 assert.Equal(t, tt.expected, result)
1676 })
1677 }
1678}
1679
1680func TestSanitizeName(t *testing.T) {
1681 tests := []struct {
1682 domain string
1683 expected string
1684 description string
1685 }{
1686 {
1687 domain: "git.example.com",
1688 expected: "git__example__com",
1689 description: "Dots become double underscores",
1690 },
1691 {
1692 domain: "git-example.com",
1693 expected: "git-example__com",
1694 description: "Hyphens preserved, dots become underscores",
1695 },
1696 {
1697 domain: "app:8080.example.com",
1698 expected: "app-_8080__example__com",
1699 description: "Colons become hyphen-underscore",
1700 },
1701 {
1702 domain: "git.example.com:3000",
1703 expected: "git__example__com-_3000",
1704 description: "Multiple separators handled distinctly",
1705 },
1706 {
1707 domain: "simple.com",
1708 expected: "simple__com",
1709 description: "Simple domain",
1710 },
1711 }
1712
1713 for _, tt := range tests {
1714 t.Run(tt.domain, func(t *testing.T) {
1715 result := sanitizeName(tt.domain)
1716 assert.Equal(t, tt.expected, result, "sanitization should match expected")
1717 })
1718 }
1719
1720 // Verify no collisions between potentially conflicting domains
1721 t.Run("NoCollisions", func(t *testing.T) {
1722 domains := []string{
1723 "git.example.com",
1724 "git-example.com",
1725 "app:8080.example.com",
1726 "app-8080-example.com",
1727 }
1728
1729 results := make(map[string]string)
1730 for _, d := range domains {
1731 result := sanitizeName(d)
1732 if original, exists := results[result]; exists {
1733 t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
1734 }
1735 results[result] = d
1736 }
1737 })
1738}
1739
1740func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1741 // Verify that concurrent Deploy calls for the same domain are serialized
1742 // and both succeed without container name conflicts.
1743 runtime := mocks.NewMockContainerRuntime(t)
1744 envLoader := mocks.NewMockEnvLoader(t)
1745 eventBus := mocks.NewMockEventPublisher(t)
1746 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1747
1748 config := Config{
1749 ReadinessDelay: time.Millisecond,
1750 DrainDelay: time.Millisecond,
1751 }
1752 svc := NewService(runtime, envLoader, eventBus, nil, config)
1753 svc.SetProxyCacheInvalidator(cacheInvalidator)
1754 ctx := testContext()
1755
1756 route := domain.Route{
1757 Domain: "test.example.com",
1758 Image: "myapp:latest",
1759 }
1760
1761 // Track call order to verify serialization
1762 var callOrder []string
1763 var callMu sync.Mutex
1764
1765 // Setup mocks that will be called by both deploys sequentially.
1766 // First deploy: no existing container → creates gordon-test.example.com
1767 // Second deploy: sees first container → creates gordon-test.example.com-new
1768
1769 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1770 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
1771 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
1772 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, nil).Times(2)
1773 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
1774
1775 createCall := 0
1776 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
1777 RunAndReturn(func(_ context.Context, cfg *domain.ContainerConfig) (*domain.Container, error) {
1778 callMu.Lock()
1779 createCall++
1780 n := createCall
1781 callOrder = append(callOrder, fmt.Sprintf("create-%d:%s", n, cfg.Name))
1782 callMu.Unlock()
1783 return &domain.Container{
1784 ID: fmt.Sprintf("container-%d", n),
1785 Name: cfg.Name,
1786 Image: cfg.Image,
1787 Status: "created",
1788 }, nil
1789 })
1790
1791 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
1792 // IsContainerRunning is called 2x per deploy in waitForReady (initial check + after delay)
1793 runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
1794
1795 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
1796 RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
1797 // Return correct container name based on ID
1798 // container-1 is the first deploy (canonical name)
1799 // container-2 is the second deploy (with -new suffix)
1800 name := "gordon-test.example.com"
1801 if id == "container-2" {
1802 name = "gordon-test.example.com-new"
1803 }
1804 return &domain.Container{
1805 ID: id,
1806 Name: name,
1807 Image: "myapp:latest",
1808 Status: "running",
1809 Ports: []int{8080},
1810 }, nil
1811 })
1812
1813 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)
1814
1815 // Both deploys call InvalidateTarget (to ensure proxy picks up the new container).
1816 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
1817
1818 // which means it will also stop+remove+rename the old container.
1819 runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
1820 runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
1821 runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Once()
1822
1823 // Launch two concurrent deploys
1824 var wg sync.WaitGroup
1825 errs := make([]error, 2)
1826
1827 wg.Add(2)
1828 for i := range 2 {
1829 go func(idx int) {
1830 defer wg.Done()
1831 _, errs[idx] = svc.Deploy(ctx, route)
1832 }(i)
1833 }
1834 wg.Wait()
1835
1836 // Both should succeed (second deploy waits for the first to finish)
1837 assert.NoError(t, errs[0], "first deploy should succeed")
1838 assert.NoError(t, errs[1], "second deploy should succeed")
1839
1840 // Verify creates were serialized (not interleaved)
1841 callMu.Lock()
1842 assert.Len(t, callOrder, 2, "should have exactly 2 create calls")
1843 // First deploy uses canonical name, second uses -new suffix (zero-downtime)
1844 assert.Equal(t, "create-1:gordon-test.example.com", callOrder[0], "first create should use canonical name")
1845 assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
1846 callMu.Unlock()
1847}
1848
1849func TestService_Deploy_ContextCancellation(t *testing.T) {
1850 // Verify that deploy lock acquisition respects context cancellation.
1851 runtime := mocks.NewMockContainerRuntime(t)
1852 envLoader := mocks.NewMockEnvLoader(t)
1853 eventBus := mocks.NewMockEventPublisher(t)
1854
1855 config := Config{
1856 ReadinessDelay: time.Millisecond,
1857 DrainDelay: time.Millisecond,
1858 }
1859 svc := NewService(runtime, envLoader, eventBus, nil, config)
1860
1861 route := domain.Route{
1862 Domain: "test.example.com",
1863 Image: "myapp:latest",
1864 }
1865
1866 // Test context cancelled before lock acquisition.
1867 cancelledCtx, cancel := context.WithCancel(testContext())
1868 cancel() // Cancel immediately
1869
1870 _, err := svc.Deploy(cancelledCtx, route)
1871 assert.Error(t, err, "deploy should fail with cancelled context")
1872 assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
1873}
1874
1875// TestService_Deploy_CacheInvalidationBeforeOldContainerStop verifies the fix for
1876// the proxy cache race condition: InvalidateTarget must be called synchronously
1877// BEFORE the old container is stopped, preventing 503 errors during deployment.
1878func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
1879 runtime := mocks.NewMockContainerRuntime(t)
1880 envLoader := mocks.NewMockEnvLoader(t)
1881 eventBus := mocks.NewMockEventPublisher(t)
1882 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1883
1884 config := Config{
1885 ReadinessDelay: time.Millisecond,
1886 DrainDelay: time.Millisecond,
1887 }
1888 svc := NewService(runtime, envLoader, eventBus, nil, config)
1889 svc.SetProxyCacheInvalidator(cacheInvalidator)
1890 ctx := testContext()
1891
1892 // Pre-populate with existing container
1893 existingContainer := &domain.Container{
1894 ID: "old-container",
1895 Name: "gordon-test.example.com",
1896 Status: "running",
1897 }
1898 svc.containers["test.example.com"] = existingContainer
1899
1900 route := domain.Route{
1901 Domain: "test.example.com",
1902 Image: "myapp:v2",
1903 }
1904
1905 // Track ordering of cache invalidation and container stop
1906 var callOrder []string
1907 var orderMu sync.Mutex
1908
1909 // Standard deploy mocks
1910 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1911 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
1912 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1913 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1914 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1915
1916 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1917 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
1918 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1919 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1920 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1921 ID: "new-container", Status: "running",
1922 }, nil)
1923 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1924
1925 // Track: cache invalidation should happen first
1926 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").
1927 Run(func(_ context.Context, _ string) {
1928 orderMu.Lock()
1929 callOrder = append(callOrder, "invalidate_cache")
1930 orderMu.Unlock()
1931 }).Return()
1932
1933 // Track: stop should happen after invalidation
1934 runtime.EXPECT().StopContainer(mock.Anything, "old-container").
1935 RunAndReturn(func(_ context.Context, _ string) error {
1936 orderMu.Lock()
1937 callOrder = append(callOrder, "stop_old_container")
1938 orderMu.Unlock()
1939 return nil
1940 })
1941 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
1942 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1943
1944 result, err := svc.Deploy(ctx, route)
1945
1946 assert.NoError(t, err)
1947 assert.Equal(t, "new-container", result.ID)
1948
1949 // Verify ordering: cache invalidation MUST happen before old container stop
1950 orderMu.Lock()
1951 defer orderMu.Unlock()
1952 assert.Equal(t, []string{"invalidate_cache", "stop_old_container"}, callOrder,
1953 "InvalidateTarget must be called before StopContainer to prevent 503 errors")
1954}
1955
1956// TestService_Deploy_NilCacheInvalidator verifies that deploy works gracefully
1957// when no cache invalidator is set (e.g., in tests or minimal configurations).
1958func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
1959 runtime := mocks.NewMockContainerRuntime(t)
1960 envLoader := mocks.NewMockEnvLoader(t)
1961 eventBus := mocks.NewMockEventPublisher(t)
1962
1963 config := Config{
1964 ReadinessDelay: time.Millisecond,
1965 DrainDelay: time.Millisecond,
1966 }
1967 // Intentionally NOT setting cache invalidator
1968 svc := NewService(runtime, envLoader, eventBus, nil, config)
1969 ctx := testContext()
1970
1971 // Pre-populate with existing container
1972 svc.containers["test.example.com"] = &domain.Container{
1973 ID: "old-container",
1974 Name: "gordon-test.example.com",
1975 Status: "running",
1976 }
1977
1978 route := domain.Route{
1979 Domain: "test.example.com",
1980 Image: "myapp:v2",
1981 }
1982
1983 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1984 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
1985 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1986 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1987 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1988
1989 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1990 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
1991 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1992 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1993 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1994 ID: "new-container", Status: "running",
1995 }, nil)
1996 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1997 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
1998 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
1999 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2000
2001 result, err := svc.Deploy(ctx, route)
2002
2003 assert.NoError(t, err)
2004 assert.Equal(t, "new-container", result.ID)
2005}