Private/Read-RunbookDefinition.ps1
|
function Read-RunbookDefinition { <# .SYNOPSIS Parses a YAML runbook file into a structured PowerShell object. .DESCRIPTION Attempts to use the PowerShell-Yaml module for parsing. If unavailable, falls back to a built-in basic YAML parser that handles simple key-value pairs, lists, and multi-line strings sufficient for the runbook format. Validates that required fields (name, steps with id/action) are present. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path ) if (-not (Test-Path $Path)) { throw "Runbook file not found: $Path" } $yamlContent = Get-Content -Path $Path -Raw -ErrorAction Stop # Try PowerShell-Yaml module first $parsed = $null $usedModule = $false try { if (Get-Module -ListAvailable -Name 'powershell-yaml' -ErrorAction SilentlyContinue) { Import-Module powershell-yaml -ErrorAction Stop $parsed = ConvertFrom-Yaml -Yaml $yamlContent -ErrorAction Stop $usedModule = $true } } catch { Write-Verbose "PowerShell-Yaml module not available or failed, using built-in parser: $_" $parsed = $null } if (-not $usedModule -or $null -eq $parsed) { $parsed = ConvertFrom-BasicYaml -YamlText $yamlContent } # Validate required fields if (-not $parsed.name) { throw "Runbook validation failed: 'name' field is required." } if (-not $parsed.steps -or $parsed.steps.Count -eq 0) { throw "Runbook validation failed: 'steps' field is required and must contain at least one step." } foreach ($step in $parsed.steps) { if (-not $step.id) { throw "Runbook validation failed: each step must have an 'id' field." } if (-not $step.action) { throw "Runbook validation failed: step '$($step.id)' must have an 'action' field." } } # Return as a structured object [PSCustomObject]@{ Name = $parsed.name Version = if ($parsed.version) { $parsed.version } else { '1.0' } Description = if ($parsed.description) { $parsed.description } else { '' } Trigger = if ($parsed.trigger) { $parsed.trigger } else { $null } Parameters = if ($parsed.parameters) { $parsed.parameters } else { @() } Steps = $parsed.steps RawParsed = $parsed SourcePath = $Path } } function ConvertFrom-BasicYaml { <# .SYNOPSIS Basic YAML parser for runbook definitions. .DESCRIPTION Handles simple key-value pairs, nested objects, lists (with - prefix), multi-line strings (| and > blocks), and quoted strings. Not a full YAML spec implementation - designed specifically for runbook structures. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$YamlText ) $lines = $YamlText -split "`n" | ForEach-Object { $_ -replace "`r$", '' } function Get-IndentLevel { param([string]$Line) if ($Line -match '^(\s*)') { return $Matches[1].Length } return 0 } function Parse-Value { param([string]$Val) $Val = $Val.Trim() if ($Val -eq '' -or $Val -eq '~' -or $Val -eq 'null') { return $null } if ($Val -eq 'true') { return $true } if ($Val -eq 'false') { return $false } if ($Val -match '^\d+$') { return [int]$Val } if ($Val -match '^\d+\.\d+$') { return [double]$Val } # Remove quotes if (($Val.StartsWith('"') -and $Val.EndsWith('"')) -or ($Val.StartsWith("'") -and $Val.EndsWith("'"))) { return $Val.Substring(1, $Val.Length - 2) } return $Val } function Parse-Block { param( [string[]]$Lines, [ref]$Index, [int]$BaseIndent ) $result = [ordered]@{} $currentKey = $null while ($Index.Value -lt $Lines.Count) { $line = $Lines[$Index.Value] # Skip empty lines and comments if ($line.Trim() -eq '' -or $line.Trim().StartsWith('#')) { $Index.Value++ continue } $indent = Get-IndentLevel -Line $line $trimmed = $line.Trim() # If less indented than our base, we're done with this block if ($indent -lt $BaseIndent -and $Index.Value -gt 0) { break } # List item at current level if ($trimmed.StartsWith('- ') -and $indent -eq $BaseIndent) { # This is a list at the top level of this block - shouldn't happen for root # but handle for nested lists break } # Key-value pair if ($trimmed -match '^([^:]+?):\s*(.*)$') { $key = $Matches[1].Trim() $value = $Matches[2].Trim() if ($value -eq '' -or $value -eq '|' -or $value -eq '>') { # Check what's next $isMultiline = ($value -eq '|' -or $value -eq '>') $foldStyle = ($value -eq '>') $Index.Value++ if ($Index.Value -ge $Lines.Count) { $result[$key] = $null continue } $nextLine = $Lines[$Index.Value] $nextIndent = Get-IndentLevel -Line $nextLine $nextTrimmed = $nextLine.Trim() if ($nextTrimmed -eq '' -or $nextIndent -le $indent) { if ($isMultiline) { $result[$key] = '' } else { $result[$key] = $null } continue } if ($isMultiline) { # Multi-line string block $multiLines = @() $blockIndent = $nextIndent while ($Index.Value -lt $Lines.Count) { $ml = $Lines[$Index.Value] $mlIndent = Get-IndentLevel -Line $ml if ($ml.Trim() -eq '') { $multiLines += '' $Index.Value++ continue } if ($mlIndent -lt $blockIndent) { break } $multiLines += $ml.Substring([Math]::Min($blockIndent, $ml.Length)) $Index.Value++ } if ($foldStyle) { $result[$key] = ($multiLines -join ' ').Trim() } else { $result[$key] = ($multiLines -join "`n").TrimEnd() } continue } if ($nextTrimmed.StartsWith('- ')) { # It's a list $listItems = @() $listIndent = $nextIndent while ($Index.Value -lt $Lines.Count) { $ll = $Lines[$Index.Value] $llTrimmed = $ll.Trim() $llIndent = Get-IndentLevel -Line $ll if ($llTrimmed -eq '' -or $llTrimmed.StartsWith('#')) { $Index.Value++ continue } if ($llIndent -lt $listIndent) { break } if (-not $llTrimmed.StartsWith('- ') -and $llIndent -eq $listIndent) { break } if ($llTrimmed.StartsWith('- ') -and $llIndent -eq $listIndent) { $itemContent = $llTrimmed.Substring(2).Trim() # Check if this list item has nested content if ($itemContent -match '^([^:]+?):\s*(.*)$') { # It's a mapping in a list $itemObj = [ordered]@{} $itemKey = $Matches[1].Trim() $itemVal = $Matches[2].Trim() $itemObj[$itemKey] = Parse-Value -Val $itemVal $Index.Value++ # Check for more keys in this list item while ($Index.Value -lt $Lines.Count) { $subLine = $Lines[$Index.Value] $subTrimmed = $subLine.Trim() $subIndent = Get-IndentLevel -Line $subLine if ($subTrimmed -eq '' -or $subTrimmed.StartsWith('#')) { $Index.Value++ continue } if ($subIndent -le $listIndent) { break } if ($subTrimmed.StartsWith('- ')) { break } if ($subTrimmed -match '^([^:]+?):\s*(.*)$') { $subKey = $Matches[1].Trim() $subVal = $Matches[2].Trim() if ($subVal -eq '' -or $subVal -eq '|' -or $subVal -eq '>') { $isMulti = ($subVal -eq '|' -or $subVal -eq '>') $isFold = ($subVal -eq '>') $Index.Value++ if ($isMulti -and $Index.Value -lt $Lines.Count) { $mLines = @() $bIndent = Get-IndentLevel -Line $Lines[$Index.Value] while ($Index.Value -lt $Lines.Count) { $ml2 = $Lines[$Index.Value] $ml2Indent = Get-IndentLevel -Line $ml2 if ($ml2.Trim() -eq '') { $mLines += '' $Index.Value++ continue } if ($ml2Indent -lt $bIndent) { break } $mLines += $ml2.Substring([Math]::Min($bIndent, $ml2.Length)) $Index.Value++ } if ($isFold) { $itemObj[$subKey] = ($mLines -join ' ').Trim() } else { $itemObj[$subKey] = ($mLines -join "`n").TrimEnd() } } else { $itemObj[$subKey] = $null } continue } $itemObj[$subKey] = Parse-Value -Val $subVal } $Index.Value++ } $listItems += [PSCustomObject]$itemObj } else { $listItems += (Parse-Value -Val $itemContent) $Index.Value++ } } else { $Index.Value++ } } $result[$key] = $listItems continue } else { # Nested object $subObj = Parse-Block -Lines $Lines -Index $Index -BaseIndent $nextIndent $result[$key] = [PSCustomObject]$subObj continue } } else { $result[$key] = Parse-Value -Val $value $Index.Value++ } } else { $Index.Value++ } } return $result } $idx = [ref]0 $rootObj = Parse-Block -Lines $lines -Index $idx -BaseIndent 0 return [PSCustomObject]$rootObj } |