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