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