feat: migrate receiver to Fuego framework with OpenAPI generation
All checks were successful
ci / ci (push) Successful in 2m2s
ci / goreleaser (push) Successful in 2m29s

Replace net/http handlers with Fuego framework for automatic OpenAPI 3.0
spec generation. Add generated Go client package, OpenAPI extraction
script, and update Makefile with separate build/run targets for both
binaries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Manuel Ganter 2026-02-18 11:12:14 +01:00
parent 479c13f596
commit bc9d0dd8ea
No known key found for this signature in database
11 changed files with 2245 additions and 252 deletions

View file

@ -1,15 +1,18 @@
// ABOUTME: HTTP handlers for the metrics receiver service.
// ABOUTME: Provides endpoints for receiving and querying metrics.
// ABOUTME: HTTP handlers for the metrics receiver service using Fuego framework.
// ABOUTME: Provides endpoints for receiving and querying metrics with automatic OpenAPI generation.
package receiver
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-fuego/fuego"
)
// Handler handles HTTP requests for the metrics receiver
@ -32,128 +35,175 @@ func NewHandler(store *Store, logger *slog.Logger, readToken, hmacKey string, to
return &Handler{store: store, logger: logger, readToken: readToken, hmacKey: hmacKey, tokenTTL: tokenTTL}
}
// RegisterRoutes registers all HTTP routes on the given mux
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/metrics", h.handleReceiveMetrics)
mux.HandleFunc("POST /api/v1/token", h.handleGenerateToken)
mux.HandleFunc("GET /api/v1/metrics/repo/{org}/{repo}/{workflow}/{job}", h.handleGetByWorkflowJob)
mux.HandleFunc("GET /api/v1/sizing/repo/{org}/{repo}/{workflow}/{job}", h.handleGetSizing)
mux.HandleFunc("GET /health", h.handleHealth)
// Common errors
var (
ErrUnauthorized = errors.New("authorization required")
ErrInvalidToken = errors.New("invalid token")
ErrInvalidFormat = errors.New("invalid authorization format")
ErrMissingHMACKey = errors.New("token generation requires a configured HMAC key")
ErrMissingFields = errors.New("organization, repository, workflow, and job are required")
ErrMissingRunID = errors.New("run_id is required")
ErrInvalidParams = errors.New("org, repo, workflow and job are required")
ErrNoMetrics = errors.New("no metrics found for this workflow/job")
ErrInvalidPercent = errors.New("invalid cpu_percentile: must be one of peak, p99, p95, p75, p50, avg")
)
// HealthResponse is the response for the health endpoint
type HealthResponse struct {
Status string `json:"status"`
}
// validateReadToken checks the Authorization header for a valid Bearer token.
func (h *Handler) validateReadToken(w http.ResponseWriter, r *http.Request) bool {
if h.readToken == "" {
h.logger.Warn("no read-token configured, rejecting request", slog.String("path", r.URL.Path))
http.Error(w, "authorization required", http.StatusUnauthorized)
return false
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
h.logger.Warn("missing authorization header", slog.String("path", r.URL.Path))
http.Error(w, "authorization required", http.StatusUnauthorized)
return false
}
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
h.logger.Warn("invalid authorization format", slog.String("path", r.URL.Path))
http.Error(w, "invalid authorization format", http.StatusUnauthorized)
return false
}
token := strings.TrimPrefix(authHeader, bearerPrefix)
if subtle.ConstantTimeCompare([]byte(token), []byte(h.readToken)) != 1 {
h.logger.Warn("invalid token", slog.String("path", r.URL.Path))
http.Error(w, "invalid token", http.StatusUnauthorized)
return false
}
return true
// MetricCreatedResponse is the response when a metric is successfully created
type MetricCreatedResponse struct {
ID uint `json:"id"`
Status string `json:"status"`
}
func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) {
if h.hmacKey == "" {
http.Error(w, "token generation requires a configured HMAC key", http.StatusBadRequest)
return
}
if !h.validateReadToken(w, r) {
return
}
var req TokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
if req.Organization == "" || req.Repository == "" || req.Workflow == "" || req.Job == "" {
http.Error(w, "organization, repository, workflow, and job are required", http.StatusBadRequest)
return
}
token := GenerateToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(TokenResponse{Token: token})
// GetMetricsRequest contains path parameters for getting metrics
type GetMetricsRequest struct {
Org string `path:"org"`
Repo string `path:"repo"`
Workflow string `path:"workflow"`
Job string `path:"job"`
}
// validatePushToken checks push authentication via scoped HMAC token.
func (h *Handler) validatePushToken(w http.ResponseWriter, r *http.Request, exec ExecutionContext) bool {
// GetSizingRequest contains path and query parameters for sizing endpoint
type GetSizingRequest struct {
Org string `path:"org"`
Repo string `path:"repo"`
Workflow string `path:"workflow"`
Job string `path:"job"`
Runs int `query:"runs" default:"5" validate:"min=1,max=100" description:"Number of recent runs to analyze"`
Buffer int `query:"buffer" default:"20" validate:"min=0,max=100" description:"Buffer percentage to add"`
CPUPercentile string `query:"cpu_percentile" default:"p95" description:"CPU percentile to use (peak, p99, p95, p75, p50, avg)"`
}
// RegisterRoutes registers all HTTP routes on the Fuego server
func (h *Handler) RegisterRoutes(s *fuego.Server) {
// Health endpoint (no auth)
fuego.Get(s, "/health", h.Health)
// API group with authentication
api := fuego.Group(s, "/api/v1")
// Token generation (requires read token)
fuego.Post(api, "/token", h.GenerateToken, fuego.OptionMiddleware(h.requireReadToken))
// Metrics endpoints
fuego.Post(api, "/metrics", h.ReceiveMetrics) // Uses push token validated in handler
fuego.Get(api, "/metrics/repo/{org}/{repo}/{workflow}/{job}", h.GetMetricsByWorkflowJob, fuego.OptionMiddleware(h.requireReadToken))
// Sizing endpoint
fuego.Get(api, "/sizing/repo/{org}/{repo}/{workflow}/{job}", h.GetSizing, fuego.OptionMiddleware(h.requireReadToken))
}
// requireReadToken is middleware that validates the read token
func (h *Handler) requireReadToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if h.readToken == "" {
h.logger.Warn("no read-token configured, rejecting request", slog.String("path", r.URL.Path))
http.Error(w, "authorization required", http.StatusUnauthorized)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
h.logger.Warn("missing authorization header", slog.String("path", r.URL.Path))
http.Error(w, "authorization required", http.StatusUnauthorized)
return
}
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
h.logger.Warn("invalid authorization format", slog.String("path", r.URL.Path))
http.Error(w, "invalid authorization format", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, bearerPrefix)
if subtle.ConstantTimeCompare([]byte(token), []byte(h.readToken)) != 1 {
h.logger.Warn("invalid token", slog.String("path", r.URL.Path))
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// validatePushToken checks push authentication via scoped HMAC token
func (h *Handler) validatePushToken(r *http.Request, exec ExecutionContext) error {
if h.hmacKey == "" {
h.logger.Warn("no HMAC key configured, rejecting push", slog.String("path", r.URL.Path))
http.Error(w, "authorization required", http.StatusUnauthorized)
return false
return ErrUnauthorized
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
h.logger.Warn("missing push authorization", slog.String("path", r.URL.Path))
http.Error(w, "authorization required", http.StatusUnauthorized)
return false
return ErrUnauthorized
}
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
h.logger.Warn("invalid push authorization format", slog.String("path", r.URL.Path))
http.Error(w, "invalid authorization format", http.StatusUnauthorized)
return false
return ErrInvalidFormat
}
token := strings.TrimPrefix(authHeader, bearerPrefix)
if !ValidateToken(h.hmacKey, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job, h.tokenTTL) {
h.logger.Warn("invalid push token", slog.String("path", r.URL.Path))
http.Error(w, "invalid token", http.StatusUnauthorized)
return false
return ErrInvalidToken
}
return true
return nil
}
func (h *Handler) handleReceiveMetrics(w http.ResponseWriter, r *http.Request) {
// Health returns the service health status
func (h *Handler) Health(c fuego.ContextNoBody) (HealthResponse, error) {
return HealthResponse{Status: "ok"}, nil
}
// GenerateToken generates a scoped HMAC push token for a workflow/job
func (h *Handler) GenerateToken(c fuego.ContextWithBody[TokenRequest]) (TokenResponse, error) {
if h.hmacKey == "" {
return TokenResponse{}, fuego.BadRequestError{Detail: ErrMissingHMACKey.Error()}
}
req, err := c.Body()
if err != nil {
return TokenResponse{}, fuego.BadRequestError{Detail: "invalid JSON body"}
}
if req.Organization == "" || req.Repository == "" || req.Workflow == "" || req.Job == "" {
return TokenResponse{}, fuego.BadRequestError{Detail: ErrMissingFields.Error()}
}
token := GenerateToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job)
return TokenResponse{Token: token}, nil
}
// ReceiveMetrics receives and stores metrics from a collector
func (h *Handler) ReceiveMetrics(c fuego.ContextNoBody) (MetricCreatedResponse, error) {
var payload MetricsPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {
h.logger.Error("failed to decode payload", slog.String("error", err.Error()))
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
return
return MetricCreatedResponse{}, fuego.BadRequestError{Detail: "invalid JSON payload"}
}
if payload.Execution.RunID == "" {
http.Error(w, "run_id is required", http.StatusBadRequest)
return
return MetricCreatedResponse{}, fuego.BadRequestError{Detail: ErrMissingRunID.Error()}
}
if !h.validatePushToken(w, r, payload.Execution) {
return
// Validate push token
if err := h.validatePushToken(c.Request(), payload.Execution); err != nil {
return MetricCreatedResponse{}, fuego.UnauthorizedError{Detail: err.Error()}
}
id, err := h.store.SaveMetric(&payload)
if err != nil {
h.logger.Error("failed to save metric", slog.String("error", err.Error()))
http.Error(w, "failed to save metric", http.StatusInternalServerError)
return
return MetricCreatedResponse{}, fuego.InternalServerError{Detail: "failed to save metric"}
}
h.logger.Info("metric saved",
@ -162,30 +212,25 @@ func (h *Handler) handleReceiveMetrics(w http.ResponseWriter, r *http.Request) {
slog.String("repository", payload.Execution.Repository),
)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{"id": id, "status": "created"})
c.SetStatus(http.StatusCreated)
return MetricCreatedResponse{ID: id, Status: "created"}, nil
}
func (h *Handler) handleGetByWorkflowJob(w http.ResponseWriter, r *http.Request) {
if !h.validateReadToken(w, r) {
return
}
// GetMetricsByWorkflowJob retrieves all metrics for a specific workflow/job
func (h *Handler) GetMetricsByWorkflowJob(c fuego.ContextNoBody) ([]MetricResponse, error) {
org := c.PathParam("org")
repo := c.PathParam("repo")
workflow := c.PathParam("workflow")
job := c.PathParam("job")
org := r.PathValue("org")
repo := r.PathValue("repo")
workflow := r.PathValue("workflow")
job := r.PathValue("job")
if org == "" || repo == "" || workflow == "" || job == "" {
http.Error(w, "org, repo, workflow and job are required", http.StatusBadRequest)
return
return nil, fuego.BadRequestError{Detail: ErrInvalidParams.Error()}
}
metrics, err := h.store.GetMetricsByWorkflowJob(org, repo, workflow, job)
if err != nil {
h.logger.Error("failed to get metrics", slog.String("error", err.Error()))
http.Error(w, "failed to get metrics", http.StatusInternalServerError)
return
return nil, fuego.InternalServerError{Detail: "failed to get metrics"}
}
// Convert to response type with Payload as JSON object
@ -194,67 +239,53 @@ func (h *Handler) handleGetByWorkflowJob(w http.ResponseWriter, r *http.Request)
response[i] = m.ToResponse()
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
return response, nil
}
func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// GetSizing computes Kubernetes resource sizing recommendations
func (h *Handler) GetSizing(c fuego.ContextNoBody) (SizingResponse, error) {
org := c.PathParam("org")
repo := c.PathParam("repo")
workflow := c.PathParam("workflow")
job := c.PathParam("job")
func (h *Handler) handleGetSizing(w http.ResponseWriter, r *http.Request) {
if !h.validateReadToken(w, r) {
return
}
org := r.PathValue("org")
repo := r.PathValue("repo")
workflow := r.PathValue("workflow")
job := r.PathValue("job")
if org == "" || repo == "" || workflow == "" || job == "" {
http.Error(w, "org, repo, workflow and job are required", http.StatusBadRequest)
return
return SizingResponse{}, fuego.BadRequestError{Detail: ErrInvalidParams.Error()}
}
// Parse query parameters with defaults
runs := parseIntQueryParam(r, "runs", 5, 1, 100)
buffer := parseIntQueryParam(r, "buffer", 20, 0, 100)
cpuPercentile := r.URL.Query().Get("cpu_percentile")
runs := parseIntQueryParamFromContext(c, "runs", 5, 1, 100)
buffer := parseIntQueryParamFromContext(c, "buffer", 20, 0, 100)
cpuPercentile := c.QueryParam("cpu_percentile")
if cpuPercentile == "" {
cpuPercentile = "p95"
}
if !IsValidPercentile(cpuPercentile) {
http.Error(w, "invalid cpu_percentile: must be one of peak, p99, p95, p75, p50, avg", http.StatusBadRequest)
return
return SizingResponse{}, fuego.BadRequestError{Detail: ErrInvalidPercent.Error()}
}
metrics, err := h.store.GetRecentMetricsByWorkflowJob(org, repo, workflow, job, runs)
if err != nil {
h.logger.Error("failed to get metrics", slog.String("error", err.Error()))
http.Error(w, "failed to get metrics", http.StatusInternalServerError)
return
return SizingResponse{}, fuego.InternalServerError{Detail: "failed to get metrics"}
}
if len(metrics) == 0 {
http.Error(w, "no metrics found for this workflow/job", http.StatusNotFound)
return
return SizingResponse{}, fuego.NotFoundError{Detail: ErrNoMetrics.Error()}
}
response, err := computeSizing(metrics, buffer, cpuPercentile)
if err != nil {
h.logger.Error("failed to compute sizing", slog.String("error", err.Error()))
http.Error(w, "failed to compute sizing", http.StatusInternalServerError)
return
return SizingResponse{}, fuego.InternalServerError{Detail: "failed to compute sizing"}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
return *response, nil
}
// parseIntQueryParam parses an integer query parameter with default, min, and max values
func parseIntQueryParam(r *http.Request, name string, defaultVal, minVal, maxVal int) int {
strVal := r.URL.Query().Get(name)
// parseIntQueryParamFromContext parses an integer query parameter with default, min, and max values
func parseIntQueryParamFromContext(c fuego.ContextNoBody, name string, defaultVal, minVal, maxVal int) int {
strVal := c.QueryParam(name)
if strVal == "" {
return defaultVal
}