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
}