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