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
1140func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1141 runtime := mocks.NewMockContainerRuntime(t)
1142 envLoader := mocks.NewMockEnvLoader(t)
1143 eventBus := mocks.NewMockEventPublisher(t)
1144
1145 config := Config{
1146 RegistryDomain: "reg.example.com",
1147 RegistryPort: 5000,
1148 ReadinessDelay: time.Millisecond,
1149 }
1150 svc := NewService(runtime, envLoader, eventBus, nil, config)
1151
1152 // Mark context as internal deploy — this is what syncAndAutoStart should do
1153 ctx := domain.WithInternalDeploy(testContext())
1154
1155 routes := []domain.Route{
1156 {Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"},
1157 }
1158
1159 // Key assertion: PullImage should be called with localhost:5000 rewrite,
1160 // NOT the original reg.example.com/myapp:latest
1161 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1162 runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1163 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)
1164 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1165 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "reg.example.com/myapp:latest").Return([]int{8080}, nil)
1166 envLoader.EXPECT().LoadEnv(mock.Anything, "app1.example.com").Return([]string{}, nil)
1167 runtime.EXPECT().InspectImageEnv(mock.Anything, "reg.example.com/myapp:latest").Return([]string{}, nil)
1168 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1169 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1170 }, nil)
1171 runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil)
1172 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-1").Return(true, nil).Times(2)
1173 runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{
1174 Status: "running",
1175 }, nil)
1176 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1177
1178 err := svc.AutoStart(ctx, routes)
1179 assert.NoError(t, err)
1180}
1181
1182// TestService_Deploy_OrphanCleanupSkipsTrackedContainer verifies that the orphan cleanup
1183// does not remove the currently tracked container during zero-downtime deployment.
1184// This is critical for preventing downtime - the old container must stay running
1185// until the new container is ready and traffic is switched.
1186func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1187 runtime := mocks.NewMockContainerRuntime(t)
1188 envLoader := mocks.NewMockEnvLoader(t)
1189 eventBus := mocks.NewMockEventPublisher(t)
1190
1191 config := Config{
1192 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1193 }
1194 svc := NewService(runtime, envLoader, eventBus, nil, config)
1195 ctx := testContext()
1196
1197 // Pre-populate with existing tracked container
1198 existingContainer := &domain.Container{
1199 ID: "tracked-container-123",
1200 Name: "gordon-test.example.com",
1201 Status: "running",
1202 }
1203 svc.containers["test.example.com"] = existingContainer
1204
1205 route := domain.Route{
1206 Domain: "test.example.com",
1207 Image: "myapp:v2",
1208 }
1209
1210 // ListContainers returns the tracked container - this simulates the orphan check
1211 // finding a container with the same name. The bug was that it would remove this
1212 // container BEFORE the new one was ready, causing downtime.
1213 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1214 {
1215 ID: "tracked-container-123",
1216 Name: "gordon-test.example.com",
1217 Status: "running",
1218 },
1219 }, nil)
1220
1221 // Image operations
1222 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1223 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1224 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1225
1226 // Environment
1227 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1228 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1229
1230 // Create new container with -new suffix for zero-downtime
1231 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1232 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1233 return cfg.Name == "gordon-test.example.com-new"
1234 })).Return(newContainer, nil)
1235 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1236
1237 // Wait for ready
1238 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1239
1240 // Inspect after ready
1241 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1242 ID: "new-container",
1243 Status: "running",
1244 }, nil)
1245
1246 // Publish event
1247 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1248
1249 // NOW (after new container is ready) the old container should be stopped and removed
1250 // This is the correct zero-downtime sequence - not during orphan cleanup
1251 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1252 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1253
1254 // Rename new container to canonical name
1255 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1256
1257 result, err := svc.Deploy(ctx, route)
1258
1259 assert.NoError(t, err)
1260 assert.Equal(t, "new-container", result.ID)
1261
1262 // Verify new container is now tracked
1263 tracked, exists := svc.Get(ctx, "test.example.com")
1264 assert.True(t, exists)
1265 assert.Equal(t, "new-container", tracked.ID)
1266}
1267
1268// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers
1269// with the same name but NOT tracked are properly removed as orphans.
1270func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1271 runtime := mocks.NewMockContainerRuntime(t)
1272 envLoader := mocks.NewMockEnvLoader(t)
1273 eventBus := mocks.NewMockEventPublisher(t)
1274
1275 config := Config{
1276 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1277 }
1278 svc := NewService(runtime, envLoader, eventBus, nil, config)
1279 ctx := testContext()
1280
1281 // NO tracked container - service is empty for this domain
1282
1283 route := domain.Route{
1284 Domain: "test.example.com",
1285 Image: "myapp:v1",
1286 }
1287
1288 // ListContainers returns an orphaned container (same name, but not tracked)
1289 // This could happen if Gordon crashed and restarted, or container was created manually
1290 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1291 {
1292 ID: "orphan-container",
1293 Name: "gordon-test.example.com",
1294 Status: "running",
1295 },
1296 }, nil)
1297
1298 // Orphan should be stopped and removed BEFORE we proceed
1299 runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
1300 runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
1301
1302 // Image operations
1303 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1304 runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
1305 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
1306
1307 // Environment
1308 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1309 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v1").Return([]string{}, nil)
1310
1311 // Create container (no -new suffix since no existing tracked container)
1312 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
1313 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1314 return cfg.Name == "gordon-test.example.com"
1315 })).Return(newContainer, nil)
1316 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1317
1318 // Wait for ready
1319 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1320
1321 // Inspect after ready
1322 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1323 ID: "new-container",
1324 Status: "running",
1325 Ports: []int{8080},
1326 }, nil)
1327
1328 // Publish event
1329 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1330
1331 result, err := svc.Deploy(ctx, route)
1332
1333 assert.NoError(t, err)
1334 assert.Equal(t, "new-container", result.ID)
1335
1336 // Verify new container is tracked
1337 tracked, exists := svc.Get(ctx, "test.example.com")
1338 assert.True(t, exists)
1339 assert.Equal(t, "new-container", tracked.ID)
1340}
1341
1342func TestService_StripRegistryPrefix(t *testing.T) {
1343 tests := []struct {
1344 name string
1345 registryDomain string
1346 image string
1347 expected string
1348 }{
1349 {
1350 name: "strips registry prefix",
1351 registryDomain: "reg.example.com",
1352 image: "reg.example.com/myapp:latest",
1353 expected: "myapp:latest",
1354 },
1355 {
1356 name: "strips registry prefix with trailing slash in domain",
1357 registryDomain: "reg.example.com/",
1358 image: "reg.example.com/myapp:v1.0",
1359 expected: "myapp:v1.0",
1360 },
1361 {
1362 name: "preserves image without registry prefix",
1363 registryDomain: "reg.example.com",
1364 image: "nginx:latest",
1365 expected: "nginx:latest",
1366 },
1367 {
1368 name: "preserves image with different registry",
1369 registryDomain: "reg.example.com",
1370 image: "gcr.io/project/image:latest",
1371 expected: "gcr.io/project/image:latest",
1372 },
1373 {
1374 name: "handles empty registry domain",
1375 registryDomain: "",
1376 image: "reg.example.com/myapp:latest",
1377 expected: "reg.example.com/myapp:latest",
1378 },
1379 {
1380 name: "handles nested paths",
1381 registryDomain: "reg.example.com",
1382 image: "reg.example.com/org/repo/app:latest",
1383 expected: "org/repo/app:latest",
1384 },
1385 }
1386
1387 for _, tt := range tests {
1388 t.Run(tt.name, func(t *testing.T) {
1389 runtime := mocks.NewMockContainerRuntime(t)
1390 envLoader := mocks.NewMockEnvLoader(t)
1391 eventBus := mocks.NewMockEventPublisher(t)
1392
1393 config := Config{
1394 RegistryDomain: tt.registryDomain,
1395 }
1396 svc := NewService(runtime, envLoader, eventBus, nil, config)
1397
1398 result := svc.stripRegistryPrefix(tt.image)
1399 assert.Equal(t, tt.expected, result)
1400 })
1401 }
1402}
1403
1404func TestSanitizeName(t *testing.T) {
1405 tests := []struct {
1406 domain string
1407 expected string
1408 description string
1409 }{
1410 {
1411 domain: "git.example.com",
1412 expected: "git__example__com",
1413 description: "Dots become double underscores",
1414 },
1415 {
1416 domain: "git-example.com",
1417 expected: "git-example__com",
1418 description: "Hyphens preserved, dots become underscores",
1419 },
1420 {
1421 domain: "app:8080.example.com",
1422 expected: "app-_8080__example__com",
1423 description: "Colons become hyphen-underscore",
1424 },
1425 {
1426 domain: "git.example.com:3000",
1427 expected: "git__example__com-_3000",
1428 description: "Multiple separators handled distinctly",
1429 },
1430 {
1431 domain: "simple.com",
1432 expected: "simple__com",
1433 description: "Simple domain",
1434 },
1435 }
1436
1437 for _, tt := range tests {
1438 t.Run(tt.domain, func(t *testing.T) {
1439 result := sanitizeName(tt.domain)
1440 assert.Equal(t, tt.expected, result, "sanitization should match expected")
1441 })
1442 }
1443
1444 // Verify no collisions between potentially conflicting domains
1445 t.Run("NoCollisions", func(t *testing.T) {
1446 domains := []string{
1447 "git.example.com",
1448 "git-example.com",
1449 "app:8080.example.com",
1450 "app-8080-example.com",
1451 }
1452
1453 results := make(map[string]string)
1454 for _, d := range domains {
1455 result := sanitizeName(d)
1456 if original, exists := results[result]; exists {
1457 t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
1458 }
1459 results[result] = d
1460 }
1461 })
1462}
1463
1464func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1465 // Verify that concurrent Deploy calls for the same domain are serialized
1466 // and both succeed without container name conflicts.
1467 runtime := mocks.NewMockContainerRuntime(t)
1468 envLoader := mocks.NewMockEnvLoader(t)
1469 eventBus := mocks.NewMockEventPublisher(t)
1470
1471 config := Config{
1472 ReadinessDelay: time.Millisecond,
1473 }
1474 svc := NewService(runtime, envLoader, eventBus, nil, config)
1475 ctx := testContext()
1476
1477 route := domain.Route{
1478 Domain: "test.example.com",
1479 Image: "myapp:latest",
1480 }
1481
1482 // Track call order to verify serialization
1483 var callOrder []string
1484 var callMu sync.Mutex
1485
1486 // Setup mocks that will be called by both deploys sequentially.
1487 // First deploy: no existing container → creates gordon-test.example.com
1488 // Second deploy: sees first container → creates gordon-test.example.com-new
1489
1490 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1491 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
1492 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
1493 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, nil).Times(2)
1494 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
1495
1496 createCall := 0
1497 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
1498 RunAndReturn(func(_ context.Context, cfg *domain.ContainerConfig) (*domain.Container, error) {
1499 callMu.Lock()
1500 createCall++
1501 n := createCall
1502 callOrder = append(callOrder, fmt.Sprintf("create-%d:%s", n, cfg.Name))
1503 callMu.Unlock()
1504 return &domain.Container{
1505 ID: fmt.Sprintf("container-%d", n),
1506 Name: cfg.Name,
1507 Image: cfg.Image,
1508 Status: "created",
1509 }, nil
1510 })
1511
1512 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
1513 // IsContainerRunning is called 2x per deploy in waitForReady (initial check + after delay)
1514 runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
1515
1516 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
1517 RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
1518 // Return correct container name based on ID
1519 // container-1 is the first deploy (canonical name)
1520 // container-2 is the second deploy (with -new suffix)
1521 name := "gordon-test.example.com"
1522 if id == "container-2" {
1523 name = "gordon-test.example.com-new"
1524 }
1525 return &domain.Container{
1526 ID: id,
1527 Name: name,
1528 Image: "myapp:latest",
1529 Status: "running",
1530 Ports: []int{8080},
1531 }, nil
1532 })
1533
1534 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)
1535
1536 // The second deploy will see the first container and do zero-downtime swap,
1537 // which means it will also stop+remove+rename the old container.
1538 runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
1539 runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
1540 runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Once()
1541
1542 // Launch two concurrent deploys
1543 var wg sync.WaitGroup
1544 errs := make([]error, 2)
1545
1546 wg.Add(2)
1547 for i := range 2 {
1548 go func(idx int) {
1549 defer wg.Done()
1550 _, errs[idx] = svc.Deploy(ctx, route)
1551 }(i)
1552 }
1553 wg.Wait()
1554
1555 // Both should succeed (second deploy waits for the first to finish)
1556 assert.NoError(t, errs[0], "first deploy should succeed")
1557 assert.NoError(t, errs[1], "second deploy should succeed")
1558
1559 // Verify creates were serialized (not interleaved)
1560 callMu.Lock()
1561 assert.Len(t, callOrder, 2, "should have exactly 2 create calls")
1562 // First deploy uses canonical name, second uses -new suffix (zero-downtime)
1563 assert.Equal(t, "create-1:gordon-test.example.com", callOrder[0], "first create should use canonical name")
1564 assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
1565 callMu.Unlock()
1566}
1567
1568func TestService_Deploy_ContextCancellation(t *testing.T) {
1569 // Verify that deploy lock acquisition respects context cancellation.
1570 runtime := mocks.NewMockContainerRuntime(t)
1571 envLoader := mocks.NewMockEnvLoader(t)
1572 eventBus := mocks.NewMockEventPublisher(t)
1573
1574 config := Config{
1575 ReadinessDelay: time.Millisecond,
1576 }
1577 svc := NewService(runtime, envLoader, eventBus, nil, config)
1578
1579 route := domain.Route{
1580 Domain: "test.example.com",
1581 Image: "myapp:latest",
1582 }
1583
1584 // Test context cancelled before lock acquisition.
1585 cancelledCtx, cancel := context.WithCancel(testContext())
1586 cancel() // Cancel immediately
1587
1588 _, err := svc.Deploy(cancelledCtx, route)
1589 assert.Error(t, err, "deploy should fail with cancelled context")
1590 assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
1591}