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