v2.31.0-dev22 8d342c4

service_test.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close