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}