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"
21 mocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
22 "github.com/bnema/gordon/internal/domain"
23)
24
25func testContext() context.Context {
26 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
27}
28
29// testMinDelayConfig returns a Config with all timing delays set to 1ms,
30// suitable for unit tests that don't want to wait for real timeouts.
31func testMinDelayConfig() Config {
32 return Config{
33 ReadinessDelay: time.Millisecond,
34 DrainDelay: time.Millisecond,
35 DrainDelayConfigured: true,
36 StabilizationDelay: time.Millisecond,
37 }
38}
39
40func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) {
41 t.Helper()
42
43 prev := otel.GetMeterProvider()
44 t.Cleanup(func() {
45 otel.SetMeterProvider(prev)
46 })
47
48 reader := sdkmetric.NewManualReader()
49 mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
50 otel.SetMeterProvider(mp)
51 t.Cleanup(func() {
52 _ = mp.Shutdown(context.Background())
53 })
54
55 m, err := telemetry.NewMetrics()
56 require.NoError(t, err)
57 return m, reader
58}
59
60func managedMetricState(t *testing.T, reader *sdkmetric.ManualReader) (int64, int, []metricdata.DataPoint[int64]) {
61 t.Helper()
62
63 var rm metricdata.ResourceMetrics
64 require.NoError(t, reader.Collect(context.Background(), &rm))
65
66 for _, sm := range rm.ScopeMetrics {
67 for _, m := range sm.Metrics {
68 if m.Name != "gordon.container.managed" {
69 continue
70 }
71 sum, ok := m.Data.(metricdata.Sum[int64])
72 require.True(t, ok)
73 var total int64
74 for _, dp := range sum.DataPoints {
75 total += dp.Value
76 }
77 return total, len(sum.DataPoints), sum.DataPoints
78 }
79 }
80
81 return 0, 0, nil
82}
83
84func TestService_NewService_WithConfigProvider(t *testing.T) {
85 runtime := mocks.NewMockContainerRuntime(t)
86 envLoader := mocks.NewMockEnvLoader(t)
87 eventBus := mocks.NewMockEventPublisher(t)
88
89 provider := mocks.NewMockAttachmentConfigProvider(t)
90 provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
91 Attachments: map[string][]string{"test.com": {"postgres:16"}},
92 NetworkGroups: map[string][]string{"group1": {"test.com"}},
93 }).Maybe()
94
95 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
96 assert.NotNil(t, svc)
97}
98
99func TestService_ResolveAttachments_UsesLiveConfigProvider(t *testing.T) {
100 runtime := mocks.NewMockContainerRuntime(t)
101 envLoader := mocks.NewMockEnvLoader(t)
102 eventBus := mocks.NewMockEventPublisher(t)
103
104 provider := mocks.NewMockAttachmentConfigProvider(t)
105 provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
106 Attachments: map[string][]string{"app.example.com": {"postgres:16"}},
107 NetworkGroups: map[string][]string{},
108 }).Once()
109 provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
110 Attachments: map[string][]string{"app.example.com": {"postgres:16", "redis:7"}},
111 NetworkGroups: map[string][]string{},
112 }).Once()
113
114 // Create service with EMPTY static config but a live provider
115 svc := NewService(runtime, envLoader, eventBus, nil, Config{
116 NetworkIsolation: true,
117 NetworkPrefix: "gordon",
118 }, provider)
119
120 // resolveAttachmentsForDomain should return the provider's data, not the empty snapshot
121 result := svc.resolveAttachmentsForDomain("app.example.com")
122 assert.Equal(t, []string{"postgres:16"}, result)
123
124 result = svc.resolveAttachmentsForDomain("app.example.com")
125 assert.Equal(t, []string{"postgres:16", "redis:7"}, result)
126}
127
128func TestService_ResolveAttachments_GroupBasedViaProvider(t *testing.T) {
129 provider := mocks.NewMockAttachmentConfigProvider(t)
130 provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
131 Attachments: map[string][]string{
132 "shared-group": {"postgres:16"},
133 },
134 NetworkGroups: map[string][]string{
135 "shared-group": {"app1.example.com", "app2.example.com"},
136 },
137 }).Twice()
138
139 svc := NewService(
140 mocks.NewMockContainerRuntime(t),
141 mocks.NewMockEnvLoader(t),
142 mocks.NewMockEventPublisher(t),
143 nil, Config{NetworkIsolation: true, NetworkPrefix: "gordon"}, provider,
144 )
145
146 // Domain in group should see group attachments
147 result := svc.resolveAttachmentsForDomain("app1.example.com")
148 assert.Equal(t, []string{"postgres:16"}, result)
149
150 // Domain not in any group should see nothing
151 result = svc.resolveAttachmentsForDomain("other.example.com")
152 assert.Empty(t, result)
153}
154
155func TestService_GetNetworkForApp_UsesLiveConfigProvider(t *testing.T) {
156 provider := mocks.NewMockAttachmentConfigProvider(t)
157 provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
158 Attachments: map[string][]string{},
159 NetworkGroups: map[string][]string{
160 "shared-group": {"app1.example.com", "app2.example.com"},
161 },
162 }).Once()
163 provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
164 Attachments: map[string][]string{},
165 NetworkGroups: map[string][]string{
166 "shared-group": {"app1.example.com", "app2.example.com", "app3.example.com"},
167 },
168 }).Once()
169 provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
170 Attachments: map[string][]string{},
171 NetworkGroups: map[string][]string{
172 "shared-group": {"app1.example.com", "app2.example.com", "app3.example.com"},
173 },
174 }).Once()
175
176 svc := NewService(
177 mocks.NewMockContainerRuntime(t),
178 mocks.NewMockEnvLoader(t),
179 mocks.NewMockEventPublisher(t),
180 nil, Config{NetworkIsolation: true, NetworkPrefix: "gordon"}, provider,
181 )
182
183 // Should use group network: generateNetworkName produces "gordon-shared-group"
184 network := svc.getNetworkForApp("app1.example.com")
185 assert.Equal(t, "gordon-shared-group", network)
186
187 network = svc.getNetworkForApp("app3.example.com")
188 assert.Equal(t, "gordon-shared-group", network)
189
190 // Domain not in any group gets its own network: "gordon-solo-example-com"
191 network = svc.getNetworkForApp("solo.example.com")
192 assert.Equal(t, "gordon-solo-example-com", network)
193}
194
195func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) {
196 runtime := mocks.NewMockContainerRuntime(t)
197 envLoader := mocks.NewMockEnvLoader(t)
198 eventBus := mocks.NewMockEventPublisher(t)
199
200 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
201 ctx := testContext()
202 metrics, reader := setupMetricsTest(t)
203 svc.SetMetrics(metrics)
204 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Twice()
205
206 // First deploy increments metric.
207 svc.activateDeployedContainer(ctx, "test.example.com", &domain.Container{ID: "container-1"})
208 value, seriesCount, points := managedMetricState(t, reader)
209 assert.Equal(t, int64(1), value)
210 assert.Equal(t, 1, seriesCount)
211 assert.Equal(t, 0, points[0].Attributes.Len())
212
213 // Replacing an existing domain should not increment.
214 svc.activateDeployedContainer(ctx, "test.example.com", &domain.Container{ID: "container-2"})
215 value, seriesCount, points = managedMetricState(t, reader)
216 assert.Equal(t, int64(1), value)
217 assert.Equal(t, 1, seriesCount)
218 assert.Equal(t, 0, points[0].Attributes.Len())
219
220 runtime.EXPECT().RemoveContainer(mock.Anything, "container-2", true).Return(nil)
221 require.NoError(t, svc.Remove(ctx, "container-2", true))
222
223 value, seriesCount, points = managedMetricState(t, reader)
224 assert.Equal(t, int64(0), value)
225 assert.Equal(t, 1, seriesCount)
226 assert.Equal(t, 0, points[0].Attributes.Len())
227}
228
229func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T) {
230 runtime := mocks.NewMockContainerRuntime(t)
231 envLoader := mocks.NewMockEnvLoader(t)
232 eventBus := mocks.NewMockEventPublisher(t)
233
234 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
235 ctx := testContext()
236 metrics, reader := setupMetricsTest(t)
237 svc.SetMetrics(metrics)
238
239 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
240 {
241 ID: "container-1",
242 Labels: map[string]string{
243 "gordon.domain": "app1.example.com",
244 "gordon.managed": "true",
245 },
246 },
247 {
248 ID: "container-2",
249 Labels: map[string]string{
250 "gordon.domain": "app2.example.com",
251 "gordon.managed": "true",
252 },
253 },
254 }, nil).Once()
255 require.NoError(t, svc.SyncContainers(ctx))
256
257 value, seriesCount, points := managedMetricState(t, reader)
258 assert.Equal(t, int64(2), value)
259 assert.Equal(t, 1, seriesCount)
260 assert.Equal(t, 0, points[0].Attributes.Len())
261
262 // No runtime change: metric should remain stable.
263 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
264 {
265 ID: "container-1",
266 Labels: map[string]string{
267 "gordon.domain": "app1.example.com",
268 "gordon.managed": "true",
269 },
270 },
271 {
272 ID: "container-2",
273 Labels: map[string]string{
274 "gordon.domain": "app2.example.com",
275 "gordon.managed": "true",
276 },
277 },
278 }, nil).Once()
279 require.NoError(t, svc.SyncContainers(ctx))
280
281 value, seriesCount, points = managedMetricState(t, reader)
282 assert.Equal(t, int64(2), value)
283 assert.Equal(t, 1, seriesCount)
284 assert.Equal(t, 0, points[0].Attributes.Len())
285
286 // One container removed in runtime: metric should decrement by one.
287 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
288 {
289 ID: "container-1",
290 Labels: map[string]string{
291 "gordon.domain": "app1.example.com",
292 "gordon.managed": "true",
293 },
294 },
295 }, nil).Once()
296 require.NoError(t, svc.SyncContainers(ctx))
297
298 value, seriesCount, points = managedMetricState(t, reader)
299 assert.Equal(t, int64(1), value)
300 assert.Equal(t, 1, seriesCount)
301 assert.Equal(t, 0, points[0].Attributes.Len())
302}
303
304func TestService_Deploy_Success(t *testing.T) {
305 runtime := mocks.NewMockContainerRuntime(t)
306 envLoader := mocks.NewMockEnvLoader(t)
307 eventBus := mocks.NewMockEventPublisher(t)
308
309 config := Config{
310 NetworkIsolation: false,
311 VolumeAutoCreate: false,
312 ReadinessDelay: time.Millisecond, // Minimal delay for tests
313 DrainDelay: time.Millisecond,
314 DrainDelayConfigured: true,
315 }
316
317 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
318 ctx := testContext()
319
320 route := domain.Route{
321 Domain: "test.example.com",
322 Image: "myapp:latest",
323 }
324
325 // No container in memory — runtime resolution returns nothing
326 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
327
328 // Setup mocks - no orphaned containers
329 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
330
331 // Image is not found locally, needs to be pulled
332 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
333 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
334
335 // Get exposed ports
336 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
337 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
338
339 // Load environment
340 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{"FOO=bar"}, nil)
341
342 // Inspect image env
343 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{"DEFAULT=value"}, nil)
344
345 // No volume auto-create, so no volume calls
346
347 // Create and start container
348 createdContainer := &domain.Container{
349 ID: "container-123",
350 Name: "gordon-test.example.com",
351 Image: "myapp:latest",
352 Status: "created",
353 }
354 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
355 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
356
357 // Wait for ready: IsContainerRunning (first check returns true) + verify after delay
358 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
359
360 // Re-inspect after start
361 runningContainer := &domain.Container{
362 ID: "container-123",
363 Name: "gordon-test.example.com",
364 Image: "myapp:latest",
365 Status: "running",
366 Ports: []int{8080},
367 }
368 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
369
370 // Publish container deployed event
371 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
372
373 result, err := svc.Deploy(ctx, route)
374
375 assert.NoError(t, err)
376 assert.NotNil(t, result)
377 assert.Equal(t, "container-123", result.ID)
378 assert.Equal(t, "running", result.Status)
379
380 // Verify container is tracked
381 tracked, exists := svc.Get(ctx, "test.example.com")
382 assert.True(t, exists)
383 assert.Equal(t, "container-123", tracked.ID)
384}
385
386func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T) {
387 runtime := mocks.NewMockContainerRuntime(t)
388 envLoader := mocks.NewMockEnvLoader(t)
389 eventBus := mocks.NewMockEventPublisher(t)
390
391 config := Config{
392 NetworkIsolation: false,
393 VolumeAutoCreate: false,
394 ReadinessDelay: time.Millisecond,
395 DrainDelay: time.Millisecond,
396 DrainDelayConfigured: true,
397 }
398
399 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
400 ctx := testContext()
401
402 route := domain.Route{
403 Domain: "test.example.com",
404 Image: "myapp:latest",
405 }
406
407 // No container in memory — runtime resolution returns nothing
408 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
409
410 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
411 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
412 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
413 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
414 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
415 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
416 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
417
418 createdContainer := &domain.Container{
419 ID: "container-123",
420 Name: "gordon-test.example.com",
421 Image: "myapp:latest",
422 Status: "created",
423 }
424 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
425 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
426
427 // Readiness check sequence:
428 // 1) Initial "started" check => running
429 // 2) Post-delay verification => transient not-running
430 // 3) Recovery-window poll => running again
431 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once()
432 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(false, nil).Once()
433 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once()
434
435 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
436 ID: "container-123",
437 Name: "gordon-test.example.com",
438 Image: "myapp:latest",
439 Status: "running",
440 Ports: []int{8080},
441 }, nil)
442 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
443
444 result, err := svc.Deploy(ctx, route)
445
446 assert.NoError(t, err)
447 assert.NotNil(t, result)
448 assert.Equal(t, "container-123", result.ID)
449}
450
451func TestService_Deploy_ImagePullFailure(t *testing.T) {
452 runtime := mocks.NewMockContainerRuntime(t)
453 envLoader := mocks.NewMockEnvLoader(t)
454 eventBus := mocks.NewMockEventPublisher(t)
455
456 config := Config{}
457 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
458 ctx := testContext()
459
460 route := domain.Route{
461 Domain: "test.example.com",
462 Image: "myapp:latest",
463 }
464
465 // No container in memory — runtime resolution returns nothing
466 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
467
468 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
469 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
470 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(errors.New("image not found"))
471
472 result, err := svc.Deploy(ctx, route)
473
474 assert.Error(t, err)
475 assert.Nil(t, result)
476 assert.Contains(t, err.Error(), "failed to pull image")
477}
478
479func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
480 runtime := mocks.NewMockContainerRuntime(t)
481
482 config := Config{
483 RegistryAuthEnabled: true,
484 ServiceTokenUsername: "gordon-service",
485 ServiceToken: "service-token",
486 }
487
488 svc := NewService(runtime, nil, nil, nil, config, nil)
489 ctx := testContext()
490
491 runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil)
492
493 err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
494
495 assert.NoError(t, err)
496}
497
498func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
499 runtime := mocks.NewMockContainerRuntime(t)
500
501 config := Config{
502 RegistryAuthEnabled: true,
503 }
504
505 svc := NewService(runtime, nil, nil, nil, config, nil)
506 ctx := testContext()
507
508 err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
509
510 assert.Error(t, err)
511 assert.Contains(t, err.Error(), "registry service token not configured")
512}
513
514func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {
515 runtime := mocks.NewMockContainerRuntime(t)
516
517 config := Config{
518 RegistryAuthEnabled: true,
519 InternalRegistryUsername: "internal",
520 InternalRegistryPassword: "secret",
521 }
522
523 svc := NewService(runtime, nil, nil, nil, config, nil)
524 ctx := testContext()
525
526 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").
527 Return(errors.New("connection refused")).
528 Once()
529 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").
530 Return(nil).
531 Once()
532
533 err := svc.pullImage(ctx, "localhost:5000/myapp:latest", true)
534
535 assert.NoError(t, err)
536}
537
538func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
539 runtime := mocks.NewMockContainerRuntime(t)
540 envLoader := mocks.NewMockEnvLoader(t)
541 eventBus := mocks.NewMockEventPublisher(t)
542 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
543
544 config := Config{
545 ReadinessDelay: time.Millisecond, // Minimal delay for tests
546 DrainDelay: time.Millisecond,
547 DrainDelayConfigured: true,
548 StabilizationDelay: time.Millisecond,
549 }
550 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
551 svc.SetProxyCacheInvalidator(cacheInvalidator)
552 ctx := testContext()
553
554 // Pre-populate with existing container
555 existingContainer := &domain.Container{
556 ID: "old-container",
557 Name: "gordon-test.example.com",
558 Status: "running",
559 }
560 svc.containers["test.example.com"] = existingContainer
561
562 route := domain.Route{
563 Domain: "test.example.com",
564 Image: "myapp:v2",
565 }
566
567 // Cleanup orphans
568 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
569
570 // Image operations
571 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
572 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
573 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
574 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
575
576 // Environment
577 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
578 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
579
580 // Create new container (with -new suffix for zero-downtime)
581 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
582 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
583 return cfg.Name == "gordon-test.example.com-new"
584 })).Return(newContainer, nil)
585 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
586
587 // Wait for ready (2 calls) + stabilization check (1 call)
588 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
589
590 // Inspect after ready
591 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
592 ID: "new-container",
593 Status: "running",
594 }, nil)
595
596 // Publish event
597 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
598
599 // Synchronous cache invalidation before old container cleanup
600 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
601
602 // Now cleanup old container (after new one is ready + cache invalidated + stabilization + drain delay)
603 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
604 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
605
606 // Rename new container to canonical name
607 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
608
609 result, err := svc.Deploy(ctx, route)
610
611 assert.NoError(t, err)
612 svc.WaitForCleanup()
613 assert.Equal(t, "new-container", result.ID)
614}
615
616// TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale verifies that
617// when in-memory state has no tracked container for a domain, Deploy queries
618// the runtime and discovers the running container. This prevents orphan cleanup
619// from killing the real active container after a Gordon restart.
620func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T) {
621 runtime := mocks.NewMockContainerRuntime(t)
622 envLoader := mocks.NewMockEnvLoader(t)
623 eventBus := mocks.NewMockEventPublisher(t)
624 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
625
626 config := testMinDelayConfig()
627 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
628 svc.SetProxyCacheInvalidator(cacheInvalidator)
629 ctx := testContext()
630
631 // NO tracked container in memory — simulates Gordon restart
632 // (svc.containers is empty for "test.example.com")
633
634 route := domain.Route{
635 Domain: "test.example.com",
636 Image: "myapp:v2",
637 }
638
639 // resolveExistingContainer should query running containers from runtime
640 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
641 {
642 ID: "runtime-active-container",
643 Name: "gordon-test.example.com",
644 Status: "running",
645 Labels: map[string]string{
646 domain.LabelDomain: "test.example.com",
647 domain.LabelManaged: "true",
648 },
649 },
650 }, nil)
651
652 // Orphan cleanup lists ALL containers (including stopped) — the runtime-active
653 // container should be skipped because it is now recognized as the existing one
654 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
655 {
656 ID: "runtime-active-container",
657 Name: "gordon-test.example.com",
658 Status: "running",
659 Labels: map[string]string{
660 domain.LabelDomain: "test.example.com",
661 domain.LabelManaged: "true",
662 },
663 },
664 }, nil)
665
666 // Image operations
667 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
668 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
669 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
670 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
671
672 // Environment
673 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
674 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
675
676 // Create new container with -new suffix (zero-downtime: existing was resolved from runtime)
677 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
678 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
679 return cfg.Name == "gordon-test.example.com-new"
680 })).Return(newContainer, nil)
681 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
682
683 // Wait for ready (2 calls) + stabilization check (1 call)
684 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
685
686 // Inspect after ready
687 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
688 ID: "new-container",
689 Status: "running",
690 }, nil)
691
692 // Publish event
693 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
694
695 // Synchronous cache invalidation
696 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
697
698 // AFTER new container is ready: stop and remove old runtime-discovered container
699 // This is the key assertion — StopContainer on the runtime-active container
700 // happens during finalizePreviousContainer, NOT during orphan cleanup
701 runtime.EXPECT().StopContainer(mock.Anything, "runtime-active-container").Return(nil)
702 runtime.EXPECT().RemoveContainer(mock.Anything, "runtime-active-container", true).Return(nil)
703
704 // Rename new container to canonical name
705 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
706
707 result, err := svc.Deploy(ctx, route)
708
709 assert.NoError(t, err)
710 svc.WaitForCleanup()
711 assert.Equal(t, "new-container", result.ID)
712
713 // Verify new container is now tracked
714 tracked, exists := svc.Get(ctx, "test.example.com")
715 assert.True(t, exists)
716 assert.Equal(t, "new-container", tracked.ID)
717}
718
719func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
720 runtime := mocks.NewMockContainerRuntime(t)
721 envLoader := mocks.NewMockEnvLoader(t)
722 eventBus := mocks.NewMockEventPublisher(t)
723
724 config := testMinDelayConfig()
725 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
726 ctx := testContext()
727 expectedEnvHash := hashEnvironment([]string{})
728
729 // Existing container has ImageID, but GetImageID will fail
730 existingContainer := &domain.Container{
731 ID: "existing-container",
732 Name: "gordon-test.example.com",
733 Image: "myapp:latest",
734 ImageID: "sha256:abc123",
735 Status: "running",
736 Labels: map[string]string{
737 domain.LabelEnvHash: expectedEnvHash,
738 },
739 }
740 svc.containers["test.example.com"] = existingContainer
741
742 route := domain.Route{
743 Domain: "test.example.com",
744 Image: "myapp:latest",
745 }
746
747 // prepareDeployResources
748 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
749 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
750 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
751 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
752 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
753 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
754
755 // GetImageID fails => deploy proceeds normally (graceful degradation)
756 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("", errors.New("image inspect failed"))
757
758 // Full deploy proceeds despite the error
759 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
760 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
761 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
762 // Wait for ready (2 calls) + stabilization check (1 call)
763 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
764 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
765 ID: "new-container", Status: "running",
766 }, nil)
767 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
768
769 // Old container is finalized
770 runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
771 runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
772 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
773
774 result, err := svc.Deploy(ctx, route)
775
776 assert.NoError(t, err)
777 svc.WaitForCleanup()
778 assert.Equal(t, "new-container", result.ID)
779}
780
781func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
782 runtime := mocks.NewMockContainerRuntime(t)
783 envLoader := mocks.NewMockEnvLoader(t)
784 eventBus := mocks.NewMockEventPublisher(t)
785
786 config := Config{
787 NetworkIsolation: true,
788 NetworkPrefix: "gordon",
789 ReadinessDelay: time.Millisecond, // Minimal delay for tests
790 DrainDelay: time.Millisecond,
791 DrainDelayConfigured: true,
792 }
793 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
794 ctx := testContext()
795
796 route := domain.Route{
797 Domain: "test.example.com",
798 Image: "myapp:latest",
799 }
800
801 // No container in memory — runtime resolution returns nothing
802 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
803
804 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
805 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
806 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
807 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
808 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
809 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
810
811 // Network isolation - should check and create network
812 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil)
813 runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", map[string]string{"driver": "bridge"}).Return(nil)
814
815 container := &domain.Container{ID: "container-123", Status: "created"}
816 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
817 return cfg.NetworkMode == "gordon-test-example-com"
818 })).Return(container, nil)
819 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
820
821 // Wait for ready
822 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
823
824 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
825 ID: "container-123",
826 Status: "running",
827 }, nil)
828
829 // Publish event
830 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
831
832 result, err := svc.Deploy(ctx, route)
833
834 assert.NoError(t, err)
835 assert.NotNil(t, result)
836}
837
838func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
839 runtime := mocks.NewMockContainerRuntime(t)
840 envLoader := mocks.NewMockEnvLoader(t)
841 eventBus := mocks.NewMockEventPublisher(t)
842
843 config := Config{
844 VolumeAutoCreate: true,
845 VolumePrefix: "gordon",
846 ReadinessDelay: time.Millisecond, // Minimal delay for tests
847 DrainDelay: time.Millisecond,
848 DrainDelayConfigured: true,
849 }
850 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
851 ctx := testContext()
852
853 route := domain.Route{
854 Domain: "test.example.com",
855 Image: "myapp:latest",
856 }
857
858 // No container in memory — runtime resolution returns nothing
859 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
860
861 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
862 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
863 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
864 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
865 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
866 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
867
868 // Volume operations
869 runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data", "/config"}, nil)
870 runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-data").Return(false, nil)
871 runtime.EXPECT().CreateVolume(mock.Anything, "gordon-test-example-com-data").Return(nil)
872 runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-config").Return(true, nil)
873
874 container := &domain.Container{ID: "container-123", Status: "created"}
875 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
876 return len(cfg.Volumes) == 2
877 })).Return(container, nil)
878 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
879
880 // Wait for ready
881 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
882
883 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
884 ID: "container-123",
885 Status: "running",
886 }, nil)
887
888 // Publish event
889 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
890
891 result, err := svc.Deploy(ctx, route)
892
893 assert.NoError(t, err)
894 assert.NotNil(t, result)
895}
896
897func TestService_Stop(t *testing.T) {
898 runtime := mocks.NewMockContainerRuntime(t)
899 envLoader := mocks.NewMockEnvLoader(t)
900 eventBus := mocks.NewMockEventPublisher(t)
901
902 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
903 ctx := testContext()
904
905 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(nil)
906
907 err := svc.Stop(ctx, "container-123")
908
909 assert.NoError(t, err)
910}
911
912func TestService_Stop_Error(t *testing.T) {
913 runtime := mocks.NewMockContainerRuntime(t)
914 envLoader := mocks.NewMockEnvLoader(t)
915 eventBus := mocks.NewMockEventPublisher(t)
916
917 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
918 ctx := testContext()
919
920 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(errors.New("container not found"))
921
922 err := svc.Stop(ctx, "container-123")
923
924 assert.Error(t, err)
925 assert.Contains(t, err.Error(), "failed to stop container")
926}
927
928func TestService_Remove(t *testing.T) {
929 runtime := mocks.NewMockContainerRuntime(t)
930 envLoader := mocks.NewMockEnvLoader(t)
931 eventBus := mocks.NewMockEventPublisher(t)
932
933 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
934 ctx := testContext()
935
936 // Add tracked container
937 svc.containers["test.example.com"] = &domain.Container{
938 ID: "container-123",
939 Name: "gordon-test.example.com",
940 }
941
942 runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)
943
944 err := svc.Remove(ctx, "container-123", true)
945
946 assert.NoError(t, err)
947
948 // Verify container is no longer tracked
949 _, exists := svc.Get(ctx, "test.example.com")
950 assert.False(t, exists)
951}
952
953func TestService_Remove_WithNetworkCleanup(t *testing.T) {
954 runtime := mocks.NewMockContainerRuntime(t)
955 envLoader := mocks.NewMockEnvLoader(t)
956 eventBus := mocks.NewMockEventPublisher(t)
957
958 config := Config{
959 NetworkIsolation: true,
960 NetworkPrefix: "gordon",
961 }
962 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
963 ctx := testContext()
964
965 svc.containers["test.example.com"] = &domain.Container{
966 ID: "container-123",
967 Name: "gordon-test.example.com",
968 }
969
970 runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)
971 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
972 {Name: "gordon-test-example-com", Containers: []string{}},
973 }, nil)
974 runtime.EXPECT().RemoveNetwork(mock.Anything, "gordon-test-example-com").Return(nil)
975
976 err := svc.Remove(ctx, "container-123", true)
977
978 assert.NoError(t, err)
979}
980
981func TestService_Get(t *testing.T) {
982 runtime := mocks.NewMockContainerRuntime(t)
983 envLoader := mocks.NewMockEnvLoader(t)
984 eventBus := mocks.NewMockEventPublisher(t)
985
986 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
987 ctx := testContext()
988
989 container := &domain.Container{ID: "container-123"}
990 svc.containers["test.example.com"] = container
991
992 result, exists := svc.Get(ctx, "test.example.com")
993
994 assert.True(t, exists)
995 assert.Equal(t, "container-123", result.ID)
996}
997
998func TestService_Get_NotFound(t *testing.T) {
999 runtime := mocks.NewMockContainerRuntime(t)
1000 envLoader := mocks.NewMockEnvLoader(t)
1001 eventBus := mocks.NewMockEventPublisher(t)
1002
1003 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1004 ctx := testContext()
1005
1006 result, exists := svc.Get(ctx, "nonexistent.example.com")
1007
1008 assert.False(t, exists)
1009 assert.Nil(t, result)
1010}
1011
1012func TestService_List(t *testing.T) {
1013 runtime := mocks.NewMockContainerRuntime(t)
1014 envLoader := mocks.NewMockEnvLoader(t)
1015 eventBus := mocks.NewMockEventPublisher(t)
1016
1017 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1018 ctx := testContext()
1019
1020 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
1021 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
1022
1023 result := svc.List(ctx)
1024
1025 assert.Len(t, result, 2)
1026 assert.Equal(t, "container-1", result["app1.example.com"].ID)
1027 assert.Equal(t, "container-2", result["app2.example.com"].ID)
1028}
1029
1030func TestService_HealthCheck(t *testing.T) {
1031 runtime := mocks.NewMockContainerRuntime(t)
1032 envLoader := mocks.NewMockEnvLoader(t)
1033 eventBus := mocks.NewMockEventPublisher(t)
1034
1035 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1036 ctx := testContext()
1037
1038 svc.containers["healthy.example.com"] = &domain.Container{ID: "healthy-container"}
1039 svc.containers["unhealthy.example.com"] = &domain.Container{ID: "unhealthy-container"}
1040
1041 runtime.EXPECT().IsContainerRunning(mock.Anything, "healthy-container").Return(true, nil)
1042 runtime.EXPECT().IsContainerRunning(mock.Anything, "unhealthy-container").Return(false, nil)
1043
1044 result := svc.HealthCheck(ctx)
1045
1046 assert.True(t, result["healthy.example.com"])
1047 assert.False(t, result["unhealthy.example.com"])
1048}
1049
1050func TestService_SyncContainers(t *testing.T) {
1051 runtime := mocks.NewMockContainerRuntime(t)
1052 envLoader := mocks.NewMockEnvLoader(t)
1053 eventBus := mocks.NewMockEventPublisher(t)
1054
1055 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1056 ctx := testContext()
1057
1058 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
1059 {
1060 ID: "container-1",
1061 Name: "gordon-app1.example.com",
1062 Labels: map[string]string{
1063 "gordon.domain": "app1.example.com",
1064 "gordon.managed": "true",
1065 },
1066 },
1067 {
1068 ID: "container-2",
1069 Name: "gordon-app2.example.com",
1070 Labels: map[string]string{
1071 "gordon.domain": "app2.example.com",
1072 "gordon.managed": "true",
1073 },
1074 },
1075 {
1076 ID: "unmanaged-container",
1077 Name: "some-other-app",
1078 Labels: map[string]string{},
1079 },
1080 }, nil)
1081
1082 err := svc.SyncContainers(ctx)
1083
1084 assert.NoError(t, err)
1085 assert.Len(t, svc.containers, 2)
1086 assert.Contains(t, svc.containers, "app1.example.com")
1087 assert.Contains(t, svc.containers, "app2.example.com")
1088}
1089
1090func TestService_Shutdown(t *testing.T) {
1091 runtime := mocks.NewMockContainerRuntime(t)
1092 envLoader := mocks.NewMockEnvLoader(t)
1093 eventBus := mocks.NewMockEventPublisher(t)
1094
1095 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1096 ctx := testContext()
1097
1098 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
1099 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
1100
1101 // Shutdown no longer stops containers — they are left running for
1102 // SyncContainers + AutoStart to pick back up on next boot.
1103 err := svc.Shutdown(ctx)
1104
1105 assert.NoError(t, err)
1106 // Containers remain tracked (not stopped).
1107 assert.Len(t, svc.containers, 2)
1108}
1109
1110func TestService_Restart_NotFound(t *testing.T) {
1111 runtime := mocks.NewMockContainerRuntime(t)
1112 envLoader := mocks.NewMockEnvLoader(t)
1113 eventBus := mocks.NewMockEventPublisher(t)
1114
1115 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1116 ctx := testContext()
1117
1118 err := svc.Restart(ctx, "nonexistent.example.com", false)
1119
1120 assert.Error(t, err)
1121 assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
1122}
1123
1124func TestService_Restart_Success(t *testing.T) {
1125 runtime := mocks.NewMockContainerRuntime(t)
1126 envLoader := mocks.NewMockEnvLoader(t)
1127 eventBus := mocks.NewMockEventPublisher(t)
1128
1129 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1130 ctx := testContext()
1131
1132 svc.containers["test.example.com"] = &domain.Container{
1133 ID: "container-123",
1134 Name: "gordon-test.example.com",
1135 Status: "running",
1136 }
1137
1138 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1139
1140 err := svc.Restart(ctx, "test.example.com", false)
1141
1142 assert.NoError(t, err)
1143}
1144
1145func TestService_Restart_Error(t *testing.T) {
1146 runtime := mocks.NewMockContainerRuntime(t)
1147 envLoader := mocks.NewMockEnvLoader(t)
1148 eventBus := mocks.NewMockEventPublisher(t)
1149
1150 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1151 ctx := testContext()
1152
1153 svc.containers["test.example.com"] = &domain.Container{
1154 ID: "container-123",
1155 Name: "gordon-test.example.com",
1156 Status: "running",
1157 }
1158
1159 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(errors.New("restart failed"))
1160
1161 err := svc.Restart(ctx, "test.example.com", false)
1162
1163 assert.Error(t, err)
1164 assert.Contains(t, err.Error(), "failed to restart container")
1165}
1166
1167func TestService_Restart_ReconcilesStaleContainerID(t *testing.T) {
1168 runtime := mocks.NewMockContainerRuntime(t)
1169 envLoader := mocks.NewMockEnvLoader(t)
1170 eventBus := mocks.NewMockEventPublisher(t)
1171
1172 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1173 ctx := testContext()
1174
1175 svc.containers["test.example.com"] = &domain.Container{
1176 ID: "stale-container-id",
1177 Name: "gordon-test.example.com",
1178 Status: "running",
1179 }
1180
1181 runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
1182 Return(errors.New("no container with name or ID \"stale-container-id\" found")).
1183 Once()
1184 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
1185 {
1186 ID: "fresh-container-id",
1187 Name: "gordon-test.example.com",
1188 Labels: map[string]string{
1189 "gordon.domain": "test.example.com",
1190 "gordon.managed": "true",
1191 },
1192 },
1193 }, nil).Once()
1194 runtime.EXPECT().RestartContainer(mock.Anything, "fresh-container-id").Return(nil).Once()
1195
1196 err := svc.Restart(ctx, "test.example.com", false)
1197
1198 assert.NoError(t, err)
1199}
1200
1201func TestService_Restart_ReconcilesStaleContainerID_NotFoundAfterSync(t *testing.T) {
1202 runtime := mocks.NewMockContainerRuntime(t)
1203 envLoader := mocks.NewMockEnvLoader(t)
1204 eventBus := mocks.NewMockEventPublisher(t)
1205
1206 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1207 ctx := testContext()
1208
1209 svc.containers["test.example.com"] = &domain.Container{
1210 ID: "stale-container-id",
1211 Name: "gordon-test.example.com",
1212 Status: "running",
1213 }
1214
1215 runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
1216 Return(errors.New("no such container")).
1217 Once()
1218 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
1219
1220 err := svc.Restart(ctx, "test.example.com", false)
1221
1222 assert.Error(t, err)
1223 assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
1224}
1225
1226func TestService_Restart_WithAttachments(t *testing.T) {
1227 runtime := mocks.NewMockContainerRuntime(t)
1228 envLoader := mocks.NewMockEnvLoader(t)
1229 eventBus := mocks.NewMockEventPublisher(t)
1230
1231 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1232 ctx := testContext()
1233
1234 svc.containers["test.example.com"] = &domain.Container{
1235 ID: "container-123",
1236 Name: "gordon-test.example.com",
1237 Status: "running",
1238 }
1239 svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"}
1240 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
1241 {
1242 ID: "container-123",
1243 Labels: map[string]string{
1244 domain.LabelDomain: "test.example.com",
1245 domain.LabelManaged: "true",
1246 },
1247 },
1248 {
1249 ID: "container-attach-1",
1250 Labels: map[string]string{
1251 domain.LabelManaged: "true",
1252 domain.LabelAttachment: "true",
1253 domain.LabelAttachedTo: "test.example.com",
1254 },
1255 },
1256 {
1257 ID: "container-attach-2",
1258 Labels: map[string]string{
1259 domain.LabelManaged: "true",
1260 domain.LabelAttachment: "true",
1261 domain.LabelAttachedTo: "test.example.com",
1262 },
1263 },
1264 }, nil).Maybe()
1265
1266 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1267 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
1268 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-2").Return(fmt.Errorf("boom"))
1269
1270 err := svc.Restart(ctx, "test.example.com", true)
1271
1272 assert.NoError(t, err)
1273}
1274
1275func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *testing.T) {
1276 runtime := mocks.NewMockContainerRuntime(t)
1277 envLoader := mocks.NewMockEnvLoader(t)
1278 eventBus := mocks.NewMockEventPublisher(t)
1279
1280 provider := mocks.NewMockAttachmentConfigProvider(t)
1281 provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1282 Attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1283 NetworkGroups: map[string][]string{},
1284 }).Maybe()
1285
1286 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1287
1288 svc.containers["test.example.com"] = &domain.Container{
1289 ID: "container-123",
1290 Name: "gordon-test.example.com",
1291 Status: "running",
1292 }
1293 // No attachment containers tracked — s.attachments is empty
1294 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()
1295
1296 err := svc.Restart(testContext(), "test.example.com", true)
1297
1298 assert.Error(t, err)
1299 assert.True(t, errors.Is(err, domain.ErrAttachmentNotDeployed))
1300 // Main container should NOT have been restarted
1301 runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, mock.Anything)
1302}
1303
1304func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing.T) {
1305 runtime := mocks.NewMockContainerRuntime(t)
1306 envLoader := mocks.NewMockEnvLoader(t)
1307 eventBus := mocks.NewMockEventPublisher(t)
1308
1309 provider := mocks.NewMockAttachmentConfigProvider(t)
1310 provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1311 Attachments: map[string][]string{"test.example.com": {"postgres:16", "redis:7"}},
1312 NetworkGroups: map[string][]string{},
1313 }).Maybe()
1314
1315 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1316
1317 svc.containers["test.example.com"] = &domain.Container{
1318 ID: "container-123",
1319 Name: "gordon-test.example.com",
1320 Status: "running",
1321 }
1322 // Only 1 of 2 attachments deployed
1323 svc.attachments["test.example.com"] = []string{"attach-postgres"}
1324 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
1325 {
1326 ID: "container-123",
1327 Labels: map[string]string{
1328 domain.LabelDomain: "test.example.com",
1329 domain.LabelManaged: "true",
1330 },
1331 },
1332 {
1333 ID: "attach-postgres",
1334 Labels: map[string]string{
1335 domain.LabelManaged: "true",
1336 domain.LabelAttachment: "true",
1337 domain.LabelAttachedTo: "test.example.com",
1338 },
1339 },
1340 }, nil).Maybe()
1341
1342 err := svc.Restart(testContext(), "test.example.com", true)
1343
1344 assert.Error(t, err)
1345 assert.True(t, errors.Is(err, domain.ErrAttachmentNotDeployed))
1346 assert.Contains(t, err.Error(), "1 missing")
1347 // Main container should NOT have been restarted
1348 runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, mock.Anything)
1349}
1350
1351func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) {
1352 runtime := mocks.NewMockContainerRuntime(t)
1353 envLoader := mocks.NewMockEnvLoader(t)
1354 eventBus := mocks.NewMockEventPublisher(t)
1355
1356 provider := mocks.NewMockAttachmentConfigProvider(t)
1357 provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1358 Attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1359 NetworkGroups: map[string][]string{},
1360 }).Maybe()
1361
1362 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1363
1364 svc.containers["test.example.com"] = &domain.Container{
1365 ID: "container-123",
1366 Name: "gordon-test.example.com",
1367 Status: "running",
1368 }
1369 svc.attachments["test.example.com"] = []string{"attach-456"}
1370 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
1371 {
1372 ID: "container-123",
1373 Labels: map[string]string{
1374 domain.LabelDomain: "test.example.com",
1375 domain.LabelManaged: "true",
1376 },
1377 },
1378 {
1379 ID: "attach-456",
1380 Labels: map[string]string{
1381 domain.LabelManaged: "true",
1382 domain.LabelAttachment: "true",
1383 domain.LabelAttachedTo: "test.example.com",
1384 },
1385 },
1386 }, nil).Maybe()
1387
1388 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1389 runtime.EXPECT().RestartContainer(mock.Anything, "attach-456").Return(nil)
1390
1391 err := svc.Restart(testContext(), "test.example.com", true)
1392 assert.NoError(t, err)
1393}
1394
1395func TestService_Restart_WithoutAttachmentFlag_IgnoresMissingAttachments(t *testing.T) {
1396 runtime := mocks.NewMockContainerRuntime(t)
1397 envLoader := mocks.NewMockEnvLoader(t)
1398 eventBus := mocks.NewMockEventPublisher(t)
1399
1400 provider := mocks.NewMockAttachmentConfigProvider(t)
1401 provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1402 Attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1403 NetworkGroups: map[string][]string{},
1404 }).Maybe()
1405
1406 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1407
1408 svc.containers["test.example.com"] = &domain.Container{
1409 ID: "container-123",
1410 Name: "gordon-test.example.com",
1411 Status: "running",
1412 }
1413
1414 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1415
1416 err := svc.Restart(testContext(), "test.example.com", false)
1417 assert.NoError(t, err)
1418}
1419
1420func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *testing.T) {
1421 runtime := mocks.NewMockContainerRuntime(t)
1422 envLoader := mocks.NewMockEnvLoader(t)
1423 eventBus := mocks.NewMockEventPublisher(t)
1424
1425 provider := mocks.NewMockAttachmentConfigProvider(t)
1426 provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1427 Attachments: map[string][]string{},
1428 NetworkGroups: map[string][]string{},
1429 }).Maybe()
1430
1431 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1432
1433 svc.containers["test.example.com"] = &domain.Container{
1434 ID: "container-123",
1435 Name: "gordon-test.example.com",
1436 Status: "running",
1437 }
1438 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()
1439
1440 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1441
1442 err := svc.Restart(testContext(), "test.example.com", true)
1443 assert.NoError(t, err)
1444}
1445
1446func TestNormalizeImageRef(t *testing.T) {
1447 tests := []struct {
1448 name string
1449 input string
1450 expected string
1451 }{
1452 {
1453 name: "simple image",
1454 input: "nginx",
1455 expected: "docker.io/library/nginx",
1456 },
1457 {
1458 name: "user/repo",
1459 input: "user/myapp",
1460 expected: "docker.io/user/myapp",
1461 },
1462 {
1463 name: "registry/repo",
1464 input: "gcr.io/project/image",
1465 expected: "gcr.io/project/image",
1466 },
1467 {
1468 name: "with tag",
1469 input: "nginx:latest",
1470 expected: "docker.io/library/nginx",
1471 },
1472 {
1473 name: "localhost with port",
1474 input: "localhost:5000/myapp:latest",
1475 expected: "localhost:5000/myapp",
1476 },
1477 {
1478 name: "localhost with port no tag",
1479 input: "localhost:5000/myapp",
1480 expected: "localhost:5000/myapp",
1481 },
1482 {
1483 name: "registry domain with port",
1484 input: "registry.example.com:5000/image:v1.0",
1485 expected: "registry.example.com:5000/image",
1486 },
1487 }
1488
1489 for _, tt := range tests {
1490 t.Run(tt.name, func(t *testing.T) {
1491 result := normalizeImageRef(tt.input)
1492 assert.Equal(t, tt.expected, result)
1493 })
1494 }
1495}
1496
1497func TestGenerateVolumeName(t *testing.T) {
1498 tests := []struct {
1499 name string
1500 prefix string
1501 domain string
1502 volumePath string
1503 expected string
1504 }{
1505 {
1506 name: "simple path",
1507 prefix: "gordon",
1508 domain: "app.example.com",
1509 volumePath: "/data",
1510 expected: "gordon-app-example-com-data",
1511 },
1512 {
1513 name: "nested path",
1514 prefix: "gordon",
1515 domain: "app.example.com",
1516 volumePath: "/var/lib/data",
1517 expected: "gordon-app-example-com-var-lib-data",
1518 },
1519 }
1520
1521 for _, tt := range tests {
1522 t.Run(tt.name, func(t *testing.T) {
1523 result := generateVolumeName(tt.prefix, tt.domain, tt.volumePath)
1524 assert.Equal(t, tt.expected, result)
1525 })
1526 }
1527}
1528
1529func TestMergeEnvironmentVariables(t *testing.T) {
1530 dockerEnv := []string{"FOO=docker", "BAR=docker"}
1531 userEnv := []string{"FOO=user", "BAZ=user"}
1532
1533 result := mergeEnvironmentVariables(dockerEnv, userEnv)
1534
1535 // User env should override docker env
1536 assert.Contains(t, result, "FOO=user")
1537 assert.Contains(t, result, "BAR=docker")
1538 assert.Contains(t, result, "BAZ=user")
1539}
1540
1541func TestBuildImageRef(t *testing.T) {
1542 tests := []struct {
1543 name string
1544 image string
1545 registryAuthEnabled bool
1546 registryDomain string
1547 wantRef string
1548 }{
1549 {
1550 name: "adds registry domain prefix",
1551 image: "myapp:latest",
1552 registryAuthEnabled: true,
1553 registryDomain: "registry.example.com",
1554 wantRef: "registry.example.com/myapp:latest",
1555 },
1556 {
1557 name: "keeps existing registry domain prefix",
1558 image: "registry.example.com/myapp:latest",
1559 registryAuthEnabled: true,
1560 registryDomain: "registry.example.com",
1561 wantRef: "registry.example.com/myapp:latest",
1562 },
1563 {
1564 name: "skips external registry images",
1565 image: "docker.io/library/nginx:latest",
1566 registryAuthEnabled: true,
1567 registryDomain: "registry.example.com",
1568 wantRef: "docker.io/library/nginx:latest",
1569 },
1570 {
1571 name: "returns original when auth disabled",
1572 image: "myapp:latest",
1573 registryAuthEnabled: false,
1574 registryDomain: "registry.example.com",
1575 wantRef: "myapp:latest",
1576 },
1577 {
1578 name: "localhost domain adds prefix",
1579 image: "myapp:latest",
1580 registryAuthEnabled: true,
1581 registryDomain: "localhost:5000",
1582 wantRef: "localhost:5000/myapp:latest",
1583 },
1584 {
1585 name: "localhost domain keeps existing prefix",
1586 image: "localhost:5000/myapp:latest",
1587 registryAuthEnabled: true,
1588 registryDomain: "localhost:5000",
1589 wantRef: "localhost:5000/myapp:latest",
1590 },
1591 {
1592 name: "explicit host:port different than RegistryDomain",
1593 image: "localhost:5001/myapp:latest",
1594 registryAuthEnabled: true,
1595 registryDomain: "localhost:5000",
1596 wantRef: "localhost:5001/myapp:latest",
1597 },
1598 {
1599 name: "ghcr.io external registry",
1600 image: "ghcr.io/owner/repo:latest",
1601 registryAuthEnabled: true,
1602 registryDomain: "localhost:5000",
1603 wantRef: "ghcr.io/owner/repo:latest",
1604 },
1605 {
1606 name: "gcr.io external registry",
1607 image: "gcr.io/project/image:v1",
1608 registryAuthEnabled: true,
1609 registryDomain: "registry.example.com",
1610 wantRef: "gcr.io/project/image:v1",
1611 },
1612 {
1613 name: "quay.io external registry",
1614 image: "quay.io/org/app:tag",
1615 registryAuthEnabled: true,
1616 registryDomain: "localhost:5000",
1617 wantRef: "quay.io/org/app:tag",
1618 },
1619 {
1620 name: "localhost without port keeps existing prefix",
1621 image: "localhost/myapp:latest",
1622 registryAuthEnabled: true,
1623 registryDomain: "localhost:5000",
1624 wantRef: "localhost/myapp:latest",
1625 },
1626 {
1627 name: "registry domain with trailing slash",
1628 image: "myapp:latest",
1629 registryAuthEnabled: true,
1630 registryDomain: "registry.example.com/",
1631 wantRef: "registry.example.com/myapp:latest",
1632 },
1633 {
1634 name: "ipv6 registry without port",
1635 image: "[fd00::1]/myapp:latest",
1636 registryAuthEnabled: true,
1637 registryDomain: "localhost:5000",
1638 wantRef: "[fd00::1]/myapp:latest",
1639 },
1640 {
1641 name: "ipv6 registry with port",
1642 image: "[fd00::1]:5000/myapp:latest",
1643 registryAuthEnabled: true,
1644 registryDomain: "localhost:5000",
1645 wantRef: "[fd00::1]:5000/myapp:latest",
1646 },
1647 }
1648
1649 for _, tt := range tests {
1650 t.Run(tt.name, func(t *testing.T) {
1651 svc := &Service{
1652 config: Config{
1653 RegistryAuthEnabled: tt.registryAuthEnabled,
1654 RegistryDomain: tt.registryDomain,
1655 },
1656 }
1657 gotRef := svc.buildImageRef(tt.image)
1658 assert.Equal(t, tt.wantRef, gotRef, "unexpected image reference")
1659 })
1660 }
1661}
1662
1663func TestRewriteToRegistryDomain(t *testing.T) {
1664 tests := []struct {
1665 name string
1666 imageRef string
1667 registryDomain string
1668 wantRef string
1669 }{
1670 {
1671 name: "keeps registry domain prefix",
1672 imageRef: "registry.example.com/myapp:latest",
1673 registryDomain: "registry.example.com",
1674 wantRef: "registry.example.com/myapp:latest",
1675 },
1676 {
1677 name: "prefixes registry domain",
1678 imageRef: "myapp:v1.0",
1679 registryDomain: "registry.example.com",
1680 wantRef: "registry.example.com/myapp:v1.0",
1681 },
1682 {
1683 name: "prefixes external image",
1684 imageRef: "docker.io/library/nginx:latest",
1685 registryDomain: "registry.example.com",
1686 wantRef: "registry.example.com/docker.io/library/nginx:latest",
1687 },
1688 {
1689 name: "empty registry domain returns original",
1690 imageRef: "registry.example.com/myapp:latest",
1691 registryDomain: "",
1692 wantRef: "registry.example.com/myapp:latest",
1693 },
1694 }
1695
1696 for _, tt := range tests {
1697 t.Run(tt.name, func(t *testing.T) {
1698 gotRef := rewriteToRegistryDomain(tt.imageRef, tt.registryDomain)
1699 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
1700 })
1701 }
1702}
1703
1704func TestRewriteToLocalRegistry(t *testing.T) {
1705 tests := []struct {
1706 name string
1707 imageRef string
1708 registryDomain string
1709 registryPort int
1710 wantRef string
1711 }{
1712 {
1713 name: "rewrites registry domain prefix",
1714 imageRef: "registry.example.com/myapp:latest",
1715 registryDomain: "registry.example.com",
1716 registryPort: 5000,
1717 wantRef: "localhost:5000/myapp:latest",
1718 },
1719 {
1720 name: "prefixes local registry when no domain",
1721 imageRef: "myapp:v1.0",
1722 registryDomain: "registry.example.com",
1723 registryPort: 5000,
1724 wantRef: "localhost:5000/myapp:v1.0",
1725 },
1726 {
1727 name: "keeps existing localhost prefix",
1728 imageRef: "localhost:5000/myapp:latest",
1729 registryDomain: "registry.example.com",
1730 registryPort: 5000,
1731 wantRef: "localhost:5000/myapp:latest",
1732 },
1733 {
1734 name: "prefixes external image path",
1735 imageRef: "docker.io/library/nginx:latest",
1736 registryDomain: "registry.example.com",
1737 registryPort: 5000,
1738 wantRef: "localhost:5000/docker.io/library/nginx:latest",
1739 },
1740 }
1741
1742 for _, tt := range tests {
1743 t.Run(tt.name, func(t *testing.T) {
1744 gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)
1745 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
1746 })
1747 }
1748}
1749
1750func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1751 runtime := mocks.NewMockContainerRuntime(t)
1752 envLoader := mocks.NewMockEnvLoader(t)
1753 eventBus := mocks.NewMockEventPublisher(t)
1754
1755 config := Config{
1756 RegistryAuthEnabled: true,
1757 RegistryDomain: "registry.example.com",
1758 RegistryPort: 5000,
1759 InternalRegistryUsername: "internal",
1760 InternalRegistryPassword: "secret",
1761 ReadinessDelay: time.Millisecond,
1762 DrainDelay: time.Millisecond,
1763 DrainDelayConfigured: true,
1764 }
1765
1766 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1767
1768 // Use internal deploy context (simulating image push event)
1769 ctx := domain.WithInternalDeploy(testContext())
1770
1771 route := domain.Route{
1772 Domain: "test.example.com",
1773 Image: "myapp:latest",
1774 }
1775
1776 // No container in memory — runtime resolution returns nothing
1777 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
1778
1779 // Setup mocks - no orphaned containers
1780 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1781
1782 // Even though image exists locally, internal deploy should force pull
1783 // Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check
1784 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
1785
1786 // Tag image to canonical name
1787 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil)
1788 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1789
1790 // Get exposed ports
1791 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
1792 runtime.EXPECT().GetImageLabels(mock.Anything, "registry.example.com/myapp:latest").Return(nil, nil)
1793
1794 // Load environment
1795 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1796 runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil)
1797
1798 // Create and start container
1799 createdContainer := &domain.Container{
1800 ID: "container-123",
1801 Name: "gordon-test.example.com",
1802 Status: "created",
1803 }
1804 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
1805 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
1806
1807 // Wait for ready
1808 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
1809
1810 // Re-inspect after start
1811 runningContainer := &domain.Container{
1812 ID: "container-123",
1813 Name: "gordon-test.example.com",
1814 Status: "running",
1815 Ports: []int{8080},
1816 }
1817 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
1818
1819 // Publish event
1820 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1821
1822 result, err := svc.Deploy(ctx, route)
1823
1824 assert.NoError(t, err)
1825 assert.NotNil(t, result)
1826 assert.Equal(t, "container-123", result.ID)
1827}
1828
1829func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1830 runtime := mocks.NewMockContainerRuntime(t)
1831 envLoader := mocks.NewMockEnvLoader(t)
1832 eventBus := mocks.NewMockEventPublisher(t)
1833
1834 config := Config{
1835 ReadinessDelay: time.Millisecond,
1836 DrainDelay: time.Millisecond,
1837 DrainDelayConfigured: true,
1838 }
1839 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1840 ctx := testContext()
1841
1842 routes := []domain.Route{
1843 {Domain: "app1.example.com", Image: "myapp1:latest"},
1844 {Domain: "app2.example.com", Image: "myapp2:latest"},
1845 }
1846
1847 // No container in memory — runtime resolution returns nothing (one per route)
1848 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Times(2)
1849
1850 // Setup mocks for route deployments
1851 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1852 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
1853 runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
1854 runtime.EXPECT().GetImageLabels(mock.Anything, mock.AnythingOfType("string")).Return(nil, nil).Times(2)
1855 envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1856 runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1857
1858 // Create and start containers — readiness is skipped so no IsContainerRunning calls.
1859 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1860 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1861 }, nil).Once()
1862 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1863 ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
1864 }, nil).Once()
1865 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
1866 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
1867 Status: "running",
1868 }, nil).Times(2)
1869 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2)
1870
1871 err := svc.AutoStart(ctx, routes)
1872
1873 assert.NoError(t, err)
1874 assert.Len(t, svc.containers, 2)
1875}
1876
1877func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1878 runtime := mocks.NewMockContainerRuntime(t)
1879 envLoader := mocks.NewMockEnvLoader(t)
1880 eventBus := mocks.NewMockEventPublisher(t)
1881
1882 config := Config{
1883 ReadinessDelay: time.Millisecond,
1884 DrainDelay: time.Millisecond,
1885 DrainDelayConfigured: true,
1886 }
1887 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1888 ctx := testContext()
1889
1890 // Pre-populate with existing container
1891 svc.containers["app1.example.com"] = &domain.Container{
1892 ID: "existing-container",
1893 }
1894
1895 routes := []domain.Route{
1896 {Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists
1897 {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
1898 }
1899
1900 // No container in memory for app2 — runtime resolution returns nothing
1901 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
1902
1903 // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning.
1904 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
1905 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
1906 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
1907 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp2:latest").Return(nil, nil).Once()
1908 envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
1909 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
1910 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1911 ID: "container-2", Status: "created",
1912 }, nil).Once()
1913 runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
1914 runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
1915 Status: "running",
1916 }, nil).Once()
1917 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
1918
1919 err := svc.AutoStart(ctx, routes)
1920
1921 assert.NoError(t, err)
1922 assert.Len(t, svc.containers, 2)
1923}
1924
1925func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
1926 runtime := mocks.NewMockContainerRuntime(t)
1927 envLoader := mocks.NewMockEnvLoader(t)
1928 eventBus := mocks.NewMockEventPublisher(t)
1929
1930 config := Config{}
1931 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1932 ctx := testContext()
1933
1934 routes := []domain.Route{
1935 {Domain: "app1.example.com", Image: "myapp1:latest"},
1936 }
1937
1938 // No container in memory — runtime resolution returns nothing
1939 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
1940
1941 // Setup mocks for failure
1942 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1943 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1944 runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found"))
1945
1946 err := svc.AutoStart(ctx, routes)
1947
1948 // AutoStart should return error when some deployments fail
1949 assert.Error(t, err)
1950 assert.Contains(t, err.Error(), "auto-start completed with 1 errors")
1951}
1952
1953func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1954 runtime := mocks.NewMockContainerRuntime(t)
1955 envLoader := mocks.NewMockEnvLoader(t)
1956 eventBus := mocks.NewMockEventPublisher(t)
1957
1958 config := Config{
1959 RegistryDomain: "reg.example.com",
1960 RegistryPort: 5000,
1961 ReadinessDelay: time.Millisecond,
1962 DrainDelay: time.Millisecond,
1963 DrainDelayConfigured: true,
1964 }
1965 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1966
1967 // Mark context as internal deploy — this is what syncAndAutoStart should do
1968 ctx := domain.WithInternalDeploy(testContext())
1969
1970 routes := []domain.Route{
1971 {Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"},
1972 }
1973
1974 // No container in memory — runtime resolution returns nothing
1975 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
1976
1977 // Key assertion: PullImage should be called with localhost:5000 rewrite,
1978 // NOT the original reg.example.com/myapp:latest.
1979 // Readiness is skipped — no IsContainerRunning calls.
1980 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1981 runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1982 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)
1983 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1984 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "reg.example.com/myapp:latest").Return([]int{8080}, nil)
1985 runtime.EXPECT().GetImageLabels(mock.Anything, "reg.example.com/myapp:latest").Return(nil, nil)
1986 envLoader.EXPECT().LoadEnv(mock.Anything, "app1.example.com").Return([]string{}, nil)
1987 runtime.EXPECT().InspectImageEnv(mock.Anything, "reg.example.com/myapp:latest").Return([]string{}, nil)
1988 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1989 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1990 }, nil)
1991 runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil)
1992 runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{
1993 Status: "running",
1994 }, nil)
1995 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1996
1997 err := svc.AutoStart(ctx, routes)
1998 assert.NoError(t, err)
1999}
2000
2001// TestService_Deploy_OrphanCleanupSkipsTrackedContainer verifies that the orphan cleanup
2002// does not remove the currently tracked container during zero-downtime deployment.
2003// This is critical for preventing downtime - the old container must stay running
2004// until the new container is ready and traffic is switched.
2005func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
2006 runtime := mocks.NewMockContainerRuntime(t)
2007 envLoader := mocks.NewMockEnvLoader(t)
2008 eventBus := mocks.NewMockEventPublisher(t)
2009 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2010
2011 config := Config{
2012 ReadinessDelay: time.Millisecond, // Minimal delay for tests
2013 DrainDelay: time.Millisecond,
2014 DrainDelayConfigured: true,
2015 StabilizationDelay: time.Millisecond,
2016 }
2017 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2018 svc.SetProxyCacheInvalidator(cacheInvalidator)
2019 ctx := testContext()
2020
2021 // Pre-populate with existing tracked container
2022 existingContainer := &domain.Container{
2023 ID: "tracked-container-123",
2024 Name: "gordon-test.example.com",
2025 Status: "running",
2026 }
2027 svc.containers["test.example.com"] = existingContainer
2028
2029 route := domain.Route{
2030 Domain: "test.example.com",
2031 Image: "myapp:v2",
2032 }
2033
2034 // ListContainers returns the tracked container - this simulates the orphan check
2035 // finding a container with the same name. The bug was that it would remove this
2036 // container BEFORE the new one was ready, causing downtime.
2037 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2038 {
2039 ID: "tracked-container-123",
2040 Name: "gordon-test.example.com",
2041 Status: "running",
2042 },
2043 }, nil)
2044
2045 // Image operations
2046 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2047 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2048 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2049 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2050
2051 // Environment
2052 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2053 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2054
2055 // Create new container with -new suffix for zero-downtime
2056 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2057 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2058 return cfg.Name == "gordon-test.example.com-new"
2059 })).Return(newContainer, nil)
2060 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2061
2062 // Wait for ready (2 calls) + stabilization check (1 call)
2063 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2064
2065 // Inspect after ready
2066 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2067 ID: "new-container",
2068 Status: "running",
2069 }, nil)
2070
2071 // Publish event
2072 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2073
2074 // Synchronous cache invalidation
2075 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2076
2077 // NOW (after new container is ready) the old container should be stopped and removed
2078 // This is the correct zero-downtime sequence - not during orphan cleanup
2079 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
2080 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
2081
2082 // Rename new container to canonical name
2083 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2084
2085 result, err := svc.Deploy(ctx, route)
2086
2087 assert.NoError(t, err)
2088 svc.WaitForCleanup()
2089 assert.Equal(t, "new-container", result.ID)
2090
2091 // Verify new container is now tracked
2092 tracked, exists := svc.Get(ctx, "test.example.com")
2093 assert.True(t, exists)
2094 assert.Equal(t, "new-container", tracked.ID)
2095}
2096
2097// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that stopped canonical
2098// containers with the same name but NOT tracked are properly removed as orphans.
2099// Running canonical containers are preserved (see NeverKillsRunningCanonical test).
2100func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
2101 runtime := mocks.NewMockContainerRuntime(t)
2102 envLoader := mocks.NewMockEnvLoader(t)
2103 eventBus := mocks.NewMockEventPublisher(t)
2104
2105 config := Config{
2106 ReadinessDelay: time.Millisecond, // Minimal delay for tests
2107 DrainDelay: time.Millisecond,
2108 DrainDelayConfigured: true,
2109 }
2110 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2111 ctx := testContext()
2112
2113 // NO tracked container - service is empty for this domain
2114
2115 route := domain.Route{
2116 Domain: "test.example.com",
2117 Image: "myapp:v1",
2118 }
2119
2120 // No container in memory — runtime resolution finds no managed container
2121 // (the orphan is stopped so it won't appear in running-only query)
2122 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2123
2124 // ListContainers(true) returns a STOPPED orphaned container (same name, but not tracked)
2125 // Stopped canonical containers are safe to remove during orphan cleanup
2126 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2127 {
2128 ID: "orphan-container",
2129 Name: "gordon-test.example.com",
2130 Status: "exited",
2131 },
2132 }, nil)
2133
2134 // Stopped orphan should be stopped and removed BEFORE we proceed
2135 runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
2136 runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
2137
2138 // Image operations
2139 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2140 runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
2141 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
2142 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v1").Return(nil, nil)
2143
2144 // Environment
2145 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2146 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v1").Return([]string{}, nil)
2147
2148 // Create container (no -new suffix since no existing tracked container)
2149 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
2150 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2151 return cfg.Name == "gordon-test.example.com"
2152 })).Return(newContainer, nil)
2153 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2154
2155 // Wait for ready
2156 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2157
2158 // Inspect after ready
2159 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2160 ID: "new-container",
2161 Status: "running",
2162 Ports: []int{8080},
2163 }, nil)
2164
2165 // Publish event
2166 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2167
2168 result, err := svc.Deploy(ctx, route)
2169
2170 assert.NoError(t, err)
2171 assert.Equal(t, "new-container", result.ID)
2172
2173 // Verify new container is tracked
2174 tracked, exists := svc.Get(ctx, "test.example.com")
2175 assert.True(t, exists)
2176 assert.Equal(t, "new-container", tracked.ID)
2177}
2178
2179func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
2180 runtime := mocks.NewMockContainerRuntime(t)
2181 envLoader := mocks.NewMockEnvLoader(t)
2182 eventBus := mocks.NewMockEventPublisher(t)
2183 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2184
2185 config := testMinDelayConfig()
2186 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2187 svc.SetProxyCacheInvalidator(cacheInvalidator)
2188 ctx := testContext()
2189
2190 // Tracked canonical container exists, but stale -new container should be removed.
2191 svc.containers["test.example.com"] = &domain.Container{
2192 ID: "tracked-container-123",
2193 Name: "gordon-test.example.com",
2194 Status: "running",
2195 }
2196
2197 route := domain.Route{
2198 Domain: "test.example.com",
2199 Image: "myapp:v2",
2200 }
2201
2202 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2203 {
2204 ID: "tracked-container-123",
2205 Name: "gordon-test.example.com",
2206 Status: "running",
2207 },
2208 {
2209 ID: "stale-new-container",
2210 Name: "gordon-test.example.com-new",
2211 Status: "exited",
2212 },
2213 }, nil)
2214 runtime.EXPECT().StopContainer(mock.Anything, "stale-new-container").Return(nil).Once()
2215 runtime.EXPECT().RemoveContainer(mock.Anything, "stale-new-container", true).Return(nil).Once()
2216
2217 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2218 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2219 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2220 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2221 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2222 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2223
2224 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2225 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2226 return cfg.Name == "gordon-test.example.com-new"
2227 })).Return(newContainer, nil)
2228 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2229 // Wait for ready (2 calls) + stabilization check (1 call)
2230 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2231 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2232 ID: "new-container",
2233 Status: "running",
2234 }, nil)
2235 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2236
2237 // Synchronous cache invalidation
2238 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2239
2240 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
2241 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
2242 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2243
2244 result, err := svc.Deploy(ctx, route)
2245 assert.NoError(t, err)
2246 svc.WaitForCleanup()
2247 assert.NotNil(t, result)
2248 assert.Equal(t, "new-container", result.ID)
2249}
2250
2251func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) {
2252 runtime := mocks.NewMockContainerRuntime(t)
2253 envLoader := mocks.NewMockEnvLoader(t)
2254 eventBus := mocks.NewMockEventPublisher(t)
2255 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2256
2257 config := testMinDelayConfig()
2258 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2259 svc.SetProxyCacheInvalidator(cacheInvalidator)
2260 ctx := testContext()
2261
2262 // Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name.
2263 svc.containers["test.example.com"] = &domain.Container{
2264 ID: "tracked-temp-container",
2265 Name: "gordon-test.example.com-new",
2266 Status: "running",
2267 }
2268
2269 route := domain.Route{
2270 Domain: "test.example.com",
2271 Image: "myapp:v2",
2272 }
2273
2274 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2275 {
2276 ID: "tracked-temp-container",
2277 Name: "gordon-test.example.com-new",
2278 Status: "running",
2279 },
2280 }, nil)
2281
2282 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2283 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2284 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2285 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2286 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2287 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2288
2289 created := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-next", Status: "created"}
2290 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2291 return cfg.Name == "gordon-test.example.com-next"
2292 })).Return(created, nil)
2293 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2294 // Wait for ready (2 calls) + stabilization check (1 call)
2295 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2296 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2297 ID: "new-container",
2298 Status: "running",
2299 }, nil)
2300 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2301
2302 // Synchronous cache invalidation
2303 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2304
2305 runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil)
2306 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil)
2307 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2308
2309 result, err := svc.Deploy(ctx, route)
2310 assert.NoError(t, err)
2311 svc.WaitForCleanup()
2312 assert.NotNil(t, result)
2313 assert.Equal(t, "new-container", result.ID)
2314}
2315
2316func TestService_StripRegistryPrefix(t *testing.T) {
2317 tests := []struct {
2318 name string
2319 registryDomain string
2320 image string
2321 expected string
2322 }{
2323 {
2324 name: "strips registry prefix",
2325 registryDomain: "reg.example.com",
2326 image: "reg.example.com/myapp:latest",
2327 expected: "myapp:latest",
2328 },
2329 {
2330 name: "strips registry prefix with trailing slash in domain",
2331 registryDomain: "reg.example.com/",
2332 image: "reg.example.com/myapp:v1.0",
2333 expected: "myapp:v1.0",
2334 },
2335 {
2336 name: "preserves image without registry prefix",
2337 registryDomain: "reg.example.com",
2338 image: "nginx:latest",
2339 expected: "nginx:latest",
2340 },
2341 {
2342 name: "preserves image with different registry",
2343 registryDomain: "reg.example.com",
2344 image: "gcr.io/project/image:latest",
2345 expected: "gcr.io/project/image:latest",
2346 },
2347 {
2348 name: "handles empty registry domain",
2349 registryDomain: "",
2350 image: "reg.example.com/myapp:latest",
2351 expected: "reg.example.com/myapp:latest",
2352 },
2353 {
2354 name: "handles nested paths",
2355 registryDomain: "reg.example.com",
2356 image: "reg.example.com/org/repo/app:latest",
2357 expected: "org/repo/app:latest",
2358 },
2359 }
2360
2361 for _, tt := range tests {
2362 t.Run(tt.name, func(t *testing.T) {
2363 runtime := mocks.NewMockContainerRuntime(t)
2364 envLoader := mocks.NewMockEnvLoader(t)
2365 eventBus := mocks.NewMockEventPublisher(t)
2366
2367 config := Config{
2368 RegistryDomain: tt.registryDomain,
2369 }
2370 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2371
2372 result := svc.stripRegistryPrefix(tt.image)
2373 assert.Equal(t, tt.expected, result)
2374 })
2375 }
2376}
2377
2378func TestSanitizeName(t *testing.T) {
2379 tests := []struct {
2380 domain string
2381 expected string
2382 description string
2383 }{
2384 {
2385 domain: "git.example.com",
2386 expected: "git__example__com",
2387 description: "Dots become double underscores",
2388 },
2389 {
2390 domain: "git-example.com",
2391 expected: "git-example__com",
2392 description: "Hyphens preserved, dots become underscores",
2393 },
2394 {
2395 domain: "app:8080.example.com",
2396 expected: "app-_8080__example__com",
2397 description: "Colons become hyphen-underscore",
2398 },
2399 {
2400 domain: "git.example.com:3000",
2401 expected: "git__example__com-_3000",
2402 description: "Multiple separators handled distinctly",
2403 },
2404 {
2405 domain: "simple.com",
2406 expected: "simple__com",
2407 description: "Simple domain",
2408 },
2409 }
2410
2411 for _, tt := range tests {
2412 t.Run(tt.domain, func(t *testing.T) {
2413 result := sanitizeName(tt.domain)
2414 assert.Equal(t, tt.expected, result, "sanitization should match expected")
2415 })
2416 }
2417
2418 // Verify no collisions between potentially conflicting domains
2419 t.Run("NoCollisions", func(t *testing.T) {
2420 domains := []string{
2421 "git.example.com",
2422 "git-example.com",
2423 "app:8080.example.com",
2424 "app-8080-example.com",
2425 }
2426
2427 results := make(map[string]string)
2428 for _, d := range domains {
2429 result := sanitizeName(d)
2430 if original, exists := results[result]; exists {
2431 t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
2432 }
2433 results[result] = d
2434 }
2435 })
2436}
2437
2438func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
2439 // Verify that concurrent Deploy calls for the same domain are serialized
2440 // and both succeed without container name conflicts.
2441 runtime := mocks.NewMockContainerRuntime(t)
2442 envLoader := mocks.NewMockEnvLoader(t)
2443 eventBus := mocks.NewMockEventPublisher(t)
2444 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2445
2446 config := testMinDelayConfig()
2447 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2448 svc.SetProxyCacheInvalidator(cacheInvalidator)
2449 ctx := testContext()
2450
2451 route := domain.Route{
2452 Domain: "test.example.com",
2453 Image: "myapp:latest",
2454 }
2455
2456 // Track call order to verify serialization
2457 var callOrder []string
2458 var callMu sync.Mutex
2459
2460 // Setup mocks that will be called by both deploys sequentially.
2461 // First deploy: no existing container → creates gordon-test.example.com
2462 // Second deploy: sees first container → creates gordon-test.example.com-new
2463
2464 // First deploy resolves from runtime (no container in memory yet)
2465 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
2466
2467 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
2468 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
2469 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
2470 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
2471 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, nil).Times(2)
2472 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
2473
2474 createCall := 0
2475 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
2476 RunAndReturn(func(_ context.Context, cfg *domain.ContainerConfig) (*domain.Container, error) {
2477 callMu.Lock()
2478 createCall++
2479 n := createCall
2480 callOrder = append(callOrder, fmt.Sprintf("create-%d:%s", n, cfg.Name))
2481 callMu.Unlock()
2482 return &domain.Container{
2483 ID: fmt.Sprintf("container-%d", n),
2484 Name: cfg.Name,
2485 Image: cfg.Image,
2486 Status: "created",
2487 }, nil
2488 })
2489
2490 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
2491 // IsContainerRunning: 2x per deploy in waitForReady + 1x stabilization for the second deploy (which has existing)
2492 runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(5)
2493
2494 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
2495 RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
2496 // Return correct container name based on ID
2497 // container-1 is the first deploy (canonical name)
2498 // container-2 is the second deploy (with -new suffix)
2499 name := "gordon-test.example.com"
2500 if id == "container-2" {
2501 name = "gordon-test.example.com-new"
2502 }
2503 return &domain.Container{
2504 ID: id,
2505 Name: name,
2506 Image: "myapp:latest",
2507 Status: "running",
2508 Ports: []int{8080},
2509 }, nil
2510 })
2511
2512 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)
2513
2514 // Both deploys call InvalidateTarget (to ensure proxy picks up the new container).
2515 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
2516
2517 // which means it will also stop+remove+rename the old container.
2518 runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
2519 runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
2520 runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Once()
2521
2522 // Launch two concurrent deploys
2523 var wg sync.WaitGroup
2524 errs := make([]error, 2)
2525
2526 wg.Add(2)
2527 for i := range 2 {
2528 go func(idx int) {
2529 defer wg.Done()
2530 _, errs[idx] = svc.Deploy(ctx, route)
2531 }(i)
2532 }
2533 wg.Wait()
2534 svc.WaitForCleanup()
2535
2536 // Both should succeed (second deploy waits for the first to finish)
2537 assert.NoError(t, errs[0], "first deploy should succeed")
2538 assert.NoError(t, errs[1], "second deploy should succeed")
2539
2540 // Verify creates were serialized (not interleaved)
2541 callMu.Lock()
2542 assert.Len(t, callOrder, 2, "should have exactly 2 create calls")
2543 // First deploy uses canonical name, second uses -new suffix (zero-downtime)
2544 assert.Equal(t, "create-1:gordon-test.example.com", callOrder[0], "first create should use canonical name")
2545 assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
2546 callMu.Unlock()
2547}
2548
2549func TestService_Deploy_ContextCancellation(t *testing.T) {
2550 // Verify that deploy lock acquisition respects context cancellation.
2551 runtime := mocks.NewMockContainerRuntime(t)
2552 envLoader := mocks.NewMockEnvLoader(t)
2553 eventBus := mocks.NewMockEventPublisher(t)
2554
2555 config := Config{
2556 ReadinessDelay: time.Millisecond,
2557 DrainDelay: time.Millisecond,
2558 DrainDelayConfigured: true,
2559 }
2560 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2561
2562 route := domain.Route{
2563 Domain: "test.example.com",
2564 Image: "myapp:latest",
2565 }
2566
2567 // Test context cancelled before lock acquisition.
2568 cancelledCtx, cancel := context.WithCancel(testContext())
2569 cancel() // Cancel immediately
2570
2571 _, err := svc.Deploy(cancelledCtx, route)
2572 assert.Error(t, err, "deploy should fail with cancelled context")
2573 assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
2574}
2575
2576// TestService_Deploy_CacheInvalidationBeforeOldContainerStop verifies the fix for
2577// the proxy cache race condition: InvalidateTarget must be called synchronously
2578// BEFORE the old container is stopped, preventing 503 errors during deployment.
2579func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
2580 runtime := mocks.NewMockContainerRuntime(t)
2581 envLoader := mocks.NewMockEnvLoader(t)
2582 eventBus := mocks.NewMockEventPublisher(t)
2583 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2584
2585 config := testMinDelayConfig()
2586 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2587 svc.SetProxyCacheInvalidator(cacheInvalidator)
2588 ctx := testContext()
2589
2590 // Pre-populate with existing container
2591 existingContainer := &domain.Container{
2592 ID: "old-container",
2593 Name: "gordon-test.example.com",
2594 Status: "running",
2595 }
2596 svc.containers["test.example.com"] = existingContainer
2597
2598 route := domain.Route{
2599 Domain: "test.example.com",
2600 Image: "myapp:v2",
2601 }
2602
2603 // Track ordering of cache invalidation and container stop
2604 var callOrder []string
2605 var orderMu sync.Mutex
2606
2607 // Standard deploy mocks
2608 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2609 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
2610 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2611 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2612 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2613 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2614
2615 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2616 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2617 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2618 // Wait for ready (2 calls) + stabilization check (1 call)
2619 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2620 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2621 ID: "new-container", Status: "running",
2622 }, nil)
2623 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2624
2625 // Track: cache invalidation should happen first
2626 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").
2627 Run(func(_ context.Context, _ string) {
2628 orderMu.Lock()
2629 callOrder = append(callOrder, "invalidate_cache")
2630 orderMu.Unlock()
2631 }).Return()
2632
2633 // Track: stop should happen after invalidation
2634 runtime.EXPECT().StopContainer(mock.Anything, "old-container").
2635 RunAndReturn(func(_ context.Context, _ string) error {
2636 orderMu.Lock()
2637 callOrder = append(callOrder, "stop_old_container")
2638 orderMu.Unlock()
2639 return nil
2640 })
2641 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2642 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2643
2644 result, err := svc.Deploy(ctx, route)
2645
2646 assert.NoError(t, err)
2647 svc.WaitForCleanup()
2648 assert.Equal(t, "new-container", result.ID)
2649
2650 // Verify ordering: cache invalidation MUST happen before old container stop
2651 orderMu.Lock()
2652 defer orderMu.Unlock()
2653 assert.Equal(t, []string{"invalidate_cache", "stop_old_container"}, callOrder,
2654 "InvalidateTarget must be called before StopContainer to prevent 503 errors")
2655}
2656
2657// TestService_Deploy_NilCacheInvalidator verifies that deploy works gracefully
2658// when no cache invalidator is set (e.g., in tests or minimal configurations).
2659func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
2660 runtime := mocks.NewMockContainerRuntime(t)
2661 envLoader := mocks.NewMockEnvLoader(t)
2662 eventBus := mocks.NewMockEventPublisher(t)
2663
2664 config := testMinDelayConfig()
2665 // Intentionally NOT setting cache invalidator
2666 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2667 ctx := testContext()
2668
2669 // Pre-populate with existing container
2670 svc.containers["test.example.com"] = &domain.Container{
2671 ID: "old-container",
2672 Name: "gordon-test.example.com",
2673 Status: "running",
2674 }
2675
2676 route := domain.Route{
2677 Domain: "test.example.com",
2678 Image: "myapp:v2",
2679 }
2680
2681 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2682 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
2683 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2684 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2685 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2686 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2687
2688 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2689 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2690 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2691 // Wait for ready (2 calls) + stabilization check (1 call)
2692 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2693 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2694 ID: "new-container", Status: "running",
2695 }, nil)
2696 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2697 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
2698 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2699 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2700
2701 result, err := svc.Deploy(ctx, route)
2702
2703 assert.NoError(t, err)
2704 svc.WaitForCleanup()
2705 assert.Equal(t, "new-container", result.ID)
2706}
2707
2708func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
2709 runtime := mocks.NewMockContainerRuntime(t)
2710 envLoader := mocks.NewMockEnvLoader(t)
2711 eventBus := mocks.NewMockEventPublisher(t)
2712
2713 config := Config{
2714 ReadinessDelay: time.Millisecond,
2715 DrainDelay: time.Millisecond,
2716 DrainDelayConfigured: true,
2717 }
2718 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2719 ctx := testContext()
2720 expectedEnvHash := hashEnvironment([]string{})
2721
2722 // Pre-populate with existing container that has an ImageID (set by InspectContainer).
2723 // This simulates the first deploy (from image.pushed event) having already completed.
2724 existingContainer := &domain.Container{
2725 ID: "existing-container",
2726 Name: "gordon-test.example.com",
2727 Image: "myapp:latest",
2728 ImageID: "sha256:abc123",
2729 Status: "running",
2730 Labels: map[string]string{
2731 domain.LabelEnvHash: expectedEnvHash,
2732 },
2733 }
2734 svc.containers["test.example.com"] = existingContainer
2735
2736 route := domain.Route{
2737 Domain: "test.example.com",
2738 Image: "myapp:latest",
2739 }
2740
2741 // prepareDeployResources will run: orphan cleanup, image pull, etc.
2742 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2743 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2744 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2745 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
2746 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2747 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2748
2749 // skipRedundantDeploy: resolve image ID and compare with existing container
2750 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
2751 runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
2752
2753 // No CreateContainer, StartContainer, or readiness calls should happen
2754
2755 result, err := svc.Deploy(ctx, route)
2756
2757 assert.NoError(t, err)
2758 assert.NotNil(t, result)
2759 assert.Equal(t, "existing-container", result.ID)
2760
2761 // Container should still be tracked
2762 tracked, exists := svc.Get(ctx, "test.example.com")
2763 assert.True(t, exists)
2764 assert.Equal(t, "existing-container", tracked.ID)
2765}
2766
2767func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testing.T) {
2768 runtime := mocks.NewMockContainerRuntime(t)
2769 envLoader := mocks.NewMockEnvLoader(t)
2770 eventBus := mocks.NewMockEventPublisher(t)
2771
2772 config := Config{
2773 ReadinessDelay: time.Millisecond,
2774 DrainDelay: time.Millisecond,
2775 DrainDelayConfigured: true,
2776 }
2777 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2778 ctx := testContext()
2779 expectedEnvHash := hashEnvironment([]string{})
2780
2781 // Pre-populate with existing container missing ImageID (common after stale in-memory state).
2782 existingContainer := &domain.Container{
2783 ID: "existing-container",
2784 Name: "gordon-test.example.com",
2785 Image: "myapp:latest",
2786 Status: "running",
2787 Labels: map[string]string{
2788 domain.LabelEnvHash: expectedEnvHash,
2789 },
2790 }
2791 svc.containers["test.example.com"] = existingContainer
2792
2793 route := domain.Route{
2794 Domain: "test.example.com",
2795 Image: "myapp:latest",
2796 }
2797
2798 // prepareDeployResources
2799 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2800 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2801 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2802 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
2803 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2804 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2805
2806 // Existing container image ID is recovered from runtime inspection,
2807 // then redundancy check can skip the deploy.
2808 runtime.EXPECT().InspectContainer(mock.Anything, "existing-container").Return(&domain.Container{
2809 ID: "existing-container",
2810 ImageID: "sha256:abc123",
2811 Status: "running",
2812 }, nil)
2813 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
2814 runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
2815
2816 result, err := svc.Deploy(ctx, route)
2817
2818 assert.NoError(t, err)
2819 assert.NotNil(t, result)
2820 assert.Equal(t, "existing-container", result.ID)
2821
2822 tracked, exists := svc.Get(ctx, "test.example.com")
2823 assert.True(t, exists)
2824 assert.Equal(t, "existing-container", tracked.ID)
2825}
2826
2827func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
2828 runtime := mocks.NewMockContainerRuntime(t)
2829 envLoader := mocks.NewMockEnvLoader(t)
2830 eventBus := mocks.NewMockEventPublisher(t)
2831 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2832
2833 config := testMinDelayConfig()
2834 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2835 svc.SetProxyCacheInvalidator(cacheInvalidator)
2836 ctx := testContext()
2837
2838 // Existing container with a DIFFERENT image ID than what's being deployed
2839 existingContainer := &domain.Container{
2840 ID: "old-container",
2841 Name: "gordon-test.example.com",
2842 Image: "myapp:latest",
2843 ImageID: "sha256:old-image",
2844 Status: "running",
2845 }
2846 svc.containers["test.example.com"] = existingContainer
2847
2848 route := domain.Route{
2849 Domain: "test.example.com",
2850 Image: "myapp:latest",
2851 }
2852
2853 // prepareDeployResources
2854 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2855 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2856 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2857 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
2858 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2859 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2860
2861 // skipRedundantDeploy: image IDs differ, so deploy proceeds
2862 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:new-image", nil)
2863
2864 // Full deploy proceeds
2865 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2866 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2867 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2868 // Wait for ready (2 calls) + stabilization check (1 call)
2869 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2870 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2871 ID: "new-container", Status: "running",
2872 }, nil)
2873 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2874 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2875 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
2876 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2877 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2878
2879 result, err := svc.Deploy(ctx, route)
2880
2881 assert.NoError(t, err)
2882 svc.WaitForCleanup()
2883 assert.Equal(t, "new-container", result.ID)
2884}
2885
2886func TestSyncContainersRebuildsAttachmentMap(t *testing.T) {
2887 runtime := mocks.NewMockContainerRuntime(t)
2888 envLoader := mocks.NewMockEnvLoader(t)
2889 eventBus := mocks.NewMockEventPublisher(t)
2890
2891 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
2892 ctx := testContext()
2893
2894 // ListContainers returns one main container and one attachment container
2895 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
2896 {
2897 ID: "main-container-1",
2898 Labels: map[string]string{
2899 domain.LabelDomain: "app.example.com",
2900 domain.LabelManaged: "true",
2901 },
2902 },
2903 {
2904 ID: "attachment-container-1",
2905 Labels: map[string]string{
2906 domain.LabelManaged: "true",
2907 domain.LabelAttachment: "true",
2908 domain.LabelAttachedTo: "app.example.com",
2909 },
2910 },
2911 }, nil).Once()
2912
2913 require.NoError(t, svc.SyncContainers(ctx))
2914
2915 // Main container should be in s.containers
2916 tracked, exists := svc.Get(ctx, "app.example.com")
2917 require.True(t, exists, "main container should be tracked after SyncContainers")
2918 assert.Equal(t, "main-container-1", tracked.ID)
2919
2920 // Attachment container should be in s.attachments under the owner domain
2921 svc.mu.RLock()
2922 attachIDs := svc.attachments["app.example.com"]
2923 svc.mu.RUnlock()
2924
2925 require.Len(t, attachIDs, 1, "attachment map should contain one entry for app.example.com after SyncContainers")
2926 assert.Equal(t, "attachment-container-1", attachIDs[0])
2927}
2928
2929func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
2930 runtime := mocks.NewMockContainerRuntime(t)
2931 envLoader := mocks.NewMockEnvLoader(t)
2932 eventBus := mocks.NewMockEventPublisher(t)
2933
2934 config := testMinDelayConfig()
2935 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2936 ctx := testContext()
2937 expectedEnvHash := hashEnvironment([]string{})
2938
2939 // Existing container has same image ID but is NOT running (crashed)
2940 existingContainer := &domain.Container{
2941 ID: "existing-container",
2942 Name: "gordon-test.example.com",
2943 Image: "myapp:latest",
2944 ImageID: "sha256:abc123",
2945 Status: "exited",
2946 Labels: map[string]string{
2947 domain.LabelEnvHash: expectedEnvHash,
2948 },
2949 }
2950 svc.containers["test.example.com"] = existingContainer
2951
2952 route := domain.Route{
2953 Domain: "test.example.com",
2954 Image: "myapp:latest",
2955 }
2956
2957 // prepareDeployResources
2958 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2959 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2960 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2961 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
2962 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2963 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2964
2965 // skipRedundantDeploy: same image ID but container not running => proceed with deploy
2966 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
2967 runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(false, nil)
2968
2969 // Full deploy proceeds (replaces crashed container)
2970 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2971 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2972 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2973 // Wait for ready (2 calls) + stabilization check (1 call)
2974 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2975 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2976 ID: "new-container", Status: "running",
2977 }, nil)
2978 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2979
2980 // Old (exited) container is still finalized
2981 runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
2982 runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
2983 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2984
2985 result, err := svc.Deploy(ctx, route)
2986
2987 assert.NoError(t, err)
2988 svc.WaitForCleanup()
2989 assert.Equal(t, "new-container", result.ID)
2990}
2991
2992func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
2993 runtime := mocks.NewMockContainerRuntime(t)
2994 envLoader := mocks.NewMockEnvLoader(t)
2995 eventBus := mocks.NewMockEventPublisher(t)
2996
2997 config := testMinDelayConfig()
2998 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2999 ctx := testContext()
3000 expectedEnvHash := hashEnvironment([]string{})
3001
3002 existingContainer := &domain.Container{
3003 ID: "existing-container",
3004 Name: "gordon-test.example.com",
3005 Image: "myapp:latest",
3006 ImageID: "sha256:abc123",
3007 Status: "running",
3008 Labels: map[string]string{
3009 domain.LabelEnvHash: "different-hash",
3010 },
3011 }
3012 svc.containers["test.example.com"] = existingContainer
3013
3014 route := domain.Route{
3015 Domain: "test.example.com",
3016 Image: "myapp:latest",
3017 }
3018
3019 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3020 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3021 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3022 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3023 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3024 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3025
3026 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
3027 runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
3028
3029 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3030 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3031 return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash
3032 })).Return(newContainer, nil)
3033 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3034 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3035 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3036 ID: "new-container", Status: "running",
3037 }, nil)
3038 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3039 runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
3040 runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
3041 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3042
3043 result, err := svc.Deploy(ctx, route)
3044
3045 assert.NoError(t, err)
3046 svc.WaitForCleanup()
3047 assert.Equal(t, "new-container", result.ID)
3048}
3049
3050func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) {
3051 runtime := mocks.NewMockContainerRuntime(t)
3052 envLoader := mocks.NewMockEnvLoader(t)
3053 eventBus := mocks.NewMockEventPublisher(t)
3054
3055 config := testMinDelayConfig()
3056 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3057 ctx := testContext()
3058 expectedEnvHash := hashEnvironment([]string{})
3059
3060 existingContainer := &domain.Container{
3061 ID: "existing-container",
3062 Name: "gordon-test.example.com",
3063 Image: "myapp:latest",
3064 ImageID: "sha256:abc123",
3065 Status: "running",
3066 Labels: map[string]string{},
3067 }
3068 svc.containers["test.example.com"] = existingContainer
3069
3070 route := domain.Route{
3071 Domain: "test.example.com",
3072 Image: "myapp:latest",
3073 }
3074
3075 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3076 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3077 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3078 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3079 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3080 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3081
3082 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
3083 runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
3084
3085 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3086 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3087 return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash
3088 })).Return(newContainer, nil)
3089 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3090 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3091 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3092 ID: "new-container", Status: "running",
3093 }, nil)
3094 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3095 runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
3096 runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
3097 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3098
3099 result, err := svc.Deploy(ctx, route)
3100
3101 assert.NoError(t, err)
3102 svc.WaitForCleanup()
3103 assert.Equal(t, "new-container", result.ID)
3104}
3105
3106func TestService_attachmentEnvDrifted_ReturnsFalseWhenHashMatches(t *testing.T) {
3107 runtime := mocks.NewMockContainerRuntime(t)
3108 envLoader := mocks.NewMockEnvLoader(t)
3109 eventBus := mocks.NewMockEventPublisher(t)
3110
3111 svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
3112 ctx := testContext()
3113 imageRef := svc.buildImageRef("postgres:16")
3114 envVars := []string{"POSTGRES_PASSWORD=secret"}
3115 envHash := hashEnvironment(envVars)
3116
3117 envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil)
3118 runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil)
3119
3120 drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{
3121 Labels: map[string]string{domain.LabelEnvHash: envHash},
3122 }, "gordon-app-example-com-postgres", "postgres:16")
3123
3124 assert.NoError(t, err)
3125 assert.False(t, drifted)
3126}
3127
3128func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashDiffers(t *testing.T) {
3129 runtime := mocks.NewMockContainerRuntime(t)
3130 envLoader := mocks.NewMockEnvLoader(t)
3131 eventBus := mocks.NewMockEventPublisher(t)
3132
3133 svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
3134 ctx := testContext()
3135 imageRef := svc.buildImageRef("postgres:16")
3136 envVars := []string{"POSTGRES_PASSWORD=secret"}
3137
3138 envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil)
3139 runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil)
3140
3141 drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{
3142 Labels: map[string]string{domain.LabelEnvHash: "stale-hash"},
3143 }, "gordon-app-example-com-postgres", "postgres:16")
3144
3145 assert.NoError(t, err)
3146 assert.True(t, drifted)
3147}
3148
3149func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashMissing(t *testing.T) {
3150 runtime := mocks.NewMockContainerRuntime(t)
3151 envLoader := mocks.NewMockEnvLoader(t)
3152 eventBus := mocks.NewMockEventPublisher(t)
3153
3154 svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
3155 ctx := testContext()
3156 imageRef := svc.buildImageRef("postgres:16")
3157 envVars := []string{"POSTGRES_PASSWORD=secret"}
3158
3159 envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil)
3160 runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil)
3161
3162 drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{
3163 Labels: map[string]string{},
3164 }, "gordon-app-example-com-postgres", "postgres:16")
3165
3166 assert.NoError(t, err)
3167 assert.True(t, drifted)
3168}
3169
3170// TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan
3171// cleanup never stops a running container with the canonical name. Only stopped
3172// canonical containers and temp containers (-new/-next) should be cleaned up.
3173// The active canonical container is resolved via resolveExistingContainer (it has the
3174// managed label), so the new container is created with the "-new" suffix — Docker
3175// disallows creating a container with the same name as an existing running container.
3176func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
3177 runtime := mocks.NewMockContainerRuntime(t)
3178 envLoader := mocks.NewMockEnvLoader(t)
3179 eventBus := mocks.NewMockEventPublisher(t)
3180
3181 config := testMinDelayConfig()
3182 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3183 ctx := testContext()
3184
3185 // The canonical container is running and has the managed label — it will be
3186 // discovered by resolveExistingContainer's slow path (no in-memory state).
3187 existingContainer := &domain.Container{
3188 ID: "active-canonical",
3189 Name: "gordon-test.example.com",
3190 Status: "running",
3191 Labels: map[string]string{domain.LabelManaged: "true"},
3192 }
3193
3194 route := domain.Route{
3195 Domain: "test.example.com",
3196 Image: "myapp:v2",
3197 }
3198
3199 // resolveExistingContainer: slow path finds the running canonical container via label.
3200 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{existingContainer}, nil)
3201
3202 // cleanupOrphanedContainers: finds the running canonical AND a stale -new leftover.
3203 // The canonical container must NOT be killed because it's running.
3204 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
3205 existingContainer,
3206 {
3207 ID: "stale-leftover",
3208 Name: "gordon-test.example.com-new",
3209 Status: "exited",
3210 },
3211 }, nil)
3212
3213 // Only the stale -new container should be stopped and removed during orphan cleanup.
3214 runtime.EXPECT().StopContainer(mock.Anything, "stale-leftover").Return(nil).Once()
3215 runtime.EXPECT().RemoveContainer(mock.Anything, "stale-leftover", true).Return(nil).Once()
3216
3217 // Image operations: pull new image (existing.Image is empty so redundancy check is skipped)
3218 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3219 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3220 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3221 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3222
3223 // Environment
3224 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3225 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3226
3227 // Create container — existing is the canonical container, so the new one gets "-new" suffix.
3228 // Docker enforces unique container names; using "-new" avoids a name collision.
3229 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3230 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3231 return cfg.Name == "gordon-test.example.com-new"
3232 })).Return(newContainer, nil)
3233 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3234
3235 // Wait for ready (delay mode): pollContainerRunning (1×) + waitForReadyByDelay (1×)
3236 // Post-switch stabilization: 1× — total 3 calls.
3237 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3238
3239 // Inspect after ready
3240 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3241 ID: "new-container",
3242 Status: "running",
3243 Ports: []int{8080},
3244 }, nil)
3245
3246 // Rename new container to canonical name after stabilization.
3247 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil).Once()
3248
3249 // Stop and remove old canonical container.
3250 runtime.EXPECT().StopContainer(mock.Anything, "active-canonical").Return(nil).Once()
3251 runtime.EXPECT().RemoveContainer(mock.Anything, "active-canonical", true).Return(nil).Once()
3252
3253 // Publish event
3254 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3255
3256 result, err := svc.Deploy(ctx, route)
3257
3258 assert.NoError(t, err)
3259 svc.WaitForCleanup()
3260 assert.Equal(t, "new-container", result.ID)
3261
3262 // Verify new container is tracked
3263 tracked, exists := svc.Get(ctx, "test.example.com")
3264 assert.True(t, exists)
3265 assert.Equal(t, "new-container", tracked.ID)
3266}
3267
3268// TestService_Deploy_RollbackOnPostSwitchCrash verifies that if the new container crashes
3269// during the post-switch stabilization window, traffic is automatically rolled back to the
3270// old container: the old container is restored as tracked, the proxy cache is re-invalidated,
3271// and the failed new container is cleaned up.
3272func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
3273 runtime := mocks.NewMockContainerRuntime(t)
3274 envLoader := mocks.NewMockEnvLoader(t)
3275 eventBus := mocks.NewMockEventPublisher(t)
3276 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3277
3278 config := Config{
3279 ReadinessDelay: time.Millisecond,
3280 DrainDelay: time.Millisecond,
3281 DrainDelayConfigured: true,
3282 StabilizationDelay: time.Millisecond,
3283 }
3284 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3285 svc.SetProxyCacheInvalidator(cacheInvalidator)
3286 ctx := testContext()
3287
3288 // Pre-populate with existing container
3289 existing := &domain.Container{
3290 ID: "old-container",
3291 Name: "gordon-test.example.com",
3292 Status: "running",
3293 }
3294 svc.containers["test.example.com"] = existing
3295
3296 route := domain.Route{
3297 Domain: "test.example.com",
3298 Image: "myapp:v2",
3299 }
3300
3301 // Cleanup orphans
3302 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3303
3304 // Image operations
3305 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3306 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3307 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3308 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3309
3310 // Environment
3311 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3312 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3313
3314 // Create new container
3315 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3316 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3317 return cfg.Name == "gordon-test.example.com-new"
3318 })).Return(newContainer, nil)
3319 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3320
3321 // Readiness: new container is running during readiness checks (2 calls)
3322 // Stabilization: new container has crashed (returns false)
3323 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
3324 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()
3325
3326 // Rollback: verify old container is still running before restoring
3327 runtime.EXPECT().IsContainerRunning(mock.Anything, "old-container").Return(true, nil).Once()
3328
3329 // Inspect after ready
3330 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3331 ID: "new-container",
3332 Name: "gordon-test.example.com-new",
3333 Status: "running",
3334 }, nil)
3335
3336 // Publish event (happens during activateDeployedContainer)
3337 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3338
3339 // InvalidateTarget called TWICE: once during activate, once during rollback
3340 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
3341
3342 // Cleanup failed new container during rollback
3343 runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
3344 runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
3345
3346 // StopContainer on old-container should NOT be called (old stays running)
3347 // (no expectation for StopContainer("old-container"))
3348
3349 result, err := svc.Deploy(ctx, route)
3350
3351 // Deploy returns the old (existing) container after rollback
3352 assert.NoError(t, err)
3353 assert.NotNil(t, result)
3354 assert.Equal(t, "old-container", result.ID, "should return old container after rollback")
3355
3356 // Verify old container is restored as tracked
3357 trackedRB, existsRB := svc.Get(ctx, "test.example.com")
3358 assert.True(t, existsRB)
3359 assert.Equal(t, "old-container", trackedRB.ID, "old container should be restored after rollback")
3360}
3361
3362// TestService_Deploy_StabilizationSuccess verifies that when the new container remains
3363// healthy during the stabilization window, deployment proceeds normally and the old
3364// container is finalized (stopped and removed).
3365func TestService_Deploy_StabilizationSuccess(t *testing.T) {
3366 runtime := mocks.NewMockContainerRuntime(t)
3367 envLoader := mocks.NewMockEnvLoader(t)
3368 eventBus := mocks.NewMockEventPublisher(t)
3369 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3370
3371 config := Config{
3372 ReadinessDelay: time.Millisecond,
3373 DrainDelay: time.Millisecond,
3374 DrainDelayConfigured: true,
3375 StabilizationDelay: time.Millisecond,
3376 }
3377 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3378 svc.SetProxyCacheInvalidator(cacheInvalidator)
3379 ctx := testContext()
3380
3381 // Pre-populate with existing container
3382 existing := &domain.Container{
3383 ID: "old-container",
3384 Name: "gordon-test.example.com",
3385 Status: "running",
3386 }
3387 svc.containers["test.example.com"] = existing
3388
3389 route := domain.Route{
3390 Domain: "test.example.com",
3391 Image: "myapp:v2",
3392 }
3393
3394 // Cleanup orphans
3395 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3396
3397 // Image operations
3398 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3399 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3400 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3401 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3402
3403 // Environment
3404 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3405 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3406
3407 // Create new container
3408 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3409 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3410 return cfg.Name == "gordon-test.example.com-new"
3411 })).Return(newContainer, nil)
3412 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3413
3414 // Readiness (2 calls) + stabilization check (1 call) — all return running
3415 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3416
3417 // Inspect after ready
3418 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3419 ID: "new-container",
3420 Name: "gordon-test.example.com-new",
3421 Status: "running",
3422 }, nil)
3423
3424 // Publish event
3425 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3426
3427 // Cache invalidation (once, during activate)
3428 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
3429
3430 // Old container finalized normally
3431 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
3432 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
3433 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3434
3435 result, err := svc.Deploy(ctx, route)
3436
3437 assert.NoError(t, err)
3438 svc.WaitForCleanup()
3439 assert.NotNil(t, result)
3440 assert.Equal(t, "new-container", result.ID, "should return new container on successful stabilization")
3441
3442 // Verify new container is tracked
3443 trackedSS, existsSS := svc.Get(ctx, "test.example.com")
3444 assert.True(t, existsSS)
3445 assert.Equal(t, "new-container", trackedSS.ID)
3446}
3447
3448// TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady verifies the strict
3449// ordering invariant: StopContainer("old-container") must NEVER happen before
3450// InspectContainer("new-container") completes (the last step of readiness).
3451// Uses testify's NotBefore() to enforce mock call ordering.
3452func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) {
3453 runtime := mocks.NewMockContainerRuntime(t)
3454 envLoader := mocks.NewMockEnvLoader(t)
3455 eventBus := mocks.NewMockEventPublisher(t)
3456 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3457
3458 config := Config{
3459 ReadinessDelay: time.Millisecond,
3460 DrainDelay: time.Millisecond,
3461 DrainDelayConfigured: true,
3462 StabilizationDelay: time.Millisecond,
3463 }
3464 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3465 svc.SetProxyCacheInvalidator(cacheInvalidator)
3466 ctx := testContext()
3467
3468 // Pre-populate with existing tracked container
3469 existingContainer := &domain.Container{
3470 ID: "old-container",
3471 Name: "gordon-test.example.com",
3472 Status: "running",
3473 }
3474 svc.containers["test.example.com"] = existingContainer
3475
3476 route := domain.Route{
3477 Domain: "test.example.com",
3478 Image: "myapp:v2",
3479 }
3480
3481 // Cleanup orphans
3482 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3483
3484 // Image operations
3485 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3486 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3487 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3488 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3489
3490 // Environment
3491 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3492 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3493
3494 // Create new container with -new suffix for zero-downtime
3495 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3496 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3497 return cfg.Name == "gordon-test.example.com-new"
3498 })).Return(newContainer, nil)
3499 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3500
3501 // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call)
3502 // Stabilization check (1 call)
3503 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3504
3505 // KEY ORDERING CONSTRAINT: InspectContainer("new-container") is the last step
3506 // of readiness inside createStartedContainer. StopContainer("old-container")
3507 // must happen AFTER this call completes.
3508 inspectCall := runtime.EXPECT().InspectContainer(mock.Anything, "new-container").
3509 Return(&domain.Container{
3510 ID: "new-container",
3511 Name: "gordon-test.example.com-new",
3512 Status: "running",
3513 }, nil)
3514
3515 // Publish event
3516 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3517
3518 // Synchronous cache invalidation
3519 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
3520
3521 // STRICT ORDERING: StopContainer on old container must NOT happen before
3522 // InspectContainer on new container (readiness completion)
3523 runtime.EXPECT().StopContainer(mock.Anything, "old-container").
3524 Return(nil).
3525 NotBefore(inspectCall.Call)
3526
3527 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
3528 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3529
3530 result, err := svc.Deploy(ctx, route)
3531
3532 assert.NoError(t, err)
3533 svc.WaitForCleanup()
3534 assert.NotNil(t, result)
3535 assert.Equal(t, "new-container", result.ID)
3536
3537 // Verify new container is tracked
3538 tracked, exists := svc.Get(ctx, "test.example.com")
3539 assert.True(t, exists)
3540 assert.Equal(t, "new-container", tracked.ID)
3541}
3542
3543// TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched verifies that when a
3544// new container fails readiness (IsContainerRunning returns false), the old container
3545// is completely untouched: never stopped, never removed. Deploy returns an error and
3546// the old container remains as the tracked container.
3547func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) {
3548 runtime := mocks.NewMockContainerRuntime(t)
3549 envLoader := mocks.NewMockEnvLoader(t)
3550 eventBus := mocks.NewMockEventPublisher(t)
3551
3552 config := Config{
3553 ReadinessDelay: time.Millisecond,
3554 DrainDelay: time.Millisecond,
3555 DrainDelayConfigured: true,
3556 StabilizationDelay: time.Millisecond,
3557 }
3558 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3559 ctx := testContext()
3560
3561 // Pre-populate with existing tracked container
3562 existingContainer := &domain.Container{
3563 ID: "old-container",
3564 Name: "gordon-test.example.com",
3565 Status: "running",
3566 }
3567 svc.containers["test.example.com"] = existingContainer
3568
3569 route := domain.Route{
3570 Domain: "test.example.com",
3571 Image: "myapp:v2",
3572 }
3573
3574 // Cleanup orphans
3575 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3576
3577 // Image operations
3578 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3579 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3580 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3581 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3582
3583 // Environment
3584 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3585 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3586
3587 // Create new container
3588 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3589 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3590 return cfg.Name == "gordon-test.example.com-new"
3591 })).Return(newContainer, nil)
3592 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3593
3594 // Readiness FAILS: pollContainerRunning succeeds (container starts) but
3595 // waitForReadyByDelay verification finds it not running.
3596 // pollContainerRunning: returns true (container initially starts)
3597 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
3598 // waitForReadyByDelay post-delay verification: returns false (container crashed)
3599 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()
3600 // Recovery window poll: return error to fail fast instead of waiting 30s
3601 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").
3602 Return(false, errors.New("container exited")).Once()
3603
3604 // cleanupFailedContainer: stop and remove the failed new container
3605 runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
3606 runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
3607
3608 // NOTE: StopContainer("old-container") and RemoveContainer("old-container", true)
3609 // are intentionally NOT mocked. If either is called, testify will panic with
3610 // "unexpected method call" — verifying the old container is never touched.
3611
3612 result, err := svc.Deploy(ctx, route)
3613
3614 // Deploy should return an error (readiness failure)
3615 assert.Error(t, err)
3616 assert.Nil(t, result)
3617 assert.Contains(t, err.Error(), "readiness check")
3618
3619 // Old container must still be tracked — completely untouched
3620 tracked, exists := svc.Get(ctx, "test.example.com")
3621 assert.True(t, exists, "old container should still be tracked after readiness failure")
3622 assert.Equal(t, "old-container", tracked.ID, "old container ID should be unchanged")
3623}
3624
3625// TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy is a sanity check:
3626// first deploy with no existing container should work normally without any
3627// finalize/drain logic being triggered.
3628func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
3629 runtime := mocks.NewMockContainerRuntime(t)
3630 envLoader := mocks.NewMockEnvLoader(t)
3631 eventBus := mocks.NewMockEventPublisher(t)
3632
3633 config := Config{
3634 ReadinessDelay: time.Millisecond,
3635 DrainDelay: time.Millisecond,
3636 DrainDelayConfigured: true,
3637 StabilizationDelay: time.Millisecond,
3638 }
3639 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3640 ctx := testContext()
3641
3642 // NO existing container — this is a first deploy
3643
3644 route := domain.Route{
3645 Domain: "test.example.com",
3646 Image: "myapp:v2",
3647 }
3648
3649 // resolveExistingContainer: no container in memory, runtime returns nothing
3650 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
3651
3652 // Cleanup orphans — nothing found
3653 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3654
3655 // Image operations
3656 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3657 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3658 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3659 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3660
3661 // Environment
3662 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3663 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3664
3665 // Create container — canonical name (no -new suffix since no existing container)
3666 newContainer := &domain.Container{ID: "first-container", Name: "gordon-test.example.com", Status: "created"}
3667 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3668 return cfg.Name == "gordon-test.example.com"
3669 })).Return(newContainer, nil)
3670 runtime.EXPECT().StartContainer(mock.Anything, "first-container").Return(nil)
3671
3672 // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call)
3673 // No stabilization check for first deploy (hasExisting is false)
3674 runtime.EXPECT().IsContainerRunning(mock.Anything, "first-container").Return(true, nil).Times(2)
3675
3676 // Inspect after readiness
3677 runtime.EXPECT().InspectContainer(mock.Anything, "first-container").Return(&domain.Container{
3678 ID: "first-container",
3679 Name: "gordon-test.example.com",
3680 Status: "running",
3681 Ports: []int{8080},
3682 }, nil)
3683
3684 // Publish event
3685 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3686
3687 // NOTE: No StopContainer, RemoveContainer, or RenameContainer expectations —
3688 // first deploy has no previous container to finalize, and no stabilization check.
3689
3690 result, err := svc.Deploy(ctx, route)
3691
3692 assert.NoError(t, err)
3693 assert.NotNil(t, result)
3694 assert.Equal(t, "first-container", result.ID)
3695 assert.Equal(t, "running", result.Status)
3696
3697 // Verify container is tracked
3698 tracked, exists := svc.Get(ctx, "test.example.com")
3699 assert.True(t, exists)
3700 assert.Equal(t, "first-container", tracked.ID)
3701}
3702
3703func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
3704 runtime := mocks.NewMockContainerRuntime(t)
3705 envLoader := mocks.NewMockEnvLoader(t)
3706 eventBus := mocks.NewMockEventPublisher(t)
3707
3708 config := Config{
3709 NetworkIsolation: false,
3710 VolumeAutoCreate: false,
3711 ReadinessDelay: time.Millisecond,
3712 DrainDelay: time.Millisecond,
3713 DrainDelayConfigured: true,
3714 }
3715
3716 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3717 ctx := testContext()
3718
3719 route := domain.Route{
3720 Domain: "gitea.example.com",
3721 Image: "gitea/gitea:latest",
3722 }
3723
3724 // No container in memory — runtime resolution returns nothing
3725 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
3726 // No orphaned containers
3727 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3728
3729 // Image is found locally
3730 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"gitea/gitea:latest"}, nil)
3731
3732 // Exposed ports — image exposes both SSH and HTTP
3733 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "gitea/gitea:latest").Return([]int{22, 3000}, nil)
3734
3735 // Image labels include gordon.proxy.port and gordon.health
3736 runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
3737 domain.LabelProxyPort: "3000",
3738 domain.LabelHealth: "/healthz",
3739 "some.other.label": "ignored",
3740 }, nil)
3741
3742 // Load environment
3743 envLoader.EXPECT().LoadEnv(mock.Anything, "gitea.example.com").Return(nil, nil)
3744 runtime.EXPECT().InspectImageEnv(mock.Anything, "gitea/gitea:latest").Return(nil, nil)
3745
3746 // Create container — ASSERT that labels include propagated image labels
3747 createdContainer := &domain.Container{
3748 ID: "c-gitea-123",
3749 Name: "gordon-gitea.example.com",
3750 Image: "gitea/gitea:latest",
3751 Status: "created",
3752 }
3753 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3754 // Verify gordon.proxy.port was propagated from image labels
3755 if cfg.Labels[domain.LabelProxyPort] != "3000" {
3756 return false
3757 }
3758 // Verify gordon.health was propagated from image labels
3759 if cfg.Labels[domain.LabelHealth] != "/healthz" {
3760 return false
3761 }
3762 // Verify non-gordon labels were NOT propagated
3763 if _, exists := cfg.Labels["some.other.label"]; exists {
3764 return false
3765 }
3766 // Verify standard container labels are still present
3767 if cfg.Labels[domain.LabelManaged] != "true" {
3768 return false
3769 }
3770 return true
3771 })).Return(createdContainer, nil)
3772
3773 runtime.EXPECT().StartContainer(mock.Anything, "c-gitea-123").Return(nil)
3774
3775 // Readiness: delay mode (minimal)
3776 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-gitea-123").Return(true, nil).Times(2)
3777
3778 // Re-inspect
3779 runningContainer := &domain.Container{
3780 ID: "c-gitea-123",
3781 Name: "gordon-gitea.example.com",
3782 Image: "gitea/gitea:latest",
3783 Status: "running",
3784 Ports: []int{22, 3000},
3785 }
3786 runtime.EXPECT().InspectContainer(mock.Anything, "c-gitea-123").Return(runningContainer, nil)
3787
3788 // Publish event
3789 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3790
3791 result, err := svc.Deploy(ctx, route)
3792
3793 require.NoError(t, err)
3794 assert.Equal(t, "c-gitea-123", result.ID)
3795 assert.Equal(t, "running", result.Status)
3796}
3797
3798func TestLoadEnvironment_PreResolvedEnv(t *testing.T) {
3799 ctx := testContext()
3800
3801 mockEnvLoader := mocks.NewMockEnvLoader(t)
3802 mockRuntime := mocks.NewMockContainerRuntime(t)
3803
3804 // EnvLoader should NOT be called when pre-resolved env is provided
3805 mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest").
3806 Return([]string{"FROM_DOCKERFILE=base"}, nil)
3807
3808 svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
3809
3810 preResolved := []string{"DB_HOST=localhost", "API_KEY=secret"}
3811 result, err := svc.loadEnvironment(ctx, preResolved, "ignored-domain", "myapp:latest")
3812 require.NoError(t, err)
3813
3814 assert.Contains(t, result, "DB_HOST=localhost")
3815 assert.Contains(t, result, "API_KEY=secret")
3816 assert.Contains(t, result, "FROM_DOCKERFILE=base")
3817}
3818
3819func TestLoadEnvironment_NoPreResolvedEnv(t *testing.T) {
3820 ctx := testContext()
3821
3822 mockEnvLoader := mocks.NewMockEnvLoader(t)
3823 mockRuntime := mocks.NewMockContainerRuntime(t)
3824
3825 mockEnvLoader.EXPECT().LoadEnv(ctx, "myapp.example.com").
3826 Return([]string{"DB_HOST=prod"}, nil)
3827 mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest").
3828 Return([]string{"FROM_DOCKERFILE=base"}, nil)
3829
3830 svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
3831
3832 result, err := svc.loadEnvironment(ctx, nil, "myapp.example.com", "myapp:latest")
3833 require.NoError(t, err)
3834
3835 assert.Contains(t, result, "DB_HOST=prod")
3836 assert.Contains(t, result, "FROM_DOCKERFILE=base")
3837}