New MCP server that queries live Prometheus and Alertmanager HTTP APIs with 8 tools: list_alerts, get_alert, search_metrics, get_metric_metadata, query (PromQL), list_targets, list_silences, and create_silence. Extends the MCP core with ModeCustom and NewGenericServer for servers that don't require a database. Includes CLI with direct commands (alerts, query, targets, metrics), NixOS module, and comprehensive httptest-based tests. Bumps existing binaries to 0.2.1 due to shared internal/mcp change. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
210 lines
5.7 KiB
Go
210 lines
5.7 KiB
Go
package monitoring
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestPrometheusClient_Query(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/query" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if r.URL.Query().Get("query") != "up" {
|
|
t.Errorf("unexpected query param: %s", r.URL.Query().Get("query"))
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"status": "success",
|
|
"data": {
|
|
"resultType": "vector",
|
|
"result": [
|
|
{
|
|
"metric": {"__name__": "up", "job": "prometheus", "instance": "localhost:9090"},
|
|
"value": [1234567890, "1"]
|
|
},
|
|
{
|
|
"metric": {"__name__": "up", "job": "node", "instance": "localhost:9100"},
|
|
"value": [1234567890, "0"]
|
|
}
|
|
]
|
|
}
|
|
}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewPrometheusClient(srv.URL)
|
|
data, err := client.Query(context.Background(), "up", time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if data.ResultType != "vector" {
|
|
t.Errorf("expected resultType=vector, got %s", data.ResultType)
|
|
}
|
|
if len(data.Result) != 2 {
|
|
t.Fatalf("expected 2 results, got %d", len(data.Result))
|
|
}
|
|
if data.Result[0].Metric["job"] != "prometheus" {
|
|
t.Errorf("expected job=prometheus, got %s", data.Result[0].Metric["job"])
|
|
}
|
|
}
|
|
|
|
func TestPrometheusClient_QueryError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"status": "error",
|
|
"errorType": "bad_data",
|
|
"error": "invalid expression"
|
|
}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewPrometheusClient(srv.URL)
|
|
_, err := client.Query(context.Background(), "invalid{", time.Time{})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !contains(err.Error(), "invalid expression") {
|
|
t.Errorf("expected error to contain 'invalid expression', got: %s", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestPrometheusClient_LabelValues(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/label/__name__/values" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"status": "success",
|
|
"data": ["up", "node_cpu_seconds_total", "prometheus_build_info"]
|
|
}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewPrometheusClient(srv.URL)
|
|
values, err := client.LabelValues(context.Background(), "__name__")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(values) != 3 {
|
|
t.Fatalf("expected 3 values, got %d", len(values))
|
|
}
|
|
if values[0] != "up" {
|
|
t.Errorf("expected first value=up, got %s", values[0])
|
|
}
|
|
}
|
|
|
|
func TestPrometheusClient_Metadata(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/metadata" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"status": "success",
|
|
"data": {
|
|
"up": [{"type": "gauge", "help": "Whether the target is up.", "unit": ""}],
|
|
"node_cpu_seconds_total": [{"type": "counter", "help": "CPU seconds spent.", "unit": "seconds"}]
|
|
}
|
|
}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewPrometheusClient(srv.URL)
|
|
metadata, err := client.Metadata(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(metadata) != 2 {
|
|
t.Fatalf("expected 2 metrics, got %d", len(metadata))
|
|
}
|
|
if metadata["up"][0].Type != "gauge" {
|
|
t.Errorf("expected up type=gauge, got %s", metadata["up"][0].Type)
|
|
}
|
|
}
|
|
|
|
func TestPrometheusClient_Targets(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/targets" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"status": "success",
|
|
"data": {
|
|
"activeTargets": [
|
|
{
|
|
"labels": {"instance": "localhost:9090", "job": "prometheus"},
|
|
"scrapePool": "prometheus",
|
|
"scrapeUrl": "http://localhost:9090/metrics",
|
|
"globalUrl": "http://localhost:9090/metrics",
|
|
"lastError": "",
|
|
"lastScrape": "2024-01-01T00:00:00Z",
|
|
"lastScrapeDuration": 0.01,
|
|
"health": "up",
|
|
"scrapeInterval": "15s",
|
|
"scrapeTimeout": "10s"
|
|
}
|
|
],
|
|
"droppedTargets": []
|
|
}
|
|
}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewPrometheusClient(srv.URL)
|
|
data, err := client.Targets(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(data.ActiveTargets) != 1 {
|
|
t.Fatalf("expected 1 active target, got %d", len(data.ActiveTargets))
|
|
}
|
|
if data.ActiveTargets[0].Health != "up" {
|
|
t.Errorf("expected health=up, got %s", data.ActiveTargets[0].Health)
|
|
}
|
|
}
|
|
|
|
func TestPrometheusClient_HTTPError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte("internal error"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewPrometheusClient(srv.URL)
|
|
_, err := client.Query(context.Background(), "up", time.Time{})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !contains(err.Error(), "500") {
|
|
t.Errorf("expected error to contain status code, got: %s", err.Error())
|
|
}
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && searchString(s, substr)
|
|
}
|
|
|
|
func searchString(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|