v2.31.0-dev8 ec8c5b9

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

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