494 lines
12 KiB
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)
|
|
}
|
|
})
|
|
}
|
|
}
|