ClusterValidator.MCP.psm1
|
<#
ClusterValidator.MCP — a thin PowerShell client for a ClusterValidator MCP server. It contains NO validation logic, NO cluster access, NO licensing code, and NO engine. It launches the published stdio tool (dnx DetentPoint.ClusterValidator.Mcp), speaks MCP JSON-RPC over its stdin/stdout, and formats what the server returns. The server does the work and the server is what is licensed; this client withholds nothing. v1 transport is stdio-only (the tool runs locally on the DBA's own Windows machine). There is no HTTP endpoint, no API key, no TLS — by decision, not omission. #> Set-StrictMode -Version Latest # --------------------------------------------------------------------------- # Module state + compile-time constants # --------------------------------------------------------------------------- # The single active stdio session (set by Connect-CvValidator). $null when disconnected. $script:CvSession = $null # PIDs of child tool processes we started, so the session-exit reaper can kill an orphan even # when the shell closes without Remove-Module. At most one is live at a time. $script:CvChildPids = [System.Collections.Generic.List[int]]::new() $script:CvExitSubscriber = $null # Which published tool this client drives, and the compatibility bounds (ruling 2026-08-10): # hard-fail below the minimum; warn ONLY when the tool crosses a major version (stay quiet # within 1.x — a patch/minor must not nag every user). Kept in sync with the manifest's # PrivateData.ToolCompatibility. $script:CvToolPackageId = 'DetentPoint.ClusterValidator.Mcp' $script:CvToolMinVersion = [version]'1.12.0' $script:CvToolWarnAtOrAbove = [version]'2.0.0' # MCP + node constraints (the engine validates a 2-node minimum; 16 is the WSFC ceiling). $script:CvMcpProtocol = '2024-11-05' # Per-request stdio read timeout: a stalled/crashed tool throws a clear TimeoutException # instead of blocking the caller forever. Polls (get_validation_result) return in well under # this; the long validation itself is polled, never a single blocking call. $script:CvReadTimeoutMs = 180000 $script:CvMinNodes = 2 $script:CvMaxNodes = 16 # =========================================================================== # Private helpers # =========================================================================== function Resolve-CvLauncher { <# .SYNOPSIS Locate the launcher for the stdio tool (dnx), or fail with an actionable message. .NOTES Steps: 1. If an explicit command was given, return it unchanged (caller override wins). 2. Prefer `dnx` on PATH (the README/manifest install path). 3. Fall back to dnx.cmd in the .NET SDK folder ($env:ProgramFiles\dotnet). 4. Throw a terminating error naming the fix (install the .NET SDK) when none is found. #> [CmdletBinding()] param([string]$Command) if (-not [string]::IsNullOrWhiteSpace($Command)) { return $Command } $onPath = Get-Command 'dnx' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($onPath) { return $onPath.Source } $sdkDnx = Join-Path $env:ProgramFiles 'dotnet\dnx.cmd' if (Test-Path -LiteralPath $sdkDnx) { return $sdkDnx } throw [System.InvalidOperationException]::new( "Cannot find 'dnx'. ClusterValidator.MCP launches the tool with 'dnx $script:CvToolPackageId'. " + "Install the .NET SDK (10.0+) so 'dnx' is on PATH, or pass -Command with the launcher path.") } function Start-CvToolProcess { <# .SYNOPSIS Start the stdio tool as a child process with redirected pipes and return a session object. .NOTES Steps: 1. Build a ProcessStartInfo redirecting stdin/stdout/stderr; a .cmd/.bat launcher (dnx.cmd) runs via the command processor. 2. If a license token was supplied, inject it as CV_LICENSE into the child env only. 3. Start the process; on immediate failure surface the stderr tail, not a bare error. 4. Return a session hashtable (process, pipes, id counter) for the JSON-RPC helpers. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Command, [Parameter(Mandatory)][string[]]$ArgumentList, [string]$License ) $psi = [System.Diagnostics.ProcessStartInfo]::new() $psi.RedirectStandardInput = $true $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true # dnx ships as dnx.cmd on Windows; a .cmd/.bat cannot be started directly with # UseShellExecute=false, so run it through the command processor (stdio passes through). if ($Command -match '\.(cmd|bat)$') { $psi.FileName = $env:ComSpec $psi.ArgumentList.Add('/c') $psi.ArgumentList.Add($Command) } else { $psi.FileName = $Command } foreach ($a in $ArgumentList) { $psi.ArgumentList.Add($a) } # The license token is a secret: it goes only into the child's environment, is never # persisted, logged, or written to the verbose stream. if (-not [string]::IsNullOrWhiteSpace($License)) { $psi.EnvironmentVariables['CV_LICENSE'] = $License } try { $proc = [System.Diagnostics.Process]::Start($psi) } catch { throw [System.InvalidOperationException]::new( "Failed to launch the tool ('$Command'). $($_.Exception.Message)") } return @{ Process = $proc NextId = 1 ServerInfo = $null Command = $Command } } function Send-CvMcpNotification { <# .SYNOPSIS Send a JSON-RPC notification (no id, no response expected) to the tool. .NOTES Steps: 1. Compose a jsonrpc 2.0 notification object with the method (and params if any). 2. Serialize compact and write a single newline-delimited line to the tool's stdin. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Session, [Parameter(Mandatory)][string]$Method, $Params ) $msg = @{ jsonrpc = '2.0'; method = $Method } if ($null -ne $Params) { $msg.params = $Params } $line = $msg | ConvertTo-Json -Depth 20 -Compress $Session.Process.StandardInput.WriteLine($line) $Session.Process.StandardInput.Flush() } function Invoke-CvMcpRequest { <# .SYNOPSIS Send a JSON-RPC request over stdio and return its result, correlating by id. .NOTES Steps: 1. Allocate the next request id and compose the jsonrpc 2.0 request line. 2. Write it newline-delimited to the tool's stdin and flush. 3. Read stdout lines (bounded by a per-request timeout): skip blanks, non-JSON, notifications, other ids. 4. On the matching id, throw on a JSON-RPC error, else return the result payload. 5. Throw on EOF (tool closed the pipe) or TimeoutException (tool stalled past the read timeout). #> [CmdletBinding()] param( [Parameter(Mandatory)]$Session, [Parameter(Mandatory)][string]$Method, $Params ) $id = $Session.NextId $Session.NextId = $id + 1 $req = @{ jsonrpc = '2.0'; id = $id; method = $Method } if ($null -ne $Params) { $req.params = $Params } $line = $req | ConvertTo-Json -Depth 20 -Compress $Session.Process.StandardInput.WriteLine($line) $Session.Process.StandardInput.Flush() $deadlineUtc = [DateTime]::UtcNow.AddMilliseconds($script:CvReadTimeoutMs) while ($true) { $remaining = [int][Math]::Max(0, ($deadlineUtc - [DateTime]::UtcNow).TotalMilliseconds) $readTask = $Session.Process.StandardOutput.ReadLineAsync() if (-not $readTask.Wait($remaining)) { throw [System.TimeoutException]::new( "The tool did not respond within $([int]($script:CvReadTimeoutMs / 1000))s for '$Method' — it may be stalled. Run Disconnect-CvValidator and retry.") } $out = $readTask.Result if ($null -eq $out) { $errTail = '' try { $errTail = $Session.Process.StandardError.ReadToEnd() } catch { } throw [System.IO.IOException]::new( "The tool closed the connection before answering '$Method'." + $(if ($errTail) { " Tool stderr: $($errTail.Trim())" } else { '' })) } if ([string]::IsNullOrWhiteSpace($out)) { continue } try { $msg = $out | ConvertFrom-Json -ErrorAction Stop } catch { continue } # a stray non-JSON line on stdout: skip rather than fail $names = $msg.PSObject.Properties.Name if ($names -notcontains 'id' -or $msg.id -ne $id) { continue } # notification / other id if ($names -contains 'error' -and $null -ne $msg.error) { $emsg = if ($msg.error.PSObject.Properties.Name -contains 'message') { $msg.error.message } else { 'unknown error' } throw [System.InvalidOperationException]::new("The server rejected '$Method': $emsg") } return $msg.result } } function ConvertFrom-CvToolContent { <# .SYNOPSIS Extract and JSON-parse the text payload from an MCP tool-call result's content blocks. .NOTES Steps: 1. Concatenate the text of every 'text' content block in the result. 2. Return $null when there is no text (caller decides whether that is an error). 3. Parse the concatenated text as JSON and return the object (or the raw text on parse failure). #> [CmdletBinding()] param([Parameter(Mandatory)]$Result) $text = '' if ($Result -and ($Result.PSObject.Properties.Name -contains 'content') -and $Result.content) { foreach ($block in $Result.content) { if ($block.PSObject.Properties.Name -contains 'text' -and $block.text) { $text += $block.text } } } if ([string]::IsNullOrWhiteSpace($text)) { return $null } try { return $text | ConvertFrom-Json -ErrorAction Stop } catch { return $text } } function Test-CvToolVersion { <# .SYNOPSIS Apply the tool-version compatibility bounds: hard-fail below minimum, warn above ceiling. .NOTES Steps: 1. Read the version the server reported in its initialize serverInfo. 2. If it is missing or unparseable, warn once (do not hard-fail on an unknown version). 3. Below the declared minimum: throw a terminating error naming the required version. 4. Above the tested ceiling: warn that it is untested but allowed, and continue. #> [CmdletBinding()] param([Parameter(Mandatory)]$Session) $raw = $null if ($Session.ServerInfo -and ($Session.ServerInfo.PSObject.Properties.Name -contains 'version')) { $raw = $Session.ServerInfo.version } [version]$v = $null if ([string]::IsNullOrWhiteSpace($raw) -or -not [version]::TryParse($raw, [ref]$v)) { Write-Warning "Could not read the tool version from the server; skipping the compatibility check." return } if ($v -lt $script:CvToolMinVersion) { throw [System.InvalidOperationException]::new( "The tool version $v is older than the required minimum $($script:CvToolMinVersion). " + "Update it: dnx $script:CvToolPackageId --yes (or 'dotnet tool update -g $script:CvToolPackageId').") } if ($v -ge $script:CvToolWarnAtOrAbove) { Write-Warning "Tool version $v is a new major release (>= $($script:CvToolWarnAtOrAbove)) that this client has not been tested against; it is probably fine. Update the client if you hit problems." } } function Assert-CvConnected { <# .SYNOPSIS Ensure a live session exists, or throw a terminating error that says how to fix it. .NOTES Steps: 1. If no session is stored, or its process has exited, throw with the Connect hint. 2. Otherwise return silently so the caller can proceed. #> [CmdletBinding()] param() if ($null -eq $script:CvSession -or $script:CvSession.Process.HasExited) { throw [System.InvalidOperationException]::new( "Not connected to a ClusterValidator MCP server. Run Connect-CvValidator first.") } } function Stop-CvSession { <# .SYNOPSIS Kill the active tool process (if any), forget its PID, and clear the session slot. .NOTES Steps: 1. If there is no active session, do nothing. 2. Capture the child PID, then kill the process if it is still running. 3. Remove the PID from the reaper list and clear the session slot. #> [CmdletBinding()] param() if ($null -eq $script:CvSession) { return } $procId = $null try { $procId = [int]$script:CvSession.Process.Id } catch { } try { if (-not $script:CvSession.Process.HasExited) { $script:CvSession.Process.Kill() } } catch { } if ($null -ne $procId) { try { [void]$script:CvChildPids.Remove($procId) } catch { } } $script:CvSession = $null } function Invoke-CvTool { <# .SYNOPSIS Call a named MCP tool on the active session and return its parsed JSON payload. .NOTES Steps: 1. Assert a live session. 2. Issue a tools/call request with the tool name and arguments. 3. Extract and JSON-parse the tool's text content, returning the object. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Name, [hashtable]$Arguments = @{} ) Assert-CvConnected $result = Invoke-CvMcpRequest -Session $script:CvSession -Method 'tools/call' -Params @{ name = $Name; arguments = $Arguments } return ConvertFrom-CvToolContent -Result $result } # =========================================================================== # Public cmdlets (Cv noun prefix; six, mapping to the five server tools + connect) # =========================================================================== function Connect-CvValidator { <# .SYNOPSIS Launch the local ClusterValidator MCP tool, complete the MCP handshake, and report its version. .DESCRIPTION Starts the published stdio tool (dnx DetentPoint.ClusterValidator.Mcp) as a child process, performs the MCP initialize/initialized handshake, applies the tool-version compatibility check, and stores the session for the other cmdlets. Windows only; the tool needs CIM/AD at runtime. If a license token is supplied it is passed to the tool's environment as CV_LICENSE and never persisted or logged; with none, the tool runs in Free mode (2 clusters/month). .PARAMETER License The CV_LICENSE token. Defaults to the CV_LICENSE environment variable when omitted. A value passed here is used for the child process only and is never written to disk, log, or verbose. .PARAMETER Command Override the launcher (defaults to resolving 'dnx'). .PARAMETER ArgumentList Override the launcher arguments (defaults to the tool package id and --yes). .EXAMPLE Connect-CvValidator Launches the tool in Free mode and prints the server name and version. .EXAMPLE Connect-CvValidator -License $env:CV_LICENSE Launches the tool with a purchased license so higher tiers unlock. .NOTES Steps: 1. Dispose any prior session (kill a still-running child) so Connect is idempotent. 2. Resolve the launcher and default arguments; start the tool with redirected stdio. 3. Send initialize; capture serverInfo; send the initialized notification. 4. Apply the tool-version bounds (hard-floor / warn-ceiling). 5. Store the session and emit a small connection object (server name + version). #> [CmdletBinding()] [OutputType([pscustomobject])] param( [string]$License = $env:CV_LICENSE, [string]$Command, [string[]]$ArgumentList ) Stop-CvSession # idempotent; disposes a prior connection so Connect can be re-run $launcher = Resolve-CvLauncher -Command $Command if (-not $ArgumentList -or $ArgumentList.Count -eq 0) { $ArgumentList = @($script:CvToolPackageId, '--yes') } $session = Start-CvToolProcess -Command $launcher -ArgumentList $ArgumentList -License $License try { [void]$script:CvChildPids.Add([int]$session.Process.Id) } catch { } $initParams = @{ protocolVersion = $script:CvMcpProtocol capabilities = @{} clientInfo = @{ name = 'ClusterValidator.MCP'; version = '1.0.0' } } try { $init = Invoke-CvMcpRequest -Session $session -Method 'initialize' -Params $initParams } catch { try { if (-not $session.Process.HasExited) { $session.Process.Kill() } } catch { } try { [void]$script:CvChildPids.Remove([int]$session.Process.Id) } catch { } throw [System.InvalidOperationException]::new( "Connected to the tool but the MCP handshake failed. $($_.Exception.Message)") } if ($init -and ($init.PSObject.Properties.Name -contains 'serverInfo')) { $session.ServerInfo = $init.serverInfo } Send-CvMcpNotification -Session $session -Method 'notifications/initialized' $script:CvSession = $session Test-CvToolVersion -Session $session $name = if ($session.ServerInfo) { $session.ServerInfo.name } else { 'ClusterValidator' } $ver = if ($session.ServerInfo -and ($session.ServerInfo.PSObject.Properties.Name -contains 'version')) { $session.ServerInfo.version } else { 'unknown' } Write-Verbose "Connected to $name $ver via $launcher." [pscustomobject]@{ PSTypeName = 'ClusterValidator.MCP.Connection' Server = $name Version = $ver Launcher = $launcher Licensed = -not [string]::IsNullOrWhiteSpace($License) } } function Disconnect-CvValidator { <# .SYNOPSIS Stop the tool process and end the session. Idempotent — a quiet no-op when not connected. .DESCRIPTION Kills the child stdio tool started by Connect-CvValidator and clears the session. Safe to call when nothing is connected (returns without error). The child is also reaped automatically on Remove-Module and when the PowerShell session exits, so a closed shell never leaves an orphan. .EXAMPLE Disconnect-CvValidator .NOTES Steps: 1. If no session is active, return quietly (idempotent). 2. Otherwise kill the child tool and clear the session state. #> [CmdletBinding()] param() if ($null -eq $script:CvSession) { return } Stop-CvSession } function Invoke-CvValidation { <# .SYNOPSIS Start a cluster validation run against 2-16 nodes; return a run id (or the finished result). .DESCRIPTION Calls run_cluster_validation on the connected tool. Node count is validated client-side (2-16) before the call. Credentials, if supplied, are mapped to the tool's remoting parameters; with none the tool uses the caller's current identity. .PARAMETER Node The cluster node names (2 to 16). .PARAMETER Credential Optional credential for remoting to the nodes (domain/user/password). .PARAMETER Stage Which phase set to run; defaults to Auto (the full ordered run). .PARAMETER ReportPath Directory for the HTML/JSON/transcript report triad the run writes. Defaults to a ClusterValidator folder under the temp path, created if missing, so that Export-CvValidationReport can return the HTML report without extra setup. .PARAMETER ExpectedDiskCount Expected shared-disk count per node. Left at 0 (the default), the Storage phase reports the disk count without passing or failing on it. Set it to your cluster's known count to have a node whose count differs flagged as a Fail. .PARAMETER Wait Poll to completion and return the finished result instead of just the run id. .EXAMPLE Invoke-CvValidation -Node sqlnode1, sqlnode2 Starts a run and returns its run id. .EXAMPLE Invoke-CvValidation -Node sqlnode1, sqlnode2 -Wait Starts a run, shows progress, and returns the completed result. .EXAMPLE $cred = Get-Credential CONTOSO\clusteradmin $r = Invoke-CvValidation -Node sqlnode1, sqlnode2 -Credential $cred -Wait Get-CvValidationFinding -RunId $r.RunId -Status Fail Validates two nodes with an explicit credential from a non-domain-joined host, waits for completion, then lists only the Fail findings. .NOTES Steps: 1. Assert a live session and validate the node count is within 2-16. 2. Resolve the report directory (default under temp), creating it if missing. 3. Map an optional credential to remotingDomain/remotingUser/remotingPassword. 4. Call run_cluster_validation (with the report path) and read the returned runId. 5. With -Wait, delegate to Get-CvValidationResult -Wait; otherwise emit the run id object. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$Node, [pscredential]$Credential, [string]$Stage = 'Auto', [string]$ReportPath, [int]$ExpectedDiskCount = 0, [switch]$Wait ) Assert-CvConnected $count = @($Node).Count if ($count -lt $script:CvMinNodes -or $count -gt $script:CvMaxNodes) { throw [System.ArgumentException]::new( "Supply between $($script:CvMinNodes) and $($script:CvMaxNodes) node names; you supplied $count.", 'Node') } # A run must write its report somewhere the tool can hand back, or export_cluster_report # returns nothing. Default to a ClusterValidator temp folder so the report just works. if ([string]::IsNullOrWhiteSpace($ReportPath)) { $ReportPath = Join-Path ([System.IO.Path]::GetTempPath()) 'ClusterValidator' } if (-not (Test-Path -LiteralPath $ReportPath)) { New-Item -ItemType Directory -Path $ReportPath -Force | Out-Null } $dom = ''; $usr = ''; $pass = '' if ($Credential) { $nc = $Credential.GetNetworkCredential() $dom = $nc.Domain; $usr = $nc.UserName; $pass = $nc.Password } $payload = Invoke-CvTool -Name 'run_cluster_validation' -Arguments @{ nodes = $Node stage = $Stage remotingDomain = $dom remotingUser = $usr remotingPassword = $pass reportPath = $ReportPath expectedDiskCount = $ExpectedDiskCount } # A license refusal comes back as content (refused=true) with the plain-English reason — # surface it directly, not as a transport error. The run was not started. if ($payload -and ($payload.PSObject.Properties.Name -contains 'refused') -and $payload.refused) { $reason = if (($payload.PSObject.Properties.Name -contains 'refusalReason') -and $payload.refusalReason) { $payload.refusalReason } else { 'The validation was refused by the license gate.' } throw [System.InvalidOperationException]::new([string]$reason) } $runId = if ($payload -and ($payload.PSObject.Properties.Name -contains 'runId')) { $payload.runId } else { $null } if ([string]::IsNullOrWhiteSpace($runId)) { throw [System.InvalidOperationException]::new("The tool did not return a run id. Raw response: $payload") } if ($Wait) { return Get-CvValidationResult -RunId $runId -Wait } [pscustomobject]@{ PSTypeName = 'ClusterValidator.MCP.Run'; RunId = $runId } } function Get-CvValidationResult { <# .SYNOPSIS Get the status/summary of a run; with -Wait, poll to completion showing progress. .PARAMETER RunId The run id from Invoke-CvValidation. .PARAMETER Wait Poll until the run reaches Completed or Failed, updating Write-Progress each phase. .PARAMETER PollSeconds Seconds between polls while waiting (default 4). .EXAMPLE Get-CvValidationResult -RunId $r.RunId -Wait .NOTES Steps: 1. Assert a live session. 2. Call get_validation_result for the run id. 3. Without -Wait, emit the current status object once. 4. With -Wait, loop: update Write-Progress on phase change, stop on Completed/Failed. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string]$RunId, [switch]$Wait, [int]$PollSeconds = 4 ) Assert-CvConnected $poll = { $p = Invoke-CvTool -Name 'get_validation_result' -Arguments @{ runId = $RunId } [pscustomobject]@{ PSTypeName = 'ClusterValidator.MCP.Result' RunId = $RunId Status = if ($p.PSObject.Properties.Name -contains 'status') { $p.status } else { 'Unknown' } CurrentPhase = if ($p.PSObject.Properties.Name -contains 'currentPhase') { $p.currentPhase } else { '' } ExitCode = if ($p.PSObject.Properties.Name -contains 'exitCode') { $p.exitCode } else { $null } FindingCount = if ($p.PSObject.Properties.Name -contains 'results' -and $p.results) { @($p.results).Count } else { 0 } } } if (-not $Wait) { return & $poll } $last = '' try { while ($true) { $r = & $poll if ($r.CurrentPhase -and $r.CurrentPhase -ne $last) { Write-Progress -Activity "Validating cluster (run $RunId)" -Status $r.CurrentPhase $last = $r.CurrentPhase } if ($r.Status -in 'Completed', 'Failed') { return $r } Start-Sleep -Seconds $PollSeconds } } finally { Write-Progress -Activity "Validating cluster (run $RunId)" -Completed } } function Get-CvValidationFinding { <# .SYNOPSIS Emit one object per finding for a run; filter by -Phase and -Status. .PARAMETER RunId The run id. .PARAMETER Phase Only findings for this phase. .PARAMETER Status Only findings with this status (e.g. Pass, Warning, Fail). .EXAMPLE Get-CvValidationFinding -RunId $r.RunId -Status Fail | Export-Csv failures.csv .NOTES Steps: 1. Assert a live session and call get_phase_findings for the run id. 2. Shape each finding into a typed object (Phase/Status/Category/Message). 3. Apply the optional -Phase and -Status filters before emitting. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string]$RunId, [string]$Phase, [string]$Status ) Assert-CvConnected $payload = Invoke-CvTool -Name 'get_phase_findings' -Arguments @{ runId = $RunId } if ($null -eq $payload) { return } foreach ($f in @($payload)) { $obj = [pscustomobject]@{ PSTypeName = 'ClusterValidator.MCP.Finding' Phase = if ($f.PSObject.Properties.Name -contains 'phase') { $f.phase } else { '' } Status = if ($f.PSObject.Properties.Name -contains 'status') { $f.status } else { '' } Category = if ($f.PSObject.Properties.Name -contains 'category') { $f.category } else { '' } Message = if ($f.PSObject.Properties.Name -contains 'message') { $f.message } else { '' } } if ($Phase -and $obj.Phase -ne $Phase) { continue } if ($Status -and $obj.Status -ne $Status) { continue } $obj } } function Export-CvValidationReport { <# .SYNOPSIS Export the HTML report for a run and return its file path. .PARAMETER RunId The run id. .EXAMPLE $path = Export-CvValidationReport -RunId $r.RunId .NOTES Steps: 1. Assert a live session and call export_cluster_report for the run id. 2. Read the report path from the response and emit it. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][string]$RunId) Assert-CvConnected $payload = Invoke-CvTool -Name 'export_cluster_report' -Arguments @{ runId = $RunId } if ($null -eq $payload) { throw [System.InvalidOperationException]::new("The tool returned no report path for run $RunId.") } if ($payload -is [string]) { return $payload } foreach ($prop in 'reportPath', 'path', 'reportFile') { if ($payload.PSObject.Properties.Name -contains $prop -and $payload.$prop) { return [string]$payload.$prop } } return [string]$payload } function Get-CvValidationPhase { <# .SYNOPSIS List the validation phases the server exposes, with their cost profile. .EXAMPLE Get-CvValidationPhase .NOTES Steps: 1. Assert a live session and call get_phases. 2. Emit one typed object per phase (Phase / RequiresCluster / LongRunning / Automatic). #> [CmdletBinding()] [OutputType([pscustomobject])] param() Assert-CvConnected $payload = Invoke-CvTool -Name 'get_phases' -Arguments @{} if ($null -eq $payload) { return } foreach ($p in @($payload)) { [pscustomobject]@{ PSTypeName = 'ClusterValidator.MCP.Phase' Phase = if ($p.PSObject.Properties.Name -contains 'phase') { $p.phase } else { $p } RequiresCluster = if ($p.PSObject.Properties.Name -contains 'requiresCluster') { $p.requiresCluster } else { $null } LongRunning = if ($p.PSObject.Properties.Name -contains 'longRunning') { $p.longRunning } else { $null } Automatic = if ($p.PSObject.Properties.Name -contains 'automatic') { $p.automatic } else { $null } } } } # --------------------------------------------------------------------------- # Cleanup: reap the child tool on Remove-Module AND on session exit (a closed shell), # so a dnx child never outlives the session as an orphan. # --------------------------------------------------------------------------- # Session-exit reaper: fires when the PowerShell session closes without Remove-Module. # Closes over $script:CvChildPids (a reference type), so it sees whatever PIDs are live at exit. $script:CvExitAction = { foreach ($procId in @($CvChildPids)) { try { $p = Get-Process -Id $procId -ErrorAction SilentlyContinue; if ($p) { $p.Kill() } } catch { } } }.GetNewClosure() try { $script:CvExitSubscriber = Register-EngineEvent -SourceIdentifier ([System.Management.Automation.PsEngineEvent]::Exiting) -Action $script:CvExitAction } catch { } $MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { Stop-CvSession $script:CvChildPids.Clear() # neutralize the exit reaper even if its subscriber outlives us if ($null -ne $script:CvExitSubscriber) { try { $sub = Get-EventSubscriber -ErrorAction SilentlyContinue | Where-Object { $_.Action -and $_.Action.Id -eq $script:CvExitSubscriber.Id } | Select-Object -First 1 if ($sub) { Unregister-Event -SubscriptionId $sub.SubscriptionId -Force -ErrorAction SilentlyContinue } } catch { } $script:CvExitSubscriber = $null } } Export-ModuleMember -Function Connect-CvValidator, Disconnect-CvValidator, Invoke-CvValidation, Get-CvValidationResult, Get-CvValidationFinding, Export-CvValidationReport, Get-CvValidationPhase |