Files
silo-server/internal/api/handlers/admin_diagnostics.go
Quick104andClaude Fable 5 4fa84a661a feat(diagnostics): client diagnostics server foundation
Implements slice 1 of docs/design/2026-07-19-client-diagnostics.md: the
versioned contract (schemas, fixtures, Go validator), storage-validated
diagnostics.uploads_enabled gate, account-scoped status endpoint, hardened
streaming multipart ingest with quota reservation and a receiving/ready/
failed report state machine, S3 streaming puts, acting-admin report API
(list/detail/download/delete with audit events), and the retention +
orphan-reconciliation cleanup task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XppCCycoaskCsW7ja1fZct
2026-07-20 11:13:52 -04:00

266 lines
7.9 KiB
Go

package handlers
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/diagnostics"
)
const diagnosticsDownloadExpiry = 15 * time.Minute
type AdminDiagnosticsService interface {
ListForAdmin(ctx context.Context, filters diagnostics.ListFilters) (diagnostics.ListResult, error)
GetReport(ctx context.Context, id string) (*diagnostics.Report, error)
PresignReportDownload(ctx context.Context, report *diagnostics.Report, expiry time.Duration) (string, error)
OpenReportDownload(ctx context.Context, report *diagnostics.Report) (io.ReadCloser, error)
DeleteReport(ctx context.Context, id string) (*diagnostics.Report, error)
}
type adminDiagnosticsEffectiveTTL interface {
EffectiveReportDownloadTTL(requested time.Duration) time.Duration
}
type diagnosticsDownloadURLResponse struct {
DownloadURL string `json:"download_url"`
ExpiresAt time.Time `json:"expires_at"`
}
func RegisterAdminDiagnosticsRoutes(r chi.Router, h *DiagnosticsHandler) {
if h == nil {
return
}
r.Route("/diagnostics/reports", func(r chi.Router) {
r.Get("/", h.HandleAdminListReports)
r.Get("/{id}", h.HandleAdminGetReport)
r.Get("/{id}/download", h.HandleAdminDownloadReport)
r.Delete("/{id}", h.HandleAdminDeleteReport)
})
}
func (h *DiagnosticsHandler) HandleAdminListReports(w http.ResponseWriter, r *http.Request) {
admin, ok := h.admin()
if !ok {
writeDiagnosticsAdminUnavailable(w)
return
}
filters, err := parseDiagnosticsReportListFilters(r)
if err != nil {
writeDiagnosticsAdminError(w, err)
return
}
result, err := admin.ListForAdmin(r.Context(), filters)
if err != nil {
writeDiagnosticsAdminError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *DiagnosticsHandler) HandleAdminGetReport(w http.ResponseWriter, r *http.Request) {
admin, ok := h.admin()
if !ok {
writeDiagnosticsAdminUnavailable(w)
return
}
report, err := admin.GetReport(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeDiagnosticsAdminError(w, err)
return
}
writeJSON(w, http.StatusOK, report)
}
func (h *DiagnosticsHandler) HandleAdminDownloadReport(w http.ResponseWriter, r *http.Request) {
admin, ok := h.admin()
if !ok {
writeDiagnosticsAdminUnavailable(w)
return
}
reportID := chi.URLParam(r, "id")
report, err := admin.GetReport(r.Context(), reportID)
if err != nil {
writeDiagnosticsAdminError(w, err)
return
}
if report.State != diagnostics.StateReady {
writeDiagnosticsAdminError(w, diagnostics.ErrReportNotReady)
return
}
ttl := diagnosticsEffectiveDownloadTTL(admin, diagnosticsDownloadExpiry)
if !diagnosticsProxyDownloadRequested(r) {
if url, err := admin.PresignReportDownload(r.Context(), report, ttl); err == nil && strings.TrimSpace(url) != "" {
h.auditDownload(r, report.ID)
writeJSON(w, http.StatusOK, diagnosticsDownloadURLResponse{
DownloadURL: url,
ExpiresAt: time.Now().UTC().Add(ttl),
})
return
}
}
body, err := admin.OpenReportDownload(r.Context(), report)
if err != nil {
writeDiagnosticsAdminError(w, err)
return
}
defer body.Close()
h.auditDownload(r, report.ID)
w.Header().Set("Content-Type", diagnostics.ReportDownloadContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", diagnosticsReportFilename(report)))
if report.BlobBytes != nil && *report.BlobBytes >= 0 {
w.Header().Set("Content-Length", strconv.FormatInt(*report.BlobBytes, 10))
}
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, body); err != nil {
h.diagnosticsLogger().WarnContext(r.Context(), "diagnostic report download stream failed",
"component", "diagnostics",
"admin_user_id", currentAdminUserID(r),
"report_id", report.ID,
"error", err,
)
}
}
func (h *DiagnosticsHandler) HandleAdminDeleteReport(w http.ResponseWriter, r *http.Request) {
admin, ok := h.admin()
if !ok {
writeDiagnosticsAdminUnavailable(w)
return
}
reportID := chi.URLParam(r, "id")
report, err := admin.DeleteReport(r.Context(), reportID)
if err != nil {
writeDiagnosticsAdminError(w, err)
return
}
h.diagnosticsLogger().InfoContext(r.Context(), "diagnostic report deleted",
"component", "diagnostics",
"admin_user_id", currentAdminUserID(r),
"report_id", report.ID,
)
w.WriteHeader(http.StatusNoContent)
}
func (h *DiagnosticsHandler) admin() (AdminDiagnosticsService, bool) {
if h.adminService == nil {
return nil, false
}
return h.adminService, true
}
func (h *DiagnosticsHandler) diagnosticsLogger() *slog.Logger {
if h.logger == nil {
return slog.Default()
}
return h.logger
}
func (h *DiagnosticsHandler) auditDownload(r *http.Request, reportID string) {
h.diagnosticsLogger().InfoContext(r.Context(), "diagnostic report downloaded",
"component", "diagnostics",
"admin_user_id", currentAdminUserID(r),
"report_id", reportID,
)
}
func parseDiagnosticsReportListFilters(r *http.Request) (diagnostics.ListFilters, error) {
filters := diagnostics.ListFilters{
Platform: strings.TrimSpace(r.URL.Query().Get("platform")),
ReportType: strings.TrimSpace(r.URL.Query().Get("report_type")),
Cursor: strings.TrimSpace(r.URL.Query().Get("cursor")),
Limit: parseLimit(r, 50),
}
userID, err := parseOptionalIntQuery(r, "user_id")
if err != nil {
return diagnostics.ListFilters{}, err
}
filters.UserID = userID
if from, err := parseTimeQuery(r, "from"); err != nil {
return diagnostics.ListFilters{}, err
} else {
filters.From = from
}
if to, err := parseTimeQuery(r, "to"); err != nil {
return diagnostics.ListFilters{}, err
} else {
filters.To = to
}
shortID := strings.TrimSpace(r.URL.Query().Get("short_id"))
if shortID != "" {
normalized, err := diagnostics.ParseShortID(shortID)
if err != nil {
return diagnostics.ListFilters{}, err
}
filters.ShortID = normalized
}
return filters, nil
}
func diagnosticsReportFilename(report *diagnostics.Report) string {
name := strings.TrimSpace(report.ShortID)
if name == "" {
name = strings.TrimSpace(report.ID)
}
if name == "" {
name = "report"
}
return "silo-diagnostics-" + name + ".tar.gz"
}
func diagnosticsProxyDownloadRequested(r *http.Request) bool {
raw := strings.TrimSpace(r.URL.Query().Get("proxy"))
return raw == "1" || strings.EqualFold(raw, "true") || strings.EqualFold(raw, "yes")
}
func diagnosticsEffectiveDownloadTTL(admin AdminDiagnosticsService, requested time.Duration) time.Duration {
ttl := requested
if effective, ok := admin.(adminDiagnosticsEffectiveTTL); ok {
ttl = effective.EffectiveReportDownloadTTL(requested)
}
if ttl <= 0 {
return requested
}
return ttl
}
func writeDiagnosticsAdminUnavailable(w http.ResponseWriter) {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Diagnostics reports are not configured")
}
func writeDiagnosticsAdminError(w http.ResponseWriter, err error) {
var parseErr *requestParseError
switch {
case errors.As(err, &parseErr):
writeError(w, http.StatusBadRequest, "bad_request", parseErr.Error())
case errors.Is(err, diagnostics.ErrInvalidShortID):
writeError(w, http.StatusBadRequest, "bad_request", "Invalid short_id")
case errors.Is(err, diagnostics.ErrInvalidCursor):
writeError(w, http.StatusBadRequest, "bad_request", "Invalid cursor")
case errors.Is(err, diagnostics.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "Diagnostic report not found")
case errors.Is(err, diagnostics.ErrReportNotReady):
writeError(w, http.StatusConflict, "not_ready", "Diagnostic report is not ready")
case errors.Is(err, diagnostics.ErrStorageUnavailable):
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not available")
case diagnostics.IsObjectNotFound(err):
writeError(w, http.StatusNotFound, "not_found", "Diagnostic report bundle not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Diagnostics report operation failed")
}
}