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 // Check container status
56 container, exists := s.containerSvc.Get(ctx, route.Domain)
57 if !exists || container == nil {
58 health.ContainerStatus = "not found"
59 health.Error = "container not found"
60 log.Debug().Msg("container not found")
61 return health
62 }
63
64 health.ContainerStatus = container.Status
65
66 // Only probe HTTP if container is running
67 if container.Status != string(domain.ContainerStatusRunning) {
68 health.Error = fmt.Sprintf("container is %s", container.Status)
69 log.Debug().Str("status", container.Status).Msg("container not running, skipping HTTP probe")
70 return health
71 }
72
73 // Probe HTTP endpoint
74 url := fmt.Sprintf("https://%s/", route.Domain)
75 statusCode, responseTime, err := s.prober.Probe(ctx, url)
76 if err != nil {
77 health.Error = err.Error()
78 log.Debug().Err(err).Str("url", url).Msg("HTTP probe failed")
79 return health
80 }
81
82 health.HTTPStatus = statusCode
83 health.ResponseTimeMs = responseTime
84 health.Healthy = statusCode >= 200 && statusCode < 400
85
86 log.Debug().
87 Int("http_status", statusCode).
88 Int64("response_time_ms", responseTime).
89 Bool("healthy", health.Healthy).
90 Msg("health check complete")
91
92 return health
93}
94
95// CheckAllRoutes performs health checks on all configured routes.
96func (s *Service) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth {
97 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
98 zerowrap.FieldLayer: "usecase",
99 zerowrap.FieldUseCase: "CheckAllRoutes",
100 })
101 log := zerowrap.FromCtx(ctx)
102
103 routes := s.configSvc.GetRoutes(ctx)
104 results := make(map[string]*domain.RouteHealth, len(routes))
105
106 if len(routes) == 0 {
107 log.Debug().Msg("no routes configured")
108 return results
109 }
110
111 // Check routes concurrently with semaphore to limit resource usage
112 var mu sync.Mutex
113 var wg sync.WaitGroup
114 sem := make(chan struct{}, maxConcurrentProbes)
115
116 for _, route := range routes {
117 wg.Add(1)
118 go func(r domain.Route) {
119 defer wg.Done()
120 sem <- struct{}{} // Acquire semaphore
121 defer func() { <-sem }() // Release semaphore
122
123 health := s.CheckRoute(ctx, r)
124 mu.Lock()
125 results[r.Domain] = health
126 mu.Unlock()
127 }(route)
128 }
129
130 wg.Wait()
131
132 log.Debug().Int("routes_checked", len(results)).Msg("all health checks complete")
133 return results
134}