Files
syncbot/internal/manager/http.go
T
iceBear67 a006483bbc
ci / test (push) Canceled after 0s
docker / build (push) Canceled after 0s
init
2026-08-14 07:13:22 +00:00

153 lines
4.7 KiB
Go

package manager
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)
// Handler exposes the manager over HTTP:
//
// /healthz the process is alive (use this for a container health check)
// /readyz every repo has synced successfully at least once
// /status JSON snapshot of every repo
// /metrics Prometheus text format
func (m *Manager) Handler(version string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeText(w, http.StatusOK, "ok\n")
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
var pending []string
for _, s := range m.Statuses() {
if s.LastSuccess.IsZero() {
pending = append(pending, s.Name)
}
}
if len(pending) > 0 {
writeText(w, http.StatusServiceUnavailable,
"awaiting first successful sync: "+strings.Join(pending, ", ")+"\n")
return
}
writeText(w, http.StatusOK, "ready\n")
})
mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) {
statuses := m.Statuses()
body := struct {
Version string `json:"version"`
Uptime string `json:"uptime"`
Repos []Status `json:"repos"`
Healthy bool `json:"healthy"`
Failing int `json:"failing"`
Reported string `json:"reported_at"`
}{
Version: version,
Uptime: m.Uptime().Round(time.Second).String(),
Repos: statuses,
Healthy: true,
Reported: time.Now().UTC().Format(time.RFC3339),
}
for _, s := range statuses {
if s.Failures > 0 {
body.Failing++
body.Healthy = false
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(body)
})
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
var b strings.Builder
fmt.Fprintf(&b, "# HELP syncbot_build_info Version of the running binary.\n")
fmt.Fprintf(&b, "# TYPE syncbot_build_info gauge\n")
fmt.Fprintf(&b, "syncbot_build_info{version=%q} 1\n", version)
fmt.Fprintf(&b, "# HELP syncbot_uptime_seconds Time since start.\n")
fmt.Fprintf(&b, "# TYPE syncbot_uptime_seconds gauge\n")
fmt.Fprintf(&b, "syncbot_uptime_seconds %s\n", seconds(m.Uptime()))
metric(&b, "syncbot_sync_total", "counter", "Sync cycles started.")
for _, s := range m.Statuses() {
fmt.Fprintf(&b, "syncbot_sync_total{repo=%q} %d\n", s.Name, s.Syncs)
}
metric(&b, "syncbot_push_total", "counter", "Cycles that pushed to dst.")
for _, s := range m.Statuses() {
fmt.Fprintf(&b, "syncbot_push_total{repo=%q} %d\n", s.Name, s.Pushes)
}
metric(&b, "syncbot_consecutive_failures", "gauge", "Failed cycles since the last success.")
for _, s := range m.Statuses() {
fmt.Fprintf(&b, "syncbot_consecutive_failures{repo=%q} %d\n", s.Name, s.Failures)
}
metric(&b, "syncbot_refs", "gauge", "Mirrored refs.")
for _, s := range m.Statuses() {
fmt.Fprintf(&b, "syncbot_refs{repo=%q} %d\n", s.Name, s.Refs)
}
metric(&b, "syncbot_last_success_timestamp_seconds", "gauge", "Unix time of the last successful sync.")
for _, s := range m.Statuses() {
fmt.Fprintf(&b, "syncbot_last_success_timestamp_seconds{repo=%q} %d\n", s.Name, unix(s.LastSuccess))
}
metric(&b, "syncbot_last_duration_seconds", "gauge", "Duration of the last sync cycle.")
for _, s := range m.Statuses() {
fmt.Fprintf(&b, "syncbot_last_duration_seconds{repo=%q} %s\n", s.Name,
strconv.FormatFloat(float64(s.LastDurMS)/1000, 'f', 3, 64))
}
_, _ = w.Write([]byte(b.String()))
})
return mux
}
// Serve runs the HTTP endpoint until ctx is cancelled.
func (m *Manager) Serve(ctx context.Context, addr, version string, log *slog.Logger) error {
srv := &http.Server{
Addr: addr,
Handler: m.Handler(version),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutdown)
}()
log.Info("http listening", "addr", addr,
"endpoints", "/healthz /readyz /status /metrics")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
func metric(b *strings.Builder, name, kind, help string) {
fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s %s\n", name, help, name, kind)
}
func seconds(d time.Duration) string {
return strconv.FormatFloat(d.Seconds(), 'f', 3, 64)
}
func unix(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.Unix()
}
func writeText(w http.ResponseWriter, code int, body string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(code)
_, _ = w.Write([]byte(body))
}