v2.10.0 893d8fa

service_test.go RawHistoryFolder

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

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