1// Package health implements health checking over ACTIVE app services.
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/boundaries/out"
13 "github.com/bnema/gordon/internal/domain"
14)
15
16// maxConcurrentProbes limits the number of concurrent health probes to prevent resource exhaustion.
17const maxConcurrentProbes = 10
18
19// Service implements the HealthService interface over ACTIVE app state.
20// Probes dial recorded loopback backends rootless-first, never
21// container IPs.
22type Service struct {
23 state out.AppStateReader
24 runtime out.ContainerRuntime
25 prober in.HTTPProber
26 log zerowrap.Logger
27}
28
29// NewService creates a new health service.
30func NewService(
31 state out.AppStateReader,
32 runtime out.ContainerRuntime,
33 prober in.HTTPProber,
34 log zerowrap.Logger,
35) *Service {
36 return &Service{
37 state: state,
38 runtime: runtime,
39 prober: prober,
40 log: log,
41 }
42}
43
44// CheckAllRoutes performs health checks on all app-served HTTP hosts.
45// The result maps canonical host to health; stopped-intent apps are
46// skipped (not routable, not healthy).
47func (s *Service) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth {
48 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
49 zerowrap.FieldLayer: "usecase",
50 zerowrap.FieldUseCase: "CheckAllRoutes",
51 })
52 log := zerowrap.FromCtx(ctx)
53
54 targets := s.hostTargets(ctx)
55 results := make(map[string]*domain.RouteHealth, len(targets))
56
57 if len(targets) == 0 {
58 log.Debug().Msg("no app HTTP hosts to check")
59 return results
60 }
61
62 // Check hosts concurrently with semaphore to limit resource usage
63 var mu sync.Mutex
64 var wg sync.WaitGroup
65 sem := make(chan struct{}, maxConcurrentProbes)
66
67 for _, target := range targets {
68 wg.Add(1)
69 go func(t hostTarget) {
70 defer wg.Done()
71 sem <- struct{}{} // Acquire semaphore
72 defer func() { <-sem }() // Release semaphore
73
74 health := s.checkHost(ctx, t)
75 mu.Lock()
76 results[t.host] = health
77 mu.Unlock()
78 }(target)
79 }
80
81 wg.Wait()
82
83 log.Debug().Int("routes_checked", len(results)).Msg("all health checks complete")
84 return results
85}
86
87// hostTarget is one probed HTTP host.
88type hostTarget struct {
89 host string
90 backend domain.AppBackend
91}
92
93// hostTargets enumerates probed hosts from ACTIVE state, skipping
94// stopped-intent apps.
95func (s *Service) hostTargets(ctx context.Context) []hostTarget {
96 apps, err := s.state.ListApps(ctx)
97 if err != nil {
98 return nil
99 }
100 var targets []hostTarget
101 for _, app := range apps {
102 intent, err := s.state.LoadIntent(ctx, app)
103 if err != nil || intent.Stopped {
104 continue
105 }
106 active, ok, err := s.state.LoadActive(ctx, app)
107 if err != nil || !ok {
108 continue
109 }
110 for _, eff := range active.Services {
111 for _, h := range eff.Spec.HTTP {
112 if h.Host == "" {
113 continue
114 }
115 targets = append(targets, hostTarget{host: h.Host, backend: eff.BackendFor(h.Port)})
116 }
117 }
118 }
119 return targets
120}
121
122// checkHost probes one host's recorded loopback backend.
123func (s *Service) checkHost(ctx context.Context, target hostTarget) *domain.RouteHealth {
124 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
125 zerowrap.FieldLayer: "usecase",
126 zerowrap.FieldUseCase: "CheckHost",
127 "domain": target.host,
128 })
129 log := zerowrap.FromCtx(ctx)
130
131 health := &domain.RouteHealth{
132 Domain: target.host,
133 ContainerStatus: "unknown",
134 }
135
136 if !target.backend.Resolved() {
137 health.ContainerStatus = "not found"
138 health.Error = "no recorded backend bind for host"
139 log.Debug().Msg("host has no resolved backend")
140 return health
141 }
142
143 running, err := s.runtime.IsContainerRunning(ctx, target.backend.ContainerID)
144 if err != nil || !running {
145 health.ContainerStatus = "not running"
146 health.Error = "container not running"
147 log.Debug().Msg("container not running, skipping HTTP probe")
148 return health
149 }
150 health.ContainerStatus = string(domain.ContainerStatusRunning)
151
152 url := fmt.Sprintf("http://127.0.0.1:%d/", target.backend.Port)
153 statusCode, responseTime, err := s.prober.Probe(ctx, url)
154 if err != nil {
155 health.Error = err.Error()
156 log.Debug().Err(err).Str("url", url).Msg("HTTP probe failed")
157 return health
158 }
159
160 health.HTTPStatus = statusCode
161 health.ResponseTimeMs = responseTime
162 health.Healthy = statusCode >= 200 && statusCode < 400
163
164 log.Debug().
165 Int("http_status", statusCode).
166 Int64("response_time_ms", responseTime).
167 Bool("healthy", health.Healthy).
168 Msg("health check complete")
169
170 return health
171}