v2.1.0 0fb867d

service.go RawHistoryFolder

1// Package proxy implements the reverse proxy use case.
2package proxy
3
4import (
5	"context"
6	"fmt"
7	"net/http"
8	"net/http/httputil"
9	"net/url"
10	"os"
11	"strconv"
12	"strings"
13	"sync"
14
15	"github.com/bnema/zerowrap"
16
17	"gordon/internal/boundaries/in"
18	"gordon/internal/boundaries/out"
19	"gordon/internal/domain"
20)
21
22// Config holds configuration needed by the proxy service.
23type Config struct {
24	RegistryDomain string
25	RegistryPort   int
26}
27
28// Service implements the ProxyService interface.
29type Service struct {
30	runtime      out.ContainerRuntime
31	containerSvc in.ContainerService
32	configSvc    in.ConfigService
33	config       Config
34	targets      map[string]*domain.ProxyTarget
35	mu           sync.RWMutex
36}
37
38// NewService creates a new proxy service.
39func NewService(
40	runtime out.ContainerRuntime,
41	containerSvc in.ContainerService,
42	configSvc in.ConfigService,
43	config Config,
44) *Service {
45	return &Service{
46		runtime:      runtime,
47		containerSvc: containerSvc,
48		configSvc:    configSvc,
49		config:       config,
50		targets:      make(map[string]*domain.ProxyTarget),
51	}
52}
53
54// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
55func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
56	// Enrich request context with fields for downstream logging
57	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
58		zerowrap.FieldLayer:    "adapter",
59		zerowrap.FieldAdapter:  "http",
60		zerowrap.FieldMethod:   r.Method,
61		zerowrap.FieldPath:     r.URL.Path,
62		zerowrap.FieldHost:     r.Host,
63		zerowrap.FieldClientIP: r.RemoteAddr,
64	})
65	r = r.WithContext(ctx)
66	log := zerowrap.FromCtx(ctx)
67
68	// Check if this is the registry domain
69	if s.config.RegistryDomain != "" && r.Host == s.config.RegistryDomain {
70		log.Info().Msg("routing request to registry")
71		s.proxyToRegistry(w, r)
72		return
73	}
74
75	// Get target for this domain
76	target, err := s.GetTarget(ctx, r.Host)
77	if err != nil {
78		log.Warn().Err(err).Msg("no route found for domain")
79		http.NotFound(w, r)
80		return
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
89	s.proxyToTarget(w, r, target)
90}
91
92// GetTarget returns the proxy target for a given domain.
93func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) {
94	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
95		zerowrap.FieldLayer:   "usecase",
96		zerowrap.FieldUseCase: "GetTarget",
97		"domain":              domainName,
98	})
99	log := zerowrap.FromCtx(ctx)
100
101	// Check cache first
102	s.mu.RLock()
103	if target, exists := s.targets[domainName]; exists {
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")
110		return target, nil
111	}
112	s.mu.RUnlock()
113
114	// Get container for this domain
115	container, exists := s.containerSvc.Get(ctx, domainName)
116	if !exists {
117		log.Debug().Msg("container not found for domain")
118		return nil, domain.ErrNoTargetAvailable
119	}
120	log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
121
122	// Build target based on runtime mode
123	var target *domain.ProxyTarget
124
125	if s.isRunningInContainer() {
126		// Gordon is in a container - use container network
127		containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
128		if err != nil {
129			return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
130		}
131		target = &domain.ProxyTarget{
132			Host:        containerIP,
133			Port:        containerPort,
134			ContainerID: container.ID,
135			Scheme:      "http",
136		}
137	} else {
138		// Gordon is on the host - use host port mapping
139		routes := s.configSvc.GetRoutes(ctx)
140		var route *domain.Route
141		for _, r := range routes {
142			if r.Domain == domainName {
143				route = &r
144				break
145			}
146		}
147
148		if route == nil {
149			return nil, domain.ErrRouteNotFound
150		}
151
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")
157		}
158
159		hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
160		if err != nil {
161			return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
162		}
163
164		target = &domain.ProxyTarget{
165			Host:        "localhost",
166			Port:        hostPort,
167			ContainerID: container.ID,
168			Scheme:      "http",
169		}
170	}
171
172	// Cache the target
173	s.mu.Lock()
174	s.targets[domainName] = target
175	s.mu.Unlock()
176
177	return target, nil
178}
179
180// RegisterTarget registers a new proxy target for a domain.
181func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
182	s.mu.Lock()
183	defer s.mu.Unlock()
184
185	s.targets[domainName] = target
186	return nil
187}
188
189// UnregisterTarget removes a proxy target for a domain.
190func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
191	s.mu.Lock()
192	defer s.mu.Unlock()
193
194	delete(s.targets, domainName)
195	return nil
196}
197
198// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
199// This is used during zero-downtime deployments to switch traffic to a new container.
200func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
201	s.mu.Lock()
202	defer s.mu.Unlock()
203
204	delete(s.targets, domainName)
205}
206
207// RefreshTargets refreshes all proxy targets from container state.
208func (s *Service) RefreshTargets(ctx context.Context) error {
209	s.mu.Lock()
210	s.targets = make(map[string]*domain.ProxyTarget)
211	s.mu.Unlock()
212
213	log := zerowrap.FromCtx(ctx)
214	log.Debug().Msg("proxy targets cache cleared")
215	return nil
216}
217
218// UpdateConfig updates the service configuration.
219func (s *Service) UpdateConfig(config Config) {
220	s.config = config
221}
222
223// Helper methods
224
225func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
226	log := zerowrap.FromCtx(r.Context())
227
228	targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))
229	if err != nil {
230		log.WrapErr(err, "failed to parse target URL")
231		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
232		return
233	}
234
235	proxy := httputil.NewSingleHostReverseProxy(targetURL)
236
237	proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
238		log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error")
239		http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
240	}
241
242	proxy.ModifyResponse = func(resp *http.Response) error {
243		resp.Header.Set("X-Proxied-By", "Gordon")
244		resp.Header.Set("X-Container-ID", target.ContainerID)
245		return nil
246	}
247
248	log.Debug().
249		Str("target", targetURL.String()).
250		Str(zerowrap.FieldEntityID, target.ContainerID).
251		Msg("proxying request")
252
253	proxy.ServeHTTP(w, r)
254}
255
256func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
257	log := zerowrap.FromCtx(r.Context())
258
259	targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", s.config.RegistryPort))
260	if err != nil {
261		log.WrapErr(err, "failed to parse registry target URL")
262		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
263		return
264	}
265
266	proxy := httputil.NewSingleHostReverseProxy(targetURL)
267
268	proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
269		log.Error().Err(err).Int("registry_port", s.config.RegistryPort).Msg("registry proxy error")
270		http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
271	}
272
273	proxy.ModifyResponse = func(resp *http.Response) error {
274		resp.Header.Set("X-Proxied-By", "Gordon")
275		resp.Header.Set("X-Registry-Backend", "gordon-registry")
276		return nil
277	}
278
279	log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
280
281	proxy.ServeHTTP(w, r)
282}
283
284func (s *Service) isRunningInContainer() bool {
285	// Check for /.dockerenv
286	if _, err := os.Stat("/.dockerenv"); err == nil {
287		return true
288	}
289
290	// Check cgroup for container indicators
291	if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
292		content := string(data)
293		if strings.Contains(content, "docker") ||
294			strings.Contains(content, "containerd") ||
295			strings.Contains(content, "podman") {
296			return true
297		}
298	}
299
300	// Check hostname pattern
301	if hostname, err := os.Hostname(); err == nil {
302		if len(hostname) == 12 || len(hostname) == 64 {
303			return true
304		}
305	}
306
307	// Check environment variables
308	if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
309		os.Getenv("DOCKER_CONTAINER") != "" ||
310		os.Getenv("container") != "" {
311		return true
312	}
313
314	return false
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.
319func (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