detached a340e3d

feat(proxy): support gordon.proxy.port label for port selection

- Check for gordon.proxy.port label on image to determine proxy port
- Fall back to first exposed port if label not present
- Use container.Image (actual running image) instead of route.Image
- Add debug logging for proxy target resolution
commit
a340e3d8f980a67d12f3bcfe7d5cf7efb088fcc5
author
brice · · 2026-01-14 06:23 UTC
parent
757fa05
signature
unsigned
1 file · +54 -5
M internal/usecase/proxy/service.go +54 -5
@@ -8,6 +8,7 @@ import (
88 "net/http/httputil"
99 "net/url"
1010 "os"
11+ "strconv"
1112 "strings"
1213 "sync"
1314
@@ -79,6 +80,12 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
7980 return
8081 }
8182
83+ log.Debug().
84+ Str("host", target.Host).
85+ Int("port", target.Port).
86+ Str("container_id", target.ContainerID).
87+ Msg("resolved proxy target")
88+
8289 s.proxyToTarget(w, r, target)
8390 }
8491
@@ -95,6 +102,11 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
95102 s.mu.RLock()
96103 if target, exists := s.targets[domainName]; exists {
97104 s.mu.RUnlock()
105+ log.Debug().
106+ Str("host", target.Host).
107+ Int("port", target.Port).
108+ Str("container_id", target.ContainerID).
109+ Msg("using cached proxy target")
98110 return target, nil
99111 }
100112 s.mu.RUnlock()
@@ -102,8 +114,10 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
102114 // Get container for this domain
103115 container, exists := s.containerSvc.Get(ctx, domainName)
104116 if !exists {
117+ log.Debug().Msg("container not found for domain")
105118 return nil, domain.ErrNoTargetAvailable
106119 }
120+ log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
107121
108122 // Build target based on runtime mode
109123 var target *domain.ProxyTarget
@@ -135,14 +149,16 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
135149 return nil, domain.ErrRouteNotFound
136150 }
137151
138- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, route.Image)
139- if err != nil || len(exposedPorts) == 0 {
140- return nil, log.WrapErr(err, "failed to get exposed ports")
152+ // Determine target port: check for label first, then fall back to first exposed port
153+ // Use container.Image (the actual running image) instead of route.Image (config shorthand)
154+ targetPort, err := s.getProxyPort(ctx, container.Image)
155+ if err != nil {
156+ return nil, log.WrapErr(err, "failed to determine proxy port")
141157 }
142158
143- hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, exposedPorts[0])
159+ hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
144160 if err != nil {
145- return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": exposedPorts[0]})
161+ return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
146162 }
147163
148164 target = &domain.ProxyTarget{
@@ -297,3 +313,36 @@ func (s *Service) isRunningInContainer() bool {
297313
298314 return false
299315 }
316+
317+// getProxyPort determines the port to proxy to for an image.
318+// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
319+func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
320+ log := zerowrap.FromCtx(ctx)
321+
322+ log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
323+
324+ // Check for explicit port label
325+ labels, err := s.runtime.GetImageLabels(ctx, imageRef)
326+ if err != nil {
327+ log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
328+ } else if portStr, ok := labels[domain.LabelProxyPort]; ok {
329+ port, err := strconv.Atoi(portStr)
330+ if err == nil && port > 0 {
331+ log.Debug().Int("port", port).Msg("using proxy port from image label")
332+ return port, nil
333+ }
334+ log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
335+ }
336+
337+ // Fall back to first exposed port
338+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
339+ if err != nil {
340+ return 0, err
341+ }
342+ if len(exposedPorts) == 0 {
343+ return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
344+ }
345+
346+ log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
347+ return exposedPorts[0], nil
348+}
@@ -8,6 +8,7 @@ import (
8 "net/http/httputil"8 "net/http/httputil"
9 "net/url"9 "net/url"
10 "os"10 "os"
11+ "strconv"
11 "strings"12 "strings"
12 "sync"13 "sync"
13 14
@@ -79,6 +80,12 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
79 return80 return
80 }81 }
81 82
83+ log.Debug().
84+ Str("host", target.Host).
85+ Int("port", target.Port).
86+ Str("container_id", target.ContainerID).
87+ Msg("resolved proxy target")
88+
82 s.proxyToTarget(w, r, target)89 s.proxyToTarget(w, r, target)
83 }90 }
84 91
@@ -95,6 +102,11 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
95 s.mu.RLock()102 s.mu.RLock()
96 if target, exists := s.targets[domainName]; exists {103 if target, exists := s.targets[domainName]; exists {
97 s.mu.RUnlock()104 s.mu.RUnlock()
105+ log.Debug().
106+ Str("host", target.Host).
107+ Int("port", target.Port).
108+ Str("container_id", target.ContainerID).
109+ Msg("using cached proxy target")
98 return target, nil110 return target, nil
99 }111 }
100 s.mu.RUnlock()112 s.mu.RUnlock()
@@ -102,8 +114,10 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
102 // Get container for this domain114 // Get container for this domain
103 container, exists := s.containerSvc.Get(ctx, domainName)115 container, exists := s.containerSvc.Get(ctx, domainName)
104 if !exists {116 if !exists {
117+ log.Debug().Msg("container not found for domain")
105 return nil, domain.ErrNoTargetAvailable118 return nil, domain.ErrNoTargetAvailable
106 }119 }
120+ log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
107 121
108 // Build target based on runtime mode122 // Build target based on runtime mode
109 var target *domain.ProxyTarget123 var target *domain.ProxyTarget
@@ -135,14 +149,16 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
135 return nil, domain.ErrRouteNotFound149 return nil, domain.ErrRouteNotFound
136 }150 }
137 151
138- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, route.Image)152+ // Determine target port: check for label first, then fall back to first exposed port
139- if err != nil || len(exposedPorts) == 0 {153+ // Use container.Image (the actual running image) instead of route.Image (config shorthand)
140- return nil, log.WrapErr(err, "failed to get exposed ports")154+ targetPort, err := s.getProxyPort(ctx, container.Image)
155+ if err != nil {
156+ return nil, log.WrapErr(err, "failed to determine proxy port")
141 }157 }
142 158
143- hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, exposedPorts[0])159+ hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
144 if err != nil {160 if err != nil {
145- return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": exposedPorts[0]})161+ return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
146 }162 }
147 163
148 target = &domain.ProxyTarget{164 target = &domain.ProxyTarget{
@@ -297,3 +313,36 @@ func (s *Service) isRunningInContainer() bool {
297 313
298 return false314 return false
299 }315 }
316+
317+// getProxyPort determines the port to proxy to for an image.
318+// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
319+func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
320+ log := zerowrap.FromCtx(ctx)
321+
322+ log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
323+
324+ // Check for explicit port label
325+ labels, err := s.runtime.GetImageLabels(ctx, imageRef)
326+ if err != nil {
327+ log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
328+ } else if portStr, ok := labels[domain.LabelProxyPort]; ok {
329+ port, err := strconv.Atoi(portStr)
330+ if err == nil && port > 0 {
331+ log.Debug().Int("port", port).Msg("using proxy port from image label")
332+ return port, nil
333+ }
334+ log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
335+ }
336+
337+ // Fall back to first exposed port
338+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
339+ if err != nil {
340+ return 0, err
341+ }
342+ if len(exposedPorts) == 0 {
343+ return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
344+ }
345+
346+ log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
347+ return exposedPorts[0], nil
348+}

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