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