v2.11.0 dca6537

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

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