feat: migrate receiver to Fuego framework with OpenAPI generation
All checks were successful
ci / ci (push) Successful in 2m2s
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:
parent
479c13f596
commit
bc9d0dd8ea
11 changed files with 2245 additions and 252 deletions
34
Makefile
34
Makefile
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
665
docs/openapi.json
Normal 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
33
go.mod
|
|
@ -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
100
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
1096
pkg/client/client.gen.go
Normal file
File diff suppressed because it is too large
Load diff
64
scripts/extract-openapi/main.go
Normal file
64
scripts/extract-openapi/main.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue