Public/Invoke-Runbook.ps1
|
function Invoke-Runbook { <# .SYNOPSIS Executes a YAML-defined runbook as a decision tree against one or more target computers. .DESCRIPTION Main orchestrator for the Infra-RunbookEngine module. Reads a YAML runbook definition, validates it, checks maintenance windows and blast radius, then walks the decision tree executing script steps, evaluating conditions, calling integrations, and handling approvals. Supports WhatIf mode to preview execution without making changes, approval workflows through Console/Email/Teams/Slack, and generates HTML execution reports. Each execution is logged to $env:USERPROFILE\.runbookengine\executions\ and feeds into the learning system for continuous improvement. .PARAMETER RunbookName Name of a built-in template (e.g., 'high-cpu') or full path to a YAML runbook file. .PARAMETER ComputerName One or more target computer names to execute the runbook against. .PARAMETER Parameters Hashtable of additional parameters to pass to the runbook steps. .PARAMETER WhatIf Dry-run mode. Walks the entire decision tree showing what would execute without running anything. .PARAMETER RequireApproval Pause at steps marked requires_approval and wait for approval before proceeding. .PARAMETER ApprovalMethod Communication channel for approval requests: Console, Email, Teams, or Slack. .PARAMETER ApprovalContact Email address or webhook URL for the approval method. .PARAMETER SkipBlastRadiusCheck Skip the blast radius assessment before executing remediation steps. .PARAMETER Force Skip all approval requirements and execute without pausing. .PARAMETER OutputPath Directory path where the HTML execution report will be saved. .EXAMPLE Invoke-Runbook -RunbookName 'high-cpu' -ComputerName 'SERVER01' -WhatIf Preview what the high-cpu runbook would do on SERVER01 without executing. .EXAMPLE Invoke-Runbook -RunbookName 'C:\Runbooks\custom.yml' -ComputerName 'DB01','DB02' -RequireApproval -ApprovalMethod Console Execute a custom runbook on two servers with console approval for protected steps. .EXAMPLE Invoke-Runbook -RunbookName 'disk-space' -ComputerName 'FILE01' -Force -OutputPath 'C:\Reports' Execute disk-space runbook, skip approvals, and save an HTML report. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0)] [string]$RunbookName, [Parameter(Position = 1)] [string[]]$ComputerName, [Parameter()] [hashtable]$Parameters = @{}, [Parameter()] [switch]$RequireApproval, [Parameter()] [ValidateSet('Console', 'Email', 'Teams', 'Slack')] [string]$ApprovalMethod = 'Console', [Parameter()] [string]$ApprovalContact, [Parameter()] [switch]$SkipBlastRadiusCheck, [Parameter()] [switch]$Force, [Parameter()] [string]$OutputPath ) $executionId = [guid]::NewGuid().ToString() $startTime = Get-Date $isWhatIf = $WhatIfPreference # Resolve runbook path $runbookPath = $null if (Test-Path $RunbookName) { $runbookPath = (Resolve-Path $RunbookName).Path } else { # Check templates directory $templatePath = Join-Path $script:TemplatePath "$RunbookName.yml" if (Test-Path $templatePath) { $runbookPath = $templatePath } else { $templatePath = Join-Path $script:TemplatePath "$RunbookName.yaml" if (Test-Path $templatePath) { $runbookPath = $templatePath } else { # Check user runbooks directory $userPath = Join-Path $script:RunbooksPath "$RunbookName.yml" if (Test-Path $userPath) { $runbookPath = $userPath } } } } if (-not $runbookPath) { throw "Runbook not found: $RunbookName. Provide a valid path or built-in template name." } # Parse the runbook definition $runbook = Read-RunbookDefinition -Path $runbookPath -ErrorAction Stop Write-Verbose "Loaded runbook: $($runbook.Name) v$($runbook.Version)" # Merge parameters $execParams = @{} foreach ($paramDef in $runbook.Parameters) { $pName = if ($paramDef.name) { $paramDef.name } elseif ($paramDef -is [string]) { $paramDef } else { $null } if ($pName -and $Parameters.ContainsKey($pName)) { $execParams[$pName] = $Parameters[$pName] } elseif ($pName -and $paramDef.default) { $execParams[$pName] = $paramDef.default } } # If ComputerName is provided, add it to params if ($ComputerName) { $execParams['ComputerName'] = $ComputerName[0] } # Process each computer $allResults = foreach ($target in $ComputerName) { $execParams['ComputerName'] = $target Write-Verbose "Processing target: $target" # Check maintenance window if (-not $isWhatIf) { $mwResult = Test-MaintenanceWindow -ComputerName $target if ($mwResult.InWindow) { Write-Host "[$target] In maintenance window: $($mwResult.WindowName) (until $($mwResult.WindowEnd))" -ForegroundColor Cyan } else { Write-Verbose "[$target] Not in a maintenance window." } } elseif ($isWhatIf) { Write-Host "[WhatIf] Would check maintenance window for $target" -ForegroundColor Cyan } # Build step index for decision tree navigation $stepIndex = @{} foreach ($step in $runbook.Steps) { $stepIndex[$step.id] = $step } # Execute the decision tree $stepResults = [System.Collections.Generic.List[object]]::new() $approvalLog = [System.Collections.Generic.List[object]]::new() $verificationResults = [System.Collections.Generic.List[object]]::new() $executionStatus = 'Completed' $variables = @{} foreach ($key in $execParams.Keys) { $variables[$key] = $execParams[$key] } # Start at first step $currentStepId = $runbook.Steps[0].id $visitedSteps = [System.Collections.Generic.HashSet[string]]::new() $maxSteps = 100 # Safety limit while ($currentStepId -and $visitedSteps.Count -lt $maxSteps) { if ($visitedSteps.Contains($currentStepId)) { Write-Warning "Loop detected at step '$currentStepId'. Aborting." $executionStatus = 'Aborted' break } $visitedSteps.Add($currentStepId) | Out-Null $step = $stepIndex[$currentStepId] if (-not $step) { Write-Warning "Step '$currentStepId' not found in runbook." $executionStatus = 'Failed' break } $stepStart = Get-Date $stepStatus = 'Success' $stepOutput = $null $nextStepId = $null Write-Verbose "Executing step: $($step.id) ($($step.action)) - $($step.description)" # Blast radius check for steps that require approval if ($step.requires_approval -and -not $SkipBlastRadiusCheck -and -not $isWhatIf) { $blastResult = Test-BlastRadius -ComputerName $target -Action $step.description -RunbookStep $step if ($blastResult.RequiresApproval -and -not $Force) { if ($RequireApproval -or $blastResult.Level -in @('High', 'Critical')) { $approvalResult = Send-ApprovalRequest -Method $ApprovalMethod ` -Contact $ApprovalContact -RunbookName $runbook.Name ` -StepDescription $step.description -BlastRadius $blastResult ` -ComputerName $target $approvalLog.Add($approvalResult) if (-not $approvalResult.Approved) { Write-Warning "Step '$($step.id)' was not approved. Aborting execution." $stepStatus = 'Denied' $executionStatus = 'Aborted' $stepResults.Add([PSCustomObject]@{ StepId = $step.id Action = $step.action Description = $step.description Status = $stepStatus Output = "Approval denied" Duration = "{0:N1}s" -f ((Get-Date) - $stepStart).TotalSeconds }) break } } } } elseif ($step.requires_approval -and $isWhatIf) { Write-Host "[WhatIf] Step '$($step.id)' requires approval - would request via $ApprovalMethod" -ForegroundColor Yellow } switch ($step.action) { 'script' { if ($isWhatIf) { Write-Host "[WhatIf] Step '$($step.id)': Would execute script - $($step.description)" -ForegroundColor Cyan $scriptPreview = if ($step.script) { ($step.script -split "`n" | Select-Object -First 3) -join "`n" } else { '(no script)' } Write-Host "[WhatIf] Script preview: $scriptPreview" -ForegroundColor Gray $stepOutput = "WhatIf: Script would execute" # In WhatIf, simulate outputs for decision tree navigation if ($step.outputs) { foreach ($outVar in $step.outputs) { $outName = if ($outVar -is [string]) { $outVar } else { $outVar.ToString() } $variables[$outName] = "[WhatIf simulated: $outName]" } } # Find next sequential step $stepList = @($runbook.Steps) for ($i = 0; $i -lt $stepList.Count; $i++) { if ($stepList[$i].id -eq $step.id -and ($i + 1) -lt $stepList.Count) { $nextStepId = $stepList[$i + 1].id break } } } else { try { # Build script with variable injection $scriptText = $step.script $varBlock = "" foreach ($vk in $variables.Keys) { $vv = $variables[$vk] if ($vv -is [string]) { $varBlock += "`$$vk = '$($vv -replace "'","''")';`n" } elseif ($vv -is [int] -or $vv -is [double]) { $varBlock += "`$$vk = $vv;`n" } elseif ($null -ne $vv) { $varBlock += "`$$vk = `$using_$vk;`n" } } $fullScript = $varBlock + $scriptText $sb = [scriptblock]::Create($fullScript) $stepOutput = & $sb # Store outputs if ($step.outputs) { foreach ($outVar in $step.outputs) { $outName = if ($outVar -is [string]) { $outVar } else { $outVar.ToString() } $variables[$outName] = $stepOutput } } # Handle verification if ($step.verify -and $step.verify.check) { $waitSec = if ($step.verify.wait_seconds) { [int]$step.verify.wait_seconds } else { 30 } $verifyScript = $step.verify.check $fullVerify = $varBlock + $verifyScript $verifySb = [scriptblock]::Create($fullVerify) $verifyResult = Test-FixVerification -VerificationScript $verifySb ` -WaitSeconds $waitSec -ComputerName $target $verifyResult | Add-Member -NotePropertyName 'StepId' -NotePropertyValue $step.id -Force $verificationResults.Add($verifyResult) if (-not $verifyResult.Verified) { $stepStatus = 'VerificationFailed' } } } catch { $stepStatus = 'Failed' $stepOutput = $_.Exception.Message Write-Warning "Step '$($step.id)' failed: $_" } # Find next sequential step $stepList = @($runbook.Steps) for ($i = 0; $i -lt $stepList.Count; $i++) { if ($stepList[$i].id -eq $step.id -and ($i + 1) -lt $stepList.Count) { $nextStepId = $stepList[$i + 1].id break } } } } 'decision' { $conditionText = $step.condition if ($isWhatIf) { Write-Host "[WhatIf] Step '$($step.id)': Decision - $($step.description)" -ForegroundColor Cyan Write-Host "[WhatIf] Condition: $conditionText" -ForegroundColor Gray Write-Host "[WhatIf] If true -> $($step.if_true)" -ForegroundColor Green Write-Host "[WhatIf] If false -> $($step.if_false)" -ForegroundColor DarkYellow # In WhatIf, walk BOTH paths by default (show true path) $stepOutput = "WhatIf: Would evaluate condition" # Follow the if_true path for WhatIf traversal if ($step.if_true) { $nextStepId = $step.if_true } } else { try { # Build condition with variable injection $varBlock = "" foreach ($vk in $variables.Keys) { $vv = $variables[$vk] if ($vv -is [string]) { $varBlock += "`$$vk = '$($vv -replace "'","''")';`n" } elseif ($vv -is [int] -or $vv -is [double]) { $varBlock += "`$$vk = $vv;`n" } elseif ($null -ne $vv) { # For complex objects, store them directly Set-Variable -Name $vk -Value $vv -Scope Local $varBlock += "" } } $condScript = $varBlock + $conditionText $condSb = [scriptblock]::Create($condScript) $condResult = & $condSb if ($condResult) { $nextStepId = $step.if_true $stepOutput = "Condition TRUE -> $($step.if_true)" } else { $nextStepId = $step.if_false $stepOutput = "Condition FALSE -> $($step.if_false)" } } catch { $stepStatus = 'Failed' $stepOutput = "Condition evaluation failed: $_" Write-Warning "Decision step '$($step.id)' failed: $_" } } } 'integration' { if ($isWhatIf) { Write-Host "[WhatIf] Step '$($step.id)': Integration - $($step.description)" -ForegroundColor Cyan Write-Host "[WhatIf] Module: $($step.module) | Function: $($step.function)" -ForegroundColor Gray $stepOutput = "WhatIf: Would call $($step.module)\$($step.function)" if ($step.outputs) { foreach ($outVar in $step.outputs) { $outName = if ($outVar -is [string]) { $outVar } else { $outVar.ToString() } $variables[$outName] = "[WhatIf simulated: $outName]" } } # Find next sequential step $stepList = @($runbook.Steps) for ($i = 0; $i -lt $stepList.Count; $i++) { if ($stepList[$i].id -eq $step.id -and ($i + 1) -lt $stepList.Count) { $nextStepId = $stepList[$i + 1].id break } } } else { try { # Check if the integration module is available $moduleName = $step.module $functionName = $step.function if (-not (Get-Command $functionName -ErrorAction SilentlyContinue)) { # Try importing the module try { Import-Module $moduleName -ErrorAction Stop } catch { Write-Warning "Integration module '$moduleName' not available. Skipping step." $stepStatus = 'Skipped' $stepOutput = "Module '$moduleName' not available" } } if ($stepStatus -ne 'Skipped') { # Build parameters for the integration call $integParams = @{} if ($step.parameters) { $paramObj = $step.parameters if ($paramObj -is [PSCustomObject]) { foreach ($prop in $paramObj.PSObject.Properties) { $val = $prop.Value # Resolve variable references like "$ComputerName" if ($val -is [string] -and $val.StartsWith('$')) { $varName = $val.TrimStart('$') if ($variables.ContainsKey($varName)) { $val = $variables[$varName] } } $integParams[$prop.Name] = $val } } } $stepOutput = & $functionName @integParams if ($step.outputs) { foreach ($outVar in $step.outputs) { $outName = if ($outVar -is [string]) { $outVar } else { $outVar.ToString() } $variables[$outName] = $stepOutput } } } } catch { $stepStatus = 'Failed' $stepOutput = "Integration call failed: $_" Write-Warning "Integration step '$($step.id)' failed: $_" } # Find next sequential step $stepList = @($runbook.Steps) for ($i = 0; $i -lt $stepList.Count; $i++) { if ($stepList[$i].id -eq $step.id -and ($i + 1) -lt $stepList.Count) { $nextStepId = $stepList[$i + 1].id break } } } } 'notify' { $message = $step.message # Resolve variable references in the message foreach ($vk in $variables.Keys) { $message = $message -replace "\`$$vk", "$($variables[$vk])" } if ($isWhatIf) { Write-Host "[WhatIf] Step '$($step.id)': Notify - $($step.description)" -ForegroundColor Cyan Write-Host "[WhatIf] Message: $message" -ForegroundColor Gray $stepOutput = "WhatIf: Would send notification" } else { Write-Host "NOTIFICATION [$($step.id)]: $message" -ForegroundColor Yellow if ($step.include_data -and $variables.ContainsKey($step.include_data)) { $dataToInclude = $variables[$step.include_data] Write-Host " Included data:" -ForegroundColor Gray $dataToInclude | Format-Table -AutoSize | Out-Host } $stepOutput = "Notification sent: $message" } # Find next sequential step $stepList = @($runbook.Steps) for ($i = 0; $i -lt $stepList.Count; $i++) { if ($stepList[$i].id -eq $step.id -and ($i + 1) -lt $stepList.Count) { $nextStepId = $stepList[$i + 1].id break } } } 'escalate' { $message = $step.message foreach ($vk in $variables.Keys) { $message = $message -replace "\`$$vk", "$($variables[$vk])" } $priority = if ($step.priority) { $step.priority } else { 'medium' } if ($isWhatIf) { Write-Host "[WhatIf] Step '$($step.id)': Escalate - $($step.description)" -ForegroundColor Cyan Write-Host "[WhatIf] Priority: $priority" -ForegroundColor Gray Write-Host "[WhatIf] Message: $message" -ForegroundColor Gray $stepOutput = "WhatIf: Would escalate" } else { Write-Host "ESCALATION [$priority] [$($step.id)]: $message" -ForegroundColor Red $stepOutput = "Escalated: $message" $executionStatus = 'Escalated' } # Escalation is typically a terminal step $nextStepId = $null } default { Write-Warning "Unknown action type '$($step.action)' at step '$($step.id)'" $stepStatus = 'Skipped' $stepOutput = "Unknown action type: $($step.action)" # Find next sequential step $stepList = @($runbook.Steps) for ($i = 0; $i -lt $stepList.Count; $i++) { if ($stepList[$i].id -eq $step.id -and ($i + 1) -lt $stepList.Count) { $nextStepId = $stepList[$i + 1].id break } } } } $stepEnd = Get-Date $stepResults.Add([PSCustomObject]@{ StepId = $step.id Action = $step.action Description = $step.description Status = $stepStatus Output = $stepOutput Duration = "{0:N1}s" -f ($stepEnd - $stepStart).TotalSeconds }) # Record learning for non-WhatIf runs if (-not $isWhatIf -and $step.action -in @('script', 'integration')) { $learningContext = @{ ComputerName = $target } if ($stepStatus -eq 'Failed' -and $stepOutput) { $learningContext['Error'] = "$stepOutput" } Update-RunbookLearning -RunbookName $runbook.Name -StepId $step.id ` -Action $step.action -Succeeded ($stepStatus -eq 'Success') ` -ComputerName $target -Context $learningContext | Out-Null } # If step failed and it's not a decision, abort (unless it was just skipped) if ($stepStatus -eq 'Failed' -and $step.action -ne 'decision') { $executionStatus = 'Failed' break } $currentStepId = $nextStepId } # Check for correlations $correlations = $null if (-not $isWhatIf) { $correlations = Find-CorrelatedIssues -ComputerName $target -Symptom $runbook.Name if ($correlations.CorrelationsFound -gt 0) { Write-Host "CORRELATION DETECTED: $($correlations.PossibleRootCause) (Confidence: $($correlations.Confidence))" -ForegroundColor Magenta } } $endTime = Get-Date $duration = "{0:N1}s" -f ($endTime - $startTime).TotalSeconds $result = [PSCustomObject]@{ ExecutionId = $executionId RunbookName = $runbook.Name RunbookVersion = $runbook.Version ComputerName = $target Parameters = $execParams.Clone() Status = $executionStatus StartTime = $startTime.ToString('o') EndTime = $endTime.ToString('o') Duration = $duration StepResults = $stepResults.ToArray() ApprovalLog = $approvalLog.ToArray() VerificationResults = $verificationResults.ToArray() Correlations = $correlations WhatIf = $isWhatIf } # Save execution log (unless WhatIf) if (-not $isWhatIf) { $execFilePath = Join-Path $script:ExecutionsPath "$executionId.json" $result | ConvertTo-Json -Depth 10 | Set-Content -Path $execFilePath -Encoding UTF8 Write-Verbose "Execution log saved: $execFilePath" } $result } # Generate HTML report if OutputPath specified if ($OutputPath -and $allResults) { $reportData = if (@($allResults).Count -eq 1) { $allResults } else { $allResults[0] } $reportFile = Join-Path $OutputPath "RunbookExecution_$($executionId.Substring(0,8))_$(Get-Date -Format 'yyyyMMdd_HHmmss').html" $htmlPath = New-HtmlDashboard -ReportType 'ExecutionReport' -Data $reportData -OutputPath $reportFile Write-Host "Execution report saved: $htmlPath" -ForegroundColor Green } return $allResults } |