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 TestRewriteToRegistryDomain(t *testing.T) {
747 tests := []struct {
748 name string
749 imageRef string
750 registryDomain string
751 wantRef string
752 }{
753 {
754 name: "keeps registry domain prefix",
755 imageRef: "registry.example.com/myapp:latest",
756 registryDomain: "registry.example.com",
757 wantRef: "registry.example.com/myapp:latest",
758 },
759 {
760 name: "prefixes registry domain",
761 imageRef: "myapp:v1.0",
762 registryDomain: "registry.example.com",
763 wantRef: "registry.example.com/myapp:v1.0",
764 },
765 {
766 name: "prefixes external image",
767 imageRef: "docker.io/library/nginx:latest",
768 registryDomain: "registry.example.com",
769 wantRef: "registry.example.com/docker.io/library/nginx:latest",
770 },
771 {
772 name: "empty registry domain returns original",
773 imageRef: "registry.example.com/myapp:latest",
774 registryDomain: "",
775 wantRef: "registry.example.com/myapp:latest",
776 },
777 }
778
779 for _, tt := range tests {
780 t.Run(tt.name, func(t *testing.T) {
781 gotRef := rewriteToRegistryDomain(tt.imageRef, tt.registryDomain)
782 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
783 })
784 }
785}
786
787func TestRewriteToLocalRegistry(t *testing.T) {
788 tests := []struct {
789 name string
790 imageRef string
791 registryDomain string
792 registryPort int
793 wantRef string
794 }{
795 {
796 name: "rewrites registry domain prefix",
797 imageRef: "registry.example.com/myapp:latest",
798 registryDomain: "registry.example.com",
799 registryPort: 5000,
800 wantRef: "localhost:5000/myapp:latest",
801 },
802 {
803 name: "prefixes local registry when no domain",
804 imageRef: "myapp:v1.0",
805 registryDomain: "registry.example.com",
806 registryPort: 5000,
807 wantRef: "localhost:5000/myapp:v1.0",
808 },
809 {
810 name: "keeps existing localhost prefix",
811 imageRef: "localhost:5000/myapp:latest",
812 registryDomain: "registry.example.com",
813 registryPort: 5000,
814 wantRef: "localhost:5000/myapp:latest",
815 },
816 {
817 name: "prefixes external image path",
818 imageRef: "docker.io/library/nginx:latest",
819 registryDomain: "registry.example.com",
820 registryPort: 5000,
821 wantRef: "localhost:5000/docker.io/library/nginx:latest",
822 },
823 }
824
825 for _, tt := range tests {
826 t.Run(tt.name, func(t *testing.T) {
827 gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)
828 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
829 })
830 }
831}
832
833func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
834 runtime := mocks.NewMockContainerRuntime(t)
835 envLoader := mocks.NewMockEnvLoader(t)
836 eventBus := mocks.NewMockEventPublisher(t)
837
838 config := Config{
839 RegistryAuthEnabled: true,
840 RegistryDomain: "registry.example.com",
841 RegistryPort: 5000,
842 InternalRegistryUsername: "internal",
843 InternalRegistryPassword: "secret",
844 ReadinessDelay: time.Millisecond,
845 }
846
847 svc := NewService(runtime, envLoader, eventBus, nil, config)
848
849 // Use internal deploy context (simulating image push event)
850 ctx := domain.WithInternalDeploy(testContext())
851
852 route := domain.Route{
853 Domain: "test.example.com",
854 Image: "myapp:latest",
855 }
856
857 // Setup mocks - no orphaned containers
858 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
859
860 // Even though image exists locally, internal deploy should force pull
861 // Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check
862 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
863
864 // Tag image to canonical name
865 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil)
866 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
867
868 // Get exposed ports
869 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
870
871 // Load environment
872 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
873 runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil)
874
875 // Create and start container
876 createdContainer := &domain.Container{
877 ID: "container-123",
878 Name: "gordon-test.example.com",
879 Status: "created",
880 }
881 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
882 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
883
884 // Wait for ready
885 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
886
887 // Re-inspect after start
888 runningContainer := &domain.Container{
889 ID: "container-123",
890 Name: "gordon-test.example.com",
891 Status: "running",
892 Ports: []int{8080},
893 }
894 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
895
896 // Publish event
897 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
898
899 result, err := svc.Deploy(ctx, route)
900
901 assert.NoError(t, err)
902 assert.NotNil(t, result)
903 assert.Equal(t, "container-123", result.ID)
904}
905
906func TestService_AutoStart_StartsNewContainers(t *testing.T) {
907 runtime := mocks.NewMockContainerRuntime(t)
908 envLoader := mocks.NewMockEnvLoader(t)
909 eventBus := mocks.NewMockEventPublisher(t)
910
911 config := Config{
912 ReadinessDelay: time.Millisecond,
913 }
914 svc := NewService(runtime, envLoader, eventBus, nil, config)
915 ctx := testContext()
916
917 routes := []domain.Route{
918 {Domain: "app1.example.com", Image: "myapp1:latest"},
919 {Domain: "app2.example.com", Image: "myapp2:latest"},
920 }
921
922 // Setup mocks for first route deployment
923 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
924 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
925 runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
926 envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
927 runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
928
929 // Create and start containers
930 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
931 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
932 }, nil).Once()
933 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
934 ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
935 }, nil).Once()
936 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
937 runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
938 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
939 Status: "running",
940 }, nil).Times(2)
941 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2)
942
943 err := svc.AutoStart(ctx, routes)
944
945 assert.NoError(t, err)
946 assert.Len(t, svc.containers, 2)
947}
948
949func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
950 runtime := mocks.NewMockContainerRuntime(t)
951 envLoader := mocks.NewMockEnvLoader(t)
952 eventBus := mocks.NewMockEventPublisher(t)
953
954 config := Config{
955 ReadinessDelay: time.Millisecond,
956 }
957 svc := NewService(runtime, envLoader, eventBus, nil, config)
958 ctx := testContext()
959
960 // Pre-populate with existing container
961 svc.containers["app1.example.com"] = &domain.Container{
962 ID: "existing-container",
963 }
964
965 routes := []domain.Route{
966 {Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists
967 {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
968 }
969
970 // Only deploy for app2 (app1 is skipped)
971 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
972 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
973 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
974 envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
975 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
976 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
977 ID: "container-2", Status: "created",
978 }, nil).Once()
979 runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
980 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2)
981 runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
982 Status: "running",
983 }, nil).Once()
984 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
985
986 err := svc.AutoStart(ctx, routes)
987
988 assert.NoError(t, err)
989 assert.Len(t, svc.containers, 2)
990}
991
992func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
993 runtime := mocks.NewMockContainerRuntime(t)
994 envLoader := mocks.NewMockEnvLoader(t)
995 eventBus := mocks.NewMockEventPublisher(t)
996
997 config := Config{}
998 svc := NewService(runtime, envLoader, eventBus, nil, config)
999 ctx := testContext()
1000
1001 routes := []domain.Route{
1002 {Domain: "app1.example.com", Image: "myapp1:latest"},
1003 }
1004
1005 // Setup mocks for failure
1006 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1007 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1008 runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found"))
1009
1010 err := svc.AutoStart(ctx, routes)
1011
1012 // AutoStart should return error when some deployments fail
1013 assert.Error(t, err)
1014 assert.Contains(t, err.Error(), "auto-start completed with 1 errors")
1015}
1016
1017// TestService_Deploy_OrphanCleanupSkipsTrackedContainer verifies that the orphan cleanup
1018// does not remove the currently tracked container during zero-downtime deployment.
1019// This is critical for preventing downtime - the old container must stay running
1020// until the new container is ready and traffic is switched.
1021func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1022 runtime := mocks.NewMockContainerRuntime(t)
1023 envLoader := mocks.NewMockEnvLoader(t)
1024 eventBus := mocks.NewMockEventPublisher(t)
1025
1026 config := Config{
1027 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1028 }
1029 svc := NewService(runtime, envLoader, eventBus, nil, config)
1030 ctx := testContext()
1031
1032 // Pre-populate with existing tracked container
1033 existingContainer := &domain.Container{
1034 ID: "tracked-container-123",
1035 Name: "gordon-test.example.com",
1036 Status: "running",
1037 }
1038 svc.containers["test.example.com"] = existingContainer
1039
1040 route := domain.Route{
1041 Domain: "test.example.com",
1042 Image: "myapp:v2",
1043 }
1044
1045 // ListContainers returns the tracked container - this simulates the orphan check
1046 // finding a container with the same name. The bug was that it would remove this
1047 // container BEFORE the new one was ready, causing downtime.
1048 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1049 {
1050 ID: "tracked-container-123",
1051 Name: "gordon-test.example.com",
1052 Status: "running",
1053 },
1054 }, nil)
1055
1056 // Image operations
1057 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1058 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1059 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1060
1061 // Environment
1062 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1063 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1064
1065 // Create new container with -new suffix for zero-downtime
1066 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1067 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1068 return cfg.Name == "gordon-test.example.com-new"
1069 })).Return(newContainer, nil)
1070 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1071
1072 // Wait for ready
1073 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1074
1075 // Inspect after ready
1076 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1077 ID: "new-container",
1078 Status: "running",
1079 }, nil)
1080
1081 // Publish event
1082 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1083
1084 // NOW (after new container is ready) the old container should be stopped and removed
1085 // This is the correct zero-downtime sequence - not during orphan cleanup
1086 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1087 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1088
1089 // Rename new container to canonical name
1090 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1091
1092 result, err := svc.Deploy(ctx, route)
1093
1094 assert.NoError(t, err)
1095 assert.Equal(t, "new-container", result.ID)
1096
1097 // Verify new container is now tracked
1098 tracked, exists := svc.Get(ctx, "test.example.com")
1099 assert.True(t, exists)
1100 assert.Equal(t, "new-container", tracked.ID)
1101}
1102
1103// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers
1104// with the same name but NOT tracked are properly removed as orphans.
1105func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1106 runtime := mocks.NewMockContainerRuntime(t)
1107 envLoader := mocks.NewMockEnvLoader(t)
1108 eventBus := mocks.NewMockEventPublisher(t)
1109
1110 config := Config{
1111 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1112 }
1113 svc := NewService(runtime, envLoader, eventBus, nil, config)
1114 ctx := testContext()
1115
1116 // NO tracked container - service is empty for this domain
1117
1118 route := domain.Route{
1119 Domain: "test.example.com",
1120 Image: "myapp:v1",
1121 }
1122
1123 // ListContainers returns an orphaned container (same name, but not tracked)
1124 // This could happen if Gordon crashed and restarted, or container was created manually
1125 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1126 {
1127 ID: "orphan-container",
1128 Name: "gordon-test.example.com",
1129 Status: "running",
1130 },
1131 }, nil)
1132
1133 // Orphan should be stopped and removed BEFORE we proceed
1134 runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
1135 runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
1136
1137 // Image operations
1138 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1139 runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
1140 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
1141
1142 // Environment
1143 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1144 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v1").Return([]string{}, nil)
1145
1146 // Create container (no -new suffix since no existing tracked container)
1147 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
1148 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1149 return cfg.Name == "gordon-test.example.com"
1150 })).Return(newContainer, nil)
1151 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1152
1153 // Wait for ready
1154 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1155
1156 // Inspect after ready
1157 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1158 ID: "new-container",
1159 Status: "running",
1160 Ports: []int{8080},
1161 }, nil)
1162
1163 // Publish event
1164 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1165
1166 result, err := svc.Deploy(ctx, route)
1167
1168 assert.NoError(t, err)
1169 assert.Equal(t, "new-container", result.ID)
1170
1171 // Verify new container is tracked
1172 tracked, exists := svc.Get(ctx, "test.example.com")
1173 assert.True(t, exists)
1174 assert.Equal(t, "new-container", tracked.ID)
1175}
1176
1177func TestService_StripRegistryPrefix(t *testing.T) {
1178 tests := []struct {
1179 name string
1180 registryDomain string
1181 image string
1182 expected string
1183 }{
1184 {
1185 name: "strips registry prefix",
1186 registryDomain: "reg.example.com",
1187 image: "reg.example.com/myapp:latest",
1188 expected: "myapp:latest",
1189 },
1190 {
1191 name: "strips registry prefix with trailing slash in domain",
1192 registryDomain: "reg.example.com/",
1193 image: "reg.example.com/myapp:v1.0",
1194 expected: "myapp:v1.0",
1195 },
1196 {
1197 name: "preserves image without registry prefix",
1198 registryDomain: "reg.example.com",
1199 image: "nginx:latest",
1200 expected: "nginx:latest",
1201 },
1202 {
1203 name: "preserves image with different registry",
1204 registryDomain: "reg.example.com",
1205 image: "gcr.io/project/image:latest",
1206 expected: "gcr.io/project/image:latest",
1207 },
1208 {
1209 name: "handles empty registry domain",
1210 registryDomain: "",
1211 image: "reg.example.com/myapp:latest",
1212 expected: "reg.example.com/myapp:latest",
1213 },
1214 {
1215 name: "handles nested paths",
1216 registryDomain: "reg.example.com",
1217 image: "reg.example.com/org/repo/app:latest",
1218 expected: "org/repo/app:latest",
1219 },
1220 }
1221
1222 for _, tt := range tests {
1223 t.Run(tt.name, func(t *testing.T) {
1224 runtime := mocks.NewMockContainerRuntime(t)
1225 envLoader := mocks.NewMockEnvLoader(t)
1226 eventBus := mocks.NewMockEventPublisher(t)
1227
1228 config := Config{
1229 RegistryDomain: tt.registryDomain,
1230 }
1231 svc := NewService(runtime, envLoader, eventBus, nil, config)
1232
1233 result := svc.stripRegistryPrefix(tt.image)
1234 assert.Equal(t, tt.expected, result)
1235 })
1236 }
1237}
1238
1239func TestSanitizeName(t *testing.T) {
1240 tests := []struct {
1241 domain string
1242 expected string
1243 description string
1244 }{
1245 {
1246 domain: "git.example.com",
1247 expected: "git__example__com",
1248 description: "Dots become double underscores",
1249 },
1250 {
1251 domain: "git-example.com",
1252 expected: "git-example__com",
1253 description: "Hyphens preserved, dots become underscores",
1254 },
1255 {
1256 domain: "app:8080.example.com",
1257 expected: "app-_8080__example__com",
1258 description: "Colons become hyphen-underscore",
1259 },
1260 {
1261 domain: "git.example.com:3000",
1262 expected: "git__example__com-_3000",
1263 description: "Multiple separators handled distinctly",
1264 },
1265 {
1266 domain: "simple.com",
1267 expected: "simple__com",
1268 description: "Simple domain",
1269 },
1270 }
1271
1272 for _, tt := range tests {
1273 t.Run(tt.domain, func(t *testing.T) {
1274 result := sanitizeName(tt.domain)
1275 assert.Equal(t, tt.expected, result, "sanitization should match expected")
1276 })
1277 }
1278
1279 // Verify no collisions between potentially conflicting domains
1280 t.Run("NoCollisions", func(t *testing.T) {
1281 domains := []string{
1282 "git.example.com",
1283 "git-example.com",
1284 "app:8080.example.com",
1285 "app-8080-example.com",
1286 }
1287
1288 results := make(map[string]string)
1289 for _, d := range domains {
1290 result := sanitizeName(d)
1291 if original, exists := results[result]; exists {
1292 t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
1293 }
1294 results[result] = d
1295 }
1296 })
1297}