v2.31.1 f37fe67

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

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