v2.16.0 ebd2ab3

service_test.go RawHistoryFolder

1package container
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"sync"
8	"testing"
9	"time"
10
11	"github.com/bnema/zerowrap"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/mock"
14	"github.com/stretchr/testify/require"
15	"go.opentelemetry.io/otel"
16	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
17	"go.opentelemetry.io/otel/sdk/metric/metricdata"
18
19	"github.com/bnema/gordon/internal/adapters/out/telemetry"
20	"github.com/bnema/gordon/internal/boundaries/out/mocks"
21	"github.com/bnema/gordon/internal/domain"
22)
23
24func testContext() context.Context {
25	return zerowrap.WithCtx(context.Background(), zerowrap.Default())
26}
27
28func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) {
29	t.Helper()
30
31	prev := otel.GetMeterProvider()
32	t.Cleanup(func() {
33		otel.SetMeterProvider(prev)
34	})
35
36	reader := sdkmetric.NewManualReader()
37	mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
38	otel.SetMeterProvider(mp)
39	t.Cleanup(func() {
40		_ = mp.Shutdown(context.Background())
41	})
42
43	m, err := telemetry.NewMetrics()
44	require.NoError(t, err)
45	return m, reader
46}
47
48func managedMetricState(t *testing.T, reader *sdkmetric.ManualReader) (int64, int, []metricdata.DataPoint[int64]) {
49	t.Helper()
50
51	var rm metricdata.ResourceMetrics
52	require.NoError(t, reader.Collect(context.Background(), &rm))
53
54	for _, sm := range rm.ScopeMetrics {
55		for _, m := range sm.Metrics {
56			if m.Name != "gordon.container.managed" {
57				continue
58			}
59			sum, ok := m.Data.(metricdata.Sum[int64])
60			require.True(t, ok)
61			var total int64
62			for _, dp := range sum.DataPoints {
63				total += dp.Value
64			}
65			return total, len(sum.DataPoints), sum.DataPoints
66		}
67	}
68
69	return 0, 0, nil
70}
71
72func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) {
73	runtime := mocks.NewMockContainerRuntime(t)
74	envLoader := mocks.NewMockEnvLoader(t)
75	eventBus := mocks.NewMockEventPublisher(t)
76
77	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
78	ctx := testContext()
79	metrics, reader := setupMetricsTest(t)
80	svc.SetMetrics(metrics)
81	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Twice()
82
83	// First deploy increments metric.
84	svc.activateDeployedContainer(ctx, "test.example.com", &domain.Container{ID: "container-1"})
85	value, seriesCount, points := managedMetricState(t, reader)
86	assert.Equal(t, int64(1), value)
87	assert.Equal(t, 1, seriesCount)
88	assert.Equal(t, 0, points[0].Attributes.Len())
89
90	// Replacing an existing domain should not increment.
91	svc.activateDeployedContainer(ctx, "test.example.com", &domain.Container{ID: "container-2"})
92	value, seriesCount, points = managedMetricState(t, reader)
93	assert.Equal(t, int64(1), value)
94	assert.Equal(t, 1, seriesCount)
95	assert.Equal(t, 0, points[0].Attributes.Len())
96
97	runtime.EXPECT().RemoveContainer(mock.Anything, "container-2", true).Return(nil)
98	require.NoError(t, svc.Remove(ctx, "container-2", true))
99
100	value, seriesCount, points = managedMetricState(t, reader)
101	assert.Equal(t, int64(0), value)
102	assert.Equal(t, 1, seriesCount)
103	assert.Equal(t, 0, points[0].Attributes.Len())
104}
105
106func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T) {
107	runtime := mocks.NewMockContainerRuntime(t)
108	envLoader := mocks.NewMockEnvLoader(t)
109	eventBus := mocks.NewMockEventPublisher(t)
110
111	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
112	ctx := testContext()
113	metrics, reader := setupMetricsTest(t)
114	svc.SetMetrics(metrics)
115
116	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
117		{
118			ID: "container-1",
119			Labels: map[string]string{
120				"gordon.domain":  "app1.example.com",
121				"gordon.managed": "true",
122			},
123		},
124		{
125			ID: "container-2",
126			Labels: map[string]string{
127				"gordon.domain":  "app2.example.com",
128				"gordon.managed": "true",
129			},
130		},
131	}, nil).Once()
132	require.NoError(t, svc.SyncContainers(ctx))
133
134	value, seriesCount, points := managedMetricState(t, reader)
135	assert.Equal(t, int64(2), value)
136	assert.Equal(t, 1, seriesCount)
137	assert.Equal(t, 0, points[0].Attributes.Len())
138
139	// No runtime change: metric should remain stable.
140	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
141		{
142			ID: "container-1",
143			Labels: map[string]string{
144				"gordon.domain":  "app1.example.com",
145				"gordon.managed": "true",
146			},
147		},
148		{
149			ID: "container-2",
150			Labels: map[string]string{
151				"gordon.domain":  "app2.example.com",
152				"gordon.managed": "true",
153			},
154		},
155	}, nil).Once()
156	require.NoError(t, svc.SyncContainers(ctx))
157
158	value, seriesCount, points = managedMetricState(t, reader)
159	assert.Equal(t, int64(2), value)
160	assert.Equal(t, 1, seriesCount)
161	assert.Equal(t, 0, points[0].Attributes.Len())
162
163	// One container removed in runtime: metric should decrement by one.
164	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
165		{
166			ID: "container-1",
167			Labels: map[string]string{
168				"gordon.domain":  "app1.example.com",
169				"gordon.managed": "true",
170			},
171		},
172	}, nil).Once()
173	require.NoError(t, svc.SyncContainers(ctx))
174
175	value, seriesCount, points = managedMetricState(t, reader)
176	assert.Equal(t, int64(1), value)
177	assert.Equal(t, 1, seriesCount)
178	assert.Equal(t, 0, points[0].Attributes.Len())
179}
180
181func TestService_Deploy_Success(t *testing.T) {
182	runtime := mocks.NewMockContainerRuntime(t)
183	envLoader := mocks.NewMockEnvLoader(t)
184	eventBus := mocks.NewMockEventPublisher(t)
185
186	config := Config{
187		NetworkIsolation: false,
188		VolumeAutoCreate: false,
189		ReadinessDelay:   time.Millisecond, // Minimal delay for tests
190		DrainDelay:       time.Millisecond,
191	}
192
193	svc := NewService(runtime, envLoader, eventBus, nil, config)
194	ctx := testContext()
195
196	route := domain.Route{
197		Domain: "test.example.com",
198		Image:  "myapp:latest",
199	}
200
201	// Setup mocks - no orphaned containers
202	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
203
204	// Image is not found locally, needs to be pulled
205	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
206	runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
207
208	// Get exposed ports
209	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
210
211	// Load environment
212	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{"FOO=bar"}, nil)
213
214	// Inspect image env
215	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{"DEFAULT=value"}, nil)
216
217	// No volume auto-create, so no volume calls
218
219	// Create and start container
220	createdContainer := &domain.Container{
221		ID:     "container-123",
222		Name:   "gordon-test.example.com",
223		Image:  "myapp:latest",
224		Status: "created",
225	}
226	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
227	runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
228
229	// Wait for ready: IsContainerRunning (first check returns true) + verify after delay
230	runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
231
232	// Re-inspect after start
233	runningContainer := &domain.Container{
234		ID:     "container-123",
235		Name:   "gordon-test.example.com",
236		Image:  "myapp:latest",
237		Status: "running",
238		Ports:  []int{8080},
239	}
240	runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
241
242	// Publish container deployed event
243	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
244
245	result, err := svc.Deploy(ctx, route)
246
247	assert.NoError(t, err)
248	assert.NotNil(t, result)
249	assert.Equal(t, "container-123", result.ID)
250	assert.Equal(t, "running", result.Status)
251
252	// Verify container is tracked
253	tracked, exists := svc.Get(ctx, "test.example.com")
254	assert.True(t, exists)
255	assert.Equal(t, "container-123", tracked.ID)
256}
257
258func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T) {
259	runtime := mocks.NewMockContainerRuntime(t)
260	envLoader := mocks.NewMockEnvLoader(t)
261	eventBus := mocks.NewMockEventPublisher(t)
262
263	config := Config{
264		NetworkIsolation: false,
265		VolumeAutoCreate: false,
266		ReadinessDelay:   time.Millisecond,
267		DrainDelay:       time.Millisecond,
268	}
269
270	svc := NewService(runtime, envLoader, eventBus, nil, config)
271	ctx := testContext()
272
273	route := domain.Route{
274		Domain: "test.example.com",
275		Image:  "myapp:latest",
276	}
277
278	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
279	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
280	runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
281	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
282	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
283	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
284
285	createdContainer := &domain.Container{
286		ID:     "container-123",
287		Name:   "gordon-test.example.com",
288		Image:  "myapp:latest",
289		Status: "created",
290	}
291	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
292	runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
293
294	// Readiness check sequence:
295	// 1) Initial "started" check => running
296	// 2) Post-delay verification => transient not-running
297	// 3) Recovery-window poll => running again
298	runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once()
299	runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(false, nil).Once()
300	runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once()
301
302	runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
303		ID:     "container-123",
304		Name:   "gordon-test.example.com",
305		Image:  "myapp:latest",
306		Status: "running",
307		Ports:  []int{8080},
308	}, nil)
309	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
310
311	result, err := svc.Deploy(ctx, route)
312
313	assert.NoError(t, err)
314	assert.NotNil(t, result)
315	assert.Equal(t, "container-123", result.ID)
316}
317
318func TestService_Deploy_ImagePullFailure(t *testing.T) {
319	runtime := mocks.NewMockContainerRuntime(t)
320	envLoader := mocks.NewMockEnvLoader(t)
321	eventBus := mocks.NewMockEventPublisher(t)
322
323	config := Config{}
324	svc := NewService(runtime, envLoader, eventBus, nil, config)
325	ctx := testContext()
326
327	route := domain.Route{
328		Domain: "test.example.com",
329		Image:  "myapp:latest",
330	}
331
332	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
333	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
334	runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(errors.New("image not found"))
335
336	result, err := svc.Deploy(ctx, route)
337
338	assert.Error(t, err)
339	assert.Nil(t, result)
340	assert.Contains(t, err.Error(), "failed to pull image")
341}
342
343func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
344	runtime := mocks.NewMockContainerRuntime(t)
345
346	config := Config{
347		RegistryAuthEnabled:  true,
348		ServiceTokenUsername: "gordon-service",
349		ServiceToken:         "service-token",
350	}
351
352	svc := NewService(runtime, nil, nil, nil, config)
353	ctx := testContext()
354
355	runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil)
356
357	err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
358
359	assert.NoError(t, err)
360}
361
362func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
363	runtime := mocks.NewMockContainerRuntime(t)
364
365	config := Config{
366		RegistryAuthEnabled: true,
367	}
368
369	svc := NewService(runtime, nil, nil, nil, config)
370	ctx := testContext()
371
372	err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
373
374	assert.Error(t, err)
375	assert.Contains(t, err.Error(), "registry service token not configured")
376}
377
378func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {
379	runtime := mocks.NewMockContainerRuntime(t)
380
381	config := Config{
382		RegistryAuthEnabled:      true,
383		InternalRegistryUsername: "internal",
384		InternalRegistryPassword: "secret",
385	}
386
387	svc := NewService(runtime, nil, nil, nil, config)
388	ctx := testContext()
389
390	runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").
391		Return(errors.New("connection refused")).
392		Once()
393	runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").
394		Return(nil).
395		Once()
396
397	err := svc.pullImage(ctx, "localhost:5000/myapp:latest", true)
398
399	assert.NoError(t, err)
400}
401
402func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
403	runtime := mocks.NewMockContainerRuntime(t)
404	envLoader := mocks.NewMockEnvLoader(t)
405	eventBus := mocks.NewMockEventPublisher(t)
406	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
407
408	config := Config{
409		ReadinessDelay: time.Millisecond, // Minimal delay for tests
410		DrainDelay:     time.Millisecond,
411	}
412	svc := NewService(runtime, envLoader, eventBus, nil, config)
413	svc.SetProxyCacheInvalidator(cacheInvalidator)
414	ctx := testContext()
415
416	// Pre-populate with existing container
417	existingContainer := &domain.Container{
418		ID:     "old-container",
419		Name:   "gordon-test.example.com",
420		Status: "running",
421	}
422	svc.containers["test.example.com"] = existingContainer
423
424	route := domain.Route{
425		Domain: "test.example.com",
426		Image:  "myapp:v2",
427	}
428
429	// Cleanup orphans
430	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
431
432	// Image operations
433	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
434	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
435	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
436
437	// Environment
438	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
439	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
440
441	// Create new container (with -new suffix for zero-downtime)
442	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
443	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
444		return cfg.Name == "gordon-test.example.com-new"
445	})).Return(newContainer, nil)
446	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
447
448	// Wait for ready
449	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
450
451	// Inspect after ready
452	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
453		ID:     "new-container",
454		Status: "running",
455	}, nil)
456
457	// Publish event
458	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
459
460	// Synchronous cache invalidation before old container cleanup
461	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
462
463	// Now cleanup old container (after new one is ready + cache invalidated + drain delay)
464	runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
465	runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
466
467	// Rename new container to canonical name
468	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
469
470	result, err := svc.Deploy(ctx, route)
471
472	assert.NoError(t, err)
473	assert.Equal(t, "new-container", result.ID)
474
475	// Ensure the service's tracked container entry now points to the new container.
476	tracked, ok := svc.Get(ctx, "test.example.com")
477	assert.True(t, ok)
478	assert.Equal(t, "new-container", tracked.ID)
479}
480
481func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
482	runtime := mocks.NewMockContainerRuntime(t)
483	envLoader := mocks.NewMockEnvLoader(t)
484	eventBus := mocks.NewMockEventPublisher(t)
485
486	config := Config{
487		ReadinessDelay: time.Millisecond,
488		DrainDelay:     time.Millisecond,
489	}
490	svc := NewService(runtime, envLoader, eventBus, nil, config)
491	ctx := testContext()
492
493	// Existing container has ImageID, but GetImageID will fail
494	existingContainer := &domain.Container{
495		ID:      "existing-container",
496		Name:    "gordon-test.example.com",
497		Image:   "myapp:latest",
498		ImageID: "sha256:abc123",
499		Status:  "running",
500	}
501	svc.containers["test.example.com"] = existingContainer
502
503	route := domain.Route{
504		Domain: "test.example.com",
505		Image:  "myapp:latest",
506	}
507
508	// prepareDeployResources
509	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
510	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
511	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
512	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
513	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
514
515	// GetImageID fails => deploy proceeds normally (graceful degradation)
516	runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("", errors.New("image inspect failed"))
517
518	// Full deploy proceeds despite the error
519	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
520	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
521	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
522	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
523	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
524		ID: "new-container", Status: "running",
525	}, nil)
526	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
527
528	// Old container is finalized
529	runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
530	runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil)
531	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
532
533	result, err := svc.Deploy(ctx, route)
534
535	assert.NoError(t, err)
536	assert.Equal(t, "new-container", result.ID)
537}
538
539func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
540	runtime := mocks.NewMockContainerRuntime(t)
541	envLoader := mocks.NewMockEnvLoader(t)
542	eventBus := mocks.NewMockEventPublisher(t)
543
544	config := Config{
545		NetworkIsolation: true,
546		NetworkPrefix:    "gordon",
547		ReadinessDelay:   time.Millisecond, // Minimal delay for tests
548		DrainDelay:       time.Millisecond,
549	}
550	svc := NewService(runtime, envLoader, eventBus, nil, config)
551	ctx := testContext()
552
553	route := domain.Route{
554		Domain: "test.example.com",
555		Image:  "myapp:latest",
556	}
557
558	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
559	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
560	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
561	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
562	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
563
564	// Network isolation - should check and create network
565	runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil)
566	runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", map[string]string{"driver": "bridge"}).Return(nil)
567
568	container := &domain.Container{ID: "container-123", Status: "created"}
569	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
570		return cfg.NetworkMode == "gordon-test-example-com"
571	})).Return(container, nil)
572	runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
573
574	// Wait for ready
575	runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
576
577	runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
578		ID:     "container-123",
579		Status: "running",
580	}, nil)
581
582	// Publish event
583	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
584
585	result, err := svc.Deploy(ctx, route)
586
587	assert.NoError(t, err)
588	assert.NotNil(t, result)
589}
590
591func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
592	runtime := mocks.NewMockContainerRuntime(t)
593	envLoader := mocks.NewMockEnvLoader(t)
594	eventBus := mocks.NewMockEventPublisher(t)
595
596	config := Config{
597		VolumeAutoCreate: true,
598		VolumePrefix:     "gordon",
599		ReadinessDelay:   time.Millisecond, // Minimal delay for tests
600		DrainDelay:       time.Millisecond,
601	}
602	svc := NewService(runtime, envLoader, eventBus, nil, config)
603	ctx := testContext()
604
605	route := domain.Route{
606		Domain: "test.example.com",
607		Image:  "myapp:latest",
608	}
609
610	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
611	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
612	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
613	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
614	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
615
616	// Volume operations
617	runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data", "/config"}, nil)
618	runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-data").Return(false, nil)
619	runtime.EXPECT().CreateVolume(mock.Anything, "gordon-test-example-com-data").Return(nil)
620	runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-config").Return(true, nil)
621
622	container := &domain.Container{ID: "container-123", Status: "created"}
623	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
624		return len(cfg.Volumes) == 2
625	})).Return(container, nil)
626	runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
627
628	// Wait for ready
629	runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
630
631	runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
632		ID:     "container-123",
633		Status: "running",
634	}, nil)
635
636	// Publish event
637	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
638
639	result, err := svc.Deploy(ctx, route)
640
641	assert.NoError(t, err)
642	assert.NotNil(t, result)
643}
644
645func TestService_Stop(t *testing.T) {
646	runtime := mocks.NewMockContainerRuntime(t)
647	envLoader := mocks.NewMockEnvLoader(t)
648	eventBus := mocks.NewMockEventPublisher(t)
649
650	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
651	ctx := testContext()
652
653	runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(nil)
654
655	err := svc.Stop(ctx, "container-123")
656
657	assert.NoError(t, err)
658}
659
660func TestService_Stop_Error(t *testing.T) {
661	runtime := mocks.NewMockContainerRuntime(t)
662	envLoader := mocks.NewMockEnvLoader(t)
663	eventBus := mocks.NewMockEventPublisher(t)
664
665	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
666	ctx := testContext()
667
668	runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(errors.New("container not found"))
669
670	err := svc.Stop(ctx, "container-123")
671
672	assert.Error(t, err)
673	assert.Contains(t, err.Error(), "failed to stop container")
674}
675
676func TestService_Remove(t *testing.T) {
677	runtime := mocks.NewMockContainerRuntime(t)
678	envLoader := mocks.NewMockEnvLoader(t)
679	eventBus := mocks.NewMockEventPublisher(t)
680
681	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
682	ctx := testContext()
683
684	// Add tracked container
685	svc.containers["test.example.com"] = &domain.Container{
686		ID:   "container-123",
687		Name: "gordon-test.example.com",
688	}
689
690	runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)
691
692	err := svc.Remove(ctx, "container-123", true)
693
694	assert.NoError(t, err)
695
696	// Verify container is no longer tracked
697	_, exists := svc.Get(ctx, "test.example.com")
698	assert.False(t, exists)
699}
700
701func TestService_Remove_WithNetworkCleanup(t *testing.T) {
702	runtime := mocks.NewMockContainerRuntime(t)
703	envLoader := mocks.NewMockEnvLoader(t)
704	eventBus := mocks.NewMockEventPublisher(t)
705
706	config := Config{
707		NetworkIsolation: true,
708		NetworkPrefix:    "gordon",
709	}
710	svc := NewService(runtime, envLoader, eventBus, nil, config)
711	ctx := testContext()
712
713	svc.containers["test.example.com"] = &domain.Container{
714		ID:   "container-123",
715		Name: "gordon-test.example.com",
716	}
717
718	runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)
719	runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
720		{Name: "gordon-test-example-com", Containers: []string{}},
721	}, nil)
722	runtime.EXPECT().RemoveNetwork(mock.Anything, "gordon-test-example-com").Return(nil)
723
724	err := svc.Remove(ctx, "container-123", true)
725
726	assert.NoError(t, err)
727}
728
729func TestService_Get(t *testing.T) {
730	runtime := mocks.NewMockContainerRuntime(t)
731	envLoader := mocks.NewMockEnvLoader(t)
732	eventBus := mocks.NewMockEventPublisher(t)
733
734	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
735	ctx := testContext()
736
737	container := &domain.Container{ID: "container-123"}
738	svc.containers["test.example.com"] = container
739
740	result, exists := svc.Get(ctx, "test.example.com")
741
742	assert.True(t, exists)
743	assert.Equal(t, "container-123", result.ID)
744}
745
746func TestService_Get_NotFound(t *testing.T) {
747	runtime := mocks.NewMockContainerRuntime(t)
748	envLoader := mocks.NewMockEnvLoader(t)
749	eventBus := mocks.NewMockEventPublisher(t)
750
751	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
752	ctx := testContext()
753
754	result, exists := svc.Get(ctx, "nonexistent.example.com")
755
756	assert.False(t, exists)
757	assert.Nil(t, result)
758}
759
760func TestService_List(t *testing.T) {
761	runtime := mocks.NewMockContainerRuntime(t)
762	envLoader := mocks.NewMockEnvLoader(t)
763	eventBus := mocks.NewMockEventPublisher(t)
764
765	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
766	ctx := testContext()
767
768	svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
769	svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
770
771	result := svc.List(ctx)
772
773	assert.Len(t, result, 2)
774	assert.Equal(t, "container-1", result["app1.example.com"].ID)
775	assert.Equal(t, "container-2", result["app2.example.com"].ID)
776}
777
778func TestService_HealthCheck(t *testing.T) {
779	runtime := mocks.NewMockContainerRuntime(t)
780	envLoader := mocks.NewMockEnvLoader(t)
781	eventBus := mocks.NewMockEventPublisher(t)
782
783	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
784	ctx := testContext()
785
786	svc.containers["healthy.example.com"] = &domain.Container{ID: "healthy-container"}
787	svc.containers["unhealthy.example.com"] = &domain.Container{ID: "unhealthy-container"}
788
789	runtime.EXPECT().IsContainerRunning(mock.Anything, "healthy-container").Return(true, nil)
790	runtime.EXPECT().IsContainerRunning(mock.Anything, "unhealthy-container").Return(false, nil)
791
792	result := svc.HealthCheck(ctx)
793
794	assert.True(t, result["healthy.example.com"])
795	assert.False(t, result["unhealthy.example.com"])
796}
797
798func TestService_SyncContainers(t *testing.T) {
799	runtime := mocks.NewMockContainerRuntime(t)
800	envLoader := mocks.NewMockEnvLoader(t)
801	eventBus := mocks.NewMockEventPublisher(t)
802
803	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
804	ctx := testContext()
805
806	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
807		{
808			ID:   "container-1",
809			Name: "gordon-app1.example.com",
810			Labels: map[string]string{
811				"gordon.domain":  "app1.example.com",
812				"gordon.managed": "true",
813			},
814		},
815		{
816			ID:   "container-2",
817			Name: "gordon-app2.example.com",
818			Labels: map[string]string{
819				"gordon.domain":  "app2.example.com",
820				"gordon.managed": "true",
821			},
822		},
823		{
824			ID:     "unmanaged-container",
825			Name:   "some-other-app",
826			Labels: map[string]string{},
827		},
828	}, nil)
829
830	err := svc.SyncContainers(ctx)
831
832	assert.NoError(t, err)
833	assert.Len(t, svc.containers, 2)
834	assert.Contains(t, svc.containers, "app1.example.com")
835	assert.Contains(t, svc.containers, "app2.example.com")
836}
837
838func TestService_Shutdown(t *testing.T) {
839	runtime := mocks.NewMockContainerRuntime(t)
840	envLoader := mocks.NewMockEnvLoader(t)
841	eventBus := mocks.NewMockEventPublisher(t)
842
843	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
844	ctx := testContext()
845
846	svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
847	svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
848
849	// Shutdown no longer stops containers — they are left running for
850	// SyncContainers + AutoStart to pick back up on next boot.
851	err := svc.Shutdown(ctx)
852
853	assert.NoError(t, err)
854	// Containers remain tracked (not stopped).
855	assert.Len(t, svc.containers, 2)
856}
857
858func TestService_Restart_NotFound(t *testing.T) {
859	runtime := mocks.NewMockContainerRuntime(t)
860	envLoader := mocks.NewMockEnvLoader(t)
861	eventBus := mocks.NewMockEventPublisher(t)
862
863	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
864	ctx := testContext()
865
866	err := svc.Restart(ctx, "nonexistent.example.com", false)
867
868	assert.Error(t, err)
869	assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
870}
871
872func TestService_Restart_Success(t *testing.T) {
873	runtime := mocks.NewMockContainerRuntime(t)
874	envLoader := mocks.NewMockEnvLoader(t)
875	eventBus := mocks.NewMockEventPublisher(t)
876
877	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
878	ctx := testContext()
879
880	svc.containers["test.example.com"] = &domain.Container{
881		ID:     "container-123",
882		Name:   "gordon-test.example.com",
883		Status: "running",
884	}
885
886	runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
887
888	err := svc.Restart(ctx, "test.example.com", false)
889
890	assert.NoError(t, err)
891}
892
893func TestService_Restart_Error(t *testing.T) {
894	runtime := mocks.NewMockContainerRuntime(t)
895	envLoader := mocks.NewMockEnvLoader(t)
896	eventBus := mocks.NewMockEventPublisher(t)
897
898	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
899	ctx := testContext()
900
901	svc.containers["test.example.com"] = &domain.Container{
902		ID:     "container-123",
903		Name:   "gordon-test.example.com",
904		Status: "running",
905	}
906
907	runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(errors.New("restart failed"))
908
909	err := svc.Restart(ctx, "test.example.com", false)
910
911	assert.Error(t, err)
912	assert.Contains(t, err.Error(), "failed to restart container")
913}
914
915func TestService_Restart_ReconcilesStaleContainerID(t *testing.T) {
916	runtime := mocks.NewMockContainerRuntime(t)
917	envLoader := mocks.NewMockEnvLoader(t)
918	eventBus := mocks.NewMockEventPublisher(t)
919
920	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
921	ctx := testContext()
922
923	svc.containers["test.example.com"] = &domain.Container{
924		ID:     "stale-container-id",
925		Name:   "gordon-test.example.com",
926		Status: "running",
927	}
928
929	runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
930		Return(errors.New("no container with name or ID \"stale-container-id\" found")).
931		Once()
932	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
933		{
934			ID:   "fresh-container-id",
935			Name: "gordon-test.example.com",
936			Labels: map[string]string{
937				"gordon.domain":  "test.example.com",
938				"gordon.managed": "true",
939			},
940		},
941	}, nil).Once()
942	runtime.EXPECT().RestartContainer(mock.Anything, "fresh-container-id").Return(nil).Once()
943
944	err := svc.Restart(ctx, "test.example.com", false)
945
946	assert.NoError(t, err)
947}
948
949func TestService_Restart_ReconcilesStaleContainerID_NotFoundAfterSync(t *testing.T) {
950	runtime := mocks.NewMockContainerRuntime(t)
951	envLoader := mocks.NewMockEnvLoader(t)
952	eventBus := mocks.NewMockEventPublisher(t)
953
954	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
955	ctx := testContext()
956
957	svc.containers["test.example.com"] = &domain.Container{
958		ID:     "stale-container-id",
959		Name:   "gordon-test.example.com",
960		Status: "running",
961	}
962
963	runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
964		Return(errors.New("no such container")).
965		Once()
966	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
967
968	err := svc.Restart(ctx, "test.example.com", false)
969
970	assert.Error(t, err)
971	assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
972}
973
974func TestService_Restart_WithAttachments(t *testing.T) {
975	runtime := mocks.NewMockContainerRuntime(t)
976	envLoader := mocks.NewMockEnvLoader(t)
977	eventBus := mocks.NewMockEventPublisher(t)
978
979	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
980	ctx := testContext()
981
982	svc.containers["test.example.com"] = &domain.Container{
983		ID:     "container-123",
984		Name:   "gordon-test.example.com",
985		Status: "running",
986	}
987	svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"}
988
989	runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
990	runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
991	runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-2").Return(fmt.Errorf("boom"))
992
993	err := svc.Restart(ctx, "test.example.com", true)
994
995	assert.NoError(t, err)
996}
997
998func TestNormalizeImageRef(t *testing.T) {
999	tests := []struct {
1000		name     string
1001		input    string
1002		expected string
1003	}{
1004		{
1005			name:     "simple image",
1006			input:    "nginx",
1007			expected: "docker.io/library/nginx",
1008		},
1009		{
1010			name:     "user/repo",
1011			input:    "user/myapp",
1012			expected: "docker.io/user/myapp",
1013		},
1014		{
1015			name:     "registry/repo",
1016			input:    "gcr.io/project/image",
1017			expected: "gcr.io/project/image",
1018		},
1019		{
1020			name:     "with tag",
1021			input:    "nginx:latest",
1022			expected: "docker.io/library/nginx",
1023		},
1024		{
1025			name:     "localhost with port",
1026			input:    "localhost:5000/myapp:latest",
1027			expected: "localhost:5000/myapp",
1028		},
1029		{
1030			name:     "localhost with port no tag",
1031			input:    "localhost:5000/myapp",
1032			expected: "localhost:5000/myapp",
1033		},
1034		{
1035			name:     "registry domain with port",
1036			input:    "registry.example.com:5000/image:v1.0",
1037			expected: "registry.example.com:5000/image",
1038		},
1039	}
1040
1041	for _, tt := range tests {
1042		t.Run(tt.name, func(t *testing.T) {
1043			result := normalizeImageRef(tt.input)
1044			assert.Equal(t, tt.expected, result)
1045		})
1046	}
1047}
1048
1049func TestGenerateVolumeName(t *testing.T) {
1050	tests := []struct {
1051		name       string
1052		prefix     string
1053		domain     string
1054		volumePath string
1055		expected   string
1056	}{
1057		{
1058			name:       "simple path",
1059			prefix:     "gordon",
1060			domain:     "app.example.com",
1061			volumePath: "/data",
1062			expected:   "gordon-app-example-com-data",
1063		},
1064		{
1065			name:       "nested path",
1066			prefix:     "gordon",
1067			domain:     "app.example.com",
1068			volumePath: "/var/lib/data",
1069			expected:   "gordon-app-example-com-var-lib-data",
1070		},
1071	}
1072
1073	for _, tt := range tests {
1074		t.Run(tt.name, func(t *testing.T) {
1075			result := generateVolumeName(tt.prefix, tt.domain, tt.volumePath)
1076			assert.Equal(t, tt.expected, result)
1077		})
1078	}
1079}
1080
1081func TestMergeEnvironmentVariables(t *testing.T) {
1082	dockerEnv := []string{"FOO=docker", "BAR=docker"}
1083	userEnv := []string{"FOO=user", "BAZ=user"}
1084
1085	result := mergeEnvironmentVariables(dockerEnv, userEnv)
1086
1087	// User env should override docker env
1088	assert.Contains(t, result, "FOO=user")
1089	assert.Contains(t, result, "BAR=docker")
1090	assert.Contains(t, result, "BAZ=user")
1091}
1092
1093func TestBuildImageRef(t *testing.T) {
1094	tests := []struct {
1095		name                string
1096		image               string
1097		registryAuthEnabled bool
1098		registryDomain      string
1099		wantRef             string
1100	}{
1101		{
1102			name:                "adds registry domain prefix",
1103			image:               "myapp:latest",
1104			registryAuthEnabled: true,
1105			registryDomain:      "registry.example.com",
1106			wantRef:             "registry.example.com/myapp:latest",
1107		},
1108		{
1109			name:                "keeps existing registry domain prefix",
1110			image:               "registry.example.com/myapp:latest",
1111			registryAuthEnabled: true,
1112			registryDomain:      "registry.example.com",
1113			wantRef:             "registry.example.com/myapp:latest",
1114		},
1115		{
1116			name:                "skips external registry images",
1117			image:               "docker.io/library/nginx:latest",
1118			registryAuthEnabled: true,
1119			registryDomain:      "registry.example.com",
1120			wantRef:             "docker.io/library/nginx:latest",
1121		},
1122		{
1123			name:                "returns original when auth disabled",
1124			image:               "myapp:latest",
1125			registryAuthEnabled: false,
1126			registryDomain:      "registry.example.com",
1127			wantRef:             "myapp:latest",
1128		},
1129		{
1130			name:                "localhost domain adds prefix",
1131			image:               "myapp:latest",
1132			registryAuthEnabled: true,
1133			registryDomain:      "localhost:5000",
1134			wantRef:             "localhost:5000/myapp:latest",
1135		},
1136		{
1137			name:                "localhost domain keeps existing prefix",
1138			image:               "localhost:5000/myapp:latest",
1139			registryAuthEnabled: true,
1140			registryDomain:      "localhost:5000",
1141			wantRef:             "localhost:5000/myapp:latest",
1142		},
1143		{
1144			name:                "explicit host:port different than RegistryDomain",
1145			image:               "localhost:5001/myapp:latest",
1146			registryAuthEnabled: true,
1147			registryDomain:      "localhost:5000",
1148			wantRef:             "localhost:5001/myapp:latest",
1149		},
1150		{
1151			name:                "ghcr.io external registry",
1152			image:               "ghcr.io/owner/repo:latest",
1153			registryAuthEnabled: true,
1154			registryDomain:      "localhost:5000",
1155			wantRef:             "ghcr.io/owner/repo:latest",
1156		},
1157		{
1158			name:                "gcr.io external registry",
1159			image:               "gcr.io/project/image:v1",
1160			registryAuthEnabled: true,
1161			registryDomain:      "registry.example.com",
1162			wantRef:             "gcr.io/project/image:v1",
1163		},
1164		{
1165			name:                "quay.io external registry",
1166			image:               "quay.io/org/app:tag",
1167			registryAuthEnabled: true,
1168			registryDomain:      "localhost:5000",
1169			wantRef:             "quay.io/org/app:tag",
1170		},
1171		{
1172			name:                "localhost without port keeps existing prefix",
1173			image:               "localhost/myapp:latest",
1174			registryAuthEnabled: true,
1175			registryDomain:      "localhost:5000",
1176			wantRef:             "localhost/myapp:latest",
1177		},
1178		{
1179			name:                "registry domain with trailing slash",
1180			image:               "myapp:latest",
1181			registryAuthEnabled: true,
1182			registryDomain:      "registry.example.com/",
1183			wantRef:             "registry.example.com/myapp:latest",
1184		},
1185		{
1186			name:                "ipv6 registry without port",
1187			image:               "[fd00::1]/myapp:latest",
1188			registryAuthEnabled: true,
1189			registryDomain:      "localhost:5000",
1190			wantRef:             "[fd00::1]/myapp:latest",
1191		},
1192		{
1193			name:                "ipv6 registry with port",
1194			image:               "[fd00::1]:5000/myapp:latest",
1195			registryAuthEnabled: true,
1196			registryDomain:      "localhost:5000",
1197			wantRef:             "[fd00::1]:5000/myapp:latest",
1198		},
1199	}
1200
1201	for _, tt := range tests {
1202		t.Run(tt.name, func(t *testing.T) {
1203			svc := &Service{
1204				config: Config{
1205					RegistryAuthEnabled: tt.registryAuthEnabled,
1206					RegistryDomain:      tt.registryDomain,
1207				},
1208			}
1209			gotRef := svc.buildImageRef(tt.image)
1210			assert.Equal(t, tt.wantRef, gotRef, "unexpected image reference")
1211		})
1212	}
1213}
1214
1215func TestRewriteToRegistryDomain(t *testing.T) {
1216	tests := []struct {
1217		name           string
1218		imageRef       string
1219		registryDomain string
1220		wantRef        string
1221	}{
1222		{
1223			name:           "keeps registry domain prefix",
1224			imageRef:       "registry.example.com/myapp:latest",
1225			registryDomain: "registry.example.com",
1226			wantRef:        "registry.example.com/myapp:latest",
1227		},
1228		{
1229			name:           "prefixes registry domain",
1230			imageRef:       "myapp:v1.0",
1231			registryDomain: "registry.example.com",
1232			wantRef:        "registry.example.com/myapp:v1.0",
1233		},
1234		{
1235			name:           "prefixes external image",
1236			imageRef:       "docker.io/library/nginx:latest",
1237			registryDomain: "registry.example.com",
1238			wantRef:        "registry.example.com/docker.io/library/nginx:latest",
1239		},
1240		{
1241			name:           "empty registry domain returns original",
1242			imageRef:       "registry.example.com/myapp:latest",
1243			registryDomain: "",
1244			wantRef:        "registry.example.com/myapp:latest",
1245		},
1246	}
1247
1248	for _, tt := range tests {
1249		t.Run(tt.name, func(t *testing.T) {
1250			gotRef := rewriteToRegistryDomain(tt.imageRef, tt.registryDomain)
1251			assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
1252		})
1253	}
1254}
1255
1256func TestRewriteToLocalRegistry(t *testing.T) {
1257	tests := []struct {
1258		name           string
1259		imageRef       string
1260		registryDomain string
1261		registryPort   int
1262		wantRef        string
1263	}{
1264		{
1265			name:           "rewrites registry domain prefix",
1266			imageRef:       "registry.example.com/myapp:latest",
1267			registryDomain: "registry.example.com",
1268			registryPort:   5000,
1269			wantRef:        "localhost:5000/myapp:latest",
1270		},
1271		{
1272			name:           "prefixes local registry when no domain",
1273			imageRef:       "myapp:v1.0",
1274			registryDomain: "registry.example.com",
1275			registryPort:   5000,
1276			wantRef:        "localhost:5000/myapp:v1.0",
1277		},
1278		{
1279			name:           "keeps existing localhost prefix",
1280			imageRef:       "localhost:5000/myapp:latest",
1281			registryDomain: "registry.example.com",
1282			registryPort:   5000,
1283			wantRef:        "localhost:5000/myapp:latest",
1284		},
1285		{
1286			name:           "prefixes external image path",
1287			imageRef:       "docker.io/library/nginx:latest",
1288			registryDomain: "registry.example.com",
1289			registryPort:   5000,
1290			wantRef:        "localhost:5000/docker.io/library/nginx:latest",
1291		},
1292	}
1293
1294	for _, tt := range tests {
1295		t.Run(tt.name, func(t *testing.T) {
1296			gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)
1297			assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
1298		})
1299	}
1300}
1301
1302func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1303	runtime := mocks.NewMockContainerRuntime(t)
1304	envLoader := mocks.NewMockEnvLoader(t)
1305	eventBus := mocks.NewMockEventPublisher(t)
1306
1307	config := Config{
1308		RegistryAuthEnabled:      true,
1309		RegistryDomain:           "registry.example.com",
1310		RegistryPort:             5000,
1311		InternalRegistryUsername: "internal",
1312		InternalRegistryPassword: "secret",
1313		ReadinessDelay:           time.Millisecond,
1314		DrainDelay:               time.Millisecond,
1315	}
1316
1317	svc := NewService(runtime, envLoader, eventBus, nil, config)
1318
1319	// Use internal deploy context (simulating image push event)
1320	ctx := domain.WithInternalDeploy(testContext())
1321
1322	route := domain.Route{
1323		Domain: "test.example.com",
1324		Image:  "myapp:latest",
1325	}
1326
1327	// Setup mocks - no orphaned containers
1328	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1329
1330	// Even though image exists locally, internal deploy should force pull
1331	// Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check
1332	runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
1333
1334	// Tag image to canonical name
1335	runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil)
1336	runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1337
1338	// Get exposed ports
1339	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
1340
1341	// Load environment
1342	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1343	runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil)
1344
1345	// Create and start container
1346	createdContainer := &domain.Container{
1347		ID:     "container-123",
1348		Name:   "gordon-test.example.com",
1349		Status: "created",
1350	}
1351	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
1352	runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
1353
1354	// Wait for ready
1355	runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
1356
1357	// Re-inspect after start
1358	runningContainer := &domain.Container{
1359		ID:     "container-123",
1360		Name:   "gordon-test.example.com",
1361		Status: "running",
1362		Ports:  []int{8080},
1363	}
1364	runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
1365
1366	// Publish event
1367	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1368
1369	result, err := svc.Deploy(ctx, route)
1370
1371	assert.NoError(t, err)
1372	assert.NotNil(t, result)
1373	assert.Equal(t, "container-123", result.ID)
1374}
1375
1376func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1377	runtime := mocks.NewMockContainerRuntime(t)
1378	envLoader := mocks.NewMockEnvLoader(t)
1379	eventBus := mocks.NewMockEventPublisher(t)
1380
1381	config := Config{
1382		ReadinessDelay: time.Millisecond,
1383		DrainDelay:     time.Millisecond,
1384	}
1385	svc := NewService(runtime, envLoader, eventBus, nil, config)
1386	ctx := testContext()
1387
1388	routes := []domain.Route{
1389		{Domain: "app1.example.com", Image: "myapp1:latest"},
1390		{Domain: "app2.example.com", Image: "myapp2:latest"},
1391	}
1392
1393	// Setup mocks for route deployments
1394	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1395	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
1396	runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
1397	envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1398	runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1399
1400	// Create and start containers — readiness is skipped so no IsContainerRunning calls.
1401	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1402		ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1403	}, nil).Once()
1404	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1405		ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
1406	}, nil).Once()
1407	runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
1408	runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
1409		Status: "running",
1410	}, nil).Times(2)
1411	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2)
1412
1413	err := svc.AutoStart(ctx, routes)
1414
1415	assert.NoError(t, err)
1416	assert.Len(t, svc.containers, 2)
1417}
1418
1419func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1420	runtime := mocks.NewMockContainerRuntime(t)
1421	envLoader := mocks.NewMockEnvLoader(t)
1422	eventBus := mocks.NewMockEventPublisher(t)
1423
1424	config := Config{
1425		ReadinessDelay: time.Millisecond,
1426		DrainDelay:     time.Millisecond,
1427	}
1428	svc := NewService(runtime, envLoader, eventBus, nil, config)
1429	ctx := testContext()
1430
1431	// Pre-populate with existing container
1432	svc.containers["app1.example.com"] = &domain.Container{
1433		ID: "existing-container",
1434	}
1435
1436	routes := []domain.Route{
1437		{Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists
1438		{Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
1439	}
1440
1441	// Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning.
1442	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
1443	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
1444	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
1445	envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
1446	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
1447	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1448		ID: "container-2", Status: "created",
1449	}, nil).Once()
1450	runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
1451	runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
1452		Status: "running",
1453	}, nil).Once()
1454	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
1455
1456	err := svc.AutoStart(ctx, routes)
1457
1458	assert.NoError(t, err)
1459	assert.Len(t, svc.containers, 2)
1460}
1461
1462func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
1463	runtime := mocks.NewMockContainerRuntime(t)
1464	envLoader := mocks.NewMockEnvLoader(t)
1465	eventBus := mocks.NewMockEventPublisher(t)
1466
1467	config := Config{}
1468	svc := NewService(runtime, envLoader, eventBus, nil, config)
1469	ctx := testContext()
1470
1471	routes := []domain.Route{
1472		{Domain: "app1.example.com", Image: "myapp1:latest"},
1473	}
1474
1475	// Setup mocks for failure
1476	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1477	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1478	runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found"))
1479
1480	err := svc.AutoStart(ctx, routes)
1481
1482	// AutoStart should return error when some deployments fail
1483	assert.Error(t, err)
1484	assert.Contains(t, err.Error(), "auto-start completed with 1 errors")
1485}
1486
1487func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1488	runtime := mocks.NewMockContainerRuntime(t)
1489	envLoader := mocks.NewMockEnvLoader(t)
1490	eventBus := mocks.NewMockEventPublisher(t)
1491
1492	config := Config{
1493		RegistryDomain: "reg.example.com",
1494		RegistryPort:   5000,
1495		ReadinessDelay: time.Millisecond,
1496		DrainDelay:     time.Millisecond,
1497	}
1498	svc := NewService(runtime, envLoader, eventBus, nil, config)
1499
1500	// Mark context as internal deploy — this is what syncAndAutoStart should do
1501	ctx := domain.WithInternalDeploy(testContext())
1502
1503	routes := []domain.Route{
1504		{Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"},
1505	}
1506
1507	// Key assertion: PullImage should be called with localhost:5000 rewrite,
1508	// NOT the original reg.example.com/myapp:latest.
1509	// Readiness is skipped — no IsContainerRunning calls.
1510	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1511	runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1512	runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)
1513	runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1514	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "reg.example.com/myapp:latest").Return([]int{8080}, nil)
1515	envLoader.EXPECT().LoadEnv(mock.Anything, "app1.example.com").Return([]string{}, nil)
1516	runtime.EXPECT().InspectImageEnv(mock.Anything, "reg.example.com/myapp:latest").Return([]string{}, nil)
1517	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1518		ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1519	}, nil)
1520	runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil)
1521	runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{
1522		Status: "running",
1523	}, nil)
1524	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1525
1526	err := svc.AutoStart(ctx, routes)
1527	assert.NoError(t, err)
1528}
1529
1530// TestService_Deploy_OrphanCleanupSkipsTrackedContainer verifies that the orphan cleanup
1531// does not remove the currently tracked container during zero-downtime deployment.
1532// This is critical for preventing downtime - the old container must stay running
1533// until the new container is ready and traffic is switched.
1534func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1535	runtime := mocks.NewMockContainerRuntime(t)
1536	envLoader := mocks.NewMockEnvLoader(t)
1537	eventBus := mocks.NewMockEventPublisher(t)
1538	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1539
1540	config := Config{
1541		ReadinessDelay: time.Millisecond, // Minimal delay for tests
1542		DrainDelay:     time.Millisecond,
1543	}
1544	svc := NewService(runtime, envLoader, eventBus, nil, config)
1545	svc.SetProxyCacheInvalidator(cacheInvalidator)
1546	ctx := testContext()
1547
1548	// Pre-populate with existing tracked container
1549	existingContainer := &domain.Container{
1550		ID:     "tracked-container-123",
1551		Name:   "gordon-test.example.com",
1552		Status: "running",
1553	}
1554	svc.containers["test.example.com"] = existingContainer
1555
1556	route := domain.Route{
1557		Domain: "test.example.com",
1558		Image:  "myapp:v2",
1559	}
1560
1561	// ListContainers returns the tracked container - this simulates the orphan check
1562	// finding a container with the same name. The bug was that it would remove this
1563	// container BEFORE the new one was ready, causing downtime.
1564	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1565		{
1566			ID:     "tracked-container-123",
1567			Name:   "gordon-test.example.com",
1568			Status: "running",
1569		},
1570	}, nil)
1571
1572	// Image operations
1573	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1574	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1575	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1576
1577	// Environment
1578	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1579	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1580
1581	// Create new container with -new suffix for zero-downtime
1582	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1583	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1584		return cfg.Name == "gordon-test.example.com-new"
1585	})).Return(newContainer, nil)
1586	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1587
1588	// Wait for ready
1589	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1590
1591	// Inspect after ready
1592	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1593		ID:     "new-container",
1594		Status: "running",
1595	}, nil)
1596
1597	// Publish event
1598	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1599
1600	// Synchronous cache invalidation
1601	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1602
1603	// NOW (after new container is ready) the old container should be stopped and removed
1604	// This is the correct zero-downtime sequence - not during orphan cleanup
1605	runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1606	runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1607
1608	// Rename new container to canonical name
1609	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1610
1611	result, err := svc.Deploy(ctx, route)
1612
1613	assert.NoError(t, err)
1614	assert.Equal(t, "new-container", result.ID)
1615
1616	// Verify new container is now tracked
1617	tracked, exists := svc.Get(ctx, "test.example.com")
1618	assert.True(t, exists)
1619	assert.Equal(t, "new-container", tracked.ID)
1620}
1621
1622// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers
1623// with the same name but NOT tracked are properly removed as orphans.
1624func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1625	runtime := mocks.NewMockContainerRuntime(t)
1626	envLoader := mocks.NewMockEnvLoader(t)
1627	eventBus := mocks.NewMockEventPublisher(t)
1628
1629	config := Config{
1630		ReadinessDelay: time.Millisecond, // Minimal delay for tests
1631		DrainDelay:     time.Millisecond,
1632	}
1633	svc := NewService(runtime, envLoader, eventBus, nil, config)
1634	ctx := testContext()
1635
1636	// NO tracked container - service is empty for this domain
1637
1638	route := domain.Route{
1639		Domain: "test.example.com",
1640		Image:  "myapp:v1",
1641	}
1642
1643	// ListContainers returns an orphaned container (same name, but not tracked)
1644	// This could happen if Gordon crashed and restarted, or container was created manually
1645	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1646		{
1647			ID:     "orphan-container",
1648			Name:   "gordon-test.example.com",
1649			Status: "running",
1650		},
1651	}, nil)
1652
1653	// Orphan should be stopped and removed BEFORE we proceed
1654	runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
1655	runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
1656
1657	// Image operations
1658	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1659	runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
1660	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
1661
1662	// Environment
1663	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1664	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v1").Return([]string{}, nil)
1665
1666	// Create container (no -new suffix since no existing tracked container)
1667	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
1668	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1669		return cfg.Name == "gordon-test.example.com"
1670	})).Return(newContainer, nil)
1671	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1672
1673	// Wait for ready
1674	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1675
1676	// Inspect after ready
1677	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1678		ID:     "new-container",
1679		Status: "running",
1680		Ports:  []int{8080},
1681	}, nil)
1682
1683	// Publish event
1684	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1685
1686	result, err := svc.Deploy(ctx, route)
1687
1688	assert.NoError(t, err)
1689	assert.Equal(t, "new-container", result.ID)
1690
1691	// Verify new container is tracked
1692	tracked, exists := svc.Get(ctx, "test.example.com")
1693	assert.True(t, exists)
1694	assert.Equal(t, "new-container", tracked.ID)
1695}
1696
1697func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
1698	runtime := mocks.NewMockContainerRuntime(t)
1699	envLoader := mocks.NewMockEnvLoader(t)
1700	eventBus := mocks.NewMockEventPublisher(t)
1701	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1702
1703	config := Config{
1704		ReadinessDelay: time.Millisecond,
1705		DrainDelay:     time.Millisecond,
1706	}
1707	svc := NewService(runtime, envLoader, eventBus, nil, config)
1708	svc.SetProxyCacheInvalidator(cacheInvalidator)
1709	ctx := testContext()
1710
1711	// Tracked canonical container exists, but stale -new container should be removed.
1712	svc.containers["test.example.com"] = &domain.Container{
1713		ID:     "tracked-container-123",
1714		Name:   "gordon-test.example.com",
1715		Status: "running",
1716	}
1717
1718	route := domain.Route{
1719		Domain: "test.example.com",
1720		Image:  "myapp:v2",
1721	}
1722
1723	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1724		{
1725			ID:     "tracked-container-123",
1726			Name:   "gordon-test.example.com",
1727			Status: "running",
1728		},
1729		{
1730			ID:     "stale-new-container",
1731			Name:   "gordon-test.example.com-new",
1732			Status: "exited",
1733		},
1734	}, nil)
1735	runtime.EXPECT().StopContainer(mock.Anything, "stale-new-container").Return(nil).Once()
1736	runtime.EXPECT().RemoveContainer(mock.Anything, "stale-new-container", true).Return(nil).Once()
1737
1738	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1739	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1740	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1741	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1742	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1743
1744	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1745	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1746		return cfg.Name == "gordon-test.example.com-new"
1747	})).Return(newContainer, nil)
1748	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1749	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1750	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1751		ID:     "new-container",
1752		Status: "running",
1753	}, nil)
1754	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1755
1756	// Synchronous cache invalidation
1757	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1758
1759	runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1760	runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1761	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1762
1763	result, err := svc.Deploy(ctx, route)
1764	assert.NoError(t, err)
1765	assert.NotNil(t, result)
1766	assert.Equal(t, "new-container", result.ID)
1767}
1768
1769func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) {
1770	runtime := mocks.NewMockContainerRuntime(t)
1771	envLoader := mocks.NewMockEnvLoader(t)
1772	eventBus := mocks.NewMockEventPublisher(t)
1773	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1774
1775	config := Config{
1776		ReadinessDelay: time.Millisecond,
1777		DrainDelay:     time.Millisecond,
1778	}
1779	svc := NewService(runtime, envLoader, eventBus, nil, config)
1780	svc.SetProxyCacheInvalidator(cacheInvalidator)
1781	ctx := testContext()
1782
1783	// Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name.
1784	svc.containers["test.example.com"] = &domain.Container{
1785		ID:     "tracked-temp-container",
1786		Name:   "gordon-test.example.com-new",
1787		Status: "running",
1788	}
1789
1790	route := domain.Route{
1791		Domain: "test.example.com",
1792		Image:  "myapp:v2",
1793	}
1794
1795	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1796		{
1797			ID:     "tracked-temp-container",
1798			Name:   "gordon-test.example.com-new",
1799			Status: "running",
1800		},
1801	}, nil)
1802
1803	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1804	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1805	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1806	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1807	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1808
1809	created := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-next", Status: "created"}
1810	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1811		return cfg.Name == "gordon-test.example.com-next"
1812	})).Return(created, nil)
1813	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1814	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1815	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1816		ID:     "new-container",
1817		Status: "running",
1818	}, nil)
1819	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1820
1821	// Synchronous cache invalidation
1822	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1823
1824	runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil)
1825	runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil)
1826	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1827
1828	result, err := svc.Deploy(ctx, route)
1829	assert.NoError(t, err)
1830	assert.NotNil(t, result)
1831	assert.Equal(t, "new-container", result.ID)
1832}
1833
1834func TestService_StripRegistryPrefix(t *testing.T) {
1835	tests := []struct {
1836		name           string
1837		registryDomain string
1838		image          string
1839		expected       string
1840	}{
1841		{
1842			name:           "strips registry prefix",
1843			registryDomain: "reg.example.com",
1844			image:          "reg.example.com/myapp:latest",
1845			expected:       "myapp:latest",
1846		},
1847		{
1848			name:           "strips registry prefix with trailing slash in domain",
1849			registryDomain: "reg.example.com/",
1850			image:          "reg.example.com/myapp:v1.0",
1851			expected:       "myapp:v1.0",
1852		},
1853		{
1854			name:           "preserves image without registry prefix",
1855			registryDomain: "reg.example.com",
1856			image:          "nginx:latest",
1857			expected:       "nginx:latest",
1858		},
1859		{
1860			name:           "preserves image with different registry",
1861			registryDomain: "reg.example.com",
1862			image:          "gcr.io/project/image:latest",
1863			expected:       "gcr.io/project/image:latest",
1864		},
1865		{
1866			name:           "handles empty registry domain",
1867			registryDomain: "",
1868			image:          "reg.example.com/myapp:latest",
1869			expected:       "reg.example.com/myapp:latest",
1870		},
1871		{
1872			name:           "handles nested paths",
1873			registryDomain: "reg.example.com",
1874			image:          "reg.example.com/org/repo/app:latest",
1875			expected:       "org/repo/app:latest",
1876		},
1877	}
1878
1879	for _, tt := range tests {
1880		t.Run(tt.name, func(t *testing.T) {
1881			runtime := mocks.NewMockContainerRuntime(t)
1882			envLoader := mocks.NewMockEnvLoader(t)
1883			eventBus := mocks.NewMockEventPublisher(t)
1884
1885			config := Config{
1886				RegistryDomain: tt.registryDomain,
1887			}
1888			svc := NewService(runtime, envLoader, eventBus, nil, config)
1889
1890			result := svc.stripRegistryPrefix(tt.image)
1891			assert.Equal(t, tt.expected, result)
1892		})
1893	}
1894}
1895
1896func TestSanitizeName(t *testing.T) {
1897	tests := []struct {
1898		domain      string
1899		expected    string
1900		description string
1901	}{
1902		{
1903			domain:      "git.example.com",
1904			expected:    "git__example__com",
1905			description: "Dots become double underscores",
1906		},
1907		{
1908			domain:      "git-example.com",
1909			expected:    "git-example__com",
1910			description: "Hyphens preserved, dots become underscores",
1911		},
1912		{
1913			domain:      "app:8080.example.com",
1914			expected:    "app-_8080__example__com",
1915			description: "Colons become hyphen-underscore",
1916		},
1917		{
1918			domain:      "git.example.com:3000",
1919			expected:    "git__example__com-_3000",
1920			description: "Multiple separators handled distinctly",
1921		},
1922		{
1923			domain:      "simple.com",
1924			expected:    "simple__com",
1925			description: "Simple domain",
1926		},
1927	}
1928
1929	for _, tt := range tests {
1930		t.Run(tt.domain, func(t *testing.T) {
1931			result := sanitizeName(tt.domain)
1932			assert.Equal(t, tt.expected, result, "sanitization should match expected")
1933		})
1934	}
1935
1936	// Verify no collisions between potentially conflicting domains
1937	t.Run("NoCollisions", func(t *testing.T) {
1938		domains := []string{
1939			"git.example.com",
1940			"git-example.com",
1941			"app:8080.example.com",
1942			"app-8080-example.com",
1943		}
1944
1945		results := make(map[string]string)
1946		for _, d := range domains {
1947			result := sanitizeName(d)
1948			if original, exists := results[result]; exists {
1949				t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
1950			}
1951			results[result] = d
1952		}
1953	})
1954}
1955
1956func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1957	// Verify that concurrent Deploy calls for the same domain are serialized
1958	// and both succeed without container name conflicts.
1959	runtime := mocks.NewMockContainerRuntime(t)
1960	envLoader := mocks.NewMockEnvLoader(t)
1961	eventBus := mocks.NewMockEventPublisher(t)
1962	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1963
1964	config := Config{
1965		ReadinessDelay: time.Millisecond,
1966		DrainDelay:     time.Millisecond,
1967	}
1968	svc := NewService(runtime, envLoader, eventBus, nil, config)
1969	svc.SetProxyCacheInvalidator(cacheInvalidator)
1970	ctx := testContext()
1971
1972	route := domain.Route{
1973		Domain: "test.example.com",
1974		Image:  "myapp:latest",
1975	}
1976
1977	// Track call order to verify serialization
1978	var callOrder []string
1979	var callMu sync.Mutex
1980
1981	// Setup mocks that will be called by both deploys sequentially.
1982	// First deploy: no existing container → creates gordon-test.example.com
1983	// Second deploy: sees first container → creates gordon-test.example.com-new
1984
1985	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1986	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
1987	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
1988	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, nil).Times(2)
1989	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
1990
1991	createCall := 0
1992	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
1993		RunAndReturn(func(_ context.Context, cfg *domain.ContainerConfig) (*domain.Container, error) {
1994			callMu.Lock()
1995			createCall++
1996			n := createCall
1997			callOrder = append(callOrder, fmt.Sprintf("create-%d:%s", n, cfg.Name))
1998			callMu.Unlock()
1999			return &domain.Container{
2000				ID:     fmt.Sprintf("container-%d", n),
2001				Name:   cfg.Name,
2002				Image:  cfg.Image,
2003				Status: "created",
2004			}, nil
2005		})
2006
2007	runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
2008	// IsContainerRunning is called 2x per deploy in waitForReady (initial check + after delay)
2009	runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
2010
2011	runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
2012		RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
2013			// Return correct container name based on ID
2014			// container-1 is the first deploy (canonical name)
2015			// container-2 is the second deploy (with -new suffix)
2016			name := "gordon-test.example.com"
2017			if id == "container-2" {
2018				name = "gordon-test.example.com-new"
2019			}
2020			return &domain.Container{
2021				ID:     id,
2022				Name:   name,
2023				Image:  "myapp:latest",
2024				Status: "running",
2025				Ports:  []int{8080},
2026			}, nil
2027		})
2028
2029	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)
2030
2031	// Both deploys call InvalidateTarget (to ensure proxy picks up the new container).
2032	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
2033
2034	// which means it will also stop+remove+rename the old container.
2035	runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
2036	runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
2037	runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Once()
2038
2039	// Launch two concurrent deploys
2040	var wg sync.WaitGroup
2041	errs := make([]error, 2)
2042
2043	wg.Add(2)
2044	for i := range 2 {
2045		go func(idx int) {
2046			defer wg.Done()
2047			_, errs[idx] = svc.Deploy(ctx, route)
2048		}(i)
2049	}
2050	wg.Wait()
2051
2052	// Both should succeed (second deploy waits for the first to finish)
2053	assert.NoError(t, errs[0], "first deploy should succeed")
2054	assert.NoError(t, errs[1], "second deploy should succeed")
2055
2056	// Verify creates were serialized (not interleaved)
2057	callMu.Lock()
2058	assert.Len(t, callOrder, 2, "should have exactly 2 create calls")
2059	// First deploy uses canonical name, second uses -new suffix (zero-downtime)
2060	assert.Equal(t, "create-1:gordon-test.example.com", callOrder[0], "first create should use canonical name")
2061	assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
2062	callMu.Unlock()
2063}
2064
2065func TestService_Deploy_ContextCancellation(t *testing.T) {
2066	// Verify that deploy lock acquisition respects context cancellation.
2067	runtime := mocks.NewMockContainerRuntime(t)
2068	envLoader := mocks.NewMockEnvLoader(t)
2069	eventBus := mocks.NewMockEventPublisher(t)
2070
2071	config := Config{
2072		ReadinessDelay: time.Millisecond,
2073		DrainDelay:     time.Millisecond,
2074	}
2075	svc := NewService(runtime, envLoader, eventBus, nil, config)
2076
2077	route := domain.Route{
2078		Domain: "test.example.com",
2079		Image:  "myapp:latest",
2080	}
2081
2082	// Test context cancelled before lock acquisition.
2083	cancelledCtx, cancel := context.WithCancel(testContext())
2084	cancel() // Cancel immediately
2085
2086	_, err := svc.Deploy(cancelledCtx, route)
2087	assert.Error(t, err, "deploy should fail with cancelled context")
2088	assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
2089}
2090
2091// TestService_Deploy_CacheInvalidationBeforeOldContainerStop verifies the fix for
2092// the proxy cache race condition: InvalidateTarget must be called synchronously
2093// BEFORE the old container is stopped, preventing 503 errors during deployment.
2094func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
2095	runtime := mocks.NewMockContainerRuntime(t)
2096	envLoader := mocks.NewMockEnvLoader(t)
2097	eventBus := mocks.NewMockEventPublisher(t)
2098	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2099
2100	config := Config{
2101		ReadinessDelay: time.Millisecond,
2102		DrainDelay:     time.Millisecond,
2103	}
2104	svc := NewService(runtime, envLoader, eventBus, nil, config)
2105	svc.SetProxyCacheInvalidator(cacheInvalidator)
2106	ctx := testContext()
2107
2108	// Pre-populate with existing container
2109	existingContainer := &domain.Container{
2110		ID:     "old-container",
2111		Name:   "gordon-test.example.com",
2112		Status: "running",
2113	}
2114	svc.containers["test.example.com"] = existingContainer
2115
2116	route := domain.Route{
2117		Domain: "test.example.com",
2118		Image:  "myapp:v2",
2119	}
2120
2121	// Track ordering of cache invalidation and container stop
2122	var callOrder []string
2123	var orderMu sync.Mutex
2124
2125	// Standard deploy mocks
2126	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2127	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
2128	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2129	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2130	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2131
2132	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2133	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2134	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2135	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2136	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2137		ID: "new-container", Status: "running",
2138	}, nil)
2139	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2140
2141	// Track: cache invalidation should happen first
2142	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").
2143		Run(func(_ context.Context, _ string) {
2144			orderMu.Lock()
2145			callOrder = append(callOrder, "invalidate_cache")
2146			orderMu.Unlock()
2147		}).Return()
2148
2149	// Track: stop should happen after invalidation
2150	runtime.EXPECT().StopContainer(mock.Anything, "old-container").
2151		RunAndReturn(func(_ context.Context, _ string) error {
2152			orderMu.Lock()
2153			callOrder = append(callOrder, "stop_old_container")
2154			orderMu.Unlock()
2155			return nil
2156		})
2157	runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2158	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2159
2160	result, err := svc.Deploy(ctx, route)
2161
2162	assert.NoError(t, err)
2163	assert.Equal(t, "new-container", result.ID)
2164
2165	// Verify ordering: cache invalidation MUST happen before old container stop
2166	orderMu.Lock()
2167	defer orderMu.Unlock()
2168	assert.Equal(t, []string{"invalidate_cache", "stop_old_container"}, callOrder,
2169		"InvalidateTarget must be called before StopContainer to prevent 503 errors")
2170}
2171
2172// TestService_Deploy_NilCacheInvalidator verifies that deploy works gracefully
2173// when no cache invalidator is set (e.g., in tests or minimal configurations).
2174func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
2175	runtime := mocks.NewMockContainerRuntime(t)
2176	envLoader := mocks.NewMockEnvLoader(t)
2177	eventBus := mocks.NewMockEventPublisher(t)
2178
2179	config := Config{
2180		ReadinessDelay: time.Millisecond,
2181		DrainDelay:     time.Millisecond,
2182	}
2183	// Intentionally NOT setting cache invalidator
2184	svc := NewService(runtime, envLoader, eventBus, nil, config)
2185	ctx := testContext()
2186
2187	// Pre-populate with existing container
2188	svc.containers["test.example.com"] = &domain.Container{
2189		ID:     "old-container",
2190		Name:   "gordon-test.example.com",
2191		Status: "running",
2192	}
2193
2194	route := domain.Route{
2195		Domain: "test.example.com",
2196		Image:  "myapp:v2",
2197	}
2198
2199	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2200	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
2201	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2202	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2203	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2204
2205	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2206	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2207	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2208	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2209	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2210		ID: "new-container", Status: "running",
2211	}, nil)
2212	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2213	runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
2214	runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2215	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2216
2217	result, err := svc.Deploy(ctx, route)
2218
2219	assert.NoError(t, err)
2220	assert.Equal(t, "new-container", result.ID)
2221}
2222
2223func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
2224	runtime := mocks.NewMockContainerRuntime(t)
2225	envLoader := mocks.NewMockEnvLoader(t)
2226	eventBus := mocks.NewMockEventPublisher(t)
2227
2228	config := Config{
2229		ReadinessDelay: time.Millisecond,
2230		DrainDelay:     time.Millisecond,
2231	}
2232	svc := NewService(runtime, envLoader, eventBus, nil, config)
2233	ctx := testContext()
2234
2235	// Pre-populate with existing container that has an ImageID (set by InspectContainer).
2236	// This simulates the first deploy (from image.pushed event) having already completed.
2237	existingContainer := &domain.Container{
2238		ID:      "existing-container",
2239		Name:    "gordon-test.example.com",
2240		Image:   "myapp:latest",
2241		ImageID: "sha256:abc123",
2242		Status:  "running",
2243	}
2244	svc.containers["test.example.com"] = existingContainer
2245
2246	route := domain.Route{
2247		Domain: "test.example.com",
2248		Image:  "myapp:latest",
2249	}
2250
2251	// prepareDeployResources will run: orphan cleanup, image pull, etc.
2252	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2253	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2254	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2255	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2256	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2257
2258	// skipRedundantDeploy: resolve image ID and compare with existing container
2259	runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
2260	runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
2261
2262	// No CreateContainer, StartContainer, or readiness calls should happen
2263
2264	result, err := svc.Deploy(ctx, route)
2265
2266	assert.NoError(t, err)
2267	assert.NotNil(t, result)
2268	assert.Equal(t, "existing-container", result.ID)
2269
2270	// Container should still be tracked
2271	tracked, exists := svc.Get(ctx, "test.example.com")
2272	assert.True(t, exists)
2273	assert.Equal(t, "existing-container", tracked.ID)
2274}
2275
2276func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
2277	runtime := mocks.NewMockContainerRuntime(t)
2278	envLoader := mocks.NewMockEnvLoader(t)
2279	eventBus := mocks.NewMockEventPublisher(t)
2280	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2281
2282	config := Config{
2283		ReadinessDelay: time.Millisecond,
2284		DrainDelay:     time.Millisecond,
2285	}
2286	svc := NewService(runtime, envLoader, eventBus, nil, config)
2287	svc.SetProxyCacheInvalidator(cacheInvalidator)
2288	ctx := testContext()
2289
2290	// Existing container with a DIFFERENT image ID than what's being deployed
2291	existingContainer := &domain.Container{
2292		ID:      "old-container",
2293		Name:    "gordon-test.example.com",
2294		Image:   "myapp:latest",
2295		ImageID: "sha256:old-image",
2296		Status:  "running",
2297	}
2298	svc.containers["test.example.com"] = existingContainer
2299
2300	route := domain.Route{
2301		Domain: "test.example.com",
2302		Image:  "myapp:latest",
2303	}
2304
2305	// prepareDeployResources
2306	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2307	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2308	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2309	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2310	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2311
2312	// skipRedundantDeploy: image IDs differ, so deploy proceeds
2313	runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:new-image", nil)
2314
2315	// Full deploy proceeds
2316	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2317	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2318	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2319	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2320	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2321		ID: "new-container", Status: "running",
2322	}, nil)
2323	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2324	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2325	runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
2326	runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2327	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2328
2329	result, err := svc.Deploy(ctx, route)
2330
2331	assert.NoError(t, err)
2332	assert.Equal(t, "new-container", result.ID)
2333}
2334
2335func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
2336	runtime := mocks.NewMockContainerRuntime(t)
2337	envLoader := mocks.NewMockEnvLoader(t)
2338	eventBus := mocks.NewMockEventPublisher(t)
2339
2340	config := Config{
2341		ReadinessDelay: time.Millisecond,
2342		DrainDelay:     time.Millisecond,
2343	}
2344	svc := NewService(runtime, envLoader, eventBus, nil, config)
2345	ctx := testContext()
2346
2347	// Existing container has same image ID but is NOT running (crashed)
2348	existingContainer := &domain.Container{
2349		ID:      "existing-container",
2350		Name:    "gordon-test.example.com",
2351		Image:   "myapp:latest",
2352		ImageID: "sha256:abc123",
2353		Status:  "exited",
2354	}
2355	svc.containers["test.example.com"] = existingContainer
2356
2357	route := domain.Route{
2358		Domain: "test.example.com",
2359		Image:  "myapp:latest",
2360	}
2361
2362	// prepareDeployResources
2363	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2364	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2365	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2366	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2367	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2368
2369	// skipRedundantDeploy: same image ID but container not running => proceed with deploy
2370	runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
2371	runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(false, nil)
2372
2373	// Full deploy proceeds (replaces crashed container)
2374	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2375	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2376	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2377	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2378	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2379		ID: "new-container", Status: "running",
2380	}, nil)
2381	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2382
2383	// Old (exited) container is still finalized
2384	runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil)
2385	runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", 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
2390	assert.NoError(t, err)
2391	assert.Equal(t, "new-container", result.ID)
2392}

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