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