v2.31.0-dev5 4162c86

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

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