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

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,11 +1,10 @@
# ABOUTME: Makefile for forgejo-runner-sizer project.
# ABOUTME: Provides targets for building, formatting, linting, and testing.
BINARY_NAME := sizer
CMD_PATH := ./cmd/collector
GO := go
GOLANGCI_LINT := $(GO) run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2
GITLEAKS := $(GO) run github.com/zricethezav/gitleaks/v8@v8.30.0
OAPI_CODEGEN := $(GO) run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
# Build flags
LDFLAGS := -s -w
@ -13,18 +12,23 @@ BUILD_FLAGS := -ldflags "$(LDFLAGS)"
default: run
.PHONY: all build clean fmt format lint gitleaks test run help vet tidy install-hooks
.PHONY: all build build-collector build-receiver clean fmt format lint gitleaks test run-collector run-receiver help vet tidy install-hooks openapi generate-client
# Default target
all: fmt vet lint build
## Build targets
build: ## Build the binary
$(GO) build $(BUILD_FLAGS) -o $(BINARY_NAME) $(CMD_PATH)
build: build-collector build-receiver ## Build both binaries
build-collector: ## Build the collector binary
$(GO) build $(BUILD_FLAGS) -o collector ./cmd/collector
build-receiver: ## Build the receiver binary
$(GO) build $(BUILD_FLAGS) -o receiver ./cmd/receiver
clean: ## Remove build artifacts
rm -f $(BINARY_NAME) coverage.out coverage.html
rm -f collector receiver coverage.out coverage.html
$(GO) clean
## Code quality targets
@ -46,6 +50,16 @@ gitleaks: ## Check for secrets in git history
gitleaks-all: ## Check for secrets in git history
$(GITLEAKS) git .
## OpenAPI / Client Generation
openapi: ## Generate OpenAPI spec from Fuego routes
$(GO) run scripts/extract-openapi/main.go
generate-client: openapi ## Generate Go client from OpenAPI spec
rm -rf pkg/client
mkdir -p pkg/client
$(OAPI_CODEGEN) -generate types,client -package client docs/openapi.json > pkg/client/client.gen.go
## Dependency management
tidy: ## Tidy go modules
@ -62,11 +76,11 @@ test-coverage: ## Run tests with coverage
## Run targets
run: build ## Build and run with default settings
./$(BINARY_NAME)
run-collector: build-collector ## Build and run the collector
./collector
run-text: build ## Build and run with text output format
./$(BINARY_NAME) --log-format text --interval 2s
run-receiver: build-receiver ## Build and run the receiver
./receiver --read-token=secure-read-token --hmac-key=secure-hmac-key
## Git hooks

View file

@ -1,16 +1,17 @@
// ABOUTME: Entry point for the metrics receiver service.
// ABOUTME: HTTP service using Fuego framework with automatic OpenAPI 3.0 generation.
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/getkin/kin-openapi/openapi3"
"github.com/go-fuego/fuego"
"edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver"
)
@ -39,42 +40,44 @@ func main() {
defer func() { _ = store.Close() }()
handler := receiver.NewHandler(store, logger, *readToken, *hmacKey, *tokenTTL)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
server := &http.Server{
Addr: *addr,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
// Create Fuego server with OpenAPI configuration
s := fuego.NewServer(
fuego.WithAddr(*addr),
fuego.WithEngineOptions(
fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{
PrettyFormatJSON: true,
JSONFilePath: "docs/openapi.json",
SwaggerURL: "/swagger",
Info: &openapi3.Info{
Title: "Forgejo Runner Resource Collector API",
Version: "1.0.0",
Description: "HTTP service that receives and stores CI/CD resource metrics from collectors, providing query and sizing recommendation APIs.",
Contact: &openapi3.Contact{
Name: "API Support",
URL: "https://edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer",
},
License: &openapi3.License{
Name: "Apache 2.0",
URL: "http://www.apache.org/licenses/LICENSE-2.0.html",
},
},
}),
),
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigChan
logger.Info("received signal, shutting down", slog.String("signal", sig.String()))
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
_ = server.Shutdown(shutdownCtx)
}()
// Register routes
handler.RegisterRoutes(s)
logger.Info("starting metrics receiver",
slog.String("addr", *addr),
slog.String("db", *dbPath),
slog.String("swagger", fmt.Sprintf("http://localhost%s/swagger", *addr)),
)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
// Run server (handles graceful shutdown)
if err := s.Run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
<-ctx.Done()
logger.Info("receiver stopped gracefully")
}

