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")
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")
1824
1825 require.NoError(t, err)
1826 assert.Equal(t, map[string]string{"/data": stableName}, volumes)
1827}
1828
1829func TestValidateAttachmentOwnership(t *testing.T) {
1830 owned := &domain.Container{Labels: map[string]string{
1831 domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com",
1832 }}
1833 require.NoError(t, validateAttachmentOwnership(owned, "app.example.com", "gordon-app-postgres"))
1834
1835 foreign := &domain.Container{Labels: map[string]string{
1836 domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "other.example.com",
1837 }}
1838 assert.ErrorIs(t, validateAttachmentOwnership(foreign, "app.example.com", "gordon-app-postgres"), domain.ErrAttachmentOwnershipMismatch)
1839}
1840
1841func TestMergeEnvironmentVariables(t *testing.T) {
1842 dockerEnv := []string{"FOO=docker", "BAR=docker"}
1843 userEnv := []string{"FOO=user", "BAZ=user"}
1844
1845 result := mergeEnvironmentVariables(dockerEnv, userEnv)
1846
1847 // User env should override docker env
1848 assert.Contains(t, result, "FOO=user")
1849 assert.Contains(t, result, "BAR=docker")
1850 assert.Contains(t, result, "BAZ=user")
1851}
1852
1853func TestBuildImageRef(t *testing.T) {
1854 tests := []struct {
1855 name string
1856 image string
1857 registryAuthEnabled bool
1858 registryDomain string
1859 wantRef string
1860 }{
1861 {
1862 name: "adds registry domain prefix",
1863 image: "myapp:latest",
1864 registryAuthEnabled: true,
1865 registryDomain: "registry.example.com",
1866 wantRef: "registry.example.com/myapp:latest",
1867 },
1868 {
1869 name: "keeps existing registry domain prefix",
1870 image: "registry.example.com/myapp:latest",
1871 registryAuthEnabled: true,
1872 registryDomain: "registry.example.com",
1873 wantRef: "registry.example.com/myapp:latest",
1874 },
1875 {
1876 name: "skips external registry images",
1877 image: "docker.io/library/nginx:latest",
1878 registryAuthEnabled: true,
1879 registryDomain: "registry.example.com",
1880 wantRef: "docker.io/library/nginx:latest",
1881 },
1882 {
1883 name: "returns original when auth disabled",
1884 image: "myapp:latest",
1885 registryAuthEnabled: false,
1886 registryDomain: "registry.example.com",
1887 wantRef: "myapp:latest",
1888 },
1889 {
1890 name: "localhost domain adds prefix",
1891 image: "myapp:latest",
1892 registryAuthEnabled: true,
1893 registryDomain: "localhost:5000",
1894 wantRef: "localhost:5000/myapp:latest",
1895 },
1896 {
1897 name: "localhost domain keeps existing prefix",
1898 image: "localhost:5000/myapp:latest",
1899 registryAuthEnabled: true,
1900 registryDomain: "localhost:5000",
1901 wantRef: "localhost:5000/myapp:latest",
1902 },
1903 {
1904 name: "explicit host:port different than RegistryDomain",
1905 image: "localhost:5001/myapp:latest",
1906 registryAuthEnabled: true,
1907 registryDomain: "localhost:5000",
1908 wantRef: "localhost:5001/myapp:latest",
1909 },
1910 {
1911 name: "ghcr.io external registry",
1912 image: "ghcr.io/owner/repo:latest",
1913 registryAuthEnabled: true,
1914 registryDomain: "localhost:5000",
1915 wantRef: "ghcr.io/owner/repo:latest",
1916 },
1917 {
1918 name: "gcr.io external registry",
1919 image: "gcr.io/project/image:v1",
1920 registryAuthEnabled: true,
1921 registryDomain: "registry.example.com",
1922 wantRef: "gcr.io/project/image:v1",
1923 },
1924 {
1925 name: "quay.io external registry",
1926 image: "quay.io/org/app:tag",
1927 registryAuthEnabled: true,
1928 registryDomain: "localhost:5000",
1929 wantRef: "quay.io/org/app:tag",
1930 },
1931 {
1932 name: "localhost without port keeps existing prefix",
1933 image: "localhost/myapp:latest",
1934 registryAuthEnabled: true,
1935 registryDomain: "localhost:5000",
1936 wantRef: "localhost/myapp:latest",
1937 },
1938 {
1939 name: "registry domain with trailing slash",
1940 image: "myapp:latest",
1941 registryAuthEnabled: true,
1942 registryDomain: "registry.example.com/",
1943 wantRef: "registry.example.com/myapp:latest",
1944 },
1945 {
1946 name: "ipv6 registry without port",
1947 image: "[fd00::1]/myapp:latest",
1948 registryAuthEnabled: true,
1949 registryDomain: "localhost:5000",
1950 wantRef: "[fd00::1]/myapp:latest",
1951 },
1952 {
1953 name: "ipv6 registry with port",
1954 image: "[fd00::1]:5000/myapp:latest",
1955 registryAuthEnabled: true,
1956 registryDomain: "localhost:5000",
1957 wantRef: "[fd00::1]:5000/myapp:latest",
1958 },
1959 }
1960
1961 for _, tt := range tests {
1962 t.Run(tt.name, func(t *testing.T) {
1963 svc := &Service{
1964 config: Config{
1965 RegistryAuthEnabled: tt.registryAuthEnabled,
1966 RegistryDomain: tt.registryDomain,
1967 },
1968 }
1969 gotRef := svc.buildImageRef(tt.image)
1970 assert.Equal(t, tt.wantRef, gotRef, "unexpected image reference")
1971 })
1972 }
1973}
1974
1975func TestRewriteToRegistryDomain(t *testing.T) {
1976 tests := []struct {
1977 name string
1978 imageRef string
1979 registryDomain string
1980 wantRef string
1981 }{
1982 {
1983 name: "keeps registry domain prefix",
1984 imageRef: "registry.example.com/myapp:latest",
1985 registryDomain: "registry.example.com",
1986 wantRef: "registry.example.com/myapp:latest",
1987 },
1988 {
1989 name: "prefixes registry domain",
1990 imageRef: "myapp:v1.0",
1991 registryDomain: "registry.example.com",
1992 wantRef: "registry.example.com/myapp:v1.0",
1993 },
1994 {
1995 name: "prefixes external image",
1996 imageRef: "docker.io/library/nginx:latest",
1997 registryDomain: "registry.example.com",
1998 wantRef: "registry.example.com/docker.io/library/nginx:latest",
1999 },
2000 {
2001 name: "empty registry domain returns original",
2002 imageRef: "registry.example.com/myapp:latest",
2003 registryDomain: "",
2004 wantRef: "registry.example.com/myapp:latest",
2005 },
2006 }
2007
2008 for _, tt := range tests {
2009 t.Run(tt.name, func(t *testing.T) {
2010 gotRef := rewriteToRegistryDomain(tt.imageRef, tt.registryDomain)
2011 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
2012 })
2013 }
2014}
2015
2016func TestRewriteToLocalRegistry(t *testing.T) {
2017 tests := []struct {
2018 name string
2019 imageRef string
2020 registryDomain string
2021 legacyRegistryDomains []string
2022 registryPort int
2023 wantRef string
2024 }{
2025 {
2026 name: "rewrites registry domain prefix",
2027 imageRef: "registry.example.com/myapp:latest",
2028 registryDomain: "registry.example.com",
2029 registryPort: 5000,
2030 wantRef: "localhost:5000/myapp:latest",
2031 },
2032 {
2033 name: "prefixes local registry when no domain",
2034 imageRef: "myapp:v1.0",
2035 registryDomain: "registry.example.com",
2036 registryPort: 5000,
2037 wantRef: "localhost:5000/myapp:v1.0",
2038 },
2039 {
2040 name: "keeps existing localhost prefix",
2041 imageRef: "localhost:5000/myapp:latest",
2042 registryDomain: "registry.example.com",
2043 registryPort: 5000,
2044 wantRef: "localhost:5000/myapp:latest",
2045 },
2046 {
2047 name: "rewrites legacy registry domain prefix",
2048 imageRef: "old-registry.example.com/myapp:latest",
2049 registryDomain: "new-registry.example.com",
2050 legacyRegistryDomains: []string{"old-registry.example.com"},
2051 registryPort: 5000,
2052 wantRef: "localhost:5000/myapp:latest",
2053 },
2054 {
2055 name: "rewrites legacy registry domain prefix with explicit port and digest",
2056 imageRef: "old-registry.example.com:5001/myapp@sha256:deadbeef",
2057 registryDomain: "new-registry.example.com",
2058 legacyRegistryDomains: []string{"old-registry.example.com:5001"},
2059 registryPort: 5000,
2060 wantRef: "localhost:5000/myapp@sha256:deadbeef",
2061 },
2062 {
2063 name: "external image remains external",
2064 imageRef: "docker.io/library/nginx:latest",
2065 registryDomain: "new-registry.example.com",
2066 legacyRegistryDomains: []string{"old-registry.example.com"},
2067 registryPort: 5000,
2068 wantRef: "docker.io/library/nginx:latest",
2069 },
2070 {
2071 name: "hostile lookalike remains external",
2072 imageRef: "old-registry.example.com.evil/myapp:latest",
2073 registryDomain: "new-registry.example.com",
2074 legacyRegistryDomains: []string{"old-registry.example.com"},
2075 registryPort: 5000,
2076 wantRef: "old-registry.example.com.evil/myapp:latest",
2077 },
2078 }
2079
2080 for _, tt := range tests {
2081 t.Run(tt.name, func(t *testing.T) {
2082 gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.legacyRegistryDomains, tt.registryPort)
2083 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
2084 })
2085 }
2086}
2087
2088func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
2089 runtime := mocks.NewMockContainerRuntime(t)
2090 envLoader := mocks.NewMockEnvLoader(t)
2091 eventBus := mocks.NewMockEventPublisher(t)
2092
2093 config := Config{
2094 AllowedRegistries: []string{"docker.io"},
2095 RegistryAuthEnabled: true,
2096 RegistryDomain: "registry.example.com",
2097 RegistryPort: 5000,
2098 InternalRegistryUsername: "internal",
2099 InternalRegistryPassword: "secret",
2100 ReadinessDelay: time.Millisecond,
2101 DrainDelay: time.Millisecond,
2102 DrainDelayConfigured: true,
2103 }
2104
2105 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2106
2107 // Use internal deploy context (simulating image push event)
2108 ctx := domain.WithInternalDeploy(testContext())
2109
2110 route := domain.Route{
2111 Domain: "test.example.com",
2112 Image: "myapp:latest",
2113 }
2114
2115 // No container in memory — runtime resolution returns nothing
2116 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2117
2118 // Setup mocks - no orphaned containers
2119 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2120
2121 // Even though image exists locally, internal deploy should force pull
2122 // Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check
2123 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
2124
2125 // Tag image to canonical name
2126 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil)
2127 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
2128
2129 // Get exposed ports
2130 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
2131 runtime.EXPECT().GetImageLabels(mock.Anything, "registry.example.com/myapp:latest").Return(nil, nil)
2132
2133 // Load environment
2134 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2135 runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil)
2136
2137 // Create and start container
2138 createdContainer := &domain.Container{
2139 ID: "container-123",
2140 Name: "gordon-test.example.com",
2141 Status: "created",
2142 }
2143 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
2144 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
2145
2146 // Wait for ready
2147 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
2148
2149 // Re-inspect after start
2150 runningContainer := &domain.Container{
2151 ID: "container-123",
2152 Name: "gordon-test.example.com",
2153 Status: "running",
2154 Ports: []int{8080},
2155 }
2156 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
2157
2158 // Publish event
2159 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2160
2161 result, err := svc.Deploy(ctx, route)
2162
2163 assert.NoError(t, err)
2164 assert.NotNil(t, result)
2165 assert.Equal(t, "container-123", result.ID)
2166}
2167
2168func TestService_Deploy_InternalDeployForcesPull_LegacyRegistryHost(t *testing.T) {
2169 runtime := mocks.NewMockContainerRuntime(t)
2170 envLoader := mocks.NewMockEnvLoader(t)
2171 eventBus := mocks.NewMockEventPublisher(t)
2172
2173 config := Config{
2174 AllowedRegistries: []string{"docker.io"},
2175 RegistryAuthEnabled: true,
2176 RegistryDomain: "new-registry.example.com",
2177 LegacyRegistryDomains: []string{"old-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 ctx := domain.WithInternalDeploy(testContext())
2188
2189 route := domain.Route{
2190 Domain: "test.example.com",
2191 Image: "old-registry.example.com/myapp:latest",
2192 }
2193
2194 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2195 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2196 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
2197 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "old-registry.example.com/myapp:latest").Return(nil)
2198 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
2199
2200 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "old-registry.example.com/myapp:latest").Return([]int{8080}, nil)
2201 runtime.EXPECT().GetImageLabels(mock.Anything, "old-registry.example.com/myapp:latest").Return(nil, nil)
2202 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2203 runtime.EXPECT().InspectImageEnv(mock.Anything, "old-registry.example.com/myapp:latest").Return([]string{}, nil)
2204
2205 createdContainer := &domain.Container{
2206 ID: "container-legacy",
2207 Name: "gordon-test.example.com",
2208 Status: "created",
2209 }
2210 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
2211 runtime.EXPECT().StartContainer(mock.Anything, "container-legacy").Return(nil)
2212 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-legacy").Return(true, nil).Times(2)
2213 runtime.EXPECT().InspectContainer(mock.Anything, "container-legacy").Return(&domain.Container{
2214 ID: "container-legacy",
2215 Name: "gordon-test.example.com",
2216 Status: "running",
2217 Ports: []int{8080},
2218 }, nil)
2219 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2220
2221 result, err := svc.Deploy(ctx, route)
2222
2223 assert.NoError(t, err)
2224 assert.NotNil(t, result)
2225 assert.Equal(t, "container-legacy", result.ID)
2226}
2227
2228func TestService_AutoStart_StartsNewContainers(t *testing.T) {
2229 runtime := mocks.NewMockContainerRuntime(t)
2230 envLoader := mocks.NewMockEnvLoader(t)
2231 eventBus := mocks.NewMockEventPublisher(t)
2232
2233 config := Config{
2234 AllowedRegistries: []string{"docker.io"},
2235 ReadinessDelay: time.Millisecond,
2236 DrainDelay: time.Millisecond,
2237 DrainDelayConfigured: true,
2238 }
2239 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2240 ctx := testContext()
2241
2242 routes := []domain.Route{
2243 {Domain: "app1.example.com", Image: "myapp1:latest"},
2244 {Domain: "app2.example.com", Image: "myapp2:latest"},
2245 }
2246
2247 // No container in memory — runtime resolution returns nothing (one per route)
2248 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Times(2)
2249
2250 // Setup mocks for route deployments
2251 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
2252 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
2253 runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
2254 runtime.EXPECT().GetImageLabels(mock.Anything, mock.AnythingOfType("string")).Return(nil, nil).Times(2)
2255 envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
2256 runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
2257
2258 // Create and start containers — readiness is skipped so no IsContainerRunning calls.
2259 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
2260 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
2261 }, nil).Once()
2262 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
2263 ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
2264 }, nil).Once()
2265 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
2266 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
2267 Status: "running",
2268 }, nil).Times(2)
2269 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2)
2270
2271 err := svc.AutoStart(ctx, routes)
2272
2273 assert.NoError(t, err)
2274 assert.Len(t, svc.containers, 2)
2275}
2276
2277func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
2278 runtime := mocks.NewMockContainerRuntime(t)
2279 envLoader := mocks.NewMockEnvLoader(t)
2280 eventBus := mocks.NewMockEventPublisher(t)
2281
2282 config := Config{
2283 AllowedRegistries: []string{"docker.io"},
2284 ReadinessDelay: time.Millisecond,
2285 DrainDelay: time.Millisecond,
2286 DrainDelayConfigured: true,
2287 }
2288 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2289 ctx := testContext()
2290
2291 // Pre-populate with existing container
2292 svc.containers["app1.example.com"] = &domain.Container{
2293 ID: "existing-container",
2294 }
2295
2296 routes := []domain.Route{
2297 {Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists
2298 {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
2299 }
2300
2301 // No container in memory for app2 — runtime resolution returns nothing
2302 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
2303
2304 // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning.
2305 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
2306 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
2307 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
2308 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp2:latest").Return(nil, nil).Once()
2309 envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
2310 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
2311 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
2312 ID: "container-2", Status: "created",
2313 }, nil).Once()
2314 runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
2315 runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
2316 Status: "running",
2317 }, nil).Once()
2318 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
2319
2320 err := svc.AutoStart(ctx, routes)
2321
2322 assert.NoError(t, err)
2323 assert.Len(t, svc.containers, 2)
2324}
2325
2326func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
2327 runtime := mocks.NewMockContainerRuntime(t)
2328 envLoader := mocks.NewMockEnvLoader(t)
2329 eventBus := mocks.NewMockEventPublisher(t)
2330
2331 config := Config{AllowedRegistries: []string{"docker.io"}}
2332 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2333 ctx := testContext()
2334
2335 routes := []domain.Route{
2336 {Domain: "app1.example.com", Image: "myapp1:latest"},
2337 }
2338
2339 // No container in memory — runtime resolution returns nothing
2340 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2341
2342 // Setup mocks for failure
2343 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2344 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2345 runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found"))
2346
2347 err := svc.AutoStart(ctx, routes)
2348
2349 // AutoStart should return error when some deployments fail
2350 assert.Error(t, err)
2351 assert.Contains(t, err.Error(), "auto-start completed with 1 errors")
2352}
2353
2354func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
2355 runtime := mocks.NewMockContainerRuntime(t)
2356 envLoader := mocks.NewMockEnvLoader(t)
2357 eventBus := mocks.NewMockEventPublisher(t)
2358
2359 config := Config{
2360 RegistryDomain: "reg.example.com",
2361 RegistryPort: 5000,
2362 ReadinessDelay: time.Millisecond,
2363 DrainDelay: time.Millisecond,
2364 DrainDelayConfigured: true,
2365 }
2366 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2367
2368 // Mark context as internal deploy — this is what syncAndAutoStart should do
2369 ctx := domain.WithInternalDeploy(testContext())
2370
2371 routes := []domain.Route{
2372 {Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"},
2373 }
2374
2375 // No container in memory — runtime resolution returns nothing
2376 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2377
2378 // Key assertion: PullImage should be called with localhost:5000 rewrite,
2379 // NOT the original reg.example.com/myapp:latest.
2380 // Readiness is skipped — no IsContainerRunning calls.
2381 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2382 runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
2383 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)
2384 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
2385 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "reg.example.com/myapp:latest").Return([]int{8080}, nil)
2386 runtime.EXPECT().GetImageLabels(mock.Anything, "reg.example.com/myapp:latest").Return(nil, nil)
2387 envLoader.EXPECT().LoadEnv(mock.Anything, "app1.example.com").Return([]string{}, nil)
2388 runtime.EXPECT().InspectImageEnv(mock.Anything, "reg.example.com/myapp:latest").Return([]string{}, nil)
2389 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
2390 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
2391 }, nil)
2392 runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil)
2393 runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{
2394 Status: "running",
2395 }, nil)
2396 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2397
2398 err := svc.AutoStart(ctx, routes)
2399 assert.NoError(t, err)
2400}
2401
2402// TestService_Deploy_OrphanCleanupSkipsTrackedContainer verifies that the orphan cleanup
2403// does not remove the currently tracked container during zero-downtime deployment.
2404// This is critical for preventing downtime - the old container must stay running
2405// until the new container is ready and traffic is switched.
2406func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
2407 runtime := mocks.NewMockContainerRuntime(t)
2408 envLoader := mocks.NewMockEnvLoader(t)
2409 eventBus := mocks.NewMockEventPublisher(t)
2410 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2411
2412 config := Config{
2413 AllowedRegistries: []string{"docker.io"},
2414 ReadinessDelay: time.Millisecond, // Minimal delay for tests
2415 DrainDelay: time.Millisecond,
2416 DrainDelayConfigured: true,
2417 StabilizationDelay: time.Millisecond,
2418 }
2419 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2420 svc.SetProxyCacheInvalidator(cacheInvalidator)
2421 ctx := testContext()
2422
2423 // Pre-populate with existing tracked container
2424 existingContainer := &domain.Container{
2425 ID: "tracked-container-123",
2426 Name: "gordon-test.example.com",
2427 Status: "running",
2428 }
2429 svc.containers["test.example.com"] = existingContainer
2430
2431 route := domain.Route{
2432 Domain: "test.example.com",
2433 Image: "myapp:v2",
2434 }
2435
2436 // ListContainers returns the tracked container - this simulates the orphan check
2437 // finding a container with the same name. The bug was that it would remove this
2438 // container BEFORE the new one was ready, causing downtime.
2439 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2440 {
2441 ID: "tracked-container-123",
2442 Name: "gordon-test.example.com",
2443 Status: "running",
2444 },
2445 }, nil)
2446
2447 // Image operations
2448 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2449 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2450 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2451 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2452
2453 // Environment
2454 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2455 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2456
2457 // Create new container with -new suffix for zero-downtime
2458 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2459 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2460 return cfg.Name == "gordon-test.example.com-new"
2461 })).Return(newContainer, nil)
2462 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2463
2464 // Wait for ready (2 calls) + stabilization check (1 call)
2465 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2466
2467 // Inspect after ready
2468 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2469 ID: "new-container",
2470 Status: "running",
2471 }, nil)
2472
2473 // Publish event
2474 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2475
2476 // Synchronous cache invalidation
2477 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2478
2479 // NOW (after new container is ready) the old container should be stopped and removed
2480 // This is the correct zero-downtime sequence - not during orphan cleanup
2481 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
2482 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
2483
2484 // Rename new container to canonical name
2485 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2486
2487 result, err := svc.Deploy(ctx, route)
2488
2489 assert.NoError(t, err)
2490 svc.WaitForCleanup()
2491 assert.Equal(t, "new-container", result.ID)
2492
2493 // Verify new container is now tracked
2494 tracked, exists := svc.Get(ctx, "test.example.com")
2495 assert.True(t, exists)
2496 assert.Equal(t, "new-container", tracked.ID)
2497}
2498
2499// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that stopped canonical
2500// containers with the same name but NOT tracked are properly removed as orphans.
2501// Running canonical containers are preserved (see NeverKillsRunningCanonical test).
2502func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
2503 runtime := mocks.NewMockContainerRuntime(t)
2504 envLoader := mocks.NewMockEnvLoader(t)
2505 eventBus := mocks.NewMockEventPublisher(t)
2506
2507 config := Config{
2508 AllowedRegistries: []string{"docker.io"},
2509 ReadinessDelay: time.Millisecond, // Minimal delay for tests
2510 DrainDelay: time.Millisecond,
2511 DrainDelayConfigured: true,
2512 }
2513 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2514 ctx := testContext()
2515
2516 // NO tracked container - service is empty for this domain
2517
2518 route := domain.Route{
2519 Domain: "test.example.com",
2520 Image: "myapp:v1",
2521 }
2522
2523 // No container in memory — runtime resolution finds no managed container
2524 // (the orphan is stopped so it won't appear in running-only query)
2525 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2526
2527 // ListContainers(true) returns a STOPPED orphaned container (same name, but not tracked)
2528 // Stopped canonical containers are safe to remove during orphan cleanup
2529 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2530 {
2531 ID: "orphan-container",
2532 Name: "gordon-test.example.com",
2533 Status: "exited",
2534 },
2535 }, nil)
2536
2537 // Stopped orphan should be stopped and removed BEFORE we proceed
2538 runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
2539 runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
2540
2541 // Image operations
2542 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2543 runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
2544 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
2545 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v1").Return(nil, nil)
2546
2547 // Environment
2548 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2549 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v1").Return([]string{}, nil)
2550
2551 // Create container (no -new suffix since no existing tracked container)
2552 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
2553 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2554 return cfg.Name == "gordon-test.example.com"
2555 })).Return(newContainer, nil)
2556 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2557
2558 // Wait for ready
2559 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2560
2561 // Inspect after ready
2562 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2563 ID: "new-container",
2564 Status: "running",
2565 Ports: []int{8080},
2566 }, nil)
2567
2568 // Publish event
2569 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2570
2571 result, err := svc.Deploy(ctx, route)
2572
2573 assert.NoError(t, err)
2574 assert.Equal(t, "new-container", result.ID)
2575
2576 // Verify new container is tracked
2577 tracked, exists := svc.Get(ctx, "test.example.com")
2578 assert.True(t, exists)
2579 assert.Equal(t, "new-container", tracked.ID)
2580}
2581
2582func TestService_Deploy_OrphanCleanupRemovesRunningStaleCandidate(t *testing.T) {
2583 runtime := mocks.NewMockContainerRuntime(t)
2584 envLoader := mocks.NewMockEnvLoader(t)
2585 eventBus := mocks.NewMockEventPublisher(t)
2586 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2587
2588 config := testMinDelayConfig()
2589 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2590 svc.SetProxyCacheInvalidator(cacheInvalidator)
2591 ctx := testContext()
2592
2593 // The tracked canonical container is active. A candidate left running by an
2594 // interrupted request must be removed before its temporary name is reused.
2595 svc.containers["test.example.com"] = &domain.Container{
2596 ID: "tracked-container-123",
2597 Name: "gordon-test.example.com",
2598 Status: "running",
2599 }
2600
2601 route := domain.Route{
2602 Domain: "test.example.com",
2603 Image: "myapp:v2",
2604 }
2605
2606 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2607 {
2608 ID: "tracked-container-123",
2609 Name: "gordon-test.example.com",
2610 Status: "running",
2611 },
2612 {
2613 ID: "stale-new-container",
2614 Name: "gordon-test.example.com-new",
2615 Status: "running",
2616 },
2617 }, nil)
2618 runtime.EXPECT().StopContainer(mock.Anything, "stale-new-container").Return(nil).Once()
2619 runtime.EXPECT().RemoveContainer(mock.Anything, "stale-new-container", true).Return(nil).Once()
2620
2621 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2622 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2623 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2624 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2625 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2626 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2627
2628 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2629 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2630 return cfg.Name == "gordon-test.example.com-new"
2631 })).Return(newContainer, nil)
2632 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2633 // Wait for ready (2 calls) + stabilization check (1 call)
2634 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2635 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2636 ID: "new-container",
2637 Status: "running",
2638 }, nil)
2639 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2640
2641 // Synchronous cache invalidation
2642 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2643
2644 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
2645 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
2646 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2647
2648 result, err := svc.Deploy(ctx, route)
2649 assert.NoError(t, err)
2650 svc.WaitForCleanup()
2651 assert.NotNil(t, result)
2652 assert.Equal(t, "new-container", result.ID)
2653}
2654
2655func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) {
2656 runtime := mocks.NewMockContainerRuntime(t)
2657 envLoader := mocks.NewMockEnvLoader(t)
2658 eventBus := mocks.NewMockEventPublisher(t)
2659 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2660
2661 config := testMinDelayConfig()
2662 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2663 svc.SetProxyCacheInvalidator(cacheInvalidator)
2664 ctx := testContext()
2665
2666 // Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name.
2667 svc.containers["test.example.com"] = &domain.Container{
2668 ID: "tracked-temp-container",
2669 Name: "gordon-test.example.com-new",
2670 Status: "running",
2671 }
2672
2673 route := domain.Route{
2674 Domain: "test.example.com",
2675 Image: "myapp:v2",
2676 }
2677
2678 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2679 {
2680 ID: "tracked-temp-container",
2681 Name: "gordon-test.example.com-new",
2682 Status: "running",
2683 },
2684 }, nil)
2685
2686 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2687 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2688 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2689 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2690 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2691 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2692
2693 created := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-next", Status: "created"}
2694 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2695 return cfg.Name == "gordon-test.example.com-next"
2696 })).Return(created, nil)
2697 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2698 // Wait for ready (2 calls) + stabilization check (1 call)
2699 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2700 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2701 ID: "new-container",
2702 Status: "running",
2703 }, nil)
2704 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2705
2706 // Synchronous cache invalidation
2707 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2708
2709 runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil)
2710 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil)
2711 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2712
2713 result, err := svc.Deploy(ctx, route)
2714 assert.NoError(t, err)
2715 svc.WaitForCleanup()
2716 assert.NotNil(t, result)
2717 assert.Equal(t, "new-container", result.ID)
2718}
2719
2720func TestService_StripRegistryPrefix(t *testing.T) {
2721 tests := []struct {
2722 name string
2723 registryDomain string
2724 legacyRegistryDomains []string
2725 image string
2726 expected string
2727 }{
2728 {
2729 name: "strips registry prefix",
2730 registryDomain: "reg.example.com",
2731 image: "reg.example.com/myapp:latest",
2732 expected: "myapp:latest",
2733 },
2734 {
2735 name: "strips registry prefix with trailing slash in domain",
2736 registryDomain: "reg.example.com/",
2737 image: "reg.example.com/myapp:v1.0",
2738 expected: "myapp:v1.0",
2739 },
2740 {
2741 name: "strips legacy registry prefix",
2742 registryDomain: "new-reg.example.com",
2743 legacyRegistryDomains: []string{"old-reg.example.com"},
2744 image: "old-reg.example.com/myapp:latest",
2745 expected: "myapp:latest",
2746 },
2747 {
2748 name: "preserves image without registry prefix",
2749 registryDomain: "reg.example.com",
2750 image: "nginx:latest",
2751 expected: "nginx:latest",
2752 },
2753 {
2754 name: "preserves image with different registry",
2755 registryDomain: "reg.example.com",
2756 image: "gcr.io/project/image:latest",
2757 expected: "gcr.io/project/image:latest",
2758 },
2759 {
2760 name: "handles empty registry domain",
2761 registryDomain: "",
2762 image: "reg.example.com/myapp:latest",
2763 expected: "reg.example.com/myapp:latest",
2764 },
2765 {
2766 name: "handles nested paths",
2767 registryDomain: "reg.example.com",
2768 image: "reg.example.com/org/repo/app:latest",
2769 expected: "org/repo/app:latest",
2770 },
2771 }
2772
2773 for _, tt := range tests {
2774 t.Run(tt.name, func(t *testing.T) {
2775 runtime := mocks.NewMockContainerRuntime(t)
2776 envLoader := mocks.NewMockEnvLoader(t)
2777 eventBus := mocks.NewMockEventPublisher(t)
2778
2779 config := Config{
2780 RegistryDomain: tt.registryDomain,
2781 LegacyRegistryDomains: tt.legacyRegistryDomains,
2782 }
2783 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2784
2785 result := svc.stripRegistryPrefix(tt.image)
2786 assert.Equal(t, tt.expected, result)
2787 })
2788 }
2789}
2790
2791func TestSanitizeName(t *testing.T) {
2792 tests := []struct {
2793 domain string
2794 expected string
2795 description string
2796 }{
2797 {
2798 domain: "git.example.com",
2799 expected: "git__example__com",
2800 description: "Dots become double underscores",
2801 },
2802 {
2803 domain: "git-example.com",
2804 expected: "git-example__com",
2805 description: "Hyphens preserved, dots become underscores",
2806 },
2807 {
2808 domain: "app:8080.example.com",
2809 expected: "app-_8080__example__com",
2810 description: "Colons become hyphen-underscore",
2811 },
2812 {
2813 domain: "git.example.com:3000",
2814 expected: "git__example__com-_3000",
2815 description: "Multiple separators handled distinctly",
2816 },
2817 {
2818 domain: "simple.com",
2819 expected: "simple__com",
2820 description: "Simple domain",
2821 },
2822 }
2823
2824 for _, tt := range tests {
2825 t.Run(tt.domain, func(t *testing.T) {
2826 result := sanitizeName(tt.domain)
2827 assert.Equal(t, tt.expected, result, "sanitization should match expected")
2828 })
2829 }
2830
2831 // Verify no collisions between potentially conflicting domains
2832 t.Run("NoCollisions", func(t *testing.T) {
2833 domains := []string{
2834 "git.example.com",
2835 "git-example.com",
2836 "app:8080.example.com",
2837 "app-8080-example.com",
2838 }
2839
2840 results := make(map[string]string)
2841 for _, d := range domains {
2842 result := sanitizeName(d)
2843 if original, exists := results[result]; exists {
2844 t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
2845 }
2846 results[result] = d
2847 }
2848 })
2849}
2850
2851func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
2852 // Verify that concurrent Deploy calls for the same domain are serialized
2853 // and both succeed without container name conflicts.
2854 runtime := mocks.NewMockContainerRuntime(t)
2855 envLoader := mocks.NewMockEnvLoader(t)
2856 eventBus := mocks.NewMockEventPublisher(t)
2857 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2858
2859 config := testMinDelayConfig()
2860 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2861 svc.SetProxyCacheInvalidator(cacheInvalidator)
2862 ctx := testContext()
2863
2864 route := domain.Route{
2865 Domain: "test.example.com",
2866 Image: "myapp:latest",
2867 }
2868
2869 // Track call order to verify serialization
2870 var callOrder []string
2871 var callMu sync.Mutex
2872
2873 // Setup mocks that will be called by both deploys sequentially.
2874 // First deploy: no existing container → creates gordon-test.example.com
2875 // Second deploy: sees first container → creates gordon-test.example.com-new
2876
2877 // First deploy resolves from runtime (no container in memory yet)
2878 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
2879
2880 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
2881 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
2882 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
2883 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
2884 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, nil).Times(2)
2885 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
2886
2887 createCall := 0
2888 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
2889 RunAndReturn(func(_ context.Context, cfg *domain.ContainerConfig) (*domain.Container, error) {
2890 callMu.Lock()
2891 createCall++
2892 n := createCall
2893 callOrder = append(callOrder, fmt.Sprintf("create-%d:%s", n, cfg.Name))
2894 callMu.Unlock()
2895 return &domain.Container{
2896 ID: fmt.Sprintf("container-%d", n),
2897 Name: cfg.Name,
2898 Image: cfg.Image,
2899 Status: "created",
2900 }, nil
2901 })
2902
2903 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
2904 // IsContainerRunning: 2x per deploy in waitForReady + 1x stabilization for the second deploy (which has existing)
2905 runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(5)
2906
2907 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
2908 RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
2909 // Return correct container name based on ID
2910 // container-1 is the first deploy (canonical name)
2911 // container-2 is the second deploy (with -new suffix)
2912 name := "gordon-test.example.com"
2913 if id == "container-2" {
2914 name = "gordon-test.example.com-new"
2915 }
2916 return &domain.Container{
2917 ID: id,
2918 Name: name,
2919 Image: "myapp:latest",
2920 Status: "running",
2921 Ports: []int{8080},
2922 }, nil
2923 })
2924
2925 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)
2926
2927 // Both deploys call InvalidateTarget (to ensure proxy picks up the new container).
2928 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
2929
2930 // which means it will also stop+remove+rename the old container.
2931 runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
2932 runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
2933 runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Once()
2934
2935 // Launch two concurrent deploys
2936 var wg sync.WaitGroup
2937 errs := make([]error, 2)
2938
2939 wg.Add(2)
2940 for i := range 2 {
2941 go func(idx int) {
2942 defer wg.Done()
2943 _, errs[idx] = svc.Deploy(ctx, route)
2944 }(i)
2945 }
2946 wg.Wait()
2947 svc.WaitForCleanup()
2948
2949 // Both should succeed (second deploy waits for the first to finish)
2950 assert.NoError(t, errs[0], "first deploy should succeed")
2951 assert.NoError(t, errs[1], "second deploy should succeed")
2952
2953 // Verify creates were serialized (not interleaved)
2954 callMu.Lock()
2955 assert.Len(t, callOrder, 2, "should have exactly 2 create calls")
2956 // First deploy uses canonical name, second uses -new suffix (zero-downtime)
2957 assert.Equal(t, "create-1:gordon-test.example.com", callOrder[0], "first create should use canonical name")
2958 assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
2959 callMu.Unlock()
2960}
2961
2962func TestService_Deploy_ContextCancellation(t *testing.T) {
2963 // Verify that deploy lock acquisition respects context cancellation.
2964 runtime := mocks.NewMockContainerRuntime(t)
2965 envLoader := mocks.NewMockEnvLoader(t)
2966 eventBus := mocks.NewMockEventPublisher(t)
2967
2968 config := Config{
2969 AllowedRegistries: []string{"docker.io"},
2970 ReadinessDelay: time.Millisecond,
2971 DrainDelay: time.Millisecond,
2972 DrainDelayConfigured: true,
2973 }
2974 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2975
2976 route := domain.Route{
2977 Domain: "test.example.com",
2978 Image: "myapp:latest",
2979 }
2980
2981 // Test context cancelled before lock acquisition.
2982 cancelledCtx, cancel := context.WithCancel(testContext())
2983 cancel() // Cancel immediately
2984
2985 _, err := svc.Deploy(cancelledCtx, route)
2986 assert.Error(t, err, "deploy should fail with cancelled context")
2987 assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
2988}
2989
2990// TestService_Deploy_CacheInvalidationBeforeOldContainerStop verifies the fix for
2991// the proxy cache race condition: InvalidateTarget must be called synchronously
2992// BEFORE the old container is stopped, preventing 503 errors during deployment.
2993func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
2994 runtime := mocks.NewMockContainerRuntime(t)
2995 envLoader := mocks.NewMockEnvLoader(t)
2996 eventBus := mocks.NewMockEventPublisher(t)
2997 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2998
2999 config := testMinDelayConfig()
3000 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3001 svc.SetProxyCacheInvalidator(cacheInvalidator)
3002 ctx := testContext()
3003
3004 // Pre-populate with existing container
3005 existingContainer := &domain.Container{
3006 ID: "old-container",
3007 Name: "gordon-test.example.com",
3008 Status: "running",
3009 }
3010 svc.containers["test.example.com"] = existingContainer
3011
3012 route := domain.Route{
3013 Domain: "test.example.com",
3014 Image: "myapp:v2",
3015 }
3016
3017 // Track ordering of cache invalidation and container stop
3018 var callOrder []string
3019 var orderMu sync.Mutex
3020
3021 // Standard deploy mocks
3022 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3023 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
3024 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3025 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3026 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3027 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3028
3029 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3030 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
3031 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3032 // Wait for ready (2 calls) + stabilization check (1 call)
3033 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3034 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3035 ID: "new-container", Status: "running",
3036 }, nil)
3037 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3038
3039 // Track: cache invalidation should happen first
3040 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").
3041 Run(func(_ context.Context, _ string) {
3042 orderMu.Lock()
3043 callOrder = append(callOrder, "invalidate_cache")
3044 orderMu.Unlock()
3045 }).Return()
3046
3047 // Track: stop should happen after invalidation
3048 runtime.EXPECT().StopContainer(mock.Anything, "old-container").
3049 RunAndReturn(func(_ context.Context, _ string) error {
3050 orderMu.Lock()
3051 callOrder = append(callOrder, "stop_old_container")
3052 orderMu.Unlock()
3053 return nil
3054 })
3055 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
3056 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3057
3058 result, err := svc.Deploy(ctx, route)
3059
3060 assert.NoError(t, err)
3061 svc.WaitForCleanup()
3062 assert.Equal(t, "new-container", result.ID)
3063
3064 // Verify ordering: cache invalidation MUST happen before old container stop
3065 orderMu.Lock()
3066 defer orderMu.Unlock()
3067 assert.Equal(t, []string{"invalidate_cache", "stop_old_container"}, callOrder,
3068 "InvalidateTarget must be called before StopContainer to prevent 503 errors")
3069}
3070
3071// TestService_Deploy_NilCacheInvalidator verifies that deploy works gracefully
3072// when no cache invalidator is set (e.g., in tests or minimal configurations).
3073func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
3074 runtime := mocks.NewMockContainerRuntime(t)
3075 envLoader := mocks.NewMockEnvLoader(t)
3076 eventBus := mocks.NewMockEventPublisher(t)
3077
3078 config := testMinDelayConfig()
3079 // Intentionally NOT setting cache invalidator
3080 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3081 ctx := testContext()
3082
3083 // Pre-populate with existing container
3084 svc.containers["test.example.com"] = &domain.Container{
3085 ID: "old-container",
3086 Name: "gordon-test.example.com",
3087 Status: "running",
3088 }
3089
3090 route := domain.Route{
3091 Domain: "test.example.com",
3092 Image: "myapp:v2",
3093 }
3094
3095 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3096 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
3097 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3098 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3099 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3100 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3101
3102 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3103 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
3104 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3105 // Wait for ready (2 calls) + stabilization check (1 call)
3106 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3107 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3108 ID: "new-container", Status: "running",
3109 }, nil)
3110 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3111 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
3112 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
3113 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3114
3115 result, err := svc.Deploy(ctx, route)
3116
3117 assert.NoError(t, err)
3118 svc.WaitForCleanup()
3119 assert.Equal(t, "new-container", result.ID)
3120}
3121
3122func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
3123 runtime := mocks.NewMockContainerRuntime(t)
3124 envLoader := mocks.NewMockEnvLoader(t)
3125 eventBus := mocks.NewMockEventPublisher(t)
3126
3127 config := Config{
3128 AllowedRegistries: []string{"docker.io"},
3129 ReadinessDelay: time.Millisecond,
3130 DrainDelay: time.Millisecond,
3131 DrainDelayConfigured: true,
3132 }
3133 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3134 ctx := testContext()
3135 expectedEnvHash := hashEnvironment([]string{})
3136
3137 // Pre-populate with existing container that has an ImageID (set by InspectContainer).
3138 // This simulates the first deploy (from image.pushed event) having already completed.
3139 existingContainer := &domain.Container{
3140 ID: "existing-container",
3141 Name: "gordon-test.example.com",
3142 Image: "myapp:latest",
3143 ImageID: "sha256:abc123",
3144 Status: "running",
3145 Labels: map[string]string{
3146 domain.LabelEnvHash: expectedEnvHash,
3147 },
3148 }
3149 svc.containers["test.example.com"] = existingContainer
3150
3151 route := domain.Route{
3152 Domain: "test.example.com",
3153 Image: "myapp:latest",
3154 }
3155
3156 // prepareDeployResources will run: orphan cleanup, image pull, etc.
3157 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3158 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3159 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3160 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3161 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3162 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3163
3164 // skipRedundantDeploy: resolve image ID and compare with existing container
3165 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
3166 runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
3167
3168 // No CreateContainer, StartContainer, or readiness calls should happen
3169
3170 result, err := svc.Deploy(ctx, route)
3171
3172 assert.NoError(t, err)
3173 assert.NotNil(t, result)
3174 assert.Equal(t, "existing-container", result.ID)
3175
3176 // Container should still be tracked
3177 tracked, exists := svc.Get(ctx, "test.example.com")
3178 assert.True(t, exists)
3179 assert.Equal(t, "existing-container", tracked.ID)
3180}
3181
3182func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testing.T) {
3183 runtime := mocks.NewMockContainerRuntime(t)
3184 envLoader := mocks.NewMockEnvLoader(t)
3185 eventBus := mocks.NewMockEventPublisher(t)
3186
3187 config := Config{
3188 AllowedRegistries: []string{"docker.io"},
3189 ReadinessDelay: time.Millisecond,
3190 DrainDelay: time.Millisecond,
3191 DrainDelayConfigured: true,
3192 }
3193 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3194 ctx := testContext()
3195 expectedEnvHash := hashEnvironment([]string{})
3196
3197 // Pre-populate with existing container missing ImageID (common after stale in-memory state).
3198 existingContainer := &domain.Container{
3199 ID: "existing-container",
3200 Name: "gordon-test.example.com",
3201 Image: "myapp:latest",
3202 Status: "running",
3203 Labels: map[string]string{
3204 domain.LabelEnvHash: expectedEnvHash,
3205 },
3206 }
3207 svc.containers["test.example.com"] = existingContainer
3208
3209 route := domain.Route{
3210 Domain: "test.example.com",
3211 Image: "myapp:latest",
3212 }
3213
3214 // prepareDeployResources
3215 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3216 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3217 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3218 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3219 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3220 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3221
3222 // Existing container image ID is recovered from runtime inspection,
3223 // then redundancy check can skip the deploy.
3224 runtime.EXPECT().InspectContainer(mock.Anything, "existing-container").Return(&domain.Container{
3225 ID: "existing-container",
3226 ImageID: "sha256:abc123",
3227 Status: "running",
3228 }, nil)
3229 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
3230 runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
3231
3232 result, err := svc.Deploy(ctx, route)
3233
3234 assert.NoError(t, err)
3235 assert.NotNil(t, result)
3236 assert.Equal(t, "existing-container", result.ID)
3237
3238 tracked, exists := svc.Get(ctx, "test.example.com")
3239 assert.True(t, exists)
3240 assert.Equal(t, "existing-container", tracked.ID)
3241}
3242
3243func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
3244 runtime := mocks.NewMockContainerRuntime(t)
3245 envLoader := mocks.NewMockEnvLoader(t)
3246 eventBus := mocks.NewMockEventPublisher(t)
3247 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3248
3249 config := testMinDelayConfig()
3250 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3251 svc.SetProxyCacheInvalidator(cacheInvalidator)
3252 ctx := testContext()
3253
3254 // Existing container with a DIFFERENT image ID than what's being deployed
3255 existingContainer := &domain.Container{
3256 ID: "old-container",
3257 Name: "gordon-test.example.com",
3258 Image: "myapp:latest",
3259 ImageID: "sha256:old-image",
3260 Status: "running",
3261 }
3262 svc.containers["test.example.com"] = existingContainer
3263
3264 route := domain.Route{
3265 Domain: "test.example.com",
3266 Image: "myapp:latest",
3267 }
3268
3269 // prepareDeployResources
3270 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3271 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3272 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3273 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3274 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3275 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3276
3277 // skipRedundantDeploy: image IDs differ, so deploy proceeds
3278 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:new-image", nil)
3279
3280 // Full deploy proceeds
3281 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3282 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
3283 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3284 // Wait for ready (2 calls) + stabilization check (1 call)
3285 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3286 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3287 ID: "new-container", Status: "running",
3288 }, nil)
3289 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3290 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
3291 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
3292 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
3293 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3294
3295 result, err := svc.Deploy(ctx, route)
3296
3297 assert.NoError(t, err)
3298 svc.WaitForCleanup()
3299 assert.Equal(t, "new-container", result.ID)
3300}
3301
3302func TestSyncContainersRebuildsAttachmentMap(t *testing.T) {
3303 runtime := mocks.NewMockContainerRuntime(t)
3304 envLoader := mocks.NewMockEnvLoader(t)
3305 eventBus := mocks.NewMockEventPublisher(t)
3306
3307 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
3308 ctx := testContext()
3309
3310 // ListContainers returns one main container and one attachment container
3311 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
3312 {
3313 ID: "main-container-1",
3314 Labels: map[string]string{
3315 domain.LabelDomain: "app.example.com",
3316 domain.LabelManaged: "true",
3317 },
3318 },
3319 {
3320 ID: "attachment-container-1",
3321 Labels: map[string]string{
3322 domain.LabelManaged: "true",
3323 domain.LabelAttachment: "true",
3324 domain.LabelAttachedTo: "app.example.com",
3325 },
3326 },
3327 }, nil).Once()
3328
3329 require.NoError(t, svc.SyncContainers(ctx))
3330
3331 // Main container should be in s.containers
3332 tracked, exists := svc.Get(ctx, "app.example.com")
3333 require.True(t, exists, "main container should be tracked after SyncContainers")
3334 assert.Equal(t, "main-container-1", tracked.ID)
3335
3336 // Attachment container should be in s.attachments under the owner domain
3337 svc.mu.RLock()
3338 attachIDs := svc.attachments["app.example.com"]
3339 svc.mu.RUnlock()
3340
3341 require.Len(t, attachIDs, 1, "attachment map should contain one entry for app.example.com after SyncContainers")
3342 assert.Equal(t, "attachment-container-1", attachIDs[0])
3343}
3344
3345func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
3346 runtime := mocks.NewMockContainerRuntime(t)
3347 envLoader := mocks.NewMockEnvLoader(t)
3348 eventBus := mocks.NewMockEventPublisher(t)
3349
3350 config := testMinDelayConfig()
3351 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3352 ctx := testContext()
3353 expectedEnvHash := hashEnvironment([]string{})
3354
3355 // Existing container has same image ID but is NOT running (crashed)
3356 existingContainer := &domain.Container{
3357 ID: "existing-container",
3358 Name: "gordon-test.example.com",
3359 Image: "myapp:latest",
3360 ImageID: "sha256:abc123",
3361 Status: "exited",
3362 Labels: map[string]string{
3363 domain.LabelEnvHash: expectedEnvHash,
3364 },
3365 }
3366 svc.containers["test.example.com"] = existingContainer
3367
3368 route := domain.Route{
3369 Domain: "test.example.com",
3370 Image: "myapp:latest",
3371 }
3372
3373 // prepareDeployResources
3374 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3375 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3376 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3377 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3378 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3379 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3380
3381 // skipRedundantDeploy: same image ID but container not running => proceed with deploy
3382 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
3383 runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(false, nil)
3384
3385 // Full deploy proceeds (replaces crashed container)
3386 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3387 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
3388 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3389 // Wait for ready (2 calls) + stabilization check (1 call)
3390 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3391 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3392 ID: "new-container", Status: "running",
3393 }, nil)
3394 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3395
3396 // Old (exited) container is still finalized
3397 runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
3398 runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
3399 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3400
3401 result, err := svc.Deploy(ctx, route)
3402
3403 assert.NoError(t, err)
3404 svc.WaitForCleanup()
3405 assert.Equal(t, "new-container", result.ID)
3406}
3407
3408func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
3409 runtime := mocks.NewMockContainerRuntime(t)
3410 envLoader := mocks.NewMockEnvLoader(t)
3411 eventBus := mocks.NewMockEventPublisher(t)
3412
3413 config := testMinDelayConfig()
3414 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3415 ctx := testContext()
3416 expectedEnvHash := hashEnvironment([]string{})
3417
3418 existingContainer := &domain.Container{
3419 ID: "existing-container",
3420 Name: "gordon-test.example.com",
3421 Image: "myapp:latest",
3422 ImageID: "sha256:abc123",
3423 Status: "running",
3424 Labels: map[string]string{
3425 domain.LabelEnvHash: "different-hash",
3426 },
3427 }
3428 svc.containers["test.example.com"] = existingContainer
3429
3430 route := domain.Route{
3431 Domain: "test.example.com",
3432 Image: "myapp:latest",
3433 }
3434
3435 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3436 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3437 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3438 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3439 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3440 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3441
3442 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
3443 runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
3444
3445 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3446 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3447 return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash
3448 })).Return(newContainer, nil)
3449 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3450 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3451 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3452 ID: "new-container", Status: "running",
3453 }, nil)
3454 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3455 runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
3456 runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
3457 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3458
3459 result, err := svc.Deploy(ctx, route)
3460
3461 assert.NoError(t, err)
3462 svc.WaitForCleanup()
3463 assert.Equal(t, "new-container", result.ID)
3464}
3465
3466func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) {
3467 runtime := mocks.NewMockContainerRuntime(t)
3468 envLoader := mocks.NewMockEnvLoader(t)
3469 eventBus := mocks.NewMockEventPublisher(t)
3470
3471 config := testMinDelayConfig()
3472 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3473 ctx := testContext()
3474 expectedEnvHash := hashEnvironment([]string{})
3475
3476 existingContainer := &domain.Container{
3477 ID: "existing-container",
3478 Name: "gordon-test.example.com",
3479 Image: "myapp:latest",
3480 ImageID: "sha256:abc123",
3481 Status: "running",
3482 Labels: map[string]string{},
3483 }
3484 svc.containers["test.example.com"] = existingContainer
3485
3486 route := domain.Route{
3487 Domain: "test.example.com",
3488 Image: "myapp:latest",
3489 }
3490
3491 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3492 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3493 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3494 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3495 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3496 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3497
3498 runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
3499 runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
3500
3501 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3502 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3503 return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash
3504 })).Return(newContainer, nil)
3505 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3506 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3507 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3508 ID: "new-container", Status: "running",
3509 }, nil)
3510 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3511 runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
3512 runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
3513 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3514
3515 result, err := svc.Deploy(ctx, route)
3516
3517 assert.NoError(t, err)
3518 svc.WaitForCleanup()
3519 assert.Equal(t, "new-container", result.ID)
3520}
3521
3522func TestService_attachmentEnvDrifted_ReturnsFalseWhenHashMatches(t *testing.T) {
3523 runtime := mocks.NewMockContainerRuntime(t)
3524 envLoader := mocks.NewMockEnvLoader(t)
3525 eventBus := mocks.NewMockEventPublisher(t)
3526
3527 svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
3528 ctx := testContext()
3529 imageRef := svc.buildImageRef("postgres:16")
3530 envVars := []string{"POSTGRES_PASSWORD=secret"}
3531 envHash := hashEnvironment(envVars)
3532
3533 envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil)
3534 runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil)
3535
3536 drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{
3537 Labels: map[string]string{domain.LabelEnvHash: envHash},
3538 }, "gordon-app-example-com-postgres", "postgres:16")
3539
3540 assert.NoError(t, err)
3541 assert.False(t, drifted)
3542}
3543
3544func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashDiffers(t *testing.T) {
3545 runtime := mocks.NewMockContainerRuntime(t)
3546 envLoader := mocks.NewMockEnvLoader(t)
3547 eventBus := mocks.NewMockEventPublisher(t)
3548
3549 svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
3550 ctx := testContext()
3551 imageRef := svc.buildImageRef("postgres:16")
3552 envVars := []string{"POSTGRES_PASSWORD=secret"}
3553
3554 envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil)
3555 runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil)
3556
3557 drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{
3558 Labels: map[string]string{domain.LabelEnvHash: "stale-hash"},
3559 }, "gordon-app-example-com-postgres", "postgres:16")
3560
3561 assert.NoError(t, err)
3562 assert.True(t, drifted)
3563}
3564
3565func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashMissing(t *testing.T) {
3566 runtime := mocks.NewMockContainerRuntime(t)
3567 envLoader := mocks.NewMockEnvLoader(t)
3568 eventBus := mocks.NewMockEventPublisher(t)
3569
3570 svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
3571 ctx := testContext()
3572 imageRef := svc.buildImageRef("postgres:16")
3573 envVars := []string{"POSTGRES_PASSWORD=secret"}
3574
3575 envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil)
3576 runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil)
3577
3578 drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{
3579 Labels: map[string]string{},
3580 }, "gordon-app-example-com-postgres", "postgres:16")
3581
3582 assert.NoError(t, err)
3583 assert.True(t, drifted)
3584}
3585
3586// TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan
3587// cleanup never stops a running container with the canonical name. Only stopped
3588// canonical containers and temp containers (-new/-next) should be cleaned up.
3589// The active canonical container is resolved via resolveExistingContainer (it has the
3590// managed label), so the new container is created with the "-new" suffix — Docker
3591// disallows creating a container with the same name as an existing running container.
3592func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
3593 runtime := mocks.NewMockContainerRuntime(t)
3594 envLoader := mocks.NewMockEnvLoader(t)
3595 eventBus := mocks.NewMockEventPublisher(t)
3596
3597 config := testMinDelayConfig()
3598 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3599 ctx := testContext()
3600
3601 // The canonical container is running and has the managed label — it will be
3602 // discovered by resolveExistingContainer's slow path (no in-memory state).
3603 existingContainer := &domain.Container{
3604 ID: "active-canonical",
3605 Name: "gordon-test.example.com",
3606 Status: "running",
3607 Labels: map[string]string{domain.LabelManaged: "true"},
3608 }
3609
3610 route := domain.Route{
3611 Domain: "test.example.com",
3612 Image: "myapp:v2",
3613 }
3614
3615 // resolveExistingContainer: slow path finds the running canonical container via label.
3616 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{existingContainer}, nil)
3617
3618 // cleanupOrphanedContainers: finds the running canonical AND a stale -new leftover.
3619 // The canonical container must NOT be killed because it's running.
3620 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
3621 existingContainer,
3622 {
3623 ID: "stale-leftover",
3624 Name: "gordon-test.example.com-new",
3625 Status: "exited",
3626 },
3627 }, nil)
3628
3629 // Only the stale -new container should be stopped and removed during orphan cleanup.
3630 runtime.EXPECT().StopContainer(mock.Anything, "stale-leftover").Return(nil).Once()
3631 runtime.EXPECT().RemoveContainer(mock.Anything, "stale-leftover", true).Return(nil).Once()
3632
3633 // Image operations: pull new image (existing.Image is empty so redundancy check is skipped)
3634 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3635 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3636 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3637 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3638
3639 // Environment
3640 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3641 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3642
3643 // Create container — existing is the canonical container, so the new one gets "-new" suffix.
3644 // Docker enforces unique container names; using "-new" avoids a name collision.
3645 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3646 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3647 return cfg.Name == "gordon-test.example.com-new"
3648 })).Return(newContainer, nil)
3649 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3650
3651 // Wait for ready (delay mode): pollContainerRunning (1×) + waitForReadyByDelay (1×)
3652 // Post-switch stabilization: 1× — total 3 calls.
3653 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3654
3655 // Inspect after ready
3656 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3657 ID: "new-container",
3658 Status: "running",
3659 Ports: []int{8080},
3660 }, nil)
3661
3662 // Rename new container to canonical name after stabilization.
3663 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil).Once()
3664
3665 // Stop and remove old canonical container.
3666 runtime.EXPECT().StopContainer(mock.Anything, "active-canonical").Return(nil).Once()
3667 runtime.EXPECT().RemoveContainer(mock.Anything, "active-canonical", true).Return(nil).Once()
3668
3669 // Publish event
3670 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3671
3672 result, err := svc.Deploy(ctx, route)
3673
3674 assert.NoError(t, err)
3675 svc.WaitForCleanup()
3676 assert.Equal(t, "new-container", result.ID)
3677
3678 // Verify new container is tracked
3679 tracked, exists := svc.Get(ctx, "test.example.com")
3680 assert.True(t, exists)
3681 assert.Equal(t, "new-container", tracked.ID)
3682}
3683
3684func TestService_CleanupOrphanedContainers_SkipsRestartingContainer(t *testing.T) {
3685 runtime := mocks.NewMockContainerRuntime(t)
3686 envLoader := mocks.NewMockEnvLoader(t)
3687 eventBus := mocks.NewMockEventPublisher(t)
3688
3689 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
3690 ctx := testContext()
3691
3692 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
3693 {
3694 ID: "restarting-container",
3695 Name: "gordon-test.example.com",
3696 Status: "restarting",
3697 },
3698 }, nil)
3699
3700 err := svc.cleanupOrphanedContainers(ctx, "test.example.com", "")
3701
3702 assert.NoError(t, err)
3703}
3704
3705// TestService_Deploy_RollbackOnPostSwitchCrash verifies that if the new container crashes
3706// during the post-switch stabilization window, traffic is automatically rolled back to the
3707// old container: the old container is restored as tracked, the proxy cache is re-invalidated,
3708// and the failed new container is cleaned up.
3709func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
3710 runtime := mocks.NewMockContainerRuntime(t)
3711 envLoader := mocks.NewMockEnvLoader(t)
3712 eventBus := mocks.NewMockEventPublisher(t)
3713 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3714
3715 config := Config{
3716 AllowedRegistries: []string{"docker.io"},
3717 ReadinessDelay: time.Millisecond,
3718 DrainDelay: time.Millisecond,
3719 DrainDelayConfigured: true,
3720 StabilizationDelay: time.Millisecond,
3721 }
3722 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3723 svc.SetProxyCacheInvalidator(cacheInvalidator)
3724 ctx := testContext()
3725
3726 // Pre-populate with existing container
3727 existing := &domain.Container{
3728 ID: "old-container",
3729 Name: "gordon-test.example.com",
3730 Status: "running",
3731 }
3732 svc.containers["test.example.com"] = existing
3733
3734 route := domain.Route{
3735 Domain: "test.example.com",
3736 Image: "myapp:v2",
3737 }
3738
3739 // Cleanup orphans
3740 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3741
3742 // Image operations
3743 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3744 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3745 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3746 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3747
3748 // Environment
3749 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3750 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3751
3752 // Create new container
3753 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3754 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3755 return cfg.Name == "gordon-test.example.com-new"
3756 })).Return(newContainer, nil)
3757 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3758
3759 // Readiness: new container is running during readiness checks (2 calls)
3760 // Stabilization: new container has crashed (returns false)
3761 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
3762 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()
3763
3764 // Rollback: verify old container is still running before restoring
3765 runtime.EXPECT().IsContainerRunning(mock.Anything, "old-container").Return(true, nil).Once()
3766
3767 // Inspect after ready
3768 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3769 ID: "new-container",
3770 Name: "gordon-test.example.com-new",
3771 Status: "running",
3772 }, nil)
3773
3774 // Publish event (happens during activateDeployedContainer)
3775 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3776
3777 // InvalidateTarget called TWICE: once during activate, once during rollback
3778 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
3779
3780 // Cleanup failed new container during rollback
3781 runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
3782 runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
3783
3784 // StopContainer on old-container should NOT be called (old stays running)
3785 // (no expectation for StopContainer("old-container"))
3786
3787 result, err := svc.Deploy(ctx, route)
3788
3789 // Deploy returns the old (existing) container after rollback
3790 assert.NoError(t, err)
3791 assert.NotNil(t, result)
3792 assert.Equal(t, "old-container", result.ID, "should return old container after rollback")
3793
3794 // Verify old container is restored as tracked
3795 trackedRB, existsRB := svc.Get(ctx, "test.example.com")
3796 assert.True(t, existsRB)
3797 assert.Equal(t, "old-container", trackedRB.ID, "old container should be restored after rollback")
3798}
3799
3800// TestService_Deploy_StabilizationSuccess verifies that when the new container remains
3801// healthy during the stabilization window, deployment proceeds normally and the old
3802// container is finalized (stopped and removed).
3803func TestService_Deploy_StabilizationSuccess(t *testing.T) {
3804 runtime := mocks.NewMockContainerRuntime(t)
3805 envLoader := mocks.NewMockEnvLoader(t)
3806 eventBus := mocks.NewMockEventPublisher(t)
3807 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3808
3809 config := Config{
3810 AllowedRegistries: []string{"docker.io"},
3811 ReadinessDelay: time.Millisecond,
3812 DrainDelay: time.Millisecond,
3813 DrainDelayConfigured: true,
3814 StabilizationDelay: time.Millisecond,
3815 }
3816 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3817 svc.SetProxyCacheInvalidator(cacheInvalidator)
3818 ctx := testContext()
3819
3820 // Pre-populate with existing container
3821 existing := &domain.Container{
3822 ID: "old-container",
3823 Name: "gordon-test.example.com",
3824 Status: "running",
3825 }
3826 svc.containers["test.example.com"] = existing
3827
3828 route := domain.Route{
3829 Domain: "test.example.com",
3830 Image: "myapp:v2",
3831 }
3832
3833 // Cleanup orphans
3834 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3835
3836 // Image operations
3837 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3838 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3839 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3840 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3841
3842 // Environment
3843 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3844 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3845
3846 // Create new container
3847 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3848 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3849 return cfg.Name == "gordon-test.example.com-new"
3850 })).Return(newContainer, nil)
3851 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3852
3853 // Readiness (2 calls) + stabilization check (1 call) — all return running
3854 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3855
3856 // Inspect after ready
3857 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3858 ID: "new-container",
3859 Name: "gordon-test.example.com-new",
3860 Status: "running",
3861 }, nil)
3862
3863 // Publish event
3864 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3865
3866 // Cache invalidation (once, during activate)
3867 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
3868
3869 // Old container finalized normally
3870 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
3871 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
3872 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3873
3874 result, err := svc.Deploy(ctx, route)
3875
3876 assert.NoError(t, err)
3877 svc.WaitForCleanup()
3878 assert.NotNil(t, result)
3879 assert.Equal(t, "new-container", result.ID, "should return new container on successful stabilization")
3880
3881 // Verify new container is tracked
3882 trackedSS, existsSS := svc.Get(ctx, "test.example.com")
3883 assert.True(t, existsSS)
3884 assert.Equal(t, "new-container", trackedSS.ID)
3885}
3886
3887// TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady verifies the strict
3888// ordering invariant: StopContainer("old-container") must NEVER happen before
3889// InspectContainer("new-container") completes (the last step of readiness).
3890// Uses testify's NotBefore() to enforce mock call ordering.
3891func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) {
3892 runtime := mocks.NewMockContainerRuntime(t)
3893 envLoader := mocks.NewMockEnvLoader(t)
3894 eventBus := mocks.NewMockEventPublisher(t)
3895 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3896
3897 config := Config{
3898 AllowedRegistries: []string{"docker.io"},
3899 ReadinessDelay: time.Millisecond,
3900 DrainDelay: time.Millisecond,
3901 DrainDelayConfigured: true,
3902 StabilizationDelay: time.Millisecond,
3903 }
3904 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3905 svc.SetProxyCacheInvalidator(cacheInvalidator)
3906 ctx := testContext()
3907
3908 // Pre-populate with existing tracked container
3909 existingContainer := &domain.Container{
3910 ID: "old-container",
3911 Name: "gordon-test.example.com",
3912 Status: "running",
3913 }
3914 svc.containers["test.example.com"] = existingContainer
3915
3916 route := domain.Route{
3917 Domain: "test.example.com",
3918 Image: "myapp:v2",
3919 }
3920
3921 // Cleanup orphans
3922 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3923
3924 // Image operations
3925 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3926 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3927 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3928 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3929
3930 // Environment
3931 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3932 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3933
3934 // Create new container with -new suffix for zero-downtime
3935 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3936 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3937 return cfg.Name == "gordon-test.example.com-new"
3938 })).Return(newContainer, nil)
3939 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3940
3941 // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call)
3942 // Stabilization check (1 call)
3943 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3944
3945 // KEY ORDERING CONSTRAINT: InspectContainer("new-container") is the last step
3946 // of readiness inside createStartedContainer. StopContainer("old-container")
3947 // must happen AFTER this call completes.
3948 inspectCall := runtime.EXPECT().InspectContainer(mock.Anything, "new-container").
3949 Return(&domain.Container{
3950 ID: "new-container",
3951 Name: "gordon-test.example.com-new",
3952 Status: "running",
3953 }, nil)
3954
3955 // Publish event
3956 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3957
3958 // Synchronous cache invalidation
3959 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
3960
3961 // STRICT ORDERING: StopContainer on old container must NOT happen before
3962 // InspectContainer on new container (readiness completion)
3963 runtime.EXPECT().StopContainer(mock.Anything, "old-container").
3964 Return(nil).
3965 NotBefore(inspectCall.Call)
3966
3967 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
3968 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3969
3970 result, err := svc.Deploy(ctx, route)
3971
3972 assert.NoError(t, err)
3973 svc.WaitForCleanup()
3974 assert.NotNil(t, result)
3975 assert.Equal(t, "new-container", result.ID)
3976
3977 // Verify new container is tracked
3978 tracked, exists := svc.Get(ctx, "test.example.com")
3979 assert.True(t, exists)
3980 assert.Equal(t, "new-container", tracked.ID)
3981}
3982
3983// TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched verifies that when a
3984// new container fails readiness (IsContainerRunning returns false), the old container
3985// is completely untouched: never stopped, never removed. Deploy returns an error and
3986// the old container remains as the tracked container.
3987func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) {
3988 runtime := mocks.NewMockContainerRuntime(t)
3989 envLoader := mocks.NewMockEnvLoader(t)
3990 eventBus := mocks.NewMockEventPublisher(t)
3991
3992 config := Config{
3993 AllowedRegistries: []string{"docker.io"},
3994 ReadinessDelay: time.Millisecond,
3995 DrainDelay: time.Millisecond,
3996 DrainDelayConfigured: true,
3997 StabilizationDelay: time.Millisecond,
3998 }
3999 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4000 ctx := testContext()
4001
4002 // Pre-populate with existing tracked container
4003 existingContainer := &domain.Container{
4004 ID: "old-container",
4005 Name: "gordon-test.example.com",
4006 Status: "running",
4007 }
4008 svc.containers["test.example.com"] = existingContainer
4009
4010 route := domain.Route{
4011 Domain: "test.example.com",
4012 Image: "myapp:v2",
4013 }
4014
4015 // Cleanup orphans
4016 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
4017
4018 // Image operations
4019 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
4020 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
4021 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
4022 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
4023
4024 // Environment
4025 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
4026 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
4027
4028 // Create new container
4029 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
4030 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
4031 return cfg.Name == "gordon-test.example.com-new"
4032 })).Return(newContainer, nil)
4033 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
4034
4035 // Readiness FAILS: pollContainerRunning succeeds (container starts) but
4036 // waitForReadyByDelay verification finds it not running.
4037 // pollContainerRunning: returns true (container initially starts)
4038 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
4039 // waitForReadyByDelay post-delay verification: returns false (container crashed)
4040 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()
4041 // Recovery window poll: return error to fail fast instead of waiting 30s
4042 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").
4043 Return(false, errors.New("container exited")).Once()
4044 runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, "crash line\n"), nil)
4045
4046 // cleanupFailedContainer: stop and remove the failed new container
4047 runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
4048 runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
4049
4050 // NOTE: StopContainer("old-container") and RemoveContainer("old-container", true)
4051 // are intentionally NOT mocked. If either is called, testify will panic with
4052 // "unexpected method call" — verifying the old container is never touched.
4053
4054 result, err := svc.Deploy(ctx, route)
4055
4056 // Deploy should return an error (readiness failure)
4057 assert.Error(t, err)
4058 assert.Nil(t, result)
4059 var deployErr *domain.DeployFailureError
4060 require.ErrorAs(t, err, &deployErr)
4061 assert.Equal(t, "failed to deploy", deployErr.Error())
4062 assert.Equal(t, "container failed readiness check", deployErr.Cause)
4063 assert.Equal(t, []string{"crash line"}, deployErr.Logs)
4064 assert.Equal(t, "gordon-test.example.com-new", deployErr.ContainerName)
4065 assert.Equal(t, "new-container", deployErr.ContainerID)
4066 assert.ErrorContains(t, deployErr.Err, "container exited")
4067
4068 // Old container must still be tracked — completely untouched
4069 tracked, exists := svc.Get(ctx, "test.example.com")
4070 assert.True(t, exists, "old container should still be tracked after readiness failure")
4071 assert.Equal(t, "old-container", tracked.ID, "old container ID should be unchanged")
4072}
4073
4074// TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy is a sanity check:
4075// first deploy with no existing container should work normally without any
4076// finalize/drain logic being triggered.
4077func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
4078 runtime := mocks.NewMockContainerRuntime(t)
4079 envLoader := mocks.NewMockEnvLoader(t)
4080 eventBus := mocks.NewMockEventPublisher(t)
4081
4082 config := Config{
4083 AllowedRegistries: []string{"docker.io"},
4084 ReadinessDelay: time.Millisecond,
4085 DrainDelay: time.Millisecond,
4086 DrainDelayConfigured: true,
4087 StabilizationDelay: time.Millisecond,
4088 }
4089 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4090 ctx := testContext()
4091
4092 // NO existing container — this is a first deploy
4093
4094 route := domain.Route{
4095 Domain: "test.example.com",
4096 Image: "myapp:v2",
4097 }
4098
4099 // resolveExistingContainer: no container in memory, runtime returns nothing
4100 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
4101
4102 // Cleanup orphans — nothing found
4103 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
4104
4105 // Image operations
4106 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
4107 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
4108 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
4109 runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
4110
4111 // Environment
4112 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
4113 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
4114
4115 // Create container — canonical name (no -new suffix since no existing container)
4116 newContainer := &domain.Container{ID: "first-container", Name: "gordon-test.example.com", Status: "created"}
4117 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
4118 return cfg.Name == "gordon-test.example.com"
4119 })).Return(newContainer, nil)
4120 runtime.EXPECT().StartContainer(mock.Anything, "first-container").Return(nil)
4121
4122 // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call)
4123 // No stabilization check for first deploy (hasExisting is false)
4124 runtime.EXPECT().IsContainerRunning(mock.Anything, "first-container").Return(true, nil).Times(2)
4125
4126 // Inspect after readiness
4127 runtime.EXPECT().InspectContainer(mock.Anything, "first-container").Return(&domain.Container{
4128 ID: "first-container",
4129 Name: "gordon-test.example.com",
4130 Status: "running",
4131 Ports: []int{8080},
4132 }, nil)
4133
4134 // Publish event
4135 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
4136
4137 // NOTE: No StopContainer, RemoveContainer, or RenameContainer expectations —
4138 // first deploy has no previous container to finalize, and no stabilization check.
4139
4140 result, err := svc.Deploy(ctx, route)
4141
4142 assert.NoError(t, err)
4143 assert.NotNil(t, result)
4144 assert.Equal(t, "first-container", result.ID)
4145 assert.Equal(t, "running", result.Status)
4146
4147 // Verify container is tracked
4148 tracked, exists := svc.Get(ctx, "test.example.com")
4149 assert.True(t, exists)
4150 assert.Equal(t, "first-container", tracked.ID)
4151}
4152
4153func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
4154 runtime := mocks.NewMockContainerRuntime(t)
4155 envLoader := mocks.NewMockEnvLoader(t)
4156 eventBus := mocks.NewMockEventPublisher(t)
4157
4158 config := Config{
4159 AllowedRegistries: []string{"docker.io"},
4160 NetworkIsolation: false,
4161 VolumeAutoCreate: false,
4162 ReadinessDelay: time.Millisecond,
4163 DrainDelay: time.Millisecond,
4164 DrainDelayConfigured: true,
4165 }
4166
4167 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4168 ctx := testContext()
4169
4170 route := domain.Route{
4171 Domain: "gitea.example.com",
4172 Image: "gitea/gitea:latest",
4173 }
4174
4175 // No container in memory — runtime resolution returns nothing
4176 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
4177 // No orphaned containers
4178 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
4179
4180 // Image is found locally
4181 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"gitea/gitea:latest"}, nil)
4182
4183 // Exposed ports — image exposes both SSH and HTTP
4184 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "gitea/gitea:latest").Return([]int{22, 3000}, nil)
4185
4186 // Image labels include gordon.proxy.port and gordon.health
4187 runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
4188 domain.LabelProxyPort: "3000",
4189 domain.LabelHealth: "/healthz",
4190 "some.other.label": "ignored",
4191 }, nil)
4192
4193 // Load environment
4194 envLoader.EXPECT().LoadEnv(mock.Anything, "gitea.example.com").Return(nil, nil)
4195 runtime.EXPECT().InspectImageEnv(mock.Anything, "gitea/gitea:latest").Return(nil, nil)
4196
4197 // Create container — ASSERT that labels include propagated image labels
4198 createdContainer := &domain.Container{
4199 ID: "c-gitea-123",
4200 Name: "gordon-gitea.example.com",
4201 Image: "gitea/gitea:latest",
4202 Status: "created",
4203 }
4204 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
4205 // Verify gordon.proxy.port was propagated from image labels
4206 if cfg.Labels[domain.LabelProxyPort] != "3000" {
4207 return false
4208 }
4209 // Verify gordon.health was propagated from image labels
4210 if cfg.Labels[domain.LabelHealth] != "/healthz" {
4211 return false
4212 }
4213 // Verify non-gordon labels were NOT propagated
4214 if _, exists := cfg.Labels["some.other.label"]; exists {
4215 return false
4216 }
4217 // Verify standard container labels are still present
4218 if cfg.Labels[domain.LabelManaged] != "true" {
4219 return false
4220 }
4221 return true
4222 })).Return(createdContainer, nil)
4223
4224 runtime.EXPECT().StartContainer(mock.Anything, "c-gitea-123").Return(nil)
4225
4226 // Readiness: delay mode (minimal)
4227 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-gitea-123").Return(true, nil).Times(2)
4228
4229 // Re-inspect
4230 runningContainer := &domain.Container{
4231 ID: "c-gitea-123",
4232 Name: "gordon-gitea.example.com",
4233 Image: "gitea/gitea:latest",
4234 Status: "running",
4235 Ports: []int{22, 3000},
4236 }
4237 runtime.EXPECT().InspectContainer(mock.Anything, "c-gitea-123").Return(runningContainer, nil)
4238
4239 // Publish event
4240 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
4241
4242 result, err := svc.Deploy(ctx, route)
4243
4244 require.NoError(t, err)
4245 assert.Equal(t, "c-gitea-123", result.ID)
4246 assert.Equal(t, "running", result.Status)
4247}
4248
4249func TestLoadEnvironment_PreResolvedEnv(t *testing.T) {
4250 ctx := testContext()
4251
4252 mockEnvLoader := mocks.NewMockEnvLoader(t)
4253 mockRuntime := mocks.NewMockContainerRuntime(t)
4254
4255 // EnvLoader should NOT be called when pre-resolved env is provided
4256 mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest").
4257 Return([]string{"FROM_DOCKERFILE=base"}, nil)
4258
4259 svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4260
4261 preResolved := []string{"DB_HOST=localhost", "API_KEY=secret"}
4262 result, err := svc.loadEnvironment(ctx, preResolved, "ignored-domain", "myapp:latest")
4263 require.NoError(t, err)
4264
4265 assert.Contains(t, result, "DB_HOST=localhost")
4266 assert.Contains(t, result, "API_KEY=secret")
4267 assert.Contains(t, result, "FROM_DOCKERFILE=base")
4268}
4269
4270func TestLoadEnvironment_NoPreResolvedEnv(t *testing.T) {
4271 ctx := testContext()
4272
4273 mockEnvLoader := mocks.NewMockEnvLoader(t)
4274 mockRuntime := mocks.NewMockContainerRuntime(t)
4275
4276 mockEnvLoader.EXPECT().LoadEnv(ctx, "myapp.example.com").
4277 Return([]string{"DB_HOST=prod"}, nil)
4278 mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest").
4279 Return([]string{"FROM_DOCKERFILE=base"}, nil)
4280
4281 svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4282
4283 result, err := svc.loadEnvironment(ctx, nil, "myapp.example.com", "myapp:latest")
4284 require.NoError(t, err)
4285
4286 assert.Contains(t, result, "DB_HOST=prod")
4287 assert.Contains(t, result, "FROM_DOCKERFILE=base")
4288}
4289
4290func TestService_WaitForAttachmentReady_TCPProbe(t *testing.T) {
4291 // Start a real TCP listener
4292 ln, err := net.Listen("tcp", "127.0.0.1:0")
4293 require.NoError(t, err)
4294 defer ln.Close()
4295 go func() {
4296 for {
4297 conn, err := ln.Accept()
4298 if err != nil {
4299 return
4300 }
4301 conn.Close()
4302 }
4303 }()
4304 addr := ln.Addr().(*net.TCPAddr)
4305
4306 runtime := mocks.NewMockContainerRuntime(t)
4307 svc := NewService(runtime, nil, nil, nil, Config{
4308 AttachmentReadinessTimeout: 5 * time.Second,
4309 }, nil)
4310
4311 ctx := testContext()
4312 containerID := "attachment-1"
4313 containerConfig := &domain.ContainerConfig{
4314 Ports: []int{5432},
4315 }
4316
4317 // pollContainerRunning
4318 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
4319 // No healthcheck
4320 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
4321 // Resolve endpoint to our real listener
4322 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(addr.IP.String(), addr.Port, nil).Once()
4323 runtime.EXPECT().GetContainerPort(mock.Anything, containerID, addr.Port).Return(addr.Port, nil).Once()
4324
4325 err = svc.waitForAttachmentReady(ctx, containerID, containerConfig)
4326 assert.NoError(t, err)
4327}
4328
4329func TestService_WaitForAttachmentReady_Timeout(t *testing.T) {
4330 // Reserve a port with no listener
4331 ln, err := net.Listen("tcp", "127.0.0.1:0")
4332 require.NoError(t, err)
4333 addr := ln.Addr().(*net.TCPAddr)
4334 ln.Close() // Close so nothing listens
4335
4336 runtime := mocks.NewMockContainerRuntime(t)
4337 svc := NewService(runtime, nil, nil, nil, Config{
4338 AttachmentReadinessTimeout: 300 * time.Millisecond,
4339 }, nil)
4340
4341 ctx := testContext()
4342 containerID := "attachment-1"
4343 containerConfig := &domain.ContainerConfig{
4344 Ports: []int{5432},
4345 }
4346
4347 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
4348 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
4349 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(addr.IP.String(), addr.Port, nil).Once()
4350 runtime.EXPECT().GetContainerPort(mock.Anything, containerID, addr.Port).Return(addr.Port, nil).Once()
4351
4352 err = svc.waitForAttachmentReady(ctx, containerID, containerConfig)
4353 assert.Error(t, err)
4354 assert.Contains(t, err.Error(), "TCP probe timeout")
4355}
4356
4357func TestService_WaitForAttachmentReady_FallsBackToDelay(t *testing.T) {
4358 runtime := mocks.NewMockContainerRuntime(t)
4359 svc := NewService(runtime, nil, nil, nil, Config{
4360 ReadinessDelay: time.Millisecond,
4361 }, nil)
4362
4363 ctx := testContext()
4364 containerID := "attachment-1"
4365 // No ports → skip TCP probe → fall back to delay
4366 containerConfig := &domain.ContainerConfig{}
4367
4368 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
4369 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
4370 // Delay fallback checks IsContainerRunning again
4371 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
4372
4373 err := svc.waitForAttachmentReady(ctx, containerID, containerConfig)
4374 assert.NoError(t, err)
4375}
4376
4377func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
4378 runtime := mocks.NewMockContainerRuntime(t)
4379 envLoader := mocks.NewMockEnvLoader(t)
4380 eventBus := mocks.NewMockEventPublisher(t)
4381
4382 svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
4383 ctx := testContext()
4384
4385 ownerDomain := "app.example.com"
4386 serviceImage := "postgres:16"
4387 networkName := "gordon-net"
4388 containerName := fmt.Sprintf("gordon-%s-postgres", domain.SanitizeDomainForContainer(ownerDomain))
4389
4390 // resolveExistingAttachment: findContainerByName for new name, then legacy name — both miss
4391 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
4392
4393 // ensureImage: pullRefForDeploy returns imageRef unchanged (no registry configured);
4394 // ensureLocalImage checks ListImages and finds it locally.
4395 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"postgres:16"}, nil)
4396
4397 // GetImageExposedPorts
4398 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "postgres:16").Return([]int{5432}, nil)
4399
4400 // loadEnvironment
4401 envLoader.EXPECT().LoadEnv(mock.Anything, containerName).Return([]string{}, nil)
4402 runtime.EXPECT().InspectImageEnv(mock.Anything, "postgres:16").Return([]string{}, nil)
4403
4404 // setupVolumes: VolumeAutoCreate is false (default), so no volume calls needed
4405
4406 // CreateContainer — capture the config and verify Aliases
4407 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
4408 Run(func(_ context.Context, cfg *domain.ContainerConfig) {
4409 assert.Equal(t, []string{"postgres"}, cfg.Aliases, "attachment container must have network aliases for DNS resolution")
4410 assert.Equal(t, "postgres", cfg.Hostname)
4411 assert.Equal(t, networkName, cfg.NetworkMode)
4412 assert.Equal(t, domain.RestartPolicyAlways, cfg.RestartPolicy)
4413 }).
4414 Return(&domain.Container{ID: "pg-container-1", Name: containerName}, nil)
4415
4416 // StartContainer
4417 runtime.EXPECT().StartContainer(mock.Anything, "pg-container-1").Return(nil)
4418
4419 // waitForAttachmentReady: pollContainerRunning
4420 runtime.EXPECT().IsContainerRunning(mock.Anything, "pg-container-1").Return(true, nil).Times(2)
4421 // No Docker healthcheck
4422 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "pg-container-1").Return("", false, nil)
4423 // TCP probe: resolve endpoint fails -> falls back to delay
4424 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, "pg-container-1").Return("", 0, errors.New("no network"))
4425
4426 err := svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName)
4427 require.NoError(t, err)
4428
4429 // Verify the attachment is tracked
4430 svc.mu.RLock()
4431 attachIDs := svc.attachments[ownerDomain]
4432 svc.mu.RUnlock()
4433 require.Len(t, attachIDs, 1)
4434 assert.Equal(t, "pg-container-1", attachIDs[0])
4435}
4436
4437func TestService_BuildValidatedImageRef_RegistryPolicy(t *testing.T) {
4438 svc := NewService(nil, nil, nil, nil, Config{
4439 RegistryAuthEnabled: true,
4440 RegistryDomain: "registry.example.com",
4441 AllowedRegistries: []string{"docker.io"},
4442 }, nil)
4443
4444 ref, err := svc.buildValidatedImageRef(context.Background(), "myapp:latest")
4445 require.NoError(t, err)
4446 assert.Equal(t, "registry.example.com/myapp:latest", ref)
4447
4448 ref, err = svc.buildValidatedImageRef(context.Background(), "registry.example.com/myapp:latest")
4449 require.NoError(t, err)
4450 assert.Equal(t, "registry.example.com/myapp:latest", ref)
4451
4452 ref, err = svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx:latest")
4453 require.NoError(t, err)
4454 assert.Equal(t, "docker.io/library/nginx:latest", ref)
4455
4456 for _, image := range []string{
4457 "ghcr.io/acme/app:latest",
4458 "localhost:5000/app:latest",
4459 "127.0.0.1:5000/app:latest",
4460 "10.0.0.2:5000/app:latest",
4461 "169.254.169.254/app:latest",
4462 } {
4463 t.Run(image, func(t *testing.T) {
4464 _, err := svc.buildValidatedImageRef(context.Background(), image)
4465 require.Error(t, err)
4466 })
4467 }
4468}
4469
4470func TestService_BuildValidatedImageRef_RejectsExternalRegistryWhenAllowlistEmpty(t *testing.T) {
4471 svc := NewService(nil, nil, nil, nil, Config{}, nil)
4472
4473 _, err := svc.buildValidatedImageRef(context.Background(), "ghcr.io/acme/app:latest")
4474 require.Error(t, err)
4475 assert.Contains(t, err.Error(), "images.allowed_registries")
4476
4477 err = svc.validateImagePullRef(context.Background(), "nginx:latest")
4478 require.Error(t, err)
4479 assert.Contains(t, err.Error(), "docker.io")
4480}
4481
4482func TestService_BuildValidatedImageRef_RequireDigestOnlyForExternalAllowedRegistries(t *testing.T) {
4483 svc := NewService(nil, nil, nil, nil, Config{
4484 RegistryAuthEnabled: true,
4485 RegistryDomain: "registry.example.com",
4486 RequireImageDigest: true,
4487 AllowedRegistries: []string{"docker.io"},
4488 }, nil)
4489
4490 _, err := svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx:latest")
4491 require.Error(t, err)
4492
4493 err = svc.validateImagePullRef(context.Background(), "nginx:latest")
4494 require.Error(t, err)
4495
4496 _, err = svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
4497 require.NoError(t, err)
4498
4499 _, err = svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx@sha256:not-a-digest")
4500 require.Error(t, err)
4501
4502 ref, err := svc.buildValidatedImageRef(context.Background(), "myapp:latest")
4503 require.NoError(t, err)
4504 assert.Equal(t, "registry.example.com/myapp:latest", ref)
4505
4506 _, err = svc.buildValidatedImageRef(context.Background(), "registry.example.com/myapp:latest")
4507 require.NoError(t, err)
4508}
4509
4510func TestService_BuildValidatedImageRef_AllowedRegistryMatchesPort(t *testing.T) {
4511 svc := NewService(nil, nil, nil, nil, Config{AllowedRegistries: []string{"docker.io:5000"}}, nil)
4512
4513 _, err := svc.buildValidatedImageRef(context.Background(), "docker.io:5000/app:latest")
4514 require.NoError(t, err)
4515
4516 _, err = svc.buildValidatedImageRef(context.Background(), "docker.io:5001/app:latest")
4517 require.Error(t, err)
4518}
4519
4520func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testing.T) {
4521 runtime := mocks.NewMockContainerRuntime(t)
4522 svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil)
4523
4524 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil)
4525 runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{
4526 Driver: "bridge",
4527 Internal: true,
4528 Labels: map[string]string{domain.LabelManaged: "true"},
4529 }).Return(nil)
4530
4531 require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app"))
4532}
4533
4534func TestService_BuildContainerConfig_StrictSecurityProfile(t *testing.T) {
4535 svc := NewService(nil, nil, nil, nil, Config{SecurityProfile: "strict"}, nil)
4536
4537 cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4538
4539 assert.True(t, cfg.ReadOnlyRootFS)
4540 assert.Equal(t, []string{"ALL"}, cfg.CapDrop)
4541 assert.Equal(t, []string{"NET_BIND_SERVICE"}, cfg.CapAdd)
4542}
4543
4544func TestService_BuildContainerConfig_CompatSecurityProfileDefault(t *testing.T) {
4545 svc := NewService(nil, nil, nil, nil, Config{}, nil)
4546
4547 cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4548
4549 assert.False(t, cfg.ReadOnlyRootFS)
4550 assert.Nil(t, cfg.CapDrop)
4551 assert.Nil(t, cfg.CapAdd)
4552}
4553
4554func TestService_BuildContainerConfig_SetsRestartPolicyAlways(t *testing.T) {
4555 svc := NewService(nil, nil, nil, nil, Config{}, nil)
4556
4557 cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4558
4559 assert.Equal(t, domain.RestartPolicyAlways, cfg.RestartPolicy)
4560}
4561
4562func TestService_ReconcileRemovedRoute_RemovesOnlyMainRouteContainers(t *testing.T) {
4563 ctx := testContext()
4564 runtime := mocks.NewMockContainerRuntime(t)
4565 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4566
4567 mainContainer := &domain.Container{
4568 ID: "route-container",
4569 Name: "gordon-app.example.com",
4570 Status: string(domain.ContainerStatusRunning),
4571 Labels: map[string]string{
4572 domain.LabelManaged: "true",
4573 domain.LabelRoute: "app.example.com",
4574 domain.LabelDomain: "app.example.com",
4575 },
4576 }
4577 attachment := &domain.Container{
4578 ID: "attachment-container",
4579 Name: "gordon-app-example-com-postgres",
4580 Status: string(domain.ContainerStatusRunning),
4581 Labels: map[string]string{
4582 domain.LabelManaged: "true",
4583 domain.LabelAttachment: "true",
4584 domain.LabelAttachedTo: "app.example.com",
4585 },
4586 }
4587
4588 svc.mu.Lock()
4589 svc.containers["app.example.com"] = mainContainer
4590 svc.attachments["app.example.com"] = []string{attachment.ID}
4591 svc.managedCount = 1
4592 svc.mu.Unlock()
4593
4594 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer, attachment}, nil).Once()
4595 runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once()
4596 runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once()
4597
4598 report, err := svc.ReconcileRemovedRoute(ctx, "app.example.com")
4599 require.NoError(t, err)
4600 require.NotNil(t, report)
4601
4602 assert.Equal(t, "app.example.com", report.Domain)
4603 require.Len(t, report.RemovedContainers, 1)
4604 assert.Equal(t, mainContainer.ID, report.RemovedContainers[0].ID)
4605 require.Len(t, report.PreservedAttachments, 1)
4606 assert.Equal(t, attachment.ID, report.PreservedAttachments[0].ContainerID)
4607 assert.Contains(t, report.Hints, "attachments preserved; remove or purge attachments explicitly if they are no longer needed")
4608
4609 _, exists := svc.Get(ctx, "app.example.com")
4610 assert.False(t, exists, "removed route must be cleared from in-memory tracking so monitor cannot restart it")
4611
4612 svc.mu.RLock()
4613 _, attachmentsTracked := svc.attachments["app.example.com"]
4614 svc.mu.RUnlock()
4615 assert.True(t, attachmentsTracked, "route cleanup preserves attachment tracking for follow-up cleanup")
4616}
4617
4618func TestService_ReconcileRemovedRoute_IsIdempotentWhenNoContainerExists(t *testing.T) {
4619 ctx := testContext()
4620 runtime := mocks.NewMockContainerRuntime(t)
4621 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4622
4623 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
4624
4625 report, err := svc.ReconcileRemovedRoute(ctx, "missing.example.com")
4626 require.NoError(t, err)
4627 require.NotNil(t, report)
4628
4629 assert.Equal(t, "missing.example.com", report.Domain)
4630 assert.Empty(t, report.RemovedContainers)
4631 assert.Contains(t, report.Warnings, "no active route container found for removed route")
4632}
4633
4634func TestService_PreviewRemovedRouteCleanup_ReportsRuntimeStateWithoutMutation(t *testing.T) {
4635 ctx := testContext()
4636 runtime := mocks.NewMockContainerRuntime(t)
4637 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4638
4639 mainContainer := &domain.Container{
4640 ID: "route-container",
4641 Name: "gordon-app.example.com",
4642 Status: string(domain.ContainerStatusRunning),
4643 Labels: map[string]string{
4644 domain.LabelManaged: "true",
4645 domain.LabelRoute: "app.example.com",
4646 domain.LabelDomain: "app.example.com",
4647 },
4648 }
4649 attachment := &domain.Container{
4650 ID: "attachment-container",
4651 Name: "gordon-app-example-com-postgres",
4652 Status: string(domain.ContainerStatusRunning),
4653 Labels: map[string]string{
4654 domain.LabelManaged: "true",
4655 domain.LabelAttachment: "true",
4656 domain.LabelAttachedTo: "app.example.com",
4657 },
4658 }
4659
4660 svc.mu.Lock()
4661 svc.containers["app.example.com"] = mainContainer
4662 svc.attachments["app.example.com"] = []string{attachment.ID}
4663 svc.managedCount = 1
4664 svc.mu.Unlock()
4665
4666 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer, attachment}, nil).Once()
4667
4668 report, err := svc.PreviewRemovedRouteCleanup(ctx, "app.example.com")
4669 require.NoError(t, err)
4670 require.NotNil(t, report)
4671
4672 require.Len(t, report.OrphanedEntities, 1)
4673 assert.Equal(t, "route_container", report.OrphanedEntities[0].Kind)
4674 assert.Equal(t, mainContainer.ID, report.OrphanedEntities[0].ID)
4675 require.Len(t, report.PreservedAttachments, 1)
4676 assert.Equal(t, attachment.ID, report.PreservedAttachments[0].ContainerID)
4677 assert.Contains(t, report.Hints, "attachments preserved; remove or purge attachments explicitly if they are no longer needed")
4678
4679 _, exists := svc.Get(ctx, "app.example.com")
4680 assert.True(t, exists, "preview should not clear route tracking")
4681}
4682
4683func TestService_ReconcileRemovedRoute_CanonicalizesDomainBeforeCleanup(t *testing.T) {
4684 ctx := testContext()
4685 runtime := mocks.NewMockContainerRuntime(t)
4686 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4687
4688 mainContainer := &domain.Container{
4689 ID: "route-container",
4690 Name: "gordon-app.example.com",
4691 Status: string(domain.ContainerStatusRunning),
4692 Labels: map[string]string{
4693 domain.LabelManaged: "true",
4694 domain.LabelRoute: "app.example.com",
4695 domain.LabelDomain: "app.example.com",
4696 },
4697 }
4698 svc.mu.Lock()
4699 svc.containers["app.example.com"] = mainContainer
4700 svc.managedCount = 1
4701 svc.mu.Unlock()
4702
4703 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer}, nil).Once()
4704 runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once()
4705 runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once()
4706
4707 report, err := svc.ReconcileRemovedRoute(ctx, "App.EXAMPLE.com")
4708 require.NoError(t, err)
4709 require.NotNil(t, report)
4710 assert.Equal(t, "app.example.com", report.Domain)
4711 require.Len(t, report.RemovedContainers, 1)
4712
4713 _, exists := svc.Get(ctx, "app.example.com")
4714 assert.False(t, exists)
4715}
4716
4717func TestService_ReconcileRemovedRoute_StopsLogCollectionForRemovedContainer(t *testing.T) {
4718 ctx := testContext()
4719 runtime := mocks.NewMockContainerRuntime(t)
4720 logWriter := mocks.NewMockContainerLogWriter(t)
4721 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), logWriter, Config{}, nil)
4722
4723 mainContainer := &domain.Container{
4724 ID: "route-container",
4725 Name: "gordon-app.example.com",
4726 Status: string(domain.ContainerStatusRunning),
4727 Labels: map[string]string{
4728 domain.LabelManaged: "true",
4729 domain.LabelRoute: "app.example.com",
4730 },
4731 }
4732
4733 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer}, nil).Once()
4734 logWriter.EXPECT().StopLogging(mainContainer.ID).Return(nil).Once()
4735 runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once()
4736 runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once()
4737
4738 _, err := svc.ReconcileRemovedRoute(ctx, "app.example.com")
4739 require.NoError(t, err)
4740}
4741
4742func TestService_ReconcileRemovedRoute_InvalidatesProxyCacheAndMetric(t *testing.T) {
4743 ctx := testContext()
4744 runtime := mocks.NewMockContainerRuntime(t)
4745 eventBus := mocks.NewMockEventPublisher(t)
4746 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
4747 svc := NewService(runtime, nil, eventBus, nil, Config{}, nil)
4748
4749 metrics, reader := setupMetricsTest(t)
4750 svc.SetMetrics(metrics)
4751
4752 mainContainer := &domain.Container{
4753 ID: "route-container",
4754 Name: "gordon-app.example.com",
4755 Status: string(domain.ContainerStatusRunning),
4756 Labels: map[string]string{
4757 domain.LabelManaged: "true",
4758 domain.LabelRoute: "app.example.com",
4759 },
4760 }
4761 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
4762 svc.activateDeployedContainer(ctx, "app.example.com", mainContainer)
4763 svc.SetProxyCacheInvalidator(cacheInvalidator)
4764
4765 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer}, nil).Once()
4766 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "app.example.com").Return().Once()
4767 runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once()
4768 runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once()
4769
4770 _, err := svc.ReconcileRemovedRoute(ctx, "app.example.com")
4771 require.NoError(t, err)
4772
4773 value, _, _ := managedMetricState(t, reader)
4774 assert.Equal(t, int64(0), value)
4775}
4776
4777func TestService_ListOrphanedAttachments_DetectsRunningAttachmentNoLongerConfigured(t *testing.T) {
4778 ctx := testContext()
4779 runtime := mocks.NewMockContainerRuntime(t)
4780 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{Attachments: map[string][]string{
4781 "app.example.com": {"redis:7"},
4782 }}, nil)
4783
4784 orphan := &domain.Container{
4785 ID: "postgres-1",
4786 Name: "gordon-app-example-com-postgres",
4787 Image: "postgres:16",
4788 Status: string(domain.ContainerStatusRunning),
4789 Labels: map[string]string{
4790 domain.LabelManaged: "true",
4791 domain.LabelAttachment: "true",
4792 domain.LabelAttachedTo: "app.example.com",
4793 domain.LabelImage: "postgres:16",
4794 },
4795 }
4796 configured := &domain.Container{
4797 ID: "redis-1",
4798 Name: "gordon-app-example-com-redis",
4799 Image: "redis:7",
4800 Status: string(domain.ContainerStatusRunning),
4801 Labels: map[string]string{
4802 domain.LabelManaged: "true",
4803 domain.LabelAttachment: "true",
4804 domain.LabelAttachedTo: "app.example.com",
4805 domain.LabelImage: "redis:7",
4806 },
4807 }
4808
4809 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{orphan, configured}, nil).Once()
4810
4811 orphans, err := svc.ListOrphanedAttachments(ctx)
4812 require.NoError(t, err)
4813 require.Len(t, orphans, 1)
4814 assert.Equal(t, orphan.ID, orphans[0].ContainerID)
4815 assert.Equal(t, "postgres:16", orphans[0].Image)
4816 assert.Contains(t, orphans[0].Reason, "no longer configured")
4817}
4818
4819func TestService_CleanupOrphanedAttachments_DryRunPreservesRunningContainer(t *testing.T) {
4820 ctx := testContext()
4821 runtime := mocks.NewMockContainerRuntime(t)
4822 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4823 orphan := &domain.Container{
4824 ID: "postgres-1",
4825 Name: "gordon-app-example-com-postgres",
4826 Image: "postgres:16",
4827 Status: string(domain.ContainerStatusRunning),
4828 Labels: map[string]string{
4829 domain.LabelManaged: "true",
4830 domain.LabelAttachment: "true",
4831 domain.LabelAttachedTo: "app.example.com",
4832 domain.LabelImage: "postgres:16",
4833 },
4834 }
4835 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{orphan}, nil).Once()
4836
4837 report, err := svc.CleanupOrphanedAttachments(ctx, "", false)
4838 require.NoError(t, err)
4839 require.NotNil(t, report)
4840 require.Len(t, report.PreservedAttachments, 1)
4841 assert.Equal(t, orphan.ID, report.PreservedAttachments[0].ContainerID)
4842 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, orphan.ID)
4843 runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, orphan.ID, mock.Anything)
4844}
4845
4846func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData(t *testing.T) {
4847 ctx := testContext()
4848 runtime := mocks.NewMockContainerRuntime(t)
4849 logWriter := mocks.NewMockContainerLogWriter(t)
4850 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), logWriter, Config{}, nil)
4851 running := &domain.Container{
4852 ID: "postgres-1",
4853 Name: "gordon-app-example-com-postgres",
4854 Image: "postgres:16",
4855 Status: string(domain.ContainerStatusRunning),
4856 Labels: map[string]string{
4857 domain.LabelManaged: "true",
4858 domain.LabelAttachment: "true",
4859 domain.LabelAttachedTo: "app.example.com",
4860 domain.LabelImage: "postgres:16",
4861 },
4862 }
4863 stopped := &domain.Container{
4864 ID: "redis-1",
4865 Name: "gordon-app-example-com-redis",
4866 Image: "redis:7",
4867 Status: string(domain.ContainerStatusExited),
4868 Labels: map[string]string{
4869 domain.LabelManaged: "true",
4870 domain.LabelAttachment: "true",
4871 domain.LabelAttachedTo: "app.example.com",
4872 domain.LabelImage: "redis:7",
4873 },
4874 }
4875 svc.mu.Lock()
4876 svc.attachments["app.example.com"] = []string{running.ID, stopped.ID}
4877 svc.mu.Unlock()
4878
4879 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{running, stopped}, nil).Once()
4880 logWriter.EXPECT().StopLogging(running.ID).Return(nil).Once()
4881 runtime.EXPECT().StopContainer(mock.Anything, running.ID).Return(nil).Once()
4882 runtime.EXPECT().RemoveContainer(mock.Anything, running.ID, true).Return(nil).Once()
4883 logWriter.EXPECT().StopLogging(stopped.ID).Return(nil).Once()
4884 runtime.EXPECT().RemoveContainer(mock.Anything, stopped.ID, true).Return(nil).Once()
4885
4886 report, err := svc.CleanupOrphanedAttachments(ctx, "", true)
4887 require.NoError(t, err)
4888 require.NotNil(t, report)
4889 require.Len(t, report.RemovedContainers, 2)
4890 assert.Equal(t, running.ID, report.RemovedContainers[0].ID)
4891 assert.Equal(t, stopped.ID, report.RemovedContainers[1].ID)
4892 assert.Contains(t, report.Hints, "attachment volumes and data were preserved")
4893
4894 svc.mu.RLock()
4895 ids := svc.attachments["app.example.com"]
4896 svc.mu.RUnlock()
4897 assert.Empty(t, ids)
4898}
4899
4900func TestService_ListOrphanedAttachments_IncludesStoppedAttachments(t *testing.T) {
4901 ctx := testContext()
4902 runtime := mocks.NewMockContainerRuntime(t)
4903 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4904 stopped := &domain.Container{
4905 ID: "postgres-1",
4906 Name: "gordon-app-example-com-postgres",
4907 Image: "postgres:16",
4908 Status: string(domain.ContainerStatusExited),
4909 Labels: map[string]string{
4910 domain.LabelManaged: "true",
4911 domain.LabelAttachment: "true",
4912 domain.LabelAttachedTo: "app.example.com",
4913 domain.LabelImage: "postgres:16",
4914 },
4915 }
4916 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{stopped}, nil).Once()
4917
4918 orphans, err := svc.ListOrphanedAttachments(ctx)
4919 require.NoError(t, err)
4920 require.Len(t, orphans, 1)
4921 assert.Equal(t, stopped.ID, orphans[0].ContainerID)
4922 assert.Equal(t, string(domain.ContainerStatusExited), orphans[0].Status)
4923}
4924
4925func TestService_ListOrphanedAttachments_IncludesOwnerAndHonorsGroupConfig(t *testing.T) {
4926 ctx := testContext()
4927 runtime := mocks.NewMockContainerRuntime(t)
4928 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{
4929 NetworkIsolation: true,
4930 NetworkGroups: map[string][]string{
4931 "backend": {"app.example.com"},
4932 },
4933 Attachments: map[string][]string{
4934 "backend": {"postgres:16"},
4935 },
4936 }, nil)
4937 configuredByGroup := &domain.Container{
4938 ID: "postgres-1",
4939 Name: "gordon-app-example-com-postgres",
4940 Image: "postgres:16",
4941 Status: string(domain.ContainerStatusRunning),
4942 Labels: map[string]string{
4943 domain.LabelManaged: "true",
4944 domain.LabelAttachment: "true",
4945 domain.LabelAttachedTo: "app.example.com",
4946 domain.LabelImage: "postgres:16",
4947 },
4948 }
4949 orphan := &domain.Container{
4950 ID: "redis-1",
4951 Name: "gordon-app-example-com-redis",
4952 Image: "redis:7",
4953 Status: string(domain.ContainerStatusRunning),
4954 Labels: map[string]string{
4955 domain.LabelManaged: "true",
4956 domain.LabelAttachment: "true",
4957 domain.LabelAttachedTo: "app.example.com",
4958 domain.LabelImage: "redis:7",
4959 },
4960 }
4961 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{configuredByGroup, orphan}, nil).Once()
4962
4963 orphans, err := svc.ListOrphanedAttachments(ctx)
4964 require.NoError(t, err)
4965 require.Len(t, orphans, 1)
4966 assert.Equal(t, "redis-1", orphans[0].ContainerID)
4967 assert.Equal(t, "app.example.com", orphans[0].Owner)
4968}