diff --git a/src/PSProxmoxVE.Core/Utilities/GuestStatusSnapshot.cs b/src/PSProxmoxVE.Core/Utilities/GuestStatusSnapshot.cs new file mode 100644 index 0000000..457764a --- /dev/null +++ b/src/PSProxmoxVE.Core/Utilities/GuestStatusSnapshot.cs @@ -0,0 +1,40 @@ +using System; +using Newtonsoft.Json.Linq; + +namespace PSProxmoxVE.Core.Utilities +{ + /// + /// Reads the fields of a guest status/current response that decide whether a + /// lifecycle wait (-Wait on Start, Stop, Restart, Reset, Resume) is finished. + /// + public static class GuestStatusSnapshot + { + /// + /// Evaluates a status/current response body against the status a caller is waiting for. + /// + /// Raw status/current response body. + /// The status being waited for (e.g. "running", "stopped", "paused"). + /// + /// StatusMatched: the guest reports . qmpstatus is + /// preferred over status when present, because PVE reports status=running with + /// qmpstatus=paused for a suspended VM. + /// Locked: the guest config still carries a lock, so the next API call against it + /// would fail to acquire the lock file. + /// + public static (bool StatusMatched, bool Locked) Evaluate(string json, string expectedStatus) + { + if (string.IsNullOrEmpty(json)) + return (false, false); + + var data = JObject.Parse(json)["data"]; + var status = data?["status"]?.ToString(); + var qmpStatus = data?["qmpstatus"]?.ToString(); + var effectiveStatus = qmpStatus ?? status; + + var matched = string.Equals(effectiveStatus, expectedStatus, StringComparison.OrdinalIgnoreCase); + var locked = !string.IsNullOrEmpty(data?["lock"]?.ToString()); + + return (matched, locked); + } + } +} diff --git a/tests/PSProxmoxVE.Core.Tests/Utilities/GuestStatusSnapshotTests.cs b/tests/PSProxmoxVE.Core.Tests/Utilities/GuestStatusSnapshotTests.cs new file mode 100644 index 0000000..bb47d53 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Utilities/GuestStatusSnapshotTests.cs @@ -0,0 +1,70 @@ +using PSProxmoxVE.Core.Utilities; +using Xunit; + +namespace PSProxmoxVE.Core.Tests.Utilities +{ + public class GuestStatusSnapshotTests + { + [Fact] + public void Evaluate_RunningAndUnlocked_MatchesAndIsNotLocked() + { + var json = @"{""data"": {""status"": ""running"", ""qmpstatus"": ""running""}}"; + + var result = GuestStatusSnapshot.Evaluate(json, "running"); + + Assert.True(result.StatusMatched); + Assert.False(result.Locked); + } + + [Fact] + public void Evaluate_RunningButStillLocked_MatchesAndIsLocked() + { + var json = @"{""data"": {""status"": ""running"", ""qmpstatus"": ""running"", ""lock"": ""clone""}}"; + + var result = GuestStatusSnapshot.Evaluate(json, "running"); + + Assert.True(result.StatusMatched); + Assert.True(result.Locked); + } + + [Fact] + public void Evaluate_PrefersQmpStatusOverStatus() + { + // PVE reports status=running with qmpstatus=paused for a suspended VM. + var json = @"{""data"": {""status"": ""running"", ""qmpstatus"": ""paused""}}"; + + Assert.False(GuestStatusSnapshot.Evaluate(json, "running").StatusMatched); + Assert.True(GuestStatusSnapshot.Evaluate(json, "paused").StatusMatched); + } + + [Fact] + public void Evaluate_ContainerResponseWithoutQmpStatus_FallsBackToStatus() + { + var json = @"{""data"": {""status"": ""stopped""}}"; + + var result = GuestStatusSnapshot.Evaluate(json, "stopped"); + + Assert.True(result.StatusMatched); + Assert.False(result.Locked); + } + + [Fact] + public void Evaluate_EmptyLockValue_IsNotLocked() + { + var json = @"{""data"": {""status"": ""stopped"", ""lock"": """"}}"; + + Assert.False(GuestStatusSnapshot.Evaluate(json, "stopped").Locked); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Evaluate_EmptyBody_DoesNotMatch(string? json) + { + var result = GuestStatusSnapshot.Evaluate(json!, "running"); + + Assert.False(result.StatusMatched); + Assert.False(result.Locked); + } + } +}