maksit-cluster-console/src/MaksIT.ClusterConsole.Tests/ClusterIssuesTests.cs

326 lines
11 KiB
C#

using System.Text.Json.Nodes;
using MaksIT.ClusterConsole.Shared;
namespace MaksIT.ClusterConsole.Tests;
public class ClusterIssuesTests {
[Fact]
public void Collect_includes_node_conditions_and_warning_events() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
var nodeCreated = now.AddDays(-120);
var eventSeen = now.AddMinutes(-176);
var nodes = new[] {
Node("k3ssrv0001", "n1", nodeCreated, [
Condition("Ready", "True"),
Condition("EtcdIsVoter", "True", "Node is a voting member of the etcd cluster"),
Condition("MemoryPressure", "True", "kubelet has memory pressure")
])
};
var events = new[] {
Event("e1", "Warning", "invalid capacity 0 on image filesystem", "Node", "k3ssrv0001", "n1", eventSeen),
Event("e2", "Normal", "Started", "Pod", "ok", "p-ok", eventSeen)
};
var set = ClusterIssues.Collect(nodes, events, [], now);
Assert.Equal(2, set.Warnings.Count);
Assert.Empty(set.Errors);
var node = Assert.Single(set.Warnings, w => w.Kind == "Node");
Assert.Equal("k3ssrv0001", node.ObjectName);
Assert.Contains("memory pressure", node.Message);
Assert.Equal(ClusterIssues.Active, node.State);
Assert.Equal("120d", node.Age);
var warning = Assert.Single(set.Warnings, w => w.Kind == "Event");
Assert.StartsWith("invalid capacity", warning.Message);
Assert.Equal(ClusterIssues.Resolved, warning.State);
Assert.Equal("2h56m", warning.Age);
}
[Fact]
public void Collect_skips_healthy_etcd_voter_condition() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
var nodes = new[] {
Node("k3ssrv0001", "n1", now.AddDays(-121), [
Condition("Ready", "True"),
Condition("EtcdIsVoter", "True", "Node is a voting member of the etcd cluster")
])
};
var set = ClusterIssues.Collect(nodes, [], [], now);
Assert.Empty(set.Warnings);
Assert.Empty(set.Errors);
}
[Fact]
public void Collect_warns_when_etcd_is_not_a_voter() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
var nodeCreated = now.AddDays(-121);
var nodes = new[] {
Node("k3ssrv0001", "n1", nodeCreated, [
Condition("Ready", "True"),
Condition("EtcdIsVoter", "False", "this server has not yet been promoted from learner to voting member")
])
};
var set = ClusterIssues.Collect(nodes, [], [], now);
var warning = Assert.Single(set.Warnings);
Assert.Equal("Node", warning.Kind);
Assert.Equal("k3ssrv0001", warning.ObjectName);
Assert.Contains("learner", warning.Message);
Assert.Equal(ClusterIssues.Active, warning.State);
Assert.Equal("121d", warning.Age);
Assert.Empty(set.Errors);
}
[Fact]
public void Collect_keeps_latest_warning_per_involved_object() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
var older = now.AddHours(-4);
var newer = now.AddMinutes(-2);
var events = new[] {
Event("e1", "Warning", "old", "Node", "n", "uid", older, "Ready"),
Event("e2", "Warning", "new", "Node", "n", "uid", newer, "Ready")
};
var set = ClusterIssues.Collect([], events, [], now);
var warning = Assert.Single(set.Warnings);
Assert.Equal("new", warning.Message);
Assert.Equal(ClusterIssues.Active, warning.State);
}
[Fact]
public void Collect_skips_healthy_pod_warnings_and_keeps_unready_pods() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
var seen = now.AddMinutes(-3);
var healthy = Pod("ok", "p-ok", "Running", ready: true, priority: 0);
var unready = Pod("bad", "p-bad", "Running", ready: false, priority: 0);
var events = new[] {
Event("e1", "Warning", "probe failed", "Pod", "ok", "p-ok", seen),
Event("e2", "Warning", "probe failed", "Pod", "bad", "p-bad", seen)
};
var set = ClusterIssues.Collect([], events, [healthy, unready], now);
var warning = Assert.Single(set.Warnings);
Assert.Equal("bad", warning.ObjectName);
Assert.Equal(ClusterIssues.Active, warning.State);
}
[Fact]
public void Collect_puts_error_typed_events_in_errors() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
var seen = now.AddMinutes(-5);
var events = new[] {
Event("e1", "Error", "sync failed", "Deployment", "api", "d1", seen)
};
var set = ClusterIssues.Collect([], events, [], now);
Assert.Empty(set.Warnings);
var error = Assert.Single(set.Errors);
Assert.Equal("Event", error.Kind);
Assert.Equal("sync failed", error.Message);
Assert.Equal("Error", error.Severity);
Assert.Equal(ClusterIssues.Active, error.State);
}
[Fact]
public void Collect_marks_stale_events_resolved() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
var events = new[] {
Event(
"e1",
"Warning",
"Waiting for disk default-disk-080000000000 (/mnt/longhorn) on node k3ssrv0001 to be ready",
"Node",
"k3ssrv0001",
"n1",
now.AddMinutes(-26),
"Ready")
};
var set = ClusterIssues.Collect([], events, [], now);
var warning = Assert.Single(set.Warnings);
Assert.Equal(ClusterIssues.Resolved, warning.State);
}
[Fact]
public void Collect_marks_warning_resolved_when_later_normal_has_same_reason() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
var warningAt = now.AddMinutes(-3);
var events = new[] {
Event("e1", "Warning", "Waiting for disk to be ready", "Node", "k3ssrv0001", "n1", warningAt, "Ready"),
Event("e2", "Normal", "Disk default-disk-080000000000(/mnt/longhorn) on node k3ssrv0001 is ready", "Node", "k3ssrv0001", "n1", warningAt.AddSeconds(1), "Ready")
};
var set = ClusterIssues.Collect([], events, [], now);
var warning = Assert.Single(set.Warnings);
Assert.Equal(ClusterIssues.Resolved, warning.State);
Assert.StartsWith("Waiting for disk", warning.Message);
}
[Fact]
public void Collect_keeps_unhealthy_pod_warning_active_when_stale() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
var unready = Pod("bad", "p-bad", "Pending", ready: false, priority: 0);
var events = new[] {
Event("e1", "Warning", "0/3 nodes are available", "Pod", "bad", "p-bad", now.AddMinutes(-20), "FailedScheduling")
};
var set = ClusterIssues.Collect([], events, [unready], now);
var warning = Assert.Single(set.Warnings);
Assert.Equal(ClusterIssues.Active, warning.State);
}
[Fact]
public void Collect_includes_unreachable_services_and_pending_claims() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
var services = new[] {
new JsonObject {
["metadata"] = new JsonObject {
["name"] = "api",
["namespace"] = "apps",
["uid"] = "svc",
["creationTimestamp"] = now.AddMinutes(-5).UtcDateTime.ToString("o"),
["annotations"] = new JsonObject { ["lbipam.cilium.io/ips"] = "172.16.0.11" }
},
["spec"] = new JsonObject { ["type"] = "LoadBalancer" },
["status"] = new JsonObject()
}
};
var claims = new[] {
new JsonObject {
["metadata"] = new JsonObject {
["name"] = "data",
["namespace"] = "apps",
["uid"] = "pvc",
["creationTimestamp"] = now.AddMinutes(-8).UtcDateTime.ToString("o")
},
["status"] = new JsonObject {
["phase"] = "Pending",
["conditions"] = new JsonArray {
new JsonObject { ["message"] = "waiting for a volume" }
}
}
}
};
var set = ClusterIssues.Collect([], [], [], now, services, claims);
var service = Assert.Single(set.Warnings, issue => issue.Kind == "Service");
Assert.Equal("apps/api", service.ObjectName);
Assert.Equal(ClusterIssues.Active, service.State);
Assert.Contains("does not match", service.Message);
var claim = Assert.Single(set.Warnings, issue => issue.Kind == "PersistentVolumeClaim");
Assert.Equal("apps/data", claim.ObjectName);
Assert.Equal("waiting for a volume", claim.Message);
}
[Fact]
public void Caption_mentions_resolved_counts() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
Assert.Equal("Warnings: 0", ClusterIssues.Caption("Warnings", []));
Assert.Equal(
"Warnings: 1",
ClusterIssues.Caption("Warnings", [
Issue("a", ClusterIssues.Active, now)
]));
Assert.Equal(
"Warnings: 2 resolved",
ClusterIssues.Caption("Warnings", [
Issue("a", ClusterIssues.Resolved, now),
Issue("b", ClusterIssues.Resolved, now)
]));
Assert.Equal(
"Warnings: 1 (2 resolved)",
ClusterIssues.Caption("Warnings", [
Issue("a", ClusterIssues.Active, now),
Issue("b", ClusterIssues.Resolved, now),
Issue("c", ClusterIssues.Resolved, now)
]));
}
[Fact]
public void Format_lists_severity_state_object_and_message() {
var at = DateTimeOffset.Parse("2026-10-04T21:00:00Z");
var text = ClusterIssues.Format(new ClusterIssueSet(
[Issue("e1", ClusterIssues.Active, at)],
[]));
Assert.Contains("WARN Active Event obj (1m): msg", text, StringComparison.Ordinal);
}
[Fact]
public void Age_formats_hours_and_minutes() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
var when = now.AddHours(-4).AddMinutes(-30);
Assert.Equal("4h30m", JsonPath.Age(when, now));
}
private static ClusterIssue Issue(string id, string state, DateTimeOffset at) =>
new(id, "msg", "obj", "Event", "1m", at, "Warning", state);
private static JsonObject Node(string name, string uid, DateTimeOffset created, JsonObject[] conditions) =>
new() {
["metadata"] = new JsonObject {
["name"] = name,
["uid"] = uid,
["creationTimestamp"] = created.UtcDateTime.ToString("o")
},
["status"] = new JsonObject {
["conditions"] = new JsonArray(conditions.Select(c => c.DeepClone()).ToArray())
}
};
private static JsonObject Condition(string type, string status, string? message = null) {
var obj = new JsonObject { ["type"] = type, ["status"] = status };
if (message is not null)
obj["message"] = message;
return obj;
}
private static JsonObject Event(
string uid,
string type,
string message,
string kind,
string name,
string involvedUid,
DateTimeOffset lastTimestamp,
string? reason = null) =>
new() {
["metadata"] = new JsonObject { ["uid"] = uid, ["creationTimestamp"] = lastTimestamp.UtcDateTime.ToString("o") },
["type"] = type,
["reason"] = reason ?? type,
["message"] = message,
["lastTimestamp"] = lastTimestamp.UtcDateTime.ToString("o"),
["involvedObject"] = new JsonObject {
["kind"] = kind,
["name"] = name,
["uid"] = involvedUid
}
};
private static JsonObject Pod(string name, string uid, string phase, bool ready, int priority) =>
new() {
["metadata"] = new JsonObject { ["name"] = name, ["uid"] = uid, ["namespace"] = "kube-system" },
["spec"] = new JsonObject { ["priority"] = priority },
["status"] = new JsonObject {
["phase"] = phase,
["conditions"] = new JsonArray {
new JsonObject { ["type"] = "Ready", ["status"] = ready ? "True" : "False" }
}
}
};
}