ananke/testing/metrics/exporter_http_contract_test.go

168 lines
6.0 KiB
Go

package metricsquality
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/metrics"
)
// TestExporterEmitsCoreMetrics runs one orchestration or CLI step.
// Signature: TestExporterEmitsCoreMetrics(t *testing.T).
// Why: keeps exporter metric output stable after moving checks into the
// top-level testing module.
func TestExporterEmitsCoreMetrics(t *testing.T) {
exporter := metrics.New()
exporter.UpdateBudget(321)
exporter.UpdateSample(metrics.Sample{
Name: "Pyrphoros",
Target: "pyrphoros@localhost",
OnBattery: true,
LowBattery: false,
RuntimeSecond: 412,
BatteryCharge: 81.5,
LoadPercent: 27.4,
PowerNominalW: 510,
ThresholdSec: 354,
Trigger: true,
BreachCount: 2,
Status: "OB",
UpdatedAt: time.Unix(1710000000, 0).UTC(),
})
exporter.MarkShutdown("ups-threshold")
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rr := httptest.NewRecorder()
exporter.Handler("/metrics").ServeHTTP(rr, req)
body := rr.Body.String()
mustContain := []string{
"ananke_shutdown_budget_seconds 321",
"ananke_shutdown_triggers_total 1",
"ananke_ups_on_battery{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_runtime_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_battery_charge_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_load_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_power_nominal_watts{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_threshold_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
}
for _, fragment := range mustContain {
if !strings.Contains(body, fragment) {
t.Fatalf("missing metric fragment %q in output:\n%s", fragment, body)
}
}
}
// TestExporterHealthzAndEscaping runs one orchestration or CLI step.
// Signature: TestExporterHealthzAndEscaping(t *testing.T).
// Why: covers exporter health routing and label escaping from the top-level
// testing module so HTTP behavior stays explicit after migration.
func TestExporterHealthzAndEscaping(t *testing.T) {
exporter := metrics.New()
exporter.UpdateSample(metrics.Sample{
Name: `Sta"tera`,
Target: `statera\host`,
Status: `O"B`,
LastError: "x",
})
handler := exporter.Handler("/custom")
healthReq := httptest.NewRequest(http.MethodGet, "/healthz", nil)
healthRR := httptest.NewRecorder()
handler.ServeHTTP(healthRR, healthReq)
if healthRR.Code != http.StatusOK || strings.TrimSpace(healthRR.Body.String()) != "ok" {
t.Fatalf("unexpected health response: code=%d body=%q", healthRR.Code, healthRR.Body.String())
}
metricsReq := httptest.NewRequest(http.MethodGet, "/custom", nil)
metricsRR := httptest.NewRecorder()
handler.ServeHTTP(metricsRR, metricsReq)
body := metricsRR.Body.String()
if !strings.Contains(body, `source="Sta\\\"tera"`) {
t.Fatalf("expected escaped source label, got:\n%s", body)
}
if !strings.Contains(body, `target="statera\\\\host"`) {
t.Fatalf("expected escaped target label, got:\n%s", body)
}
if !strings.Contains(body, "ananke_ups_error") {
t.Fatalf("expected error metric line in output")
}
}
// TestExporterHelperContracts runs one orchestration or CLI step.
// Signature: TestExporterHelperContracts(t *testing.T).
// Why: preserves direct helper coverage for metric formatting utilities after
// removing same-package root tests.
func TestExporterHelperContracts(t *testing.T) {
if metrics.TestHookBoolNum(true) != 1 || metrics.TestHookBoolNum(false) != 0 {
t.Fatalf("unexpected boolNum values")
}
if got := metrics.TestHookSafeLabelValue(`a"b\c`); got != `a\"b\\c` {
t.Fatalf("unexpected escaped string: %q", got)
}
}
// TestExporterEmitsGitOpsMetrics runs one orchestration or CLI step.
// Signature: TestExporterEmitsGitOpsMetrics(t *testing.T).
// Why: the overview dashboard depends on Ananke-owned Flux object-state metrics
// rather than the narrower controller metrics exposed by Flux itself.
func TestExporterEmitsGitOpsMetrics(t *testing.T) {
exporter := metrics.New()
exporter.UpdateGitOpsSnapshot(metrics.GitOpsSnapshot{
UpdatedAt: time.Unix(1710000100, 0).UTC(),
ScrapeSuccess: true,
FluxSources: []metrics.GitOpsFluxSource{{
Namespace: "flux-system",
Name: "flux-system",
URL: "ssh://git@example/repo.git",
Branch: "main",
Revision: "main@sha1:abc123",
Ready: true,
Reason: "Succeeded",
}},
Kustomizations: []metrics.GitOpsKustomization{{
Namespace: "flux-system",
Name: "monitoring",
Path: "./services/monitoring",
SourceNamespace: "flux-system",
SourceName: "flux-system",
Revision: "main@sha1:abc123",
Ready: true,
Reason: "ReconciliationSucceeded",
}},
HelmReleases: []metrics.GitOpsHelmRelease{{
Namespace: "monitoring",
Name: "grafana",
Chart: "grafana",
Version: "8.5.0",
AppVersion: "11.4.0",
Revision: "sha256:abc123",
Ready: false,
Reason: "Progressing",
Suspended: true,
}},
})
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rr := httptest.NewRecorder()
exporter.Handler("/metrics").ServeHTTP(rr, req)
body := rr.Body.String()
mustContain := []string{
"ananke_gitops_last_scrape_timestamp_seconds 1710000100",
"ananke_gitops_scrape_success 1",
`ananke_gitops_flux_source_info{namespace="flux-system",name="flux-system",url="ssh://git@example/repo.git",branch="main",revision="main@sha1:abc123",ready="true",reason="Succeeded"} 1`,
`ananke_gitops_kustomization_ready{namespace="flux-system",name="monitoring"} 1`,
`ananke_gitops_helmrelease_info{namespace="monitoring",name="grafana",chart="grafana",version="8.5.0",app_version="11.4.0",revision="sha256:abc123",ready="false",reason="Progressing"} 1`,
`ananke_gitops_helmrelease_suspended{namespace="monitoring",name="grafana"} 1`,
}
for _, fragment := range mustContain {
if !strings.Contains(body, fragment) {
t.Fatalf("missing GitOps metric fragment %q in output:\n%s", fragment, body)
}
}
}