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