552 lines
17 KiB
C#
552 lines
17 KiB
C#
using EntKube.Web.Data;
|
|
using EntKube.Web.Services;
|
|
using FluentAssertions;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace EntKube.Web.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests for PrometheusService — the service that connects to a kube-prometheus-stack
|
|
/// running in a cluster and retrieves health/status metrics. These tests verify the
|
|
/// data-layer plumbing (finding clusters, validating configuration) and response
|
|
/// parsing. Actual Prometheus queries require a live cluster.
|
|
/// </summary>
|
|
public class PrometheusServiceTests : IDisposable
|
|
{
|
|
private readonly SqliteConnection connection;
|
|
private readonly ApplicationDbContext db;
|
|
private readonly TestDbContextFactory dbFactory;
|
|
private readonly PrometheusService sut;
|
|
|
|
public PrometheusServiceTests()
|
|
{
|
|
connection = new SqliteConnection("DataSource=:memory:");
|
|
connection.Open();
|
|
|
|
DbContextOptions<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseSqlite(connection)
|
|
.Options;
|
|
|
|
db = new ApplicationDbContext(options);
|
|
dbFactory = new TestDbContextFactory(connection);
|
|
db.Database.EnsureCreated();
|
|
|
|
sut = new PrometheusService(dbFactory);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
db.Dispose();
|
|
connection.Dispose();
|
|
}
|
|
|
|
// ──────── GetClusterHealthAsync — failure paths ────────
|
|
|
|
[Fact]
|
|
public async Task GetClusterHealthAsync_ClusterNotFound_ReturnsFailure()
|
|
{
|
|
// Arrange — use a random cluster ID that doesn't exist in the database.
|
|
|
|
Guid nonExistentId = Guid.NewGuid();
|
|
|
|
// Act
|
|
|
|
KubernetesOperationResult<ClusterHealthSummary> result =
|
|
await sut.GetClusterHealthAsync(nonExistentId);
|
|
|
|
// Assert — should fail gracefully with a clear message.
|
|
|
|
result.IsSuccess.Should().BeFalse();
|
|
result.Error.Should().Contain("not found");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetClusterHealthAsync_NoPrometheusComponent_ReturnsFailure()
|
|
{
|
|
// Arrange — cluster exists but has no prometheus component configured.
|
|
|
|
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestTenant", Slug = "test" };
|
|
Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "prod" };
|
|
KubernetesCluster cluster = new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TenantId = tenant.Id,
|
|
EnvironmentId = env.Id,
|
|
Name = "prod-cluster",
|
|
ApiServerUrl = "https://k8s.example.com",
|
|
Kubeconfig = "apiVersion: v1\nkind: Config"
|
|
};
|
|
|
|
db.Set<Tenant>().Add(tenant);
|
|
db.Set<Data.Environment>().Add(env);
|
|
db.KubernetesClusters.Add(cluster);
|
|
await db.SaveChangesAsync();
|
|
|
|
// Act
|
|
|
|
KubernetesOperationResult<ClusterHealthSummary> result =
|
|
await sut.GetClusterHealthAsync(cluster.Id);
|
|
|
|
// Assert — no prometheus component means we can't query metrics.
|
|
|
|
result.IsSuccess.Should().BeFalse();
|
|
result.Error.Should().Contain("prometheus");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetClusterHealthAsync_NoKubeconfig_ReturnsFailure()
|
|
{
|
|
// Arrange — cluster has prometheus component but no kubeconfig stored.
|
|
|
|
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestTenant2", Slug = "test2" };
|
|
Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "prod" };
|
|
KubernetesCluster cluster = new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TenantId = tenant.Id,
|
|
EnvironmentId = env.Id,
|
|
Name = "no-kubeconfig-cluster",
|
|
ApiServerUrl = "https://k8s.example.com",
|
|
Kubeconfig = null
|
|
};
|
|
|
|
ClusterComponent prometheusComponent = new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ClusterId = cluster.Id,
|
|
Name = "kube-prometheus-stack",
|
|
ComponentType = "HelmChart",
|
|
Configuration = """{"namespace":"monitoring","serviceName":"prometheus-kube-prometheus-prometheus","servicePort":9090}"""
|
|
};
|
|
|
|
db.Set<Tenant>().Add(tenant);
|
|
db.Set<Data.Environment>().Add(env);
|
|
db.KubernetesClusters.Add(cluster);
|
|
db.ClusterComponents.Add(prometheusComponent);
|
|
await db.SaveChangesAsync();
|
|
|
|
// Act
|
|
|
|
KubernetesOperationResult<ClusterHealthSummary> result =
|
|
await sut.GetClusterHealthAsync(cluster.Id);
|
|
|
|
// Assert — without kubeconfig we can't connect to the cluster.
|
|
|
|
result.IsSuccess.Should().BeFalse();
|
|
result.Error.Should().Contain("kubeconfig");
|
|
}
|
|
|
|
// ──────── ParsePrometheusResponse — parsing metric results ────────
|
|
|
|
[Fact]
|
|
public void ParseInstantQueryResult_ValidVectorResponse_ReturnsValues()
|
|
{
|
|
// Arrange — a typical Prometheus instant query response for `up` metric.
|
|
|
|
string json = """
|
|
{
|
|
"status": "success",
|
|
"data": {
|
|
"resultType": "vector",
|
|
"result": [
|
|
{"metric": {"job": "node-exporter", "instance": "node1:9100"}, "value": [1700000000, "1"]},
|
|
{"metric": {"job": "node-exporter", "instance": "node2:9100"}, "value": [1700000000, "1"]},
|
|
{"metric": {"job": "node-exporter", "instance": "node3:9100"}, "value": [1700000000, "0"]}
|
|
]
|
|
}
|
|
}
|
|
""";
|
|
|
|
// Act
|
|
|
|
List<PrometheusMetricResult> results = PrometheusService.ParseInstantQueryResult(json);
|
|
|
|
// Assert — three results parsed, with correct values.
|
|
|
|
results.Should().HaveCount(3);
|
|
results[0].Value.Should().Be(1.0);
|
|
results[1].Value.Should().Be(1.0);
|
|
results[2].Value.Should().Be(0.0);
|
|
results[0].Labels.Should().ContainKey("instance").WhoseValue.Should().Be("node1:9100");
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseInstantQueryResult_EmptyResult_ReturnsEmptyList()
|
|
{
|
|
// Arrange — Prometheus returns success but no matching series.
|
|
|
|
string json = """
|
|
{
|
|
"status": "success",
|
|
"data": {
|
|
"resultType": "vector",
|
|
"result": []
|
|
}
|
|
}
|
|
""";
|
|
|
|
// Act
|
|
|
|
List<PrometheusMetricResult> results = PrometheusService.ParseInstantQueryResult(json);
|
|
|
|
// Assert
|
|
|
|
results.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseInstantQueryResult_ErrorResponse_ReturnsEmptyList()
|
|
{
|
|
// Arrange — Prometheus returns an error status.
|
|
|
|
string json = """
|
|
{
|
|
"status": "error",
|
|
"errorType": "bad_data",
|
|
"error": "invalid expression"
|
|
}
|
|
""";
|
|
|
|
// Act
|
|
|
|
List<PrometheusMetricResult> results = PrometheusService.ParseInstantQueryResult(json);
|
|
|
|
// Assert — graceful degradation, no exception thrown.
|
|
|
|
results.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseInstantQueryResult_ScalarResponse_ReturnsSingleValue()
|
|
{
|
|
// Arrange — a scalar query result (e.g. `count(up)`).
|
|
|
|
string json = """
|
|
{
|
|
"status": "success",
|
|
"data": {
|
|
"resultType": "scalar",
|
|
"result": [1700000000, "42"]
|
|
}
|
|
}
|
|
""";
|
|
|
|
// Act
|
|
|
|
List<PrometheusMetricResult> results = PrometheusService.ParseInstantQueryResult(json);
|
|
|
|
// Assert
|
|
|
|
results.Should().HaveCount(1);
|
|
results[0].Value.Should().Be(42.0);
|
|
}
|
|
|
|
// ──────── GetPrometheusConfig — extracting config from component ────────
|
|
|
|
[Fact]
|
|
public void GetPrometheusConfig_ValidJson_ReturnsConfig()
|
|
{
|
|
// Arrange — a component with properly formatted configuration JSON.
|
|
|
|
ClusterComponent component = new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ClusterId = Guid.NewGuid(),
|
|
Name = "kube-prometheus-stack",
|
|
ComponentType = "HelmChart",
|
|
Configuration = """{"namespace":"monitoring","serviceName":"prometheus-kube-prometheus-prometheus","servicePort":9090}"""
|
|
};
|
|
|
|
// Act
|
|
|
|
PrometheusConfig? config = PrometheusService.GetPrometheusConfig(component);
|
|
|
|
// Assert
|
|
|
|
config.Should().NotBeNull();
|
|
config!.Namespace.Should().Be("monitoring");
|
|
config.ServiceName.Should().Be("prometheus-kube-prometheus-prometheus");
|
|
config.ServicePort.Should().Be(9090);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetPrometheusConfig_NullConfiguration_ReturnsDefaultConfig()
|
|
{
|
|
// Arrange — component exists but has no configuration set yet.
|
|
// We should return sensible defaults for kube-prometheus-stack.
|
|
|
|
ClusterComponent component = new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ClusterId = Guid.NewGuid(),
|
|
Name = "kube-prometheus-stack",
|
|
ComponentType = "HelmChart",
|
|
Configuration = null
|
|
};
|
|
|
|
// Act
|
|
|
|
PrometheusConfig? config = PrometheusService.GetPrometheusConfig(component);
|
|
|
|
// Assert — defaults for standard kube-prometheus-stack helm install.
|
|
|
|
config.Should().NotBeNull();
|
|
config!.Namespace.Should().Be("monitoring");
|
|
config.ServiceName.Should().Be("prometheus-kube-prometheus-prometheus");
|
|
config.ServicePort.Should().Be(9090);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetPrometheusConfig_InvalidJson_ReturnsDefaultConfig()
|
|
{
|
|
// Arrange — configuration is corrupt JSON.
|
|
|
|
ClusterComponent component = new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ClusterId = Guid.NewGuid(),
|
|
Name = "kube-prometheus-stack",
|
|
ComponentType = "HelmChart",
|
|
Configuration = "not valid json {"
|
|
};
|
|
|
|
// Act
|
|
|
|
PrometheusConfig? config = PrometheusService.GetPrometheusConfig(component);
|
|
|
|
// Assert — graceful fallback to defaults.
|
|
|
|
config.Should().NotBeNull();
|
|
config!.Namespace.Should().Be("monitoring");
|
|
}
|
|
|
|
// ──────── ParseRangeQueryResult — time-series parsing ────────
|
|
|
|
[Fact]
|
|
public void ParseRangeQueryResult_ValidMatrixResponse_ReturnsTimeSeries()
|
|
{
|
|
// Arrange — a typical Prometheus range query response with matrix data.
|
|
// Each result is a series with multiple [timestamp, value] pairs over time.
|
|
|
|
string json = """
|
|
{
|
|
"status": "success",
|
|
"data": {
|
|
"resultType": "matrix",
|
|
"result": [
|
|
{
|
|
"metric": {"instance": "node1:9100"},
|
|
"values": [
|
|
[1700000000, "45.2"],
|
|
[1700000060, "47.1"],
|
|
[1700000120, "43.8"],
|
|
[1700000180, "50.3"]
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
""";
|
|
|
|
// Act
|
|
|
|
List<PrometheusTimeSeries> results = PrometheusService.ParseRangeQueryResult(json);
|
|
|
|
// Assert — one series with four data points.
|
|
|
|
results.Should().HaveCount(1);
|
|
results[0].Labels.Should().ContainKey("instance").WhoseValue.Should().Be("node1:9100");
|
|
results[0].DataPoints.Should().HaveCount(4);
|
|
results[0].DataPoints[0].Value.Should().BeApproximately(45.2, 0.01);
|
|
results[0].DataPoints[3].Value.Should().BeApproximately(50.3, 0.01);
|
|
results[0].DataPoints[0].Timestamp.Should().BeAfter(DateTime.UnixEpoch);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseRangeQueryResult_MultipleSeriesResponse_ReturnsAll()
|
|
{
|
|
// Arrange — two series (e.g. CPU per node) in a range query.
|
|
|
|
string json = """
|
|
{
|
|
"status": "success",
|
|
"data": {
|
|
"resultType": "matrix",
|
|
"result": [
|
|
{
|
|
"metric": {"node": "node1"},
|
|
"values": [[1700000000, "30.0"], [1700000060, "35.0"]]
|
|
},
|
|
{
|
|
"metric": {"node": "node2"},
|
|
"values": [[1700000000, "60.0"], [1700000060, "62.5"]]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
""";
|
|
|
|
// Act
|
|
|
|
List<PrometheusTimeSeries> results = PrometheusService.ParseRangeQueryResult(json);
|
|
|
|
// Assert
|
|
|
|
results.Should().HaveCount(2);
|
|
results[0].Labels["node"].Should().Be("node1");
|
|
results[1].Labels["node"].Should().Be("node2");
|
|
results[1].DataPoints[1].Value.Should().BeApproximately(62.5, 0.01);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseRangeQueryResult_EmptyResult_ReturnsEmptyList()
|
|
{
|
|
// Arrange
|
|
|
|
string json = """
|
|
{
|
|
"status": "success",
|
|
"data": {
|
|
"resultType": "matrix",
|
|
"result": []
|
|
}
|
|
}
|
|
""";
|
|
|
|
// Act
|
|
|
|
List<PrometheusTimeSeries> results = PrometheusService.ParseRangeQueryResult(json);
|
|
|
|
// Assert
|
|
|
|
results.Should().BeEmpty();
|
|
}
|
|
|
|
// ──────── ParseAlertmanagerAlerts — alertmanager response parsing ────────
|
|
|
|
[Fact]
|
|
public void ParseAlertmanagerAlerts_ValidResponse_ReturnsAlerts()
|
|
{
|
|
// Arrange — Alertmanager /api/v2/alerts returns an array of alert objects.
|
|
|
|
string json = """
|
|
[
|
|
{
|
|
"labels": {"alertname": "HighCPU", "severity": "warning", "instance": "node1:9100"},
|
|
"annotations": {"summary": "CPU usage is above 90%", "description": "Node node1 has high CPU load"},
|
|
"startsAt": "2026-05-16T10:30:00Z",
|
|
"endsAt": "0001-01-01T00:00:00Z",
|
|
"status": {"state": "active"},
|
|
"fingerprint": "abc123"
|
|
},
|
|
{
|
|
"labels": {"alertname": "PodCrashLooping", "severity": "critical", "namespace": "default", "pod": "api-7f8b9-xyz"},
|
|
"annotations": {"summary": "Pod is crash looping"},
|
|
"startsAt": "2026-05-16T09:15:00Z",
|
|
"endsAt": "0001-01-01T00:00:00Z",
|
|
"status": {"state": "active"},
|
|
"fingerprint": "def456"
|
|
}
|
|
]
|
|
""";
|
|
|
|
// Act
|
|
|
|
List<AlertInfo> alerts = PrometheusService.ParseAlertmanagerAlerts(json);
|
|
|
|
// Assert — two alerts parsed with correct severity and labels.
|
|
|
|
alerts.Should().HaveCount(2);
|
|
alerts[0].Name.Should().Be("HighCPU");
|
|
alerts[0].Severity.Should().Be("warning");
|
|
alerts[0].Summary.Should().Contain("CPU usage");
|
|
alerts[0].State.Should().Be("active");
|
|
alerts[0].Labels.Should().ContainKey("instance");
|
|
alerts[1].Name.Should().Be("PodCrashLooping");
|
|
alerts[1].Severity.Should().Be("critical");
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseAlertmanagerAlerts_EmptyArray_ReturnsEmptyList()
|
|
{
|
|
// Arrange
|
|
|
|
string json = "[]";
|
|
|
|
// Act
|
|
|
|
List<AlertInfo> alerts = PrometheusService.ParseAlertmanagerAlerts(json);
|
|
|
|
// Assert
|
|
|
|
alerts.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseAlertmanagerAlerts_InvalidJson_ReturnsEmptyList()
|
|
{
|
|
// Arrange — corrupt JSON should degrade gracefully.
|
|
|
|
string json = "not valid json {";
|
|
|
|
// Act
|
|
|
|
List<AlertInfo> alerts = PrometheusService.ParseAlertmanagerAlerts(json);
|
|
|
|
// Assert
|
|
|
|
alerts.Should().BeEmpty();
|
|
}
|
|
|
|
// ──────── ParseAlertmanagerSilences — silence parsing ────────
|
|
|
|
[Fact]
|
|
public void ParseAlertmanagerSilences_ValidResponse_ReturnsSilences()
|
|
{
|
|
// Arrange — Alertmanager /api/v2/silences response.
|
|
|
|
string json = """
|
|
[
|
|
{
|
|
"id": "silence-001",
|
|
"status": {"state": "active"},
|
|
"comment": "Maintenance window for node upgrade",
|
|
"createdBy": "ops-team",
|
|
"startsAt": "2026-05-16T08:00:00Z",
|
|
"endsAt": "2026-05-16T12:00:00Z",
|
|
"matchers": [
|
|
{"name": "instance", "value": "node1:9100", "isRegex": false, "isEqual": true}
|
|
]
|
|
},
|
|
{
|
|
"id": "silence-002",
|
|
"status": {"state": "expired"},
|
|
"comment": "Past maintenance",
|
|
"createdBy": "admin",
|
|
"startsAt": "2026-05-15T00:00:00Z",
|
|
"endsAt": "2026-05-15T04:00:00Z",
|
|
"matchers": [
|
|
{"name": "alertname", "value": "Watchdog", "isRegex": false, "isEqual": true}
|
|
]
|
|
}
|
|
]
|
|
""";
|
|
|
|
// Act
|
|
|
|
List<SilenceInfo> silences = PrometheusService.ParseAlertmanagerSilences(json);
|
|
|
|
// Assert — two silences, one active and one expired.
|
|
|
|
silences.Should().HaveCount(2);
|
|
silences[0].Id.Should().Be("silence-001");
|
|
silences[0].State.Should().Be("active");
|
|
silences[0].Comment.Should().Contain("Maintenance");
|
|
silences[0].CreatedBy.Should().Be("ops-team");
|
|
silences[0].Matchers.Should().HaveCount(1);
|
|
silences[0].Matchers[0].Name.Should().Be("instance");
|
|
silences[1].State.Should().Be("expired");
|
|
}
|
|
}
|