1package container
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/bnema/zerowrap"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"
14
15 "github.com/bnema/gordon/internal/boundaries/out/mocks"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19func testContext() context.Context {
20 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
21}
22
23func TestService_Deploy_Success(t *testing.T) {
24 runtime := mocks.NewMockContainerRuntime(t)
25 envLoader := mocks.NewMockEnvLoader(t)
26 eventBus := mocks.NewMockEventPublisher(t)
27
28 config := Config{
29 NetworkIsolation: false,
30 VolumeAutoCreate: false,
31 ReadinessDelay: time.Millisecond, // Minimal delay for tests
32 DrainDelay: time.Millisecond,
33 }
34
35 svc := NewService(runtime, envLoader, eventBus, nil, config)
36 ctx := testContext()
37
38 route := domain.Route{
39 Domain: "test.example.com",
40 Image: "myapp:latest",
41 }
42
43 // Setup mocks - no orphaned containers
44 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
45
46 // Image is not found locally, needs to be pulled
47 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
48 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
49
50 // Get exposed ports
51 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
52
53 // Load environment
54 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{"FOO=bar"}, nil)
55
56 // Inspect image env
57 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{"DEFAULT=value"}, nil)
58
59 // No volume auto-create, so no volume calls
60
61 // Create and start container
62 createdContainer := &domain.Container{
63 ID: "container-123",
64 Name: "gordon-test.example.com",
65 Image: "myapp:latest",
66 Status: "created",
67 }
68 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
69 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
70
71 // Wait for ready: IsContainerRunning (first check returns true) + verify after delay
72 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
73
74 // Re-inspect after start
75 runningContainer := &domain.Container{
76 ID: "container-123",
77 Name: "gordon-test.example.com",
78 Image: "myapp:latest",
79 Status: "running",
80 Ports: []int{8080},
81 }
82 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
83
84 // Publish container deployed event
85 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
86
87 result, err := svc.Deploy(ctx, route)
88
89 assert.NoError(t, err)
90 assert.NotNil(t, result)
91 assert.Equal(t, "container-123", result.ID)
92 assert.Equal(t, "running", result.Status)
93
94 // Verify container is tracked
95 tracked, exists := svc.Get(ctx, "test.example.com")
96 assert.True(t, exists)
97 assert.Equal(t, "container-123", tracked.ID)
98}
99
100func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T) {
101 runtime := mocks.NewMockContainerRuntime(t)
102 envLoader := mocks.NewMockEnvLoader(t)
103 eventBus := mocks.NewMockEventPublisher(t)
104
105 config := Config{
106 NetworkIsolation: false,
107 VolumeAutoCreate: false,
108 ReadinessDelay: time.Millisecond,
109 DrainDelay: time.Millisecond,
110 }
111
112 svc := NewService(runtime, envLoader, eventBus, nil, config)
113 ctx := testContext()
114
115 route := domain.Route{
116 Domain: "test.example.com",
117 Image: "myapp:latest",
118 }
119
120 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
121 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
122 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
123 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
124 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
125 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
126
127 createdContainer := &domain.Container{
128 ID: "container-123",
129 Name: "gordon-test.example.com",
130 Image: "myapp:latest",
131 Status: "created",
132 }
133 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
134 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
135
136 // Readiness check sequence:
137 // 1) Initial "started" check => running
138 // 2) Post-delay verification => transient not-running
139 // 3) Recovery-window poll => running again
140 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once()
141 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(false, nil).Once()
142 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once()
143
144 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
145 ID: "container-123",
146 Name: "gordon-test.example.com",
147 Image: "myapp:latest",
148 Status: "running",
149 Ports: []int{8080},
150 }, nil)
151 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
152
153 result, err := svc.Deploy(ctx, route)
154
155 assert.NoError(t, err)
156 assert.NotNil(t, result)
157 assert.Equal(t, "container-123", result.ID)
158}
159
160func TestService_Deploy_ImagePullFailure(t *testing.T) {
161 runtime := mocks.NewMockContainerRuntime(t)
162 envLoader := mocks.NewMockEnvLoader(t)
163 eventBus := mocks.NewMockEventPublisher(t)
164
165 config := Config{}
166 svc := NewService(runtime, envLoader, eventBus, nil, config)
167 ctx := testContext()
168
169 route := domain.Route{
170 Domain: "test.example.com",
171 Image: "myapp:latest",
172 }
173
174 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
175 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
176 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(errors.New("image not found"))
177
178 result, err := svc.Deploy(ctx, route)
179
180 assert.Error(t, err)
181 assert.Nil(t, result)
182 assert.Contains(t, err.Error(), "failed to pull image")
183}
184
185func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
186 runtime := mocks.NewMockContainerRuntime(t)
187
188 config := Config{
189 RegistryAuthEnabled: true,
190 ServiceTokenUsername: "gordon-service",
191 ServiceToken: "service-token",
192 }
193
194 svc := NewService(runtime, nil, nil, nil, config)
195 ctx := testContext()
196
197 runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil)
198
199 err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
200
201 assert.NoError(t, err)
202}
203
204func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
205 runtime := mocks.NewMockContainerRuntime(t)
206
207 config := Config{
208 RegistryAuthEnabled: true,
209 }
210
211 svc := NewService(runtime, nil, nil, nil, config)
212 ctx := testContext()
213
214 err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
215
216 assert.Error(t, err)
217 assert.Contains(t, err.Error(), "registry service token not configured")
218}
219
220func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {
221 runtime := mocks.NewMockContainerRuntime(t)
222
223 config := Config{
224 RegistryAuthEnabled: true,
225 InternalRegistryUsername: "internal",
226 InternalRegistryPassword: "secret",
227 }
228
229 svc := NewService(runtime, nil, nil, nil, config)
230 ctx := testContext()
231
232 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").
233 Return(errors.New("connection refused")).
234 Once()
235 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").
236 Return(nil).
237 Once()
238
239 err := svc.pullImage(ctx, "localhost:5000/myapp:latest", true)
240
241 assert.NoError(t, err)
242}
243
244func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
245 runtime := mocks.NewMockContainerRuntime(t)
246 envLoader := mocks.NewMockEnvLoader(t)
247 eventBus := mocks.NewMockEventPublisher(t)
248 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
249
250 config := Config{
251 ReadinessDelay: time.Millisecond, // Minimal delay for tests
252 DrainDelay: time.Millisecond,
253 }
254 svc := NewService(runtime, envLoader, eventBus, nil, config)
255 svc.SetProxyCacheInvalidator(cacheInvalidator)
256 ctx := testContext()
257
258 // Pre-populate with existing container
259 existingContainer := &domain.Container{
260 ID: "old-container",
261 Name: "gordon-test.example.com",
262 Status: "running",
263 }
264 svc.containers["test.example.com"] = existingContainer
265
266 route := domain.Route{
267 Domain: "test.example.com",
268 Image: "myapp:v2",
269 }
270
271 // Cleanup orphans
272 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
273
274 // Image operations
275 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
276 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
277 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
278
279 // Environment
280 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
281 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
282
283 // Create new container (with -new suffix for zero-downtime)
284 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
285 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
286 return cfg.Name == "gordon-test.example.com-new"
287 })).Return(newContainer, nil)
288 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
289
290 // Wait for ready
291 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
292
293 // Inspect after ready
294 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
295 ID: "new-container",
296 Status: "running",
297 }, nil)
298
299 // Publish event
300 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
301
302 // Synchronous cache invalidation before old container cleanup
303 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
304
305 // Now cleanup old container (after new one is ready + cache invalidated + drain delay)
306 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
307 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
308
309 // Rename new container to canonical name
310 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
311
312 result, err := svc.Deploy(ctx, route)
313
314 assert.NoError(t, err)
315 assert.Equal(t, "new-container", result.ID)
316
317 // Old container should be replaced
318 tracked, exists := svc.Get(ctx, "test.example.com")
319 assert.True(t, exists)
320 assert.Equal(t, "new-container", tracked.ID)
321}
322
323func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
324 runtime := mocks.NewMockContainerRuntime(t)
325 envLoader := mocks.NewMockEnvLoader(t)
326 eventBus := mocks.NewMockEventPublisher(t)
327
328 config := Config{
329 NetworkIsolation: true,
330 NetworkPrefix: "gordon",
331 ReadinessDelay: time.Millisecond, // Minimal delay for tests
332 DrainDelay: time.Millisecond,
333 }
334 svc := NewService(runtime, envLoader, eventBus, nil, config)
335 ctx := testContext()
336
337 route := domain.Route{
338 Domain: "test.example.com",
339 Image: "myapp:latest",
340 }
341
342 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
343 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
344 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
345 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
346 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
347
348 // Network isolation - should check and create network
349 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil)
350 runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", map[string]string{"driver": "bridge"}).Return(nil)
351
352 container := &domain.Container{ID: "container-123", Status: "created"}
353 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
354 return cfg.NetworkMode == "gordon-test-example-com"
355 })).Return(container, nil)
356 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
357
358 // Wait for ready
359 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
360
361 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
362 ID: "container-123",
363 Status: "running",
364 }, nil)
365
366 // Publish event
367 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
368
369 result, err := svc.Deploy(ctx, route)
370
371 assert.NoError(t, err)
372 assert.NotNil(t, result)
373}
374
375func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
376 runtime := mocks.NewMockContainerRuntime(t)
377 envLoader := mocks.NewMockEnvLoader(t)
378 eventBus := mocks.NewMockEventPublisher(t)
379
380 config := Config{
381 VolumeAutoCreate: true,
382 VolumePrefix: "gordon",
383 ReadinessDelay: time.Millisecond, // Minimal delay for tests
384 DrainDelay: time.Millisecond,
385 }
386 svc := NewService(runtime, envLoader, eventBus, nil, config)
387 ctx := testContext()
388
389 route := domain.Route{
390 Domain: "test.example.com",
391 Image: "myapp:latest",
392 }
393
394 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
395 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
396 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
397 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
398 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
399
400 // Volume operations
401 runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data", "/config"}, nil)
402 runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-data").Return(false, nil)
403 runtime.EXPECT().CreateVolume(mock.Anything, "gordon-test-example-com-data").Return(nil)
404 runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-config").Return(true, nil)
405
406 container := &domain.Container{ID: "container-123", Status: "created"}
407 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
408 return len(cfg.Volumes) == 2
409 })).Return(container, nil)
410 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
411
412 // Wait for ready
413 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
414
415 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
416 ID: "container-123",
417 Status: "running",
418 }, nil)
419
420 // Publish event
421 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
422
423 result, err := svc.Deploy(ctx, route)
424
425 assert.NoError(t, err)
426 assert.NotNil(t, result)
427}
428
429func TestService_Stop(t *testing.T) {
430 runtime := mocks.NewMockContainerRuntime(t)
431 envLoader := mocks.NewMockEnvLoader(t)
432 eventBus := mocks.NewMockEventPublisher(t)
433
434 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
435 ctx := testContext()
436
437 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(nil)
438
439 err := svc.Stop(ctx, "container-123")
440
441 assert.NoError(t, err)
442}
443
444func TestService_Stop_Error(t *testing.T) {
445 runtime := mocks.NewMockContainerRuntime(t)
446 envLoader := mocks.NewMockEnvLoader(t)
447 eventBus := mocks.NewMockEventPublisher(t)
448
449 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
450 ctx := testContext()
451
452 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(errors.New("container not found"))
453
454 err := svc.Stop(ctx, "container-123")
455
456 assert.Error(t, err)
457 assert.Contains(t, err.Error(), "failed to stop container")
458}
459
460func TestService_Remove(t *testing.T) {
461 runtime := mocks.NewMockContainerRuntime(t)
462 envLoader := mocks.NewMockEnvLoader(t)
463 eventBus := mocks.NewMockEventPublisher(t)
464
465 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
466 ctx := testContext()
467
468 // Add tracked container
469 svc.containers["test.example.com"] = &domain.Container{
470 ID: "container-123",
471 Name: "gordon-test.example.com",
472 }
473
474 runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)
475
476 err := svc.Remove(ctx, "container-123", true)
477
478 assert.NoError(t, err)
479
480 // Verify container is no longer tracked
481 _, exists := svc.Get(ctx, "test.example.com")
482 assert.False(t, exists)
483}
484
485func TestService_Remove_WithNetworkCleanup(t *testing.T) {
486 runtime := mocks.NewMockContainerRuntime(t)
487 envLoader := mocks.NewMockEnvLoader(t)
488 eventBus := mocks.NewMockEventPublisher(t)
489
490 config := Config{
491 NetworkIsolation: true,
492 NetworkPrefix: "gordon",
493 }
494 svc := NewService(runtime, envLoader, eventBus, nil, config)
495 ctx := testContext()
496
497 svc.containers["test.example.com"] = &domain.Container{
498 ID: "container-123",
499 Name: "gordon-test.example.com",
500 }
501
502 runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)
503 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
504 {Name: "gordon-test-example-com", Containers: []string{}},
505 }, nil)
506 runtime.EXPECT().RemoveNetwork(mock.Anything, "gordon-test-example-com").Return(nil)
507
508 err := svc.Remove(ctx, "container-123", true)
509
510 assert.NoError(t, err)
511}
512
513func TestService_Get(t *testing.T) {
514 runtime := mocks.NewMockContainerRuntime(t)
515 envLoader := mocks.NewMockEnvLoader(t)
516 eventBus := mocks.NewMockEventPublisher(t)
517
518 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
519 ctx := testContext()
520
521 container := &domain.Container{ID: "container-123"}
522 svc.containers["test.example.com"] = container
523
524 result, exists := svc.Get(ctx, "test.example.com")
525
526 assert.True(t, exists)
527 assert.Equal(t, "container-123", result.ID)
528}
529
530func TestService_Get_NotFound(t *testing.T) {
531 runtime := mocks.NewMockContainerRuntime(t)
532 envLoader := mocks.NewMockEnvLoader(t)
533 eventBus := mocks.NewMockEventPublisher(t)
534
535 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
536 ctx := testContext()
537
538 result, exists := svc.Get(ctx, "nonexistent.example.com")
539
540 assert.False(t, exists)
541 assert.Nil(t, result)
542}
543
544func TestService_List(t *testing.T) {
545 runtime := mocks.NewMockContainerRuntime(t)
546 envLoader := mocks.NewMockEnvLoader(t)
547 eventBus := mocks.NewMockEventPublisher(t)
548
549 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
550 ctx := testContext()
551
552 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
553 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
554
555 result := svc.List(ctx)
556
557 assert.Len(t, result, 2)
558 assert.Equal(t, "container-1", result["app1.example.com"].ID)
559 assert.Equal(t, "container-2", result["app2.example.com"].ID)
560}
561
562func TestService_HealthCheck(t *testing.T) {
563 runtime := mocks.NewMockContainerRuntime(t)
564 envLoader := mocks.NewMockEnvLoader(t)
565 eventBus := mocks.NewMockEventPublisher(t)
566
567 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
568 ctx := testContext()
569
570 svc.containers["healthy.example.com"] = &domain.Container{ID: "healthy-container"}
571 svc.containers["unhealthy.example.com"] = &domain.Container{ID: "unhealthy-container"}
572
573 runtime.EXPECT().IsContainerRunning(mock.Anything, "healthy-container").Return(true, nil)
574 runtime.EXPECT().IsContainerRunning(mock.Anything, "unhealthy-container").Return(false, nil)
575
576 result := svc.HealthCheck(ctx)
577
578 assert.True(t, result["healthy.example.com"])
579 assert.False(t, result["unhealthy.example.com"])
580}
581
582func TestService_SyncContainers(t *testing.T) {
583 runtime := mocks.NewMockContainerRuntime(t)
584 envLoader := mocks.NewMockEnvLoader(t)
585 eventBus := mocks.NewMockEventPublisher(t)
586
587 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
588 ctx := testContext()
589
590 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
591 {
592 ID: "container-1",
593 Name: "gordon-app1.example.com",
594 Labels: map[string]string{
595 "gordon.domain": "app1.example.com",
596 "gordon.managed": "true",
597 },
598 },
599 {
600 ID: "container-2",
601 Name: "gordon-app2.example.com",
602 Labels: map[string]string{
603 "gordon.domain": "app2.example.com",
604 "gordon.managed": "true",
605 },
606 },
607 {
608 ID: "unmanaged-container",
609 Name: "some-other-app",
610 Labels: map[string]string{},
611 },
612 }, nil)
613
614 err := svc.SyncContainers(ctx)
615
616 assert.NoError(t, err)
617 assert.Len(t, svc.containers, 2)
618 assert.Contains(t, svc.containers, "app1.example.com")
619 assert.Contains(t, svc.containers, "app2.example.com")
620}
621
622func TestService_Shutdown(t *testing.T) {
623 runtime := mocks.NewMockContainerRuntime(t)
624 envLoader := mocks.NewMockEnvLoader(t)
625 eventBus := mocks.NewMockEventPublisher(t)
626
627 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
628 ctx := testContext()
629
630 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
631 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
632
633 runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil)
634 runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(nil)
635
636 err := svc.Shutdown(ctx)
637
638 assert.NoError(t, err)
639 assert.Empty(t, svc.containers)
640}
641
642func TestService_Shutdown_PartialFailure(t *testing.T) {
643 runtime := mocks.NewMockContainerRuntime(t)
644 envLoader := mocks.NewMockEnvLoader(t)
645 eventBus := mocks.NewMockEventPublisher(t)
646
647 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
648 ctx := testContext()
649
650 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
651 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
652
653 runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil)
654 runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(errors.New("stop failed"))
655
656 err := svc.Shutdown(ctx)
657
658 // Shutdown logs errors but always returns nil for graceful degradation
659 assert.NoError(t, err)
660 // One container should still be tracked (the one that failed to stop)
661 assert.Len(t, svc.containers, 1)
662}
663
664func TestService_Restart_NotFound(t *testing.T) {
665 runtime := mocks.NewMockContainerRuntime(t)
666 envLoader := mocks.NewMockEnvLoader(t)
667 eventBus := mocks.NewMockEventPublisher(t)
668
669 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
670 ctx := testContext()
671
672 err := svc.Restart(ctx, "nonexistent.example.com", false)
673
674 assert.Error(t, err)
675 assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
676}
677
678func TestService_Restart_Success(t *testing.T) {
679 runtime := mocks.NewMockContainerRuntime(t)
680 envLoader := mocks.NewMockEnvLoader(t)
681 eventBus := mocks.NewMockEventPublisher(t)
682
683 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
684 ctx := testContext()
685
686 svc.containers["test.example.com"] = &domain.Container{
687 ID: "container-123",
688 Name: "gordon-test.example.com",
689 Status: "running",
690 }
691
692 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
693
694 err := svc.Restart(ctx, "test.example.com", false)
695
696 assert.NoError(t, err)
697}
698
699func TestService_Restart_Error(t *testing.T) {
700 runtime := mocks.NewMockContainerRuntime(t)
701 envLoader := mocks.NewMockEnvLoader(t)
702 eventBus := mocks.NewMockEventPublisher(t)
703
704 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
705 ctx := testContext()
706
707 svc.containers["test.example.com"] = &domain.Container{
708 ID: "container-123",
709 Name: "gordon-test.example.com",
710 Status: "running",
711 }
712
713 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(errors.New("restart failed"))
714
715 err := svc.Restart(ctx, "test.example.com", false)
716
717 assert.Error(t, err)
718 assert.Contains(t, err.Error(), "failed to restart container")
719}
720
721func TestService_Restart_ReconcilesStaleContainerID(t *testing.T) {
722 runtime := mocks.NewMockContainerRuntime(t)
723 envLoader := mocks.NewMockEnvLoader(t)
724 eventBus := mocks.NewMockEventPublisher(t)
725
726 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
727 ctx := testContext()
728
729 svc.containers["test.example.com"] = &domain.Container{
730 ID: "stale-container-id",
731 Name: "gordon-test.example.com",
732 Status: "running",
733 }
734
735 runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
736 Return(errors.New("no container with name or ID \"stale-container-id\" found")).
737 Once()
738 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
739 {
740 ID: "fresh-container-id",
741 Name: "gordon-test.example.com",
742 Labels: map[string]string{
743 "gordon.domain": "test.example.com",
744 "gordon.managed": "true",
745 },
746 },
747 }, nil).Once()
748 runtime.EXPECT().RestartContainer(mock.Anything, "fresh-container-id").Return(nil).Once()
749
750 err := svc.Restart(ctx, "test.example.com", false)
751
752 assert.NoError(t, err)
753}
754
755func TestService_Restart_ReconcilesStaleContainerID_NotFoundAfterSync(t *testing.T) {
756 runtime := mocks.NewMockContainerRuntime(t)
757 envLoader := mocks.NewMockEnvLoader(t)
758 eventBus := mocks.NewMockEventPublisher(t)
759
760 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
761 ctx := testContext()
762
763 svc.containers["test.example.com"] = &domain.Container{
764 ID: "stale-container-id",
765 Name: "gordon-test.example.com",
766 Status: "running",
767 }
768
769 runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
770 Return(errors.New("no such container")).
771 Once()
772 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
773
774 err := svc.Restart(ctx, "test.example.com", false)
775
776 assert.Error(t, err)
777 assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
778}
779
780func TestService_Restart_WithAttachments(t *testing.T) {
781 runtime := mocks.NewMockContainerRuntime(t)
782 envLoader := mocks.NewMockEnvLoader(t)
783 eventBus := mocks.NewMockEventPublisher(t)
784
785 svc := NewService(runtime, envLoader, eventBus, nil, Config{})
786 ctx := testContext()
787
788 svc.containers["test.example.com"] = &domain.Container{
789 ID: "container-123",
790 Name: "gordon-test.example.com",
791 Status: "running",
792 }
793 svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"}
794
795 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
796 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
797 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-2").Return(fmt.Errorf("boom"))
798
799 err := svc.Restart(ctx, "test.example.com", true)
800
801 assert.NoError(t, err)
802}
803
804func TestNormalizeImageRef(t *testing.T) {
805 tests := []struct {
806 name string
807 input string
808 expected string
809 }{
810 {
811 name: "simple image",
812 input: "nginx",
813 expected: "docker.io/library/nginx",
814 },
815 {
816 name: "user/repo",
817 input: "user/myapp",
818 expected: "docker.io/user/myapp",
819 },
820 {
821 name: "registry/repo",
822 input: "gcr.io/project/image",
823 expected: "gcr.io/project/image",
824 },
825 {
826 name: "with tag",
827 input: "nginx:latest",
828 expected: "docker.io/library/nginx",
829 },
830 {
831 name: "localhost with port",
832 input: "localhost:5000/myapp:latest",
833 expected: "localhost:5000/myapp",
834 },
835 {
836 name: "localhost with port no tag",
837 input: "localhost:5000/myapp",
838 expected: "localhost:5000/myapp",
839 },
840 {
841 name: "registry domain with port",
842 input: "registry.example.com:5000/image:v1.0",
843 expected: "registry.example.com:5000/image",
844 },
845 }
846
847 for _, tt := range tests {
848 t.Run(tt.name, func(t *testing.T) {
849 result := normalizeImageRef(tt.input)
850 assert.Equal(t, tt.expected, result)
851 })
852 }
853}
854
855func TestGenerateVolumeName(t *testing.T) {
856 tests := []struct {
857 name string
858 prefix string
859 domain string
860 volumePath string
861 expected string
862 }{
863 {
864 name: "simple path",
865 prefix: "gordon",
866 domain: "app.example.com",
867 volumePath: "/data",
868 expected: "gordon-app-example-com-data",
869 },
870 {
871 name: "nested path",
872 prefix: "gordon",
873 domain: "app.example.com",
874 volumePath: "/var/lib/data",
875 expected: "gordon-app-example-com-var-lib-data",
876 },
877 }
878
879 for _, tt := range tests {
880 t.Run(tt.name, func(t *testing.T) {
881 result := generateVolumeName(tt.prefix, tt.domain, tt.volumePath)
882 assert.Equal(t, tt.expected, result)
883 })
884 }
885}
886
887func TestMergeEnvironmentVariables(t *testing.T) {
888 dockerEnv := []string{"FOO=docker", "BAR=docker"}
889 userEnv := []string{"FOO=user", "BAZ=user"}
890
891 result := mergeEnvironmentVariables(dockerEnv, userEnv)
892
893 // User env should override docker env
894 assert.Contains(t, result, "FOO=user")
895 assert.Contains(t, result, "BAR=docker")
896 assert.Contains(t, result, "BAZ=user")
897}
898
899func TestBuildImageRef(t *testing.T) {
900 tests := []struct {
901 name string
902 image string
903 registryAuthEnabled bool
904 registryDomain string
905 wantRef string
906 }{
907 {
908 name: "adds registry domain prefix",
909 image: "myapp:latest",
910 registryAuthEnabled: true,
911 registryDomain: "registry.example.com",
912 wantRef: "registry.example.com/myapp:latest",
913 },
914 {
915 name: "keeps existing registry domain prefix",
916 image: "registry.example.com/myapp:latest",
917 registryAuthEnabled: true,
918 registryDomain: "registry.example.com",
919 wantRef: "registry.example.com/myapp:latest",
920 },
921 {
922 name: "skips external registry images",
923 image: "docker.io/library/nginx:latest",
924 registryAuthEnabled: true,
925 registryDomain: "registry.example.com",
926 wantRef: "docker.io/library/nginx:latest",
927 },
928 {
929 name: "returns original when auth disabled",
930 image: "myapp:latest",
931 registryAuthEnabled: false,
932 registryDomain: "registry.example.com",
933 wantRef: "myapp:latest",
934 },
935 {
936 name: "localhost domain adds prefix",
937 image: "myapp:latest",
938 registryAuthEnabled: true,
939 registryDomain: "localhost:5000",
940 wantRef: "localhost:5000/myapp:latest",
941 },
942 {
943 name: "localhost domain keeps existing prefix",
944 image: "localhost:5000/myapp:latest",
945 registryAuthEnabled: true,
946 registryDomain: "localhost:5000",
947 wantRef: "localhost:5000/myapp:latest",
948 },
949 {
950 name: "explicit host:port different than RegistryDomain",
951 image: "localhost:5001/myapp:latest",
952 registryAuthEnabled: true,
953 registryDomain: "localhost:5000",
954 wantRef: "localhost:5001/myapp:latest",
955 },
956 {
957 name: "ghcr.io external registry",
958 image: "ghcr.io/owner/repo:latest",
959 registryAuthEnabled: true,
960 registryDomain: "localhost:5000",
961 wantRef: "ghcr.io/owner/repo:latest",
962 },
963 {
964 name: "gcr.io external registry",
965 image: "gcr.io/project/image:v1",
966 registryAuthEnabled: true,
967 registryDomain: "registry.example.com",
968 wantRef: "gcr.io/project/image:v1",
969 },
970 {
971 name: "quay.io external registry",
972 image: "quay.io/org/app:tag",
973 registryAuthEnabled: true,
974 registryDomain: "localhost:5000",
975 wantRef: "quay.io/org/app:tag",
976 },
977 {
978 name: "localhost without port keeps existing prefix",
979 image: "localhost/myapp:latest",
980 registryAuthEnabled: true,
981 registryDomain: "localhost:5000",
982 wantRef: "localhost/myapp:latest",
983 },
984 {
985 name: "registry domain with trailing slash",
986 image: "myapp:latest",
987 registryAuthEnabled: true,
988 registryDomain: "registry.example.com/",
989 wantRef: "registry.example.com/myapp:latest",
990 },
991 {
992 name: "ipv6 registry without port",
993 image: "[fd00::1]/myapp:latest",
994 registryAuthEnabled: true,
995 registryDomain: "localhost:5000",
996 wantRef: "[fd00::1]/myapp:latest",
997 },
998 {
999 name: "ipv6 registry with port",
1000 image: "[fd00::1]:5000/myapp:latest",
1001 registryAuthEnabled: true,
1002 registryDomain: "localhost:5000",
1003 wantRef: "[fd00::1]:5000/myapp:latest",
1004 },
1005 }
1006
1007 for _, tt := range tests {
1008 t.Run(tt.name, func(t *testing.T) {
1009 svc := &Service{
1010 config: Config{
1011 RegistryAuthEnabled: tt.registryAuthEnabled,
1012 RegistryDomain: tt.registryDomain,
1013 },
1014 }
1015 gotRef := svc.buildImageRef(tt.image)
1016 assert.Equal(t, tt.wantRef, gotRef, "unexpected image reference")
1017 })
1018 }
1019}
1020
1021func TestRewriteToRegistryDomain(t *testing.T) {
1022 tests := []struct {
1023 name string
1024 imageRef string
1025 registryDomain string
1026 wantRef string
1027 }{
1028 {
1029 name: "keeps registry domain prefix",
1030 imageRef: "registry.example.com/myapp:latest",
1031 registryDomain: "registry.example.com",
1032 wantRef: "registry.example.com/myapp:latest",
1033 },
1034 {
1035 name: "prefixes registry domain",
1036 imageRef: "myapp:v1.0",
1037 registryDomain: "registry.example.com",
1038 wantRef: "registry.example.com/myapp:v1.0",
1039 },
1040 {
1041 name: "prefixes external image",
1042 imageRef: "docker.io/library/nginx:latest",
1043 registryDomain: "registry.example.com",
1044 wantRef: "registry.example.com/docker.io/library/nginx:latest",
1045 },
1046 {
1047 name: "empty registry domain returns original",
1048 imageRef: "registry.example.com/myapp:latest",
1049 registryDomain: "",
1050 wantRef: "registry.example.com/myapp:latest",
1051 },
1052 }
1053
1054 for _, tt := range tests {
1055 t.Run(tt.name, func(t *testing.T) {
1056 gotRef := rewriteToRegistryDomain(tt.imageRef, tt.registryDomain)
1057 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
1058 })
1059 }
1060}
1061
1062func TestRewriteToLocalRegistry(t *testing.T) {
1063 tests := []struct {
1064 name string
1065 imageRef string
1066 registryDomain string
1067 registryPort int
1068 wantRef string
1069 }{
1070 {
1071 name: "rewrites registry domain prefix",
1072 imageRef: "registry.example.com/myapp:latest",
1073 registryDomain: "registry.example.com",
1074 registryPort: 5000,
1075 wantRef: "localhost:5000/myapp:latest",
1076 },
1077 {
1078 name: "prefixes local registry when no domain",
1079 imageRef: "myapp:v1.0",
1080 registryDomain: "registry.example.com",
1081 registryPort: 5000,
1082 wantRef: "localhost:5000/myapp:v1.0",
1083 },
1084 {
1085 name: "keeps existing localhost prefix",
1086 imageRef: "localhost:5000/myapp:latest",
1087 registryDomain: "registry.example.com",
1088 registryPort: 5000,
1089 wantRef: "localhost:5000/myapp:latest",
1090 },
1091 {
1092 name: "prefixes external image path",
1093 imageRef: "docker.io/library/nginx:latest",
1094 registryDomain: "registry.example.com",
1095 registryPort: 5000,
1096 wantRef: "localhost:5000/docker.io/library/nginx:latest",
1097 },
1098 }
1099
1100 for _, tt := range tests {
1101 t.Run(tt.name, func(t *testing.T) {
1102 gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)
1103 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
1104 })
1105 }
1106}
1107
1108func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1109 runtime := mocks.NewMockContainerRuntime(t)
1110 envLoader := mocks.NewMockEnvLoader(t)
1111 eventBus := mocks.NewMockEventPublisher(t)
1112
1113 config := Config{
1114 RegistryAuthEnabled: true,
1115 RegistryDomain: "registry.example.com",
1116 RegistryPort: 5000,
1117 InternalRegistryUsername: "internal",
1118 InternalRegistryPassword: "secret",
1119 ReadinessDelay: time.Millisecond,
1120 DrainDelay: time.Millisecond,
1121 }
1122
1123 svc := NewService(runtime, envLoader, eventBus, nil, config)
1124
1125 // Use internal deploy context (simulating image push event)
1126 ctx := domain.WithInternalDeploy(testContext())
1127
1128 route := domain.Route{
1129 Domain: "test.example.com",
1130 Image: "myapp:latest",
1131 }
1132
1133 // Setup mocks - no orphaned containers
1134 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1135
1136 // Even though image exists locally, internal deploy should force pull
1137 // Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check
1138 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
1139
1140 // Tag image to canonical name
1141 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil)
1142 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1143
1144 // Get exposed ports
1145 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
1146
1147 // Load environment
1148 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1149 runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil)
1150
1151 // Create and start container
1152 createdContainer := &domain.Container{
1153 ID: "container-123",
1154 Name: "gordon-test.example.com",
1155 Status: "created",
1156 }
1157 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
1158 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
1159
1160 // Wait for ready
1161 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
1162
1163 // Re-inspect after start
1164 runningContainer := &domain.Container{
1165 ID: "container-123",
1166 Name: "gordon-test.example.com",
1167 Status: "running",
1168 Ports: []int{8080},
1169 }
1170 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
1171
1172 // Publish event
1173 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1174
1175 result, err := svc.Deploy(ctx, route)
1176
1177 assert.NoError(t, err)
1178 assert.NotNil(t, result)
1179 assert.Equal(t, "container-123", result.ID)
1180}
1181
1182func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1183 runtime := mocks.NewMockContainerRuntime(t)
1184 envLoader := mocks.NewMockEnvLoader(t)
1185 eventBus := mocks.NewMockEventPublisher(t)
1186
1187 config := Config{
1188 ReadinessDelay: time.Millisecond,
1189 DrainDelay: time.Millisecond,
1190 }
1191 svc := NewService(runtime, envLoader, eventBus, nil, config)
1192 ctx := testContext()
1193
1194 routes := []domain.Route{
1195 {Domain: "app1.example.com", Image: "myapp1:latest"},
1196 {Domain: "app2.example.com", Image: "myapp2:latest"},
1197 }
1198
1199 // Setup mocks for first route deployment
1200 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1201 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
1202 runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
1203 envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1204 runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1205
1206 // Create and start containers
1207 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1208 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1209 }, nil).Once()
1210 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1211 ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
1212 }, nil).Once()
1213 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
1214 runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
1215 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
1216 Status: "running",
1217 }, nil).Times(2)
1218 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2)
1219
1220 err := svc.AutoStart(ctx, routes)
1221
1222 assert.NoError(t, err)
1223 assert.Len(t, svc.containers, 2)
1224}
1225
1226func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1227 runtime := mocks.NewMockContainerRuntime(t)
1228 envLoader := mocks.NewMockEnvLoader(t)
1229 eventBus := mocks.NewMockEventPublisher(t)
1230
1231 config := Config{
1232 ReadinessDelay: time.Millisecond,
1233 DrainDelay: time.Millisecond,
1234 }
1235 svc := NewService(runtime, envLoader, eventBus, nil, config)
1236 ctx := testContext()
1237
1238 // Pre-populate with existing container
1239 svc.containers["app1.example.com"] = &domain.Container{
1240 ID: "existing-container",
1241 }
1242
1243 routes := []domain.Route{
1244 {Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists
1245 {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
1246 }
1247
1248 // Only deploy for app2 (app1 is skipped)
1249 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
1250 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
1251 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
1252 envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
1253 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
1254 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1255 ID: "container-2", Status: "created",
1256 }, nil).Once()
1257 runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
1258 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2)
1259 runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
1260 Status: "running",
1261 }, nil).Once()
1262 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
1263
1264 err := svc.AutoStart(ctx, routes)
1265
1266 assert.NoError(t, err)
1267 assert.Len(t, svc.containers, 2)
1268}
1269
1270func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
1271 runtime := mocks.NewMockContainerRuntime(t)
1272 envLoader := mocks.NewMockEnvLoader(t)
1273 eventBus := mocks.NewMockEventPublisher(t)
1274
1275 config := Config{}
1276 svc := NewService(runtime, envLoader, eventBus, nil, config)
1277 ctx := testContext()
1278
1279 routes := []domain.Route{
1280 {Domain: "app1.example.com", Image: "myapp1:latest"},
1281 }
1282
1283 // Setup mocks for failure
1284 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1285 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1286 runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found"))
1287
1288 err := svc.AutoStart(ctx, routes)
1289
1290 // AutoStart should return error when some deployments fail
1291 assert.Error(t, err)
1292 assert.Contains(t, err.Error(), "auto-start completed with 1 errors")
1293}
1294
1295func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1296 runtime := mocks.NewMockContainerRuntime(t)
1297 envLoader := mocks.NewMockEnvLoader(t)
1298 eventBus := mocks.NewMockEventPublisher(t)
1299
1300 config := Config{
1301 RegistryDomain: "reg.example.com",
1302 RegistryPort: 5000,
1303 ReadinessDelay: time.Millisecond,
1304 DrainDelay: time.Millisecond,
1305 }
1306 svc := NewService(runtime, envLoader, eventBus, nil, config)
1307
1308 // Mark context as internal deploy — this is what syncAndAutoStart should do
1309 ctx := domain.WithInternalDeploy(testContext())
1310
1311 routes := []domain.Route{
1312 {Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"},
1313 }
1314
1315 // Key assertion: PullImage should be called with localhost:5000 rewrite,
1316 // NOT the original reg.example.com/myapp:latest
1317 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1318 runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1319 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)
1320 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1321 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "reg.example.com/myapp:latest").Return([]int{8080}, nil)
1322 envLoader.EXPECT().LoadEnv(mock.Anything, "app1.example.com").Return([]string{}, nil)
1323 runtime.EXPECT().InspectImageEnv(mock.Anything, "reg.example.com/myapp:latest").Return([]string{}, nil)
1324 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1325 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1326 }, nil)
1327 runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil)
1328 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-1").Return(true, nil).Times(2)
1329 runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{
1330 Status: "running",
1331 }, nil)
1332 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1333
1334 err := svc.AutoStart(ctx, routes)
1335 assert.NoError(t, err)
1336}
1337
1338// TestService_Deploy_OrphanCleanupSkipsTrackedContainer verifies that the orphan cleanup
1339// does not remove the currently tracked container during zero-downtime deployment.
1340// This is critical for preventing downtime - the old container must stay running
1341// until the new container is ready and traffic is switched.
1342func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1343 runtime := mocks.NewMockContainerRuntime(t)
1344 envLoader := mocks.NewMockEnvLoader(t)
1345 eventBus := mocks.NewMockEventPublisher(t)
1346 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1347
1348 config := Config{
1349 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1350 DrainDelay: time.Millisecond,
1351 }
1352 svc := NewService(runtime, envLoader, eventBus, nil, config)
1353 svc.SetProxyCacheInvalidator(cacheInvalidator)
1354 ctx := testContext()
1355
1356 // Pre-populate with existing tracked container
1357 existingContainer := &domain.Container{
1358 ID: "tracked-container-123",
1359 Name: "gordon-test.example.com",
1360 Status: "running",
1361 }
1362 svc.containers["test.example.com"] = existingContainer
1363
1364 route := domain.Route{
1365 Domain: "test.example.com",
1366 Image: "myapp:v2",
1367 }
1368
1369 // ListContainers returns the tracked container - this simulates the orphan check
1370 // finding a container with the same name. The bug was that it would remove this
1371 // container BEFORE the new one was ready, causing downtime.
1372 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1373 {
1374 ID: "tracked-container-123",
1375 Name: "gordon-test.example.com",
1376 Status: "running",
1377 },
1378 }, nil)
1379
1380 // Image operations
1381 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1382 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1383 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1384
1385 // Environment
1386 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1387 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1388
1389 // Create new container with -new suffix for zero-downtime
1390 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1391 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1392 return cfg.Name == "gordon-test.example.com-new"
1393 })).Return(newContainer, nil)
1394 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1395
1396 // Wait for ready
1397 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1398
1399 // Inspect after ready
1400 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1401 ID: "new-container",
1402 Status: "running",
1403 }, nil)
1404
1405 // Publish event
1406 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1407
1408 // Synchronous cache invalidation
1409 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1410
1411 // NOW (after new container is ready) the old container should be stopped and removed
1412 // This is the correct zero-downtime sequence - not during orphan cleanup
1413 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1414 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1415
1416 // Rename new container to canonical name
1417 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1418
1419 result, err := svc.Deploy(ctx, route)
1420
1421 assert.NoError(t, err)
1422 assert.Equal(t, "new-container", result.ID)
1423
1424 // Verify new container is now tracked
1425 tracked, exists := svc.Get(ctx, "test.example.com")
1426 assert.True(t, exists)
1427 assert.Equal(t, "new-container", tracked.ID)
1428}
1429
1430// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers
1431// with the same name but NOT tracked are properly removed as orphans.
1432func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1433 runtime := mocks.NewMockContainerRuntime(t)
1434 envLoader := mocks.NewMockEnvLoader(t)
1435 eventBus := mocks.NewMockEventPublisher(t)
1436
1437 config := Config{
1438 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1439 DrainDelay: time.Millisecond,
1440 }
1441 svc := NewService(runtime, envLoader, eventBus, nil, config)
1442 ctx := testContext()
1443
1444 // NO tracked container - service is empty for this domain
1445
1446 route := domain.Route{
1447 Domain: "test.example.com",
1448 Image: "myapp:v1",
1449 }
1450
1451 // ListContainers returns an orphaned container (same name, but not tracked)
1452 // This could happen if Gordon crashed and restarted, or container was created manually
1453 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1454 {
1455 ID: "orphan-container",
1456 Name: "gordon-test.example.com",
1457 Status: "running",
1458 },
1459 }, nil)
1460
1461 // Orphan should be stopped and removed BEFORE we proceed
1462 runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
1463 runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
1464
1465 // Image operations
1466 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1467 runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
1468 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
1469
1470 // Environment
1471 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1472 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v1").Return([]string{}, nil)
1473
1474 // Create container (no -new suffix since no existing tracked container)
1475 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
1476 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1477 return cfg.Name == "gordon-test.example.com"
1478 })).Return(newContainer, nil)
1479 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1480
1481 // Wait for ready
1482 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1483
1484 // Inspect after ready
1485 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1486 ID: "new-container",
1487 Status: "running",
1488 Ports: []int{8080},
1489 }, nil)
1490
1491 // Publish event
1492 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1493
1494 result, err := svc.Deploy(ctx, route)
1495
1496 assert.NoError(t, err)
1497 assert.Equal(t, "new-container", result.ID)
1498
1499 // Verify new container is tracked
1500 tracked, exists := svc.Get(ctx, "test.example.com")
1501 assert.True(t, exists)
1502 assert.Equal(t, "new-container", tracked.ID)
1503}
1504
1505func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
1506 runtime := mocks.NewMockContainerRuntime(t)
1507 envLoader := mocks.NewMockEnvLoader(t)
1508 eventBus := mocks.NewMockEventPublisher(t)
1509 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1510
1511 config := Config{
1512 ReadinessDelay: time.Millisecond,
1513 DrainDelay: time.Millisecond,
1514 }
1515 svc := NewService(runtime, envLoader, eventBus, nil, config)
1516 svc.SetProxyCacheInvalidator(cacheInvalidator)
1517 ctx := testContext()
1518
1519 // Tracked canonical container exists, but stale -new container should be removed.
1520 svc.containers["test.example.com"] = &domain.Container{
1521 ID: "tracked-container-123",
1522 Name: "gordon-test.example.com",
1523 Status: "running",
1524 }
1525
1526 route := domain.Route{
1527 Domain: "test.example.com",
1528 Image: "myapp:v2",
1529 }
1530
1531 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1532 {
1533 ID: "tracked-container-123",
1534 Name: "gordon-test.example.com",
1535 Status: "running",
1536 },
1537 {
1538 ID: "stale-new-container",
1539 Name: "gordon-test.example.com-new",
1540 Status: "exited",
1541 },
1542 }, nil)
1543 runtime.EXPECT().StopContainer(mock.Anything, "stale-new-container").Return(nil).Once()
1544 runtime.EXPECT().RemoveContainer(mock.Anything, "stale-new-container", true).Return(nil).Once()
1545
1546 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1547 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1548 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1549 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1550 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1551
1552 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1553 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1554 return cfg.Name == "gordon-test.example.com-new"
1555 })).Return(newContainer, nil)
1556 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1557 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1558 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1559 ID: "new-container",
1560 Status: "running",
1561 }, nil)
1562 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1563
1564 // Synchronous cache invalidation
1565 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1566
1567 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1568 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1569 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1570
1571 result, err := svc.Deploy(ctx, route)
1572 assert.NoError(t, err)
1573 assert.NotNil(t, result)
1574 assert.Equal(t, "new-container", result.ID)
1575}
1576
1577func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) {
1578 runtime := mocks.NewMockContainerRuntime(t)
1579 envLoader := mocks.NewMockEnvLoader(t)
1580 eventBus := mocks.NewMockEventPublisher(t)
1581 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1582
1583 config := Config{
1584 ReadinessDelay: time.Millisecond,
1585 DrainDelay: time.Millisecond,
1586 }
1587 svc := NewService(runtime, envLoader, eventBus, nil, config)
1588 svc.SetProxyCacheInvalidator(cacheInvalidator)
1589 ctx := testContext()
1590
1591 // Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name.
1592 svc.containers["test.example.com"] = &domain.Container{
1593 ID: "tracked-temp-container",
1594 Name: "gordon-test.example.com-new",
1595 Status: "running",
1596 }
1597
1598 route := domain.Route{
1599 Domain: "test.example.com",
1600 Image: "myapp:v2",
1601 }
1602
1603 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1604 {
1605 ID: "tracked-temp-container",
1606 Name: "gordon-test.example.com-new",
1607 Status: "running",
1608 },
1609 }, nil)
1610
1611 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1612 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1613 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1614 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1615 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1616
1617 created := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-next", Status: "created"}
1618 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1619 return cfg.Name == "gordon-test.example.com-next"
1620 })).Return(created, nil)
1621 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1622 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1623 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1624 ID: "new-container",
1625 Status: "running",
1626 }, nil)
1627 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1628
1629 // Synchronous cache invalidation
1630 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1631
1632 runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil)
1633 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil)
1634 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1635
1636 result, err := svc.Deploy(ctx, route)
1637 assert.NoError(t, err)
1638 assert.NotNil(t, result)
1639 assert.Equal(t, "new-container", result.ID)
1640}
1641
1642func TestService_StripRegistryPrefix(t *testing.T) {
1643 tests := []struct {
1644 name string
1645 registryDomain string
1646 image string
1647 expected string
1648 }{
1649 {
1650 name: "strips registry prefix",
1651 registryDomain: "reg.example.com",
1652 image: "reg.example.com/myapp:latest",
1653 expected: "myapp:latest",
1654 },
1655 {
1656 name: "strips registry prefix with trailing slash in domain",
1657 registryDomain: "reg.example.com/",
1658 image: "reg.example.com/myapp:v1.0",
1659 expected: "myapp:v1.0",
1660 },
1661 {
1662 name: "preserves image without registry prefix",
1663 registryDomain: "reg.example.com",
1664 image: "nginx:latest",
1665 expected: "nginx:latest",
1666 },
1667 {
1668 name: "preserves image with different registry",
1669 registryDomain: "reg.example.com",
1670 image: "gcr.io/project/image:latest",
1671 expected: "gcr.io/project/image:latest",
1672 },
1673 {
1674 name: "handles empty registry domain",
1675 registryDomain: "",
1676 image: "reg.example.com/myapp:latest",
1677 expected: "reg.example.com/myapp:latest",
1678 },
1679 {
1680 name: "handles nested paths",
1681 registryDomain: "reg.example.com",
1682 image: "reg.example.com/org/repo/app:latest",
1683 expected: "org/repo/app:latest",
1684 },
1685 }
1686
1687 for _, tt := range tests {
1688 t.Run(tt.name, func(t *testing.T) {
1689 runtime := mocks.NewMockContainerRuntime(t)
1690 envLoader := mocks.NewMockEnvLoader(t)
1691 eventBus := mocks.NewMockEventPublisher(t)
1692
1693 config := Config{
1694 RegistryDomain: tt.registryDomain,
1695 }
1696 svc := NewService(runtime, envLoader, eventBus, nil, config)
1697
1698 result := svc.stripRegistryPrefix(tt.image)
1699 assert.Equal(t, tt.expected, result)
1700 })
1701 }
1702}
1703
1704func TestSanitizeName(t *testing.T) {
1705 tests := []struct {
1706 domain string
1707 expected string
1708 description string
1709 }{
1710 {
1711 domain: "git.example.com",
1712 expected: "git__example__com",
1713 description: "Dots become double underscores",
1714 },
1715 {
1716 domain: "git-example.com",
1717 expected: "git-example__com",
1718 description: "Hyphens preserved, dots become underscores",
1719 },
1720 {
1721 domain: "app:8080.example.com",
1722 expected: "app-_8080__example__com",
1723 description: "Colons become hyphen-underscore",
1724 },
1725 {
1726 domain: "git.example.com:3000",
1727 expected: "git__example__com-_3000",
1728 description: "Multiple separators handled distinctly",
1729 },
1730 {
1731 domain: "simple.com",
1732 expected: "simple__com",
1733 description: "Simple domain",
1734 },
1735 }
1736
1737 for _, tt := range tests {
1738 t.Run(tt.domain, func(t *testing.T) {
1739 result := sanitizeName(tt.domain)
1740 assert.Equal(t, tt.expected, result, "sanitization should match expected")
1741 })
1742 }
1743
1744 // Verify no collisions between potentially conflicting domains
1745 t.Run("NoCollisions", func(t *testing.T) {
1746 domains := []string{
1747 "git.example.com",
1748 "git-example.com",
1749 "app:8080.example.com",
1750 "app-8080-example.com",
1751 }
1752
1753 results := make(map[string]string)
1754 for _, d := range domains {
1755 result := sanitizeName(d)
1756 if original, exists := results[result]; exists {
1757 t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
1758 }
1759 results[result] = d
1760 }
1761 })
1762}
1763
1764func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1765 // Verify that concurrent Deploy calls for the same domain are serialized
1766 // and both succeed without container name conflicts.
1767 runtime := mocks.NewMockContainerRuntime(t)
1768 envLoader := mocks.NewMockEnvLoader(t)
1769 eventBus := mocks.NewMockEventPublisher(t)
1770 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1771
1772 config := Config{
1773 ReadinessDelay: time.Millisecond,
1774 DrainDelay: time.Millisecond,
1775 }
1776 svc := NewService(runtime, envLoader, eventBus, nil, config)
1777 svc.SetProxyCacheInvalidator(cacheInvalidator)
1778 ctx := testContext()
1779
1780 route := domain.Route{
1781 Domain: "test.example.com",
1782 Image: "myapp:latest",
1783 }
1784
1785 // Track call order to verify serialization
1786 var callOrder []string
1787 var callMu sync.Mutex
1788
1789 // Setup mocks that will be called by both deploys sequentially.
1790 // First deploy: no existing container → creates gordon-test.example.com
1791 // Second deploy: sees first container → creates gordon-test.example.com-new
1792
1793 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1794 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
1795 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
1796 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, nil).Times(2)
1797 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
1798
1799 createCall := 0
1800 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
1801 RunAndReturn(func(_ context.Context, cfg *domain.ContainerConfig) (*domain.Container, error) {
1802 callMu.Lock()
1803 createCall++
1804 n := createCall
1805 callOrder = append(callOrder, fmt.Sprintf("create-%d:%s", n, cfg.Name))
1806 callMu.Unlock()
1807 return &domain.Container{
1808 ID: fmt.Sprintf("container-%d", n),
1809 Name: cfg.Name,
1810 Image: cfg.Image,
1811 Status: "created",
1812 }, nil
1813 })
1814
1815 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
1816 // IsContainerRunning is called 2x per deploy in waitForReady (initial check + after delay)
1817 runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
1818
1819 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
1820 RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
1821 // Return correct container name based on ID
1822 // container-1 is the first deploy (canonical name)
1823 // container-2 is the second deploy (with -new suffix)
1824 name := "gordon-test.example.com"
1825 if id == "container-2" {
1826 name = "gordon-test.example.com-new"
1827 }
1828 return &domain.Container{
1829 ID: id,
1830 Name: name,
1831 Image: "myapp:latest",
1832 Status: "running",
1833 Ports: []int{8080},
1834 }, nil
1835 })
1836
1837 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)
1838
1839 // Both deploys call InvalidateTarget (to ensure proxy picks up the new container).
1840 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
1841
1842 // which means it will also stop+remove+rename the old container.
1843 runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
1844 runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
1845 runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Once()
1846
1847 // Launch two concurrent deploys
1848 var wg sync.WaitGroup
1849 errs := make([]error, 2)
1850
1851 wg.Add(2)
1852 for i := range 2 {
1853 go func(idx int) {
1854 defer wg.Done()
1855 _, errs[idx] = svc.Deploy(ctx, route)
1856 }(i)
1857 }
1858 wg.Wait()
1859
1860 // Both should succeed (second deploy waits for the first to finish)
1861 assert.NoError(t, errs[0], "first deploy should succeed")
1862 assert.NoError(t, errs[1], "second deploy should succeed")
1863
1864 // Verify creates were serialized (not interleaved)
1865 callMu.Lock()
1866 assert.Len(t, callOrder, 2, "should have exactly 2 create calls")
1867 // First deploy uses canonical name, second uses -new suffix (zero-downtime)
1868 assert.Equal(t, "create-1:gordon-test.example.com", callOrder[0], "first create should use canonical name")
1869 assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
1870 callMu.Unlock()
1871}
1872
1873func TestService_Deploy_ContextCancellation(t *testing.T) {
1874 // Verify that deploy lock acquisition respects context cancellation.
1875 runtime := mocks.NewMockContainerRuntime(t)
1876 envLoader := mocks.NewMockEnvLoader(t)
1877 eventBus := mocks.NewMockEventPublisher(t)
1878
1879 config := Config{
1880 ReadinessDelay: time.Millisecond,
1881 DrainDelay: time.Millisecond,
1882 }
1883 svc := NewService(runtime, envLoader, eventBus, nil, config)
1884
1885 route := domain.Route{
1886 Domain: "test.example.com",
1887 Image: "myapp:latest",
1888 }
1889
1890 // Test context cancelled before lock acquisition.
1891 cancelledCtx, cancel := context.WithCancel(testContext())
1892 cancel() // Cancel immediately
1893
1894 _, err := svc.Deploy(cancelledCtx, route)
1895 assert.Error(t, err, "deploy should fail with cancelled context")
1896 assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
1897}
1898
1899// TestService_Deploy_CacheInvalidationBeforeOldContainerStop verifies the fix for
1900// the proxy cache race condition: InvalidateTarget must be called synchronously
1901// BEFORE the old container is stopped, preventing 503 errors during deployment.
1902func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
1903 runtime := mocks.NewMockContainerRuntime(t)
1904 envLoader := mocks.NewMockEnvLoader(t)
1905 eventBus := mocks.NewMockEventPublisher(t)
1906 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1907
1908 config := Config{
1909 ReadinessDelay: time.Millisecond,
1910 DrainDelay: time.Millisecond,
1911 }
1912 svc := NewService(runtime, envLoader, eventBus, nil, config)
1913 svc.SetProxyCacheInvalidator(cacheInvalidator)
1914 ctx := testContext()
1915
1916 // Pre-populate with existing container
1917 existingContainer := &domain.Container{
1918 ID: "old-container",
1919 Name: "gordon-test.example.com",
1920 Status: "running",
1921 }
1922 svc.containers["test.example.com"] = existingContainer
1923
1924 route := domain.Route{
1925 Domain: "test.example.com",
1926 Image: "myapp:v2",
1927 }
1928
1929 // Track ordering of cache invalidation and container stop
1930 var callOrder []string
1931 var orderMu sync.Mutex
1932
1933 // Standard deploy mocks
1934 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1935 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
1936 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1937 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1938 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1939
1940 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1941 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
1942 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1943 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1944 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1945 ID: "new-container", Status: "running",
1946 }, nil)
1947 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1948
1949 // Track: cache invalidation should happen first
1950 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").
1951 Run(func(_ context.Context, _ string) {
1952 orderMu.Lock()
1953 callOrder = append(callOrder, "invalidate_cache")
1954 orderMu.Unlock()
1955 }).Return()
1956
1957 // Track: stop should happen after invalidation
1958 runtime.EXPECT().StopContainer(mock.Anything, "old-container").
1959 RunAndReturn(func(_ context.Context, _ string) error {
1960 orderMu.Lock()
1961 callOrder = append(callOrder, "stop_old_container")
1962 orderMu.Unlock()
1963 return nil
1964 })
1965 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
1966 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1967
1968 result, err := svc.Deploy(ctx, route)
1969
1970 assert.NoError(t, err)
1971 assert.Equal(t, "new-container", result.ID)
1972
1973 // Verify ordering: cache invalidation MUST happen before old container stop
1974 orderMu.Lock()
1975 defer orderMu.Unlock()
1976 assert.Equal(t, []string{"invalidate_cache", "stop_old_container"}, callOrder,
1977 "InvalidateTarget must be called before StopContainer to prevent 503 errors")
1978}
1979
1980// TestService_Deploy_NilCacheInvalidator verifies that deploy works gracefully
1981// when no cache invalidator is set (e.g., in tests or minimal configurations).
1982func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
1983 runtime := mocks.NewMockContainerRuntime(t)
1984 envLoader := mocks.NewMockEnvLoader(t)
1985 eventBus := mocks.NewMockEventPublisher(t)
1986
1987 config := Config{
1988 ReadinessDelay: time.Millisecond,
1989 DrainDelay: time.Millisecond,
1990 }
1991 // Intentionally NOT setting cache invalidator
1992 svc := NewService(runtime, envLoader, eventBus, nil, config)
1993 ctx := testContext()
1994
1995 // Pre-populate with existing container
1996 svc.containers["test.example.com"] = &domain.Container{
1997 ID: "old-container",
1998 Name: "gordon-test.example.com",
1999 Status: "running",
2000 }
2001
2002 route := domain.Route{
2003 Domain: "test.example.com",
2004 Image: "myapp:v2",
2005 }
2006
2007 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2008 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
2009 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2010 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2011 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2012
2013 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2014 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2015 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2016 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2017 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2018 ID: "new-container", Status: "running",
2019 }, nil)
2020 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2021 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
2022 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2023 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2024
2025 result, err := svc.Deploy(ctx, route)
2026
2027 assert.NoError(t, err)
2028 assert.Equal(t, "new-container", result.ID)
2029}