(bugfix): skip healthy k3s etcd voter node warnings

This commit is contained in:
Maksym Sadovnychyy 2026-08-19 21:44:49 +02:00
parent bff0d5aeef
commit bf63cf1730
4 changed files with 68 additions and 8 deletions

View File

@ -6,9 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased] ## [Unreleased]
## [0.1.1] - 2026-08-19
### Fixed ### Fixed
- Release engine reads `<Version>` from `src/Directory.Build.props` when the UI csproj has none. - Release engine reads `<Version>` from `src/Directory.Build.props` when the UI csproj has none.
- Warnings no longer treat k3s `EtcdIsVoter=True` as unhealthy; only `False` (learner / non-voter) is raised.
## [0.1.0] - 2026-08-19 ## [0.1.0] - 2026-08-19

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Version>0.1.0</Version> <Version>0.1.1</Version>
<Product>MaksIT.ClusterConsole</Product> <Product>MaksIT.ClusterConsole</Product>
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle> <AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
</PropertyGroup> </PropertyGroup>

View File

@ -28,7 +28,12 @@ public static class ClusterIssues {
private static readonly HashSet<string> HealthyNodeConditions = new(StringComparer.Ordinal) { private static readonly HashSet<string> HealthyNodeConditions = new(StringComparer.Ordinal) {
"Ready", "Ready",
"HostUpgrades", "HostUpgrades",
"SchedulingDisabled" "SchedulingDisabled",
"EtcdIsVoter"
};
private static readonly HashSet<string> UnhealthyWhenFalseNodeConditions = new(StringComparer.Ordinal) {
"EtcdIsVoter"
}; };
public static ClusterIssueSet Collect( public static ClusterIssueSet Collect(
@ -92,7 +97,7 @@ public static class ClusterIssues {
foreach (var condition in conditions.OfType<JsonObject>()) { foreach (var condition in conditions.OfType<JsonObject>()) {
var type = Text(condition["type"]); var type = Text(condition["type"]);
if (!IsTrue(condition["status"]) || HealthyNodeConditions.Contains(type)) if (!IsUnhealthyNodeCondition(type, condition["status"]))
continue; continue;
var message = Text(condition["message"]); var message = Text(condition["message"]);
@ -232,12 +237,25 @@ public static class ClusterIssues {
? value.TryGetValue<string>(out var text) ? text ?? string.Empty : value.ToString() ?? string.Empty ? value.TryGetValue<string>(out var text) ? text ?? string.Empty : value.ToString() ?? string.Empty
: node?.ToString() ?? string.Empty; : node?.ToString() ?? string.Empty;
private static bool IsTrue(JsonNode? node) { private static bool IsUnhealthyNodeCondition(string type, JsonNode? status) {
if (UnhealthyWhenFalseNodeConditions.Contains(type))
return IsFalse(status);
return IsTrue(status) && !HealthyNodeConditions.Contains(type);
}
private static bool IsTrue(JsonNode? node) =>
IsConditionStatus(node, true, "True");
private static bool IsFalse(JsonNode? node) =>
IsConditionStatus(node, false, "False");
private static bool IsConditionStatus(JsonNode? node, bool flagValue, string textValue) {
if (node is not JsonValue value) if (node is not JsonValue value)
return false; return false;
if (value.TryGetValue<bool>(out var flag)) if (value.TryGetValue<bool>(out var flag))
return flag; return flag == flagValue;
return value.TryGetValue<string>(out var text) return value.TryGetValue<string>(out var text)
&& text.Equals("True", StringComparison.OrdinalIgnoreCase); && text.Equals(textValue, StringComparison.OrdinalIgnoreCase);
} }
} }

View File

@ -13,7 +13,8 @@ public class ClusterIssuesTests {
var nodes = new[] { var nodes = new[] {
Node("k3ssrv0001", "n1", nodeCreated, [ Node("k3ssrv0001", "n1", nodeCreated, [
Condition("Ready", "True"), Condition("Ready", "True"),
Condition("EtcdIsVoter", "True", "Node is a voting member of the etcd cluster") Condition("EtcdIsVoter", "True", "Node is a voting member of the etcd cluster"),
Condition("MemoryPressure", "True", "kubelet has memory pressure")
]) ])
}; };
var events = new[] { var events = new[] {
@ -27,7 +28,7 @@ public class ClusterIssuesTests {
Assert.Empty(set.Errors); Assert.Empty(set.Errors);
var node = Assert.Single(set.Warnings, w => w.Kind == "Node"); var node = Assert.Single(set.Warnings, w => w.Kind == "Node");
Assert.Equal("k3ssrv0001", node.ObjectName); Assert.Equal("k3ssrv0001", node.ObjectName);
Assert.Contains("etcd", node.Message); Assert.Contains("memory pressure", node.Message);
Assert.Equal(ClusterIssues.Active, node.State); Assert.Equal(ClusterIssues.Active, node.State);
Assert.Equal("120d", node.Age); Assert.Equal("120d", node.Age);
@ -37,6 +38,44 @@ public class ClusterIssuesTests {
Assert.Equal("2h56m", warning.Age); 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] [Fact]
public void Collect_keeps_latest_warning_per_involved_object() { public void Collect_keeps_latest_warning_per_involved_object() {
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z"); var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");