Datum.InvokeCommand.psm1
|
#Region './Prefix.ps1' -1 $here = $PSScriptRoot $modulePath = Join-Path -Path $here -ChildPath 'Modules' Import-Module -Name (Join-Path -Path $modulePath -ChildPath 'DscResource.Common') $script:localizedData = Get-LocalizedData -DefaultUICulture en-US # Load the handler configuration (header/footer delimiters) and compile the regular expression # used to match embedded commands in Datum values. The regex captures the content between the # configurable header (default: [x=) and footer (default: =]) delimiters. $config = Import-PowerShellDataFile -Path $here\Config\Datum.InvokeCommand.Config.psd1 $regExString = '{0}(?<Content>((.|\s)+)?){1}' -f [regex]::Escape($config.Header), [regex]::Escape($config.Footer) $global:datumInvokeCommandRegEx = New-Object Text.RegularExpressions.Regex($regExString, ('IgnoreCase', 'Compiled')) #EndRegion './Prefix.ps1' 14 #Region './Private/Get-DatumCurrentNode.ps1' -1 function Get-DatumCurrentNode { <# .SYNOPSIS Resolves the current Datum node from a YAML configuration file. .DESCRIPTION This internal function determines the current node context when processing a Datum configuration file. It reads the YAML file content, converts it, and then attempts to resolve the full RSOP (Resultant Set of Policy) for the node using `Get-DatumRsop`. If the RSOP resolution succeeds, the resolved node data is returned. Otherwise, the raw file content is returned as a fallback. This function is used by `Invoke-InvokeCommandAction` to automatically determine the `$Node` context when it is not explicitly provided and the value originates from a node-specific file (not `Datum.yml`). .PARAMETER File The YAML configuration file (`System.IO.FileInfo`) to read and resolve the node from. .NOTES This is a private function and is not exported by the module. .LINK Invoke-InvokeCommandAction #> param ( [Parameter(Mandatory = $true)] [System.IO.FileInfo] $File ) $fileNode = $File | Get-Content | ConvertFrom-Yaml $rsopNode = Get-DatumRsop -Datum $datum -AllNodes $currentNode if ($rsopNode) { $rsopNode } else { $fileNode } } #EndRegion './Private/Get-DatumCurrentNode.ps1' 46 #Region './Private/Get-RelativeNodeFileName.ps1' -1 function Get-RelativeNodeFileName { <# .SYNOPSIS Converts an absolute file path to a relative Datum node path. .DESCRIPTION This internal function takes an absolute file path and converts it to a relative path suitable for identifying a Datum node within the configuration hierarchy. The function: 1. Resolves the path relative to the current location. 2. Splits the path by backslash separators. 3. Removes the file extension from the last segment. 4. Returns the segments starting from the third element onward (skipping `.\` prefix and the root data folder name), joined with backslashes. For example, given a current location of `C:\Config` and a path of `C:\Config\DscConfigData\AllNodes\Dev\DSCFile01.yml`, the function returns `AllNodes\Dev\DSCFile01`. .PARAMETER Path The absolute path to the configuration file. Accepts empty strings, in which case an empty string is returned. .NOTES This is a private function and is not exported by the module. .LINK Invoke-InvokeCommandAction #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $Path ) if (-not $Path) { return [string]::Empty } try { $p = Resolve-Path -Path $Path -Relative -ErrorAction Stop $p = $p -split '\\' $p[-1] = [System.IO.Path]::GetFileNameWithoutExtension($p[-1]) $p[2..($p.Length - 1)] -join '\' } catch { Write-Verbose 'Get-RelativeNodeFileName: nothing to catch' } } #EndRegion './Private/Get-RelativeNodeFileName.ps1' 56 #Region './Private/Get-ValueKind.ps1' -1 function Get-ValueKind { <# .SYNOPSIS Determines the type of a parsed Datum command content string. .DESCRIPTION This internal function uses the PowerShell parser to analyze the input string extracted from a Datum embedded command and determines its kind. The input is the content between the header and footer delimiters (e.g., the part between `[x=` and `=]`). The function returns a hashtable with `Kind` and `Value` keys. The possible kinds are: - **ScriptBlock**: The content is wrapped in curly braces `{ ... }`. The script block will be invoked by the caller. Example: `{ Get-Date }` - **ExpandableString**: The content is a double-quoted string `"..."` that may contain variable references or sub-expressions. It will be expanded using `$ExecutionContext.InvokeCommand.ExpandString()`. Example: `"$($Node.Name)"` - **LiteralString**: The content is a single-quoted string `'...'`. It cannot be expanded and is returned as-is with a warning. Example: `'literal value'` If the input does not match any recognized pattern, an error is written. .PARAMETER InputObject The raw content string extracted from the embedded command (the text between header and footer delimiters). Must not be null or empty. .EXAMPLE Get-ValueKind -InputObject '{ Get-Date }' Returns `@{ Kind = 'ScriptBlock'; Value = '{ Get-Date }' }`. .EXAMPLE Get-ValueKind -InputObject '"Hello $Name"' Returns `@{ Kind = 'ExpandableString'; Value = 'Hello $Name' }`. .EXAMPLE Get-ValueKind -InputObject "'literal'" Returns `@{ Kind = 'LiteralString'; Value = 'literal' }` and writes a warning. .NOTES This is a private function and is not exported by the module. .LINK Invoke-InvokeCommandAction #> [OutputType([hashtable])] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $InputObject ) $errors = $null $tokens = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput( $InputObject, [ref]$tokens, [ref]$errors ) $InputObject = $InputObject.Trim() if ($InputObject -notmatch '^{[\s\S]+}$|^"[\s\S]+"$|^''[\s\S]+''$') { Write-Error 'Get-ValueKind: The input object is not an ExpandableString, nor a literal string or ScriptBlock' } elseif (($tokens[0].Kind -eq 'LCurly' -and $tokens[-2].Kind -eq 'RCurly' -and $tokens[-1].Kind -eq 'EndOfInput') -or ($tokens[0].Kind -eq 'LCurly' -and $tokens[-3].Kind -eq 'RCurly' -and $tokens[-2].Kind -eq 'NewLine' -and $tokens[-1].Kind -eq 'EndOfInput')) { @{ Kind = 'ScriptBlock' Value = $InputObject } } elseif ($tokens.Where({ $_.Kind -eq 'StringExpandable' }).Count -eq 1) { @{ Kind = 'ExpandableString' Value = $tokens.Where({ $_.Kind -eq 'StringExpandable' }).Value } } elseif ($tokens.Where({ $_.Kind -eq 'StringLiteral' }).Count -eq 1) { Write-Warning "Get-ValueKind: The value '$InputObject' is a literal string and cannot be expanded." @{ Kind = 'LiteralString' Value = $tokens.Where({ $_.Kind -eq 'StringLiteral' }).Value } } else { Write-Error "Get-ValueKind: The value '$InputObject' could not be parsed. It is not a scriptblock nor a string." } } #EndRegion './Private/Get-ValueKind.ps1' 100 #Region './Private/Invoke-InvokeCommandActionInternal.ps1' -1 function Invoke-InvokeCommandActionInternal { <# .SYNOPSIS Executes the parsed command content extracted from an embedded Datum command string. .DESCRIPTION This internal function is called by `Invoke-InvokeCommandAction` after the input has been parsed and its type (script block, expandable string, or literal string) has been determined by `Get-ValueKind`. Based on the `DatumType.Kind`, the function performs one of the following: - **ScriptBlock**: Creates and invokes the script block using `[scriptblock]::Create()`. - **ExpandableString**: Expands the string using `$ExecutionContext.InvokeCommand.ExpandString()`. - **Other** (e.g., LiteralString): Returns the value as-is. The function also: - Sets `$global:CurrentDatumNode` and `$global:CurrentDatumFile` for use within script blocks. - Detects and prevents self-referencing loops when `Get-DatumRsop` is involved. - Recursively resolves nested embedded commands by calling `Test-InvokeCommandFilter` and `Invoke-InvokeCommandAction` on the result. - Logs invocation timing for performance diagnostics via `Write-Verbose`. .PARAMETER DatumType A hashtable containing the parsed command content with the following keys: - `Kind`: The type of content (`ScriptBlock`, `ExpandableString`, or `LiteralString`). - `Value`: The raw content string to evaluate. .PARAMETER Datum The Datum structure (hashtable) providing access to the full configuration data hierarchy. Falls back to `$DatumTree` if not provided. .NOTES This is a private function and is not exported by the module. It is called exclusively by `Invoke-InvokeCommandAction`. .LINK https://github.com/raandree/Datum.InvokeCommand .LINK Invoke-InvokeCommandAction #> param ( [Parameter(Mandatory = $true)] [hashtable] $DatumType, [Parameter()] [hashtable] $Datum ) if (-not $datum -and $DatumTree) { $datum = $DatumTree } #Prevent self-referencing loop if (($DatumType.Value.Contains('Get-DatumRsop')) -and ((Get-PSCallStack).Command | Where-Object { $_ -eq 'Get-DatumRsop' }).Count -gt 1) { return $DatumType.Value } try { $callId = New-Guid $start = Get-Date $global:CurrentDatumNode = $Node $global:CurrentDatumFile = $file Write-Verbose "Invoking command '$($DatumType.Value)'. CallId is '$callId'" $result = if ($DatumType.Kind -eq 'ScriptBlock') { $command = [scriptblock]::Create($DatumType.Value) & (& $command) } elseif ($DatumType.Kind -eq 'ExpandableString') { $ExecutionContext.InvokeCommand.ExpandString($DatumType.Value) } else { $DatumType.Value } $dynamicPart = $true while ($dynamicPart) { if ($dynamicPart = Test-InvokeCommandFilter -InputObject $result -ReturnValue) { $innerResult = Invoke-InvokeCommandAction -InputObject $result -Datum $Datum -Node $node $result = $result.Replace($dynamicPart, $innerResult) } } $duration = (Get-Date) - $start Write-Verbose "Invoke with CallId '$callId' has taken $([System.Math]::Round($duration.TotalSeconds, 2)) seconds" if ($result -is [string]) { $ExecutionContext.InvokeCommand.ExpandString($result) } else { $result } } catch { Write-Error -Message ($script:localizedData.CannotCreateScriptBlock -f $DatumType.Value, $_.Exception.Message) -Exception $_.Exception return $DatumType.Value } } #EndRegion './Private/Invoke-InvokeCommandActionInternal.ps1' 114 #Region './Public/Invoke-InvokeCommandAction.ps1' -1 function Invoke-InvokeCommandAction { <# .SYNOPSIS Invokes a Datum handler action that evaluates embedded commands within Datum configuration data. .DESCRIPTION This function is the primary action handler for the Datum.InvokeCommand module. It is called by the Datum framework when `Test-InvokeCommandFilter` returns `$true` for a given value. The function processes input objects that contain embedded commands wrapped in the configurable header/footer delimiters (default: `[x=` and `=]`). The function supports three types of embedded content: - **ScriptBlocks**: Code wrapped in curly braces, e.g., `[x={ Get-Date }=]` - **Expandable strings**: Double-quoted strings with variable expansion, e.g., `[x="$($Node.Name)"=]` - **Literal strings**: Single-quoted strings (returned as-is with a warning), e.g., `[x='literal'=]` When a Datum value contains an embedded command, the handler extracts the content using the configured regular expression, determines whether it is a script block or expandable string via `Get-ValueKind`, and then invokes or expands it accordingly via `Invoke-InvokeCommandActionInternal`. The function provides access to `$Node`, `$Datum`, and `$File` variables within the embedded commands, enabling dynamic lookups across the Datum hierarchy. Error handling behavior is controlled by the `DatumHandlersThrowOnError` property in the Datum definition. When set to `$true`, errors terminate execution; otherwise, warnings are emitted and the original input value is returned. For more information about Datum and Datum handlers, see https://github.com/gaelcolas/datum/. .PARAMETER InputObject The input object containing the embedded command string(s) to evaluate. This parameter accepts pipeline input. The value should contain a string wrapped with the configured header and footer delimiters (default: `[x=` ... `=]`). .PARAMETER Datum The Datum structure (hashtable) providing access to the full configuration data hierarchy. This is made available as `$Datum` within embedded script blocks and expandable strings, enabling lookups like `$Datum.Global.Adds.DomainName`. .PARAMETER Node The current node context (hashtable or object) being processed. If not provided, the function attempts to resolve the node from the file path using `Get-DatumCurrentNode`. This is made available as `$Node` within embedded commands. .PARAMETER ProjectPath The root path of the DSC configuration project. Used to resolve relative paths within embedded commands. .EXAMPLE $value = '[x={ Get-Date }=]' Invoke-InvokeCommandAction -InputObject $value Evaluates the embedded script block `{ Get-Date }` and returns the current date/time. .EXAMPLE $value = '[x="$($Node.Name) in $($Node.Environment)"=]' Invoke-InvokeCommandAction -InputObject $value -Datum $datum -Node $node Expands the embedded string using the `$Node` variable, returning something like 'DSCFile01 in Dev'. .EXAMPLE $value = '[x={ $Datum.Global.Adds.DomainFqdn }=]' Invoke-InvokeCommandAction -InputObject $value -Datum $datum Evaluates the script block to return the domain FQDN from Datum's global configuration, e.g., 'contoso.com'. .EXAMPLE $value = '[x={ @{ Name = "Server1"; Value = "Config1" } }=]' Invoke-InvokeCommandAction -InputObject $value Returns a hashtable from the evaluated script block. Script blocks can return any PowerShell object type including hashtables, arrays, and custom objects. .EXAMPLE '[x={ Get-Date }=]' | Invoke-InvokeCommandAction Demonstrates pipeline input support. .NOTES This function is registered as a Datum handler in the `Datum.yml` configuration file under the `DatumHandlers` section: DatumHandlers: Datum.InvokeCommand::InvokeCommand: SkipDuringLoad: true The `SkipDuringLoad: true` flag prevents the handler from being invoked during the initial Datum structure loading. Commands are evaluated only during value resolution (e.g., RSOP computation). The header and footer delimiters can be customized in the module configuration file `Config\Datum.InvokeCommand.Config.psd1`. .LINK https://github.com/raandree/Datum.InvokeCommand .LINK https://github.com/gaelcolas/datum/ .LINK Test-InvokeCommandFilter #> param ( [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [object] $InputObject, [Parameter()] [hashtable] $Datum, [Parameter(ValueFromPipelineByPropertyName = $true)] [object] $Node, [Parameter()] [string] $ProjectPath ) $throwOnError = [bool]$datum.__Definition.DatumHandlersThrowOnError if ($InputObject -is [array]) { $returnValue = @() } else { $returnValue = $null } foreach ($value in $InputObject) { $regexResult = ($datumInvokeCommandRegEx.Match($value).Groups['Content'].Value) if (-not $regexResult -and $throwOnError) { Write-Error "Could not get the content for the Datum.InvokeCommand handler, RegEx '$($datumInvokeCommandRegEx.ToString())' did not succeed." -ErrorAction Stop } elseif (-not $regexResult -and -not $throwOnError) { Write-Warning "Could not get the content for the Datum.InvokeCommand handler, RegEx '$($datumInvokeCommandRegEx.ToString())' did not succeed." $returnValue += $value continue } $datumType = Get-ValueKind -InputObject $regexResult -ErrorAction (& { if ($throwOnError) { 'Stop' } else { 'Continue' } }) if ($datumType) { try { $file = $null # avoid TerminatingError in log if $value is an attribute of node.yml # -> in this case $value.__File is $null if( $value.__File ) { $file = Get-Item -Path $value.__File -ErrorAction Ignore } } catch { Write-Verbose 'Invoke-InvokeCommandAction: Nothing to catch' } if (-not $Node -and $file) { if ($file.Name -ne 'Datum.yml') { $Node = Get-DatumCurrentNode -File $file if (-not $Node) { return $value } } } try { $returnValue += (Invoke-InvokeCommandActionInternal -DatumType $datumType -Datum $Datum -ErrorAction Stop).ForEach({ $_ | Add-Member -Name __File -MemberType NoteProperty -Value "$file" -PassThru -Force }) } catch { $throwOnError = [bool]$datum.__Definition.DatumHandlersThrowOnError if ($throwOnError) { Write-Error -Message "Error using Datum Handler $Handler, the error was: '$($_.Exception.Message)'. Returning InputObject ($InputObject)." -Exception $_.Exception -ErrorAction Stop } else { Write-Warning "Error using Datum Handler $Handler, the error was: '$($_.Exception.Message)'. Returning InputObject ($InputObject)." $returnValue += $value continue } } } else { $returnValue += $value } } if ($InputObject -is [array]) { , $returnValue } else { $returnValue } } #EndRegion './Public/Invoke-InvokeCommandAction.ps1' 229 #Region './Public/Test-InvokeCommandFilter.ps1' -1 function Test-InvokeCommandFilter { <# .SYNOPSIS Tests whether a Datum value contains an embedded command that should be processed by the Datum.InvokeCommand handler. .DESCRIPTION This filter function is invoked by the Datum framework's `ConvertTo-Datum` function on every value during data resolution. When this function returns `$true`, the Datum framework calls the corresponding action function `Invoke-InvokeCommandAction` to evaluate the embedded command. The function checks whether the input object is a string that matches the configured regular expression pattern for embedded commands (default pattern: `[x=` ... `=]`). The header and footer delimiters are defined in `Config\Datum.InvokeCommand.Config.psd1` and compiled into a regex at module load time. When the `-ReturnValue` switch is specified, the function returns the full matched string instead of a boolean. This is used internally by `Invoke-InvokeCommandActionInternal` to detect and resolve nested embedded commands. For more information about Datum handlers and their filter/action pattern, see https://github.com/gaelcolas/datum/. .PARAMETER InputObject The object to test. Only string values are evaluated against the embedded command pattern. Non-string objects always return `$false`. Accepts pipeline input. .PARAMETER ReturnValue When specified, returns the full matched string (including header and footer) instead of a boolean. Used internally for nested command resolution. .EXAMPLE Test-InvokeCommandFilter -InputObject '[x={ Get-Date }=]' Returns `$true` because the input string matches the embedded command pattern. .EXAMPLE Test-InvokeCommandFilter -InputObject 'Just a regular string' Returns `$false` because the input string does not contain an embedded command. .EXAMPLE '[x={ Get-Date }=]' | Test-InvokeCommandFilter Demonstrates pipeline input. Returns `$true`. .EXAMPLE Test-InvokeCommandFilter -InputObject '[x={ Get-Date }=]' -ReturnValue Returns the full matched string `[x={ Get-Date }=]` instead of `$true`. .EXAMPLE 42 | Test-InvokeCommandFilter Returns `$false` because the input is not a string. .NOTES Datum handler modules follow a convention of exposing a filter function (`Test-*`) and an action function (`Invoke-*`). The filter function is called first to determine if the action should be invoked for a given value. This pattern is described in the Datum documentation. .LINK https://github.com/raandree/Datum.InvokeCommand .LINK https://github.com/gaelcolas/datum/ .LINK Invoke-InvokeCommandAction #> param ( [Parameter(ValueFromPipeline = $true)] [object] $InputObject, [Parameter()] [switch] $ReturnValue ) if ($InputObject -is [string]) { $all = $datumInvokeCommandRegEx.Match($InputObject.Trim()).Groups['0'].Value $content = $datumInvokeCommandRegEx.Match($InputObject.Trim()).Groups['Content'].Value if ($ReturnValue -and $content) { $all } elseif ($content) { return $true } else { return $false } } else { return $false } } #EndRegion './Public/Test-InvokeCommandFilter.ps1' 105 |