1package container
2
3import (
4 "context"
5 "errors"
6 "testing"
7
8 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11
12 "gordon/internal/boundaries/out/mocks"
13 "gordon/internal/domain"
14)
15
16func testContext() context.Context {
17 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
18}
19
20func TestService_Deploy_Success(t *testing.T) {
21 runtime := mocks.NewMockContainerRuntime(t)
22 envLoader := mocks.NewMockEnvLoader(t)
23 eventBus := mocks.NewMockEventPublisher(t)
24
25 config := Config{
26 NetworkIsolation: false,
27 VolumeAutoCreate: false,
28 ReadinessDelay: 0, // No delay for tests
29 }
30
31 svc := NewService(runtime, envLoader, eventBus, nil, config)
32 ctx := testContext()
33
34 route := domain.Route{
35 Domain: "test.example.com",
36 Image: "myapp:latest",
37 }
38
39 // Setup mocks - no orphaned containers
40 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
41
42 // Image is not found locally, needs to be pulled
43 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
44 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
45
46 // Get exposed ports
47 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
48
49 // Load environment
50 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{"FOO=bar"}, nil)
51
52 // Inspect image env
53 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{"DEFAULT=value"}, nil)
54
55 // No volume auto-create, so no volume calls
56
57 // Create and start container
58 createdContainer := &domain.Container{
59 ID: "container-123",
60 Name: "gordon-test.example.com",
61 Image: "myapp:latest",
62 Status: "created",
63 }
64 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
65 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
66
67 // Wait for ready: IsContainerRunning (first check returns true) + verify after delay
68 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
69
70 // Re-inspect after start
71 runningContainer := &domain.Container{
72 ID: "container-123",
73 Name: "gordon-test.example.com",
74 Image: "myapp:latest",
75 Status: "running",
76 Ports: []int{8080},
77 }
78 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
79
80 // Publish container deployed event
81 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
82
83 result, err := svc.Deploy(ctx, route)
84
85 assert.NoError(t, err)
86 assert.NotNil(t, result)
87 assert.Equal(t, "container-123", result.ID)
88 assert.Equal(t, "running", result.Status)
89
90 // Verify container is tracked
91 tracked, exists := svc.Get(ctx, "test.example.com")
92 assert.True(t, exists)
93 assert.Equal(t, "container-123", tracked.ID)
94}
95
96func TestService_Deploy_ImagePullFailure(t *testing.T) {
97 runtime := mocks.NewMockContainerRuntime(t)
98 envLoader := mocks.NewMockEnvLoader(t)
99 eventBus := mocks.NewMockEventPublisher(t)
100
101 config := Config{}
102 svc := NewService(runtime, envLoader, eventBus, nil, config)
103 ctx := testContext()
104
105 route := domain.Route{
106 Domain: "test.example.com",
107 Image: "myapp:latest",
108 }
109
110 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
111 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
112 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(errors.New("image not found"))
113
114 result, err := svc.Deploy(ctx, route)
115
116 assert.Error(t, err)
117 assert.Nil(t, result)
118 assert.Contains(t, err.Error(), "failed to pull image")
119}
120
121func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
122 runtime := mocks.NewMockContainerRuntime(t)
123 envLoader := mocks.NewMockEnvLoader(t)
124 eventBus := mocks.NewMockEventPublisher(t)
125
126 config := Config{
127 ReadinessDelay: 0, // No delay for tests
128 }
129 svc := NewService(runtime, envLoader, eventBus, nil, config)
130 ctx := testContext()
131
132 // Pre-populate with existing container
133 existingContainer := &domain.Container{
134 ID: "old-container",
135 Name: "gordon-test.example.com",
136 Status: "running",
137 }
138 svc.containers["test.example.com"] = existingContainer
139
140 route := domain.Route{
141 Domain: "test.example.com",
142 Image: "myapp:v2",
143 }
144
145 // Cleanup orphans
146 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
147
148 // Image operations
149 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
150 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
151 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
152
153 // Environment
154 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
155 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
156
157 // Create new container (with -new suffix for zero-downtime)
158 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
159 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
160 return cfg.Name == "gordon-test.example.com-new"
161 })).Return(newContainer, nil)
162 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
163
164 // Wait for ready
165 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
166
167 // Inspect after ready
168 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
169 ID: "new-container",
170 Status: "running",
171 }, nil)
172
173 // Publish event
174 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
175
176 // Now cleanup old container (after new one is ready)
177 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
178 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
179
180 // Rename new container to canonical name
181 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
182
183 result, err := svc.Deploy(ctx, route)
184
185 assert.NoError(t, err)
186 assert.Equal(t, "new-container", result.ID)
187
188 // Old container should be replaced
189 tracked, exists := svc.Get(ctx, "test.example.com")
190 assert.True(t, exists)
191 assert.Equal(t, "new-container", tracked.ID)
192}
193
194func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
195 runtime := mocks.NewMockContainerRuntime(t)
196 envLoader := mocks.NewMockEnvLoader(t)
197 eventBus := mocks.NewMockEventPublisher(t)
198
199 config := Config{
200 NetworkIsolation: true,
201 NetworkPrefix: "gordon",
202 ReadinessDelay: 0, // No delay for tests
203 }
204 svc := NewService(runtime, envLoader, eventBus, nil, config)
205 ctx := testContext()
206
207 route := domain.Route{
208 Domain: "test.example.com",
209 Image: "myapp:latest",
210 }
211
212 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
213 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
214 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
215 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
216 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
217
218 // Network isolation - should check and create network
219 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil)
220 runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", map[string]string{"driver": "bridge"}).Return(nil)
221
222 container := &domain.Container{ID: "container-123", Status: "created"}
223 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
224 return cfg.NetworkMode == "gordon-test-example-com"
225 })).Return(container, nil)
226 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
227
228 // Wait for ready
229 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
230
231 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
232 ID: "container-123",
233 Status: "running",
234 }, nil)
235
236 // Publish event
237 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
238
239 result, err := svc.Deploy(ctx, route)
240
241 assert.NoError(t, err)
242 assert.NotNil(t, result)
243}
244
245func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
246 runtime := mocks.NewMockContainerRuntime(t)
247 envLoader := mocks.NewMockEnvLoader(t)
248 eventBus := mocks.NewMockEventPublisher(t)
249
250 config := Config{
251 VolumeAutoCreate: true,
252 VolumePrefix: "gordon",
253 ReadinessDelay: 0, // No delay for tests
254 }
255 svc := NewService(runtime, envLoader, eventBus, nil, config)
256 ctx := testContext()
257
258 route := domain.Route{
259 Domain: "test.example.com",
260 Image: "myapp:latest",
261 }
262
263 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
264 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
265 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
266 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
267 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
268
269 // Volume operations
270 runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data", "/config"}, nil)
271 runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-data").Return(false, nil)
272 runtime.EXPECT().CreateVolume(mock.Anything, "gordon-test-example-com-data").Return(nil)
273 runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-config").Return(true, nil)
274
275 container := &domain.Container{ID: "container-123", Status: "created"}
276 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
277 return len(cfg.Volumes) == 2
278 })).Return(container, nil)
279 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
280
281 // Wait for ready
282 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
283
284 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
285 ID: "container-123",
286 Status: "running",
287 }, nil)
288
289 // Publish event
290 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
291
292 result, err := svc.Deploy(ctx, route)
293
294 assert.NoError(t, err)
295 assert.NotNil(t, result)
296}
297
298func TestService_Stop(t *testing.T) {
299 runtime := mocks.NewMockContainerRuntime(t)
300 envLoader := mocks.NewMockEnvLoader(t)
301 eventBus := mocks.NewMockEventPublisher(t)
302
303 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
304 ctx := testContext()
305
306 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(nil)
307
308 err := svc.Stop(ctx, "container-123")
309
310 assert.NoError(t, err)
311}
312
313func TestService_Stop_Error(t *testing.T) {
314 runtime := mocks.NewMockContainerRuntime(t)
315 envLoader := mocks.NewMockEnvLoader(t)
316 eventBus := mocks.NewMockEventPublisher(t)
317
318 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
319 ctx := testContext()
320
321 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(errors.New("container not found"))
322
323 err := svc.Stop(ctx, "container-123")
324
325 assert.Error(t, err)
326 assert.Contains(t, err.Error(), "failed to stop container")
327}
328
329func TestService_Remove(t *testing.T) {
330 runtime := mocks.NewMockContainerRuntime(t)
331 envLoader := mocks.NewMockEnvLoader(t)
332 eventBus := mocks.NewMockEventPublisher(t)
333
334 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
335 ctx := testContext()
336
337 // Add tracked container
338 svc.containers["test.example.com"] = &domain.Container{
339 ID: "container-123",
340 Name: "gordon-test.example.com",
341 }
342
343 runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)
344
345 err := svc.Remove(ctx, "container-123", true)
346
347 assert.NoError(t, err)
348
349 // Verify container is no longer tracked
350 _, exists := svc.Get(ctx, "test.example.com")
351 assert.False(t, exists)
352}
353
354func TestService_Remove_WithNetworkCleanup(t *testing.T) {
355 runtime := mocks.NewMockContainerRuntime(t)
356 envLoader := mocks.NewMockEnvLoader(t)
357 eventBus := mocks.NewMockEventPublisher(t)
358
359 config := Config{
360 NetworkIsolation: true,
361 NetworkPrefix: "gordon",
362 }
363 svc := NewService(runtime, envLoader, eventBus, nil, config)
364 ctx := testContext()
365
366 svc.containers["test.example.com"] = &domain.Container{
367 ID: "container-123",
368 Name: "gordon-test.example.com",
369 }
370
371 runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)
372 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
373 {Name: "gordon-test-example-com", Containers: []string{}},
374 }, nil)
375 runtime.EXPECT().RemoveNetwork(mock.Anything, "gordon-test-example-com").Return(nil)
376
377 err := svc.Remove(ctx, "container-123", true)
378
379 assert.NoError(t, err)
380}
381
382func TestService_Get(t *testing.T) {
383 runtime := mocks.NewMockContainerRuntime(t)
384 envLoader := mocks.NewMockEnvLoader(t)
385 eventBus := mocks.NewMockEventPublisher(t)
386
387 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
388 ctx := testContext()
389
390 container := &domain.Container{ID: "container-123"}
391 svc.containers["test.example.com"] = container
392
393 result, exists := svc.Get(ctx, "test.example.com")
394
395 assert.True(t, exists)
396 assert.Equal(t, "container-123", result.ID)
397}
398
399func TestService_Get_NotFound(t *testing.T) {
400 runtime := mocks.NewMockContainerRuntime(t)
401 envLoader := mocks.NewMockEnvLoader(t)
402 eventBus := mocks.NewMockEventPublisher(t)
403
404 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
405 ctx := testContext()
406
407 result, exists := svc.Get(ctx, "nonexistent.example.com")
408
409 assert.False(t, exists)
410 assert.Nil(t, result)
411}
412
413func TestService_List(t *testing.T) {
414 runtime := mocks.NewMockContainerRuntime(t)
415 envLoader := mocks.NewMockEnvLoader(t)
416 eventBus := mocks.NewMockEventPublisher(t)
417
418 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
419 ctx := testContext()
420
421 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
422 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
423
424 result := svc.List(ctx)
425
426 assert.Len(t, result, 2)
427 assert.Equal(t, "container-1", result["app1.example.com"].ID)
428 assert.Equal(t, "container-2", result["app2.example.com"].ID)
429}
430
431func TestService_HealthCheck(t *testing.T) {
432 runtime := mocks.NewMockContainerRuntime(t)
433 envLoader := mocks.NewMockEnvLoader(t)
434 eventBus := mocks.NewMockEventPublisher(t)
435
436 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
437 ctx := testContext()
438
439 svc.containers["healthy.example.com"] = &domain.Container{ID: "healthy-container"}
440 svc.containers["unhealthy.example.com"] = &domain.Container{ID: "unhealthy-container"}
441
442 runtime.EXPECT().IsContainerRunning(mock.Anything, "healthy-container").Return(true, nil)
443 runtime.EXPECT().IsContainerRunning(mock.Anything, "unhealthy-container").Return(false, nil)
444
445 result := svc.HealthCheck(ctx)
446
447 assert.True(t, result["healthy.example.com"])
448 assert.False(t, result["unhealthy.example.com"])
449}
450
451func TestService_SyncContainers(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 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
460 {
461 ID: "container-1",
462 Name: "gordon-app1.example.com",
463 Labels: map[string]string{
464 "gordon.domain": "app1.example.com",
465 "gordon.managed": "true",
466 },
467 },
468 {
469 ID: "container-2",
470 Name: "gordon-app2.example.com",
471 Labels: map[string]string{
472 "gordon.domain": "app2.example.com",
473 "gordon.managed": "true",
474 },
475 },
476 {
477 ID: "unmanaged-container",
478 Name: "some-other-app",
479 Labels: map[string]string{},
480 },
481 }, nil)
482
483 err := svc.SyncContainers(ctx)
484
485 assert.NoError(t, err)
486 assert.Len(t, svc.containers, 2)
487 assert.Contains(t, svc.containers, "app1.example.com")
488 assert.Contains(t, svc.containers, "app2.example.com")
489}
490
491func TestService_Shutdown(t *testing.T) {
492 runtime := mocks.NewMockContainerRuntime(t)
493 envLoader := mocks.NewMockEnvLoader(t)
494 eventBus := mocks.NewMockEventPublisher(t)
495
496 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
497 ctx := testContext()
498
499 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
500 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
501
502 runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil)
503 runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(nil)
504
505 err := svc.Shutdown(ctx)
506
507 assert.NoError(t, err)
508 assert.Empty(t, svc.containers)
509}
510
511func TestService_Shutdown_PartialFailure(t *testing.T) {
512 runtime := mocks.NewMockContainerRuntime(t)
513 envLoader := mocks.NewMockEnvLoader(t)
514 eventBus := mocks.NewMockEventPublisher(t)
515
516 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
517 ctx := testContext()
518
519 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
520 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
521
522 runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil)
523 runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(errors.New("stop failed"))
524
525 err := svc.Shutdown(ctx)
526
527 // Shutdown logs errors but always returns nil for graceful degradation
528 assert.NoError(t, err)
529 // One container should still be tracked (the one that failed to stop)
530 assert.Len(t, svc.containers, 1)
531}
532
533func TestNormalizeImageRef(t *testing.T) {
534 tests := []struct {
535 name string
536 input string
537 expected string
538 }{
539 {
540 name: "simple image",
541 input: "nginx",
542 expected: "docker.io/library/nginx",
543 },
544 {
545 name: "user/repo",
546 input: "user/myapp",
547 expected: "docker.io/user/myapp",
548 },
549 {
550 name: "registry/repo",
551 input: "gcr.io/project/image",
552 expected: "gcr.io/project/image",
553 },
554 {
555 name: "with tag",
556 input: "nginx:latest",
557 expected: "docker.io/library/nginx",
558 },
559 {
560 name: "localhost with port",
561 input: "localhost:5000/myapp:latest",
562 expected: "localhost:5000/myapp",
563 },
564 {
565 name: "localhost with port no tag",
566 input: "localhost:5000/myapp",
567 expected: "localhost:5000/myapp",
568 },
569 {
570 name: "registry domain with port",
571 input: "registry.example.com:5000/image:v1.0",
572 expected: "registry.example.com:5000/image",
573 },
574 }
575
576 for _, tt := range tests {
577 t.Run(tt.name, func(t *testing.T) {
578 result := normalizeImageRef(tt.input)
579 assert.Equal(t, tt.expected, result)
580 })
581 }
582}
583
584func TestGenerateVolumeName(t *testing.T) {
585 tests := []struct {
586 name string
587 prefix string
588 domain string
589 volumePath string
590 expected string
591 }{
592 {
593 name: "simple path",
594 prefix: "gordon",
595 domain: "app.example.com",
596 volumePath: "/data",
597 expected: "gordon-app-example-com-data",
598 },
599 {
600 name: "nested path",
601 prefix: "gordon",
602 domain: "app.example.com",
603 volumePath: "/var/lib/data",
604 expected: "gordon-app-example-com-var-lib-data",
605 },
606 }
607
608 for _, tt := range tests {
609 t.Run(tt.name, func(t *testing.T) {
610 result := generateVolumeName(tt.prefix, tt.domain, tt.volumePath)
611 assert.Equal(t, tt.expected, result)
612 })
613 }
614}
615
616func TestMergeEnvironmentVariables(t *testing.T) {
617 dockerEnv := []string{"FOO=docker", "BAR=docker"}
618 userEnv := []string{"FOO=user", "BAZ=user"}
619
620 result := mergeEnvironmentVariables(dockerEnv, userEnv)
621
622 // User env should override docker env
623 assert.Contains(t, result, "FOO=user")
624 assert.Contains(t, result, "BAR=docker")
625 assert.Contains(t, result, "BAZ=user")
626}
627
628func TestRewriteToRegistryDomain(t *testing.T) {
629 tests := []struct {
630 name string
631 imageRef string
632 registryDomain string
633 wantRef string
634 }{
635 {
636 name: "keeps registry domain prefix",
637 imageRef: "registry.example.com/myapp:latest",
638 registryDomain: "registry.example.com",
639 wantRef: "registry.example.com/myapp:latest",
640 },
641 {
642 name: "prefixes registry domain",
643 imageRef: "myapp:v1.0",
644 registryDomain: "registry.example.com",
645 wantRef: "registry.example.com/myapp:v1.0",
646 },
647 {
648 name: "prefixes external image",
649 imageRef: "docker.io/library/nginx:latest",
650 registryDomain: "registry.example.com",
651 wantRef: "registry.example.com/docker.io/library/nginx:latest",
652 },
653 {
654 name: "empty registry domain returns original",
655 imageRef: "registry.example.com/myapp:latest",
656 registryDomain: "",
657 wantRef: "registry.example.com/myapp:latest",
658 },
659 }
660
661 for _, tt := range tests {
662 t.Run(tt.name, func(t *testing.T) {
663 gotRef := rewriteToRegistryDomain(tt.imageRef, tt.registryDomain)
664 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
665 })
666 }
667}
668
669func TestRewriteToLocalRegistry(t *testing.T) {
670 tests := []struct {
671 name string
672 imageRef string
673 registryDomain string
674 registryPort int
675 wantRef string
676 }{
677 {
678 name: "rewrites registry domain prefix",
679 imageRef: "registry.example.com/myapp:latest",
680 registryDomain: "registry.example.com",
681 registryPort: 5000,
682 wantRef: "localhost:5000/myapp:latest",
683 },
684 {
685 name: "prefixes local registry when no domain",
686 imageRef: "myapp:v1.0",
687 registryDomain: "registry.example.com",
688 registryPort: 5000,
689 wantRef: "localhost:5000/myapp:v1.0",
690 },
691 {
692 name: "keeps existing localhost prefix",
693 imageRef: "localhost:5000/myapp:latest",
694 registryDomain: "registry.example.com",
695 registryPort: 5000,
696 wantRef: "localhost:5000/myapp:latest",
697 },
698 {
699 name: "prefixes external image path",
700 imageRef: "docker.io/library/nginx:latest",
701 registryDomain: "registry.example.com",
702 registryPort: 5000,
703 wantRef: "localhost:5000/docker.io/library/nginx:latest",
704 },
705 }
706
707 for _, tt := range tests {
708 t.Run(tt.name, func(t *testing.T) {
709 gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)
710 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
711 })
712 }
713}
714
715func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
716 runtime := mocks.NewMockContainerRuntime(t)
717 envLoader := mocks.NewMockEnvLoader(t)
718 eventBus := mocks.NewMockEventPublisher(t)
719
720 config := Config{
721 RegistryAuthEnabled: true,
722 RegistryDomain: "registry.example.com",
723 RegistryPort: 5000,
724 InternalRegistryUsername: "internal",
725 InternalRegistryPassword: "secret",
726 ReadinessDelay: 0,
727 }
728
729 svc := NewService(runtime, envLoader, eventBus, nil, config)
730
731 // Use internal deploy context (simulating image push event)
732 ctx := domain.WithInternalDeploy(testContext())
733
734 route := domain.Route{
735 Domain: "test.example.com",
736 Image: "myapp:latest",
737 }
738
739 // Setup mocks - no orphaned containers
740 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
741
742 // Even though image exists locally, internal deploy should force pull
743 // Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check
744 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
745
746 // Tag image to canonical name
747 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil)
748 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
749
750 // Get exposed ports
751 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
752
753 // Load environment
754 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
755 runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil)
756
757 // Create and start container
758 createdContainer := &domain.Container{
759 ID: "container-123",
760 Name: "gordon-test.example.com",
761 Status: "created",
762 }
763 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
764 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
765
766 // Wait for ready
767 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
768
769 // Re-inspect after start
770 runningContainer := &domain.Container{
771 ID: "container-123",
772 Name: "gordon-test.example.com",
773 Status: "running",
774 Ports: []int{8080},
775 }
776 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
777
778 // Publish event
779 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
780
781 result, err := svc.Deploy(ctx, route)
782
783 assert.NoError(t, err)
784 assert.NotNil(t, result)
785 assert.Equal(t, "container-123", result.ID)
786}
787
788func TestService_AutoStart_StartsNewContainers(t *testing.T) {
789 runtime := mocks.NewMockContainerRuntime(t)
790 envLoader := mocks.NewMockEnvLoader(t)
791 eventBus := mocks.NewMockEventPublisher(t)
792
793 config := Config{
794 ReadinessDelay: 0,
795 }
796 svc := NewService(runtime, envLoader, eventBus, nil, config)
797 ctx := testContext()
798
799 routes := []domain.Route{
800 {Domain: "app1.example.com", Image: "myapp1:latest"},
801 {Domain: "app2.example.com", Image: "myapp2:latest"},
802 }
803
804 // Setup mocks for first route deployment
805 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
806 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
807 runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
808 envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
809 runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
810
811 // Create and start containers
812 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
813 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
814 }, nil).Once()
815 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
816 ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
817 }, nil).Once()
818 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
819 runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
820 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
821 Status: "running",
822 }, nil).Times(2)
823 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2)
824
825 err := svc.AutoStart(ctx, routes)
826
827 assert.NoError(t, err)
828 assert.Len(t, svc.containers, 2)
829}
830
831func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
832 runtime := mocks.NewMockContainerRuntime(t)
833 envLoader := mocks.NewMockEnvLoader(t)
834 eventBus := mocks.NewMockEventPublisher(t)
835
836 config := Config{
837 ReadinessDelay: 0,
838 }
839 svc := NewService(runtime, envLoader, eventBus, nil, config)
840 ctx := testContext()
841
842 // Pre-populate with existing container
843 svc.containers["app1.example.com"] = &domain.Container{
844 ID: "existing-container",
845 }
846
847 routes := []domain.Route{
848 {Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists
849 {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
850 }
851
852 // Only deploy for app2 (app1 is skipped)
853 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
854 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
855 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
856 envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
857 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
858 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
859 ID: "container-2", Status: "created",
860 }, nil).Once()
861 runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
862 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2)
863 runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
864 Status: "running",
865 }, nil).Once()
866 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
867
868 err := svc.AutoStart(ctx, routes)
869
870 assert.NoError(t, err)
871 assert.Len(t, svc.containers, 2)
872}
873
874func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
875 runtime := mocks.NewMockContainerRuntime(t)
876 envLoader := mocks.NewMockEnvLoader(t)
877 eventBus := mocks.NewMockEventPublisher(t)
878
879 config := Config{}
880 svc := NewService(runtime, envLoader, eventBus, nil, config)
881 ctx := testContext()
882
883 routes := []domain.Route{
884 {Domain: "app1.example.com", Image: "myapp1:latest"},
885 }
886
887 // Setup mocks for failure
888 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
889 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
890 runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found"))
891
892 err := svc.AutoStart(ctx, routes)
893
894 // AutoStart should return error when some deployments fail
895 assert.Error(t, err)
896 assert.Contains(t, err.Error(), "auto-start completed with 1 errors")
897}
898
899// TestService_Deploy_OrphanCleanupSkipsTrackedContainer verifies that the orphan cleanup
900// does not remove the currently tracked container during zero-downtime deployment.
901// This is critical for preventing downtime - the old container must stay running
902// until the new container is ready and traffic is switched.
903func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
904 runtime := mocks.NewMockContainerRuntime(t)
905 envLoader := mocks.NewMockEnvLoader(t)
906 eventBus := mocks.NewMockEventPublisher(t)
907
908 config := Config{
909 ReadinessDelay: 0, // No delay for tests
910 }
911 svc := NewService(runtime, envLoader, eventBus, nil, config)
912 ctx := testContext()
913
914 // Pre-populate with existing tracked container
915 existingContainer := &domain.Container{
916 ID: "tracked-container-123",
917 Name: "gordon-test.example.com",
918 Status: "running",
919 }
920 svc.containers["test.example.com"] = existingContainer
921
922 route := domain.Route{
923 Domain: "test.example.com",
924 Image: "myapp:v2",
925 }
926
927 // ListContainers returns the tracked container - this simulates the orphan check
928 // finding a container with the same name. The bug was that it would remove this
929 // container BEFORE the new one was ready, causing downtime.
930 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
931 {
932 ID: "tracked-container-123",
933 Name: "gordon-test.example.com",
934 Status: "running",
935 },
936 }, nil)
937
938 // Image operations
939 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
940 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
941 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
942
943 // Environment
944 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
945 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
946
947 // Create new container with -new suffix for zero-downtime
948 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
949 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
950 return cfg.Name == "gordon-test.example.com-new"
951 })).Return(newContainer, nil)
952 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
953
954 // Wait for ready
955 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
956
957 // Inspect after ready
958 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
959 ID: "new-container",
960 Status: "running",
961 }, nil)
962
963 // Publish event
964 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
965
966 // NOW (after new container is ready) the old container should be stopped and removed
967 // This is the correct zero-downtime sequence - not during orphan cleanup
968 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
969 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
970
971 // Rename new container to canonical name
972 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
973
974 result, err := svc.Deploy(ctx, route)
975
976 assert.NoError(t, err)
977 assert.Equal(t, "new-container", result.ID)
978
979 // Verify new container is now tracked
980 tracked, exists := svc.Get(ctx, "test.example.com")
981 assert.True(t, exists)
982 assert.Equal(t, "new-container", tracked.ID)
983}
984
985// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers
986// with the same name but NOT tracked are properly removed as orphans.
987func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
988 runtime := mocks.NewMockContainerRuntime(t)
989 envLoader := mocks.NewMockEnvLoader(t)
990 eventBus := mocks.NewMockEventPublisher(t)
991
992 config := Config{
993 ReadinessDelay: 0, // No delay for tests
994 }
995 svc := NewService(runtime, envLoader, eventBus, nil, config)
996 ctx := testContext()
997
998 // NO tracked container - service is empty for this domain
999
1000 route := domain.Route{
1001 Domain: "test.example.com",
1002 Image: "myapp:v1",
1003 }
1004
1005 // ListContainers returns an orphaned container (same name, but not tracked)
1006 // This could happen if Gordon crashed and restarted, or container was created manually
1007 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1008 {
1009 ID: "orphan-container",
1010 Name: "gordon-test.example.com",
1011 Status: "running",
1012 },
1013 }, nil)
1014
1015 // Orphan should be stopped and removed BEFORE we proceed
1016 runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
1017 runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
1018
1019 // Image operations
1020 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1021 runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
1022 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
1023
1024 // Environment
1025 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1026 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v1").Return([]string{}, nil)
1027
1028 // Create container (no -new suffix since no existing tracked container)
1029 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
1030 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1031 return cfg.Name == "gordon-test.example.com"
1032 })).Return(newContainer, nil)
1033 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1034
1035 // Wait for ready
1036 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1037
1038 // Inspect after ready
1039 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1040 ID: "new-container",
1041 Status: "running",
1042 Ports: []int{8080},
1043 }, nil)
1044
1045 // Publish event
1046 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1047
1048 result, err := svc.Deploy(ctx, route)
1049
1050 assert.NoError(t, err)
1051 assert.Equal(t, "new-container", result.ID)
1052
1053 // Verify new container is tracked
1054 tracked, exists := svc.Get(ctx, "test.example.com")
1055 assert.True(t, exists)
1056 assert.Equal(t, "new-container", tracked.ID)
1057}