service.go RawHistoryFolder

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}

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