v2.10.3 f7e7684

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
1140// TestService_Deploy_OrphanCleanupSkipsTrackedContainer verifies that the orphan cleanup
1141// does not remove the currently tracked container during zero-downtime deployment.
1142// This is critical for preventing downtime - the old container must stay running
1143// until the new container is ready and traffic is switched.
1144func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1145	runtime := mocks.NewMockContainerRuntime(t)
1146	envLoader := mocks.NewMockEnvLoader(t)
1147	eventBus := mocks.NewMockEventPublisher(t)
1148
1149	config := Config{
1150		ReadinessDelay: time.Millisecond, // Minimal delay for tests
1151	}
1152	svc := NewService(runtime, envLoader, eventBus, nil, config)
1153	ctx := testContext()
1154
1155	// Pre-populate with existing tracked container
1156	existingContainer := &domain.Container{
1157		ID:     "tracked-container-123",
1158		Name:   "gordon-test.example.com",
1159		Status: "running",
1160	}
1161	svc.containers["test.example.com"] = existingContainer
1162
1163	route := domain.Route{
1164		Domain: "test.example.com",
1165		Image:  "myapp:v2",
1166	}
1167
1168	// ListContainers returns the tracked container - this simulates the orphan check
1169	// finding a container with the same name. The bug was that it would remove this
1170	// container BEFORE the new one was ready, causing downtime.
1171	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1172		{
1173			ID:     "tracked-container-123",
1174			Name:   "gordon-test.example.com",
1175			Status: "running",
1176		},
1177	}, nil)
1178
1179	// Image operations
1180	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1181	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1182	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1183
1184	// Environment
1185	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1186	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1187
1188	// Create new container with -new suffix for zero-downtime
1189	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1190	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1191		return cfg.Name == "gordon-test.example.com-new"
1192	})).Return(newContainer, nil)
1193	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1194
1195	// Wait for ready
1196	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1197
1198	// Inspect after ready
1199	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1200		ID:     "new-container",
1201		Status: "running",
1202	}, nil)
1203
1204	// Publish event
1205	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1206
1207	// NOW (after new container is ready) the old container should be stopped and removed
1208	// This is the correct zero-downtime sequence - not during orphan cleanup
1209	runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1210	runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1211
1212	// Rename new container to canonical name
1213	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1214
1215	result, err := svc.Deploy(ctx, route)
1216
1217	assert.NoError(t, err)
1218	assert.Equal(t, "new-container", result.ID)
1219
1220	// Verify new container is now tracked
1221	tracked, exists := svc.Get(ctx, "test.example.com")
1222	assert.True(t, exists)
1223	assert.Equal(t, "new-container", tracked.ID)
1224}
1225
1226// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers
1227// with the same name but NOT tracked are properly removed as orphans.
1228func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1229	runtime := mocks.NewMockContainerRuntime(t)
1230	envLoader := mocks.NewMockEnvLoader(t)
1231	eventBus := mocks.NewMockEventPublisher(t)
1232
1233	config := Config{
1234		ReadinessDelay: time.Millisecond, // Minimal delay for tests
1235	}
1236	svc := NewService(runtime, envLoader, eventBus, nil, config)
1237	ctx := testContext()
1238
1239	// NO tracked container - service is empty for this domain
1240
1241	route := domain.Route{
1242		Domain: "test.example.com",
1243		Image:  "myapp:v1",
1244	}
1245
1246	// ListContainers returns an orphaned container (same name, but not tracked)
1247	// This could happen if Gordon crashed and restarted, or container was created manually
1248	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1249		{
1250			ID:     "orphan-container",
1251			Name:   "gordon-test.example.com",
1252			Status: "running",
1253		},
1254	}, nil)
1255
1256	// Orphan should be stopped and removed BEFORE we proceed
1257	runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
1258	runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
1259
1260	// Image operations
1261	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1262	runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
1263	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
1264
1265	// Environment
1266	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1267	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v1").Return([]string{}, nil)
1268
1269	// Create container (no -new suffix since no existing tracked container)
1270	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
1271	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1272		return cfg.Name == "gordon-test.example.com"
1273	})).Return(newContainer, nil)
1274	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1275
1276	// Wait for ready
1277	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1278
1279	// Inspect after ready
1280	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1281		ID:     "new-container",
1282		Status: "running",
1283		Ports:  []int{8080},
1284	}, nil)
1285
1286	// Publish event
1287	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1288
1289	result, err := svc.Deploy(ctx, route)
1290
1291	assert.NoError(t, err)
1292	assert.Equal(t, "new-container", result.ID)
1293
1294	// Verify new container is tracked
1295	tracked, exists := svc.Get(ctx, "test.example.com")
1296	assert.True(t, exists)
1297	assert.Equal(t, "new-container", tracked.ID)
1298}
1299
1300func TestService_StripRegistryPrefix(t *testing.T) {
1301	tests := []struct {
1302		name           string
1303		registryDomain string
1304		image          string
1305		expected       string
1306	}{
1307		{
1308			name:           "strips registry prefix",
1309			registryDomain: "reg.example.com",
1310			image:          "reg.example.com/myapp:latest",
1311			expected:       "myapp:latest",
1312		},
1313		{
1314			name:           "strips registry prefix with trailing slash in domain",
1315			registryDomain: "reg.example.com/",
1316			image:          "reg.example.com/myapp:v1.0",
1317			expected:       "myapp:v1.0",
1318		},
1319		{
1320			name:           "preserves image without registry prefix",
1321			registryDomain: "reg.example.com",
1322			image:          "nginx:latest",
1323			expected:       "nginx:latest",
1324		},
1325		{
1326			name:           "preserves image with different registry",
1327			registryDomain: "reg.example.com",
1328			image:          "gcr.io/project/image:latest",
1329			expected:       "gcr.io/project/image:latest",
1330		},
1331		{
1332			name:           "handles empty registry domain",
1333			registryDomain: "",
1334			image:          "reg.example.com/myapp:latest",
1335			expected:       "reg.example.com/myapp:latest",
1336		},
1337		{
1338			name:           "handles nested paths",
1339			registryDomain: "reg.example.com",
1340			image:          "reg.example.com/org/repo/app:latest",
1341			expected:       "org/repo/app:latest",
1342		},
1343	}
1344
1345	for _, tt := range tests {
1346		t.Run(tt.name, func(t *testing.T) {
1347			runtime := mocks.NewMockContainerRuntime(t)
1348			envLoader := mocks.NewMockEnvLoader(t)
1349			eventBus := mocks.NewMockEventPublisher(t)
1350
1351			config := Config{
1352				RegistryDomain: tt.registryDomain,
1353			}
1354			svc := NewService(runtime, envLoader, eventBus, nil, config)
1355
1356			result := svc.stripRegistryPrefix(tt.image)
1357			assert.Equal(t, tt.expected, result)
1358		})
1359	}
1360}
1361
1362func TestSanitizeName(t *testing.T) {
1363	tests := []struct {
1364		domain      string
1365		expected    string
1366		description string
1367	}{
1368		{
1369			domain:      "git.example.com",
1370			expected:    "git__example__com",
1371			description: "Dots become double underscores",
1372		},
1373		{
1374			domain:      "git-example.com",
1375			expected:    "git-example__com",
1376			description: "Hyphens preserved, dots become underscores",
1377		},
1378		{
1379			domain:      "app:8080.example.com",
1380			expected:    "app-_8080__example__com",
1381			description: "Colons become hyphen-underscore",
1382		},
1383		{
1384			domain:      "git.example.com:3000",
1385			expected:    "git__example__com-_3000",
1386			description: "Multiple separators handled distinctly",
1387		},
1388		{
1389			domain:      "simple.com",
1390			expected:    "simple__com",
1391			description: "Simple domain",
1392		},
1393	}
1394
1395	for _, tt := range tests {
1396		t.Run(tt.domain, func(t *testing.T) {
1397			result := sanitizeName(tt.domain)
1398			assert.Equal(t, tt.expected, result, "sanitization should match expected")
1399		})
1400	}
1401
1402	// Verify no collisions between potentially conflicting domains
1403	t.Run("NoCollisions", func(t *testing.T) {
1404		domains := []string{
1405			"git.example.com",
1406			"git-example.com",
1407			"app:8080.example.com",
1408			"app-8080-example.com",
1409		}
1410
1411		results := make(map[string]string)
1412		for _, d := range domains {
1413			result := sanitizeName(d)
1414			if original, exists := results[result]; exists {
1415				t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
1416			}
1417			results[result] = d
1418		}
1419	})
1420}
1421
1422func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1423	// Verify that concurrent Deploy calls for the same domain are serialized
1424	// and both succeed without container name conflicts.
1425	runtime := mocks.NewMockContainerRuntime(t)
1426	envLoader := mocks.NewMockEnvLoader(t)
1427	eventBus := mocks.NewMockEventPublisher(t)
1428
1429	config := Config{
1430		ReadinessDelay: time.Millisecond,
1431	}
1432	svc := NewService(runtime, envLoader, eventBus, nil, config)
1433	ctx := testContext()
1434
1435	route := domain.Route{
1436		Domain: "test.example.com",
1437		Image:  "myapp:latest",
1438	}
1439
1440	// Track call order to verify serialization
1441	var callOrder []string
1442	var callMu sync.Mutex
1443
1444	// Setup mocks that will be called by both deploys sequentially.
1445	// First deploy: no existing container → creates gordon-test.example.com
1446	// Second deploy: sees first container → creates gordon-test.example.com-new
1447
1448	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1449	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
1450	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
1451	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, nil).Times(2)
1452	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
1453
1454	createCall := 0
1455	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
1456		RunAndReturn(func(_ context.Context, cfg *domain.ContainerConfig) (*domain.Container, error) {
1457			callMu.Lock()
1458			createCall++
1459			n := createCall
1460			callOrder = append(callOrder, fmt.Sprintf("create-%d:%s", n, cfg.Name))
1461			callMu.Unlock()
1462			return &domain.Container{
1463				ID:     fmt.Sprintf("container-%d", n),
1464				Name:   cfg.Name,
1465				Image:  cfg.Image,
1466				Status: "created",
1467			}, nil
1468		})
1469
1470	runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
1471	// IsContainerRunning is called 2x per deploy in waitForReady (initial check + after delay)
1472	runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
1473
1474	runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
1475		RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
1476			// Return correct container name based on ID
1477			// container-1 is the first deploy (canonical name)
1478			// container-2 is the second deploy (with -new suffix)
1479			name := "gordon-test.example.com"
1480			if id == "container-2" {
1481				name = "gordon-test.example.com-new"
1482			}
1483			return &domain.Container{
1484				ID:     id,
1485				Name:   name,
1486				Image:  "myapp:latest",
1487				Status: "running",
1488				Ports:  []int{8080},
1489			}, nil
1490		})
1491
1492	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)
1493
1494	// The second deploy will see the first container and do zero-downtime swap,
1495	// which means it will also stop+remove+rename the old container.
1496	runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
1497	runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
1498	runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Once()
1499
1500	// Launch two concurrent deploys
1501	var wg sync.WaitGroup
1502	errs := make([]error, 2)
1503
1504	wg.Add(2)
1505	for i := range 2 {
1506		go func(idx int) {
1507			defer wg.Done()
1508			_, errs[idx] = svc.Deploy(ctx, route)
1509		}(i)
1510	}
1511	wg.Wait()
1512
1513	// Both should succeed (second deploy waits for the first to finish)
1514	assert.NoError(t, errs[0], "first deploy should succeed")
1515	assert.NoError(t, errs[1], "second deploy should succeed")
1516
1517	// Verify creates were serialized (not interleaved)
1518	callMu.Lock()
1519	assert.Len(t, callOrder, 2, "should have exactly 2 create calls")
1520	// First deploy uses canonical name, second uses -new suffix (zero-downtime)
1521	assert.Equal(t, "create-1:gordon-test.example.com", callOrder[0], "first create should use canonical name")
1522	assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
1523	callMu.Unlock()
1524}
1525
1526func TestService_Deploy_ContextCancellation(t *testing.T) {
1527	// Verify that deploy lock acquisition respects context cancellation.
1528	runtime := mocks.NewMockContainerRuntime(t)
1529	envLoader := mocks.NewMockEnvLoader(t)
1530	eventBus := mocks.NewMockEventPublisher(t)
1531
1532	config := Config{
1533		ReadinessDelay: time.Millisecond,
1534	}
1535	svc := NewService(runtime, envLoader, eventBus, nil, config)
1536
1537	route := domain.Route{
1538		Domain: "test.example.com",
1539		Image:  "myapp:latest",
1540	}
1541
1542	// Test context cancelled before lock acquisition.
1543	cancelledCtx, cancel := context.WithCancel(testContext())
1544	cancel() // Cancel immediately
1545
1546	_, err := svc.Deploy(cancelledCtx, route)
1547	assert.Error(t, err, "deploy should fail with cancelled context")
1548	assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
1549}

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