too much for one commit

This commit is contained in:
Nils Blomgren
2026-05-13 14:01:32 +02:00
parent a96dd33039
commit 328d494530
394 changed files with 98104 additions and 78 deletions

View File

@@ -0,0 +1,239 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.ClusterHealth;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class ClusterHealthHandlerTests
{
private readonly FakePrometheusQueryClient fakeQueryClient;
private readonly FakeClusterRepository repository;
private readonly ClusterHealthHandler sut;
public ClusterHealthHandlerTests()
{
fakeQueryClient = new FakePrometheusQueryClient();
repository = new FakeClusterRepository();
sut = new ClusterHealthHandler(repository, fakeQueryClient);
}
[Fact]
public async Task HandleAsync_WithHealthyCluster_ReturnsAllMetrics()
{
// Arrange — a cluster with a Prometheus endpoint, all metrics looking good.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "prod", Guid.NewGuid());
cluster.SetPrometheusEndpoints(new List<PrometheusEndpoint>
{
new("http://prometheus.monitoring.svc.cluster.local:9090", "monitoring", "prometheus")
});
await repository.AddAsync(cluster);
fakeQueryClient.SetResults(new Dictionary<string, double>
{
["count(kube_node_info)"] = 3,
["sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})"] = 3,
["(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100"] = 42.5,
["(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100"] = 68.2,
["sum(kube_pod_status_phase{phase=\"Running\"})"] = 45,
["sum(kube_pod_status_phase{phase=\"Pending\"})"] = 2,
["sum(kube_pod_status_phase{phase=\"Failed\"})"] = 0,
["sum(increase(kube_pod_container_status_restarts_total[1h]))"] = 3,
["sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100"] = 0.1,
["sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})"] = 0
});
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(cluster.Id);
// Assert — we get a complete health report.
result.IsSuccess.Should().BeTrue();
ClusterHealthReport report = result.Value!;
report.TotalNodes.Should().Be(3);
report.ReadyNodes.Should().Be(3);
report.CpuUtilizationPercent.Should().BeApproximately(42.5, 0.1);
report.MemoryUtilizationPercent.Should().BeApproximately(68.2, 0.1);
report.RunningPods.Should().Be(45);
report.PendingPods.Should().Be(2);
report.FailedPods.Should().Be(0);
report.ContainerRestartsLastHour.Should().Be(3);
report.ApiServerErrorRatePercent.Should().BeApproximately(0.1, 0.01);
report.NodesWithDiskPressure.Should().Be(0);
report.OverallStatus.Should().Be(ClusterHealthStatus.Healthy);
}
[Fact]
public async Task HandleAsync_WithNoPrometheusEndpoints_ReturnsFailure()
{
// Arrange — cluster without Prometheus discovered yet.
KubernetesCluster cluster = KubernetesCluster.Register(
"no-prom", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(cluster.Id);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("No Prometheus endpoints");
}
[Fact]
public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure()
{
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(Guid.NewGuid());
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_WithDegradedMetrics_ReturnsDegradedStatus()
{
// Arrange — high CPU, some pending pods, and container restarts indicate degradation.
KubernetesCluster cluster = KubernetesCluster.Register(
"degraded-cluster", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.SetPrometheusEndpoints(new List<PrometheusEndpoint>
{
new("http://prometheus.monitoring.svc.cluster.local:9090", "monitoring", "prometheus")
});
await repository.AddAsync(cluster);
fakeQueryClient.SetResults(new Dictionary<string, double>
{
["count(kube_node_info)"] = 3,
["sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})"] = 2,
["(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100"] = 85.0,
["(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100"] = 78.0,
["sum(kube_pod_status_phase{phase=\"Running\"})"] = 40,
["sum(kube_pod_status_phase{phase=\"Pending\"})"] = 8,
["sum(kube_pod_status_phase{phase=\"Failed\"})"] = 2,
["sum(increase(kube_pod_container_status_restarts_total[1h]))"] = 15,
["sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100"] = 3.0,
["sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})"] = 0
});
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(cluster.Id);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.OverallStatus.Should().Be(ClusterHealthStatus.Degraded);
}
[Fact]
public async Task HandleAsync_WithCriticalMetrics_ReturnsCriticalStatus()
{
// Arrange — nodes not ready and high API error rate = critical.
KubernetesCluster cluster = KubernetesCluster.Register(
"critical-cluster", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.SetPrometheusEndpoints(new List<PrometheusEndpoint>
{
new("http://prometheus.monitoring.svc.cluster.local:9090", "monitoring", "prometheus")
});
await repository.AddAsync(cluster);
fakeQueryClient.SetResults(new Dictionary<string, double>
{
["count(kube_node_info)"] = 3,
["sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})"] = 1,
["(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100"] = 95.0,
["(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100"] = 92.0,
["sum(kube_pod_status_phase{phase=\"Running\"})"] = 10,
["sum(kube_pod_status_phase{phase=\"Pending\"})"] = 20,
["sum(kube_pod_status_phase{phase=\"Failed\"})"] = 15,
["sum(increase(kube_pod_container_status_restarts_total[1h]))"] = 50,
["sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100"] = 12.0,
["sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})"] = 2
});
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(cluster.Id);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.OverallStatus.Should().Be(ClusterHealthStatus.Critical);
}
}
/// <summary>
/// Fake Prometheus query client for testing — returns preconfigured values
/// for specific PromQL queries without needing a real Prometheus instance.
/// </summary>
public class FakePrometheusQueryClient : IPrometheusQueryClient
{
private Dictionary<string, double> results = new();
public void SetResults(Dictionary<string, double> queryResults)
{
results = queryResults;
}
public Task<double?> QueryScalarAsync(KubernetesCluster cluster, string prometheusUrl, string promql, CancellationToken ct = default)
{
if (results.TryGetValue(promql, out double value))
{
return Task.FromResult<double?>(value);
}
return Task.FromResult<double?>(null);
}
}
/// <summary>
/// Simple fake repository for test isolation.
/// </summary>
public class FakeClusterRepository : IClusterRepository
{
private readonly List<KubernetesCluster> clusters = new();
public Task<KubernetesCluster?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
return Task.FromResult(clusters.FirstOrDefault(c => c.Id == id));
}
public Task<IReadOnlyList<KubernetesCluster>> GetAllAsync(CancellationToken ct = default)
{
return Task.FromResult<IReadOnlyList<KubernetesCluster>>(clusters.AsReadOnly());
}
public Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
clusters.Add(cluster);
return Task.CompletedTask;
}
public Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task DeleteAsync(Guid id, CancellationToken ct = default)
{
clusters.RemoveAll(c => c.Id == id);
return Task.CompletedTask;
}
}