< 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@0d738bf294e6281b936d031e1979d928007495ff
Line coverage
0%
Covered lines: 0
Uncovered lines: 164
Coverable lines: 164
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@10d476bee71c71ad215bb8ab59f219887b5b4a5e11/14/2025 - 12:29:34 Line coverage: 0% (0/164) Branch coverage: 0% (0/163) Total lines: 491 Tag: Kestrun/Kestrun@5e12b09a6838e68e704cd3dc975331b9e680a62612/18/2025 - 21:41:58 Line coverage: 0% (0/164) Branch coverage: 0% (0/163) Total lines: 490 Tag: Kestrun/Kestrun@0d738bf294e6281b936d031e1979d928007495ff 10/13/2025 - 16:52:37 Line coverage: 0% (0/163) Branch coverage: 0% (0/163) Total lines: 490 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e11/14/2025 - 12:29:34 Line coverage: 0% (0/164) Branch coverage: 0% (0/163) Total lines: 491 Tag: Kestrun/Kestrun@5e12b09a6838e68e704cd3dc975331b9e680a62612/18/2025 - 21:41:58 Line coverage: 0% (0/164) Branch coverage: 0% (0/163) Total lines: 490 Tag: Kestrun/Kestrun@0d738bf294e6281b936d031e1979d928007495ff

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 Serilog.Events;
 7using Kestrun.Hosting;
 8
 9namespace Kestrun.Health;
 10
 11/// <summary>
 12/// A health probe implemented via a PowerShell script.
 13/// </summary>
 14/// <param name="host">The Kestrun host instance.</param>
 15/// <param name="name">The name of the probe.</param>
 16/// <param name="tags">The tags associated with the probe.</param>
 17/// <param name="script">The PowerShell script to execute.</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        KestrunHost host,
 22        string name,
 23        IEnumerable<string>? tags,
 24        string script,
 25        Func<KestrunRunspacePoolManager> poolAccessor,
 026        IReadOnlyDictionary<string, object?>? arguments) : Probe(name, tags), IProbe
 27{
 028    private Serilog.ILogger Logger => host.Logger;
 029    private readonly Func<KestrunRunspacePoolManager> _poolAccessor = poolAccessor ?? throw new ArgumentNullException(na
 030    private readonly IReadOnlyDictionary<string, object?>? _arguments = arguments;
 031    private readonly string _script = string.IsNullOrWhiteSpace(script)
 032        ? throw new ArgumentException("Probe script cannot be null or whitespace.", nameof(script))
 033        : script;
 34
 35    /// <inheritdoc/>
 36    public override async Task<ProbeResult> CheckAsync(CancellationToken ct = default)
 37    {
 038        var pool = _poolAccessor();
 039        Runspace? runspace = null;
 40        try
 41        {
 042            if (Logger.IsEnabled(LogEventLevel.Debug))
 43            {
 044                Logger.Debug("PowerShellScriptProbe {Probe} acquiring runspace", Name);
 45            }
 046            runspace = await pool.AcquireAsync(ct).ConfigureAwait(false);
 047            using var ps = CreateConfiguredPowerShell(runspace);
 048            var output = await InvokeScriptAsync(ps, ct).ConfigureAwait(false);
 049            return ProcessOutput(ps, output);
 50        }
 051        catch (PipelineStoppedException) when (ct.IsCancellationRequested)
 52        {
 053            Logger.Information("PowerShell health probe {Probe} canceled (PipelineStopped).", Name);
 054            return new ProbeResult(ProbeStatus.Degraded, "Canceled");
 55        }
 056        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 57        {
 058            throw;
 59        }
 060        catch (Exception ex)
 61        {
 062            if (ex is PipelineStoppedException)
 63            {
 064                Logger.Warning(ex, "PowerShell health probe {Probe} pipeline stopped.", Name);
 065                return new ProbeResult(ProbeStatus.Degraded, "Canceled");
 66            }
 067            Logger.Error(ex, "PowerShell health probe {Probe} failed.", Name);
 068            return new ProbeResult(ProbeStatus.Unhealthy, $"Exception: {ex.Message}");
 69        }
 70        finally
 71        {
 072            if (runspace is not null)
 73            {
 074                try { pool.Release(runspace); }
 075                catch { runspace.Dispose(); }
 76            }
 77        }
 078    }
 79
 80    /// <summary>
 81    /// Creates and configures a PowerShell instance with the provided runspace and script.
 82    /// </summary>
 83    /// <param name="runspace">The runspace to use for the PowerShell instance.</param>
 84    /// <returns>A configured PowerShell instance.</returns>
 85    private PowerShell CreateConfiguredPowerShell(Runspace runspace)
 86    {
 087        var ps = PowerShell.Create();
 088        ps.Runspace = runspace;
 089        PowerShellExecutionHelpers.SetVariables(ps, _arguments, Logger);
 090        PowerShellExecutionHelpers.AddScript(ps, _script);
 091        if (Logger.IsEnabled(LogEventLevel.Debug))
 92        {
 093            Logger.Debug("PowerShellScriptProbe {Probe} invoking script length={Length}", Name, _script.Length);
 94        }
 095        return ps;
 96    }
 97
 98    /// <summary>
 99    /// Invokes the PowerShell script asynchronously.
 100    /// </summary>
 101    /// <param name="ps">The PowerShell instance to use.</param>
 102    /// <param name="ct">The cancellation token.</param>
 103    /// <returns>A task representing the asynchronous operation, with a list of PSObject as the result.</returns>
 104    private async Task<IReadOnlyList<PSObject>> InvokeScriptAsync(PowerShell ps, CancellationToken ct)
 105    {
 0106        var output = await ps.InvokeAsync(Logger, ct).ConfigureAwait(false);
 0107        if (Logger.IsEnabled(LogEventLevel.Debug))
 108        {
 0109            Logger.Debug("PowerShellScriptProbe {Probe} received {Count} output objects", Name, output.Count);
 110        }
 111        // Materialize to a List to satisfy IReadOnlyList contract and avoid invalid casts.
 0112        return output.Count == 0 ? Array.Empty<PSObject>() : new List<PSObject>(output);
 0113    }
 114
 115    /// <summary>
 116    /// Processes the output from the PowerShell script.
 117    /// </summary>
 118    /// <param name="ps">The PowerShell instance used to invoke the script.</param>
 119    /// <param name="output">The output objects returned by the script.</param>
 120    /// <returns>A ProbeResult representing the outcome of the script execution.</returns>
 121    private ProbeResult ProcessOutput(PowerShell ps, IReadOnlyList<PSObject> output)
 122    {
 0123        if (ps.HadErrors || ps.Streams.Error.Count > 0)
 124        {
 0125            var errors = string.Join("; ", ps.Streams.Error.Select(static e => e.ToString()));
 0126            ps.Streams.Error.Clear();
 0127            return new ProbeResult(ProbeStatus.Unhealthy, errors);
 128        }
 129
 0130        for (var i = output.Count - 1; i >= 0; i--)
 131        {
 0132            if (TryConvert(output[i], out var result))
 133            {
 0134                if (Logger.IsEnabled(LogEventLevel.Debug))
 135                {
 0136                    Logger.Debug("PowerShellScriptProbe {Probe} converted output index={Index} status={Status}", Name, i
 137                }
 0138                return result;
 139            }
 140        }
 0141        return new ProbeResult(ProbeStatus.Unhealthy, "PowerShell probe produced no recognizable result.");
 142    }
 143
 144    /// <summary>
 145    /// Tries to convert a PSObject to a ProbeResult.
 146    /// </summary>
 147    /// <param name="obj">The PSObject to convert.</param>
 148    /// <param name="result">The resulting ProbeResult.</param>
 149    /// <returns>True if the conversion was successful, false otherwise.</returns>
 150    private static bool TryConvert(PSObject obj, out ProbeResult result)
 151    {
 152        // Direct pass-through if already a ProbeResult
 0153        if (TryUnwrapProbeResult(obj, out result))
 154        {
 0155            return true;
 156        }
 157
 158        // Try string-based conversion
 0159        if (TryConvertFromString(obj, out result))
 160        {
 0161            return true;
 162        }
 163
 164        // Try property-based conversion
 0165        if (TryConvertFromProperties(obj, out result))
 166        {
 0167            return true;
 168        }
 169
 0170        result = default!;
 0171        return false;
 172    }
 173
 174    /// <summary>
 175    /// Handles string-based conversion scenario
 176    /// </summary>
 177    /// <param name="obj">The PSObject to convert.</param>
 178    /// <param name="result">The resulting ProbeResult.</param>
 179    /// <returns>True if the conversion was successful, false otherwise.</returns>
 180    private static bool TryConvertFromString(PSObject obj, out ProbeResult result)
 181    {
 0182        if (TryGetStatus(obj, out var status, out var descriptionWhenString, out var statusTextIsRaw))
 183        {
 0184            if (statusTextIsRaw)
 185            {
 186                // We interpreted the entire string object as both status + description
 0187                result = new ProbeResult(status, descriptionWhenString);
 0188                return true;
 189            }
 190        }
 0191        result = default!;
 0192        return false;
 193    }
 194
 195    /// <summary>
 196    /// Handles property-based conversion scenario
 197    /// </summary>
 198    /// <param name="obj">The PSObject to convert.</param>
 199    /// <param name="result">The resulting ProbeResult.</param>
 200    /// <returns>True if the conversion was successful, false otherwise.</returns>
 201    private static bool TryConvertFromProperties(PSObject obj, out ProbeResult result)
 202    {
 0203        if (TryGetStatus(obj, out var status, out var descriptionWhenString, out var statusTextIsRaw))
 204        {
 0205            if (!statusTextIsRaw)
 206            {
 0207                var description = descriptionWhenString ?? GetDescription(obj);
 0208                var data = GetDataDictionary(obj);
 0209                result = new ProbeResult(status, description, data);
 0210                return true;
 211            }
 212        }
 0213        result = default!;
 0214        return false;
 215    }
 216    /// <summary>
 217    /// Tries to unwrap a PSObject that directly wraps a ProbeResult.
 218    /// </summary>
 219    /// <param name="obj">The PSObject to unwrap.</param>
 220    /// <param name="result">The unwrapped ProbeResult.</param>
 221    /// <returns>True if the unwrapping was successful, false otherwise.</returns>
 222    private static bool TryUnwrapProbeResult(PSObject obj, out ProbeResult result)
 223    {
 0224        if (obj.BaseObject is ProbeResult pr)
 225        {
 0226            result = pr;
 0227            return true;
 228        }
 0229        result = default!;
 0230        return false;
 231    }
 232
 233    /// <summary>
 234    /// Parses the status from a PSObject.
 235    /// </summary>
 236    /// <param name="obj">The PSObject to parse.</param>
 237    /// <param name="status">The parsed ProbeStatus.</param>
 238    /// <param name="descriptionOrRaw">The description or raw status string.</param>
 239    /// <param name="isRawString">True if the status was a raw string, false otherwise.</param>
 240    /// <returns>True if the parsing was successful, false otherwise.</returns>
 241    private static bool TryGetStatus(PSObject obj, out ProbeStatus status, out string? descriptionOrRaw, out bool isRawS
 242    {
 0243        var statusValue = obj.Properties["status"]?.Value ?? obj.Properties["Status"]?.Value;
 0244        if (statusValue is null)
 245        {
 0246            if (obj.BaseObject is string statusText && TryParseStatus(statusText, out var parsedFromText))
 247            {
 0248                status = parsedFromText;
 0249                descriptionOrRaw = statusText;
 0250                isRawString = true;
 0251                return true;
 252            }
 0253            status = default;
 0254            descriptionOrRaw = null;
 0255            isRawString = false;
 0256            return false;
 257        }
 258
 0259        status = TryParseStatus(statusValue.ToString(), out var parsed)
 0260            ? parsed
 0261            : ProbeStatus.Unhealthy;
 0262        descriptionOrRaw = null; // description will be resolved separately
 0263        isRawString = false;
 0264        return true;
 265    }
 266    /// <summary>
 267    /// Gets the description from a PSObject.
 268    /// </summary>
 269    /// <param name="obj">The PSObject to extract the description from.</param>
 270    /// <returns>The description string, or null if not found.</returns>
 271    private static string? GetDescription(PSObject obj)
 0272        => obj.Properties["description"]?.Value?.ToString() ?? obj.Properties["Description"]?.Value?.ToString();
 273
 274    /// <summary>
 275    /// Gets the data dictionary from a PSObject.
 276    /// </summary>
 277    /// <param name="obj">The PSObject to extract the data from.</param>
 278    /// <returns>The data dictionary, or null if not found or empty.</returns>
 279    private static Dictionary<string, object>? GetDataDictionary(PSObject obj)
 280    {
 281        // Extract underlying dictionary (case-insensitive property name handling already done)
 0282        var dictionary = TryExtractDataDictionary(obj);
 0283        if (dictionary is null)
 284        {
 0285            return null;
 286        }
 287
 288        // Build normalized temporary dictionary (allows null filtering before final allocation)
 0289        var temp = BuildNormalizedData(dictionary);
 0290        if (temp.Count == 0)
 291        {
 0292            return null; // No meaningful data left after normalization
 293        }
 294
 0295        return PromoteData(temp);
 296    }
 297
 298    private static IDictionary? TryExtractDataDictionary(PSObject obj)
 299    {
 0300        var dataProperty = obj.Properties["data"] ?? obj.Properties["Data"];
 0301        return dataProperty?.Value is IDictionary dict && dict.Count > 0 ? dict : null;
 302    }
 303
 0304    private static bool IsValidDataKey(string? key) => !string.IsNullOrWhiteSpace(key);
 305
 306    /// <summary>
 307    /// Builds a normalized data dictionary from the original dictionary.
 308    /// </summary>
 309    /// <param name="dictionary">The original dictionary to normalize.</param>
 310    /// <returns>A new dictionary with normalized data.</returns>
 311    private static Dictionary<string, object?> BuildNormalizedData(IDictionary dictionary)
 312    {
 0313        var temp = new Dictionary<string, object?>(dictionary.Count, StringComparer.OrdinalIgnoreCase);
 0314        foreach (DictionaryEntry entry in dictionary)
 315        {
 0316            if (entry.Key is null || entry.Value is null)
 317            {
 318                continue;
 319            }
 0320            var key = entry.Key.ToString();
 0321            if (!IsValidDataKey(key))
 322            {
 323                continue;
 324            }
 0325            var normalized = NormalizePsValue(entry.Value);
 0326            if (normalized is not null)
 327            {
 0328                temp[key!] = normalized; // key validated
 329            }
 330        }
 0331        return temp;
 332    }
 333
 334    /// <summary>
 335    /// Promotes the data from a temporary dictionary to a final dictionary.
 336    /// </summary>
 337    /// <param name="temp">The temporary dictionary to promote.</param>
 338    /// <returns>The promoted dictionary.</returns>
 339    private static Dictionary<string, object> PromoteData(Dictionary<string, object?> temp)
 340    {
 0341        var final = new Dictionary<string, object>(temp.Count, StringComparer.OrdinalIgnoreCase);
 0342        foreach (var kvp in temp)
 343        {
 344            // Value cannot be null due to earlier filter
 0345            final[kvp.Key] = kvp.Value!;
 346        }
 0347        return final;
 348    }
 349
 350    /// <summary>
 351    /// Normalizes a value that may originate from PowerShell so JSON serialization stays lean.
 352    /// (Delegates to smaller helpers to keep cyclomatic complexity low.)
 353    /// </summary>
 354    /// <param name="value">The value to normalize.</param>
 355    /// <param name="depth">The current recursion depth.</param>
 356    /// <returns>The normalized value.</returns>
 357    private static object? NormalizePsValue(object? value, int depth = 0)
 358    {
 0359        if (value is null)
 360        {
 0361            return null;
 362        }
 0363        if (depth > 8)
 364        {
 0365            return CollapseAtDepth(value);
 366        }
 0367        if (value is PSObject psObj)
 368        {
 0369            return NormalizePsPsObject(psObj, depth);
 370        }
 0371        if (IsPrimitive(value))
 372        {
 0373            return value;
 374        }
 0375        if (value is IDictionary dict)
 376        {
 0377            return NormalizeDictionary(dict, depth);
 378        }
 379        // Avoid treating strings as IEnumerable<char>
 0380        return value switch
 0381        {
 0382            string s => s,
 0383            IEnumerable seq => NormalizeEnumerable(seq, depth),
 0384            _ => value
 0385        };
 386    }
 387
 388    /// <summary>
 389    /// Determines if a value is a primitive type that can be directly serialized.
 390    /// </summary>
 391    /// <param name="value">The value to check.</param>
 392    /// <returns>True if the value is a primitive type, false otherwise.</returns>
 0393    private static bool IsPrimitive(object value) => value is IFormattable && value.GetType().IsPrimitive;
 394
 395    /// <summary>
 396    /// Collapses a value to its string representation when maximum depth is reached.
 397    /// </summary>
 398    /// <param name="value">The value to collapse.</param>
 399    /// <returns>The string representation of the value.</returns>
 0400    private static string CollapseAtDepth(object value) => value is PSObject pso ? pso.ToString() : value.ToString() ?? 
 401
 402    /// <summary>
 403    /// Normalizes a PowerShell PSObject by extracting its base object and normalizing it.
 404    /// </summary>
 405    /// <param name="psObj">The PSObject to normalize.</param>
 406    /// <param name="depth">The current recursion depth.</param>
 407    private static object? NormalizePsPsObject(PSObject psObj, int depth)
 408    {
 0409        var baseObj = psObj.BaseObject;
 0410        return baseObj is null || ReferenceEquals(baseObj, psObj)
 0411            ? psObj.ToString()
 0412            : NormalizePsValue(baseObj, depth + 1);
 413    }
 414
 415    /// <summary>
 416    /// Normalizes a dictionary by converting its keys to strings and recursively normalizing its values.
 417    /// </summary>
 418    /// <param name="rawDict">The raw dictionary to normalize.</param>
 419    /// <param name="depth">The current recursion depth.</param>
 420    /// <returns>A normalized dictionary with string keys and normalized values.</returns>
 421    private static Dictionary<string, object?> NormalizeDictionary(IDictionary rawDict, int depth)
 422    {
 0423        var result = new Dictionary<string, object?>(rawDict.Count);
 0424        foreach (DictionaryEntry de in rawDict)
 425        {
 0426            if (de.Key is null)
 427            {
 428                continue;
 429            }
 0430            var k = de.Key.ToString();
 0431            if (string.IsNullOrWhiteSpace(k))
 432            {
 433                continue;
 434            }
 0435            result[k] = NormalizePsValue(de.Value, depth + 1);
 436        }
 0437        return result;
 438    }
 439
 440    /// <summary>
 441    /// Normalizes an enumerable by recursively normalizing its items.
 442    /// </summary>
 443    /// <param name="enumerable">The enumerable to normalize.</param>
 444    /// <param name="depth">The current recursion depth.</param>
 445    /// <returns>A list of normalized items.</returns>
 446    private static List<object?> NormalizeEnumerable(IEnumerable enumerable, int depth)
 447    {
 0448        var list = new List<object?>();
 0449        foreach (var item in enumerable)
 450        {
 0451            list.Add(NormalizePsValue(item, depth + 1));
 452        }
 0453        return list;
 454    }
 455
 456    /// <summary>
 457    /// Parses a status string into a ProbeStatus enum.
 458    /// </summary>
 459    /// <param name="value">The status string to parse.</param>
 460    /// <param name="status">The resulting ProbeStatus enum.</param>
 461    /// <returns>True if the parsing was successful, false otherwise.</returns>
 462    private static bool TryParseStatus(string? value, out ProbeStatus status)
 463    {
 0464        if (Enum.TryParse(value, ignoreCase: true, out status))
 465        {
 0466            return true;
 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(Kestrun.Hosting.KestrunHost,System.String,System.Collections.Generic.IEnumerable`1<System.String>,System.String,System.Func`1<Kestrun.Scripting.KestrunRunspacePoolManager>,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Object>)
get_Logger()
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&)