665
docs/openapi.json Normal file
View file

@ -0,0 +1,665 @@
{
"components": {
"schemas": {
"HTTPError": {
"description": "HTTPError schema",
"properties": {
"detail": {
"description": "Human readable error message",
"nullable": true,
"type": "string"
},
"errors": {
"items": {
"nullable": true,
"properties": {
"more": {
"additionalProperties": {
"description": "Additional information about the error",
"nullable": true
},
"description": "Additional information about the error",
"nullable": true,
"type": "object"
},
"name": {
"description": "For example, name of the parameter that caused the error",
"type": "string"
},
"reason": {
"description": "Human readable error message",
"type": "string"
}
},
"type": "object"
},
"nullable": true,
"type": "array"
},
"instance": {
"nullable": true,
"type": "string"
},
"status": {
"description": "HTTP status code",
"example": 403,
"nullable": true,
"type": "integer"
},
"title": {
"description": "Short title of the error",
"nullable": true,
"type": "string"
},
"type": {
"description": "URL of the error type. Can be used to lookup the error in a documentation",
"nullable": true,
"type": "string"
}
},
"type": "object"
},
"HealthResponse": {
"description": "HealthResponse schema",
"properties": {
"status": {
"type": "string"
}
},
"type": "object"
},
"MetricCreatedResponse": {
"description": "MetricCreatedResponse schema",
"properties": {
"id": {
"minimum": 0,
"type": "integer"
},
"status": {
"type": "string"
}
},
"type": "object"
},
"MetricResponse": {
"description": "MetricResponse schema",
"properties": {
"id": {
"minimum": 0,
"type": "integer"
},
"job": {
"type": "string"
},
"organization": {
"type": "string"
},
"payload": {},
"received_at": {
"format": "date-time",
"type": "string"
},
"repository": {
"type": "string"
},
"run_id": {
"type": "string"
},
"workflow": {
"type": "string"
}
},
"type": "object"
},
"SizingResponse": {
"description": "SizingResponse schema",
"properties": {
"containers": {
"items": {
"properties": {
"cpu": {
"properties": {
"limit": {
"type": "string"
},
"request": {
"type": "string"
}
},
"type": "object"
},
"memory": {
"properties": {
"limit": {
"type": "string"
},
"request": {
"type": "string"
}
},
"type": "object"
},
"name": {
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"meta": {
"properties": {
"buffer_percent": {
"type": "integer"
},
"cpu_percentile": {
"type": "string"
},
"runs_analyzed": {
"type": "integer"
}
},
"type": "object"
},
"total": {
"properties": {
"cpu": {
"properties": {
"limit": {
"type": "string"
},
"request": {
"type": "string"
}
},
"type": "object"
},
"memory": {
"properties": {
"limit": {
"type": "string"
},
"request": {
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
}
},
"type": "object"
},
"TokenRequest": {
"description": "TokenRequest schema",
"properties": {
"job": {
"type": "string"
},
"organization": {
"type": "string"
},
"repository": {
"type": "string"
},
"workflow": {
"type": "string"
}
},
"type": "object"
},
"TokenResponse": {
"description": "TokenResponse schema",
"properties": {
"token": {
"type": "string"
}
},
"type": "object"
},
"unknown-interface": {
"description": "unknown-interface schema"
}
}
},
"info": {
"contact": {
"name": "API Support",
"url": "https://edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer"
},
"description": "HTTP service that receives and stores CI/CD resource metrics from collectors, providing query and sizing recommendation APIs.",
"license": {
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
},
"title": "Forgejo Runner Resource Collector API",
"version": "1.0.0"
},
"openapi": "3.1.0",
"paths": {
"/api/v1/metrics": {
"post": {
"description": "#### Controller: \n\n`edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).ReceiveMetrics`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\n",
"operationId": "POST_/api/v1/metrics",
"parameters": [
{
"in": "header",
"name": "Accept",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetricCreatedResponse"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/MetricCreatedResponse"
}
}
},
"description": "OK"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
}
},
"description": "Bad Request _(validation or deserialization error)_"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
}
},
"description": "Internal Server Error _(panics)_"
},
"default": {
"description": ""
}
},
"summary": "receive metrics",
"tags": [
"api/v1"
]
}
},
"/api/v1/metrics/repo/{org}/{repo}/{workflow}/{job}": {
"get": {
"description": "#### Controller: \n\n`edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).GetMetricsByWorkflowJob`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).requireReadToken`\n\n---\n\n",
"operationId": "GET_/api/v1/metrics/repo/:org/:repo/:workflow/:job",
"parameters": [
{
"in": "header",
"name": "Accept",
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "org",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "repo",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "workflow",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "job",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/MetricResponse"
},
"type": "array"
}
},
"application/xml": {
"schema": {
"items": {
"$ref": "#/components/schemas/MetricResponse"
},
"type": "array"
}
}
},
"description": "OK"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
}
},
"description": "Bad Request _(validation or deserialization error)_"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
}
},
"description": "Internal Server Error _(panics)_"
},
"default": {
"description": ""
}
},
"summary": "get metrics by workflow job",
"tags": [
"api/v1"
]
}
},
"/api/v1/sizing/repo/{org}/{repo}/{workflow}/{job}": {
"get": {
"description": "#### Controller: \n\n`edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).GetSizing`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).requireReadToken`\n\n---\n\n",
"operationId": "GET_/api/v1/sizing/repo/:org/:repo/:workflow/:job",
"parameters": [
{
"in": "header",
"name": "Accept",
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "org",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "repo",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "workflow",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "job",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SizingResponse"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/SizingResponse"
}
}
},
"description": "OK"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
}
},
"description": "Bad Request _(validation or deserialization error)_"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
}
},
"description": "Internal Server Error _(panics)_"
},
"default": {
"description": ""
}
},
"summary": "get sizing",
"tags": [
"api/v1"
]
}
},
"/api/v1/token": {
"post": {
"description": "#### Controller: \n\n`edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).GenerateToken`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).requireReadToken`\n\n---\n\n",
"operationId": "POST_/api/v1/token",
"parameters": [
{
"in": "header",
"name": "Accept",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/TokenRequest"
}
}
},
"description": "Request body for receiver.TokenRequest",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TokenResponse"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/TokenResponse"
}
}
},
"description": "OK"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
}
},
"description": "Bad Request _(validation or deserialization error)_"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
}
},
"description": "Internal Server Error _(panics)_"
},
"default": {
"description": ""
}
},
"summary": "generate token",
"tags": [
"api/v1"
]
}
},
"/health": {
"get": {
"description": "#### Controller: \n\n`edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).Health`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\n",
"operationId": "GET_/health",
"parameters": [
{
"in": "header",
"name": "Accept",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HealthResponse"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/HealthResponse"
}
}
},
"description": "OK"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
}
},
"description": "Bad Request _(validation or deserialization error)_"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/HTTPError"
}
}
},
"description": "Internal Server Error _(panics)_"
},
"default": {
"description": ""
}
},
"summary": "health"
}
}
}
}

