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