v2.10.1 43db1bb

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 TestRewriteToRegistryDomain(t *testing.T) {
747	tests := []struct {
748		name           string
749		imageRef       string
750		registryDomain string
751		wantRef        string
752	}{
753		{
754			name:           "keeps registry domain prefix",
755			imageRef:       "registry.example.com/myapp:latest",
756			registryDomain: "registry.example.com",
757			wantRef:        "registry.example.com/myapp:latest",
758		},
759		{
760			name:           "prefixes registry domain",
761			imageRef:       "myapp:v1.0",
762			registryDomain: "registry.example.com",
763			wantRef:        "registry.example.com/myapp:v1.0",
764		},
765		{
766			name:           "prefixes external image",
767			imageRef:       "docker.io/library/nginx:latest",
768			registryDomain: "registry.example.com",
769			wantRef:        "registry.example.com/docker.io/library/nginx:latest",
770		},
771		{
772			name:           "empty registry domain returns original",
773			imageRef:       "registry.example.com/myapp:latest",
774			registryDomain: "",
775			wantRef:        "registry.example.com/myapp:latest",
776		},
777	}
778
779	for _, tt := range tests {
780		t.Run(tt.name, func(t *testing.T) {
781			gotRef := rewriteToRegistryDomain(tt.imageRef, tt.registryDomain)
782			assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
783		})
784	}
785}
786
787func TestRewriteToLocalRegistry(t *testing.T) {
788	tests := []struct {
789		name           string
790		imageRef       string
791		registryDomain string
792		registryPort   int
793		wantRef        string
794	}{
795		{
796			name:           "rewrites registry domain prefix",
797			imageRef:       "registry.example.com/myapp:latest",
798			registryDomain: "registry.example.com",
799			registryPort:   5000,
800			wantRef:        "localhost:5000/myapp:latest",
801		},
802		{
803			name:           "prefixes local registry when no domain",
804			imageRef:       "myapp:v1.0",
805			registryDomain: "registry.example.com",
806			registryPort:   5000,
807			wantRef:        "localhost:5000/myapp:v1.0",
808		},
809		{
810			name:           "keeps existing localhost prefix",
811			imageRef:       "localhost:5000/myapp:latest",
812			registryDomain: "registry.example.com",
813			registryPort:   5000,
814			wantRef:        "localhost:5000/myapp:latest",
815		},
816		{
817			name:           "prefixes external image path",
818			imageRef:       "docker.io/library/nginx:latest",
819			registryDomain: "registry.example.com",
820			registryPort:   5000,
821			wantRef:        "localhost:5000/docker.io/library/nginx:latest",
822		},
823	}
824
825	for _, tt := range tests {
826		t.Run(tt.name, func(t *testing.T) {
827			gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)
828			assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
829		})
830	}
831}
832
833func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
834	runtime := mocks.NewMockContainerRuntime(t)
835	envLoader := mocks.NewMockEnvLoader(t)
836	eventBus := mocks.NewMockEventPublisher(t)
837
838	config := Config{
839		RegistryAuthEnabled:      true,
840		RegistryDomain:           "registry.example.com",
841		RegistryPort:             5000,
842		InternalRegistryUsername: "internal",
843		InternalRegistryPassword: "secret",
844		ReadinessDelay:           time.Millisecond,
845	}
846
847	svc := NewService(runtime, envLoader, eventBus, nil, config)
848
849	// Use internal deploy context (simulating image push event)
850	ctx := domain.WithInternalDeploy(testContext())
851
852	route := domain.Route{
853		Domain: "test.example.com",
854		Image:  "myapp:latest",
855	}
856
857	// Setup mocks - no orphaned containers
858	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
859
860	// Even though image exists locally, internal deploy should force pull
861	// Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check
862	runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
863
864	// Tag image to canonical name
865	runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil)
866	runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
867
868	// Get exposed ports
869	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
870
871	// Load environment
872	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
873	runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil)
874
875	// Create and start container
876	createdContainer := &domain.Container{
877		ID:     "container-123",
878		Name:   "gordon-test.example.com",
879		Status: "created",
880	}
881	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
882	runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
883
884	// Wait for ready
885	runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
886
887	// Re-inspect after start
888	runningContainer := &domain.Container{
889		ID:     "container-123",
890		Name:   "gordon-test.example.com",
891		Status: "running",
892		Ports:  []int{8080},
893	}
894	runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
895
896	// Publish event
897	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
898
899	result, err := svc.Deploy(ctx, route)
900
901	assert.NoError(t, err)
902	assert.NotNil(t, result)
903	assert.Equal(t, "container-123", result.ID)
904}
905
906func TestService_AutoStart_StartsNewContainers(t *testing.T) {
907	runtime := mocks.NewMockContainerRuntime(t)
908	envLoader := mocks.NewMockEnvLoader(t)
909	eventBus := mocks.NewMockEventPublisher(t)
910
911	config := Config{
912		ReadinessDelay: time.Millisecond,
913	}
914	svc := NewService(runtime, envLoader, eventBus, nil, config)
915	ctx := testContext()
916
917	routes := []domain.Route{
918		{Domain: "app1.example.com", Image: "myapp1:latest"},
919		{Domain: "app2.example.com", Image: "myapp2:latest"},
920	}
921
922	// Setup mocks for first route deployment
923	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
924	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
925	runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
926	envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
927	runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
928
929	// Create and start containers
930	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
931		ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
932	}, nil).Once()
933	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
934		ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
935	}, nil).Once()
936	runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
937	runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
938	runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
939		Status: "running",
940	}, nil).Times(2)
941	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2)
942
943	err := svc.AutoStart(ctx, routes)
944
945	assert.NoError(t, err)
946	assert.Len(t, svc.containers, 2)
947}
948
949func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
950	runtime := mocks.NewMockContainerRuntime(t)
951	envLoader := mocks.NewMockEnvLoader(t)
952	eventBus := mocks.NewMockEventPublisher(t)
953
954	config := Config{
955		ReadinessDelay: time.Millisecond,
956	}
957	svc := NewService(runtime, envLoader, eventBus, nil, config)
958	ctx := testContext()
959
960	// Pre-populate with existing container
961	svc.containers["app1.example.com"] = &domain.Container{
962		ID: "existing-container",
963	}
964
965	routes := []domain.Route{
966		{Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists
967		{Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
968	}
969
970	// Only deploy for app2 (app1 is skipped)
971	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
972	runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
973	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
974	envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
975	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
976	runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
977		ID: "container-2", Status: "created",
978	}, nil).Once()
979	runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
980	runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2)
981	runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
982		Status: "running",
983	}, nil).Once()
984	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
985
986	err := svc.AutoStart(ctx, routes)
987
988	assert.NoError(t, err)
989	assert.Len(t, svc.containers, 2)
990}
991
992func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
993	runtime := mocks.NewMockContainerRuntime(t)
994	envLoader := mocks.NewMockEnvLoader(t)
995	eventBus := mocks.NewMockEventPublisher(t)
996
997	config := Config{}
998	svc := NewService(runtime, envLoader, eventBus, nil, config)
999	ctx := testContext()
1000
1001	routes := []domain.Route{
1002		{Domain: "app1.example.com", Image: "myapp1:latest"},
1003	}
1004
1005	// Setup mocks for failure
1006	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1007	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1008	runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found"))
1009
1010	err := svc.AutoStart(ctx, routes)
1011
1012	// AutoStart should return error when some deployments fail
1013	assert.Error(t, err)
1014	assert.Contains(t, err.Error(), "auto-start completed with 1 errors")
1015}
1016
1017// TestService_Deploy_OrphanCleanupSkipsTrackedContainer verifies that the orphan cleanup
1018// does not remove the currently tracked container during zero-downtime deployment.
1019// This is critical for preventing downtime - the old container must stay running
1020// until the new container is ready and traffic is switched.
1021func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1022	runtime := mocks.NewMockContainerRuntime(t)
1023	envLoader := mocks.NewMockEnvLoader(t)
1024	eventBus := mocks.NewMockEventPublisher(t)
1025
1026	config := Config{
1027		ReadinessDelay: time.Millisecond, // Minimal delay for tests
1028	}
1029	svc := NewService(runtime, envLoader, eventBus, nil, config)
1030	ctx := testContext()
1031
1032	// Pre-populate with existing tracked container
1033	existingContainer := &domain.Container{
1034		ID:     "tracked-container-123",
1035		Name:   "gordon-test.example.com",
1036		Status: "running",
1037	}
1038	svc.containers["test.example.com"] = existingContainer
1039
1040	route := domain.Route{
1041		Domain: "test.example.com",
1042		Image:  "myapp:v2",
1043	}
1044
1045	// ListContainers returns the tracked container - this simulates the orphan check
1046	// finding a container with the same name. The bug was that it would remove this
1047	// container BEFORE the new one was ready, causing downtime.
1048	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1049		{
1050			ID:     "tracked-container-123",
1051			Name:   "gordon-test.example.com",
1052			Status: "running",
1053		},
1054	}, nil)
1055
1056	// Image operations
1057	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1058	runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1059	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1060
1061	// Environment
1062	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1063	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1064
1065	// Create new container with -new suffix for zero-downtime
1066	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1067	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1068		return cfg.Name == "gordon-test.example.com-new"
1069	})).Return(newContainer, nil)
1070	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1071
1072	// Wait for ready
1073	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1074
1075	// Inspect after ready
1076	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1077		ID:     "new-container",
1078		Status: "running",
1079	}, nil)
1080
1081	// Publish event
1082	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1083
1084	// NOW (after new container is ready) the old container should be stopped and removed
1085	// This is the correct zero-downtime sequence - not during orphan cleanup
1086	runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1087	runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1088
1089	// Rename new container to canonical name
1090	runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1091
1092	result, err := svc.Deploy(ctx, route)
1093
1094	assert.NoError(t, err)
1095	assert.Equal(t, "new-container", result.ID)
1096
1097	// Verify new container is now tracked
1098	tracked, exists := svc.Get(ctx, "test.example.com")
1099	assert.True(t, exists)
1100	assert.Equal(t, "new-container", tracked.ID)
1101}
1102
1103// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers
1104// with the same name but NOT tracked are properly removed as orphans.
1105func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1106	runtime := mocks.NewMockContainerRuntime(t)
1107	envLoader := mocks.NewMockEnvLoader(t)
1108	eventBus := mocks.NewMockEventPublisher(t)
1109
1110	config := Config{
1111		ReadinessDelay: time.Millisecond, // Minimal delay for tests
1112	}
1113	svc := NewService(runtime, envLoader, eventBus, nil, config)
1114	ctx := testContext()
1115
1116	// NO tracked container - service is empty for this domain
1117
1118	route := domain.Route{
1119		Domain: "test.example.com",
1120		Image:  "myapp:v1",
1121	}
1122
1123	// ListContainers returns an orphaned container (same name, but not tracked)
1124	// This could happen if Gordon crashed and restarted, or container was created manually
1125	runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1126		{
1127			ID:     "orphan-container",
1128			Name:   "gordon-test.example.com",
1129			Status: "running",
1130		},
1131	}, nil)
1132
1133	// Orphan should be stopped and removed BEFORE we proceed
1134	runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
1135	runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
1136
1137	// Image operations
1138	runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1139	runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
1140	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
1141
1142	// Environment
1143	envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1144	runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v1").Return([]string{}, nil)
1145
1146	// Create container (no -new suffix since no existing tracked container)
1147	newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com", Status: "created"}
1148	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1149		return cfg.Name == "gordon-test.example.com"
1150	})).Return(newContainer, nil)
1151	runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1152
1153	// Wait for ready
1154	runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1155
1156	// Inspect after ready
1157	runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1158		ID:     "new-container",
1159		Status: "running",
1160		Ports:  []int{8080},
1161	}, nil)
1162
1163	// Publish event
1164	eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1165
1166	result, err := svc.Deploy(ctx, route)
1167
1168	assert.NoError(t, err)
1169	assert.Equal(t, "new-container", result.ID)
1170
1171	// Verify new container is tracked
1172	tracked, exists := svc.Get(ctx, "test.example.com")
1173	assert.True(t, exists)
1174	assert.Equal(t, "new-container", tracked.ID)
1175}
1176
1177func TestService_StripRegistryPrefix(t *testing.T) {
1178	tests := []struct {
1179		name           string
1180		registryDomain string
1181		image          string
1182		expected       string
1183	}{
1184		{
1185			name:           "strips registry prefix",
1186			registryDomain: "reg.example.com",
1187			image:          "reg.example.com/myapp:latest",
1188			expected:       "myapp:latest",
1189		},
1190		{
1191			name:           "strips registry prefix with trailing slash in domain",
1192			registryDomain: "reg.example.com/",
1193			image:          "reg.example.com/myapp:v1.0",
1194			expected:       "myapp:v1.0",
1195		},
1196		{
1197			name:           "preserves image without registry prefix",
1198			registryDomain: "reg.example.com",
1199			image:          "nginx:latest",
1200			expected:       "nginx:latest",
1201		},
1202		{
1203			name:           "preserves image with different registry",
1204			registryDomain: "reg.example.com",
1205			image:          "gcr.io/project/image:latest",
1206			expected:       "gcr.io/project/image:latest",
1207		},
1208		{
1209			name:           "handles empty registry domain",
1210			registryDomain: "",
1211			image:          "reg.example.com/myapp:latest",
1212			expected:       "reg.example.com/myapp:latest",
1213		},
1214		{
1215			name:           "handles nested paths",
1216			registryDomain: "reg.example.com",
1217			image:          "reg.example.com/org/repo/app:latest",
1218			expected:       "org/repo/app:latest",
1219		},
1220	}
1221
1222	for _, tt := range tests {
1223		t.Run(tt.name, func(t *testing.T) {
1224			runtime := mocks.NewMockContainerRuntime(t)
1225			envLoader := mocks.NewMockEnvLoader(t)
1226			eventBus := mocks.NewMockEventPublisher(t)
1227
1228			config := Config{
1229				RegistryDomain: tt.registryDomain,
1230			}
1231			svc := NewService(runtime, envLoader, eventBus, nil, config)
1232
1233			result := svc.stripRegistryPrefix(tt.image)
1234			assert.Equal(t, tt.expected, result)
1235		})
1236	}
1237}
1238
1239func TestSanitizeName(t *testing.T) {
1240	tests := []struct {
1241		domain      string
1242		expected    string
1243		description string
1244	}{
1245		{
1246			domain:      "git.example.com",
1247			expected:    "git__example__com",
1248			description: "Dots become double underscores",
1249		},
1250		{
1251			domain:      "git-example.com",
1252			expected:    "git-example__com",
1253			description: "Hyphens preserved, dots become underscores",
1254		},
1255		{
1256			domain:      "app:8080.example.com",
1257			expected:    "app-_8080__example__com",
1258			description: "Colons become hyphen-underscore",
1259		},
1260		{
1261			domain:      "git.example.com:3000",
1262			expected:    "git__example__com-_3000",
1263			description: "Multiple separators handled distinctly",
1264		},
1265		{
1266			domain:      "simple.com",
1267			expected:    "simple__com",
1268			description: "Simple domain",
1269		},
1270	}
1271
1272	for _, tt := range tests {
1273		t.Run(tt.domain, func(t *testing.T) {
1274			result := sanitizeName(tt.domain)
1275			assert.Equal(t, tt.expected, result, "sanitization should match expected")
1276		})
1277	}
1278
1279	// Verify no collisions between potentially conflicting domains
1280	t.Run("NoCollisions", func(t *testing.T) {
1281		domains := []string{
1282			"git.example.com",
1283			"git-example.com",
1284			"app:8080.example.com",
1285			"app-8080-example.com",
1286		}
1287
1288		results := make(map[string]string)
1289		for _, d := range domains {
1290			result := sanitizeName(d)
1291			if original, exists := results[result]; exists {
1292				t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
1293			}
1294			results[result] = d
1295		}
1296	})
1297}

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