v2.15.1 c808592

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	// Shutdown no longer stops containers — they are left running for
634	// SyncContainers + AutoStart to pick back up on next boot.
635	err := svc.Shutdown(ctx)
636
637	assert.NoError(t, err)
638	// Containers remain tracked (not stopped).
639	assert.Len(t, svc.containers, 2)
640}
641
642func TestService_Restart_NotFound(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	err := svc.Restart(ctx, "nonexistent.example.com", false)
651
652	assert.Error(t, err)
653	assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
654}
655
656func TestService_Restart_Success(t *testing.T) {
657	runtime := mocks.NewMockContainerRuntime(t)
658	envLoader := mocks.NewMockEnvLoader(t)
659	eventBus := mocks.NewMockEventPublisher(t)
660
661	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
662	ctx := testContext()
663
664	svc.containers["test.example.com"] = &domain.Container{
665		ID:     "container-123",
666		Name:   "gordon-test.example.com",
667		Status: "running",
668	}
669
670	runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
671
672	err := svc.Restart(ctx, "test.example.com", false)
673
674	assert.NoError(t, err)
675}
676
677func TestService_Restart_Error(t *testing.T) {
678	runtime := mocks.NewMockContainerRuntime(t)
679	envLoader := mocks.NewMockEnvLoader(t)
680	eventBus := mocks.NewMockEventPublisher(t)
681
682	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
683	ctx := testContext()
684
685	svc.containers["test.example.com"] = &domain.Container{
686		ID:     "container-123",
687		Name:   "gordon-test.example.com",
688		Status: "running",
689	}
690
691	runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(errors.New("restart failed"))
692
693	err := svc.Restart(ctx, "test.example.com", false)
694
695	assert.Error(t, err)
696	assert.Contains(t, err.Error(), "failed to restart container")
697}
698
699func TestService_Restart_ReconcilesStaleContainerID(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:     "stale-container-id",
709		Name:   "gordon-test.example.com",
710		Status: "running",
711	}
712
713	runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
714		Return(errors.New("no container with name or ID \"stale-container-id\" found")).
715		Once()
716	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
717		{
718			ID:   "fresh-container-id",
719			Name: "gordon-test.example.com",
720			Labels: map[string]string{
721				"gordon.domain":  "test.example.com",
722				"gordon.managed": "true",
723			},
724		},
725	}, nil).Once()
726	runtime.EXPECT().RestartContainer(mock.Anything, "fresh-container-id").Return(nil).Once()
727
728	err := svc.Restart(ctx, "test.example.com", false)
729
730	assert.NoError(t, err)
731}
732
733func TestService_Restart_ReconcilesStaleContainerID_NotFoundAfterSync(t *testing.T) {
734	runtime := mocks.NewMockContainerRuntime(t)
735	envLoader := mocks.NewMockEnvLoader(t)
736	eventBus := mocks.NewMockEventPublisher(t)
737
738	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
739	ctx := testContext()
740
741	svc.containers["test.example.com"] = &domain.Container{
742		ID:     "stale-container-id",
743		Name:   "gordon-test.example.com",
744		Status: "running",
745	}
746
747	runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
748		Return(errors.New("no such container")).
749		Once()
750	runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
751
752	err := svc.Restart(ctx, "test.example.com", false)
753
754	assert.Error(t, err)
755	assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
756}
757
758func TestService_Restart_WithAttachments(t *testing.T) {
759	runtime := mocks.NewMockContainerRuntime(t)
760	envLoader := mocks.NewMockEnvLoader(t)
761	eventBus := mocks.NewMockEventPublisher(t)
762
763	svc := NewService(runtime, envLoader, eventBus, nil, Config{})
764	ctx := testContext()
765
766	svc.containers["test.example.com"] = &domain.Container{
767		ID:     "container-123",
768		Name:   "gordon-test.example.com",
769		Status: "running",
770	}
771	svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"}
772
773	runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
774	runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
775	runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-2").Return(fmt.Errorf("boom"))
776
777	err := svc.Restart(ctx, "test.example.com", true)
778
779	assert.NoError(t, err)
780}
781
782func TestNormalizeImageRef(t *testing.T) {
783	tests := []struct {
784		name     string
785		input    string
786		expected string
787	}{
788		{
789			name:     "simple image",
790			input:    "nginx",
791			expected: "docker.io/library/nginx",
792		},
793		{
794			name:     "user/repo",
795			input:    "user/myapp",
796			expected: "docker.io/user/myapp",
797		},
798		{
799			name:     "registry/repo",
800			input:    "gcr.io/project/image",
801			expected: "gcr.io/project/image",
802		},
803		{
804			name:     "with tag",
805			input:    "nginx:latest",
806			expected: "docker.io/library/nginx",
807		},
808		{
809			name:     "localhost with port",
810			input:    "localhost:5000/myapp:latest",
811			expected: "localhost:5000/myapp",
812		},
813		{
814			name:     "localhost with port no tag",
815			input:    "localhost:5000/myapp",
816			expected: "localhost:5000/myapp",
817		},
818		{
819			name:     "registry domain with port",
820			input:    "registry.example.com:5000/image:v1.0",
821			expected: "registry.example.com:5000/image",
822		},
823	}
824
825	for _, tt := range tests {
826		t.Run(tt.name, func(t *testing.T) {
827			result := normalizeImageRef(tt.input)
828			assert.Equal(t, tt.expected, result)
829		})
830	}
831}
832
833func TestGenerateVolumeName(t *testing.T) {
834	tests := []struct {
835		name       string
836		prefix     string
837		domain     string
838		volumePath string
839		expected   string
840	}{
841		{
842			name:       "simple path",
843			prefix:     "gordon",
844			domain:     "app.example.com",
845			volumePath: "/data",
846			expected:   "gordon-app-example-com-data",
847		},
848		{
849			name:       "nested path",
850			prefix:     "gordon",
851			domain:     "app.example.com",
852			volumePath: "/var/lib/data",
853			expected:   "gordon-app-example-com-var-lib-data",
854		},
855	}
856
857	for _, tt := range tests {
858		t.Run(tt.name, func(t *testing.T) {
859			result := generateVolumeName(tt.prefix, tt.domain, tt.volumePath)
860			assert.Equal(t, tt.expected, result)
861		})
862	}
863}
864
865func TestMergeEnvironmentVariables(t *testing.T) {
866	dockerEnv := []string{"FOO=docker", "BAR=docker"}
867	userEnv := []string{"FOO=user", "BAZ=user"}
868
869	result := mergeEnvironmentVariables(dockerEnv, userEnv)
870
871	// User env should override docker env
872	assert.Contains(t, result, "FOO=user")
873	assert.Contains(t, result, "BAR=docker")
874	assert.Contains(t, result, "BAZ=user")
875}
876
877func TestBuildImageRef(t *testing.T) {
878	tests := []struct {
879		name                string
880		image               string
881		registryAuthEnabled bool
882		registryDomain      string
883		wantRef             string
884	}{
885		{
886			name:                "adds registry domain prefix",
887			image:               "myapp:latest",
888			registryAuthEnabled: true,
889			registryDomain:      "registry.example.com",
890			wantRef:             "registry.example.com/myapp:latest",
891		},
892		{
893			name:                "keeps existing registry domain prefix",
894			image:               "registry.example.com/myapp:latest",
895			registryAuthEnabled: true,
896			registryDomain:      "registry.example.com",
897			wantRef:             "registry.example.com/myapp:latest",
898		},
899		{
900			name:                "skips external registry images",
901			image:               "docker.io/library/nginx:latest",
902			registryAuthEnabled: true,
903			registryDomain:      "registry.example.com",
904			wantRef:             "docker.io/library/nginx:latest",
905		},
906		{
907			name:                "returns original when auth disabled",
908			image:               "myapp:latest",
909			registryAuthEnabled: false,
910			registryDomain:      "registry.example.com",
911			wantRef:             "myapp:latest",
912		},
913		{
914			name:                "localhost domain adds prefix",
915			image:               "myapp:latest",
916			registryAuthEnabled: true,
917			registryDomain:      "localhost:5000",
918			wantRef:             "localhost:5000/myapp:latest",
919		},
920		{
921			name:                "localhost domain keeps existing prefix",
922			image:               "localhost:5000/myapp:latest",
923			registryAuthEnabled: true,
924			registryDomain:      "localhost:5000",
925			wantRef:             "localhost:5000/myapp:latest",
926		},
927		{
928			name:                "explicit host:port different than RegistryDomain",
929			image:               "localhost:5001/myapp:latest",
930			registryAuthEnabled: true,
931			registryDomain:      "localhost:5000",
932			wantRef:             "localhost:5001/myapp:latest",
933		},
934		{
935			name:                "ghcr.io external registry",
936			image:               "ghcr.io/owner/repo:latest",
937			registryAuthEnabled: true,
938			registryDomain:      "localhost:5000",
939			wantRef:             "ghcr.io/owner/repo:latest",
940		},
941		{
942			name:                "gcr.io external registry",
943			image:               "gcr.io/project/image:v1",
944			registryAuthEnabled: true,
945			registryDomain:      "registry.example.com",
946			wantRef:             "gcr.io/project/image:v1",
947		},
948		{
949			name:                "quay.io external registry",
950			image:               "quay.io/org/app:tag",
951			registryAuthEnabled: true,
952			registryDomain:      "localhost:5000",
953			wantRef:             "quay.io/org/app:tag",
954		},
955		{
956			name:                "localhost without port keeps existing prefix",
957			image:               "localhost/myapp:latest",
958			registryAuthEnabled: true,
959			registryDomain:      "localhost:5000",
960			wantRef:             "localhost/myapp:latest",
961		},
962		{
963			name:                "registry domain with trailing slash",
964			image:               "myapp:latest",
965			registryAuthEnabled: true,
966			registryDomain:      "registry.example.com/",
967			wantRef:             "registry.example.com/myapp:latest",
968		},
969		{
970			name:                "ipv6 registry without port",
971			image:               "[fd00::1]/myapp:latest",
972			registryAuthEnabled: true,
973			registryDomain:      "localhost:5000",
974			wantRef:             "[fd00::1]/myapp:latest",
975		},
976		{
977			name:                "ipv6 registry with port",
978			image:               "[fd00::1]:5000/myapp:latest",
979			registryAuthEnabled: true,
980			registryDomain:      "localhost:5000",
981			wantRef:             "[fd00::1]:5000/myapp:latest",
982		},
983	}
984
985	for _, tt := range tests {
986		t.Run(tt.name, func(t *testing.T) {
987			svc := &Service{
988				config: Config{
989					RegistryAuthEnabled: tt.registryAuthEnabled,
990					RegistryDomain:      tt.registryDomain,
991				},
992			}
993			gotRef := svc.buildImageRef(tt.image)
994			assert.Equal(t, tt.wantRef, gotRef, "unexpected image reference")
995		})
996	}
997}
998
999func TestRewriteToRegistryDomain(t *testing.T) {
1000	tests := []struct {
1001		name           string
1002		imageRef       string
1003		registryDomain string
1004		wantRef        string
1005	}{
1006		{
1007			name:           "keeps registry domain prefix",
1008			imageRef:       "registry.example.com/myapp:latest",
1009			registryDomain: "registry.example.com",
1010			wantRef:        "registry.example.com/myapp:latest",
1011		},
1012		{
1013			name:           "prefixes registry domain",
1014			imageRef:       "myapp:v1.0",
1015			registryDomain: "registry.example.com",
1016			wantRef:        "registry.example.com/myapp:v1.0",
1017		},
1018		{
1019			name:           "prefixes external image",
1020			imageRef:       "docker.io/library/nginx:latest",
1021			registryDomain: "registry.example.com",
1022			wantRef:        "registry.example.com/docker.io/library/nginx:latest",
1023		},
1024		{
1025			name:           "empty registry domain returns original",
1026			imageRef:       "registry.example.com/myapp:latest",
1027			registryDomain: "",
1028			wantRef:        "registry.example.com/myapp:latest",
1029		},
1030	}
1031
1032	for _, tt := range tests {
1033		t.Run(tt.name, func(t *testing.T) {
1034			gotRef := rewriteToRegistryDomain(tt.imageRef, tt.registryDomain)
1035			assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
1036		})
1037	}
1038}
1039
1040func TestRewriteToLocalRegistry(t *testing.T) {
1041	tests := []struct {
1042		name           string
1043		imageRef       string
1044		registryDomain string
1045		registryPort   int
1046		wantRef        string
1047	}{
1048		{
1049			name:           "rewrites registry domain prefix",
1050			imageRef:       "registry.example.com/myapp:latest",
1051			registryDomain: "registry.example.com",
1052			registryPort:   5000,
1053			wantRef:        "localhost:5000/myapp:latest",
1054		},
1055		{
1056			name:           "prefixes local registry when no domain",
1057			imageRef:       "myapp:v1.0",
1058			registryDomain: "registry.example.com",
1059			registryPort:   5000,
1060			wantRef:        "localhost:5000/myapp:v1.0",
1061		},
1062		{
1063			name:           "keeps existing localhost prefix",
1064			imageRef:       "localhost:5000/myapp:latest",
1065			registryDomain: "registry.example.com",
1066			registryPort:   5000,
1067			wantRef:        "localhost:5000/myapp:latest",
1068		},
1069		{
1070			name:           "prefixes external image path",
1071			imageRef:       "docker.io/library/nginx:latest",
1072			registryDomain: "registry.example.com",
1073			registryPort:   5000,
1074			wantRef:        "localhost:5000/docker.io/library/nginx:latest",
1075		},
1076	}
1077
1078	for _, tt := range tests {
1079		t.Run(tt.name, func(t *testing.T) {
1080			gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)
1081			assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
1082		})
1083	}
1084}
1085
1086func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1087	runtime := mocks.NewMockContainerRuntime(t)
1088	envLoader := mocks.NewMockEnvLoader(t)
1089	eventBus := mocks.NewMockEventPublisher(t)
1090
1091	config := Config{
1092		RegistryAuthEnabled:      true,
1093		RegistryDomain:           "registry.example.com",
1094		RegistryPort:             5000,
1095		InternalRegistryUsername: "internal",
1096		InternalRegistryPassword: "secret",
1097		ReadinessDelay:           time.Millisecond,
1098		DrainDelay:               time.Millisecond,
1099	}
1100
1101	svc := NewService(runtime, envLoader, eventBus, nil, config)
1102
1103	// Use internal deploy context (simulating image push event)
1104	ctx := domain.WithInternalDeploy(testContext())
1105
1106	route := domain.Route{
1107		Domain: "test.example.com",
1108		Image:  "myapp:latest",
1109	}
1110
1111	// Setup mocks - no orphaned containers
1112	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1113
1114	// Even though image exists locally, internal deploy should force pull
1115	// Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check
1116	runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
1117
1118	// Tag image to canonical name
1119	runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil)
1120	runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1121
1122	// Get exposed ports
1123	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
1124
1125	// Load environment
1126	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1127	runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil)
1128
1129	// Create and start container
1130	createdContainer := &domain.Container{
1131		ID:     "container-123",
1132		Name:   "gordon-test.example.com",
1133		Status: "created",
1134	}
1135	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
1136	runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
1137
1138	// Wait for ready
1139	runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
1140
1141	// Re-inspect after start
1142	runningContainer := &domain.Container{
1143		ID:     "container-123",
1144		Name:   "gordon-test.example.com",
1145		Status: "running",
1146		Ports:  []int{8080},
1147	}
1148	runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
1149
1150	// Publish event
1151	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1152
1153	result, err := svc.Deploy(ctx, route)
1154
1155	assert.NoError(t, err)
1156	assert.NotNil(t, result)
1157	assert.Equal(t, "container-123", result.ID)
1158}
1159
1160func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1161	runtime := mocks.NewMockContainerRuntime(t)
1162	envLoader := mocks.NewMockEnvLoader(t)
1163	eventBus := mocks.NewMockEventPublisher(t)
1164
1165	config := Config{
1166		ReadinessDelay: time.Millisecond,
1167		DrainDelay:     time.Millisecond,
1168	}
1169	svc := NewService(runtime, envLoader, eventBus, nil, config)
1170	ctx := testContext()
1171
1172	routes := []domain.Route{
1173		{Domain: "app1.example.com", Image: "myapp1:latest"},
1174		{Domain: "app2.example.com", Image: "myapp2:latest"},
1175	}
1176
1177	// Setup mocks for route deployments
1178	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1179	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
1180	runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
1181	envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1182	runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1183
1184	// Create and start containers — readiness is skipped so no IsContainerRunning calls.
1185	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1186		ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1187	}, nil).Once()
1188	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1189		ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
1190	}, nil).Once()
1191	runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
1192	runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
1193		Status: "running",
1194	}, nil).Times(2)
1195	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2)
1196
1197	err := svc.AutoStart(ctx, routes)
1198
1199	assert.NoError(t, err)
1200	assert.Len(t, svc.containers, 2)
1201}
1202
1203func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1204	runtime := mocks.NewMockContainerRuntime(t)
1205	envLoader := mocks.NewMockEnvLoader(t)
1206	eventBus := mocks.NewMockEventPublisher(t)
1207
1208	config := Config{
1209		ReadinessDelay: time.Millisecond,
1210		DrainDelay:     time.Millisecond,
1211	}
1212	svc := NewService(runtime, envLoader, eventBus, nil, config)
1213	ctx := testContext()
1214
1215	// Pre-populate with existing container
1216	svc.containers["app1.example.com"] = &domain.Container{
1217		ID: "existing-container",
1218	}
1219
1220	routes := []domain.Route{
1221		{Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists
1222		{Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
1223	}
1224
1225	// Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning.
1226	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
1227	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
1228	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
1229	envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
1230	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
1231	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1232		ID: "container-2", Status: "created",
1233	}, nil).Once()
1234	runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
1235	runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
1236		Status: "running",
1237	}, nil).Once()
1238	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
1239
1240	err := svc.AutoStart(ctx, routes)
1241
1242	assert.NoError(t, err)
1243	assert.Len(t, svc.containers, 2)
1244}
1245
1246func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
1247	runtime := mocks.NewMockContainerRuntime(t)
1248	envLoader := mocks.NewMockEnvLoader(t)
1249	eventBus := mocks.NewMockEventPublisher(t)
1250
1251	config := Config{}
1252	svc := NewService(runtime, envLoader, eventBus, nil, config)
1253	ctx := testContext()
1254
1255	routes := []domain.Route{
1256		{Domain: "app1.example.com", Image: "myapp1:latest"},
1257	}
1258
1259	// Setup mocks for failure
1260	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1261	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1262	runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found"))
1263
1264	err := svc.AutoStart(ctx, routes)
1265
1266	// AutoStart should return error when some deployments fail
1267	assert.Error(t, err)
1268	assert.Contains(t, err.Error(), "auto-start completed with 1 errors")
1269}
1270
1271func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1272	runtime := mocks.NewMockContainerRuntime(t)
1273	envLoader := mocks.NewMockEnvLoader(t)
1274	eventBus := mocks.NewMockEventPublisher(t)
1275
1276	config := Config{
1277		RegistryDomain: "reg.example.com",
1278		RegistryPort:   5000,
1279		ReadinessDelay: time.Millisecond,
1280		DrainDelay:     time.Millisecond,
1281	}
1282	svc := NewService(runtime, envLoader, eventBus, nil, config)
1283
1284	// Mark context as internal deploy — this is what syncAndAutoStart should do
1285	ctx := domain.WithInternalDeploy(testContext())
1286
1287	routes := []domain.Route{
1288		{Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"},
1289	}
1290
1291	// Key assertion: PullImage should be called with localhost:5000 rewrite,
1292	// NOT the original reg.example.com/myapp:latest.
1293	// Readiness is skipped — no IsContainerRunning calls.
1294	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1295	runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1296	runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)
1297	runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1298	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "reg.example.com/myapp:latest").Return([]int{8080}, nil)
1299	envLoader.EXPECT().LoadEnv(mock.Anything, "app1.example.com").Return([]string{}, nil)
1300	runtime.EXPECT().InspectImageEnv(mock.Anything, "reg.example.com/myapp:latest").Return([]string{}, nil)
1301	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1302		ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1303	}, nil)
1304	runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil)
1305	runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{
1306		Status: "running",
1307	}, nil)
1308	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1309
1310	err := svc.AutoStart(ctx, routes)
1311	assert.NoError(t, err)
1312}
1313
1314// TestService_Deploy_OrphanCleanupSkipsTrackedContainer verifies that the orphan cleanup
1315// does not remove the currently tracked container during zero-downtime deployment.
1316// This is critical for preventing downtime - the old container must stay running
1317// until the new container is ready and traffic is switched.
1318func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1319	runtime := mocks.NewMockContainerRuntime(t)
1320	envLoader := mocks.NewMockEnvLoader(t)
1321	eventBus := mocks.NewMockEventPublisher(t)
1322	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1323
1324	config := Config{
1325		ReadinessDelay: time.Millisecond, // Minimal delay for tests
1326		DrainDelay:     time.Millisecond,
1327	}
1328	svc := NewService(runtime, envLoader, eventBus, nil, config)
1329	svc.SetProxyCacheInvalidator(cacheInvalidator)
1330	ctx := testContext()
1331
1332	// Pre-populate with existing tracked container
1333	existingContainer := &domain.Container{
1334		ID:     "tracked-container-123",
1335		Name:   "gordon-test.example.com",
1336		Status: "running",
1337	}
1338	svc.containers["test.example.com"] = existingContainer
1339
1340	route := domain.Route{
1341		Domain: "test.example.com",
1342		Image:  "myapp:v2",
1343	}
1344
1345	// ListContainers returns the tracked container - this simulates the orphan check
1346	// finding a container with the same name. The bug was that it would remove this
1347	// container BEFORE the new one was ready, causing downtime.
1348	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1349		{
1350			ID:     "tracked-container-123",
1351			Name:   "gordon-test.example.com",
1352			Status: "running",
1353		},
1354	}, nil)
1355
1356	// Image operations
1357	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1358	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1359	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1360
1361	// Environment
1362	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1363	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1364
1365	// Create new container with -new suffix for zero-downtime
1366	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1367	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1368		return cfg.Name == "gordon-test.example.com-new"
1369	})).Return(newContainer, nil)
1370	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1371
1372	// Wait for ready
1373	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1374
1375	// Inspect after ready
1376	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1377		ID:     "new-container",
1378		Status: "running",
1379	}, nil)
1380
1381	// Publish event
1382	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1383
1384	// Synchronous cache invalidation
1385	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1386
1387	// NOW (after new container is ready) the old container should be stopped and removed
1388	// This is the correct zero-downtime sequence - not during orphan cleanup
1389	runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1390	runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1391
1392	// Rename new container to canonical name
1393	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1394
1395	result, err := svc.Deploy(ctx, route)
1396
1397	assert.NoError(t, err)
1398	assert.Equal(t, "new-container", result.ID)
1399
1400	// Verify new container is now tracked
1401	tracked, exists := svc.Get(ctx, "test.example.com")
1402	assert.True(t, exists)
1403	assert.Equal(t, "new-container", tracked.ID)
1404}
1405
1406// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers
1407// with the same name but NOT tracked are properly removed as orphans.
1408func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1409	runtime := mocks.NewMockContainerRuntime(t)
1410	envLoader := mocks.NewMockEnvLoader(t)
1411	eventBus := mocks.NewMockEventPublisher(t)
1412
1413	config := Config{
1414		ReadinessDelay: time.Millisecond, // Minimal delay for tests
1415		DrainDelay:     time.Millisecond,
1416	}
1417	svc := NewService(runtime, envLoader, eventBus, nil, config)
1418	ctx := testContext()
1419
1420	// NO tracked container - service is empty for this domain
1421
1422	route := domain.Route{
1423		Domain: "test.example.com",
1424		Image:  "myapp:v1",
1425	}
1426
1427	// ListContainers returns an orphaned container (same name, but not tracked)
1428	// This could happen if Gordon crashed and restarted, or container was created manually
1429	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1430		{
1431			ID:     "orphan-container",
1432			Name:   "gordon-test.example.com",
1433			Status: "running",
1434		},
1435	}, nil)
1436
1437	// Orphan should be stopped and removed BEFORE we proceed
1438	runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
1439	runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
1440
1441	// Image operations
1442	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1443	runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
1444	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
1445
1446	// Environment
1447	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1448	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v1").Return([]string{}, nil)
1449
1450	// Create container (no -new suffix since no existing tracked container)
1451	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
1452	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1453		return cfg.Name == "gordon-test.example.com"
1454	})).Return(newContainer, nil)
1455	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1456
1457	// Wait for ready
1458	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1459
1460	// Inspect after ready
1461	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1462		ID:     "new-container",
1463		Status: "running",
1464		Ports:  []int{8080},
1465	}, nil)
1466
1467	// Publish event
1468	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1469
1470	result, err := svc.Deploy(ctx, route)
1471
1472	assert.NoError(t, err)
1473	assert.Equal(t, "new-container", result.ID)
1474
1475	// Verify new container is tracked
1476	tracked, exists := svc.Get(ctx, "test.example.com")
1477	assert.True(t, exists)
1478	assert.Equal(t, "new-container", tracked.ID)
1479}
1480
1481func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
1482	runtime := mocks.NewMockContainerRuntime(t)
1483	envLoader := mocks.NewMockEnvLoader(t)
1484	eventBus := mocks.NewMockEventPublisher(t)
1485	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1486
1487	config := Config{
1488		ReadinessDelay: time.Millisecond,
1489		DrainDelay:     time.Millisecond,
1490	}
1491	svc := NewService(runtime, envLoader, eventBus, nil, config)
1492	svc.SetProxyCacheInvalidator(cacheInvalidator)
1493	ctx := testContext()
1494
1495	// Tracked canonical container exists, but stale -new container should be removed.
1496	svc.containers["test.example.com"] = &domain.Container{
1497		ID:     "tracked-container-123",
1498		Name:   "gordon-test.example.com",
1499		Status: "running",
1500	}
1501
1502	route := domain.Route{
1503		Domain: "test.example.com",
1504		Image:  "myapp:v2",
1505	}
1506
1507	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1508		{
1509			ID:     "tracked-container-123",
1510			Name:   "gordon-test.example.com",
1511			Status: "running",
1512		},
1513		{
1514			ID:     "stale-new-container",
1515			Name:   "gordon-test.example.com-new",
1516			Status: "exited",
1517		},
1518	}, nil)
1519	runtime.EXPECT().StopContainer(mock.Anything, "stale-new-container").Return(nil).Once()
1520	runtime.EXPECT().RemoveContainer(mock.Anything, "stale-new-container", true).Return(nil).Once()
1521
1522	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1523	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1524	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1525	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1526	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1527
1528	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1529	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1530		return cfg.Name == "gordon-test.example.com-new"
1531	})).Return(newContainer, nil)
1532	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1533	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1534	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1535		ID:     "new-container",
1536		Status: "running",
1537	}, nil)
1538	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1539
1540	// Synchronous cache invalidation
1541	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1542
1543	runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1544	runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1545	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1546
1547	result, err := svc.Deploy(ctx, route)
1548	assert.NoError(t, err)
1549	assert.NotNil(t, result)
1550	assert.Equal(t, "new-container", result.ID)
1551}
1552
1553func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) {
1554	runtime := mocks.NewMockContainerRuntime(t)
1555	envLoader := mocks.NewMockEnvLoader(t)
1556	eventBus := mocks.NewMockEventPublisher(t)
1557	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1558
1559	config := Config{
1560		ReadinessDelay: time.Millisecond,
1561		DrainDelay:     time.Millisecond,
1562	}
1563	svc := NewService(runtime, envLoader, eventBus, nil, config)
1564	svc.SetProxyCacheInvalidator(cacheInvalidator)
1565	ctx := testContext()
1566
1567	// Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name.
1568	svc.containers["test.example.com"] = &domain.Container{
1569		ID:     "tracked-temp-container",
1570		Name:   "gordon-test.example.com-new",
1571		Status: "running",
1572	}
1573
1574	route := domain.Route{
1575		Domain: "test.example.com",
1576		Image:  "myapp:v2",
1577	}
1578
1579	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1580		{
1581			ID:     "tracked-temp-container",
1582			Name:   "gordon-test.example.com-new",
1583			Status: "running",
1584		},
1585	}, nil)
1586
1587	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1588	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1589	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1590	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1591	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1592
1593	created := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-next", Status: "created"}
1594	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1595		return cfg.Name == "gordon-test.example.com-next"
1596	})).Return(created, nil)
1597	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1598	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1599	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1600		ID:     "new-container",
1601		Status: "running",
1602	}, nil)
1603	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1604
1605	// Synchronous cache invalidation
1606	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1607
1608	runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil)
1609	runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil)
1610	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1611
1612	result, err := svc.Deploy(ctx, route)
1613	assert.NoError(t, err)
1614	assert.NotNil(t, result)
1615	assert.Equal(t, "new-container", result.ID)
1616}
1617
1618func TestService_StripRegistryPrefix(t *testing.T) {
1619	tests := []struct {
1620		name           string
1621		registryDomain string
1622		image          string
1623		expected       string
1624	}{
1625		{
1626			name:           "strips registry prefix",
1627			registryDomain: "reg.example.com",
1628			image:          "reg.example.com/myapp:latest",
1629			expected:       "myapp:latest",
1630		},
1631		{
1632			name:           "strips registry prefix with trailing slash in domain",
1633			registryDomain: "reg.example.com/",
1634			image:          "reg.example.com/myapp:v1.0",
1635			expected:       "myapp:v1.0",
1636		},
1637		{
1638			name:           "preserves image without registry prefix",
1639			registryDomain: "reg.example.com",
1640			image:          "nginx:latest",
1641			expected:       "nginx:latest",
1642		},
1643		{
1644			name:           "preserves image with different registry",
1645			registryDomain: "reg.example.com",
1646			image:          "gcr.io/project/image:latest",
1647			expected:       "gcr.io/project/image:latest",
1648		},
1649		{
1650			name:           "handles empty registry domain",
1651			registryDomain: "",
1652			image:          "reg.example.com/myapp:latest",
1653			expected:       "reg.example.com/myapp:latest",
1654		},
1655		{
1656			name:           "handles nested paths",
1657			registryDomain: "reg.example.com",
1658			image:          "reg.example.com/org/repo/app:latest",
1659			expected:       "org/repo/app:latest",
1660		},
1661	}
1662
1663	for _, tt := range tests {
1664		t.Run(tt.name, func(t *testing.T) {
1665			runtime := mocks.NewMockContainerRuntime(t)
1666			envLoader := mocks.NewMockEnvLoader(t)
1667			eventBus := mocks.NewMockEventPublisher(t)
1668
1669			config := Config{
1670				RegistryDomain: tt.registryDomain,
1671			}
1672			svc := NewService(runtime, envLoader, eventBus, nil, config)
1673
1674			result := svc.stripRegistryPrefix(tt.image)
1675			assert.Equal(t, tt.expected, result)
1676		})
1677	}
1678}
1679
1680func TestSanitizeName(t *testing.T) {
1681	tests := []struct {
1682		domain      string
1683		expected    string
1684		description string
1685	}{
1686		{
1687			domain:      "git.example.com",
1688			expected:    "git__example__com",
1689			description: "Dots become double underscores",
1690		},
1691		{
1692			domain:      "git-example.com",
1693			expected:    "git-example__com",
1694			description: "Hyphens preserved, dots become underscores",
1695		},
1696		{
1697			domain:      "app:8080.example.com",
1698			expected:    "app-_8080__example__com",
1699			description: "Colons become hyphen-underscore",
1700		},
1701		{
1702			domain:      "git.example.com:3000",
1703			expected:    "git__example__com-_3000",
1704			description: "Multiple separators handled distinctly",
1705		},
1706		{
1707			domain:      "simple.com",
1708			expected:    "simple__com",
1709			description: "Simple domain",
1710		},
1711	}
1712
1713	for _, tt := range tests {
1714		t.Run(tt.domain, func(t *testing.T) {
1715			result := sanitizeName(tt.domain)
1716			assert.Equal(t, tt.expected, result, "sanitization should match expected")
1717		})
1718	}
1719
1720	// Verify no collisions between potentially conflicting domains
1721	t.Run("NoCollisions", func(t *testing.T) {
1722		domains := []string{
1723			"git.example.com",
1724			"git-example.com",
1725			"app:8080.example.com",
1726			"app-8080-example.com",
1727		}
1728
1729		results := make(map[string]string)
1730		for _, d := range domains {
1731			result := sanitizeName(d)
1732			if original, exists := results[result]; exists {
1733				t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
1734			}
1735			results[result] = d
1736		}
1737	})
1738}
1739
1740func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1741	// Verify that concurrent Deploy calls for the same domain are serialized
1742	// and both succeed without container name conflicts.
1743	runtime := mocks.NewMockContainerRuntime(t)
1744	envLoader := mocks.NewMockEnvLoader(t)
1745	eventBus := mocks.NewMockEventPublisher(t)
1746	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1747
1748	config := Config{
1749		ReadinessDelay: time.Millisecond,
1750		DrainDelay:     time.Millisecond,
1751	}
1752	svc := NewService(runtime, envLoader, eventBus, nil, config)
1753	svc.SetProxyCacheInvalidator(cacheInvalidator)
1754	ctx := testContext()
1755
1756	route := domain.Route{
1757		Domain: "test.example.com",
1758		Image:  "myapp:latest",
1759	}
1760
1761	// Track call order to verify serialization
1762	var callOrder []string
1763	var callMu sync.Mutex
1764
1765	// Setup mocks that will be called by both deploys sequentially.
1766	// First deploy: no existing container → creates gordon-test.example.com
1767	// Second deploy: sees first container → creates gordon-test.example.com-new
1768
1769	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1770	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
1771	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
1772	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, nil).Times(2)
1773	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
1774
1775	createCall := 0
1776	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
1777		RunAndReturn(func(_ context.Context, cfg *domain.ContainerConfig) (*domain.Container, error) {
1778			callMu.Lock()
1779			createCall++
1780			n := createCall
1781			callOrder = append(callOrder, fmt.Sprintf("create-%d:%s", n, cfg.Name))
1782			callMu.Unlock()
1783			return &domain.Container{
1784				ID:     fmt.Sprintf("container-%d", n),
1785				Name:   cfg.Name,
1786				Image:  cfg.Image,
1787				Status: "created",
1788			}, nil
1789		})
1790
1791	runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
1792	// IsContainerRunning is called 2x per deploy in waitForReady (initial check + after delay)
1793	runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
1794
1795	runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
1796		RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
1797			// Return correct container name based on ID
1798			// container-1 is the first deploy (canonical name)
1799			// container-2 is the second deploy (with -new suffix)
1800			name := "gordon-test.example.com"
1801			if id == "container-2" {
1802				name = "gordon-test.example.com-new"
1803			}
1804			return &domain.Container{
1805				ID:     id,
1806				Name:   name,
1807				Image:  "myapp:latest",
1808				Status: "running",
1809				Ports:  []int{8080},
1810			}, nil
1811		})
1812
1813	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)
1814
1815	// Both deploys call InvalidateTarget (to ensure proxy picks up the new container).
1816	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
1817
1818	// which means it will also stop+remove+rename the old container.
1819	runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
1820	runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
1821	runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Once()
1822
1823	// Launch two concurrent deploys
1824	var wg sync.WaitGroup
1825	errs := make([]error, 2)
1826
1827	wg.Add(2)
1828	for i := range 2 {
1829		go func(idx int) {
1830			defer wg.Done()
1831			_, errs[idx] = svc.Deploy(ctx, route)
1832		}(i)
1833	}
1834	wg.Wait()
1835
1836	// Both should succeed (second deploy waits for the first to finish)
1837	assert.NoError(t, errs[0], "first deploy should succeed")
1838	assert.NoError(t, errs[1], "second deploy should succeed")
1839
1840	// Verify creates were serialized (not interleaved)
1841	callMu.Lock()
1842	assert.Len(t, callOrder, 2, "should have exactly 2 create calls")
1843	// First deploy uses canonical name, second uses -new suffix (zero-downtime)
1844	assert.Equal(t, "create-1:gordon-test.example.com", callOrder[0], "first create should use canonical name")
1845	assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
1846	callMu.Unlock()
1847}
1848
1849func TestService_Deploy_ContextCancellation(t *testing.T) {
1850	// Verify that deploy lock acquisition respects context cancellation.
1851	runtime := mocks.NewMockContainerRuntime(t)
1852	envLoader := mocks.NewMockEnvLoader(t)
1853	eventBus := mocks.NewMockEventPublisher(t)
1854
1855	config := Config{
1856		ReadinessDelay: time.Millisecond,
1857		DrainDelay:     time.Millisecond,
1858	}
1859	svc := NewService(runtime, envLoader, eventBus, nil, config)
1860
1861	route := domain.Route{
1862		Domain: "test.example.com",
1863		Image:  "myapp:latest",
1864	}
1865
1866	// Test context cancelled before lock acquisition.
1867	cancelledCtx, cancel := context.WithCancel(testContext())
1868	cancel() // Cancel immediately
1869
1870	_, err := svc.Deploy(cancelledCtx, route)
1871	assert.Error(t, err, "deploy should fail with cancelled context")
1872	assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
1873}
1874
1875// TestService_Deploy_CacheInvalidationBeforeOldContainerStop verifies the fix for
1876// the proxy cache race condition: InvalidateTarget must be called synchronously
1877// BEFORE the old container is stopped, preventing 503 errors during deployment.
1878func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
1879	runtime := mocks.NewMockContainerRuntime(t)
1880	envLoader := mocks.NewMockEnvLoader(t)
1881	eventBus := mocks.NewMockEventPublisher(t)
1882	cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1883
1884	config := Config{
1885		ReadinessDelay: time.Millisecond,
1886		DrainDelay:     time.Millisecond,
1887	}
1888	svc := NewService(runtime, envLoader, eventBus, nil, config)
1889	svc.SetProxyCacheInvalidator(cacheInvalidator)
1890	ctx := testContext()
1891
1892	// Pre-populate with existing container
1893	existingContainer := &domain.Container{
1894		ID:     "old-container",
1895		Name:   "gordon-test.example.com",
1896		Status: "running",
1897	}
1898	svc.containers["test.example.com"] = existingContainer
1899
1900	route := domain.Route{
1901		Domain: "test.example.com",
1902		Image:  "myapp:v2",
1903	}
1904
1905	// Track ordering of cache invalidation and container stop
1906	var callOrder []string
1907	var orderMu sync.Mutex
1908
1909	// Standard deploy mocks
1910	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1911	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
1912	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1913	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1914	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1915
1916	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1917	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
1918	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1919	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1920	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1921		ID: "new-container", Status: "running",
1922	}, nil)
1923	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1924
1925	// Track: cache invalidation should happen first
1926	cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").
1927		Run(func(_ context.Context, _ string) {
1928			orderMu.Lock()
1929			callOrder = append(callOrder, "invalidate_cache")
1930			orderMu.Unlock()
1931		}).Return()
1932
1933	// Track: stop should happen after invalidation
1934	runtime.EXPECT().StopContainer(mock.Anything, "old-container").
1935		RunAndReturn(func(_ context.Context, _ string) error {
1936			orderMu.Lock()
1937			callOrder = append(callOrder, "stop_old_container")
1938			orderMu.Unlock()
1939			return nil
1940		})
1941	runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
1942	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1943
1944	result, err := svc.Deploy(ctx, route)
1945
1946	assert.NoError(t, err)
1947	assert.Equal(t, "new-container", result.ID)
1948
1949	// Verify ordering: cache invalidation MUST happen before old container stop
1950	orderMu.Lock()
1951	defer orderMu.Unlock()
1952	assert.Equal(t, []string{"invalidate_cache", "stop_old_container"}, callOrder,
1953		"InvalidateTarget must be called before StopContainer to prevent 503 errors")
1954}
1955
1956// TestService_Deploy_NilCacheInvalidator verifies that deploy works gracefully
1957// when no cache invalidator is set (e.g., in tests or minimal configurations).
1958func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
1959	runtime := mocks.NewMockContainerRuntime(t)
1960	envLoader := mocks.NewMockEnvLoader(t)
1961	eventBus := mocks.NewMockEventPublisher(t)
1962
1963	config := Config{
1964		ReadinessDelay: time.Millisecond,
1965		DrainDelay:     time.Millisecond,
1966	}
1967	// Intentionally NOT setting cache invalidator
1968	svc := NewService(runtime, envLoader, eventBus, nil, config)
1969	ctx := testContext()
1970
1971	// Pre-populate with existing container
1972	svc.containers["test.example.com"] = &domain.Container{
1973		ID:     "old-container",
1974		Name:   "gordon-test.example.com",
1975		Status: "running",
1976	}
1977
1978	route := domain.Route{
1979		Domain: "test.example.com",
1980		Image:  "myapp:v2",
1981	}
1982
1983	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1984	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
1985	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1986	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1987	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1988
1989	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1990	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
1991	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1992	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1993	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1994		ID: "new-container", Status: "running",
1995	}, nil)
1996	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1997	runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
1998	runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
1999	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2000
2001	result, err := svc.Deploy(ctx, route)
2002
2003	assert.NoError(t, err)
2004	assert.Equal(t, "new-container", result.ID)
2005}

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