forgejo-runner-optimiser/internal/receiver/sizing_test.go

494 lines
12 KiB
Go

package receiver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"edp.buildth.ing/DevFW-CICD/forgejo-runner-optimiser/internal/summary"
)
func TestFormatMemoryK8s(t *testing.T) {
tests := []struct {
bytes float64
want string
}{
{0, "0Mi"},
{1024 * 1024, "1Mi"},
{256 * 1024 * 1024, "256Mi"},
{512 * 1024 * 1024, "512Mi"},
{1024 * 1024 * 1024, "1024Mi"},
{2 * 1024 * 1024 * 1024, "2048Mi"},
{1.5 * 1024 * 1024 * 1024, "1536Mi"},
{100 * 1024 * 1024, "100Mi"},
}
for _, tt := range tests {
got := formatMemoryK8s(tt.bytes)
if got != tt.want {
t.Errorf("formatMemoryK8s(%v) = %q, want %q", tt.bytes, got, tt.want)
}
}
}
func TestFormatCPUK8s(t *testing.T) {
tests := []struct {
cores float64
want string
}{
{0, "0m"},
{0.1, "100m"},
{0.5, "500m"},
{1.0, "1"},
{1.5, "1500m"},
{2.0, "2"},
{2.5, "2500m"},
{0.123, "123m"},
}
for _, tt := range tests {
got := formatCPUK8s(tt.cores)
if got != tt.want {
t.Errorf("formatCPUK8s(%v) = %q, want %q", tt.cores, got, tt.want)
}
}
}
func TestRoundUpMemoryLimit(t *testing.T) {
Mi := float64(1024 * 1024)
tests := []struct {
bytes float64
want float64
}{
{0, Mi}, // minimum 1Mi
{100, Mi}, // rounds up to 1Mi
{Mi, Mi}, // exactly 1Mi stays 1Mi
{1.5 * Mi, 2 * Mi},
{200 * Mi, 256 * Mi},
{300 * Mi, 512 * Mi},
{600 * Mi, 1024 * Mi},
}
for _, tt := range tests {
got := roundUpMemoryLimit(tt.bytes)
if got != tt.want {
t.Errorf("roundUpMemoryLimit(%v) = %v, want %v", tt.bytes, got, tt.want)
}
}
}
func TestRoundUpCPULimit(t *testing.T) {
tests := []struct {
cores float64
want float64
}{
{0, 0.5}, // minimum 0.5
{0.1, 0.5},
{0.5, 0.5},
{0.6, 1.0},
{1.0, 1.0},
{1.1, 1.5},
{1.5, 1.5},
{2.0, 2.0},
{2.3, 2.5},
}
for _, tt := range tests {
got := roundUpCPULimit(tt.cores)
if got != tt.want {
t.Errorf("roundUpCPULimit(%v) = %v, want %v", tt.cores, got, tt.want)
}
}
}
func TestSelectCPUValue(t *testing.T) {
stats := summary.StatSummary{
Peak: 10.0,
P99: 9.0,
P95: 8.0,
P75: 6.0,
P50: 5.0,
Avg: 4.0,
}
tests := []struct {
percentile string
want float64
}{
{"peak", 10.0},
{"p99", 9.0},
{"p95", 8.0},
{"p75", 6.0},
{"p50", 5.0},
{"avg", 4.0},
{"invalid", 8.0}, // defaults to p95
}
for _, tt := range tests {
got := selectCPUValue(stats, tt.percentile)
if got != tt.want {
t.Errorf("selectCPUValue(stats, %q) = %v, want %v", tt.percentile, got, tt.want)
}
}
}
func TestIsValidPercentile(t *testing.T) {
valid := []string{"peak", "p99", "p95", "p75", "p50", "avg"}
for _, p := range valid {
if !IsValidPercentile(p) {
t.Errorf("IsValidPercentile(%q) = false, want true", p)
}
}
invalid := []string{"p80", "p90", "max", ""}
for _, p := range invalid {
if IsValidPercentile(p) {
t.Errorf("IsValidPercentile(%q) = true, want false", p)
}
}
}
func TestComputeSizing_SingleRun(t *testing.T) {
runSummary := summary.RunSummary{
Containers: []summary.ContainerSummary{
{
Name: "runner",
CPUCores: summary.StatSummary{Peak: 1.0, P99: 0.9, P95: 0.8, P75: 0.6, P50: 0.5, Avg: 0.4},
MemoryBytes: summary.StatSummary{Peak: 512 * 1024 * 1024}, // 512Mi
},
},
}
payload, _ := json.Marshal(runSummary)
metrics := []Metric{{Payload: string(payload)}}
resp, err := computeSizing(metrics, 20, "p95")
if err != nil {
t.Fatalf("computeSizing() error = %v", err)
}
if len(resp.Containers) != 1 {
t.Fatalf("got %d containers, want 1", len(resp.Containers))
}
c := resp.Containers[0]
if c.Name != "runner" {
t.Errorf("container name = %q, want %q", c.Name, "runner")
}
// CPU: 0.8 * 1.2 = 0.96 -> 960m request, 1 limit
if c.CPU.Request != "960m" {
t.Errorf("CPU request = %q, want %q", c.CPU.Request, "960m")
}
if c.CPU.Limit != "1" {
t.Errorf("CPU limit = %q, want %q", c.CPU.Limit, "1")
}
// Memory: 512Mi * 1.2 = 614.4Mi -> 615Mi request, 1024Mi limit
if c.Memory.Request != "615Mi" {
t.Errorf("Memory request = %q, want %q", c.Memory.Request, "615Mi")
}
if c.Memory.Limit != "1024Mi" {
t.Errorf("Memory limit = %q, want %q", c.Memory.Limit, "1024Mi")
}
if resp.Meta.RunsAnalyzed != 1 {
t.Errorf("runs_analyzed = %d, want 1", resp.Meta.RunsAnalyzed)
}
if resp.Meta.BufferPercent != 20 {
t.Errorf("buffer_percent = %d, want 20", resp.Meta.BufferPercent)
}
if resp.Meta.CPUPercentile != "p95" {
t.Errorf("cpu_percentile = %q, want %q", resp.Meta.CPUPercentile, "p95")
}
}
func TestComputeSizing_MultipleRuns(t *testing.T) {
// Run 1: lower values
run1 := summary.RunSummary{
Containers: []summary.ContainerSummary{
{
Name: "runner",
CPUCores: summary.StatSummary{Peak: 0.5, P95: 0.4},
MemoryBytes: summary.StatSummary{Peak: 256 * 1024 * 1024},
},
},
}
// Run 2: higher values (should be used)
run2 := summary.RunSummary{
Containers: []summary.ContainerSummary{
{
Name: "runner",
CPUCores: summary.StatSummary{Peak: 1.0, P95: 0.8},
MemoryBytes: summary.StatSummary{Peak: 512 * 1024 * 1024},
},
},
}
payload1, _ := json.Marshal(run1)
payload2, _ := json.Marshal(run2)
metrics := []Metric{
{Payload: string(payload1)},
{Payload: string(payload2)},
}
resp, err := computeSizing(metrics, 0, "p95") // no buffer for easier math
if err != nil {
t.Fatalf("computeSizing() error = %v", err)
}
c := resp.Containers[0]
// CPU: max(0.4, 0.8) = 0.8
if c.CPU.Request != "800m" {
t.Errorf("CPU request = %q, want %q", c.CPU.Request, "800m")
}
// Memory: max(256, 512) = 512Mi
if c.Memory.Request != "512Mi" {
t.Errorf("Memory request = %q, want %q", c.Memory.Request, "512Mi")
}
if resp.Meta.RunsAnalyzed != 2 {
t.Errorf("runs_analyzed = %d, want 2", resp.Meta.RunsAnalyzed)
}
}
func TestComputeSizing_MultipleContainers(t *testing.T) {
runSummary := summary.RunSummary{
Containers: []summary.ContainerSummary{
{
Name: "runner",
CPUCores: summary.StatSummary{P95: 1.0},
MemoryBytes: summary.StatSummary{Peak: 512 * 1024 * 1024},
},
{
Name: "dind",
CPUCores: summary.StatSummary{P95: 0.5},
MemoryBytes: summary.StatSummary{Peak: 256 * 1024 * 1024},
},
},
}
payload, _ := json.Marshal(runSummary)
metrics := []Metric{{Payload: string(payload)}}
resp, err := computeSizing(metrics, 0, "p95")
if err != nil {
t.Fatalf("computeSizing() error = %v", err)
}
if len(resp.Containers) != 2 {
t.Fatalf("got %d containers, want 2", len(resp.Containers))
}
// Containers should be sorted alphabetically
if resp.Containers[0].Name != "dind" {
t.Errorf("first container = %q, want %q", resp.Containers[0].Name, "dind")
}
if resp.Containers[1].Name != "runner" {
t.Errorf("second container = %q, want %q", resp.Containers[1].Name, "runner")
}
// Total should be sum
if resp.Total.CPU.Request != "1500m" {
t.Errorf("total CPU request = %q, want %q", resp.Total.CPU.Request, "1500m")
}
if resp.Total.Memory.Request != "768Mi" {
t.Errorf("total memory request = %q, want %q", resp.Total.Memory.Request, "768Mi")
}
}
func TestComputeSizing_NoMetrics(t *testing.T) {
_, err := computeSizing([]Metric{}, 20, "p95")
if err == nil {
t.Error("computeSizing() with no metrics should return error")
}
}
func TestHandler_GetSizing(t *testing.T) {
const readToken = "test-token"
h, cleanup := newTestHandlerWithToken(t, readToken)
defer cleanup()
// Save metrics with container data
for i := 0; i < 3; i++ {
runSummary := summary.RunSummary{
Containers: []summary.ContainerSummary{
{
Name: "runner",
CPUCores: summary.StatSummary{Peak: 1.0, P99: 0.9, P95: 0.8, P75: 0.6, P50: 0.5, Avg: 0.4},
MemoryBytes: summary.StatSummary{Peak: 512 * 1024 * 1024},
},
},
}
payload := &MetricsPayload{
Execution: ExecutionContext{
Organization: "org",
Repository: "repo",
Workflow: "ci.yml",
Job: "build",
RunID: "run-" + string(rune('1'+i)),
},
Summary: runSummary,
}
if _, err := h.store.SaveMetric(payload); err != nil {
t.Fatalf("SaveMetric() error = %v", err)
}
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/sizing/repo/org/repo/ci.yml/build", nil)
req.Header.Set("Authorization", "Bearer "+readToken)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp SizingResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(resp.Containers) != 1 {
t.Errorf("got %d containers, want 1", len(resp.Containers))
}
if resp.Meta.RunsAnalyzed != 3 {
t.Errorf("runs_analyzed = %d, want 3", resp.Meta.RunsAnalyzed)
}
if resp.Meta.BufferPercent != 20 {
t.Errorf("buffer_percent = %d, want 20", resp.Meta.BufferPercent)
}
if resp.Meta.CPUPercentile != "p95" {
t.Errorf("cpu_percentile = %q, want %q", resp.Meta.CPUPercentile, "p95")
}
}
func TestHandler_GetSizing_CustomParams(t *testing.T) {
const readToken = "test-token"
h, cleanup := newTestHandlerWithToken(t, readToken)
defer cleanup()
// Save one metric
runSummary := summary.RunSummary{
Containers: []summary.ContainerSummary{
{
Name: "runner",
CPUCores: summary.StatSummary{Peak: 1.0, P99: 0.9, P95: 0.8, P75: 0.6, P50: 0.5, Avg: 0.4},
MemoryBytes: summary.StatSummary{Peak: 512 * 1024 * 1024},
},
},
}
payload := &MetricsPayload{
Execution: ExecutionContext{Organization: "org", Repository: "repo", Workflow: "ci.yml", Job: "build", RunID: "run-1"},
Summary: runSummary,
}
if _, err := h.store.SaveMetric(payload); err != nil {
t.Fatalf("SaveMetric() error = %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/sizing/repo/org/repo/ci.yml/build?runs=10&buffer=10&cpu_percentile=p75", nil)
req.Header.Set("Authorization", "Bearer "+readToken)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp SizingResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Meta.BufferPercent != 10 {
t.Errorf("buffer_percent = %d, want 10", resp.Meta.BufferPercent)
}
if resp.Meta.CPUPercentile != "p75" {
t.Errorf("cpu_percentile = %q, want %q", resp.Meta.CPUPercentile, "p75")
}
// CPU: 0.6 * 1.1 = 0.66
c := resp.Containers[0]
if c.CPU.Request != "660m" {
t.Errorf("CPU request = %q, want %q", c.CPU.Request, "660m")
}
}
func TestHandler_GetSizing_NotFound(t *testing.T) {
const readToken = "test-token"
h, cleanup := newTestHandlerWithToken(t, readToken)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/api/v1/sizing/repo/org/repo/ci.yml/build", nil)
req.Header.Set("Authorization", "Bearer "+readToken)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestHandler_GetSizing_InvalidPercentile(t *testing.T) {
const readToken = "test-token"
h, cleanup := newTestHandlerWithToken(t, readToken)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/api/v1/sizing/repo/org/repo/ci.yml/build?cpu_percentile=p80", nil)
req.Header.Set("Authorization", "Bearer "+readToken)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestHandler_GetSizing_AuthRequired(t *testing.T) {
const readToken = "test-token"
h, cleanup := newTestHandlerWithToken(t, readToken)
defer cleanup()
tests := []struct {
name string
authHeader string
wantCode int
}{
{"no auth", "", http.StatusUnauthorized},
{"wrong token", "Bearer wrong-token", http.StatusUnauthorized},
{"valid token", "Bearer " + readToken, http.StatusNotFound}, // no metrics, but auth works
}
mux := http.NewServeMux()
h.RegisterRoutes(mux)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/sizing/repo/org/repo/ci.yml/build", nil)
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != tt.wantCode {
t.Errorf("status = %d, want %d", rec.Code, tt.wantCode)
}
})
}
}