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