v2.31.0-dev19 35467d7

service_test.go RawHistoryFolder

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

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