diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da0faa..be1493f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.1.1] - 2026-08-19 + ### Fixed - Release engine reads `` 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 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e989c04..b8c2d00 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.1.0 + 0.1.1 MaksIT.ClusterConsole MaksIT.ClusterConsole diff --git a/src/MaksIT.ClusterConsole.Shared/ClusterIssue.cs b/src/MaksIT.ClusterConsole.Shared/ClusterIssue.cs index b3b234f..f358dc8 100644 --- a/src/MaksIT.ClusterConsole.Shared/ClusterIssue.cs +++ b/src/MaksIT.ClusterConsole.Shared/ClusterIssue.cs @@ -28,7 +28,12 @@ public static class ClusterIssues { private static readonly HashSet HealthyNodeConditions = new(StringComparer.Ordinal) { "Ready", "HostUpgrades", - "SchedulingDisabled" + "SchedulingDisabled", + "EtcdIsVoter" + }; + + private static readonly HashSet UnhealthyWhenFalseNodeConditions = new(StringComparer.Ordinal) { + "EtcdIsVoter" }; public static ClusterIssueSet Collect( @@ -92,7 +97,7 @@ public static class ClusterIssues { foreach (var condition in conditions.OfType()) { var type = Text(condition["type"]); - if (!IsTrue(condition["status"]) || HealthyNodeConditions.Contains(type)) + if (!IsUnhealthyNodeCondition(type, condition["status"])) continue; var message = Text(condition["message"]); @@ -232,12 +237,25 @@ public static class ClusterIssues { ? value.TryGetValue(out var text) ? text ?? string.Empty : value.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) return false; if (value.TryGetValue(out var flag)) - return flag; + return flag == flagValue; return value.TryGetValue(out var text) - && text.Equals("True", StringComparison.OrdinalIgnoreCase); + && text.Equals(textValue, StringComparison.OrdinalIgnoreCase); } } diff --git a/src/MaksIT.ClusterConsole.Tests/ClusterIssuesTests.cs b/src/MaksIT.ClusterConsole.Tests/ClusterIssuesTests.cs index 52fdca0..0b042b5 100644 --- a/src/MaksIT.ClusterConsole.Tests/ClusterIssuesTests.cs +++ b/src/MaksIT.ClusterConsole.Tests/ClusterIssuesTests.cs @@ -13,7 +13,8 @@ public class ClusterIssuesTests { var nodes = new[] { Node("k3ssrv0001", "n1", nodeCreated, [ 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[] { @@ -27,7 +28,7 @@ public class ClusterIssuesTests { Assert.Empty(set.Errors); var node = Assert.Single(set.Warnings, w => w.Kind == "Node"); Assert.Equal("k3ssrv0001", node.ObjectName); - Assert.Contains("etcd", node.Message); + Assert.Contains("memory pressure", node.Message); Assert.Equal(ClusterIssues.Active, node.State); Assert.Equal("120d", node.Age); @@ -37,6 +38,44 @@ public class ClusterIssuesTests { 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");