v2.31.0 e02f654

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")
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")
1824
1825	require.NoError(t, err)
1826	assert.Equal(t, map[string]string{"/data": stableName}, volumes)
1827}
1828
1829func TestValidateAttachmentOwnership(t *testing.T) {
1830	owned := &domain.Container{Labels: map[string]string{
1831		domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com",
1832	}}
1833	require.NoError(t, validateAttachmentOwnership(owned, "app.example.com", "gordon-app-postgres"))
1834
1835	foreign := &domain.Container{Labels: map[string]string{
1836		domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "other.example.com",
1837	}}
1838	assert.ErrorIs(t, validateAttachmentOwnership(foreign, "app.example.com", "gordon-app-postgres"), domain.ErrAttachmentOwnershipMismatch)
1839}
1840
1841func TestMergeEnvironmentVariables(t *testing.T) {
1842	dockerEnv := []string{"FOO=docker", "BAR=docker"}
1843	userEnv := []string{"FOO=user", "BAZ=user"}
1844
1845	result := mergeEnvironmentVariables(dockerEnv, userEnv)
1846
1847	// User env should override docker env
1848	assert.Contains(t, result, "FOO=user")
1849	assert.Contains(t, result, "BAR=docker")
1850	assert.Contains(t, result, "BAZ=user")
1851}
1852
1853func TestBuildImageRef(t *testing.T) {
1854	tests := []struct {
1855		name                string
1856		image               string
1857		registryAuthEnabled bool
1858		registryDomain      string
1859		wantRef             string
1860	}{
1861		{
1862			name:                "adds registry domain prefix",
1863			image:               "myapp:latest",
1864			registryAuthEnabled: true,
1865			registryDomain:      "registry.example.com",
1866			wantRef:             "registry.example.com/myapp:latest",
1867		},
1868		{
1869			name:                "keeps existing registry domain prefix",
1870			image:               "registry.example.com/myapp:latest",
1871			registryAuthEnabled: true,
1872			registryDomain:      "registry.example.com",
1873			wantRef:             "registry.example.com/myapp:latest",
1874		},
1875		{
1876			name:                "skips external registry images",
1877			image:               "docker.io/library/nginx:latest",
1878			registryAuthEnabled: true,
1879			registryDomain:      "registry.example.com",
1880			wantRef:             "docker.io/library/nginx:latest",
1881		},
1882		{
1883			name:                "returns original when auth disabled",
1884			image:               "myapp:latest",
1885			registryAuthEnabled: false,
1886			registryDomain:      "registry.example.com",
1887			wantRef:             "myapp:latest",
1888		},
1889		{
1890			name:                "localhost domain adds prefix",
1891			image:               "myapp:latest",
1892			registryAuthEnabled: true,
1893			registryDomain:      "localhost:5000",
1894			wantRef:             "localhost:5000/myapp:latest",
1895		},
1896		{
1897			name:                "localhost domain keeps existing prefix",
1898			image:               "localhost:5000/myapp:latest",
1899			registryAuthEnabled: true,
1900			registryDomain:      "localhost:5000",
1901			wantRef:             "localhost:5000/myapp:latest",
1902		},
1903		{
1904			name:                "explicit host:port different than RegistryDomain",
1905			image:               "localhost:5001/myapp:latest",
1906			registryAuthEnabled: true,
1907			registryDomain:      "localhost:5000",
1908			wantRef:             "localhost:5001/myapp:latest",
1909		},
1910		{
1911			name:                "ghcr.io external registry",
1912			image:               "ghcr.io/owner/repo:latest",
1913			registryAuthEnabled: true,
1914			registryDomain:      "localhost:5000",
1915			wantRef:             "ghcr.io/owner/repo:latest",
1916		},
1917		{
1918			name:                "gcr.io external registry",
1919			image:               "gcr.io/project/image:v1",
1920			registryAuthEnabled: true,
1921			registryDomain:      "registry.example.com",
1922			wantRef:             "gcr.io/project/image:v1",
1923		},
1924		{
1925			name:                "quay.io external registry",
1926			image:               "quay.io/org/app:tag",
1927			registryAuthEnabled: true,
1928			registryDomain:      "localhost:5000",
1929			wantRef:             "quay.io/org/app:tag",
1930		},
1931		{
1932			name:                "localhost without port keeps existing prefix",
1933			image:               "localhost/myapp:latest",
1934			registryAuthEnabled: true,
1935			registryDomain:      "localhost:5000",
1936			wantRef:             "localhost/myapp:latest",
1937		},
1938		{
1939			name:                "registry domain with trailing slash",
1940			image:               "myapp:latest",
1941			registryAuthEnabled: true,
1942			registryDomain:      "registry.example.com/",
1943			wantRef:             "registry.example.com/myapp:latest",
1944		},
1945		{
1946			name:                "ipv6 registry without port",
1947			image:               "[fd00::1]/myapp:latest",
1948			registryAuthEnabled: true,
1949			registryDomain:      "localhost:5000",
1950			wantRef:             "[fd00::1]/myapp:latest",
1951		},
1952		{
1953			name:                "ipv6 registry with port",
1954			image:               "[fd00::1]:5000/myapp:latest",
1955			registryAuthEnabled: true,
1956			registryDomain:      "localhost:5000",
1957			wantRef:             "[fd00::1]:5000/myapp:latest",
1958		},
1959	}
1960
1961	for _, tt := range tests {
1962		t.Run(tt.name, func(t *testing.T) {
1963			svc := &Service{
1964				config: Config{
1965					RegistryAuthEnabled: tt.registryAuthEnabled,
1966					RegistryDomain:      tt.registryDomain,
1967				},
1968			}
1969			gotRef := svc.buildImageRef(tt.image)
1970			assert.Equal(t, tt.wantRef, gotRef, "unexpected image reference")
1971		})
1972	}
1973}
1974
1975func TestRewriteToRegistryDomain(t *testing.T) {
1976	tests := []struct {
1977		name           string
1978		imageRef       string
1979		registryDomain string
1980		wantRef        string
1981	}{
1982		{
1983			name:           "keeps registry domain prefix",
1984			imageRef:       "registry.example.com/myapp:latest",
1985			registryDomain: "registry.example.com",
1986			wantRef:        "registry.example.com/myapp:latest",
1987		},
1988		{
1989			name:           "prefixes registry domain",
1990			imageRef:       "myapp:v1.0",
1991			registryDomain: "registry.example.com",
1992			wantRef:        "registry.example.com/myapp:v1.0",
1993		},
1994		{
1995			name:           "prefixes external image",
1996			imageRef:       "docker.io/library/nginx:latest",
1997			registryDomain: "registry.example.com",
1998			wantRef:        "registry.example.com/docker.io/library/nginx:latest",
1999		},
2000		{
2001			name:           "empty registry domain returns original",
2002			imageRef:       "registry.example.com/myapp:latest",
2003			registryDomain: "",
2004			wantRef:        "registry.example.com/myapp:latest",
2005		},
2006	}
2007
2008	for _, tt := range tests {
2009		t.Run(tt.name, func(t *testing.T) {
2010			gotRef := rewriteToRegistryDomain(tt.imageRef, tt.registryDomain)
2011			assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
2012		})
2013	}
2014}
2015
2016func TestRewriteToLocalRegistry(t *testing.T) {
2017	tests := []struct {
2018		name                  string
2019		imageRef              string
2020		registryDomain        string
2021		legacyRegistryDomains []string
2022		registryPort          int
2023		wantRef               string
2024	}{
2025		{
2026			name:           "rewrites registry domain prefix",
2027			imageRef:       "registry.example.com/myapp:latest",
2028			registryDomain: "registry.example.com",
2029			registryPort:   5000,
2030			wantRef:        "localhost:5000/myapp:latest",
2031		},
2032		{
2033			name:           "prefixes local registry when no domain",
2034			imageRef:       "myapp:v1.0",
2035			registryDomain: "registry.example.com",
2036			registryPort:   5000,
2037			wantRef:        "localhost:5000/myapp:v1.0",
2038		},
2039		{
2040			name:           "keeps existing localhost prefix",
2041			imageRef:       "localhost:5000/myapp:latest",
2042			registryDomain: "registry.example.com",
2043			registryPort:   5000,
2044			wantRef:        "localhost:5000/myapp:latest",
2045		},
2046		{
2047			name:                  "rewrites legacy registry domain prefix",
2048			imageRef:              "old-registry.example.com/myapp:latest",
2049			registryDomain:        "new-registry.example.com",
2050			legacyRegistryDomains: []string{"old-registry.example.com"},
2051			registryPort:          5000,
2052			wantRef:               "localhost:5000/myapp:latest",
2053		},
2054		{
2055			name:                  "rewrites legacy registry domain prefix with explicit port and digest",
2056			imageRef:              "old-registry.example.com:5001/myapp@sha256:deadbeef",
2057			registryDomain:        "new-registry.example.com",
2058			legacyRegistryDomains: []string{"old-registry.example.com:5001"},
2059			registryPort:          5000,
2060			wantRef:               "localhost:5000/myapp@sha256:deadbeef",
2061		},
2062		{
2063			name:                  "external image remains external",
2064			imageRef:              "docker.io/library/nginx:latest",
2065			registryDomain:        "new-registry.example.com",
2066			legacyRegistryDomains: []string{"old-registry.example.com"},
2067			registryPort:          5000,
2068			wantRef:               "docker.io/library/nginx:latest",
2069		},
2070		{
2071			name:                  "hostile lookalike remains external",
2072			imageRef:              "old-registry.example.com.evil/myapp:latest",
2073			registryDomain:        "new-registry.example.com",
2074			legacyRegistryDomains: []string{"old-registry.example.com"},
2075			registryPort:          5000,
2076			wantRef:               "old-registry.example.com.evil/myapp:latest",
2077		},
2078	}
2079
2080	for _, tt := range tests {
2081		t.Run(tt.name, func(t *testing.T) {
2082			gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.legacyRegistryDomains, tt.registryPort)
2083			assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
2084		})
2085	}
2086}
2087
2088func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
2089	runtime := mocks.NewMockContainerRuntime(t)
2090	envLoader := mocks.NewMockEnvLoader(t)
2091	eventBus := mocks.NewMockEventPublisher(t)
2092
2093	config := Config{
2094		AllowedRegistries:        []string{"docker.io"},
2095		RegistryAuthEnabled:      true,
2096		RegistryDomain:           "registry.example.com",
2097		RegistryPort:             5000,
2098		InternalRegistryUsername: "internal",
2099		InternalRegistryPassword: "secret",
2100		ReadinessDelay:           time.Millisecond,
2101		DrainDelay:               time.Millisecond,
2102		DrainDelayConfigured:     true,
2103	}
2104
2105	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2106
2107	// Use internal deploy context (simulating image push event)
2108	ctx := domain.WithInternalDeploy(testContext())
2109
2110	route := domain.Route{
2111		Domain: "test.example.com",
2112		Image:  "myapp:latest",
2113	}
2114
2115	// No container in memory — runtime resolution returns nothing
2116	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2117
2118	// Setup mocks - no orphaned containers
2119	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2120
2121	// Even though image exists locally, internal deploy should force pull
2122	// Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check
2123	runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
2124
2125	// Tag image to canonical name
2126	runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil)
2127	runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
2128
2129	// Get exposed ports
2130	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
2131	runtime.EXPECT().GetImageLabels(mock.Anything, "registry.example.com/myapp:latest").Return(nil, nil)
2132
2133	// Load environment
2134	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2135	runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil)
2136
2137	// Create and start container
2138	createdContainer := &domain.Container{
2139		ID:     "container-123",
2140		Name:   "gordon-test.example.com",
2141		Status: "created",
2142	}
2143	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
2144	runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
2145
2146	// Wait for ready
2147	runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
2148
2149	// Re-inspect after start
2150	runningContainer := &domain.Container{
2151		ID:     "container-123",
2152		Name:   "gordon-test.example.com",
2153		Status: "running",
2154		Ports:  []int{8080},
2155	}
2156	runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
2157
2158	// Publish event
2159	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2160
2161	result, err := svc.Deploy(ctx, route)
2162
2163	assert.NoError(t, err)
2164	assert.NotNil(t, result)
2165	assert.Equal(t, "container-123", result.ID)
2166}
2167
2168func TestService_Deploy_InternalDeployForcesPull_LegacyRegistryHost(t *testing.T) {
2169	runtime := mocks.NewMockContainerRuntime(t)
2170	envLoader := mocks.NewMockEnvLoader(t)
2171	eventBus := mocks.NewMockEventPublisher(t)
2172
2173	config := Config{
2174		AllowedRegistries:        []string{"docker.io"},
2175		RegistryAuthEnabled:      true,
2176		RegistryDomain:           "new-registry.example.com",
2177		LegacyRegistryDomains:    []string{"old-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	ctx := domain.WithInternalDeploy(testContext())
2188
2189	route := domain.Route{
2190		Domain: "test.example.com",
2191		Image:  "old-registry.example.com/myapp:latest",
2192	}
2193
2194	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2195	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2196	runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
2197	runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "old-registry.example.com/myapp:latest").Return(nil)
2198	runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
2199
2200	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "old-registry.example.com/myapp:latest").Return([]int{8080}, nil)
2201	runtime.EXPECT().GetImageLabels(mock.Anything, "old-registry.example.com/myapp:latest").Return(nil, nil)
2202	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2203	runtime.EXPECT().InspectImageEnv(mock.Anything, "old-registry.example.com/myapp:latest").Return([]string{}, nil)
2204
2205	createdContainer := &domain.Container{
2206		ID:     "container-legacy",
2207		Name:   "gordon-test.example.com",
2208		Status: "created",
2209	}
2210	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
2211	runtime.EXPECT().StartContainer(mock.Anything, "container-legacy").Return(nil)
2212	runtime.EXPECT().IsContainerRunning(mock.Anything, "container-legacy").Return(true, nil).Times(2)
2213	runtime.EXPECT().InspectContainer(mock.Anything, "container-legacy").Return(&domain.Container{
2214		ID:     "container-legacy",
2215		Name:   "gordon-test.example.com",
2216		Status: "running",
2217		Ports:  []int{8080},
2218	}, nil)
2219	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2220
2221	result, err := svc.Deploy(ctx, route)
2222
2223	assert.NoError(t, err)
2224	assert.NotNil(t, result)
2225	assert.Equal(t, "container-legacy", result.ID)
2226}
2227
2228func TestService_AutoStart_StartsNewContainers(t *testing.T) {
2229	runtime := mocks.NewMockContainerRuntime(t)
2230	envLoader := mocks.NewMockEnvLoader(t)
2231	eventBus := mocks.NewMockEventPublisher(t)
2232
2233	config := Config{
2234		AllowedRegistries:    []string{"docker.io"},
2235		ReadinessDelay:       time.Millisecond,
2236		DrainDelay:           time.Millisecond,
2237		DrainDelayConfigured: true,
2238	}
2239	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2240	ctx := testContext()
2241
2242	routes := []domain.Route{
2243		{Domain: "app1.example.com", Image: "myapp1:latest"},
2244		{Domain: "app2.example.com", Image: "myapp2:latest"},
2245	}
2246
2247	// No container in memory — runtime resolution returns nothing (one per route)
2248	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Times(2)
2249
2250	// Setup mocks for route deployments
2251	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
2252	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
2253	runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
2254	runtime.EXPECT().GetImageLabels(mock.Anything, mock.AnythingOfType("string")).Return(nil, nil).Times(2)
2255	envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
2256	runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
2257
2258	// Create and start containers — readiness is skipped so no IsContainerRunning calls.
2259	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
2260		ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
2261	}, nil).Once()
2262	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
2263		ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
2264	}, nil).Once()
2265	runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
2266	runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
2267		Status: "running",
2268	}, nil).Times(2)
2269	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2)
2270
2271	err := svc.AutoStart(ctx, routes)
2272
2273	assert.NoError(t, err)
2274	assert.Len(t, svc.containers, 2)
2275}
2276
2277func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
2278	runtime := mocks.NewMockContainerRuntime(t)
2279	envLoader := mocks.NewMockEnvLoader(t)
2280	eventBus := mocks.NewMockEventPublisher(t)
2281
2282	config := Config{
2283		AllowedRegistries:    []string{"docker.io"},
2284		ReadinessDelay:       time.Millisecond,
2285		DrainDelay:           time.Millisecond,
2286		DrainDelayConfigured: true,
2287	}
2288	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2289	ctx := testContext()
2290
2291	// Pre-populate with existing container
2292	svc.containers["app1.example.com"] = &domain.Container{
2293		ID: "existing-container",
2294	}
2295
2296	routes := []domain.Route{
2297		{Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists
2298		{Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
2299	}
2300
2301	// No container in memory for app2 — runtime resolution returns nothing
2302	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
2303
2304	// Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning.
2305	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
2306	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
2307	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
2308	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp2:latest").Return(nil, nil).Once()
2309	envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
2310	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
2311	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
2312		ID: "container-2", Status: "created",
2313	}, nil).Once()
2314	runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
2315	runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
2316		Status: "running",
2317	}, nil).Once()
2318	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
2319
2320	err := svc.AutoStart(ctx, routes)
2321
2322	assert.NoError(t, err)
2323	assert.Len(t, svc.containers, 2)
2324}
2325
2326func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
2327	runtime := mocks.NewMockContainerRuntime(t)
2328	envLoader := mocks.NewMockEnvLoader(t)
2329	eventBus := mocks.NewMockEventPublisher(t)
2330
2331	config := Config{AllowedRegistries: []string{"docker.io"}}
2332	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2333	ctx := testContext()
2334
2335	routes := []domain.Route{
2336		{Domain: "app1.example.com", Image: "myapp1:latest"},
2337	}
2338
2339	// No container in memory — runtime resolution returns nothing
2340	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2341
2342	// Setup mocks for failure
2343	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2344	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2345	runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found"))
2346
2347	err := svc.AutoStart(ctx, routes)
2348
2349	// AutoStart should return error when some deployments fail
2350	assert.Error(t, err)
2351	assert.Contains(t, err.Error(), "auto-start completed with 1 errors")
2352}
2353
2354func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
2355	runtime := mocks.NewMockContainerRuntime(t)
2356	envLoader := mocks.NewMockEnvLoader(t)
2357	eventBus := mocks.NewMockEventPublisher(t)
2358
2359	config := Config{
2360		RegistryDomain:       "reg.example.com",
2361		RegistryPort:         5000,
2362		ReadinessDelay:       time.Millisecond,
2363		DrainDelay:           time.Millisecond,
2364		DrainDelayConfigured: true,
2365	}
2366	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2367
2368	// Mark context as internal deploy — this is what syncAndAutoStart should do
2369	ctx := domain.WithInternalDeploy(testContext())
2370
2371	routes := []domain.Route{
2372		{Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"},
2373	}
2374
2375	// No container in memory — runtime resolution returns nothing
2376	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2377
2378	// Key assertion: PullImage should be called with localhost:5000 rewrite,
2379	// NOT the original reg.example.com/myapp:latest.
2380	// Readiness is skipped — no IsContainerRunning calls.
2381	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2382	runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
2383	runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)
2384	runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
2385	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "reg.example.com/myapp:latest").Return([]int{8080}, nil)
2386	runtime.EXPECT().GetImageLabels(mock.Anything, "reg.example.com/myapp:latest").Return(nil, nil)
2387	envLoader.EXPECT().LoadEnv(mock.Anything, "app1.example.com").Return([]string{}, nil)
2388	runtime.EXPECT().InspectImageEnv(mock.Anything, "reg.example.com/myapp:latest").Return([]string{}, nil)
2389	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
2390		ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
2391	}, nil)
2392	runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil)
2393	runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{
2394		Status: "running",
2395	}, nil)
2396	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2397
2398	err := svc.AutoStart(ctx, routes)
2399	assert.NoError(t, err)
2400}
2401
2402// TestService_Deploy_OrphanCleanupSkipsTrackedContainer verifies that the orphan cleanup
2403// does not remove the currently tracked container during zero-downtime deployment.
2404// This is critical for preventing downtime - the old container must stay running
2405// until the new container is ready and traffic is switched.
2406func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
2407	runtime := mocks.NewMockContainerRuntime(t)
2408	envLoader := mocks.NewMockEnvLoader(t)
2409	eventBus := mocks.NewMockEventPublisher(t)
2410	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2411
2412	config := Config{
2413		AllowedRegistries:    []string{"docker.io"},
2414		ReadinessDelay:       time.Millisecond, // Minimal delay for tests
2415		DrainDelay:           time.Millisecond,
2416		DrainDelayConfigured: true,
2417		StabilizationDelay:   time.Millisecond,
2418	}
2419	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2420	svc.SetProxyCacheInvalidator(cacheInvalidator)
2421	ctx := testContext()
2422
2423	// Pre-populate with existing tracked container
2424	existingContainer := &domain.Container{
2425		ID:     "tracked-container-123",
2426		Name:   "gordon-test.example.com",
2427		Status: "running",
2428	}
2429	svc.containers["test.example.com"] = existingContainer
2430
2431	route := domain.Route{
2432		Domain: "test.example.com",
2433		Image:  "myapp:v2",
2434	}
2435
2436	// ListContainers returns the tracked container - this simulates the orphan check
2437	// finding a container with the same name. The bug was that it would remove this
2438	// container BEFORE the new one was ready, causing downtime.
2439	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2440		{
2441			ID:     "tracked-container-123",
2442			Name:   "gordon-test.example.com",
2443			Status: "running",
2444		},
2445	}, nil)
2446
2447	// Image operations
2448	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2449	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2450	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2451	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2452
2453	// Environment
2454	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2455	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2456
2457	// Create new container with -new suffix for zero-downtime
2458	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2459	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2460		return cfg.Name == "gordon-test.example.com-new"
2461	})).Return(newContainer, nil)
2462	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2463
2464	// Wait for ready (2 calls) + stabilization check (1 call)
2465	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2466
2467	// Inspect after ready
2468	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2469		ID:     "new-container",
2470		Status: "running",
2471	}, nil)
2472
2473	// Publish event
2474	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2475
2476	// Synchronous cache invalidation
2477	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2478
2479	// NOW (after new container is ready) the old container should be stopped and removed
2480	// This is the correct zero-downtime sequence - not during orphan cleanup
2481	runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
2482	runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
2483
2484	// Rename new container to canonical name
2485	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2486
2487	result, err := svc.Deploy(ctx, route)
2488
2489	assert.NoError(t, err)
2490	svc.WaitForCleanup()
2491	assert.Equal(t, "new-container", result.ID)
2492
2493	// Verify new container is now tracked
2494	tracked, exists := svc.Get(ctx, "test.example.com")
2495	assert.True(t, exists)
2496	assert.Equal(t, "new-container", tracked.ID)
2497}
2498
2499// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that stopped canonical
2500// containers with the same name but NOT tracked are properly removed as orphans.
2501// Running canonical containers are preserved (see NeverKillsRunningCanonical test).
2502func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
2503	runtime := mocks.NewMockContainerRuntime(t)
2504	envLoader := mocks.NewMockEnvLoader(t)
2505	eventBus := mocks.NewMockEventPublisher(t)
2506
2507	config := Config{
2508		AllowedRegistries:    []string{"docker.io"},
2509		ReadinessDelay:       time.Millisecond, // Minimal delay for tests
2510		DrainDelay:           time.Millisecond,
2511		DrainDelayConfigured: true,
2512	}
2513	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2514	ctx := testContext()
2515
2516	// NO tracked container - service is empty for this domain
2517
2518	route := domain.Route{
2519		Domain: "test.example.com",
2520		Image:  "myapp:v1",
2521	}
2522
2523	// No container in memory — runtime resolution finds no managed container
2524	// (the orphan is stopped so it won't appear in running-only query)
2525	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2526
2527	// ListContainers(true) returns a STOPPED orphaned container (same name, but not tracked)
2528	// Stopped canonical containers are safe to remove during orphan cleanup
2529	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2530		{
2531			ID:     "orphan-container",
2532			Name:   "gordon-test.example.com",
2533			Status: "exited",
2534		},
2535	}, nil)
2536
2537	// Stopped orphan should be stopped and removed BEFORE we proceed
2538	runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
2539	runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
2540
2541	// Image operations
2542	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2543	runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
2544	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
2545	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v1").Return(nil, nil)
2546
2547	// Environment
2548	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2549	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v1").Return([]string{}, nil)
2550
2551	// Create container (no -new suffix since no existing tracked container)
2552	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
2553	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2554		return cfg.Name == "gordon-test.example.com"
2555	})).Return(newContainer, nil)
2556	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2557
2558	// Wait for ready
2559	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2560
2561	// Inspect after ready
2562	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2563		ID:     "new-container",
2564		Status: "running",
2565		Ports:  []int{8080},
2566	}, nil)
2567
2568	// Publish event
2569	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2570
2571	result, err := svc.Deploy(ctx, route)
2572
2573	assert.NoError(t, err)
2574	assert.Equal(t, "new-container", result.ID)
2575
2576	// Verify new container is tracked
2577	tracked, exists := svc.Get(ctx, "test.example.com")
2578	assert.True(t, exists)
2579	assert.Equal(t, "new-container", tracked.ID)
2580}
2581
2582func TestService_Deploy_OrphanCleanupRemovesRunningStaleCandidate(t *testing.T) {
2583	runtime := mocks.NewMockContainerRuntime(t)
2584	envLoader := mocks.NewMockEnvLoader(t)
2585	eventBus := mocks.NewMockEventPublisher(t)
2586	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2587
2588	config := testMinDelayConfig()
2589	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2590	svc.SetProxyCacheInvalidator(cacheInvalidator)
2591	ctx := testContext()
2592
2593	// The tracked canonical container is active. A candidate left running by an
2594	// interrupted request must be removed before its temporary name is reused.
2595	svc.containers["test.example.com"] = &domain.Container{
2596		ID:     "tracked-container-123",
2597		Name:   "gordon-test.example.com",
2598		Status: "running",
2599	}
2600
2601	route := domain.Route{
2602		Domain: "test.example.com",
2603		Image:  "myapp:v2",
2604	}
2605
2606	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2607		{
2608			ID:     "tracked-container-123",
2609			Name:   "gordon-test.example.com",
2610			Status: "running",
2611		},
2612		{
2613			ID:     "stale-new-container",
2614			Name:   "gordon-test.example.com-new",
2615			Status: "running",
2616		},
2617	}, nil)
2618	runtime.EXPECT().StopContainer(mock.Anything, "stale-new-container").Return(nil).Once()
2619	runtime.EXPECT().RemoveContainer(mock.Anything, "stale-new-container", true).Return(nil).Once()
2620
2621	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2622	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2623	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2624	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2625	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2626	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2627
2628	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2629	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2630		return cfg.Name == "gordon-test.example.com-new"
2631	})).Return(newContainer, nil)
2632	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2633	// Wait for ready (2 calls) + stabilization check (1 call)
2634	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2635	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2636		ID:     "new-container",
2637		Status: "running",
2638	}, nil)
2639	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2640
2641	// Synchronous cache invalidation
2642	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2643
2644	runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
2645	runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
2646	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2647
2648	result, err := svc.Deploy(ctx, route)
2649	assert.NoError(t, err)
2650	svc.WaitForCleanup()
2651	assert.NotNil(t, result)
2652	assert.Equal(t, "new-container", result.ID)
2653}
2654
2655func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) {
2656	runtime := mocks.NewMockContainerRuntime(t)
2657	envLoader := mocks.NewMockEnvLoader(t)
2658	eventBus := mocks.NewMockEventPublisher(t)
2659	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2660
2661	config := testMinDelayConfig()
2662	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2663	svc.SetProxyCacheInvalidator(cacheInvalidator)
2664	ctx := testContext()
2665
2666	// Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name.
2667	svc.containers["test.example.com"] = &domain.Container{
2668		ID:     "tracked-temp-container",
2669		Name:   "gordon-test.example.com-new",
2670		Status: "running",
2671	}
2672
2673	route := domain.Route{
2674		Domain: "test.example.com",
2675		Image:  "myapp:v2",
2676	}
2677
2678	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2679		{
2680			ID:     "tracked-temp-container",
2681			Name:   "gordon-test.example.com-new",
2682			Status: "running",
2683		},
2684	}, nil)
2685
2686	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2687	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2688	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2689	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2690	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2691	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2692
2693	created := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-next", Status: "created"}
2694	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2695		return cfg.Name == "gordon-test.example.com-next"
2696	})).Return(created, nil)
2697	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2698	// Wait for ready (2 calls) + stabilization check (1 call)
2699	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2700	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2701		ID:     "new-container",
2702		Status: "running",
2703	}, nil)
2704	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2705
2706	// Synchronous cache invalidation
2707	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2708
2709	runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil)
2710	runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil)
2711	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2712
2713	result, err := svc.Deploy(ctx, route)
2714	assert.NoError(t, err)
2715	svc.WaitForCleanup()
2716	assert.NotNil(t, result)
2717	assert.Equal(t, "new-container", result.ID)
2718}
2719
2720func TestService_StripRegistryPrefix(t *testing.T) {
2721	tests := []struct {
2722		name                  string
2723		registryDomain        string
2724		legacyRegistryDomains []string
2725		image                 string
2726		expected              string
2727	}{
2728		{
2729			name:           "strips registry prefix",
2730			registryDomain: "reg.example.com",
2731			image:          "reg.example.com/myapp:latest",
2732			expected:       "myapp:latest",
2733		},
2734		{
2735			name:           "strips registry prefix with trailing slash in domain",
2736			registryDomain: "reg.example.com/",
2737			image:          "reg.example.com/myapp:v1.0",
2738			expected:       "myapp:v1.0",
2739		},
2740		{
2741			name:                  "strips legacy registry prefix",
2742			registryDomain:        "new-reg.example.com",
2743			legacyRegistryDomains: []string{"old-reg.example.com"},
2744			image:                 "old-reg.example.com/myapp:latest",
2745			expected:              "myapp:latest",
2746		},
2747		{
2748			name:           "preserves image without registry prefix",
2749			registryDomain: "reg.example.com",
2750			image:          "nginx:latest",
2751			expected:       "nginx:latest",
2752		},
2753		{
2754			name:           "preserves image with different registry",
2755			registryDomain: "reg.example.com",
2756			image:          "gcr.io/project/image:latest",
2757			expected:       "gcr.io/project/image:latest",
2758		},
2759		{
2760			name:           "handles empty registry domain",
2761			registryDomain: "",
2762			image:          "reg.example.com/myapp:latest",
2763			expected:       "reg.example.com/myapp:latest",
2764		},
2765		{
2766			name:           "handles nested paths",
2767			registryDomain: "reg.example.com",
2768			image:          "reg.example.com/org/repo/app:latest",
2769			expected:       "org/repo/app:latest",
2770		},
2771	}
2772
2773	for _, tt := range tests {
2774		t.Run(tt.name, func(t *testing.T) {
2775			runtime := mocks.NewMockContainerRuntime(t)
2776			envLoader := mocks.NewMockEnvLoader(t)
2777			eventBus := mocks.NewMockEventPublisher(t)
2778
2779			config := Config{
2780				RegistryDomain:        tt.registryDomain,
2781				LegacyRegistryDomains: tt.legacyRegistryDomains,
2782			}
2783			svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2784
2785			result := svc.stripRegistryPrefix(tt.image)
2786			assert.Equal(t, tt.expected, result)
2787		})
2788	}
2789}
2790
2791func TestSanitizeName(t *testing.T) {
2792	tests := []struct {
2793		domain      string
2794		expected    string
2795		description string
2796	}{
2797		{
2798			domain:      "git.example.com",
2799			expected:    "git__example__com",
2800			description: "Dots become double underscores",
2801		},
2802		{
2803			domain:      "git-example.com",
2804			expected:    "git-example__com",
2805			description: "Hyphens preserved, dots become underscores",
2806		},
2807		{
2808			domain:      "app:8080.example.com",
2809			expected:    "app-_8080__example__com",
2810			description: "Colons become hyphen-underscore",
2811		},
2812		{
2813			domain:      "git.example.com:3000",
2814			expected:    "git__example__com-_3000",
2815			description: "Multiple separators handled distinctly",
2816		},
2817		{
2818			domain:      "simple.com",
2819			expected:    "simple__com",
2820			description: "Simple domain",
2821		},
2822	}
2823
2824	for _, tt := range tests {
2825		t.Run(tt.domain, func(t *testing.T) {
2826			result := sanitizeName(tt.domain)
2827			assert.Equal(t, tt.expected, result, "sanitization should match expected")
2828		})
2829	}
2830
2831	// Verify no collisions between potentially conflicting domains
2832	t.Run("NoCollisions", func(t *testing.T) {
2833		domains := []string{
2834			"git.example.com",
2835			"git-example.com",
2836			"app:8080.example.com",
2837			"app-8080-example.com",
2838		}
2839
2840		results := make(map[string]string)
2841		for _, d := range domains {
2842			result := sanitizeName(d)
2843			if original, exists := results[result]; exists {
2844				t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
2845			}
2846			results[result] = d
2847		}
2848	})
2849}
2850
2851func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
2852	// Verify that concurrent Deploy calls for the same domain are serialized
2853	// and both succeed without container name conflicts.
2854	runtime := mocks.NewMockContainerRuntime(t)
2855	envLoader := mocks.NewMockEnvLoader(t)
2856	eventBus := mocks.NewMockEventPublisher(t)
2857	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2858
2859	config := testMinDelayConfig()
2860	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2861	svc.SetProxyCacheInvalidator(cacheInvalidator)
2862	ctx := testContext()
2863
2864	route := domain.Route{
2865		Domain: "test.example.com",
2866		Image:  "myapp:latest",
2867	}
2868
2869	// Track call order to verify serialization
2870	var callOrder []string
2871	var callMu sync.Mutex
2872
2873	// Setup mocks that will be called by both deploys sequentially.
2874	// First deploy: no existing container → creates gordon-test.example.com
2875	// Second deploy: sees first container → creates gordon-test.example.com-new
2876
2877	// First deploy resolves from runtime (no container in memory yet)
2878	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
2879
2880	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
2881	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
2882	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
2883	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
2884	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, nil).Times(2)
2885	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
2886
2887	createCall := 0
2888	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
2889		RunAndReturn(func(_ context.Context, cfg *domain.ContainerConfig) (*domain.Container, error) {
2890			callMu.Lock()
2891			createCall++
2892			n := createCall
2893			callOrder = append(callOrder, fmt.Sprintf("create-%d:%s", n, cfg.Name))
2894			callMu.Unlock()
2895			return &domain.Container{
2896				ID:     fmt.Sprintf("container-%d", n),
2897				Name:   cfg.Name,
2898				Image:  cfg.Image,
2899				Status: "created",
2900			}, nil
2901		})
2902
2903	runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
2904	// IsContainerRunning: 2x per deploy in waitForReady + 1x stabilization for the second deploy (which has existing)
2905	runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(5)
2906
2907	runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
2908		RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
2909			// Return correct container name based on ID
2910			// container-1 is the first deploy (canonical name)
2911			// container-2 is the second deploy (with -new suffix)
2912			name := "gordon-test.example.com"
2913			if id == "container-2" {
2914				name = "gordon-test.example.com-new"
2915			}
2916			return &domain.Container{
2917				ID:     id,
2918				Name:   name,
2919				Image:  "myapp:latest",
2920				Status: "running",
2921				Ports:  []int{8080},
2922			}, nil
2923		})
2924
2925	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)
2926
2927	// Both deploys call InvalidateTarget (to ensure proxy picks up the new container).
2928	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
2929
2930	// which means it will also stop+remove+rename the old container.
2931	runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
2932	runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
2933	runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Once()
2934
2935	// Launch two concurrent deploys
2936	var wg sync.WaitGroup
2937	errs := make([]error, 2)
2938
2939	wg.Add(2)
2940	for i := range 2 {
2941		go func(idx int) {
2942			defer wg.Done()
2943			_, errs[idx] = svc.Deploy(ctx, route)
2944		}(i)
2945	}
2946	wg.Wait()
2947	svc.WaitForCleanup()
2948
2949	// Both should succeed (second deploy waits for the first to finish)
2950	assert.NoError(t, errs[0], "first deploy should succeed")
2951	assert.NoError(t, errs[1], "second deploy should succeed")
2952
2953	// Verify creates were serialized (not interleaved)
2954	callMu.Lock()
2955	assert.Len(t, callOrder, 2, "should have exactly 2 create calls")
2956	// First deploy uses canonical name, second uses -new suffix (zero-downtime)
2957	assert.Equal(t, "create-1:gordon-test.example.com", callOrder[0], "first create should use canonical name")
2958	assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
2959	callMu.Unlock()
2960}
2961
2962func TestService_Deploy_ContextCancellation(t *testing.T) {
2963	// Verify that deploy lock acquisition respects context cancellation.
2964	runtime := mocks.NewMockContainerRuntime(t)
2965	envLoader := mocks.NewMockEnvLoader(t)
2966	eventBus := mocks.NewMockEventPublisher(t)
2967
2968	config := Config{
2969		AllowedRegistries:    []string{"docker.io"},
2970		ReadinessDelay:       time.Millisecond,
2971		DrainDelay:           time.Millisecond,
2972		DrainDelayConfigured: true,
2973	}
2974	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2975
2976	route := domain.Route{
2977		Domain: "test.example.com",
2978		Image:  "myapp:latest",
2979	}
2980
2981	// Test context cancelled before lock acquisition.
2982	cancelledCtx, cancel := context.WithCancel(testContext())
2983	cancel() // Cancel immediately
2984
2985	_, err := svc.Deploy(cancelledCtx, route)
2986	assert.Error(t, err, "deploy should fail with cancelled context")
2987	assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
2988}
2989
2990// TestService_Deploy_CacheInvalidationBeforeOldContainerStop verifies the fix for
2991// the proxy cache race condition: InvalidateTarget must be called synchronously
2992// BEFORE the old container is stopped, preventing 503 errors during deployment.
2993func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
2994	runtime := mocks.NewMockContainerRuntime(t)
2995	envLoader := mocks.NewMockEnvLoader(t)
2996	eventBus := mocks.NewMockEventPublisher(t)
2997	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2998
2999	config := testMinDelayConfig()
3000	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3001	svc.SetProxyCacheInvalidator(cacheInvalidator)
3002	ctx := testContext()
3003
3004	// Pre-populate with existing container
3005	existingContainer := &domain.Container{
3006		ID:     "old-container",
3007		Name:   "gordon-test.example.com",
3008		Status: "running",
3009	}
3010	svc.containers["test.example.com"] = existingContainer
3011
3012	route := domain.Route{
3013		Domain: "test.example.com",
3014		Image:  "myapp:v2",
3015	}
3016
3017	// Track ordering of cache invalidation and container stop
3018	var callOrder []string
3019	var orderMu sync.Mutex
3020
3021	// Standard deploy mocks
3022	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3023	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
3024	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3025	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3026	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3027	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3028
3029	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3030	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
3031	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3032	// Wait for ready (2 calls) + stabilization check (1 call)
3033	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3034	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3035		ID: "new-container", Status: "running",
3036	}, nil)
3037	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3038
3039	// Track: cache invalidation should happen first
3040	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").
3041		Run(func(_ context.Context, _ string) {
3042			orderMu.Lock()
3043			callOrder = append(callOrder, "invalidate_cache")
3044			orderMu.Unlock()
3045		}).Return()
3046
3047	// Track: stop should happen after invalidation
3048	runtime.EXPECT().StopContainer(mock.Anything, "old-container").
3049		RunAndReturn(func(_ context.Context, _ string) error {
3050			orderMu.Lock()
3051			callOrder = append(callOrder, "stop_old_container")
3052			orderMu.Unlock()
3053			return nil
3054		})
3055	runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
3056	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3057
3058	result, err := svc.Deploy(ctx, route)
3059
3060	assert.NoError(t, err)
3061	svc.WaitForCleanup()
3062	assert.Equal(t, "new-container", result.ID)
3063
3064	// Verify ordering: cache invalidation MUST happen before old container stop
3065	orderMu.Lock()
3066	defer orderMu.Unlock()
3067	assert.Equal(t, []string{"invalidate_cache", "stop_old_container"}, callOrder,
3068		"InvalidateTarget must be called before StopContainer to prevent 503 errors")
3069}
3070
3071// TestService_Deploy_NilCacheInvalidator verifies that deploy works gracefully
3072// when no cache invalidator is set (e.g., in tests or minimal configurations).
3073func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
3074	runtime := mocks.NewMockContainerRuntime(t)
3075	envLoader := mocks.NewMockEnvLoader(t)
3076	eventBus := mocks.NewMockEventPublisher(t)
3077
3078	config := testMinDelayConfig()
3079	// Intentionally NOT setting cache invalidator
3080	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3081	ctx := testContext()
3082
3083	// Pre-populate with existing container
3084	svc.containers["test.example.com"] = &domain.Container{
3085		ID:     "old-container",
3086		Name:   "gordon-test.example.com",
3087		Status: "running",
3088	}
3089
3090	route := domain.Route{
3091		Domain: "test.example.com",
3092		Image:  "myapp:v2",
3093	}
3094
3095	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3096	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
3097	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3098	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3099	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3100	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3101
3102	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3103	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
3104	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3105	// Wait for ready (2 calls) + stabilization check (1 call)
3106	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3107	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3108		ID: "new-container", Status: "running",
3109	}, nil)
3110	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3111	runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
3112	runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
3113	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3114
3115	result, err := svc.Deploy(ctx, route)
3116
3117	assert.NoError(t, err)
3118	svc.WaitForCleanup()
3119	assert.Equal(t, "new-container", result.ID)
3120}
3121
3122func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
3123	runtime := mocks.NewMockContainerRuntime(t)
3124	envLoader := mocks.NewMockEnvLoader(t)
3125	eventBus := mocks.NewMockEventPublisher(t)
3126
3127	config := Config{
3128		AllowedRegistries:    []string{"docker.io"},
3129		ReadinessDelay:       time.Millisecond,
3130		DrainDelay:           time.Millisecond,
3131		DrainDelayConfigured: true,
3132	}
3133	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3134	ctx := testContext()
3135	expectedEnvHash := hashEnvironment([]string{})
3136
3137	// Pre-populate with existing container that has an ImageID (set by InspectContainer).
3138	// This simulates the first deploy (from image.pushed event) having already completed.
3139	existingContainer := &domain.Container{
3140		ID:      "existing-container",
3141		Name:    "gordon-test.example.com",
3142		Image:   "myapp:latest",
3143		ImageID: "sha256:abc123",
3144		Status:  "running",
3145		Labels: map[string]string{
3146			domain.LabelEnvHash: expectedEnvHash,
3147		},
3148	}
3149	svc.containers["test.example.com"] = existingContainer
3150
3151	route := domain.Route{
3152		Domain: "test.example.com",
3153		Image:  "myapp:latest",
3154	}
3155
3156	// prepareDeployResources will run: orphan cleanup, image pull, etc.
3157	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3158	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3159	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3160	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3161	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3162	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3163
3164	// skipRedundantDeploy: resolve image ID and compare with existing container
3165	runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
3166	runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
3167
3168	// No CreateContainer, StartContainer, or readiness calls should happen
3169
3170	result, err := svc.Deploy(ctx, route)
3171
3172	assert.NoError(t, err)
3173	assert.NotNil(t, result)
3174	assert.Equal(t, "existing-container", result.ID)
3175
3176	// Container should still be tracked
3177	tracked, exists := svc.Get(ctx, "test.example.com")
3178	assert.True(t, exists)
3179	assert.Equal(t, "existing-container", tracked.ID)
3180}
3181
3182func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testing.T) {
3183	runtime := mocks.NewMockContainerRuntime(t)
3184	envLoader := mocks.NewMockEnvLoader(t)
3185	eventBus := mocks.NewMockEventPublisher(t)
3186
3187	config := Config{
3188		AllowedRegistries:    []string{"docker.io"},
3189		ReadinessDelay:       time.Millisecond,
3190		DrainDelay:           time.Millisecond,
3191		DrainDelayConfigured: true,
3192	}
3193	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3194	ctx := testContext()
3195	expectedEnvHash := hashEnvironment([]string{})
3196
3197	// Pre-populate with existing container missing ImageID (common after stale in-memory state).
3198	existingContainer := &domain.Container{
3199		ID:     "existing-container",
3200		Name:   "gordon-test.example.com",
3201		Image:  "myapp:latest",
3202		Status: "running",
3203		Labels: map[string]string{
3204			domain.LabelEnvHash: expectedEnvHash,
3205		},
3206	}
3207	svc.containers["test.example.com"] = existingContainer
3208
3209	route := domain.Route{
3210		Domain: "test.example.com",
3211		Image:  "myapp:latest",
3212	}
3213
3214	// prepareDeployResources
3215	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3216	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3217	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3218	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3219	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3220	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3221
3222	// Existing container image ID is recovered from runtime inspection,
3223	// then redundancy check can skip the deploy.
3224	runtime.EXPECT().InspectContainer(mock.Anything, "existing-container").Return(&domain.Container{
3225		ID:      "existing-container",
3226		ImageID: "sha256:abc123",
3227		Status:  "running",
3228	}, nil)
3229	runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
3230	runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
3231
3232	result, err := svc.Deploy(ctx, route)
3233
3234	assert.NoError(t, err)
3235	assert.NotNil(t, result)
3236	assert.Equal(t, "existing-container", result.ID)
3237
3238	tracked, exists := svc.Get(ctx, "test.example.com")
3239	assert.True(t, exists)
3240	assert.Equal(t, "existing-container", tracked.ID)
3241}
3242
3243func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
3244	runtime := mocks.NewMockContainerRuntime(t)
3245	envLoader := mocks.NewMockEnvLoader(t)
3246	eventBus := mocks.NewMockEventPublisher(t)
3247	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3248
3249	config := testMinDelayConfig()
3250	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3251	svc.SetProxyCacheInvalidator(cacheInvalidator)
3252	ctx := testContext()
3253
3254	// Existing container with a DIFFERENT image ID than what's being deployed
3255	existingContainer := &domain.Container{
3256		ID:      "old-container",
3257		Name:    "gordon-test.example.com",
3258		Image:   "myapp:latest",
3259		ImageID: "sha256:old-image",
3260		Status:  "running",
3261	}
3262	svc.containers["test.example.com"] = existingContainer
3263
3264	route := domain.Route{
3265		Domain: "test.example.com",
3266		Image:  "myapp:latest",
3267	}
3268
3269	// prepareDeployResources
3270	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3271	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3272	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3273	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3274	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3275	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3276
3277	// skipRedundantDeploy: image IDs differ, so deploy proceeds
3278	runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:new-image", nil)
3279
3280	// Full deploy proceeds
3281	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3282	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
3283	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3284	// Wait for ready (2 calls) + stabilization check (1 call)
3285	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3286	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3287		ID: "new-container", Status: "running",
3288	}, nil)
3289	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3290	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
3291	runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
3292	runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
3293	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3294
3295	result, err := svc.Deploy(ctx, route)
3296
3297	assert.NoError(t, err)
3298	svc.WaitForCleanup()
3299	assert.Equal(t, "new-container", result.ID)
3300}
3301
3302func TestSyncContainersRebuildsAttachmentMap(t *testing.T) {
3303	runtime := mocks.NewMockContainerRuntime(t)
3304	envLoader := mocks.NewMockEnvLoader(t)
3305	eventBus := mocks.NewMockEventPublisher(t)
3306
3307	svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
3308	ctx := testContext()
3309
3310	// ListContainers returns one main container and one attachment container
3311	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
3312		{
3313			ID: "main-container-1",
3314			Labels: map[string]string{
3315				domain.LabelDomain:  "app.example.com",
3316				domain.LabelManaged: "true",
3317			},
3318		},
3319		{
3320			ID: "attachment-container-1",
3321			Labels: map[string]string{
3322				domain.LabelManaged:    "true",
3323				domain.LabelAttachment: "true",
3324				domain.LabelAttachedTo: "app.example.com",
3325			},
3326		},
3327	}, nil).Once()
3328
3329	require.NoError(t, svc.SyncContainers(ctx))
3330
3331	// Main container should be in s.containers
3332	tracked, exists := svc.Get(ctx, "app.example.com")
3333	require.True(t, exists, "main container should be tracked after SyncContainers")
3334	assert.Equal(t, "main-container-1", tracked.ID)
3335
3336	// Attachment container should be in s.attachments under the owner domain
3337	svc.mu.RLock()
3338	attachIDs := svc.attachments["app.example.com"]
3339	svc.mu.RUnlock()
3340
3341	require.Len(t, attachIDs, 1, "attachment map should contain one entry for app.example.com after SyncContainers")
3342	assert.Equal(t, "attachment-container-1", attachIDs[0])
3343}
3344
3345func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
3346	runtime := mocks.NewMockContainerRuntime(t)
3347	envLoader := mocks.NewMockEnvLoader(t)
3348	eventBus := mocks.NewMockEventPublisher(t)
3349
3350	config := testMinDelayConfig()
3351	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3352	ctx := testContext()
3353	expectedEnvHash := hashEnvironment([]string{})
3354
3355	// Existing container has same image ID but is NOT running (crashed)
3356	existingContainer := &domain.Container{
3357		ID:      "existing-container",
3358		Name:    "gordon-test.example.com",
3359		Image:   "myapp:latest",
3360		ImageID: "sha256:abc123",
3361		Status:  "exited",
3362		Labels: map[string]string{
3363			domain.LabelEnvHash: expectedEnvHash,
3364		},
3365	}
3366	svc.containers["test.example.com"] = existingContainer
3367
3368	route := domain.Route{
3369		Domain: "test.example.com",
3370		Image:  "myapp:latest",
3371	}
3372
3373	// prepareDeployResources
3374	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3375	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3376	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3377	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3378	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3379	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3380
3381	// skipRedundantDeploy: same image ID but container not running => proceed with deploy
3382	runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
3383	runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(false, nil)
3384
3385	// Full deploy proceeds (replaces crashed container)
3386	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3387	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
3388	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3389	// Wait for ready (2 calls) + stabilization check (1 call)
3390	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3391	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3392		ID: "new-container", Status: "running",
3393	}, nil)
3394	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3395
3396	// Old (exited) container is still finalized
3397	runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
3398	runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
3399	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3400
3401	result, err := svc.Deploy(ctx, route)
3402
3403	assert.NoError(t, err)
3404	svc.WaitForCleanup()
3405	assert.Equal(t, "new-container", result.ID)
3406}
3407
3408func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
3409	runtime := mocks.NewMockContainerRuntime(t)
3410	envLoader := mocks.NewMockEnvLoader(t)
3411	eventBus := mocks.NewMockEventPublisher(t)
3412
3413	config := testMinDelayConfig()
3414	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3415	ctx := testContext()
3416	expectedEnvHash := hashEnvironment([]string{})
3417
3418	existingContainer := &domain.Container{
3419		ID:      "existing-container",
3420		Name:    "gordon-test.example.com",
3421		Image:   "myapp:latest",
3422		ImageID: "sha256:abc123",
3423		Status:  "running",
3424		Labels: map[string]string{
3425			domain.LabelEnvHash: "different-hash",
3426		},
3427	}
3428	svc.containers["test.example.com"] = existingContainer
3429
3430	route := domain.Route{
3431		Domain: "test.example.com",
3432		Image:  "myapp:latest",
3433	}
3434
3435	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3436	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3437	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3438	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3439	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3440	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3441
3442	runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
3443	runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
3444
3445	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3446	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3447		return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash
3448	})).Return(newContainer, nil)
3449	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3450	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3451	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3452		ID: "new-container", Status: "running",
3453	}, nil)
3454	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3455	runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
3456	runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
3457	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3458
3459	result, err := svc.Deploy(ctx, route)
3460
3461	assert.NoError(t, err)
3462	svc.WaitForCleanup()
3463	assert.Equal(t, "new-container", result.ID)
3464}
3465
3466func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) {
3467	runtime := mocks.NewMockContainerRuntime(t)
3468	envLoader := mocks.NewMockEnvLoader(t)
3469	eventBus := mocks.NewMockEventPublisher(t)
3470
3471	config := testMinDelayConfig()
3472	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3473	ctx := testContext()
3474	expectedEnvHash := hashEnvironment([]string{})
3475
3476	existingContainer := &domain.Container{
3477		ID:      "existing-container",
3478		Name:    "gordon-test.example.com",
3479		Image:   "myapp:latest",
3480		ImageID: "sha256:abc123",
3481		Status:  "running",
3482		Labels:  map[string]string{},
3483	}
3484	svc.containers["test.example.com"] = existingContainer
3485
3486	route := domain.Route{
3487		Domain: "test.example.com",
3488		Image:  "myapp:latest",
3489	}
3490
3491	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3492	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
3493	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
3494	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
3495	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3496	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
3497
3498	runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
3499	runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
3500
3501	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3502	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3503		return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash
3504	})).Return(newContainer, nil)
3505	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3506	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3507	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3508		ID: "new-container", Status: "running",
3509	}, nil)
3510	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3511	runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
3512	runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
3513	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3514
3515	result, err := svc.Deploy(ctx, route)
3516
3517	assert.NoError(t, err)
3518	svc.WaitForCleanup()
3519	assert.Equal(t, "new-container", result.ID)
3520}
3521
3522func TestService_attachmentEnvDrifted_ReturnsFalseWhenHashMatches(t *testing.T) {
3523	runtime := mocks.NewMockContainerRuntime(t)
3524	envLoader := mocks.NewMockEnvLoader(t)
3525	eventBus := mocks.NewMockEventPublisher(t)
3526
3527	svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
3528	ctx := testContext()
3529	imageRef := svc.buildImageRef("postgres:16")
3530	envVars := []string{"POSTGRES_PASSWORD=secret"}
3531	envHash := hashEnvironment(envVars)
3532
3533	envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil)
3534	runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil)
3535
3536	drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{
3537		Labels: map[string]string{domain.LabelEnvHash: envHash},
3538	}, "gordon-app-example-com-postgres", "postgres:16")
3539
3540	assert.NoError(t, err)
3541	assert.False(t, drifted)
3542}
3543
3544func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashDiffers(t *testing.T) {
3545	runtime := mocks.NewMockContainerRuntime(t)
3546	envLoader := mocks.NewMockEnvLoader(t)
3547	eventBus := mocks.NewMockEventPublisher(t)
3548
3549	svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
3550	ctx := testContext()
3551	imageRef := svc.buildImageRef("postgres:16")
3552	envVars := []string{"POSTGRES_PASSWORD=secret"}
3553
3554	envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil)
3555	runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil)
3556
3557	drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{
3558		Labels: map[string]string{domain.LabelEnvHash: "stale-hash"},
3559	}, "gordon-app-example-com-postgres", "postgres:16")
3560
3561	assert.NoError(t, err)
3562	assert.True(t, drifted)
3563}
3564
3565func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashMissing(t *testing.T) {
3566	runtime := mocks.NewMockContainerRuntime(t)
3567	envLoader := mocks.NewMockEnvLoader(t)
3568	eventBus := mocks.NewMockEventPublisher(t)
3569
3570	svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
3571	ctx := testContext()
3572	imageRef := svc.buildImageRef("postgres:16")
3573	envVars := []string{"POSTGRES_PASSWORD=secret"}
3574
3575	envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil)
3576	runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil)
3577
3578	drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{
3579		Labels: map[string]string{},
3580	}, "gordon-app-example-com-postgres", "postgres:16")
3581
3582	assert.NoError(t, err)
3583	assert.True(t, drifted)
3584}
3585
3586// TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan
3587// cleanup never stops a running container with the canonical name. Only stopped
3588// canonical containers and temp containers (-new/-next) should be cleaned up.
3589// The active canonical container is resolved via resolveExistingContainer (it has the
3590// managed label), so the new container is created with the "-new" suffix — Docker
3591// disallows creating a container with the same name as an existing running container.
3592func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
3593	runtime := mocks.NewMockContainerRuntime(t)
3594	envLoader := mocks.NewMockEnvLoader(t)
3595	eventBus := mocks.NewMockEventPublisher(t)
3596
3597	config := testMinDelayConfig()
3598	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3599	ctx := testContext()
3600
3601	// The canonical container is running and has the managed label — it will be
3602	// discovered by resolveExistingContainer's slow path (no in-memory state).
3603	existingContainer := &domain.Container{
3604		ID:     "active-canonical",
3605		Name:   "gordon-test.example.com",
3606		Status: "running",
3607		Labels: map[string]string{domain.LabelManaged: "true"},
3608	}
3609
3610	route := domain.Route{
3611		Domain: "test.example.com",
3612		Image:  "myapp:v2",
3613	}
3614
3615	// resolveExistingContainer: slow path finds the running canonical container via label.
3616	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{existingContainer}, nil)
3617
3618	// cleanupOrphanedContainers: finds the running canonical AND a stale -new leftover.
3619	// The canonical container must NOT be killed because it's running.
3620	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
3621		existingContainer,
3622		{
3623			ID:     "stale-leftover",
3624			Name:   "gordon-test.example.com-new",
3625			Status: "exited",
3626		},
3627	}, nil)
3628
3629	// Only the stale -new container should be stopped and removed during orphan cleanup.
3630	runtime.EXPECT().StopContainer(mock.Anything, "stale-leftover").Return(nil).Once()
3631	runtime.EXPECT().RemoveContainer(mock.Anything, "stale-leftover", true).Return(nil).Once()
3632
3633	// Image operations: pull new image (existing.Image is empty so redundancy check is skipped)
3634	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3635	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3636	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3637	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3638
3639	// Environment
3640	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3641	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3642
3643	// Create container — existing is the canonical container, so the new one gets "-new" suffix.
3644	// Docker enforces unique container names; using "-new" avoids a name collision.
3645	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3646	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3647		return cfg.Name == "gordon-test.example.com-new"
3648	})).Return(newContainer, nil)
3649	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3650
3651	// Wait for ready (delay mode): pollContainerRunning (1×) + waitForReadyByDelay (1×)
3652	// Post-switch stabilization: 1×  — total 3 calls.
3653	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3654
3655	// Inspect after ready
3656	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3657		ID:     "new-container",
3658		Status: "running",
3659		Ports:  []int{8080},
3660	}, nil)
3661
3662	// Rename new container to canonical name after stabilization.
3663	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil).Once()
3664
3665	// Stop and remove old canonical container.
3666	runtime.EXPECT().StopContainer(mock.Anything, "active-canonical").Return(nil).Once()
3667	runtime.EXPECT().RemoveContainer(mock.Anything, "active-canonical", true).Return(nil).Once()
3668
3669	// Publish event
3670	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3671
3672	result, err := svc.Deploy(ctx, route)
3673
3674	assert.NoError(t, err)
3675	svc.WaitForCleanup()
3676	assert.Equal(t, "new-container", result.ID)
3677
3678	// Verify new container is tracked
3679	tracked, exists := svc.Get(ctx, "test.example.com")
3680	assert.True(t, exists)
3681	assert.Equal(t, "new-container", tracked.ID)
3682}
3683
3684func TestService_CleanupOrphanedContainers_SkipsRestartingContainer(t *testing.T) {
3685	runtime := mocks.NewMockContainerRuntime(t)
3686	envLoader := mocks.NewMockEnvLoader(t)
3687	eventBus := mocks.NewMockEventPublisher(t)
3688
3689	svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
3690	ctx := testContext()
3691
3692	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
3693		{
3694			ID:     "restarting-container",
3695			Name:   "gordon-test.example.com",
3696			Status: "restarting",
3697		},
3698	}, nil)
3699
3700	err := svc.cleanupOrphanedContainers(ctx, "test.example.com", "")
3701
3702	assert.NoError(t, err)
3703}
3704
3705// TestService_Deploy_RollbackOnPostSwitchCrash verifies that if the new container crashes
3706// during the post-switch stabilization window, traffic is automatically rolled back to the
3707// old container: the old container is restored as tracked, the proxy cache is re-invalidated,
3708// and the failed new container is cleaned up.
3709func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
3710	runtime := mocks.NewMockContainerRuntime(t)
3711	envLoader := mocks.NewMockEnvLoader(t)
3712	eventBus := mocks.NewMockEventPublisher(t)
3713	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3714
3715	config := Config{
3716		AllowedRegistries:    []string{"docker.io"},
3717		ReadinessDelay:       time.Millisecond,
3718		DrainDelay:           time.Millisecond,
3719		DrainDelayConfigured: true,
3720		StabilizationDelay:   time.Millisecond,
3721	}
3722	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3723	svc.SetProxyCacheInvalidator(cacheInvalidator)
3724	ctx := testContext()
3725
3726	// Pre-populate with existing container
3727	existing := &domain.Container{
3728		ID:     "old-container",
3729		Name:   "gordon-test.example.com",
3730		Status: "running",
3731	}
3732	svc.containers["test.example.com"] = existing
3733
3734	route := domain.Route{
3735		Domain: "test.example.com",
3736		Image:  "myapp:v2",
3737	}
3738
3739	// Cleanup orphans
3740	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3741
3742	// Image operations
3743	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3744	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3745	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3746	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3747
3748	// Environment
3749	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3750	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3751
3752	// Create new container
3753	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3754	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3755		return cfg.Name == "gordon-test.example.com-new"
3756	})).Return(newContainer, nil)
3757	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3758
3759	// Readiness: new container is running during readiness checks (2 calls)
3760	// Stabilization: new container has crashed (returns false)
3761	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
3762	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()
3763
3764	// Rollback: verify old container is still running before restoring
3765	runtime.EXPECT().IsContainerRunning(mock.Anything, "old-container").Return(true, nil).Once()
3766
3767	// Inspect after ready
3768	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3769		ID:     "new-container",
3770		Name:   "gordon-test.example.com-new",
3771		Status: "running",
3772	}, nil)
3773
3774	// Publish event (happens during activateDeployedContainer)
3775	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3776
3777	// InvalidateTarget called TWICE: once during activate, once during rollback
3778	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
3779
3780	// Cleanup failed new container during rollback
3781	runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
3782	runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
3783
3784	// StopContainer on old-container should NOT be called (old stays running)
3785	// (no expectation for StopContainer("old-container"))
3786
3787	result, err := svc.Deploy(ctx, route)
3788
3789	// Deploy returns the old (existing) container after rollback
3790	assert.NoError(t, err)
3791	assert.NotNil(t, result)
3792	assert.Equal(t, "old-container", result.ID, "should return old container after rollback")
3793
3794	// Verify old container is restored as tracked
3795	trackedRB, existsRB := svc.Get(ctx, "test.example.com")
3796	assert.True(t, existsRB)
3797	assert.Equal(t, "old-container", trackedRB.ID, "old container should be restored after rollback")
3798}
3799
3800// TestService_Deploy_StabilizationSuccess verifies that when the new container remains
3801// healthy during the stabilization window, deployment proceeds normally and the old
3802// container is finalized (stopped and removed).
3803func TestService_Deploy_StabilizationSuccess(t *testing.T) {
3804	runtime := mocks.NewMockContainerRuntime(t)
3805	envLoader := mocks.NewMockEnvLoader(t)
3806	eventBus := mocks.NewMockEventPublisher(t)
3807	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3808
3809	config := Config{
3810		AllowedRegistries:    []string{"docker.io"},
3811		ReadinessDelay:       time.Millisecond,
3812		DrainDelay:           time.Millisecond,
3813		DrainDelayConfigured: true,
3814		StabilizationDelay:   time.Millisecond,
3815	}
3816	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3817	svc.SetProxyCacheInvalidator(cacheInvalidator)
3818	ctx := testContext()
3819
3820	// Pre-populate with existing container
3821	existing := &domain.Container{
3822		ID:     "old-container",
3823		Name:   "gordon-test.example.com",
3824		Status: "running",
3825	}
3826	svc.containers["test.example.com"] = existing
3827
3828	route := domain.Route{
3829		Domain: "test.example.com",
3830		Image:  "myapp:v2",
3831	}
3832
3833	// Cleanup orphans
3834	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3835
3836	// Image operations
3837	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3838	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3839	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3840	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3841
3842	// Environment
3843	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3844	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3845
3846	// Create new container
3847	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3848	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3849		return cfg.Name == "gordon-test.example.com-new"
3850	})).Return(newContainer, nil)
3851	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3852
3853	// Readiness (2 calls) + stabilization check (1 call) — all return running
3854	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3855
3856	// Inspect after ready
3857	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
3858		ID:     "new-container",
3859		Name:   "gordon-test.example.com-new",
3860		Status: "running",
3861	}, nil)
3862
3863	// Publish event
3864	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3865
3866	// Cache invalidation (once, during activate)
3867	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
3868
3869	// Old container finalized normally
3870	runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
3871	runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
3872	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3873
3874	result, err := svc.Deploy(ctx, route)
3875
3876	assert.NoError(t, err)
3877	svc.WaitForCleanup()
3878	assert.NotNil(t, result)
3879	assert.Equal(t, "new-container", result.ID, "should return new container on successful stabilization")
3880
3881	// Verify new container is tracked
3882	trackedSS, existsSS := svc.Get(ctx, "test.example.com")
3883	assert.True(t, existsSS)
3884	assert.Equal(t, "new-container", trackedSS.ID)
3885}
3886
3887// TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady verifies the strict
3888// ordering invariant: StopContainer("old-container") must NEVER happen before
3889// InspectContainer("new-container") completes (the last step of readiness).
3890// Uses testify's NotBefore() to enforce mock call ordering.
3891func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) {
3892	runtime := mocks.NewMockContainerRuntime(t)
3893	envLoader := mocks.NewMockEnvLoader(t)
3894	eventBus := mocks.NewMockEventPublisher(t)
3895	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
3896
3897	config := Config{
3898		AllowedRegistries:    []string{"docker.io"},
3899		ReadinessDelay:       time.Millisecond,
3900		DrainDelay:           time.Millisecond,
3901		DrainDelayConfigured: true,
3902		StabilizationDelay:   time.Millisecond,
3903	}
3904	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3905	svc.SetProxyCacheInvalidator(cacheInvalidator)
3906	ctx := testContext()
3907
3908	// Pre-populate with existing tracked container
3909	existingContainer := &domain.Container{
3910		ID:     "old-container",
3911		Name:   "gordon-test.example.com",
3912		Status: "running",
3913	}
3914	svc.containers["test.example.com"] = existingContainer
3915
3916	route := domain.Route{
3917		Domain: "test.example.com",
3918		Image:  "myapp:v2",
3919	}
3920
3921	// Cleanup orphans
3922	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3923
3924	// Image operations
3925	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3926	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3927	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3928	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3929
3930	// Environment
3931	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3932	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3933
3934	// Create new container with -new suffix for zero-downtime
3935	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3936	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3937		return cfg.Name == "gordon-test.example.com-new"
3938	})).Return(newContainer, nil)
3939	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3940
3941	// Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call)
3942	// Stabilization check (1 call)
3943	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
3944
3945	// KEY ORDERING CONSTRAINT: InspectContainer("new-container") is the last step
3946	// of readiness inside createStartedContainer. StopContainer("old-container")
3947	// must happen AFTER this call completes.
3948	inspectCall := runtime.EXPECT().InspectContainer(mock.Anything, "new-container").
3949		Return(&domain.Container{
3950			ID:     "new-container",
3951			Name:   "gordon-test.example.com-new",
3952			Status: "running",
3953		}, nil)
3954
3955	// Publish event
3956	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3957
3958	// Synchronous cache invalidation
3959	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
3960
3961	// STRICT ORDERING: StopContainer on old container must NOT happen before
3962	// InspectContainer on new container (readiness completion)
3963	runtime.EXPECT().StopContainer(mock.Anything, "old-container").
3964		Return(nil).
3965		NotBefore(inspectCall.Call)
3966
3967	runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
3968	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
3969
3970	result, err := svc.Deploy(ctx, route)
3971
3972	assert.NoError(t, err)
3973	svc.WaitForCleanup()
3974	assert.NotNil(t, result)
3975	assert.Equal(t, "new-container", result.ID)
3976
3977	// Verify new container is tracked
3978	tracked, exists := svc.Get(ctx, "test.example.com")
3979	assert.True(t, exists)
3980	assert.Equal(t, "new-container", tracked.ID)
3981}
3982
3983// TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched verifies that when a
3984// new container fails readiness (IsContainerRunning returns false), the old container
3985// is completely untouched: never stopped, never removed. Deploy returns an error and
3986// the old container remains as the tracked container.
3987func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) {
3988	runtime := mocks.NewMockContainerRuntime(t)
3989	envLoader := mocks.NewMockEnvLoader(t)
3990	eventBus := mocks.NewMockEventPublisher(t)
3991
3992	config := Config{
3993		AllowedRegistries:    []string{"docker.io"},
3994		ReadinessDelay:       time.Millisecond,
3995		DrainDelay:           time.Millisecond,
3996		DrainDelayConfigured: true,
3997		StabilizationDelay:   time.Millisecond,
3998	}
3999	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4000	ctx := testContext()
4001
4002	// Pre-populate with existing tracked container
4003	existingContainer := &domain.Container{
4004		ID:     "old-container",
4005		Name:   "gordon-test.example.com",
4006		Status: "running",
4007	}
4008	svc.containers["test.example.com"] = existingContainer
4009
4010	route := domain.Route{
4011		Domain: "test.example.com",
4012		Image:  "myapp:v2",
4013	}
4014
4015	// Cleanup orphans
4016	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
4017
4018	// Image operations
4019	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
4020	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
4021	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
4022	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
4023
4024	// Environment
4025	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
4026	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
4027
4028	// Create new container
4029	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
4030	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
4031		return cfg.Name == "gordon-test.example.com-new"
4032	})).Return(newContainer, nil)
4033	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
4034
4035	// Readiness FAILS: pollContainerRunning succeeds (container starts) but
4036	// waitForReadyByDelay verification finds it not running.
4037	// pollContainerRunning: returns true (container initially starts)
4038	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
4039	// waitForReadyByDelay post-delay verification: returns false (container crashed)
4040	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()
4041	// Recovery window poll: return error to fail fast instead of waiting 30s
4042	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").
4043		Return(false, errors.New("container exited")).Once()
4044	runtime.EXPECT().GetContainerLogs(mock.Anything, "new-container", false).Return(dockerLogFrames(1, "crash line\n"), nil)
4045
4046	// cleanupFailedContainer: stop and remove the failed new container
4047	runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
4048	runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
4049
4050	// NOTE: StopContainer("old-container") and RemoveContainer("old-container", true)
4051	// are intentionally NOT mocked. If either is called, testify will panic with
4052	// "unexpected method call" — verifying the old container is never touched.
4053
4054	result, err := svc.Deploy(ctx, route)
4055
4056	// Deploy should return an error (readiness failure)
4057	assert.Error(t, err)
4058	assert.Nil(t, result)
4059	var deployErr *domain.DeployFailureError
4060	require.ErrorAs(t, err, &deployErr)
4061	assert.Equal(t, "failed to deploy", deployErr.Error())
4062	assert.Equal(t, "container failed readiness check", deployErr.Cause)
4063	assert.Equal(t, []string{"crash line"}, deployErr.Logs)
4064	assert.Equal(t, "gordon-test.example.com-new", deployErr.ContainerName)
4065	assert.Equal(t, "new-container", deployErr.ContainerID)
4066	assert.ErrorContains(t, deployErr.Err, "container exited")
4067
4068	// Old container must still be tracked — completely untouched
4069	tracked, exists := svc.Get(ctx, "test.example.com")
4070	assert.True(t, exists, "old container should still be tracked after readiness failure")
4071	assert.Equal(t, "old-container", tracked.ID, "old container ID should be unchanged")
4072}
4073
4074// TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy is a sanity check:
4075// first deploy with no existing container should work normally without any
4076// finalize/drain logic being triggered.
4077func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
4078	runtime := mocks.NewMockContainerRuntime(t)
4079	envLoader := mocks.NewMockEnvLoader(t)
4080	eventBus := mocks.NewMockEventPublisher(t)
4081
4082	config := Config{
4083		AllowedRegistries:    []string{"docker.io"},
4084		ReadinessDelay:       time.Millisecond,
4085		DrainDelay:           time.Millisecond,
4086		DrainDelayConfigured: true,
4087		StabilizationDelay:   time.Millisecond,
4088	}
4089	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4090	ctx := testContext()
4091
4092	// NO existing container — this is a first deploy
4093
4094	route := domain.Route{
4095		Domain: "test.example.com",
4096		Image:  "myapp:v2",
4097	}
4098
4099	// resolveExistingContainer: no container in memory, runtime returns nothing
4100	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
4101
4102	// Cleanup orphans — nothing found
4103	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
4104
4105	// Image operations
4106	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
4107	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
4108	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
4109	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
4110
4111	// Environment
4112	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
4113	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
4114
4115	// Create container — canonical name (no -new suffix since no existing container)
4116	newContainer := &domain.Container{ID: "first-container", Name: "gordon-test.example.com", Status: "created"}
4117	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
4118		return cfg.Name == "gordon-test.example.com"
4119	})).Return(newContainer, nil)
4120	runtime.EXPECT().StartContainer(mock.Anything, "first-container").Return(nil)
4121
4122	// Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call)
4123	// No stabilization check for first deploy (hasExisting is false)
4124	runtime.EXPECT().IsContainerRunning(mock.Anything, "first-container").Return(true, nil).Times(2)
4125
4126	// Inspect after readiness
4127	runtime.EXPECT().InspectContainer(mock.Anything, "first-container").Return(&domain.Container{
4128		ID:     "first-container",
4129		Name:   "gordon-test.example.com",
4130		Status: "running",
4131		Ports:  []int{8080},
4132	}, nil)
4133
4134	// Publish event
4135	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
4136
4137	// NOTE: No StopContainer, RemoveContainer, or RenameContainer expectations —
4138	// first deploy has no previous container to finalize, and no stabilization check.
4139
4140	result, err := svc.Deploy(ctx, route)
4141
4142	assert.NoError(t, err)
4143	assert.NotNil(t, result)
4144	assert.Equal(t, "first-container", result.ID)
4145	assert.Equal(t, "running", result.Status)
4146
4147	// Verify container is tracked
4148	tracked, exists := svc.Get(ctx, "test.example.com")
4149	assert.True(t, exists)
4150	assert.Equal(t, "first-container", tracked.ID)
4151}
4152
4153func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
4154	runtime := mocks.NewMockContainerRuntime(t)
4155	envLoader := mocks.NewMockEnvLoader(t)
4156	eventBus := mocks.NewMockEventPublisher(t)
4157
4158	config := Config{
4159		AllowedRegistries:    []string{"docker.io"},
4160		NetworkIsolation:     false,
4161		VolumeAutoCreate:     false,
4162		ReadinessDelay:       time.Millisecond,
4163		DrainDelay:           time.Millisecond,
4164		DrainDelayConfigured: true,
4165	}
4166
4167	svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4168	ctx := testContext()
4169
4170	route := domain.Route{
4171		Domain: "gitea.example.com",
4172		Image:  "gitea/gitea:latest",
4173	}
4174
4175	// No container in memory — runtime resolution returns nothing
4176	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
4177	// No orphaned containers
4178	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
4179
4180	// Image is found locally
4181	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"gitea/gitea:latest"}, nil)
4182
4183	// Exposed ports — image exposes both SSH and HTTP
4184	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "gitea/gitea:latest").Return([]int{22, 3000}, nil)
4185
4186	// Image labels include gordon.proxy.port and gordon.health
4187	runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
4188		domain.LabelProxyPort: "3000",
4189		domain.LabelHealth:    "/healthz",
4190		"some.other.label":    "ignored",
4191	}, nil)
4192
4193	// Load environment
4194	envLoader.EXPECT().LoadEnv(mock.Anything, "gitea.example.com").Return(nil, nil)
4195	runtime.EXPECT().InspectImageEnv(mock.Anything, "gitea/gitea:latest").Return(nil, nil)
4196
4197	// Create container — ASSERT that labels include propagated image labels
4198	createdContainer := &domain.Container{
4199		ID:     "c-gitea-123",
4200		Name:   "gordon-gitea.example.com",
4201		Image:  "gitea/gitea:latest",
4202		Status: "created",
4203	}
4204	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
4205		// Verify gordon.proxy.port was propagated from image labels
4206		if cfg.Labels[domain.LabelProxyPort] != "3000" {
4207			return false
4208		}
4209		// Verify gordon.health was propagated from image labels
4210		if cfg.Labels[domain.LabelHealth] != "/healthz" {
4211			return false
4212		}
4213		// Verify non-gordon labels were NOT propagated
4214		if _, exists := cfg.Labels["some.other.label"]; exists {
4215			return false
4216		}
4217		// Verify standard container labels are still present
4218		if cfg.Labels[domain.LabelManaged] != "true" {
4219			return false
4220		}
4221		return true
4222	})).Return(createdContainer, nil)
4223
4224	runtime.EXPECT().StartContainer(mock.Anything, "c-gitea-123").Return(nil)
4225
4226	// Readiness: delay mode (minimal)
4227	runtime.EXPECT().IsContainerRunning(mock.Anything, "c-gitea-123").Return(true, nil).Times(2)
4228
4229	// Re-inspect
4230	runningContainer := &domain.Container{
4231		ID:     "c-gitea-123",
4232		Name:   "gordon-gitea.example.com",
4233		Image:  "gitea/gitea:latest",
4234		Status: "running",
4235		Ports:  []int{22, 3000},
4236	}
4237	runtime.EXPECT().InspectContainer(mock.Anything, "c-gitea-123").Return(runningContainer, nil)
4238
4239	// Publish event
4240	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
4241
4242	result, err := svc.Deploy(ctx, route)
4243
4244	require.NoError(t, err)
4245	assert.Equal(t, "c-gitea-123", result.ID)
4246	assert.Equal(t, "running", result.Status)
4247}
4248
4249func TestLoadEnvironment_PreResolvedEnv(t *testing.T) {
4250	ctx := testContext()
4251
4252	mockEnvLoader := mocks.NewMockEnvLoader(t)
4253	mockRuntime := mocks.NewMockContainerRuntime(t)
4254
4255	// EnvLoader should NOT be called when pre-resolved env is provided
4256	mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest").
4257		Return([]string{"FROM_DOCKERFILE=base"}, nil)
4258
4259	svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4260
4261	preResolved := []string{"DB_HOST=localhost", "API_KEY=secret"}
4262	result, err := svc.loadEnvironment(ctx, preResolved, "ignored-domain", "myapp:latest")
4263	require.NoError(t, err)
4264
4265	assert.Contains(t, result, "DB_HOST=localhost")
4266	assert.Contains(t, result, "API_KEY=secret")
4267	assert.Contains(t, result, "FROM_DOCKERFILE=base")
4268}
4269
4270func TestLoadEnvironment_NoPreResolvedEnv(t *testing.T) {
4271	ctx := testContext()
4272
4273	mockEnvLoader := mocks.NewMockEnvLoader(t)
4274	mockRuntime := mocks.NewMockContainerRuntime(t)
4275
4276	mockEnvLoader.EXPECT().LoadEnv(ctx, "myapp.example.com").
4277		Return([]string{"DB_HOST=prod"}, nil)
4278	mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest").
4279		Return([]string{"FROM_DOCKERFILE=base"}, nil)
4280
4281	svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4282
4283	result, err := svc.loadEnvironment(ctx, nil, "myapp.example.com", "myapp:latest")
4284	require.NoError(t, err)
4285
4286	assert.Contains(t, result, "DB_HOST=prod")
4287	assert.Contains(t, result, "FROM_DOCKERFILE=base")
4288}
4289
4290func TestService_WaitForAttachmentReady_TCPProbe(t *testing.T) {
4291	// Start a real TCP listener
4292	ln, err := net.Listen("tcp", "127.0.0.1:0")
4293	require.NoError(t, err)
4294	defer ln.Close()
4295	go func() {
4296		for {
4297			conn, err := ln.Accept()
4298			if err != nil {
4299				return
4300			}
4301			conn.Close()
4302		}
4303	}()
4304	addr := ln.Addr().(*net.TCPAddr)
4305
4306	runtime := mocks.NewMockContainerRuntime(t)
4307	svc := NewService(runtime, nil, nil, nil, Config{
4308		AttachmentReadinessTimeout: 5 * time.Second,
4309	}, nil)
4310
4311	ctx := testContext()
4312	containerID := "attachment-1"
4313	containerConfig := &domain.ContainerConfig{
4314		Ports: []int{5432},
4315	}
4316
4317	// pollContainerRunning
4318	runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
4319	// No healthcheck
4320	runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
4321	// Resolve endpoint to our real listener
4322	runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(addr.IP.String(), addr.Port, nil).Once()
4323	runtime.EXPECT().GetContainerPort(mock.Anything, containerID, addr.Port).Return(addr.Port, nil).Once()
4324
4325	err = svc.waitForAttachmentReady(ctx, containerID, containerConfig)
4326	assert.NoError(t, err)
4327}
4328
4329func TestService_WaitForAttachmentReady_Timeout(t *testing.T) {
4330	// Reserve a port with no listener
4331	ln, err := net.Listen("tcp", "127.0.0.1:0")
4332	require.NoError(t, err)
4333	addr := ln.Addr().(*net.TCPAddr)
4334	ln.Close() // Close so nothing listens
4335
4336	runtime := mocks.NewMockContainerRuntime(t)
4337	svc := NewService(runtime, nil, nil, nil, Config{
4338		AttachmentReadinessTimeout: 300 * time.Millisecond,
4339	}, nil)
4340
4341	ctx := testContext()
4342	containerID := "attachment-1"
4343	containerConfig := &domain.ContainerConfig{
4344		Ports: []int{5432},
4345	}
4346
4347	runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
4348	runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
4349	runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(addr.IP.String(), addr.Port, nil).Once()
4350	runtime.EXPECT().GetContainerPort(mock.Anything, containerID, addr.Port).Return(addr.Port, nil).Once()
4351
4352	err = svc.waitForAttachmentReady(ctx, containerID, containerConfig)
4353	assert.Error(t, err)
4354	assert.Contains(t, err.Error(), "TCP probe timeout")
4355}
4356
4357func TestService_WaitForAttachmentReady_FallsBackToDelay(t *testing.T) {
4358	runtime := mocks.NewMockContainerRuntime(t)
4359	svc := NewService(runtime, nil, nil, nil, Config{
4360		ReadinessDelay: time.Millisecond,
4361	}, nil)
4362
4363	ctx := testContext()
4364	containerID := "attachment-1"
4365	// No ports → skip TCP probe → fall back to delay
4366	containerConfig := &domain.ContainerConfig{}
4367
4368	runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
4369	runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
4370	// Delay fallback checks IsContainerRunning again
4371	runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
4372
4373	err := svc.waitForAttachmentReady(ctx, containerID, containerConfig)
4374	assert.NoError(t, err)
4375}
4376
4377func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
4378	runtime := mocks.NewMockContainerRuntime(t)
4379	envLoader := mocks.NewMockEnvLoader(t)
4380	eventBus := mocks.NewMockEventPublisher(t)
4381
4382	svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
4383	ctx := testContext()
4384
4385	ownerDomain := "app.example.com"
4386	serviceImage := "postgres:16"
4387	networkName := "gordon-net"
4388	containerName := fmt.Sprintf("gordon-%s-postgres", domain.SanitizeDomainForContainer(ownerDomain))
4389
4390	// resolveExistingAttachment: findContainerByName for new name, then legacy name — both miss
4391	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
4392
4393	// ensureImage: pullRefForDeploy returns imageRef unchanged (no registry configured);
4394	// ensureLocalImage checks ListImages and finds it locally.
4395	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"postgres:16"}, nil)
4396
4397	// GetImageExposedPorts
4398	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "postgres:16").Return([]int{5432}, nil)
4399
4400	// loadEnvironment
4401	envLoader.EXPECT().LoadEnv(mock.Anything, containerName).Return([]string{}, nil)
4402	runtime.EXPECT().InspectImageEnv(mock.Anything, "postgres:16").Return([]string{}, nil)
4403
4404	// setupVolumes: VolumeAutoCreate is false (default), so no volume calls needed
4405
4406	// CreateContainer — capture the config and verify Aliases
4407	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
4408		Run(func(_ context.Context, cfg *domain.ContainerConfig) {
4409			assert.Equal(t, []string{"postgres"}, cfg.Aliases, "attachment container must have network aliases for DNS resolution")
4410			assert.Equal(t, "postgres", cfg.Hostname)
4411			assert.Equal(t, networkName, cfg.NetworkMode)
4412			assert.Equal(t, domain.RestartPolicyAlways, cfg.RestartPolicy)
4413		}).
4414		Return(&domain.Container{ID: "pg-container-1", Name: containerName}, nil)
4415
4416	// StartContainer
4417	runtime.EXPECT().StartContainer(mock.Anything, "pg-container-1").Return(nil)
4418
4419	// waitForAttachmentReady: pollContainerRunning
4420	runtime.EXPECT().IsContainerRunning(mock.Anything, "pg-container-1").Return(true, nil).Times(2)
4421	// No Docker healthcheck
4422	runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "pg-container-1").Return("", false, nil)
4423	// TCP probe: resolve endpoint fails -> falls back to delay
4424	runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, "pg-container-1").Return("", 0, errors.New("no network"))
4425
4426	err := svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName)
4427	require.NoError(t, err)
4428
4429	// Verify the attachment is tracked
4430	svc.mu.RLock()
4431	attachIDs := svc.attachments[ownerDomain]
4432	svc.mu.RUnlock()
4433	require.Len(t, attachIDs, 1)
4434	assert.Equal(t, "pg-container-1", attachIDs[0])
4435}
4436
4437func TestService_BuildValidatedImageRef_RegistryPolicy(t *testing.T) {
4438	svc := NewService(nil, nil, nil, nil, Config{
4439		RegistryAuthEnabled: true,
4440		RegistryDomain:      "registry.example.com",
4441		AllowedRegistries:   []string{"docker.io"},
4442	}, nil)
4443
4444	ref, err := svc.buildValidatedImageRef(context.Background(), "myapp:latest")
4445	require.NoError(t, err)
4446	assert.Equal(t, "registry.example.com/myapp:latest", ref)
4447
4448	ref, err = svc.buildValidatedImageRef(context.Background(), "registry.example.com/myapp:latest")
4449	require.NoError(t, err)
4450	assert.Equal(t, "registry.example.com/myapp:latest", ref)
4451
4452	ref, err = svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx:latest")
4453	require.NoError(t, err)
4454	assert.Equal(t, "docker.io/library/nginx:latest", ref)
4455
4456	for _, image := range []string{
4457		"ghcr.io/acme/app:latest",
4458		"localhost:5000/app:latest",
4459		"127.0.0.1:5000/app:latest",
4460		"10.0.0.2:5000/app:latest",
4461		"169.254.169.254/app:latest",
4462	} {
4463		t.Run(image, func(t *testing.T) {
4464			_, err := svc.buildValidatedImageRef(context.Background(), image)
4465			require.Error(t, err)
4466		})
4467	}
4468}
4469
4470func TestService_BuildValidatedImageRef_RejectsExternalRegistryWhenAllowlistEmpty(t *testing.T) {
4471	svc := NewService(nil, nil, nil, nil, Config{}, nil)
4472
4473	_, err := svc.buildValidatedImageRef(context.Background(), "ghcr.io/acme/app:latest")
4474	require.Error(t, err)
4475	assert.Contains(t, err.Error(), "images.allowed_registries")
4476
4477	err = svc.validateImagePullRef(context.Background(), "nginx:latest")
4478	require.Error(t, err)
4479	assert.Contains(t, err.Error(), "docker.io")
4480}
4481
4482func TestService_BuildValidatedImageRef_RequireDigestOnlyForExternalAllowedRegistries(t *testing.T) {
4483	svc := NewService(nil, nil, nil, nil, Config{
4484		RegistryAuthEnabled: true,
4485		RegistryDomain:      "registry.example.com",
4486		RequireImageDigest:  true,
4487		AllowedRegistries:   []string{"docker.io"},
4488	}, nil)
4489
4490	_, err := svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx:latest")
4491	require.Error(t, err)
4492
4493	err = svc.validateImagePullRef(context.Background(), "nginx:latest")
4494	require.Error(t, err)
4495
4496	_, err = svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
4497	require.NoError(t, err)
4498
4499	_, err = svc.buildValidatedImageRef(context.Background(), "docker.io/library/nginx@sha256:not-a-digest")
4500	require.Error(t, err)
4501
4502	ref, err := svc.buildValidatedImageRef(context.Background(), "myapp:latest")
4503	require.NoError(t, err)
4504	assert.Equal(t, "registry.example.com/myapp:latest", ref)
4505
4506	_, err = svc.buildValidatedImageRef(context.Background(), "registry.example.com/myapp:latest")
4507	require.NoError(t, err)
4508}
4509
4510func TestService_BuildValidatedImageRef_AllowedRegistryMatchesPort(t *testing.T) {
4511	svc := NewService(nil, nil, nil, nil, Config{AllowedRegistries: []string{"docker.io:5000"}}, nil)
4512
4513	_, err := svc.buildValidatedImageRef(context.Background(), "docker.io:5000/app:latest")
4514	require.NoError(t, err)
4515
4516	_, err = svc.buildValidatedImageRef(context.Background(), "docker.io:5001/app:latest")
4517	require.Error(t, err)
4518}
4519
4520func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testing.T) {
4521	runtime := mocks.NewMockContainerRuntime(t)
4522	svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil)
4523
4524	runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil)
4525	runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{
4526		Driver:   "bridge",
4527		Internal: true,
4528		Labels:   map[string]string{domain.LabelManaged: "true"},
4529	}).Return(nil)
4530
4531	require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app"))
4532}
4533
4534func TestService_BuildContainerConfig_StrictSecurityProfile(t *testing.T) {
4535	svc := NewService(nil, nil, nil, nil, Config{SecurityProfile: "strict"}, nil)
4536
4537	cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4538
4539	assert.True(t, cfg.ReadOnlyRootFS)
4540	assert.Equal(t, []string{"ALL"}, cfg.CapDrop)
4541	assert.Equal(t, []string{"NET_BIND_SERVICE"}, cfg.CapAdd)
4542}
4543
4544func TestService_BuildContainerConfig_CompatSecurityProfileDefault(t *testing.T) {
4545	svc := NewService(nil, nil, nil, nil, Config{}, nil)
4546
4547	cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4548
4549	assert.False(t, cfg.ReadOnlyRootFS)
4550	assert.Nil(t, cfg.CapDrop)
4551	assert.Nil(t, cfg.CapAdd)
4552}
4553
4554func TestService_BuildContainerConfig_SetsRestartPolicyAlways(t *testing.T) {
4555	svc := NewService(nil, nil, nil, nil, Config{}, nil)
4556
4557	cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4558
4559	assert.Equal(t, domain.RestartPolicyAlways, cfg.RestartPolicy)
4560}
4561
4562func TestService_ReconcileRemovedRoute_RemovesOnlyMainRouteContainers(t *testing.T) {
4563	ctx := testContext()
4564	runtime := mocks.NewMockContainerRuntime(t)
4565	svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4566
4567	mainContainer := &domain.Container{
4568		ID:     "route-container",
4569		Name:   "gordon-app.example.com",
4570		Status: string(domain.ContainerStatusRunning),
4571		Labels: map[string]string{
4572			domain.LabelManaged: "true",
4573			domain.LabelRoute:   "app.example.com",
4574			domain.LabelDomain:  "app.example.com",
4575		},
4576	}
4577	attachment := &domain.Container{
4578		ID:     "attachment-container",
4579		Name:   "gordon-app-example-com-postgres",
4580		Status: string(domain.ContainerStatusRunning),
4581		Labels: map[string]string{
4582			domain.LabelManaged:    "true",
4583			domain.LabelAttachment: "true",
4584			domain.LabelAttachedTo: "app.example.com",
4585		},
4586	}
4587
4588	svc.mu.Lock()
4589	svc.containers["app.example.com"] = mainContainer
4590	svc.attachments["app.example.com"] = []string{attachment.ID}
4591	svc.managedCount = 1
4592	svc.mu.Unlock()
4593
4594	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer, attachment}, nil).Once()
4595	runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once()
4596	runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once()
4597
4598	report, err := svc.ReconcileRemovedRoute(ctx, "app.example.com")
4599	require.NoError(t, err)
4600	require.NotNil(t, report)
4601
4602	assert.Equal(t, "app.example.com", report.Domain)
4603	require.Len(t, report.RemovedContainers, 1)
4604	assert.Equal(t, mainContainer.ID, report.RemovedContainers[0].ID)
4605	require.Len(t, report.PreservedAttachments, 1)
4606	assert.Equal(t, attachment.ID, report.PreservedAttachments[0].ContainerID)
4607	assert.Contains(t, report.Hints, "attachments preserved; remove or purge attachments explicitly if they are no longer needed")
4608
4609	_, exists := svc.Get(ctx, "app.example.com")
4610	assert.False(t, exists, "removed route must be cleared from in-memory tracking so monitor cannot restart it")
4611
4612	svc.mu.RLock()
4613	_, attachmentsTracked := svc.attachments["app.example.com"]
4614	svc.mu.RUnlock()
4615	assert.True(t, attachmentsTracked, "route cleanup preserves attachment tracking for follow-up cleanup")
4616}
4617
4618func TestService_ReconcileRemovedRoute_IsIdempotentWhenNoContainerExists(t *testing.T) {
4619	ctx := testContext()
4620	runtime := mocks.NewMockContainerRuntime(t)
4621	svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4622
4623	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
4624
4625	report, err := svc.ReconcileRemovedRoute(ctx, "missing.example.com")
4626	require.NoError(t, err)
4627	require.NotNil(t, report)
4628
4629	assert.Equal(t, "missing.example.com", report.Domain)
4630	assert.Empty(t, report.RemovedContainers)
4631	assert.Contains(t, report.Warnings, "no active route container found for removed route")
4632}
4633
4634func TestService_PreviewRemovedRouteCleanup_ReportsRuntimeStateWithoutMutation(t *testing.T) {
4635	ctx := testContext()
4636	runtime := mocks.NewMockContainerRuntime(t)
4637	svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4638
4639	mainContainer := &domain.Container{
4640		ID:     "route-container",
4641		Name:   "gordon-app.example.com",
4642		Status: string(domain.ContainerStatusRunning),
4643		Labels: map[string]string{
4644			domain.LabelManaged: "true",
4645			domain.LabelRoute:   "app.example.com",
4646			domain.LabelDomain:  "app.example.com",
4647		},
4648	}
4649	attachment := &domain.Container{
4650		ID:     "attachment-container",
4651		Name:   "gordon-app-example-com-postgres",
4652		Status: string(domain.ContainerStatusRunning),
4653		Labels: map[string]string{
4654			domain.LabelManaged:    "true",
4655			domain.LabelAttachment: "true",
4656			domain.LabelAttachedTo: "app.example.com",
4657		},
4658	}
4659
4660	svc.mu.Lock()
4661	svc.containers["app.example.com"] = mainContainer
4662	svc.attachments["app.example.com"] = []string{attachment.ID}
4663	svc.managedCount = 1
4664	svc.mu.Unlock()
4665
4666	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer, attachment}, nil).Once()
4667
4668	report, err := svc.PreviewRemovedRouteCleanup(ctx, "app.example.com")
4669	require.NoError(t, err)
4670	require.NotNil(t, report)
4671
4672	require.Len(t, report.OrphanedEntities, 1)
4673	assert.Equal(t, "route_container", report.OrphanedEntities[0].Kind)
4674	assert.Equal(t, mainContainer.ID, report.OrphanedEntities[0].ID)
4675	require.Len(t, report.PreservedAttachments, 1)
4676	assert.Equal(t, attachment.ID, report.PreservedAttachments[0].ContainerID)
4677	assert.Contains(t, report.Hints, "attachments preserved; remove or purge attachments explicitly if they are no longer needed")
4678
4679	_, exists := svc.Get(ctx, "app.example.com")
4680	assert.True(t, exists, "preview should not clear route tracking")
4681}
4682
4683func TestService_ReconcileRemovedRoute_CanonicalizesDomainBeforeCleanup(t *testing.T) {
4684	ctx := testContext()
4685	runtime := mocks.NewMockContainerRuntime(t)
4686	svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4687
4688	mainContainer := &domain.Container{
4689		ID:     "route-container",
4690		Name:   "gordon-app.example.com",
4691		Status: string(domain.ContainerStatusRunning),
4692		Labels: map[string]string{
4693			domain.LabelManaged: "true",
4694			domain.LabelRoute:   "app.example.com",
4695			domain.LabelDomain:  "app.example.com",
4696		},
4697	}
4698	svc.mu.Lock()
4699	svc.containers["app.example.com"] = mainContainer
4700	svc.managedCount = 1
4701	svc.mu.Unlock()
4702
4703	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer}, nil).Once()
4704	runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once()
4705	runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once()
4706
4707	report, err := svc.ReconcileRemovedRoute(ctx, "App.EXAMPLE.com")
4708	require.NoError(t, err)
4709	require.NotNil(t, report)
4710	assert.Equal(t, "app.example.com", report.Domain)
4711	require.Len(t, report.RemovedContainers, 1)
4712
4713	_, exists := svc.Get(ctx, "app.example.com")
4714	assert.False(t, exists)
4715}
4716
4717func TestService_ReconcileRemovedRoute_StopsLogCollectionForRemovedContainer(t *testing.T) {
4718	ctx := testContext()
4719	runtime := mocks.NewMockContainerRuntime(t)
4720	logWriter := mocks.NewMockContainerLogWriter(t)
4721	svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), logWriter, Config{}, nil)
4722
4723	mainContainer := &domain.Container{
4724		ID:     "route-container",
4725		Name:   "gordon-app.example.com",
4726		Status: string(domain.ContainerStatusRunning),
4727		Labels: map[string]string{
4728			domain.LabelManaged: "true",
4729			domain.LabelRoute:   "app.example.com",
4730		},
4731	}
4732
4733	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer}, nil).Once()
4734	logWriter.EXPECT().StopLogging(mainContainer.ID).Return(nil).Once()
4735	runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once()
4736	runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once()
4737
4738	_, err := svc.ReconcileRemovedRoute(ctx, "app.example.com")
4739	require.NoError(t, err)
4740}
4741
4742func TestService_ReconcileRemovedRoute_InvalidatesProxyCacheAndMetric(t *testing.T) {
4743	ctx := testContext()
4744	runtime := mocks.NewMockContainerRuntime(t)
4745	eventBus := mocks.NewMockEventPublisher(t)
4746	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
4747	svc := NewService(runtime, nil, eventBus, nil, Config{}, nil)
4748
4749	metrics, reader := setupMetricsTest(t)
4750	svc.SetMetrics(metrics)
4751
4752	mainContainer := &domain.Container{
4753		ID:     "route-container",
4754		Name:   "gordon-app.example.com",
4755		Status: string(domain.ContainerStatusRunning),
4756		Labels: map[string]string{
4757			domain.LabelManaged: "true",
4758			domain.LabelRoute:   "app.example.com",
4759		},
4760	}
4761	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
4762	svc.activateDeployedContainer(ctx, "app.example.com", mainContainer)
4763	svc.SetProxyCacheInvalidator(cacheInvalidator)
4764
4765	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer}, nil).Once()
4766	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "app.example.com").Return().Once()
4767	runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once()
4768	runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once()
4769
4770	_, err := svc.ReconcileRemovedRoute(ctx, "app.example.com")
4771	require.NoError(t, err)
4772
4773	value, _, _ := managedMetricState(t, reader)
4774	assert.Equal(t, int64(0), value)
4775}
4776
4777func TestService_ListOrphanedAttachments_DetectsRunningAttachmentNoLongerConfigured(t *testing.T) {
4778	ctx := testContext()
4779	runtime := mocks.NewMockContainerRuntime(t)
4780	svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{Attachments: map[string][]string{
4781		"app.example.com": {"redis:7"},
4782	}}, nil)
4783
4784	orphan := &domain.Container{
4785		ID:     "postgres-1",
4786		Name:   "gordon-app-example-com-postgres",
4787		Image:  "postgres:16",
4788		Status: string(domain.ContainerStatusRunning),
4789		Labels: map[string]string{
4790			domain.LabelManaged:    "true",
4791			domain.LabelAttachment: "true",
4792			domain.LabelAttachedTo: "app.example.com",
4793			domain.LabelImage:      "postgres:16",
4794		},
4795	}
4796	configured := &domain.Container{
4797		ID:     "redis-1",
4798		Name:   "gordon-app-example-com-redis",
4799		Image:  "redis:7",
4800		Status: string(domain.ContainerStatusRunning),
4801		Labels: map[string]string{
4802			domain.LabelManaged:    "true",
4803			domain.LabelAttachment: "true",
4804			domain.LabelAttachedTo: "app.example.com",
4805			domain.LabelImage:      "redis:7",
4806		},
4807	}
4808
4809	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{orphan, configured}, nil).Once()
4810
4811	orphans, err := svc.ListOrphanedAttachments(ctx)
4812	require.NoError(t, err)
4813	require.Len(t, orphans, 1)
4814	assert.Equal(t, orphan.ID, orphans[0].ContainerID)
4815	assert.Equal(t, "postgres:16", orphans[0].Image)
4816	assert.Contains(t, orphans[0].Reason, "no longer configured")
4817}
4818
4819func TestService_CleanupOrphanedAttachments_DryRunPreservesRunningContainer(t *testing.T) {
4820	ctx := testContext()
4821	runtime := mocks.NewMockContainerRuntime(t)
4822	svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4823	orphan := &domain.Container{
4824		ID:     "postgres-1",
4825		Name:   "gordon-app-example-com-postgres",
4826		Image:  "postgres:16",
4827		Status: string(domain.ContainerStatusRunning),
4828		Labels: map[string]string{
4829			domain.LabelManaged:    "true",
4830			domain.LabelAttachment: "true",
4831			domain.LabelAttachedTo: "app.example.com",
4832			domain.LabelImage:      "postgres:16",
4833		},
4834	}
4835	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{orphan}, nil).Once()
4836
4837	report, err := svc.CleanupOrphanedAttachments(ctx, "", false)
4838	require.NoError(t, err)
4839	require.NotNil(t, report)
4840	require.Len(t, report.PreservedAttachments, 1)
4841	assert.Equal(t, orphan.ID, report.PreservedAttachments[0].ContainerID)
4842	runtime.AssertNotCalled(t, "StopContainer", mock.Anything, orphan.ID)
4843	runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, orphan.ID, mock.Anything)
4844}
4845
4846func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData(t *testing.T) {
4847	ctx := testContext()
4848	runtime := mocks.NewMockContainerRuntime(t)
4849	logWriter := mocks.NewMockContainerLogWriter(t)
4850	svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), logWriter, Config{}, nil)
4851	running := &domain.Container{
4852		ID:     "postgres-1",
4853		Name:   "gordon-app-example-com-postgres",
4854		Image:  "postgres:16",
4855		Status: string(domain.ContainerStatusRunning),
4856		Labels: map[string]string{
4857			domain.LabelManaged:    "true",
4858			domain.LabelAttachment: "true",
4859			domain.LabelAttachedTo: "app.example.com",
4860			domain.LabelImage:      "postgres:16",
4861		},
4862	}
4863	stopped := &domain.Container{
4864		ID:     "redis-1",
4865		Name:   "gordon-app-example-com-redis",
4866		Image:  "redis:7",
4867		Status: string(domain.ContainerStatusExited),
4868		Labels: map[string]string{
4869			domain.LabelManaged:    "true",
4870			domain.LabelAttachment: "true",
4871			domain.LabelAttachedTo: "app.example.com",
4872			domain.LabelImage:      "redis:7",
4873		},
4874	}
4875	svc.mu.Lock()
4876	svc.attachments["app.example.com"] = []string{running.ID, stopped.ID}
4877	svc.mu.Unlock()
4878
4879	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{running, stopped}, nil).Once()
4880	logWriter.EXPECT().StopLogging(running.ID).Return(nil).Once()
4881	runtime.EXPECT().StopContainer(mock.Anything, running.ID).Return(nil).Once()
4882	runtime.EXPECT().RemoveContainer(mock.Anything, running.ID, true).Return(nil).Once()
4883	logWriter.EXPECT().StopLogging(stopped.ID).Return(nil).Once()
4884	runtime.EXPECT().RemoveContainer(mock.Anything, stopped.ID, true).Return(nil).Once()
4885
4886	report, err := svc.CleanupOrphanedAttachments(ctx, "", true)
4887	require.NoError(t, err)
4888	require.NotNil(t, report)
4889	require.Len(t, report.RemovedContainers, 2)
4890	assert.Equal(t, running.ID, report.RemovedContainers[0].ID)
4891	assert.Equal(t, stopped.ID, report.RemovedContainers[1].ID)
4892	assert.Contains(t, report.Hints, "attachment volumes and data were preserved")
4893
4894	svc.mu.RLock()
4895	ids := svc.attachments["app.example.com"]
4896	svc.mu.RUnlock()
4897	assert.Empty(t, ids)
4898}
4899
4900func TestService_ListOrphanedAttachments_IncludesStoppedAttachments(t *testing.T) {
4901	ctx := testContext()
4902	runtime := mocks.NewMockContainerRuntime(t)
4903	svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4904	stopped := &domain.Container{
4905		ID:     "postgres-1",
4906		Name:   "gordon-app-example-com-postgres",
4907		Image:  "postgres:16",
4908		Status: string(domain.ContainerStatusExited),
4909		Labels: map[string]string{
4910			domain.LabelManaged:    "true",
4911			domain.LabelAttachment: "true",
4912			domain.LabelAttachedTo: "app.example.com",
4913			domain.LabelImage:      "postgres:16",
4914		},
4915	}
4916	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{stopped}, nil).Once()
4917
4918	orphans, err := svc.ListOrphanedAttachments(ctx)
4919	require.NoError(t, err)
4920	require.Len(t, orphans, 1)
4921	assert.Equal(t, stopped.ID, orphans[0].ContainerID)
4922	assert.Equal(t, string(domain.ContainerStatusExited), orphans[0].Status)
4923}
4924
4925func TestService_ListOrphanedAttachments_IncludesOwnerAndHonorsGroupConfig(t *testing.T) {
4926	ctx := testContext()
4927	runtime := mocks.NewMockContainerRuntime(t)
4928	svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{
4929		NetworkIsolation: true,
4930		NetworkGroups: map[string][]string{
4931			"backend": {"app.example.com"},
4932		},
4933		Attachments: map[string][]string{
4934			"backend": {"postgres:16"},
4935		},
4936	}, nil)
4937	configuredByGroup := &domain.Container{
4938		ID:     "postgres-1",
4939		Name:   "gordon-app-example-com-postgres",
4940		Image:  "postgres:16",
4941		Status: string(domain.ContainerStatusRunning),
4942		Labels: map[string]string{
4943			domain.LabelManaged:    "true",
4944			domain.LabelAttachment: "true",
4945			domain.LabelAttachedTo: "app.example.com",
4946			domain.LabelImage:      "postgres:16",
4947		},
4948	}
4949	orphan := &domain.Container{
4950		ID:     "redis-1",
4951		Name:   "gordon-app-example-com-redis",
4952		Image:  "redis:7",
4953		Status: string(domain.ContainerStatusRunning),
4954		Labels: map[string]string{
4955			domain.LabelManaged:    "true",
4956			domain.LabelAttachment: "true",
4957			domain.LabelAttachedTo: "app.example.com",
4958			domain.LabelImage:      "redis:7",
4959		},
4960	}
4961	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{configuredByGroup, orphan}, nil).Once()
4962
4963	orphans, err := svc.ListOrphanedAttachments(ctx)
4964	require.NoError(t, err)
4965	require.Len(t, orphans, 1)
4966	assert.Equal(t, "redis-1", orphans[0].ContainerID)
4967	assert.Equal(t, "app.example.com", orphans[0].Owner)
4968}

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