v2.30.0-pre2 f03ab25

service_test.go RawHistoryFolder

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

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