v2.31.0-dev9 062dcae

service_test.go RawHistoryFolder

1package proxy
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	"github.com/bnema/zerowrap"
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/mock"
11
12	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
13	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14	"github.com/bnema/gordon/internal/domain"
15)
16
17func testContext() context.Context {
18	return zerowrap.WithCtx(context.Background(), zerowrap.Default())
19}
20
21func TestService_GetTarget_FromCache(t *testing.T) {
22	runtime := outmocks.NewMockContainerRuntime(t)
23	containerSvc := inmocks.NewMockContainerService(t)
24	configSvc := inmocks.NewMockConfigService(t)
25
26	config := Config{
27		RegistryDomain: "registry.example.com",
28		RegistryPort:   5000,
29	}
30	svc := NewService(runtime, containerSvc, configSvc, config)
31	ctx := testContext()
32
33	// Pre-populate cache
34	cachedTarget := &domain.ProxyTarget{
35		Host:        "192.168.1.100",
36		Port:        8080,
37		ContainerID: "container-123",
38		Scheme:      "http",
39	}
40	svc.targets["app.example.com"] = cachedTarget
41
42	// No mock calls expected - should return from cache
43
44	result, err := svc.GetTarget(ctx, "app.example.com")
45
46	assert.NoError(t, err)
47	assert.Equal(t, cachedTarget, result)
48}
49
50func TestService_GetTarget_CanonicalizesHostForLookup(t *testing.T) {
51	runtime := outmocks.NewMockContainerRuntime(t)
52	containerSvc := inmocks.NewMockContainerService(t)
53	configSvc := inmocks.NewMockConfigService(t)
54	svc := NewService(runtime, containerSvc, configSvc, Config{})
55	ctx := testContext()
56
57	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
58	containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
59
60	result, err := svc.GetTarget(ctx, "App.Example.com")
61	assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
62	assert.Nil(t, result)
63}
64
65func TestService_GetTarget_RejectsInvalidHostAuthority(t *testing.T) {
66	svc := NewService(outmocks.NewMockContainerRuntime(t), inmocks.NewMockContainerService(t), inmocks.NewMockConfigService(t), Config{})
67	result, err := svc.GetTarget(testContext(), "app.example.com:8080")
68	assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
69	assert.Nil(t, result)
70}
71
72func TestService_GetTarget_ContainerNotFound(t *testing.T) {
73	runtime := outmocks.NewMockContainerRuntime(t)
74	containerSvc := inmocks.NewMockContainerService(t)
75	configSvc := inmocks.NewMockConfigService(t)
76
77	config := Config{}
78	svc := NewService(runtime, containerSvc, configSvc, config)
79	ctx := testContext()
80
81	// Mock external routes (empty - no match)
82	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
83	containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
84
85	result, err := svc.GetTarget(ctx, "app.example.com")
86
87	assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
88	assert.Nil(t, result)
89}
90
91func TestService_GetTarget_ExternalRoute(t *testing.T) {
92	runtime := outmocks.NewMockContainerRuntime(t)
93	containerSvc := inmocks.NewMockContainerService(t)
94	configSvc := inmocks.NewMockConfigService(t)
95
96	svc := NewService(runtime, containerSvc, configSvc, Config{})
97	ctx := testContext()
98
99	// Mock external routes - use public IP to pass SSRF check
100	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
101		"reg.example.com": "203.0.113.10:5000", // TEST-NET-3 (RFC 5737) - documentation range
102	})
103
104	result, err := svc.GetTarget(ctx, "reg.example.com")
105
106	assert.NoError(t, err)
107	assert.Equal(t, "203.0.113.10", result.Host)
108	assert.Equal(t, 5000, result.Port)
109	assert.Equal(t, "", result.ContainerID)
110	assert.Equal(t, "http", result.Scheme)
111}
112
113func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
114	runtime := outmocks.NewMockContainerRuntime(t)
115	containerSvc := inmocks.NewMockContainerService(t)
116	configSvc := inmocks.NewMockConfigService(t)
117
118	svc := NewService(runtime, containerSvc, configSvc, Config{})
119	ctx := testContext()
120
121	// First call - should resolve external route (use public IP)
122	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
123		"reg.example.com": "203.0.113.10:5000",
124	}).Once()
125
126	result1, err := svc.GetTarget(ctx, "reg.example.com")
127	assert.NoError(t, err)
128	assert.Equal(t, "203.0.113.10", result1.Host)
129
130	// Second call - should return from cache (no mock call needed)
131	result2, err := svc.GetTarget(ctx, "reg.example.com")
132	assert.NoError(t, err)
133	assert.Equal(t, result1, result2)
134}
135
136func TestService_GetTarget_ExternalRoute_SSRFBlocked(t *testing.T) {
137	runtime := outmocks.NewMockContainerRuntime(t)
138	containerSvc := inmocks.NewMockContainerService(t)
139	configSvc := inmocks.NewMockConfigService(t)
140
141	svc := NewService(runtime, containerSvc, configSvc, Config{})
142	ctx := testContext()
143
144	tests := []struct {
145		name   string
146		target string
147	}{
148		{"localhost", "localhost:5000"},
149		{"loopback IP", "127.0.0.1:5000"},
150		{"private network 10.x", "10.0.0.1:5000"},
151		{"private network 172.x", "172.16.0.1:5000"},
152		{"private network 192.168.x", "192.168.1.1:5000"},
153		{"AWS metadata", "169.254.169.254:80"},
154	}
155
156	for _, tt := range tests {
157		t.Run(tt.name, func(t *testing.T) {
158			// Clear cache
159			svc.targets = make(map[string]*domain.ProxyTarget)
160
161			configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
162				"ssrf.example.com": tt.target,
163			}).Once()
164
165			result, err := svc.GetTarget(ctx, "ssrf.example.com")
166
167			assert.Error(t, err)
168			assert.ErrorIs(t, err, domain.ErrSSRFBlocked)
169			assert.Nil(t, result)
170		})
171	}
172}
173
174func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
175	runtime := outmocks.NewMockContainerRuntime(t)
176	containerSvc := inmocks.NewMockContainerService(t)
177	configSvc := inmocks.NewMockConfigService(t)
178
179	svc := NewService(runtime, containerSvc, configSvc, Config{})
180	ctx := testContext()
181
182	// Mock external routes with invalid format (missing port)
183	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
184		"invalid.example.com": "not-valid-format",
185	})
186
187	result, err := svc.GetTarget(ctx, "invalid.example.com")
188
189	assert.Error(t, err)
190	assert.Nil(t, result)
191}
192
193func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
194	tests := []struct {
195		name   string
196		target string
197	}{
198		{name: "not a number", target: "localhost:abc"},
199		{name: "zero", target: "localhost:0"},
200		{name: "too high", target: "localhost:65536"},
201	}
202
203	for _, tt := range tests {
204		t.Run(tt.name, func(t *testing.T) {
205			runtime := outmocks.NewMockContainerRuntime(t)
206			containerSvc := inmocks.NewMockContainerService(t)
207			configSvc := inmocks.NewMockConfigService(t)
208
209			svc := NewService(runtime, containerSvc, configSvc, Config{})
210			ctx := testContext()
211
212			configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
213				"invalid.example.com": tt.target,
214			})
215
216			result, err := svc.GetTarget(ctx, "invalid.example.com")
217
218			assert.Error(t, err)
219			assert.Nil(t, result)
220		})
221	}
222}
223
224func TestService_RegisterTarget(t *testing.T) {
225	runtime := outmocks.NewMockContainerRuntime(t)
226	containerSvc := inmocks.NewMockContainerService(t)
227	configSvc := inmocks.NewMockConfigService(t)
228
229	svc := NewService(runtime, containerSvc, configSvc, Config{})
230	ctx := testContext()
231
232	target := &domain.ProxyTarget{
233		Host:        "192.168.1.100",
234		Port:        8080,
235		ContainerID: "container-123",
236		Scheme:      "http",
237	}
238
239	err := svc.RegisterTarget(ctx, "App.Example.com", target)
240
241	assert.NoError(t, err)
242
243	// Verify target is cached under the canonical domain.
244	svc.mu.RLock()
245	cached := svc.targets["app.example.com"]
246	svc.mu.RUnlock()
247
248	assert.Equal(t, target, cached)
249}
250
251func TestService_UnregisterTarget(t *testing.T) {
252	runtime := outmocks.NewMockContainerRuntime(t)
253	containerSvc := inmocks.NewMockContainerService(t)
254	configSvc := inmocks.NewMockConfigService(t)
255
256	svc := NewService(runtime, containerSvc, configSvc, Config{})
257	ctx := testContext()
258
259	// Pre-populate
260	svc.targets["app.example.com"] = &domain.ProxyTarget{
261		Host: "192.168.1.100",
262		Port: 8080,
263	}
264
265	err := svc.UnregisterTarget(ctx, "App.Example.com")
266
267	assert.NoError(t, err)
268
269	// Verify target is removed
270	svc.mu.RLock()
271	_, exists := svc.targets["app.example.com"]
272	svc.mu.RUnlock()
273
274	assert.False(t, exists)
275}
276
277func TestService_RefreshTargets(t *testing.T) {
278	runtime := outmocks.NewMockContainerRuntime(t)
279	containerSvc := inmocks.NewMockContainerService(t)
280	configSvc := inmocks.NewMockConfigService(t)
281
282	svc := NewService(runtime, containerSvc, configSvc, Config{})
283	ctx := testContext()
284
285	// Pre-populate with some targets
286	svc.targets["app1.example.com"] = &domain.ProxyTarget{Host: "192.168.1.100"}
287	svc.targets["app2.example.com"] = &domain.ProxyTarget{Host: "192.168.1.101"}
288
289	err := svc.RefreshTargets(ctx)
290
291	assert.NoError(t, err)
292
293	// Verify all targets are cleared
294	svc.mu.RLock()
295	count := len(svc.targets)
296	svc.mu.RUnlock()
297
298	assert.Equal(t, 0, count)
299}
300
301func TestService_UpdateConfig(t *testing.T) {
302	runtime := outmocks.NewMockContainerRuntime(t)
303	containerSvc := inmocks.NewMockContainerService(t)
304	configSvc := inmocks.NewMockConfigService(t)
305
306	svc := NewService(runtime, containerSvc, configSvc, Config{
307		RegistryDomain: "old.registry.com",
308		RegistryPort:   5000,
309	})
310
311	newConfig := Config{
312		RegistryDomain: "new.registry.com",
313		RegistryPort:   5001,
314	}
315
316	svc.UpdateConfig(newConfig)
317
318	assert.Equal(t, "new.registry.com", svc.config.RegistryDomain)
319	assert.Equal(t, 5001, svc.config.RegistryPort)
320}
321
322func TestService_isRunningInContainer(t *testing.T) {
323	runtime := outmocks.NewMockContainerRuntime(t)
324	containerSvc := inmocks.NewMockContainerService(t)
325	configSvc := inmocks.NewMockConfigService(t)
326
327	svc := NewService(runtime, containerSvc, configSvc, Config{})
328
329	// This test just verifies the method doesn't panic
330	// The actual result depends on the environment
331	result := svc.isRunningInContainer()
332	assert.IsType(t, true, result) // Just verify it returns a bool
333}
334
335func TestService_InvalidateTarget(t *testing.T) {
336	runtime := outmocks.NewMockContainerRuntime(t)
337	containerSvc := inmocks.NewMockContainerService(t)
338	configSvc := inmocks.NewMockConfigService(t)
339
340	svc := NewService(runtime, containerSvc, configSvc, Config{})
341	ctx := testContext()
342
343	// Pre-populate cache with target
344	svc.targets["app.example.com"] = &domain.ProxyTarget{
345		Host:        "192.168.1.100",
346		Port:        8080,
347		ContainerID: "old-container",
348	}
349
350	// Invalidate the target using mixed-case input.
351	svc.InvalidateTarget(ctx, "App.Example.com")
352
353	// Verify target is removed from cache
354	svc.mu.RLock()
355	_, exists := svc.targets["app.example.com"]
356	svc.mu.RUnlock()
357
358	assert.False(t, exists, "target should be removed from cache after invalidation")
359}
360
361func TestContainerDeployedHandler_CanHandle(t *testing.T) {
362	handler := NewContainerDeployedHandler(testContext(), nil)
363
364	assert.True(t, handler.CanHandle(domain.EventContainerDeployed))
365	assert.False(t, handler.CanHandle(domain.EventImagePushed))
366	assert.False(t, handler.CanHandle(domain.EventConfigReload))
367}
368
369func TestContainerDeployedHandler_Handle_InvalidatesCache(t *testing.T) {
370	runtime := outmocks.NewMockContainerRuntime(t)
371	containerSvc := inmocks.NewMockContainerService(t)
372	configSvc := inmocks.NewMockConfigService(t)
373
374	svc := NewService(runtime, containerSvc, configSvc, Config{})
375	ctx := testContext()
376
377	// Pre-populate cache
378	svc.targets["app.example.com"] = &domain.ProxyTarget{
379		Host:        "192.168.1.100",
380		Port:        8080,
381		ContainerID: "old-container",
382	}
383
384	// Create handler with service as invalidator
385	handler := NewContainerDeployedHandler(ctx, svc)
386
387	// Simulate container deployed event
388	event := domain.Event{
389		ID:    "event-123",
390		Type:  domain.EventContainerDeployed,
391		Route: "app.example.com",
392		Data: &domain.ContainerEventPayload{
393			ContainerID: "new-container",
394			Domain:      "app.example.com",
395		},
396	}
397
398	err := handler.Handle(context.Background(), event)
399
400	assert.NoError(t, err)
401
402	// Verify target was invalidated
403	svc.mu.RLock()
404	_, exists := svc.targets["app.example.com"]
405	svc.mu.RUnlock()
406
407	assert.False(t, exists, "cache should be invalidated after container deployed event")
408}
409
410func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) {
411	runtime := outmocks.NewMockContainerRuntime(t)
412	containerSvc := inmocks.NewMockContainerService(t)
413	configSvc := inmocks.NewMockConfigService(t)
414
415	svc := NewService(runtime, containerSvc, configSvc, Config{})
416	ctx := testContext()
417
418	handler := NewContainerDeployedHandler(ctx, svc)
419
420	// Event with no domain
421	event := domain.Event{
422		ID:   "event-123",
423		Type: domain.EventContainerDeployed,
424		Data: &domain.ContainerEventPayload{
425			ContainerID: "new-container",
426		},
427	}
428
429	// Should not error, just skip
430	err := handler.Handle(context.Background(), event)
431	assert.NoError(t, err)
432}
433
434func TestRegistryInFlightTracking(t *testing.T) {
435	svc := &Service{
436		inFlight: make(map[string]int),
437	}
438
439	if got := svc.registryInFlight.Load(); got != 0 {
440		t.Fatalf("expected 0 in-flight, got %d", got)
441	}
442
443	svc.registryInFlight.Add(1)
444	if got := svc.registryInFlight.Load(); got != 1 {
445		t.Fatalf("expected 1 in-flight after Add, got %d", got)
446	}
447
448	svc.registryInFlight.Add(-1)
449	if got := svc.registryInFlight.Load(); got != 0 {
450		t.Fatalf("expected 0 in-flight after release, got %d", got)
451	}
452}
453
454func TestDrainRegistryInFlight(t *testing.T) {
455	svc := &Service{
456		inFlight: make(map[string]int),
457	}
458
459	svc.registryInFlight.Add(2)
460
461	result := make(chan bool, 1)
462	go func() {
463		result <- svc.DrainRegistryInFlight(50 * time.Millisecond)
464	}()
465
466	time.Sleep(5 * time.Millisecond)
467	svc.registryInFlight.Add(-1)
468	svc.registryInFlight.Add(-1)
469
470	select {
471	case drained := <-result:
472		if !drained {
473			t.Fatalf("DrainRegistryInFlight returned false; expected true after all requests completed")
474		}
475	case <-time.After(500 * time.Millisecond):
476		t.Fatal("DrainRegistryInFlight did not return within timeout")
477	}
478}
479
480func TestDrainRegistryInFlightTimeout(t *testing.T) {
481	svc := &Service{
482		inFlight: make(map[string]int),
483	}
484
485	// Add a request and never release it
486	svc.registryInFlight.Add(1)
487
488	drained := svc.DrainRegistryInFlight(30 * time.Millisecond)
489	if drained {
490		t.Fatal("expected DrainRegistryInFlight to return false on timeout, got true")
491	}
492}
493
494func TestService_ProxyConfig_ReflectsUpdates(t *testing.T) {
495	runtime := outmocks.NewMockContainerRuntime(t)
496	containerSvc := inmocks.NewMockContainerService(t)
497	configSvc := inmocks.NewMockConfigService(t)
498
499	svc := NewService(runtime, containerSvc, configSvc, Config{
500		RegistryDomain:     "old.registry.com",
501		RegistryPort:       5000,
502		MaxBodySize:        1024,
503		MaxResponseSize:    2048,
504		MaxConcurrentConns: 10,
505	})
506
507	cfg := svc.ProxyConfig()
508	assert.Equal(t, "old.registry.com", cfg.RegistryDomain)
509	assert.Equal(t, 5000, cfg.RegistryPort)
510	assert.Equal(t, int64(1024), cfg.MaxBodySize)
511	assert.Equal(t, int64(2048), cfg.MaxResponseSize)
512	assert.Equal(t, 10, cfg.MaxConcurrentConns)
513
514	svc.UpdateConfig(Config{
515		RegistryDomain:     "new.registry.com",
516		RegistryPort:       5001,
517		MaxBodySize:        4096,
518		MaxResponseSize:    8192,
519		MaxConcurrentConns: 50,
520	})
521
522	cfg = svc.ProxyConfig()
523	assert.Equal(t, "new.registry.com", cfg.RegistryDomain)
524	assert.Equal(t, 5001, cfg.RegistryPort)
525	assert.Equal(t, int64(4096), cfg.MaxBodySize)
526	assert.Equal(t, int64(8192), cfg.MaxResponseSize)
527	assert.Equal(t, 50, cfg.MaxConcurrentConns)
528}
529
530func TestService_IsRegistryDomain(t *testing.T) {
531	runtime := outmocks.NewMockContainerRuntime(t)
532	containerSvc := inmocks.NewMockContainerService(t)
533	configSvc := inmocks.NewMockConfigService(t)
534
535	svc := NewService(runtime, containerSvc, configSvc, Config{
536		RegistryDomain: "registry.example.com",
537	})
538
539	assert.True(t, svc.IsRegistryDomain("registry.example.com"))
540	assert.False(t, svc.IsRegistryDomain("other.example.com"))
541	assert.False(t, svc.IsRegistryDomain(""))
542}
543
544func TestService_IsRegistryDomain_EmptyConfig(t *testing.T) {
545	runtime := outmocks.NewMockContainerRuntime(t)
546	containerSvc := inmocks.NewMockContainerService(t)
547	configSvc := inmocks.NewMockConfigService(t)
548
549	svc := NewService(runtime, containerSvc, configSvc, Config{})
550
551	assert.False(t, svc.IsRegistryDomain("registry.example.com"))
552	assert.False(t, svc.IsRegistryDomain(""))
553}
554
555func TestService_TrackInFlight(t *testing.T) {
556	svc := &Service{
557		inFlight: make(map[string]int),
558	}
559
560	// Empty container ID returns noop
561	release := svc.TrackInFlight("")
562	release() // should not panic
563
564	// Track a container
565	release1 := svc.TrackInFlight("c-1")
566	svc.inFlightMu.Lock()
567	assert.Equal(t, 1, svc.inFlight["c-1"])
568	svc.inFlightMu.Unlock()
569
570	// Track same container again
571	release2 := svc.TrackInFlight("c-1")
572	svc.inFlightMu.Lock()
573	assert.Equal(t, 2, svc.inFlight["c-1"])
574	svc.inFlightMu.Unlock()
575
576	// Release one
577	release2()
578	svc.inFlightMu.Lock()
579	assert.Equal(t, 1, svc.inFlight["c-1"])
580	svc.inFlightMu.Unlock()
581
582	// Release last — should delete key
583	release1()
584	svc.inFlightMu.Lock()
585	_, exists := svc.inFlight["c-1"]
586	assert.False(t, exists, "container should be removed from inFlight map when count reaches 0")
587	svc.inFlightMu.Unlock()
588}
589
590func TestService_TrackRegistryRequest(t *testing.T) {
591	svc := &Service{
592		inFlight: make(map[string]int),
593	}
594
595	assert.Equal(t, int64(0), svc.RegistryInFlight())
596
597	svc.TrackRegistryRequest()
598	assert.Equal(t, int64(1), svc.RegistryInFlight())
599
600	svc.TrackRegistryRequest()
601	assert.Equal(t, int64(2), svc.RegistryInFlight())
602
603	svc.ReleaseRegistryRequest()
604	assert.Equal(t, int64(1), svc.RegistryInFlight())
605
606	svc.ReleaseRegistryRequest()
607	assert.Equal(t, int64(0), svc.RegistryInFlight())
608}
609
610func TestService_GetTarget_HostMode_UsesProxyPortLabel(t *testing.T) {
611	runtime := outmocks.NewMockContainerRuntime(t)
612	containerSvc := inmocks.NewMockContainerService(t)
613	configSvc := inmocks.NewMockConfigService(t)
614
615	svc := NewService(runtime, containerSvc, configSvc, Config{})
616	ctx := testContext()
617
618	// No external routes
619	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
620
621	// Container exists for this domain
622	container := &domain.Container{
623		ID:    "c-gitea",
624		Image: "gitea/gitea:latest",
625	}
626	containerSvc.EXPECT().Get(mock.Anything, "git.example.com").Return(container, true)
627
628	// Route exists
629	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
630		{Domain: "git.example.com", Image: "gitea/gitea:latest"},
631	})
632
633	// Image has gordon.proxy.port=3000 label
634	runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
635		domain.LabelProxyPort: "3000",
636	}, nil)
637
638	// Host port mapping for internal port 3000
639	runtime.EXPECT().GetContainerPort(mock.Anything, "c-gitea", 3000).Return(32000, nil)
640
641	result, err := svc.GetTarget(ctx, "git.example.com")
642
643	assert.NoError(t, err)
644	assert.Equal(t, "localhost", result.Host)
645	assert.Equal(t, 32000, result.Port)
646	assert.Equal(t, "c-gitea", result.ContainerID)
647}
648
649func TestService_GetTarget_HostMode_UsesDeprecatedPortLabel(t *testing.T) {
650	runtime := outmocks.NewMockContainerRuntime(t)
651	containerSvc := inmocks.NewMockContainerService(t)
652	configSvc := inmocks.NewMockConfigService(t)
653
654	svc := NewService(runtime, containerSvc, configSvc, Config{})
655	ctx := testContext()
656
657	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
658
659	container := &domain.Container{
660		ID:    "c-app",
661		Image: "myapp:latest",
662	}
663	containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
664
665	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
666		{Domain: "app.example.com", Image: "myapp:latest"},
667	})
668
669	// Image has deprecated gordon.port label — should still work
670	runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(map[string]string{
671		domain.LabelPort: "8080",
672	}, nil)
673
674	runtime.EXPECT().GetContainerPort(mock.Anything, "c-app", 8080).Return(33000, nil)
675
676	result, err := svc.GetTarget(ctx, "app.example.com")
677
678	assert.NoError(t, err)
679	assert.Equal(t, 33000, result.Port)
680}
681
682func TestService_GetTarget_HostMode_ProxyPortWinsOverPort(t *testing.T) {
683	runtime := outmocks.NewMockContainerRuntime(t)
684	containerSvc := inmocks.NewMockContainerService(t)
685	configSvc := inmocks.NewMockConfigService(t)
686
687	svc := NewService(runtime, containerSvc, configSvc, Config{})
688	ctx := testContext()
689
690	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
691
692	container := &domain.Container{
693		ID:    "c-dual",
694		Image: "dualapp:latest",
695	}
696	containerSvc.EXPECT().Get(mock.Anything, "dual.example.com").Return(container, true)
697
698	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
699		{Domain: "dual.example.com", Image: "dualapp:latest"},
700	})
701
702	// Both labels set — gordon.proxy.port=9000 should win over gordon.port=3000
703	runtime.EXPECT().GetImageLabels(mock.Anything, "dualapp:latest").Return(map[string]string{
704		domain.LabelProxyPort: "9000",
705		domain.LabelPort:      "3000",
706	}, nil)
707
708	// Should use 9000 (gordon.proxy.port), NOT 3000 (gordon.port)
709	runtime.EXPECT().GetContainerPort(mock.Anything, "c-dual", 9000).Return(34000, nil)
710
711	result, err := svc.GetTarget(ctx, "dual.example.com")
712
713	assert.NoError(t, err)
714	assert.Equal(t, 34000, result.Port)
715}
716
717func TestService_GetTarget_HostMode_FallsBackToExposedPort(t *testing.T) {
718	runtime := outmocks.NewMockContainerRuntime(t)
719	containerSvc := inmocks.NewMockContainerService(t)
720	configSvc := inmocks.NewMockConfigService(t)
721
722	svc := NewService(runtime, containerSvc, configSvc, Config{})
723	ctx := testContext()
724
725	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
726
727	container := &domain.Container{
728		ID:    "c-plain",
729		Image: "plain:latest",
730	}
731	containerSvc.EXPECT().Get(mock.Anything, "plain.example.com").Return(container, true)
732
733	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
734		{Domain: "plain.example.com", Image: "plain:latest"},
735	})
736
737	// No port labels — should fall back to first exposed port
738	runtime.EXPECT().GetImageLabels(mock.Anything, "plain:latest").Return(map[string]string{}, nil)
739	runtime.EXPECT().GetImageExposedPorts(mock.Anything, "plain:latest").Return([]int{8080}, nil)
740
741	runtime.EXPECT().GetContainerPort(mock.Anything, "c-plain", 8080).Return(35000, nil)
742
743	result, err := svc.GetTarget(ctx, "plain.example.com")
744
745	assert.NoError(t, err)
746	assert.Equal(t, 35000, result.Port)
747}
748
749func TestService_GetTarget_HostMode_H2CProtocolPropagated(t *testing.T) {
750	runtime := outmocks.NewMockContainerRuntime(t)
751	containerSvc := inmocks.NewMockContainerService(t)
752	configSvc := inmocks.NewMockConfigService(t)
753
754	svc := NewService(runtime, containerSvc, configSvc, Config{})
755	ctx := testContext()
756
757	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
758
759	container := &domain.Container{
760		ID:    "c-grpc",
761		Image: "grpc-app:latest",
762	}
763	containerSvc.EXPECT().Get(mock.Anything, "grpc.example.com").Return(container, true)
764
765	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
766		{Domain: "grpc.example.com", Image: "grpc-app:latest"},
767	})
768
769	runtime.EXPECT().GetImageLabels(mock.Anything, "grpc-app:latest").Return(map[string]string{
770		domain.LabelProxyPort:     "50051",
771		domain.LabelProxyProtocol: "h2c",
772	}, nil)
773
774	runtime.EXPECT().GetContainerPort(mock.Anything, "c-grpc", 50051).Return(50051, nil)
775
776	result, err := svc.GetTarget(ctx, "grpc.example.com")
777
778	assert.NoError(t, err)
779	assert.Equal(t, "h2c", result.Protocol)
780	assert.Equal(t, 50051, result.Port)
781}
782
783func TestService_GetTarget_HostMode_DefaultProtocol(t *testing.T) {
784	runtime := outmocks.NewMockContainerRuntime(t)
785	containerSvc := inmocks.NewMockContainerService(t)
786	configSvc := inmocks.NewMockConfigService(t)
787
788	svc := NewService(runtime, containerSvc, configSvc, Config{})
789	ctx := testContext()
790
791	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
792
793	container := &domain.Container{
794		ID:    "c-web",
795		Image: "web:latest",
796	}
797	containerSvc.EXPECT().Get(mock.Anything, "web.example.com").Return(container, true)
798
799	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
800		{Domain: "web.example.com", Image: "web:latest"},
801	})
802
803	runtime.EXPECT().GetImageLabels(mock.Anything, "web:latest").Return(map[string]string{
804		domain.LabelProxyPort: "8080",
805	}, nil)
806
807	runtime.EXPECT().GetContainerPort(mock.Anything, "c-web", 8080).Return(8080, nil)
808
809	result, err := svc.GetTarget(ctx, "web.example.com")
810
811	assert.NoError(t, err)
812	assert.Equal(t, "", result.Protocol)
813}

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