v2.31.0-dev6 8056b57

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

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