mirror of
https://github.com/MAKS-IT-COM/maksit-cluster-console.git
synced 2026-11-13 20:26:10 +01:00
326 lines
11 KiB
C#
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" }
|
|
}
|
|
}
|
|
};
|
|
}
|