< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Health.PowerShellScriptProbe
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Health/PowerShellScriptProbe.cs
Tag: Kestrun/Kestrun@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
0%
Covered lines: 0
Uncovered lines: 163
Coverable lines: 163
Total lines: 490
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 163
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 10/13/2025 - 16:52:37 Line coverage: 0% (0/163) Branch coverage: 0% (0/163) Total lines: 490 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e 10/13/2025 - 16:52:37 Line coverage: 0% (0/163) Branch coverage: 0% (0/163) Total lines: 490 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e

Metrics

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Health/PowerShellScriptProbe.cs

#LineLine coverage
 1using System.Collections;
 2using System.Management.Automation;
 3using System.Management.Automation.Runspaces;
 4using Kestrun.Languages;
 5using Kestrun.Scripting;
 6using SerilogLogger = Serilog.ILogger;
 7using Serilog.Events;
 8
 9namespace Kestrun.Health;
 10
 11/// <summary>
 12/// A health probe implemented via a PowerShell script.
 13/// </summary>
 14/// <param name="name">The name of the probe.</param>
 15/// <param name="tags">The tags associated with the probe.</param>
 16/// <param name="script">The PowerShell script to execute.</param>
 17/// <param name="logger">The logger to use for logging.</param>
 18/// <param name="poolAccessor">A function to access the runspace pool.</param>
 19/// <param name="arguments">The arguments for the script.</param>
 20internal sealed class PowerShellScriptProbe(
 21        string name,
 22        IEnumerable<string>? tags,
 23        string script,
 24        SerilogLogger logger,
 25        Func<KestrunRunspacePoolManager> poolAccessor,
 026        IReadOnlyDictionary<string, object?>? arguments) : Probe(name, tags, logger), IProbe
 27{
 028    private readonly Func<KestrunRunspacePoolManager> _poolAccessor = poolAccessor ?? throw new ArgumentNullException(na
 029    private readonly IReadOnlyDictionary<string, object?>? _arguments = arguments;
 030    private readonly string _script = string.IsNullOrWhiteSpace(script)
 031        ? throw new ArgumentException("Probe script cannot be null or whitespace.", nameof(script))
 032        : script;
 33
 34    /// <inheritdoc/>
 35    public override async Task<ProbeResult> CheckAsync(CancellationToken ct = default)
 36    {
 037        var pool = _poolAccessor();
 038        Runspace? runspace = null;
 39        try
 40        {
 041            if (Logger.IsEnabled(LogEventLevel.Debug))
 42            {
 043                Logger.Debug("PowerShellScriptProbe {Probe} acquiring runspace", Name);
 44            }
 045            runspace = await pool.AcquireAsync(ct).ConfigureAwait(false);
 046            using var ps = CreateConfiguredPowerShell(runspace);
 047            var output = await InvokeScriptAsync(ps, ct).ConfigureAwait(false);
 048            return ProcessOutput(ps, output);
 49        }
 050        catch (PipelineStoppedException) when (ct.IsCancellationRequested)
 51        {
 052            Logger.Information("PowerShell health probe {Probe} canceled (PipelineStopped).", Name);
 053            return new ProbeResult(ProbeStatus.Degraded, "Canceled");
 54        }
 055        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 56        {
 057            throw;
 58        }
 059        catch (Exception ex)
 60        {
 061            if (ex is PipelineStoppedException)
 62            {
 063                Logger.Warning(ex, "PowerShell health probe {Probe} pipeline stopped.", Name);
 064                return new ProbeResult(ProbeStatus.Degraded, "Canceled");
 65            }
 066            Logger.Error(ex, "PowerShell health probe {Probe} failed.", Name);
 067            return new ProbeResult(ProbeStatus.Unhealthy, $"Exception: {ex.Message}");
 68        }
 69        finally
 70        {
 071            if (runspace is not null)
 72            {
 073                try { pool.Release(runspace); }
 074                catch { runspace.Dispose(); }
 75            }
 76        }
 077    }
 78
 79    /// <summary>
 80    /// Creates and configures a PowerShell instance with the provided runspace and script.
 81    /// </summary>
 82    /// <param name="runspace">The runspace to use for the PowerShell instance.</param>
 83    /// <returns>A configured PowerShell instance.</returns>
 84    private PowerShell CreateConfiguredPowerShell(Runspace runspace)
 85    {
 086        var ps = PowerShell.Create();
 087        ps.Runspace = runspace;
 088        PowerShellExecutionHelpers.SetVariables(ps, _arguments, Logger);
 089        PowerShellExecutionHelpers.AddScript(ps, _script);
 090        if (Logger.IsEnabled(LogEventLevel.Debug))
 91        {
 092            Logger.Debug("PowerShellScriptProbe {Probe} invoking script length={Length}", Name, _script.Length);
 93        }
 094        return ps;
 95    }
 96
 97    /// <summary>
 98    /// Invokes the PowerShell script asynchronously.
 99    /// </summary>
 100    /// <param name="ps">The PowerShell instance to use.</param>
 101    /// <param name="ct">The cancellation token.</param>
 102    /// <returns>A task representing the asynchronous operation, with a list of PSObject as the result.</returns>
 103    private async Task<IReadOnlyList<PSObject>> InvokeScriptAsync(PowerShell ps, CancellationToken ct)
 104    {
 0105        var output = await PowerShellExecutionHelpers.InvokeAsync(ps, Logger, ct).ConfigureAwait(false);
 0106        if (Logger.IsEnabled(LogEventLevel.Debug))
 107        {
 0108            Logger.Debug("PowerShellScriptProbe {Probe} received {Count} output objects", Name, output.Count);
 109        }
 110        // Materialize to a List to satisfy IReadOnlyList contract and avoid invalid casts.
 0111        return output.Count == 0 ? Array.Empty<PSObject>() : new List<PSObject>(output);
 0112    }
 113
 114    /// <summary>
 115    /// Processes the output from the PowerShell script.
 116    /// </summary>
 117    /// <param name="ps">The PowerShell instance used to invoke the script.</param>
 118    /// <param name="output">The output objects returned by the script.</param>
 119    /// <returns>A ProbeResult representing the outcome of the script execution.</returns>
 120    private ProbeResult ProcessOutput(PowerShell ps, IReadOnlyList<PSObject> output)
 121    {
 0122        if (ps.HadErrors || ps.Streams.Error.Count > 0)
 123        {
 0124            var errors = string.Join("; ", ps.Streams.Error.Select(static e => e.ToString()));
 0125            ps.Streams.Error.Clear();
 0126            return new ProbeResult(ProbeStatus.Unhealthy, errors);
 127        }
 128
 0129        for (var i = output.Count - 1; i >= 0; i--)
 130        {
 0131            if (TryConvert(output[i], out var result))
 132            {
 0133                if (Logger.IsEnabled(LogEventLevel.Debug))
 134                {
 0135                    Logger.Debug("PowerShellScriptProbe {Probe} converted output index={Index} status={Status}", Name, i
 136                }
 0137                return result;
 138            }
 139        }
 0140        return new ProbeResult(ProbeStatus.Unhealthy, "PowerShell probe produced no recognizable result.");
 141    }
 142
 143    /// <summary>
 144    /// Tries to convert a PSObject to a ProbeResult.
 145    /// </summary>
 146    /// <param name="obj">The PSObject to convert.</param>
 147    /// <param name="result">The resulting ProbeResult.</param>
 148    /// <returns>True if the conversion was successful, false otherwise.</returns>
 149    private static bool TryConvert(PSObject obj, out ProbeResult result)
 150    {
 151        // Direct pass-through if already a ProbeResult
 0152        if (TryUnwrapProbeResult(obj, out result))
 153        {
 0154            return true;
 155        }
 156
 157        // Try string-based conversion
 0158        if (TryConvertFromString(obj, out result))
 159        {
 0160            return true;
 161        }
 162
 163        // Try property-based conversion
 0164        if (TryConvertFromProperties(obj, out result))
 165        {
 0166            return true;
 167        }
 168
 0169        result = default!;
 0170        return false;
 171    }
 172
 173    /// <summary>
 174    /// Handles string-based conversion scenario
 175    /// </summary>
 176    /// <param name="obj">The PSObject to convert.</param>
 177    /// <param name="result">The resulting ProbeResult.</param>
 178    /// <returns>True if the conversion was successful, false otherwise.</returns>
 179    private static bool TryConvertFromString(PSObject obj, out ProbeResult result)
 180    {
 0181        if (TryGetStatus(obj, out var status, out var descriptionWhenString, out var statusTextIsRaw))
 182        {
 0183            if (statusTextIsRaw)
 184            {
 185                // We interpreted the entire string object as both status + description
 0186                result = new ProbeResult(status, descriptionWhenString);
 0187                return true;
 188            }
 189        }
 0190        result = default!;
 0191        return false;
 192    }
 193
 194    /// <summary>
 195    /// Handles property-based conversion scenario
 196    /// </summary>
 197    /// <param name="obj">The PSObject to convert.</param>
 198    /// <param name="result">The resulting ProbeResult.</param>
 199    /// <returns>True if the conversion was successful, false otherwise.</returns>
 200    private static bool TryConvertFromProperties(PSObject obj, out ProbeResult result)
 201    {
 0202        if (TryGetStatus(obj, out var status, out var descriptionWhenString, out var statusTextIsRaw))
 203        {
 0204            if (!statusTextIsRaw)
 205            {
 0206                var description = descriptionWhenString ?? GetDescription(obj);
 0207                var data = GetDataDictionary(obj);
 0208                result = new ProbeResult(status, description, data);
 0209                return true;
 210            }
 211        }
 0212        result = default!;
 0213        return false;
 214    }
 215    /// <summary>
 216    /// Tries to unwrap a PSObject that directly wraps a ProbeResult.
 217    /// </summary>
 218    /// <param name="obj">The PSObject to unwrap.</param>
 219    /// <param name="result">The unwrapped ProbeResult.</param>
 220    /// <returns>True if the unwrapping was successful, false otherwise.</returns>
 221    private static bool TryUnwrapProbeResult(PSObject obj, out ProbeResult result)
 222    {
 0223        if (obj.BaseObject is ProbeResult pr)
 224        {
 0225            result = pr;
 0226            return true;
 227        }
 0228        result = default!;
 0229        return false;
 230    }
 231
 232    /// <summary>
 233    /// Parses the status from a PSObject.
 234    /// </summary>
 235    /// <param name="obj">The PSObject to parse.</param>
 236    /// <param name="status">The parsed ProbeStatus.</param>
 237    /// <param name="descriptionOrRaw">The description or raw status string.</param>
 238    /// <param name="isRawString">True if the status was a raw string, false otherwise.</param>
 239    /// <returns>True if the parsing was successful, false otherwise.</returns>
 240    private static bool TryGetStatus(PSObject obj, out ProbeStatus status, out string? descriptionOrRaw, out bool isRawS
 241    {
 0242        var statusValue = obj.Properties["status"]?.Value ?? obj.Properties["Status"]?.Value;
 0243        if (statusValue is null)
 244        {
 0245            if (obj.BaseObject is string statusText && TryParseStatus(statusText, out var parsedFromText))
 246            {
 0247                status = parsedFromText;
 0248                descriptionOrRaw = statusText;
 0249                isRawString = true;
 0250                return true;
 251            }
 0252            status = default;
 0253            descriptionOrRaw = null;
 0254            isRawString = false;
 0255            return false;
 256        }
 257
 0258        status = TryParseStatus(statusValue.ToString(), out var parsed)
 0259            ? parsed
 0260            : ProbeStatus.Unhealthy;
 0261        descriptionOrRaw = null; // description will be resolved separately
 0262        isRawString = false;
 0263        return true;
 264    }
 265    /// <summary>
 266    /// Gets the description from a PSObject.
 267    /// </summary>
 268    /// <param name="obj">The PSObject to extract the description from.</param>
 269    /// <returns>The description string, or null if not found.</returns>
 270    private static string? GetDescription(PSObject obj)
 0271        => obj.Properties["description"]?.Value?.ToString() ?? obj.Properties["Description"]?.Value?.ToString();
 272
 273    /// <summary>
 274    /// Gets the data dictionary from a PSObject.
 275    /// </summary>
 276    /// <param name="obj">The PSObject to extract the data from.</param>
 277    /// <returns>The data dictionary, or null if not found or empty.</returns>
 278    private static Dictionary<string, object>? GetDataDictionary(PSObject obj)
 279    {
 280        // Extract underlying dictionary (case-insensitive property name handling already done)
 0281        var dictionary = TryExtractDataDictionary(obj);
 0282        if (dictionary is null)
 283        {
 0284            return null;
 285        }
 286
 287        // Build normalized temporary dictionary (allows null filtering before final allocation)
 0288        var temp = BuildNormalizedData(dictionary);
 0289        if (temp.Count == 0)
 290        {
 0291            return null; // No meaningful data left after normalization
 292        }
 293
 0294        return PromoteData(temp);
 295    }
 296
 297    private static IDictionary? TryExtractDataDictionary(PSObject obj)
 298    {
 0299        var dataProperty = obj.Properties["data"] ?? obj.Properties["Data"];
 0300        return dataProperty?.Value is IDictionary dict && dict.Count > 0 ? dict : null;
 301    }
 302
 0303    private static bool IsValidDataKey(string? key) => !string.IsNullOrWhiteSpace(key);
 304
 305    /// <summary>
 306    /// Builds a normalized data dictionary from the original dictionary.
 307    /// </summary>
 308    /// <param name="dictionary">The original dictionary to normalize.</param>
 309    /// <returns>A new dictionary with normalized data.</returns>
 310    private static Dictionary<string, object?> BuildNormalizedData(IDictionary dictionary)
 311    {
 0312        var temp = new Dictionary<string, object?>(dictionary.Count, StringComparer.OrdinalIgnoreCase);
 0313        foreach (DictionaryEntry entry in dictionary)
 314        {
 0315            if (entry.Key is null || entry.Value is null)
 316            {
 317                continue;
 318            }
 0319            var key = entry.Key.ToString();
 0320            if (!IsValidDataKey(key))
 321            {
 322                continue;
 323            }
 0324            var normalized = NormalizePsValue(entry.Value);
 0325            if (normalized is not null)
 326            {
 0327                temp[key!] = normalized; // key validated
 328            }
 329        }
 0330        return temp;
 331    }
 332
 333    /// <summary>
 334    /// Promotes the data from a temporary dictionary to a final dictionary.
 335    /// </summary>
 336    /// <param name="temp">The temporary dictionary to promote.</param>
 337    /// <returns>The promoted dictionary.</returns>
 338    private static Dictionary<string, object> PromoteData(Dictionary<string, object?> temp)
 339    {
 0340        var final = new Dictionary<string, object>(temp.Count, StringComparer.OrdinalIgnoreCase);
 0341        foreach (var kvp in temp)
 342        {
 343            // Value cannot be null due to earlier filter
 0344            final[kvp.Key] = kvp.Value!;
 345        }
 0346        return final;
 347    }
 348
 349    /// <summary>
 350    /// Normalizes a value that may originate from PowerShell so JSON serialization stays lean.
 351    /// (Delegates to smaller helpers to keep cyclomatic complexity low.)
 352    /// </summary>
 353    /// <param name="value">The value to normalize.</param>
 354    /// <param name="depth">The current recursion depth.</param>
 355    /// <returns>The normalized value.</returns>
 356    private static object? NormalizePsValue(object? value, int depth = 0)
 357    {
 0358        if (value is null)
 359        {
 0360            return null;
 361        }
 0362        if (depth > 8)
 363        {
 0364            return CollapseAtDepth(value);
 365        }
 0366        if (value is PSObject psObj)
 367        {
 0368            return NormalizePsPsObject(psObj, depth);
 369        }
 0370        if (IsPrimitive(value))
 371        {
 0372            return value;
 373        }
 0374        if (value is IDictionary dict)
 375        {
 0376            return NormalizeDictionary(dict, depth);
 377        }
 378        // Avoid treating strings as IEnumerable<char>
 0379        return value switch
 0380        {
 0381            string s => s,
 0382            IEnumerable seq => NormalizeEnumerable(seq, depth),
 0383            _ => value
 0384        };
 385    }
 386
 387    /// <summary>
 388    /// Determines if a value is a primitive type that can be directly serialized.
 389    /// </summary>
 390    /// <param name="value">The value to check.</param>
 391    /// <returns>True if the value is a primitive type, false otherwise.</returns>
 0392    private static bool IsPrimitive(object value) => value is IFormattable && value.GetType().IsPrimitive;
 393
 394    /// <summary>
 395    /// Collapses a value to its string representation when maximum depth is reached.
 396    /// </summary>
 397    /// <param name="value">The value to collapse.</param>
 398    /// <returns>The string representation of the value.</returns>
 0399    private static string CollapseAtDepth(object value) => value is PSObject pso ? pso.ToString() : value.ToString() ?? 
 400
 401    /// <summary>
 402    /// Normalizes a PowerShell PSObject by extracting its base object and normalizing it.
 403    /// </summary>
 404    /// <param name="psObj">The PSObject to normalize.</param>
 405    /// <param name="depth">The current recursion depth.</param>
 406    private static object? NormalizePsPsObject(PSObject psObj, int depth)
 407    {
 0408        var baseObj = psObj.BaseObject;
 0409        return baseObj is null || ReferenceEquals(baseObj, psObj)
 0410            ? psObj.ToString()
 0411            : NormalizePsValue(baseObj, depth + 1);
 412    }
 413
 414    /// <summary>
 415    /// Normalizes a dictionary by converting its keys to strings and recursively normalizing its values.
 416    /// </summary>
 417    /// <param name="rawDict">The raw dictionary to normalize.</param>
 418    /// <param name="depth">The current recursion depth.</param>
 419    /// <returns>A normalized dictionary with string keys and normalized values.</returns>
 420    private static Dictionary<string, object?> NormalizeDictionary(IDictionary rawDict, int depth)
 421    {
 0422        var result = new Dictionary<string, object?>(rawDict.Count);
 0423        foreach (DictionaryEntry de in rawDict)
 424        {
 0425            if (de.Key is null)
 426            {
 427                continue;
 428            }
 0429            var k = de.Key.ToString();
 0430            if (string.IsNullOrWhiteSpace(k))
 431            {
 432                continue;
 433            }
 0434            result[k] = NormalizePsValue(de.Value, depth + 1);
 435        }
 0436        return result;
 437    }
 438
 439    /// <summary>
 440    /// Normalizes an enumerable by recursively normalizing its items.
 441    /// </summary>
 442    /// <param name="enumerable">The enumerable to normalize.</param>
 443    /// <param name="depth">The current recursion depth.</param>
 444    /// <returns>A list of normalized items.</returns>
 445    private static List<object?> NormalizeEnumerable(IEnumerable enumerable, int depth)
 446    {
 0447        var list = new List<object?>();
 0448        foreach (var item in enumerable)
 449        {
 0450            list.Add(NormalizePsValue(item, depth + 1));
 451        }
 0452        return list;
 453    }
 454
 455    /// <summary>
 456    /// Parses a status string into a ProbeStatus enum.
 457    /// </summary>
 458    /// <param name="value">The status string to parse.</param>
 459    /// <param name="status">The resulting ProbeStatus enum.</param>
 460    /// <returns>True if the parsing was successful, false otherwise.</returns>
 461    private static bool TryParseStatus(string? value, out ProbeStatus status)
 462    {
 0463        if (Enum.TryParse(value, ignoreCase: true, out status))
 464        {
 0465            return true;
 466        }
 467
 468
 0469        switch (value?.ToLowerInvariant())
 470        {
 471            case ProbeStatusLabels.STATUS_OK:
 472            case ProbeStatusLabels.STATUS_HEALTHY:
 0473                status = ProbeStatus.Healthy;
 0474                return true;
 475            case ProbeStatusLabels.STATUS_WARN:
 476            case ProbeStatusLabels.STATUS_WARNING:
 477            case ProbeStatusLabels.STATUS_DEGRADED:
 0478                status = ProbeStatus.Degraded;
 0479                return true;
 480            case ProbeStatusLabels.STATUS_FAIL:
 481            case ProbeStatusLabels.STATUS_FAILED:
 482            case ProbeStatusLabels.STATUS_UNHEALTHY:
 0483                status = ProbeStatus.Unhealthy;
 0484                return true;
 485            default:
 0486                status = ProbeStatus.Unhealthy;
 0487                return false;
 488        }
 489    }
 490}

Methods/Properties

.ctor(System.String,System.Collections.Generic.IEnumerable`1<System.String>,System.String,Serilog.ILogger,System.Func`1<Kestrun.Scripting.KestrunRunspacePoolManager>,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Object>)
CheckAsync()
CreateConfiguredPowerShell(System.Management.Automation.Runspaces.Runspace)
InvokeScriptAsync()
ProcessOutput(System.Management.Automation.PowerShell,System.Collections.Generic.IReadOnlyList`1<System.Management.Automation.PSObject>)
TryConvert(System.Management.Automation.PSObject,Kestrun.Health.ProbeResult&)
TryConvertFromString(System.Management.Automation.PSObject,Kestrun.Health.ProbeResult&)
TryConvertFromProperties(System.Management.Automation.PSObject,Kestrun.Health.ProbeResult&)
TryUnwrapProbeResult(System.Management.Automation.PSObject,Kestrun.Health.ProbeResult&)
TryGetStatus(System.Management.Automation.PSObject,Kestrun.Health.ProbeStatus&,System.String&,System.Boolean&)
GetDescription(System.Management.Automation.PSObject)
GetDataDictionary(System.Management.Automation.PSObject)
TryExtractDataDictionary(System.Management.Automation.PSObject)
IsValidDataKey(System.String)
BuildNormalizedData(System.Collections.IDictionary)
PromoteData(System.Collections.Generic.Dictionary`2<System.String,System.Object>)
NormalizePsValue(System.Object,System.Int32)
IsPrimitive(System.Object)
CollapseAtDepth(System.Object)
NormalizePsPsObject(System.Management.Automation.PSObject,System.Int32)
NormalizeDictionary(System.Collections.IDictionary,System.Int32)
NormalizeEnumerable(System.Collections.IEnumerable,System.Int32)
TryParseStatus(System.String,Kestrun.Health.ProbeStatus&)