33
go.mod
View file

@ -3,20 +3,45 @@ module edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer
go 1.25.6
require (
github.com/getkin/kin-openapi v0.133.0
github.com/glebarez/sqlite v1.11.0
github.com/go-fuego/fuego v0.19.0
github.com/oapi-codegen/runtime v1.1.2
gorm.io/gorm v1.31.1
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/text v0.20.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.31.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect

100
go.sum
View file

@ -1,27 +1,109 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-fuego/fuego v0.19.0 h1:kxkkBsrbGZP1YnPCAPIdUpMu53nreqN8N86lfi50CJw=
github.com/go-fuego/fuego v0.19.0/go.mod h1:O7CLZbvCCBA9ijhN/q8SnyFTzDdMsqYZjUbR82VDHhA=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/thejerf/slogassert v0.3.4 h1:VoTsXixRbXMrRSSxDjYTiEDCM4VWbsYPW5rB/hX24kM=
github.com/thejerf/slogassert v0.3.4/go.mod h1:0zn9ISLVKo1aPMTqcGfG1o6dWwt+Rk574GlUxHD4rs8=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=

View file

@ -14,6 +14,8 @@ import (
"testing"
"time"
"github.com/go-fuego/fuego"
"edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver"
"edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/summary"
)
@ -33,10 +35,17 @@ func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func())
}
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), testReadToken, testHMACKey, 0)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
s := fuego.NewServer(
fuego.WithoutStartupMessages(),
fuego.WithEngineOptions(
fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{
Disabled: true,
}),
),
)
handler.RegisterRoutes(s)
server := httptest.NewServer(mux)
server := httptest.NewServer(s.Mux)
cleanup := func() {
server.Close()
@ -372,10 +381,17 @@ func setupTestReceiverWithToken(t *testing.T, readToken, hmacKey string) (*recei
}
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), readToken, hmacKey, 0)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
s := fuego.NewServer(
fuego.WithoutStartupMessages(),
fuego.WithEngineOptions(
fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{
Disabled: true,
}),
),
)
handler.RegisterRoutes(s)
server := httptest.NewServer(mux)
server := httptest.NewServer(s.Mux)
cleanup := func() {
server.Close()

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
}

