src/public/Orchestration/Invoke-AitherPlaybook.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Execute a playbook with orchestration and dependency management .DESCRIPTION Executes a playbook definition, running scripts in sequence or parallel based on the playbook configuration. This is the primary way to run automation workflows defined as playbooks. Playbooks can execute scripts in parallel (for speed), sequentially (for dependencies), or in a mixed mode (some parallel, some sequential). The orchestration engine automatically handles dependencies, retries, and error handling. .PARAMETER Name Name of the playbook to execute. This parameter is REQUIRED when using the ByName parameter set. The playbook must exist in the playbooks directory. Examples: - "test-orchestration" - "pr-validation" - "deployment" .PARAMETER Playbook Playbook object from Get-AitherPlaybook. This parameter is REQUIRED when using the ByObject parameter set. Allows piping playbook objects directly. Use this when you've already loaded a playbook and want to execute it. .PARAMETER Variables Variables to pass to the playbook execution. This is a hashtable containing key-value pairs that will be available to all scripts in the playbook. Examples: - @{ Environment = "Production"; Approval = "Automatic" } - @{ OutputPath = "C:\Reports"; Verbose = $true } Variables can be accessed in scripts using $Variables.Environment, etc. .PARAMETER DryRun Show what would be executed without actually running the playbook. Displays the playbook structure, scripts that would run, and execution order. Useful for verifying playbook configuration before execution. .PARAMETER ContinueOnError Continue execution even if a script fails. By default, playbook execution stops on the first error. With this parameter, execution continues through all scripts and reports all failures at the end. Useful for: - Running validation scripts where you want to see all failures - Testing multiple components independently - Gathering comprehensive status information .PARAMETER Parallel Override playbook's parallel setting. Forces parallel execution if set to $true, or sequential execution if set to $false. If not specified, uses the playbook's default execution mode. Note: Some scripts may require sequential execution due to dependencies, which will be respected even when Parallel is $true. .PARAMETER MaxConcurrency Maximum concurrent script executions when running in parallel mode. Defaults to the value in configuration (usually 4). Increase this for: - Systems with more CPU cores - Scripts that are I/O bound rather than CPU bound - Faster execution when dependencies allow Decrease this for: - Resource-constrained systems - Scripts that consume significant resources - Better error visibility (fewer simultaneous failures) .INPUTS System.String You can pipe playbook names to Invoke-AitherPlaybook. Hashtable You can pipe playbook objects from Get-AitherPlaybook to Invoke-AitherPlaybook. .OUTPUTS PSCustomObject Returns execution result with properties: - Total: Total number of scripts - Completed: Number of successfully completed scripts - Failed: Number of failed scripts - Duration: Total execution time - Results: Detailed results for each script .EXAMPLE Invoke-AitherPlaybook -Name 'test-orchestration' Executes the 'test-orchestration' playbook with default settings. .EXAMPLE $playbook = Get-AitherPlaybook -Name 'pr-validation' Invoke-AitherPlaybook -Playbook $playbook -DryRun Loads a playbook and shows what would be executed without running it. .EXAMPLE Invoke-AitherPlaybook -Name 'deployment' -Variables @{ Environment = "Production" } -ContinueOnError Executes deployment playbook with variables and continues on errors. .EXAMPLE Get-AitherPlaybook -Name 'validation' | Invoke-AitherPlaybook -Parallel $true -MaxConcurrency 8 Pipes a playbook object and executes it in parallel with higher concurrency. .EXAMPLE Invoke-AitherPlaybook -Name 'test-suite' -DryRun -Variables @{ TestMode = "Full" } Shows what would be executed with specific variables without running. .NOTES Uses the OrchestrationEngine for execution, which provides: - Automatic dependency resolution - Parallel and sequential execution modes - Error handling and retry logic - Progress tracking - Execution history Playbooks are stored in library/playbooks/ directory as .psd1 files. Each playbook defines scripts, execution order, dependencies, and success criteria. .LINK Get-AitherPlaybook Save-AitherPlaybook New-AitherPlaybook Get-AitherOrchestrationStatus Get-AitherExecutionHistory #> function Invoke-AitherPlaybook { [OutputType([PSCustomObject])] [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ByName')] param( [Parameter(ParameterSetName = 'ByName', Mandatory = $false, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName, HelpMessage = "Name of the playbook to execute (e.g., 'pr-validation').")] [ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) if (Get-Command Get-AitherPlaybook -ErrorAction SilentlyContinue) { Get-AitherPlaybook -List | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'ParameterValue', $_.Description) } } })] [AllowEmptyString()] [string]$Name, [Parameter(ParameterSetName = 'ByObject', Mandatory = $false, ValueFromPipeline, ValueFromPipelineByPropertyName, HelpMessage = "Playbook object or hashtable definition.")] [hashtable]$Playbook, [Parameter(HelpMessage = "Variables to pass to the playbook execution.")] [hashtable]$Variables = @{}, [Parameter(HelpMessage = "Show what would be executed without running it.")] [switch]$DryRun, [Parameter(HelpMessage = "Continue execution even if a step fails.")] [switch]$ContinueOnError, [Parameter(HelpMessage = "Execute independent steps in parallel.")] [bool]$Parallel, [Parameter(HelpMessage = "Maximum number of concurrent parallel executions.")] [ValidateRange(1, 32)] [int]$MaxConcurrency, [Parameter(HelpMessage = "Show playbook execution output in console.")] [switch]$ShowOutput, [Parameter(HelpMessage = "Display transcript content after execution.")] [switch]$ShowTranscript ) begin { # Manage logging targets for this execution $originalLogTargets = $script:AitherLogTargets if ($ShowOutput) { if ($script:AitherLogTargets -notcontains 'Console') { $script:AitherLogTargets += 'Console' } } else { # Ensure Console is NOT in targets if ShowOutput is not specified $script:AitherLogTargets = $script:AitherLogTargets | Where-Object { $_ -ne 'Console' } } # Get scripts directory using robust discovery try { $scriptsPath = Get-AitherScriptsPath } catch { Write-AitherLog -Level Warning -Message "Could not resolve scripts path: $($_.Exception.Message)" -Source 'Invoke-AitherPlaybook' $scriptsPath = $null } $executionResults = @() $startTime = Get-Date } process { try { # Get playbook if name provided if ($Name) { $Playbook = Get-AitherPlaybook -Name $Name if (-not $Playbook) { throw "Playbook not found: $Name" } } if (-not $Playbook -and -not $Name) { # During module validation, parameters may be empty - skip validation if ($PSCmdlet.MyInvocation.InvocationName -eq '.') { return } throw "Playbook must be provided via -Name or -Playbook parameter" } # Merge playbook variables with provided variables. # G9 fix: also read from Playbook.Parameters if Variables is empty. # StrictMode-safe: a playbook hashtable that defines Parameters (not # Variables) would make a bare `$Playbook.Variables` THROW under # Set-StrictMode ("property 'Variables' cannot be found"), so probe # with ContainsKey before dereferencing. $playbookVariables = if ($Playbook -is [hashtable]) { if ($Playbook.ContainsKey('Variables') -and $Playbook.Variables) { $Playbook.Variables } elseif ($Playbook.ContainsKey('Parameters') -and $Playbook.Parameters) { $Playbook.Parameters } else { @{} } } else { if ($Playbook.PSObject.Properties['Variables'] -and $Playbook.Variables) { $Playbook.Variables } elseif ($Playbook.PSObject.Properties['Parameters'] -and $Playbook.Parameters) { $Playbook.Parameters } else { @{} } } $mergedVariables = $playbookVariables.Clone() foreach ($key in $Variables.Keys) { $mergedVariables[$key] = $Variables[$key] } # Optional 'Options' block — resolved StrictMode-safely (a playbook # without an Options key would make bare `$Playbook.Options` THROW). $pbOptions = if ($Playbook -is [hashtable]) { if ($Playbook.ContainsKey('Options')) { $Playbook.Options } else { $null } } elseif ($Playbook.PSObject.Properties['Options']) { $Playbook.Options } else { $null } # Determine execution mode $executeParallel = if ($PSBoundParameters.ContainsKey('Parallel')) { $Parallel } elseif ($pbOptions -and $pbOptions.ContainsKey('Parallel')) { $pbOptions.Parallel } else { $false # Default to sequential for safety } $maxConcurrency = if ($PSBoundParameters.ContainsKey('MaxConcurrency')) { $MaxConcurrency } elseif ($pbOptions -and $pbOptions.ContainsKey('MaxConcurrency')) { $pbOptions.MaxConcurrency } else { $config = Get-AitherConfigs -ErrorAction SilentlyContinue # StrictMode-safe nested access (bare $config.Automation throws when absent). $oe = Get-AitherMember (Get-AitherMember $config 'Automation') 'OrchestrationEngine' $mc = Get-AitherMember $oe 'MaxConcurrency' if ($null -ne $mc) { $mc } else { 4 } } $continueOnError = if ($PSBoundParameters.ContainsKey('ContinueOnError')) { $ContinueOnError } elseif ($pbOptions -and $pbOptions.ContainsKey('StopOnError')) { -not $pbOptions.StopOnError } else { $false } # Get sequence from playbook (StrictMode-safe key probe). $pbHasSequence = if ($Playbook -is [hashtable]) { $Playbook.ContainsKey('Sequence') } else { [bool]$Playbook.PSObject.Properties['Sequence'] } $sequence = if ($pbHasSequence -and $Playbook.Sequence) { $Playbook.Sequence } else { throw "Playbook does not contain a Sequence definition" } Write-AitherLog -Message "Executing playbook: $($Playbook.Name)" -Level Information -Source 'Invoke-AitherPlaybook' Write-AitherLog -Message " Scripts: $($sequence.Count)" -Level Information -Source 'Invoke-AitherPlaybook' Write-AitherLog -Message " Mode: $(if ($executeParallel) { 'Parallel' } else { 'Sequential' })" -Level Information -Source 'Invoke-AitherPlaybook' Write-AitherLog -Message " ContinueOnError: $continueOnError" -Level Information -Source 'Invoke-AitherPlaybook' # Dry run mode if ($DryRun) { Write-AitherLog -Level Information -Message "[DRY RUN] Playbook: $($Playbook.Name)" -Source 'Invoke-AitherPlaybook' Write-AitherLog -Level Information -Message ("=" * 60) -Source 'Invoke-AitherPlaybook' foreach ($item in $sequence) { # StrictMode-safe: sequence items are hashtables with optional # keys (Description/Params may be absent) — bare $item.X throws. $scriptId = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'Script' } else { $null } $dryCmd = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'Command' } else { $null } if (-not $scriptId) { $scriptId = if ($dryCmd) { "(command step)" } else { $item } } $descVal = Get-AitherMember $item 'Description' $desc = if ($descVal) { $descVal } else { "Script $scriptId" } Write-AitherLog -Level Information -Message " - $scriptId : $desc" -Source 'Invoke-AitherPlaybook' if ($dryCmd) { Write-AitherLog -Level Information -Message " Command: $dryCmd" -Source 'Invoke-AitherPlaybook' } $itemParams = Get-AitherMember $item 'Parameters' if (-not $itemParams) { $itemParams = Get-AitherMember $item 'Params' } if ($itemParams) { Write-AitherLog -Level Information -Message " Parameters: $($itemParams | ConvertTo-Json -Compress)" -Source 'Invoke-AitherPlaybook' } } # A playbook may opt in to a DEEP dry run: instead of only listing the # sequence, each step is invoked with -DryRun so the script's own # dry-run path runs. Listing proves the playbook parses; it cannot # prove the steps would work, which is the whole question a rehearsal # before a destructive migration is asking. # # Opt-in, so the existing playbooks are unaffected. Invoke-AitherScript # additionally refuses to execute any step that does not declare -DryRun. $dryRunMode = Get-AitherMember $Playbook 'DryRunMode' if ($dryRunMode -ne 'Execute') { return } Write-AitherLog -Level Information -Message "[DRY RUN] DryRunMode=Execute - invoking each step's own dry-run path" -Source 'Invoke-AitherPlaybook' } # Execute sequence if (-not $PSCmdlet.ShouldProcess($Playbook.Name, "Execute playbook")) { return } # Define ModuleRoot for jobs $ModuleRoot = Get-AitherModuleRoot $scriptResults = @() $completed = 0 $failed = 0 $skipped = 0 if ($executeParallel) { # Parallel execution with concurrency limit $jobs = @() $runningJobs = @{} $index = 0 while ($index -lt $sequence.Count -or $runningJobs.Count -gt 0) { # Start new jobs up to concurrency limit while ($runningJobs.Count -lt $maxConcurrency -and $index -lt $sequence.Count) { $item = $sequence[$index] # StrictMode-safe optional-key access (see DryRun branch note). $scriptId = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'Script' } else { $null } if (-not $scriptId) { $scriptId = $item } $itemParams = Get-AitherMember $item 'Parameters' if (-not $itemParams) { $itemParams = Get-AitherMember $item 'Params' } $scriptParams = if ($itemParams) { $itemParams.Clone() } else { @{} } # G8 fix: Interpolate '$VarName' placeholder strings with actual merged variable values $keysToUpdate = @($scriptParams.Keys) foreach ($key in $keysToUpdate) { $val = $scriptParams[$key] if ($val -is [string] -and $val -match '^\$\{?([^}]+)\}?$') { $varName = $Matches[1] if ($mergedVariables.ContainsKey($varName)) { $scriptParams[$key] = $mergedVariables[$varName] } elseif ($val -eq "`$$key") { $scriptParams.Remove($key) } } } # Merge variables into parameters (only add keys not already present) foreach ($key in $mergedVariables.Keys) { if (-not $scriptParams.ContainsKey($key)) { $scriptParams[$key] = $mergedVariables[$key] } } Write-AitherLog -Message "Starting script: $scriptId" -Level Information -Source 'Invoke-AitherPlaybook' $job = Start-Job -ScriptBlock { param($ScriptId, $ModuleRoot, $Params, $ShowOutput, $ShowTranscript) $modulePath = Join-Path $ModuleRoot 'AitherZero' 'AitherZero.psd1' Import-Module $modulePath -Force Invoke-AitherScript -Script $ScriptId -Parameters $Params -ErrorAction Stop -ShowOutput:$ShowOutput -ShowTranscript:$ShowTranscript } -ArgumentList $scriptId, $moduleRoot, $scriptParams, $true, $ShowTranscript $runningJobs[$job.Id] = @{ Job = $job ScriptId = $scriptId Index = $index StartTime = Get-Date } $index++ } # Check for completed jobs $completedJobs = @() foreach ($jobId in $runningJobs.Keys) { $jobInfo = $runningJobs[$jobId] if ($jobInfo.Job.State -eq 'Completed' -or $jobInfo.Job.State -eq 'Failed') { $result = Receive-Job -Job $jobInfo.Job # Display output if requested (captured from job) if ($ShowOutput) { $result | ForEach-Object { Write-AitherLog -Level Information -Message $_ -Source 'Invoke-AitherPlaybook' } } $duration = (Get-Date) - $jobInfo.StartTime $scriptResult = [PSCustomObject]@{ Script = $jobInfo.ScriptId Success = $jobInfo.Job.State -eq 'Completed' Duration = $duration Output = $result Error = if ($jobInfo.Job.State -eq 'Failed') { $jobInfo.Job.ChildJobs[0].Error } else { $null } } $scriptResults += $scriptResult if ($scriptResult.Success) { $completed++ } else { $failed++ Write-AitherLog -Message "Script failed: $($jobInfo.ScriptId)" -Level Error -Source 'Invoke-AitherPlaybook' if (-not $continueOnError) { Remove-Job -Job $jobInfo.Job $completedJobs += $jobId break } } Remove-Job -Job $jobInfo.Job $completedJobs += $jobId } } foreach ($jobId in $completedJobs) { $runningJobs.Remove($jobId) } if ($runningJobs.Count -gt 0) { Start-Sleep -Milliseconds 100 } } } else { # ── ForEach step-expansion ─────────────────────────────────── # A step may set `ForEach = '<VarName>'` where <VarName> is an array # variable (e.g. Nodes). Expand it into ONE concrete step per item, # resolving `$_.Field` placeholders in Parameters (kept TYPED) and in # Command/Condition (stringified), and — because the Script path does # not gate Condition — dropping items whose Condition is false HERE. # Steps WITHOUT a ForEach key pass through unchanged, so existing # playbooks are unaffected. $expandedSequence = @() foreach ($seqItem in $sequence) { $feVar = if ($seqItem -is [System.Collections.IDictionary]) { Get-AitherMember $seqItem 'ForEach' } else { $null } if (-not $feVar -or -not $mergedVariables.ContainsKey($feVar)) { $expandedSequence += , $seqItem continue } foreach ($feCur in @($mergedVariables[$feVar])) { $resolved = @{} foreach ($sk in @($seqItem.Keys)) { $sv = $seqItem[$sk] if (($sk -eq 'Parameters' -or $sk -eq 'Params') -and $sv -is [System.Collections.IDictionary]) { $rp = @{} foreach ($pk in @($sv.Keys)) { $pv = $sv[$pk] if ($pv -is [string] -and $pv -match '^\$_\.(\w+)$') { $fld = $Matches[1] $rp[$pk] = if ($feCur -is [System.Collections.IDictionary]) { $feCur[$fld] } else { $feCur.$fld } } elseif ($pv -is [string] -and $pv -match '\$_\.\w+') { $rp[$pk] = [regex]::Replace($pv, '\$_\.(\w+)', { param($m) $f = $m.Groups[1].Value [string]$(if ($feCur -is [System.Collections.IDictionary]) { $feCur[$f] } else { $feCur.$f }) }) } else { $rp[$pk] = $pv } } $resolved[$sk] = $rp } elseif (($sk -eq 'Command' -or $sk -eq 'Condition') -and $sv -is [string]) { $isCond = ($sk -eq 'Condition') $resolved[$sk] = [regex]::Replace($sv, '\$_\.(\w+)', { param($m) $f = $m.Groups[1].Value $val = if ($feCur -is [System.Collections.IDictionary]) { $feCur[$f] } else { $feCur.$f } if ($isCond) { if ($val -is [string]) { "'" + ($val -replace "'", "''") + "'" } else { [string]$val } } else { [string]$val } }) } else { $resolved[$sk] = $sv } } $feCond = if ($resolved.ContainsKey('Condition')) { $resolved['Condition'] } else { $null } if ($feCond) { $condPass = $true try { foreach ($vk in $mergedVariables.Keys) { Set-Variable -Name $vk -Value $mergedVariables[$vk] -Scope Local -Force } $condPass = [bool](Invoke-Expression $feCond) } catch { $condPass = $false } if (-not $condPass) { continue } } $expandedSequence += , $resolved } } # Sequential execution # Step index is stamped onto every result: resume needs to know WHICH # steps completed, and a count cannot identify them. $stepIndex = -1 foreach ($item in $expandedSequence) { $stepIndex++ # StrictMode-safe optional-key access (see DryRun branch note). $scriptId = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'Script' } else { $null } # ── Command-step support ───────────────────────────────── # Playbooks authored in the deploy-bonsai-node shape use # Command/Environment/Condition steps (a shell command with # env-var injection), NOT numbered Script ids. Before this # branch existed the engine stringified the step hashtable # and failed with "Script not found: System.Collections. # Hashtable" — no Command playbook had ever actually run. # NOTE: Command paths are repo-relative; run from repo root. $stepCommand = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'Command' } else { $null } if (-not $scriptId -and $stepCommand) { $stepName = Get-AitherMember $item 'Name' if (-not $stepName) { $stepName = $stepCommand } $stepCoE2 = Get-AitherMember $item 'ContinueOnError' $effCoE = if ($null -ne $stepCoE2) { [bool]$stepCoE2 } else { $continueOnError } # Condition gate: expression over playbook variables # (e.g. '$WireRouter -eq $true'). Variables come from the # merged Parameters+(-Variables) set. $condExpr = Get-AitherMember $item 'Condition' $condOk = $true if ($condExpr) { try { foreach ($vk in $mergedVariables.Keys) { Set-Variable -Name $vk -Value $mergedVariables[$vk] -Scope Local -Force } $condOk = [bool](Invoke-Expression $condExpr) } catch { Write-AitherLog -Message "Condition eval failed for '$stepName': $($_.Exception.Message) — skipping step" -Level Warning -Source 'Invoke-AitherPlaybook' $condOk = $false } } if (-not $condOk) { $skipped++ $scriptResults += [PSCustomObject]@{ Script = $stepName; Success = $true; Duration = [timespan]::Zero Output = '(skipped: condition false)'; Error = $null } continue } # Environment injection with '$VarName' interpolation # (same placeholder syntax the Script path supports). $envMap = Get-AitherMember $item 'Environment' $savedEnv = @{} if ($envMap) { foreach ($ek in @($envMap.Keys)) { $ev = $envMap[$ek] if ($ev -is [string] -and $ev -match '^\$\{?([^}]+)\}?$') { $vn = $Matches[1] $ev = if ($mergedVariables.ContainsKey($vn)) { [string]$mergedVariables[$vn] } else { '' } } $savedEnv[$ek] = [Environment]::GetEnvironmentVariable($ek) [Environment]::SetEnvironmentVariable($ek, [string]$ev) } } Write-AitherLog -Message "Executing command step: $stepName" -Level Information -Source 'Invoke-AitherPlaybook' $cmdStart = Get-Date try { $global:LASTEXITCODE = 0 $cmdOutput = Invoke-Expression $stepCommand 2>&1 if ($LASTEXITCODE -ne 0) { throw "command exited with code $LASTEXITCODE" } $scriptResults += [PSCustomObject]@{ Script = $stepName; Success = $true Duration = (Get-Date) - $cmdStart Output = ($cmdOutput | Out-String); Error = $null } $completed++ } catch { $scriptResults += [PSCustomObject]@{ Script = $stepName; Success = $false Duration = (Get-Date) - $cmdStart Output = ($cmdOutput | Out-String); Error = $_.Exception.Message } $failed++ Write-AitherLog -Message "Command step failed: $stepName - $($_.Exception.Message)" -Level Error -Source 'Invoke-AitherPlaybook' if (-not $effCoE) { break } } finally { foreach ($ek in $savedEnv.Keys) { [Environment]::SetEnvironmentVariable($ek, $savedEnv[$ek]) } } continue } # ── end Command-step support ───────────────────────────── if (-not $scriptId) { $scriptId = $item } # ── Condition gate for SCRIPT steps (D-849) ────────────── # `Condition` used to be honored ONLY for Command steps and # for ForEach-expanded items, so a gate written on an # ordinary Script step was silently ignored and the step # ALWAYS ran. That is how `optimize-host -Variables # @{Report=$true}` — documented as inspect-only — still # reached its disk-cleanup step. # # MIGRATION SAFETY — this gate FAILS OPEN on purpose. # ~35 existing playbooks carry conditions that were never # evaluated, and some are not PowerShell at all: mustache # templates the engine never substitutes ('{{Target}} -eq # "hyperv"') and the bare word 'Always'. Evaluating those # THROWS, and skipping on a throw would silently disable # every hyperv/gke/gce deploy step the moment this shipped. # So: a cleanly-evaluated $false SKIPS (the declared # intent); anything that cannot be evaluated RUNS, exactly # as it did before. This is an execution gate, not a # security decision — fail-open is the safe direction here. $stepCondition = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'Condition' } else { $null } if ($stepCondition -is [string] -and $stepCondition.Trim()) { $condText = $stepCondition.Trim() # Legacy non-expressions: an unsubstituted mustache # placeholder or the sentinel word 'Always'. Both mean # "run" and must not produce warning noise. $isLegacyAlways = ($condText -eq 'Always') -or ($condText -like '*{{*') if (-not $isLegacyAlways) { $scriptCondPass = $true try { foreach ($vk in $mergedVariables.Keys) { Set-Variable -Name $vk -Value $mergedVariables[$vk] -Scope Local -Force } $scriptCondPass = [bool](Invoke-Expression $condText) } catch { # Cannot evaluate -> preserve legacy behaviour and # SAY SO, so a broken condition is visible instead # of quietly gating (or quietly not gating). Write-AitherLog -Message "Condition '$condText' on step '$scriptId' could not be evaluated ($($_.Exception.Message)) - RUNNING the step (legacy behaviour)" -Level Warning -Source 'Invoke-AitherPlaybook' $scriptCondPass = $true } if (-not $scriptCondPass) { Write-AitherLog -Message "Skipping script: $scriptId (condition false: $condText)" -Level Information -Source 'Invoke-AitherPlaybook' $skipped++ $scriptResults += [PSCustomObject]@{ Script = $scriptId; Success = $true; Duration = [timespan]::Zero Output = '(skipped: condition false)'; Error = $null } continue } } } # ── end Condition gate ─────────────────────────────────── $itemParams = Get-AitherMember $item 'Parameters' if (-not $itemParams) { $itemParams = Get-AitherMember $item 'Params' } $scriptParams = if ($itemParams) { $itemParams.Clone() } else { @{} } # Per-step ContinueOnError. Playbook Sequence items set this # individually (e.g. a validator phase continues on error while an # install phase must halt). The engine previously honored only the # playbook-level -ContinueOnError switch, so a phase marked # ContinueOnError=$true still aborted the whole run. Fall back to the # global switch when the step omits it. $stepCoE = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'ContinueOnError' } else { $null } $effectiveContinueOnError = if ($null -ne $stepCoE) { [bool]$stepCoE } else { $continueOnError } # G8 fix: Interpolate '$VarName' placeholder strings with actual merged variable values $keysToUpdate = @($scriptParams.Keys) foreach ($key in $keysToUpdate) { $val = $scriptParams[$key] if ($val -is [string] -and $val -match '^\$\{?([^}]+)\}?$') { $varName = $Matches[1] if ($mergedVariables.ContainsKey($varName)) { $scriptParams[$key] = $mergedVariables[$varName] } elseif ($val -eq "`$$key") { # Self-referencing placeholder with no merged value — remove so script default applies $scriptParams.Remove($key) } } } # Merge variables into parameters (only add keys not already present) foreach ($key in $mergedVariables.Keys) { if (-not $scriptParams.ContainsKey($key)) { $scriptParams[$key] = $mergedVariables[$key] } } Write-AitherLog -Message "Executing script: $scriptId" -Level Information -Source 'Invoke-AitherPlaybook' $scriptStartTime = Get-Date try { # Pass Verbose preference explicitly $result = Invoke-AitherScript -Script $scriptId -Parameters $scriptParams -ErrorAction Stop -ShowOutput:$ShowOutput -ShowTranscript:$ShowTranscript -DryRun:$DryRun -Verbose:$VerbosePreference $duration = (Get-Date) - $scriptStartTime $scriptResult = [PSCustomObject]@{ Index = $stepIndex Script = $scriptId Success = $true Duration = $duration Output = $result Error = $null } $scriptResults += $scriptResult $completed++ } catch { $duration = (Get-Date) - $scriptStartTime $scriptResult = [PSCustomObject]@{ Index = $stepIndex Script = $scriptId Success = $false Duration = $duration Output = $null Error = $_.Exception.Message } $scriptResults += $scriptResult $failed++ Write-AitherLog -Message "Script failed: $scriptId - $($_.Exception.Message)" -Level Error -Source 'Invoke-AitherPlaybook' # OnFailure names a script to run with the failure context -- # a handler, not a log line. Without this a playbook could not # drive its own rollback, so the migration playbook's rollback # step had to be invoked by the cutover script itself. $onFailure = Get-AitherMember $Playbook 'OnFailure' if ($onFailure) { try { Write-AitherLog -Message "Invoking OnFailure handler: $onFailure" -Level Warning -Source 'Invoke-AitherPlaybook' Invoke-AitherScript -Script $onFailure -ErrorAction Stop -ShowOutput:$ShowOutput } catch { # Must not mask the original failure, must not vanish: # a rollback that did not run is the most important # thing on the page. Write-AitherLog -Message "OnFailure handler '$onFailure' FAILED: $($_.Exception.Message)" -Level Error -Source 'Invoke-AitherPlaybook' } } if (-not $effectiveContinueOnError) { break } } } } $endTime = Get-Date $totalDuration = $endTime - $startTime # Build result object $result = [PSCustomObject]@{ PSTypeName = 'AitherZero.PlaybookExecutionResult' PlaybookName = $Playbook.Name Success = $failed -eq 0 Total = $sequence.Count Completed = $completed Failed = $failed Skipped = $skipped Duration = $totalDuration Results = $scriptResults } Write-AitherLog -Message "Playbook execution completed: $completed/$($sequence.Count) succeeded, $failed failed" -Level Information -Source 'Invoke-AitherPlaybook' # Persist the execution record. Until this existed, per-step Results were # built in memory and discarded on return, and the three consumers of the # execution-history store all read a directory nothing ever wrote -- each # failing soft to empty, which reads as "no executions have run". # Resume is impossible without this: it is the only record of WHICH steps # completed. try { if (Get-Command Write-AitherExecutionRecord -ErrorAction SilentlyContinue) { Write-AitherExecutionRecord ` -ExecutionId ($script:AitherExecutionId ?? [guid]::NewGuid().ToString()) ` -PlaybookName $Playbook.Name ` -Status $(if ($failed -eq 0) { 'Completed' } else { 'Failed' }) ` -Results $scriptResults ` -StartTime $startTime ` -Variables $mergedVariables | Out-Null } } catch { # Never fail a completed playbook because bookkeeping failed, but never # swallow it either -- a silent write failure here is what makes resume # unusable later, at the worst possible moment. Write-AitherLog -Message "Failed to persist execution record: $($_.Exception.Message)" -Level Warning -Source 'Invoke-AitherPlaybook' } return $result } catch { Invoke-AitherErrorHandler -ErrorRecord $_ -Operation "Executing playbook: $($Name ?? $Playbook.Name)" -Parameters $PSBoundParameters -ThrowOnError } finally { # Restore original log targets $script:AitherLogTargets = $originalLogTargets } } } |