v2.31.0-dev23 58a92f3

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

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 8

Tags 106

↑↓ moveenter openesc close