health.go
⎇
Raw
1package handler
2
3import (
4 "context"
5 "encoding/json"
6 "maps"
7 "net/http"
8 "slices"
9 "strings"
10 "sync"
11 "time"
12
13 "vidarchive/internal/util"
14)
15
16// healthProbeTimeout caps the external version lookups so the endpoint always
17// answers promptly, even when a tool is wedged.
18const healthProbeTimeout = 3 * time.Second
19
20// toolCacheTTL bounds how stale the reported versions may be. /healthz is
21// public and each lookup forks a process, so without a cache anyone who can
22// reach the port costs the host three processes per request.
23const toolCacheTTL = time.Minute
24
25// toolCache holds the last tool version probe. The mutex is held across the
26// probe itself, so a burst of requests collapses into one set of forks.
27type toolCache struct {
28 mu sync.Mutex
29 at time.Time
30 results map[string]string
31}
32
33type healthResponse struct {
34 Status string `json:"status"`
35 Dependencies map[string]string `json:"dependencies"`
36}
37
38// Health reports whether the app can reach its database and external tools. It
39// returns 503 when the database is unreachable, so a container healthcheck or
40// orchestrator can act on it. A missing yt-dlp/ffmpeg is reported but does not
41// fail the check: the UI still works, only downloads are affected.
42func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
43 // Bound the whole probe: a health check that can hang is worse than useless,
44 // since an orchestrator reads a stuck request as "still starting".
45 ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout)
46 defer cancel()
47
48 resp := healthResponse{Status: "ok", Dependencies: make(map[string]string, 4)}
49
50 // The database is probed first because it alone decides the status code, and
51 // it must get the budget before a wedged tool can spend it — otherwise a slow
52 // yt-dlp reports the database as unreachable and the container is restarted
53 // for the wrong reason.
54 status := http.StatusOK
55 if err := h.downloadSvc.Ping(ctx); err != nil {
56 resp.Status = "unhealthy"
57 resp.Dependencies["database"] = "unreachable"
58 status = http.StatusServiceUnavailable
59 } else {
60 resp.Dependencies["database"] = "ok"
61 }
62
63 for tool, version := range h.toolVersions(ctx) {
64 resp.Dependencies[tool] = version
65 }
66
67 w.Header().Set("Content-Type", "application/json")
68 w.WriteHeader(status)
69 _ = json.NewEncoder(w).Encode(resp)
70}
71
72// toolVersions returns the external tools' versions, re-probing them at most
73// once per toolCacheTTL.
74func (h *Handler) toolVersions(ctx context.Context) map[string]string {
75 h.tools.mu.Lock()
76 defer h.tools.mu.Unlock()
77
78 if h.tools.results != nil && time.Since(h.tools.at) < toolCacheTTL {
79 return h.tools.results
80 }
81
82 results := map[string]string{
83 "yt-dlp": toolVersion(ctx, h.cfg.YTDLPPath, "--version"),
84 "ffmpeg": toolVersion(ctx, h.cfg.FFmpegPath, "-version"),
85 "ffprobe": toolVersion(ctx, h.cfg.FFprobePath, "-version"),
86 }
87 // A timed-out probe says nothing about the tool, so don't cache that verdict.
88 if !slices.Contains(slices.Collect(maps.Values(results)), "timed out") {
89 h.tools.results, h.tools.at = results, time.Now()
90 }
91 return results
92}
93
94// toolVersion returns the first line of the tool's version output. It reports
95// "not installed" when the binary is missing and "timed out" when it doesn't
96// answer within ctx.
97func toolVersion(ctx context.Context, path, versionArg string) string {
98 out, err := util.KillableCommand(ctx, path, versionArg).Output()
99 if err != nil {
100 if ctx.Err() != nil {
101 return "timed out"
102 }
103 return "not installed"
104 }
105 line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
106 // ffmpeg prints "ffmpeg version N-x ..."; keep the version token only.
107 if fields := strings.Fields(line); len(fields) >= 3 && fields[1] == "version" {
108 return fields[2]
109 }
110 return line
111}
112