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