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