v2.4.1 7c3b292

service_test.go RawHistoryFolder

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

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