v2.31.0-dev18 7b9923f

service.go RawHistoryFolder

1// Package health implements the health check use case for routes.
2package health
3
4import (
5	"context"
6	"fmt"
7	"sync"
8
9	"github.com/bnema/zerowrap"
10
11	"github.com/bnema/gordon/internal/boundaries/in"
12	"github.com/bnema/gordon/internal/domain"
13)
14
15// maxConcurrentProbes limits the number of concurrent health probes to prevent resource exhaustion.
16const maxConcurrentProbes = 10
17
18// Service implements the HealthService interface.
19type Service struct {
20	configSvc    in.ConfigService
21	containerSvc in.ContainerService
22	prober       in.HTTPProber
23	log          zerowrap.Logger
24}
25
26// NewService creates a new health service.
27func NewService(
28	configSvc in.ConfigService,
29	containerSvc in.ContainerService,
30	prober in.HTTPProber,
31	log zerowrap.Logger,
32) *Service {
33	return &Service{
34		configSvc:    configSvc,
35		containerSvc: containerSvc,
36		prober:       prober,
37		log:          log,
38	}
39}
40
41// CheckRoute performs a health check on a single route.
42func (s *Service) CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth {
43	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
44		zerowrap.FieldLayer:   "usecase",
45		zerowrap.FieldUseCase: "CheckRoute",
46		"domain":              route.Domain,
47	})
48	log := zerowrap.FromCtx(ctx)
49
50	health := &domain.RouteHealth{
51		Domain:          route.Domain,
52		ContainerStatus: "unknown",
53	}
54
55	// Validate domain before probing - prevents SSRF via invalid route domains
56	if !domain.IsValidRouteDomain(route.Domain) {
57		health.Error = "invalid route domain for health probe"
58		log.Debug().Msg("health check blocked for invalid domain")
59		return health
60	}
61
62	// Check container status
63	container, exists := s.containerSvc.Get(ctx, route.Domain)
64	if !exists || container == nil {
65		health.ContainerStatus = "not found"
66		health.Error = "container not found"
67		log.Debug().Msg("container not found")
68		return health
69	}
70
71	health.ContainerStatus = container.Status
72
73	// Only probe HTTP if container is running
74	if container.Status != string(domain.ContainerStatusRunning) {
75		health.Error = fmt.Sprintf("container is %s", container.Status)
76		log.Debug().Str("status", container.Status).Msg("container not running, skipping HTTP probe")
77		return health
78	}
79
80	// Probe HTTP endpoint
81	scheme := "http"
82	if route.HTTPS {
83		scheme = "https"
84	}
85	url := fmt.Sprintf("%s://%s/", scheme, route.Domain)
86	statusCode, responseTime, err := s.prober.Probe(ctx, url)
87	if err != nil {
88		health.Error = err.Error()
89		log.Debug().Err(err).Str("url", url).Msg("HTTP probe failed")
90		return health
91	}
92
93	health.HTTPStatus = statusCode
94	health.ResponseTimeMs = responseTime
95	health.Healthy = statusCode >= 200 && statusCode < 400
96
97	log.Debug().
98		Int("http_status", statusCode).
99		Int64("response_time_ms", responseTime).
100		Bool("healthy", health.Healthy).
101		Msg("health check complete")
102
103	return health
104}
105
106// CheckAllRoutes performs health checks on all configured routes.
107func (s *Service) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth {
108	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
109		zerowrap.FieldLayer:   "usecase",
110		zerowrap.FieldUseCase: "CheckAllRoutes",
111	})
112	log := zerowrap.FromCtx(ctx)
113
114	routes := s.configSvc.GetRoutes(ctx)
115	results := make(map[string]*domain.RouteHealth, len(routes))
116
117	if len(routes) == 0 {
118		log.Debug().Msg("no routes configured")
119		return results
120	}
121
122	// Check routes concurrently with semaphore to limit resource usage
123	var mu sync.Mutex
124	var wg sync.WaitGroup
125	sem := make(chan struct{}, maxConcurrentProbes)
126
127	for _, route := range routes {
128		wg.Add(1)
129		go func(r domain.Route) {
130			defer wg.Done()
131			sem <- struct{}{}        // Acquire semaphore
132			defer func() { <-sem }() // Release semaphore
133
134			health := s.CheckRoute(ctx, r)
135			mu.Lock()
136			results[r.Domain] = health
137			mu.Unlock()
138		}(route)
139	}
140
141	wg.Wait()
142
143	log.Debug().Int("routes_checked", len(results)).Msg("all health checks complete")
144	return results
145}

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