v2.10.2 705579a

service_test.go RawHistoryFolder

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

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