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