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