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