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)) }