v2.31.0-dev4 f8fb41e

service_test.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close