v2.13.0 7748089

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

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