v2.31.0-dev13 0d280f4

service_test.go RawHistoryFolder

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

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