v2.15.0 cf76057

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

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 8

Tags 106

↑↓ moveenter openesc close