v2.7.0 fac7705

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

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