View file

@ -11,6 +11,8 @@ import (
"strings"
"testing"
"github.com/go-fuego/fuego"
"edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/summary"
)
@ -42,9 +44,8 @@ func TestHandler_ReceiveMetrics(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+pushToken)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("status = %d, want %d", rec.Code, http.StatusCreated)
@ -69,9 +70,8 @@ func TestHandler_ReceiveMetrics_InvalidJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader([]byte("not json")))
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
@ -95,9 +95,8 @@ func TestHandler_ReceiveMetrics_MissingRunID(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader(body))
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
@ -125,9 +124,8 @@ func TestHandler_GetByWorkflowJob(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+readToken)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
@ -151,9 +149,8 @@ func TestHandler_GetByWorkflowJob_NotFound(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+readToken)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
@ -180,8 +177,7 @@ func TestHandler_GetByWorkflowJob_WithToken(t *testing.T) {
t.Fatalf("SaveMetric() error = %v", err)
}
mux := http.NewServeMux()
h.RegisterRoutes(mux)
s := newTestServer(h)
tests := []struct {
name string
@ -201,7 +197,7 @@ func TestHandler_GetByWorkflowJob_WithToken(t *testing.T) {
req.Header.Set("Authorization", tt.authHeader)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
s.Mux.ServeHTTP(rec, req)
if rec.Code != tt.wantCode {
t.Errorf("status = %d, want %d", rec.Code, tt.wantCode)
@ -217,9 +213,8 @@ func TestHandler_Health(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
@ -250,9 +245,8 @@ func TestHandler_GenerateToken(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
@ -289,9 +283,8 @@ func TestHandler_GenerateToken_NoAuth(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/token", bytes.NewReader(body))
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
@ -314,9 +307,8 @@ func TestHandler_GenerateToken_MissingFields(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
@ -338,12 +330,12 @@ func TestHandler_GenerateToken_NoReadToken(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
// With no read token, the middleware rejects before we reach the handler
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
@ -352,8 +344,7 @@ func TestHandler_ReceiveMetrics_WithPushToken(t *testing.T) {
h, cleanup := newTestHandlerWithToken(t, readToken)
defer cleanup()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
s := newTestServer(h)
exec := ExecutionContext{
Organization: "org",
@ -391,7 +382,7 @@ func TestHandler_ReceiveMetrics_WithPushToken(t *testing.T) {
req.Header.Set("Authorization", tt.authHeader)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
s.Mux.ServeHTTP(rec, req)
if rec.Code != tt.wantCode {
t.Errorf("status = %d, want %d", rec.Code, tt.wantCode)
@ -420,9 +411,8 @@ func TestHandler_ReceiveMetrics_RejectsWhenNoReadToken(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
@ -436,15 +426,27 @@ func TestHandler_GetByWorkflowJob_RejectsWhenNoReadToken(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/repo/org/repo/ci.yml/build", nil)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
func newTestServer(h *Handler) *fuego.Server {
s := fuego.NewServer(
fuego.WithoutStartupMessages(),
fuego.WithEngineOptions(
fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{
Disabled: true,
}),
),
)
h.RegisterRoutes(s)
return s
}
func newTestHandler(t *testing.T) (*Handler, func()) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")

View file

@ -342,9 +342,8 @@ func TestHandler_GetSizing(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+readToken)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
@ -396,9 +395,8 @@ func TestHandler_GetSizing_CustomParams(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+readToken)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
@ -432,9 +430,8 @@ func TestHandler_GetSizing_NotFound(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+readToken)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound)
@ -450,9 +447,8 @@ func TestHandler_GetSizing_InvalidPercentile(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+readToken)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
s := newTestServer(h)
s.Mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
@ -474,8 +470,7 @@ func TestHandler_GetSizing_AuthRequired(t *testing.T) {
{"valid token", "Bearer " + readToken, http.StatusNotFound}, // no metrics, but auth works
}
mux := http.NewServeMux()
h.RegisterRoutes(mux)
s := newTestServer(h)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -484,7 +479,7 @@ func TestHandler_GetSizing_AuthRequired(t *testing.T) {
req.Header.Set("Authorization", tt.authHeader)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
s.Mux.ServeHTTP(rec, req)
if rec.Code != tt.wantCode {
t.Errorf("status = %d, want %d", rec.Code, tt.wantCode)

1096
pkg/client/client.gen.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
//go:build ignore
// ABOUTME: Extracts OpenAPI spec from Fuego server without running it.
// ABOUTME: Run with: go run scripts/extract-openapi/main.go
package main
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"github.com/getkin/kin-openapi/openapi3"
"github.com/go-fuego/fuego"
"edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver"
)
func main() {
// Create a minimal handler (store is nil, won't be used)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
handler := receiver.NewHandler(nil, logger, "dummy", "dummy", 0)
// Create Fuego server with OpenAPI config
s := fuego.NewServer(
fuego.WithoutStartupMessages(),
fuego.WithEngineOptions(
fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{
DisableLocalSave: true,
Info: &openapi3.Info{
Title: "Forgejo Runner Resource Collector API",
Version: "1.0.0",
Description: "HTTP service that receives and stores CI/CD resource metrics from collectors, providing query and sizing recommendation APIs.",
Contact: &openapi3.Contact{
Name: "API Support",
URL: "https://edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer",
},
License: &openapi3.License{
Name: "Apache 2.0",
URL: "http://www.apache.org/licenses/LICENSE-2.0.html",
},
},
}),
),
)
// Register routes to populate OpenAPI spec
handler.RegisterRoutes(s)
// Output OpenAPI spec as JSON
spec, err := json.MarshalIndent(s.OpenAPI.Description(), "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error marshaling OpenAPI spec: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile("docs/openapi.json", spec, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing docs/openapi.json: %v\n", err)
os.Exit(1)
}
fmt.Println("Generated docs/openapi.json")
}