Scriptbook.psm1
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars", "")] param( [parameter(Mandatory = $false)][HashTable]$ImportVars ) Write-Verbose "Start loading Scriptbook module"; # check if single file module if (Test-Path (Join-Path $PSScriptRoot Public)) { $dotSourceParams = @{ Filter = '*.ps1' Recurse = $true ErrorAction = 'Stop' } $public = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Public') @dotSourceParams ) $private = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Private/*.ps1') @dotSourceParams) foreach ($import in @($private + $public)) { try { . $import.FullName } catch { throw "Unable to dot source [$($import.FullName)]" } } } $verbose = $false if ($ImportVars -and $ImportVars.ContainsKey('Verbose')) { $verbose = $ImportVars.Verbose } if ($verbose) { $VerbosePreference = 'Continue' } $strict = $true if ($ImportVars -and $ImportVars.ContainsKey('NoStrict')) { $strict = -not $ImportVars.NoStrict } if ($strict) { Set-StrictMode -Version Latest } $quiet = $false if ($ImportVars -and $ImportVars.ContainsKey('Quiet')) { $quiet = $ImportVars.Quiet } if (!($quiet)) { $module = 'Scriptbook' $scriptDir = Split-Path $MyInvocation.MyCommand.Path $manifestPath = Join-Path $scriptDir "$module.psd1" $manifest = Test-ModuleManifest -Path $manifestPath -WarningAction SilentlyContinue $version = $manifest.Version.ToString() $copyright = $manifest.Copyright $author = $manifest.Author Write-Host "$module Version $version by $author" Write-Host "Proudly created in Schiedam (NLD), $copyright" } $core = $false if ($ImportVars -and $ImportVars.ContainsKey('Core')) { $core = $ImportVars.Core } if ($core) { if ($PSVersionTable.PSVersion.Major -lt 6) { Write-Host ''.PadRight(78, '=') Throw "PowerShell version $($PSVersionTable.PSVersion) not supported by this Script" } } $Global:ScriptbookSimpleHost = $false if ($ImportVars -and $ImportVars.ContainsKey('SimpleHost')) { $Global:ScriptbookSimpleHost = $ImportVars.SimpleHost } $checkModules = $true $cacheTimeFile = Join-Path $home './Scriptbook.ModuleCache.json' $importFormat = 'Scriptbook' # load dependencies from file if not override by arguments if ($null -eq $ImportVars -or ($ImportVars.ContainsKey('SettingsFile') -or ($ImportVars.ContainsKey('Quiet') -and $ImportVars.Count -eq 1)) ) { if ($ImportVars -and $ImportVars.ContainsKey('SettingsFile')) { $importFile = $ImportVars.SettingsFile } else { $importFile = '' } $scriptName = $Script:MyInvocation.ScriptName if ([string]::IsNullOrEmpty($scriptName)) { $scriptName = Join-Path $PSScriptRoot 'Scriptbook.ps1' } if (Test-Path variable:Profile) { $profileLocation = Join-Path (Split-Path $Profile) 'Scriptbook.psd1' } else { $profileLocation = '' } $importFiles = @( $importFile, [IO.Path]::ChangeExtension($scriptName, 'psd1'), [IO.Path]::ChangeExtension((Join-Path (Split-Path $scriptName) ".$(Split-Path $scriptName -Leaf)"), 'psd1'), (Join-Path (Split-Path $scriptName) 'Scriptbook.psd1'), (Join-Path (Split-Path $scriptName) '.Scriptbook.psd1'), './depends.psd1', './variables.psd1', './.depends.psd1', './.variables.psd1', './requirements.psd1', $profileLocation ) foreach ($f in $importFiles) { if ( ![string]::IsNullOrEmpty($f) -and (Test-Path -Path $f -ErrorAction Ignore) ) { $ImportVars = Import-PowerShellDataFile -Path $f if ($f.Contains('requirements.psd1')) { $importFormat = 'Requirements' } break; } } if ($null -eq $ImportVars) { $ImportVars = @{} } } if ($ImportVars -and $importFormat -eq 'Requirements') { <# Import modules from requirements format: https://docs.microsoft.com/azure/azure-functions/functions-reference-powershell sample: @{ 'Az.Accounts' = '2.*' 'Az.Compute' = '4.*' } #> $ImportVarsNew = @{} $modules = [System.Collections.ArrayList]@() foreach ($m in $ImportVars.GetEnumerator()) { $modules.Add(@{ Module = $m.Key MinimumVersion = $m.Value } ) | Out-Null } $ImportVarsNew.Add('Depends', $modules) | Out-Null $ImportVars = $ImportVarsNew } if ($ImportVars.ContainsKey('Reset') -and $ImportVars.Reset) { # default is reset } else { if (Test-Path $cacheTimeFile) { # determine if we need to load the modules from repository again # now we check once a day the repository feed if new version are available # to speed up the start-time of our workbooks function TestPropInt2([object]$Object, [string]$Name) { foreach ($prop in $Object.PSObject.Properties) { if ($prop.Name -eq $Name) { return $true } } return $false } $md = $null $moduleCache = Get-Content -Path $cacheTimeFile -Raw | ConvertFrom-Json if (TestPropInt2 -Object $moduleCache -Name 'Time') { if (TestPropInt2 -Object $moduleCache.Time -Name 'Value') { if ($moduleCache.Time.Value -is [string]) { $md = [System.DateTime]::Parse($moduleCache.Time.Value).Date } elseif ($moduleCache.Time.Value -is [DateTime]) { $md = $moduleCache.Time.Value.Date } } else { $md = [System.DateTime]::Parse($moduleCache.Time).Date } if ($md -and ($md -eq ((Get-Date).Date))) { $checkModules = $false } } } } if ($checkModules -eq $true) { Set-Content -Path $cacheTimeFile -Value (@{ Time = (Get-Date) } | ConvertTo-Json) -Force Write-Verbose 'Loading modules...' } $depends = $null if ($ImportVars -and $ImportVars.ContainsKey('Depends')) { $depends = $ImportVars.Depends } if ($depends) { # TODO !!EH Add install modules from git repo # TODO !!EH Add cache module option (create copy local and import from local), used in scenarios without internet access (deployments) / isolated containers Write-Host "Loading Scriptbook dependencies..." $pref = $global:ProgressPreference $global:ProgressPreference = 'SilentlyContinue' foreach ($dependency in $depends) { if (!$dependency.ContainsKey('Module')) { throw "Module not found in dependency: $dependency" } $skip = if ($dependency.ContainsKey('Skip')) { $dependency.Skip } else { $false } if ($skip) { continue } if (!(Test-Path $dependency.Module)) { $skipPublisherCheck = if ($dependency.ContainsKey('SkipPublisherCheck')) { $dependency.SkipPublisherCheck } else { $false } $minimumVersion = if ($dependency.ContainsKey('MinimumVersion')) { $dependency.MinimumVersion } else { '' } $maximumVersion = if ($dependency.ContainsKey('MaximumVersion')) { $dependency.MaximumVersion } else { '' } $extraParams = @{} $credentialLocation = if ($dependency.ContainsKey('Credential')) { $dependency.Credential } else { $null } if ($credentialLocation) { if ($credentialLocation.StartsWith('https://')) { # dependency on TD.Util Module, load this module first if (Get-Command Get-AzureDevOpsCredential -ErrorAction Ignore) { $cred = Get-AzureDevOpsCredential -Url $credentialLocation $extraParams.Add('Credential', $cred) | Out-Null } } else { try { $cred = Get-LocalCredential -Name $credentialLocation $extraParams.Add('Credential', $cred) | Out-Null } catch { Write-Warning $_.Exception.Message } } } $repository = if ($dependency.ContainsKey('Repository')) { $dependency.Repository } else { 'PSGallery' } if ($repository -ne 'PSGallery') { $repo = Get-PSRepository -Name $repository -ErrorAction Ignore if ($null -eq $repo) { $repositoryUrl = if ($dependency.ContainsKey('RepositoryUrl')) { $dependency.RepositoryUrl } else { $null } if ($repositoryUrl) { if (Get-Command Register-AzureDevOpsPackageSource -ErrorAction Ignore) { Register-AzureDevOpsPackageSource -Name $repository -Url $repositoryUrl @extraParams } else { Register-PSRepository -Name $repository -SourceLocation $repositoryUrl -InstallationPolicy Trusted @extraParams } } } } if (Get-Module -Name $dependency.Module -ListAvailable -ErrorAction Ignore) { if ($checkModules) { if ($null -ne (Get-InstalledModule -Name $dependency.Module -ErrorAction Ignore) ) { $force = if ($dependency.ContainsKey('Force')) { $dependency.Force } else { $false } if ($minimumVersion) { $v1 = (Get-Module -Name $dependency.Module -ListAvailable | Select-Object -First 1).Version $v2 = [version]$minimumVersion if ($v2 -gt $v1) { Write-Verbose "Updating module $($dependency.Module) with MinimumVersion $minimumVersion" Update-Module -Name $dependency.Module -Force:$force -RequiredVersion $minimumVersion @extraParams } } else { Write-Verbose "Updating module $($dependency.Module) with MaximumVersion $maximumVersion" Update-Module -Name $dependency.Module -Force:$force -MaximumVersion $maximumVersion @extraParams } } else { Write-Warning "Module $($dependency.Module) not installed by Install-Module, cannot update module via Update-Module, using forced Install-Module" Write-Verbose "Installing module $($dependency.Module)" Install-Module -Name $dependency.Module -Force -Repository $repository -Scope CurrentUser -MinimumVersion $minimumVersion -MaximumVersion $maximumVersion -AllowClobber -SkipPublisherCheck:$skipPublisherCheck @extraParams } } } else { Write-Verbose "Installing module $($dependency.Module)" # TODO !!EH using -Force to install from untrusty repositories or do we need to handle this via Force attribute Install-Module -Name $dependency.Module -Force -Repository $repository -Scope CurrentUser -MinimumVersion $minimumVersion -MaximumVersion $maximumVersion -AllowClobber -SkipPublisherCheck:$skipPublisherCheck @extraParams } } if ($dependency.ContainsKey('Args')) { Import-Module -Name $dependency.Module -ArgumentList $dependency.Args -Global } else { Import-Module -Name $dependency.Module -Global } } $global:ProgressPreference = $pref } $variables = $null if ($ImportVars -and $ImportVars.ContainsKey('Variables')) { $variables = $ImportVars.Variables } if ($variables) { foreach ($kv in $variables.GetEnumerator()) { Set-Variable -Scope Global -Name $kv.Key -Value $kv.Value -Force } } # snapshot global vars, excluding parameters $Global:GlobalVarNames = @{} Get-Variable -Scope Global | ForEach-Object { if (!$_.Attributes) { $Global:GlobalVarNames.Add($_.Name, $null) | Out-Null } elseif ($_.Attributes.GetType().Name -ne 'PSVariableAttributeCollection' -or $_.Attributes.Count -eq 0) { $Global:GlobalVarNames.Add($_.Name, $null) | Out-Null } elseif ($_.Attributes[0].GetType().Name -ne 'ParameterAttribute') { $Global:GlobalVarNames.Add($_.Name, $null) | Out-Null } } # cleanup module script scope @( 'variables', 'author', 'checkModules', 'cacheTimeFile', 'copyright', 'depends', 'dotSourceParams', 'importFile', 'importFiles', 'importFormat', 'ImportVars', 'manifest', 'manifestPath', 'module', 'moduleCache', 'private', 'profileLocation', 'public', 'quiet', 'scriptDir', 'scriptName', 'version', 'verbose', 'f', 'strict', 'import' ) | ForEach-Object { Remove-Variable -Force -ErrorAction Ignore -Scope Script -Name $_ } Write-Verbose "Finished loading Scriptbook module"; # end import ; # experimental Set-Alias -Name Info -Value New-Info -Scope Global -Force -WhatIf:$false Set-Alias -Name Documentation -Value New-Info -Scope Global -Force -WhatIf:$false function New-Info { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [ScriptBlock] $Code, [string]$Comment, [Switch]$NoDisplay, [Switch]$Skip, [Switch]$AsDocumentation ) if ($null -eq $Code) { Throw "No info script block is provided. (Have you put the open curly brace on the next line?)" } if ($Skip.IsPresent) { return } if ($PSCmdlet.ShouldProcess("New-Info")) { if ($PSCmdlet.MyInvocation.InvocationName -eq 'Documentation') { $AsDocumentation = $true } $text = $null if ($Comment) { $text = $Comment + [System.Environment]::NewLine } $text += Get-CommentFromCode -ScriptBlock $Code if ($AsDocumentation.IsPresent) { $ctx = Get-RootContext [void]$ctx.Infos.Add($text) } elseif (!($NoDisplay.IsPresent) -or ($VerbosePreference -eq 'Continue') ) { Write-Info ($text | Out-String | Show-Markdown) } } } # experimental Set-Alias -Name S -Value New-Section -Scope Global -Force -WhatIf:$false Set-Alias -Name Section -Value New-Section -Scope Global -Force -WhatIf:$false Set-Alias -Name Markdown -Value New-Section -Scope Global -Force -WhatIf:$false function New-Section { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( $Text, [Switch]$Skip, [ScriptBlock] $Code ) if ($Skip.IsPresent) { return } if ($PSCmdlet.ShouldProcess("New-Section")) { if ($Text -is [ScriptBlock]) { Write-ScriptBlock $Text } else { Write-StringResult "$Text" Write-StringResult '' if ($null -eq $Code) { Throw "No section script block is provided. (Have you put the open curly brace on the next line?)" } Write-ScriptBlock $Code } Write-StringResult '' } } # experimental function Add-FunctionOverWrite([alias('f', 'Func')]$aFunc, [alias('n', 'WithFunc')]$aWithFunc) { Remove-Item "Alias:\$aFunc" -Force -ErrorAction Ignore New-Alias -Name $aFunc -Value $aWithFunc -Scope Global } # experimental function Invoke-FunctionOverWritten([alias('f', 'Func')]$aFunc) { $v = Get-Alias -Name $aFunc -Scope Global -ErrorAction Ignore if ($v) { Remove-Item "Alias:\$aFunc" -Force -ErrorAction Ignore } &$aFunc if ($v) { New-Alias -Name $aFunc -Value $v.Definition -Scope Global } } # experimental function Remove-FunctionOverWrite { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param([alias('f', 'Func')]$aFunc) if ($PSCmdlet.ShouldProcess("Remove-FunctionOverWrite")) { Remove-Item "Alias:\$aFunc" -Force -ErrorAction Ignore } } function FormatTaskName([Parameter(Mandatory = $true)]$Format) { # not supported/needed } function Include { [CmdletBinding()] param( [ScriptBlock] $Code ) if ($null -eq $Code) { Throw "No include script block is provided. (Have you put the open curly brace on the next line?)" } & $code } function Invoke-Task([Parameter(Mandatory = $true)]$TaskName) { Invoke-PerformIfDefined -Command "Action-$TaskName" -ThrowError $true -Manual } # TODO !!EH Convert $Script:PsakeProperties to list of scriptblocks to circumvent multiple allowed properties statements in psake scripts function Properties { [CmdletBinding()] param( [ScriptBlock] $Code ) if ($null -eq $Code) { Throw "No properties script block is provided. (Have you put the open curly brace on the next line?)" } $Script:PsakeProperties = $Code } Set-Alias -Name Invoke-PSake -Value Start-PSakeWorkflow -Scope Global -Force -WhatIf:$false function Start-PSakeWorkflow { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [Parameter(Mandatory = $true, Position = 0)][string] $File, [Parameter(Position = 1)][HashTable] $Parameters, [Parameter(Position = 2)][HashTable] $Properties ) if ($PSCmdlet.ShouldProcess("Start-PSakeWorkflow")) { $Script:PsakeInvocationParameters = $Parameters $Script:PsakeInvocationProperties = $Properties . $File } } # compatibility with PSake function Task { [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)][string] $Name, [String[]] $Tag = @(), [String[]] $Depends = @(), $Parameters = @{}, [Switch]$AsJob, [String]$Description, [ScriptBlock]$PreCondition = { $true }, [ScriptBlock]$PostCondition, # not implemented [Switch]$ContinueOnError, [String]$FromModule, [Parameter(Position = 1)] [ScriptBlock] $Code ) $lName = $Name -replace 'Invoke-', '' if ($FromModule) { if (!($taskModule = Get-Module -Name $FromModule)) { $taskModule = Get-Module -Name $FromModule -ListAvailable -ErrorAction Ignore -Verbose:$False | Sort-Object -Property Version -Descending | Select-Object -First 1 } $psakeFilePath = Join-Path -Path $taskModule.ModuleBase -ChildPath 'psakeFile.ps1' if (Test-Path $psakeFilePath) { . $psakeFilePath } } else { if ($lName -ne 'Default' -and $lName -ne '.' ) { if ($null -eq $Code) { Write-Verbose "Task: $lName No code script block is provided. (Have you put the open curly brace on the next line?)" $Code = {} } if ($null -ne $PostCondition) { Write-Warning "Post Condition not implemented" } $eaValue = 'Stop' if ($ContinueOnError.IsPresent) { $eaValue = 'Ignore' } Register-Action -Name $Name -Tag $Tag -Depends $Depends -Parameters $Parameters -AsJob:$AsJob.IsPresent -If $PreCondition -ErrorAction $eaValue -Description $Description -Code $Code -TypeName Task } } # start build-in Action: Start Workflow if ($lName -eq 'Default' -or $lName -eq '.') { Start-Workflow -Actions $Depends -Parameters $Parameters -Location (Get-Location) } } #Set-Alias -Name ActionSetup -Value TaskSetup -Scope Global -Force -WhatIf:$false Set-Alias -Name Initialize-Action -Value TaskSetup -Scope Global -Force -WhatIf:$false function TaskSetup { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [scriptblock]$Setup ) $Script:PsakeSetupTask = $Setup } #Set-Alias -Name ActionTearDown -Value TaskTearDown -Scope Global -Force -WhatIf:$false Set-Alias -Name Complete-Action -Value TaskTearDown -Scope Global -Force -WhatIf:$false function TaskTearDown { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [scriptblock]$TearDown ) $Script:PsakeTearDownTask = $TearDown } <# .SYNOPSIS Defines an action executed by Workflow .DESCRIPTION Defines an action executed by Workflow. At least one action is required by workflow. Actions are executed in workflow sequence and/or their dependency order. By using the If condition an action can be excluded from the workflow execution. An action scriptblock can be executed multiple times by using the For expression. Default the action runs in the workflow of the current Powershell session. Optionally action scriptblock can be executed in a Docker Container or a Remote Powershell Session. Action in Docker Container - requires docker installed on current machine Action in Remote Powershell Session - requires valid PSSession with access to remote host (PS Remoting or SSH Remoting) .PARAMETER Name Set the Name of the action .PARAMETER Tag Set the tag(s) array of the action .PARAMETER Depends Set list of dependent actions. Dependent actions are executed before this action in the order of the workflow dependency graph .PARAMETER Parameters Parameters to pass to Workflow .PARAMETER AsJob Run action in Powershell Job (Separate process) .PARAMETER Description Contains description of Action .PARAMETER If Boolean expression to determine if action if executed, like { $value -eq $true } .PARAMETER Disabled Determines if action is disabled, exempt from workflow .PARAMETER NextAction Next Action to execute when action is finished .PARAMETER For Sequence expression for setting the number of executing the same action scriptblock, like { 1..10 } or { 'hello','and','goodby' } .PARAMETER Parallel Determines if the action scriptblock is executed in parallel. Used by the For expression when running in Powershell 7+ and when used with switch -AsJob .PARAMETER Container Determines if action is run in container .PARAMETER ContainerOptions Determines the container options when running action in a container. ContainerOptions.Image contains the container image used. .PARAMETER Session Contains the remote session object to run the action scriptblock on remote host via Powershell Remoting .PARAMETER Isolated Determines when if user ps-modules, scriptbook module and script/workflow are copied/used by (remote) container or remote-session. .PARAMETER Unique Always generates unique name for Action. Prevent collision with required unique Action names .PARAMETER RequiredVariables Enables checking for required variables before action starts. If variable not available in global, script or local scope action fails. .PARAMETER Comment Add Comment to Action for documentation purpose .PARAMETER SuppressOutput if present suppresses write/output of Action return value or output stream to console .PARAMETER Always Determines if Action is Always executed regardless of any error in other actions, or missing in dependency tree, or missing in Workflow Actions .PARAMETER NoSequence Determines if Action is executed when sequential/linear Workflow is executed .PARAMETER Code Contains the action code/scriptblock to execute when action is enabled (default) .EXAMPLE .NOTES #> Set-Alias -Name Test -Value Action -Scope Global -Force -WhatIf:$false -Confirm:$False Set-Alias -Name Step -Value Action -Scope Global -Force -WhatIf:$false -Confirm:$False Set-Alias -Name Activity -Value Action -Scope Global -Force -WhatIf:$false -Confirm:$False Set-Alias -Name Job -Value Action -Scope Global -Force -WhatIf:$false -Confirm:$False Set-Alias -Name Chore -Value Action -Scope Global -Force -WhatIf:$false -Confirm:$False Set-Alias -Name Stage -Value Action -Scope Global -Force -WhatIf:$false -Confirm:$False Set-Alias -Name Override -Value Action -WhatIf:$false -Confirm:$False function Action { param( [Parameter(Mandatory = $true, Position = 0)][string] $Name, [String[]] $Tag = @(), [String[]] $Depends = @(), [HashTable]$Parameters = @{}, [Switch]$AsJob, [ValidateNotNullOrEmpty()] [String]$Description, [ScriptBlock] $If, [alias('Skip')][switch] $Disabled, [ValidateNotNullOrEmpty()] [string][alias('Next')] $NextAction, [ScriptBlock] $For, [switch] $Parallel, [switch] $Container, [HashTable] $ContainerOptions = @{}, [AllowNull()] $Session, [switch]$Isolated, [switch]$Unique, [ValidateNotNull()] [string[]]$RequiredVariables = @(), [string]$Comment, [switch]$SuppressOutput, [switch]$Always, [switch]$NoSequence, [switch]$WhatIf, [switch]$Confirm, [Parameter(Position = 1)] [ScriptBlock] $Code ) $lName = $Name -replace 'Invoke-', '' if ($PSCmdlet.MyInvocation.InvocationName -eq 'Test') { $Name = "Test.$Name" $lName = $Name } if ($lName -ne 'Default' -and $lName -ne '.' ) { if ($null -eq $Code) { if ([string]::IsNullOrEmpty($Name)) { Throw "No code script block is provided and Name property is mandatory. (Have you put the open curly brace on the next line?)" } else { $n = $Name.Split("`n") if ($n.Count -gt 1) { Throw "No Name provide for Action, Name is required, found scriptblock { $Name } instead." } else { Throw "No code script block is provided for '$Name'. (Have you put the open curly brace on the next line?)" } } } Register-Action -Name $Name -Tag $Tag -Depends $Depends -Parameters $Parameters -ErrorAction $ErrorActionPreference -AsJob:$AsJob.IsPresent -If $If -Description $Description -Code $Code -Disabled $Disabled.IsPresent -TypeName $PSCmdlet.MyInvocation.InvocationName -NextAction $NextAction -For $For -Parallel:$Parallel.IsPresent -Container:$Container.IsPresent -ContainerOptions $ContainerOptions -Session $Session -Isolated:$Isolated.IsPresent -Unique:$Unique.IsPresent -RequiredVariables $RequiredVariables -Comment $Comment -SuppressOutput:$SuppressOutput.IsPresent -Always:$Always.IsPresent -NoSequence:$NoSequence.IsPresent -WhatIf:$WhatIf.IsPresent -Confirm:$Confirm.IsPresent } # Start build-in Action: Start Workflow if ($lName -eq 'Default' -or $lName -eq '.') { Start-Workflow -Actions $Depends -Parameters $Parameters -Location (Get-Location) } } <# .SYNOPSIS Add Workflow Notification .DESCRIPTION Add Notifications to Workflow. Notification are shown on console when Workflow is finished. A way to emphasis Workflow output .PARAMETER Message Notification Message .PARAMETER Decoration Determines if notification is just decorative .PARAMETER Target Determines target of Notification, just console for now. #> function Add-WorkflowNotification([ValidateNotNullOrEmpty()][string]$Message, [switch]$Decoration, $Target = 'Console') { $ctx = Get-RootContext [void]$ctx.Notifications.Add($Message) } <# .SYNOPSIS Disables action to execute .DESCRIPTION Disables action to execute, can change during run-time .PARAMETER Name Name of the Action #> function Disable-Action([ValidateNotNullOrEmpty()][string]$Name) { $ctx = Get-RootContext if ($ctx.Actions.Count -eq 0) { throw "No actions defined or workflow finished." } $action = $ctx.Actions["Action-$($Name.Replace('Action-',''))"] if ($action) { $action.Disabled = $true } else { throw "Action $Name not found in Disable-Action" } } <# .SYNOPSIS Enables action to execute .DESCRIPTION Enabled action to execute, can change during run-time .PARAMETER Name Name of the Action #> function Enable-Action([ValidateNotNullOrEmpty()][string]$Name) { $ctx = Get-RootContext if ($ctx.Actions.Count -eq 0) { throw "No actions defined or workflow finished." } $action = $ctx.Actions["Action-$($Name.Replace('Action-',''))"] if ($action) { $action.Disabled = $false } else { throw "Action $Name not found in Enable-Action" } } <# .SYNOPSIS Returns Action return-value or the output written to output streams .DESCRIPTION Returns Action return-value or the output written to output streams. Each Action can return output for use in other Actions. Normally output is ignored and does not interfere with workflow .PARAMETER Name Name of the Action #> Set-Alias -Name Get-ActionOutput -Value Get-ActionReturnValue -Scope Global -Force -WhatIf:$false Set-Alias -Name Get-ActionOutputValue -Value Get-ActionReturnValue -Scope Global -Force -WhatIf:$false function Get-ActionReturnValue { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")] param($Name) $ctx = Get-RootContext if ($ctx.Actions.Count -eq 0) { throw "No actions defined or workflow finished." } $returnValue = $null $action = $ctx.Actions["Action-$($Name.Replace('Action-',''))"] if ($action) { $script:InvokedCommandsResult | ForEach-Object { if ($_.Command -eq $action.Name) { $returnValue = $_.ReturnValue } } } else { throw "Action $Name not found in Get-ActionReturnValue" } return $returnValue } <# .SYNOPSIS Returns Action runtime state .DESCRIPTION Returns Action runtime state. Each Action has some runtime state like return-value or exception .PARAMETER Name Name of the Action #> Set-Alias -Name Get-ActionOutput -Value Get-ActionReturnValue -Scope Global -Force -WhatIf:$false Set-Alias -Name Get-ActionOutputValue -Value Get-ActionReturnValue -Scope Global -Force -WhatIf:$false function Get-ActionState { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")] param($Name) $ctx = Get-RootContext if ($ctx.Actions.Count -eq 0) { throw "No actions defined or workflow finished in Get-ActionState" } $returnValue = $null $action = $ctx.Actions["Action-$($Name.Replace('Action-',''))"] if ($action) { $script:InvokedCommandsResult | ForEach-Object { if ($_.Command -eq $action.Name) { $returnValue = @{ Name = $_.Name Duration = $_.Duration Exception = $_.Exception HasError = $_.HasError ReturnValue = $_.ReturnValue Command = $_.Command; Comment = $_.Comment Invoked = $true } } } if ($null -eq $returnValue) { $returnValue = @{ Name = $action.Name Duration = 0 Exception = $null HasError = $false ReturnValue = $null Command = $action.Command; Comment = $_.Comment Invoked = $false } } } else { Throw "Action $Name not found in Get-ActionState" } return $returnValue } <# .SYNOPSIS Imports one or more Actions from File with parent defined parameters .DESCRIPTION Imports one or more Actions from File with parent defined parameters. Use this in your startup scripts. .PARAMETER File Name of the File .EXAMPLE Script File with One Action (myAction.ps1): param( [ValidateNotNullOrEmpty()] $MyParam1 ) Action -Name MyAction { Write-Info "Script param value: $MyParam1" Write-Info 'MyAction' } Startup Script (startup.ps1): param( $MyParam1 = 'Hello' ) Set-Location $PSScriptRoot Import-Module Scriptbook Import-Action ./myAction.ps1 #-Context $ExecutionContext Start-Workflow .EXAMPLE Alternative is in startup script: $parameters = Get-BoundParametersWithDefaultValue . ./myAction.ps1 @parameters #> Set-Alias -Name Import-Test -Value Import-Action -Scope Global -Force -WhatIf:$false Set-Alias -Name Import-Step -Value Import-Action -Scope Global -Force -WhatIf:$false #Set-Alias -Name Import-Activity -Value Import-Action -Scope Global -Force -WhatIf:$false #Set-Alias -Name Import-Job -Value Import-Action -Scope Global -Force -WhatIf:$false #Set-Alias -Name Import-Stage -Value Import-Action -Scope Global -Force -WhatIf:$false #Set-Alias -Name Import-Flow -Value Import-Action -Scope Global -Force -WhatIf:$false function Import-Action { [CmdletBinding()] param ( [ValidateNotNullOrEmpty()] $File, #$Context, $Invocation = $Global:MyInvocation ) $parameters = Get-BoundParametersWithDefaultValue $Invocation $localVars = @{} Get-Variable -Scope Local | ForEach-Object { [void]$localVars.Add($_.Name, $null) } Get-ChildItem $File | Sort-Object -Property FullName | ForEach-Object { $parameterList = (Get-Command -Name $_.FullName).Parameters; $parameterSelector = $parameters.Clone() foreach ($pm in $parameterSelector.Keys) { if (!$parameterList.ContainsKey($pm)) {$parameters.Remove($pm)} } . $($_.FullName) @parameters } $newLocalVars = Get-Variable -Scope Local foreach ($var in $newLocalVars.GetEnumerator()) { if (!$localVars.ContainsKey($var.Name)) { Set-Variable -Scope Global -Name $var.Name -Value $var.Value -Force -Visibility Public } } } <# Scoping Experiments. Unable to run import in Caller Scope, local vars missing --> copy local vars for now # in module scope $m = Get-Module Scriptbook & $m { param($File, $Parameters); . ./$File @Parameters } -File $File -Parameters $parameters return # in current scope . ./$File @parameters # in current scope Invoke-Command { param($File, $Parameters); . ./$File @parameters } -ArgumentList $File, $parameters #-NoNewScope # in caller context scope $module = [PSModuleInfo]::New($true) $module.SessionState = $Context.SessionState & { param($File, $Parameters); . ./$File @Parameters } -File $File -Parameters $parameters # in caller context scope . $module ./$File @parameters # in context scope $sb = { . ./configure.actions.ps1 -OrganizationPrefix 'td' -Environments @('dev', 'tst') -SubscriptionId '45f8a4be-d177-489e-8ec2-e1a53d87aadc' } $Context.InvokeCommand.InvokeScript($Context.SessionState, $sb, @()) #> <# .SYNOPSIS Invokes an Action by name. If action is not found error is generated .DESCRIPTION Invokes an Action by name. If action is not found error is generated. Allows to call an Action in another Action scriptblock. .PARAMETER Name Name of Action .EXAMPLE Invoke-Action -Name Hello #> Set-Alias -Name Invoke-Step -Value Invoke-Action -Scope Global -Force -WhatIf:$false function Invoke-Action([Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$Name) { $cnt = $script:InvokedCommands.Count Invoke-PerformIfDefined -Command "Action-$($Name.Replace('Action-',''))" -ThrowError $true -Manual if ($script:InvokedCommands.Count -eq ($cnt + 1) ) { return $script:InvokedCommands[$cnt] } else { return $null } } <# .SYNOPSIS Executes a ScriptBlock with commands .DESCRIPTION Executes a ScriptBlock with commands. If ScriptBlock contains native commands LastExitCode is checked. .PARAMETER ScriptBlock The scriptblock with commands to execute .PARAMETER Message The message to display when command fails. Use it to hide secrets or long cmd lines. .PARAMETER Location Current Location or working directory of command .PARAMETER IgnoreExitCode Ignores the LastExitCode check .PARAMETER AsJson Return result as Json Object if possible .EXAMPLE Execute { cmd.exe /c } .EXAMPLE Invoke-ScriptBlock { cmd.exe /c } -Message 'cmd' -Location c:\ -IgnoreExitCode #> Set-Alias -Name Execute -Value Invoke-ScriptBlock -Scope Global -Force -WhatIf:$false Set-Alias -Name Exec -Value Invoke-ScriptBlock -Scope Global -Force -WhatIf:$false function Invoke-ScriptBlock { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory = $true, Position = 0)][Scriptblock] $ScriptBlock, [alias('m')] [string][ValidateNotNullOrEmpty()] $Message, [alias('wd', 'WorkingDirectory')][ValidateNotNullOrEmpty()] $Location, [switch]$IgnoreExitCode, [switch]$AsJson #,[string]$Image # run in container image, TODO !!EH Mem, Cpu, disk, returns out-files/volume ) function ConvertFrom-JsonInternal { [CmdletBinding()] param( [Parameter(ValueFromPipeline)] [string] $line ) begin { $lines = [System.Collections.ArrayList]@() } process { [void]$lines.Add($line) } end { # only valid json passes, otherwise return original stream as strings try { return $lines | ConvertFrom-Json } catch { return $lines | ConvertTo-String } } } # prevent accidental scope name collision $internalMessage = $Message Remove-Variable -Name Message -Scope Local $internalLocation = $Location Remove-Variable -Name Location -Scope Local $internalScriptBlock = $ScriptBlock Remove-Variable -Name ScriptBlock -Scope Local Write-Verbose "Start Executing $internalMessage" if (-not $PSCmdlet.ShouldProcess($internalLocation)) { return } if ($internalLocation) { Push-Location $internalLocation } try { $Global:LastExitCode = 0 # https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_scopes?view=powershell-7.1 #. $internalScriptBlock # current scope == module scope for functions in modules (scripts are in user scope) # current scope == child scope if ($AsJson.IsPresent) { Invoke-Command -ScriptBlock $internalScriptBlock | ConvertFrom-JsonInternal } else { Invoke-Command -ScriptBlock $internalScriptBlock } [Console]::ResetColor() # some programs mess this up if ( ($Global:LastExitCode -ne 0) -and !$IgnoreExitCode.IsPresent ) { # to prevent secret leaks use Message parameter in commands to hide secrets/passwords $msg = $internalMessage if (!$msg) { $msg = $internalScriptBlock.ToString() } Throw "Executing $msg failed with exit-code $($Global:LastExitCode)" } } finally { if ($internalLocation) { Pop-Location } Write-Verbose "Finish Executing $internalMessage" } } Set-Alias -Name Out-Info -Value Out-ScriptbookHost -Scope Global -Force -WhatIf:$false function Out-ScriptbookHost { [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [object] $InputObject ) $InputObject | Out-Default } <# .SYNOPSIS Global function hooks which can be used at runtime to get more detailed information about the running workflow and actions .DESCRIPTION Global function hooks which can be used at runtime to get more detailed information about the running workflow and actions #> function Global:Invoke-BeforeWorkflow($Commands) { return $true } function Global:Invoke-AfterWorkflow($Commands, $ErrorRecord) { } function Global:Invoke-BeforePerform($Command) { return $true } function Global:Invoke-AfterPerform($Command, $ErrorRecord) { } function Global:Write-OnLog($Msg) {} function Global:Write-OnLogException($Exception) {} <# .SYNOPSIS DependsOn attribute to register function dependencies .DESCRIPTION DependsOn attribute to register function dependencies. Allows for using functions like Actions with dependency graph support. .EXAMPLE # implicit invokes function Invoke-Hello because function Invoke-Goodby is dependent on it function Invoke-Hello { Write-Info "Hello" } function Invoke-GoodBy { [DependsOn(("Hello"))]param() Write-Info "GoodBy" } Start-Workflow Goodby #> class DependsOn : System.Attribute { [string[]]$Name DependsOn([string[]]$name) { $this.Name = $name } } <# .SYNOPSIS Registers and validates a new Action for Workflow. .DESCRIPTION Registers and validates a new Action for Workflow. Action is recorded but not executed until the workflow starts. .PARAMETER Name .PARAMETER IsGroup .PARAMETER Tag .PARAMETER Depends .PARAMETER Parameters .PARAMETER AsJob .PARAMETER If .PARAMETER Description .PARAMETER Disabled .PARAMETER TypeName .PARAMETER NextAction .PARAMETER For .PARAMETER Parallel .PARAMETER Container .PARAMETER ContainerOptions .PARAMETER Session .PARAMETER Isolated .PARAMETER Unique .PARAMETER RequiredVariables .PARAMETER Comment .PARAMETER SuppressOutput .PARAMETER Always .PARAMETER Code .EXAMPLE Register-Action #> function Register-Action { [OutputType([System.Void])] param( [Parameter(Mandatory = $true, Position = 0)][string][ValidateNotNullOrEmpty()]$Name, [switch]$IsGroup, [string[]] $Tag = @(), [string[]] $Depends = @(), [AllowNull()] $Parameters = @{}, [AllowNull()] [switch]$AsJob, [ScriptBlock] $If, [String]$Description, [bool]$Disabled = $false, [ValidateNotNullOrEmpty()] [string]$TypeName, [string]$NextAction, [ScriptBlock] $For, [switch]$Parallel, [switch]$Container, [HashTable]$ContainerOptions = @{}, [AllowNull()] $Session, [switch]$Isolated, [switch]$Unique, [string[]]$RequiredVariables, [string]$Comment, [switch]$SuppressOutput, [switch]$Always, [switch]$NoSequence, [switch]$WhatIf, [switch]$Confirm, [ScriptBlock] $Code ) $ctx = Get-RootContext $lName = $Name -replace 'Invoke-', '' if ($Unique.IsPresent) { $ctx.UniqueIdCounter += 1 $lName = "$lName-$("{0:00}" -f $ctx.UniqueIdCounter)" } # Get Comments $text = $null if ($Comment) { $text = $Comment + [System.Environment]::NewLine } $text += Get-CommentFromCode -ScriptBlock $Code $lAction = New-Object PSObject -Property @{ Code = $Code Name = "Action-$lName" DisplayName = $lName Id = (New-Guid) Tags = $Tag ErrorAction = $ErrorActionPreference Depends = $Depends Parameters = $Parameters AsJob = [bool]$AsJob.IsPresent If = $If Description = $Description IsGroup = $IsGroup.IsPresent Disabled = $Disabled TypeName = $TypeName NextAction = $NextAction For = $For Parallel = $Parallel.IsPresent Container = $Container.IsPresent ContainerOptions = $ContainerOptions Session = $Session Isolated = $Isolated RequiredVariables = $RequiredVariables Comment = ($text | Out-String) SuppressOutput = $SuppressOutput Always = $Always NoSequence = $NoSequence WhatIf = $WhatIf Confirm = $Confirm } if ($ctx.Actions.ContainsKey($lAction.Name)) { $displayName = $lAction.Name.Replace('Action-', '').Replace('Invoke-', '') Throw "Duplicate Name '$displayName' found, use unique name for each action/step/job/flow" } # add if ($ctx.InAction) { [void]$ctx.NestedActions.Add($lAction) } else { [void]$ctx.ActionSequence.Add($lAction) } [void]$ctx.Actions.Add($lAction.Name, $lAction) Set-Alias -Name $Name -Value Action -Scope Global -Force -ErrorAction Ignore -WhatIf:$false -Confirm:$False } <# .SYNOPSIS Resets the workflow global state/variables .DESCRIPTION Resets the workflow global state/variables and prepares for next workflow start in current session, enables support of multiple workflows in one session. Also used for unit testing purposes. .EXAMPLE Reset-Workflow #> function Reset-Workflow { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [switch]$Soft ) if ($PSCmdlet.ShouldProcess("Reset-Workflow")) { $Script:RootContext = New-RootContext -WhatIf:$false -Soft:$Soft.IsPresent # compatibility with PSake $Script:PSakeProperties = $null $Script:PSakeInvocationParameters = $null $Script:PSakeInvocationProperties = $null $Script:PSakeSetupTask = $null $Script:PSakeTearDownTask = $null } } function Global:Start-Scriptbook { param( $File, $Actions, $Parameters, [switch]$Container, [HashTable]$ContainerOptions = @{} ) if ($Container.IsPresent -or ($ContainerOptions.Count -gt 0) -and !$env:InScriptbookContainer) { Start-ScriptInContainer -File $Script:MyInvocation.ScriptName -Options $ContainerOptions -Parameters $Parameters return } else { . $File -Actions $Actions -Parameters $Parameters } } <# .SYNOPSIS Starts a Workflow defined by Workflow Actions or PowerShell Functions .DESCRIPTION Starts a Workflow defined by Workflow Actions or PowerShell Functions. Each action scriptblock is executed once in the order given by the workflow. Use the workflow report to see the final execution order/stack. The workflow is executed by the order of actions found in the script Workflow file or by the actions parameter. When actions have dependencies they are resolved at run-time and executed according the dependency graph. To influence the execution of actions use the action 'If' property .PARAMETER Actions Contains the Action(s) to execute in sequential order. Overrides the order found in the script file and limits the actions to execute. Depended actions are always executed except when switch NoDepends is used. Use * to select all Actions in script. Use wildcard '*" to select actions by name with wildcard. .PARAMETER Parameters Parameters to pass to Workflow .PARAMETER Name Set the name of the workflow .PARAMETER Tag Set the workflow Tag(s) .PARAMETER Location Set current directory to location specified .PARAMETER File Starts the workflow actions from Workflow file specified .PARAMETER NoReport Disables the action report at the end of the workflow .PARAMETER NoLogging Disables the start/finish action logging .PARAMETER NoDepends Disables the calling of dependent actions. Allows for executing one specific Action .PARAMETER Test Starts workflow in 'TestWorkflow' Mode. No actions are executed except Test Actions .PARAMETER Transcript Creates a record of PowerShell Workflow session and saves this to a file. .PARAMETER Container Determines if workflow is run in container .PARAMETER ContainerOptions Determines the container options when running in a container. ContainerOptions.Image contains the container image used. .PARAMETER Parallel Determines if the workflow actions are executed in parallel. !Experimental .PARAMETER WhatIf / Plan Shows the Workflow execution plan .PARAMETER Documentation Shows the Workflow documentation and execution plan .EXAMPLE Start-WorkFlow .REMARKS - Workflow File not working yet --> Include ? .NOTES Workflow Workflow is modeled as a set of actions invoked in some sequence where the completion of one action flows directly into the start of the next action #> Set-Alias -Name Start-Flow -Value Start-Workflow -Scope Global -Force -WhatIf:$false Set-Alias -Name Start-Saga -Value Start-Workflow -Scope Global -Force -WhatIf:$false Set-Alias -Name Start-Pipeline -Value Start-Workflow -Scope Global -Force -WhatIf:$false function Start-Workflow { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Position = 0)] [AllowNull()] [array][alias('Actions', 'Steps', 'Jobs', 'Activities')]$WorkflowActions, [AllowNull()] [alias('Parameters')] $WorkflowParameters, [ValidateNotNullOrEmpty()] [alias('Name')] [string]$WorkflowName = '.', [String[]] $Tag = @(), [alias('Location')] [string]$WorkflowLocation, [alias('File')] [string]$WorkflowFile, [switch]$NoReport, [switch]$NoLogging, [switch]$NoDepends, [alias('Test')] [switch]$TestWorkflow, [alias('Transcript')] [switch]$WorkflowTranscript, [alias('Container')] [switch]$WorkflowContainer, [alias('ContainerOptions')] [HashTable]$WorkflowContainerOptions = @{}, [alias('Parallel')] [switch]$WorkflowParallel, [switch]$Plan, [switch]$Documentation ) if ($WorkflowContainer.IsPresent -and !$env:InScriptbookContainer) { # TODO !!EH supply all the parameters of the script caller? Start-ScriptInContainer -File $Script:MyInvocation.ScriptName -Options $WorkflowContainerOptions # -Parameters ([Hashtable]$Global:MyInvocation.MyCommand.WorkflowParameters) return; } $Global:LastExitCode = 0 $script:InvokedCommands = @() $script:InvokedCommandsResult = @() $workflowErrorAction = $ErrorActionPreference if ($WorkflowLocation) { $Script:WorkflowLocation = $WorkflowLocation } else { $Script:WorkflowLocation = Get-Location } $ctx = Get-RootContext $ctx.NoLogging = $NoLogging.IsPresent $isWhatIf = -not $PSCmdlet.ShouldProcess($WorkflowName, "Workflow") if ($Plan.IsPresent -or $Documentation.IsPresent) { $WhatIfPreference = $true $isWhatIf = $WhatIfPreference } $scriptName = $Script:MyInvocation.ScriptName if ([string]::IsNullOrEmpty($scriptName)) { $scriptName = Join-Path $PSScriptRoot 'Scriptbook.ps1' } $hasErrors = $false $workflowStopwatch = [System.Diagnostics.Stopwatch]::StartNew(); Write-ScriptLog @{action = "Workflow-Started"; param = $WorkflowActions; } -AsWorkflow $currentLocation = Get-Location try { if ($WorkflowTranscript.IsPresent) { Start-Transcript -Path "$scriptName.log" -Append -Force -IncludeInvocationHeader } try { if ($WorkflowFile -and (Test-Path $WorkflowFile) ) { . $WorkflowFile } try { if (Global:Invoke-BeforeWorkflow -Commands $WorkflowActions) { try { if ($null -ne $WorkflowActions -and ($WorkflowActions.count -gt 0) -and ($WorkflowActions[0] -ne '*')) { $expandedActions = Expand-WorkflowActions $WorkflowActions foreach ($action in $expandedActions) { if (!($action.StartsWith('!'))) { Invoke-PerformIfDefined -Command "Invoke-$($action.Replace('Invoke-', ''))" -ThrowError $true -ActionParameters $WorkflowParameters -NoDepends:$NoDepends.IsPresent -Test:$TestWorkflow.IsPresent -WhatIf:$isWhatIf } } } else { Invoke-ActionSequence -Actions $ctx.ActionSequence -ThrowError $true -Test:$TestWorkflow.IsPresent -WhatIf:$isWhatIf -Parallel:$WorkflowParallel.IsPresent } } finally { Invoke-ActionAlways -Actions $ctx.ActionSequence -ThrowError $true -Test:$TestWorkflow.IsPresent -WhatIf:$isWhatIf -Parallel:$WorkflowParallel.IsPresent } } } finally { Global:Invoke-AfterWorkflow -Commands $WorkflowActions } } catch { if ($workflowErrorAction -eq 'Continue') { Write-ExceptionMessage $_ -TraceLineCnt 15 } elseif ($workflowErrorAction -notin 'Ignore', 'SilentlyContinue') { $hasErrors = $true Write-ExceptionMessage $_ -TraceLineCnt 15 Global:Write-OnLogException -Exception $_.Exception Global:Invoke-AfterWorkflow -Commands $WorkflowActions -ErrorRecord $_ | Out-Null Throw } } if ($Global:LastExitCode -ne 0) { Write-Warning "Workflow: Unsuspected LastExitCode found from native commands/executable: $($Global:LastExitCode), check logging." $Global:LastExitCode = 0; } } finally { Set-Location $currentLocation $workflowStopwatch.Stop() if (!$NoReport.IsPresent) { #TODO !!EH: Fix issue ansi escape sequences and Format-Table (invalid sizing) $script:InvokedCommandsResult | ForEach-Object { $hasErrors = if ($null -ne $_.Exception) { $true } else { $hasErrors }; <#if ($_.Exception) { $_.Name = "`e[37;41m$($_.Name)`e[0m" } else { $_.Name = "`e[00;00m$($_.Name)`e[0m" }; #> } if ($workflowErrorAction -in 'Ignore', 'SilentlyContinue') { $hasErrors = $false } if ($TestWorkflow.IsPresent) { $reportTitle = 'Workflow (Test) Report' } elseif ($Documentation.IsPresent) { $reportTitle = "Workflow Documentation" } elseif ($isWhatIf) { $reportTitle = "Workflow ($(if ($Plan.IsPresent) { 'Plan' } else { 'WhatIf' })) Report" } else { $reportTitle = 'Workflow Report' } Write-ScriptLog @{action = "Workflow-Finished"; } -AsWorkflow -AsError:$hasErrors if ($Documentation.IsPresent) { Write-Experimental "Workflow Documentation" } Write-Info ''.PadRight(78, '-') if ($hasErrors) { Write-Info "$reportTitle '$WorkflowName' with errors $((Get-Date).ToString('s'))" -ForegroundColor White -BackgroundColor Red } else { Write-Info "$reportTitle '$WorkflowName' $((Get-Date).ToString('s'))" } if ($Tag) { Write-Info $Tag } Write-Info ''.PadRight(78, '-') $script:InvokedCommandsResult | ForEach-Object { if ($_.Exception) { $_.Exception = $_.Exception.Message } } $script:InvokedCommandsResult | ForEach-Object { $_.Name = ''.PadLeft(($_.Indent) + 1, '-') + $_.Name } $script:InvokedCommandsResult | ForEach-Object { if ($_.Skipped -or $_.WhatIf) { $_.Duration = 'Skipped' } } if ($Documentation.IsPresent) { if (Test-Path $scriptName) { $text = Get-CommentFromCode -File $scriptName -First 1 Write-Info ($text | Out-String) Write-Info ''.PadRight(78, '-') } $ctx = Get-RootContext if ($ctx.Infos.Count -gt 0) { foreach ($info in $ctx.Infos) { Write-Info ($info | Out-String) } Write-Info ''.PadRight(78, '-') } $script:InvokedCommandsResult | ForEach-Object { $item = [PSCustomObject]$_ Write-Info "Action $(Get-AnsiColoredString -String $item.Name -Color 36)" -ForegroundColor Magenta Write-Info ''.PadRight(78, '-') if (![string]::IsNullOrEmpty($item.Comment)) { Write-Info "$($item.Comment)" } else { Write-Info '<no documentation>' } Write-Info ''.PadRight(78, '-') } $script:InvokedCommandsResult += @{ Name = ''; Duration = '================'; } Write-Info '' Write-Info "Workflow Sequence" -ForegroundColor Magenta $script:InvokedCommandsResult += @{ Name = 'Total'; Duration = $workflowStopwatch.Elapsed; } $script:InvokedCommandsResult | ForEach-Object { [PSCustomObject]$_ } | Format-Table -AutoSize -Property Name, Duration | Out-String | Write-Info } else { $script:InvokedCommandsResult += @{ Name = ''; Duration = '================'; } $script:InvokedCommandsResult += @{ Name = 'Total'; Duration = $workflowStopwatch.Elapsed; } $script:InvokedCommandsResult | ForEach-Object { [PSCustomObject]$_ } | Format-Table -AutoSize Name, Duration, Exception, @{Label = 'Output' ; Expression = { $_.ReturnValue } } | Out-String | Write-Info } Write-Info ''.PadRight(78, '-') } $ctx = Get-RootContext if ($ctx.Notifications.Count -gt 0) { foreach ($notification in $ctx.Notifications) { Write-Info ($notification | Out-String) } } if ($WorkflowTranscript.IsPresent) { Stop-Transcript } if ($Script:RootContext) { $Script:PreviousRunContext = $Script:RootContext.PSObject.Copy() } Reset-Workflow -WhatIf:$false } } <# .SYNOPSIS Use workflow inline .DESCRIPTION Use workflow inline and aliases Flow and Pipeline .REMARK See parameters of Start-Workflow .EXAMPLE Use-Workflow -Name Workflow1 { Action Hello { Write-Info "Hello from Workflow1 1" } Action GoodBy { Write-Info "GoodBy from Workflow1 1" } } #> Set-Alias -Name Flow -Value Use-Workflow -Scope Global -Force -WhatIf:$false Set-Alias -Name Pipeline -Value Use-Workflow -Scope Global -Force -WhatIf:$false function Use-Workflow { [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNullOrEmpty()] [alias('Name')] [string]$WorkflowName, [array][alias('a', 'Action', 'Actions')]$WorkflowActions, [alias('Parameters')] $WorkflowParameters, [String[]] $Tag = @(), [alias('Location')] [string]$WorkflowLocation, [alias('File')] [string]$WorkflowFile, [switch]$NoReport, [switch]$NoLogging, [switch]$NoDepends, [alias('Test')] [switch]$TestWorkflow, [Parameter(Position = 1)] [alias('Code')] [ScriptBlock] $WorkflowCode ) $invokeErrorAction = $ErrorActionPreference if ($null -eq $WorkflowCode) { Throw "No workflow code script block is provided and Name property is mandatory. (Have you put the open curly brace on the next line?)" } try { & $WorkflowCode $WorkflowParameters } catch { if ($invokeErrorAction -eq 'Continue') { Write-ScriptLog $_.Exception.Message -AsError } elseif ($invokeErrorAction -notin 'Ignore', 'SilentlyContinue') { Throw } } # force clear context on start each workflow Start-Workflow -Actions $WorkflowActions -Parameters $WorkflowParameters -Name $WorkflowName -Tag $Tag -Location $WorkflowLocation -WorkflowFile $WorkflowFile -NoReport:$NoReport.IsPresent -NoLogging:$NoLogging.IsPresent -NoDepends:$NoDepends.IsPresent -Test:$TestWorkflow.IsPresent Reset-Workflow -WhatIf:$false } <# .SYNOPSIS Decrypts secret with Seed value .DESCRIPTION Decrypts secret with Seed value. Seed complexity is - At least one upper case letter [A-Z] - At least one lower case letter [a-z] - At least one number [0-9] - At least one special character (!,@,%,^,&,$,_) - Password length must be 7 to 25 characters. .PARAMETER Value Value to decrypt .PARAMETER Seed Seed value used to decrypt value #> function Get-DecryptedSecret { [CmdletBinding()] param( [ValidateNotNullOrEmpty()] [alias('v')] $Value, [ValidateNotNullOrEmpty()] [ValidateLength(8, 1024)] [ValidatePattern('^((?=.*[a-z])(?=.*[A-Z])(?=.*\d)|(?=.*[a-z])(?=.*[A-Z])(?=.*[^A-Za-z0-9])|(?=.*[a-z])(?=.*\d)(?=.*[^A-Za-z0-9])|(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]))([A-Za-z\d@#$%^&£*\-_+=[\]{}|\\:();!]|\.(?!@)){8,16}$')] [alias('s', 'k', 'Key')] $Seed ) $hash = (New-Object System.Security.Cryptography.SHA256CryptoServiceProvider).ComputeHash(([system.Text.Encoding]::Unicode).GetBytes($Seed)); $iv = New-Object byte[] 16; $key = New-Object byte[] 16; [System.Buffer]::BlockCopy($hash, 0, $key, 0, $key.Length) $decryptor = ([System.Security.Cryptography.AesCryptoServiceProvider]::Create()).CreateDecryptor($key, $iv) $buffer = [System.Convert]::FromBase64String($Value); $decryptedBlob = $deCryptor.TransformFinalBlock($buffer, 0, $buffer.Length); return [System.Text.Encoding]::Unicode.GetString($decryptedBlob) } <# .SYNOPSIS Encrypts secret with Seed value .DESCRIPTION Encrypts secret with Seed value. Seed complexity is - At least one upper case letter [A-Z] - At least one lower case letter [a-z] - At least one number [0-9] - At least one special character (!,@,%,^,&,$,_) - Password length must be 7 to 25 characters. .PARAMETER Value Value to encrypts .PARAMETER Seed Seed value used to encrypts value #> function Get-EncryptedSecret { [CmdletBinding()] param( [ValidateNotNullOrEmpty()] [alias('v')] $Value, [ValidateNotNullOrEmpty()] [ValidateLength(8, 1024)] [ValidatePattern('^((?=.*[a-z])(?=.*[A-Z])(?=.*\d)|(?=.*[a-z])(?=.*[A-Z])(?=.*[^A-Za-z0-9])|(?=.*[a-z])(?=.*\d)(?=.*[^A-Za-z0-9])|(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]))([A-Za-z\d@#$%^&£*\-_+=[\]{}|\\:();!]|\.(?!@)){8,16}$')] [alias('s', 'k', 'Key')] $Seed ) $hash = (New-Object System.Security.Cryptography.SHA256CryptoServiceProvider).ComputeHash(([system.Text.Encoding]::Unicode).GetBytes($Seed)); $iv = New-Object byte[] 16; $key = New-Object byte[] 16; [System.Buffer]::BlockCopy($hash, 0, $key, 0, $key.Length) $encryptor = ([System.Security.Cryptography.AesCryptoServiceProvider]::Create()).CreateEncryptor($key, $iv) $buffer = [System.Text.Encoding]::Unicode.GetBytes($Value); $encryptedBlob = $encryptor.TransformFinalBlock($buffer, 0, $buffer.Length); return [System.Convert]::ToBase64String($encryptedBlob) } <# .SYNOPSIS Get Credential from Local Credential cache in User profile on Windows. .DESCRIPTION Get Credential from Local Credential cache in User profile on Windows if found. Otherwise in interactive sessions Get-Credential is used to query for Credentials and store them in local cache encrypted. .PARAMETER Name Name of credential for reference only #> function Get-LocalCredential([Parameter(Mandatory = $true)][string]$Name) { #TODO !!EH Windows Only or by design? $credPath = Join-Path $home "Cred_$Name.xml" if ( Test-Path $credPath ) { $cred = Import-Clixml -Path $credPath } else { # not fail safe but better than nothing if ( ((Get-Host).Name -eq 'ConsoleHost') -and ([bool]([Environment]::GetCommandLineArgs() -like '-noni*')) ) { Throw "Get-LocalCredential not working when running script in -NonInteractive Mode, unable to prompt for Credentials" } $parent = Split-Path $credPath -Parent if ( -not ( Test-Path $parent ) ) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } $cred = Get-Credential -Title "Provide '$Name' username/password" $cred | Export-Clixml -Path $credPath } return $cred } <# .SYNOPSIS Asserts/Checks the supplied boolean condition .DESCRIPTION Asserts/Checks the supplied boolean condition. Throws an exception with message details if fails .PARAMETER Condition Boolean value of condition to check .PARAMETER Value Actual value to check .PARAMETER Operator Check comparison operator like: -eq, -ne, -gt .PARAMETER Expected Expected value for check .PARAMETER Message The message to display when assert fails .EXAMPLE Assert-Condition -Condition $false 'Error checking this condition' .EXAMPLE Assert -c (Test-Path $myFile) 'File not found' .EXAMPLE $cnt = 5 Assert-Condition ($cnt -eq 5) 'Error checking this condition' .EXAMPLE $cnt = 5 Assert-Condition ($cnt -eq 5) 'Error checking this condition' .EXAMPLE $cnt = 5 Assert-Condition -Value $cnt -Expected 5 'Error checking this condition' .EXAMPLE $cnt = 4 Assert-Condition -Value $cnt -Operator '-ne' -Expected 5 'Error checking this condition' #> Set-Alias -Name Assert -Value Assert-Condition -Scope Global -Force -WhatIf:$false function Assert-Condition { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingInvokeExpression", "")] [CmdletBinding(DefaultParameterSetName = 'Condition', SupportsShouldProcess)] param( [bool][Alias('c')][Parameter(Position = 0, Mandatory = $true, ParameterSetName = 'Condition')]$Condition, [Alias('v', 'Actual', 'Real')][Parameter(Mandatory = $true, ParameterSetName = 'Comparison')]$Value, [Alias('o')][Parameter(ParameterSetName = 'Comparison')]$Operator = '-eq', [Alias('e', 'v2', 'Value2')][Parameter(Mandatory = $true, ParameterSetName = 'Comparison')]$Expected = '-eq', [Alias('m')] [Parameter(Mandatory = $true, Position = 1, ParameterSetName = 'Condition')] [Parameter(Mandatory = $true, ParameterSetName = 'Comparison')] [string]$Message ) if ($PSCmdlet.ParameterSetName -eq 'Condition') { $expMsg = "Check: $Condition" } else { $expMsg = "Check: ($Value $Operator $Expected)" } if ($PSCmdlet.ShouldProcess("Assert-Condition", $expMsg)) { if ($PSCmdlet.ParameterSetName -eq 'Condition') { if (-not $Condition) { Write-Verbose "Assert-Condition: $Message" Throw "Assert-Condition: $Message" } } else { if ($Value -is [string]) { $check = Invoke-Expression "'$Value' $Operator '$Expected'" } else { $check = Invoke-Expression "$Value $Operator $Expected" } if (-not $check) { Write-Verbose "Assert-Condition expected '$Expected' actual '$Value' with operation '$Operator' $Message" Throw "Assert-Condition: Expected '$Expected' Actual '$Value' with Operation '$Operator' $Message" } } } } <# .SYNOPSIS Checks the minimum required version of command .DESCRIPTION Checks the minimum required version of command. Command includes Powershell commands and native commands. Select -Minimum to allow higher major versions. Default higher minimum versions are allowed. For example Version is 4.1 then 4.1-4.9 are valid versions. remarks. Version/Minimum check only works on Windows. .PARAMETER Command The command .PARAMETER Version Version of command required. Higher minor versions are allowed .PARAMETER Minimum Minimum version of command required. Higher Major version are allowed .EXAMPLE Assert-Version -Command cmd -Version 1.0 #> function Assert-Version([Parameter(Mandatory = $true)][string]$Command, [Parameter(Mandatory = $true)][string]$Version, [switch]$Minimum) { if (!$IsWindows) { $cmdNative = $Command.Replace('.exe', '') } else { $cmdNative = $Command } $cm = Get-Command $cmdNative -ErrorAction Ignore if ($cm) { if ($IsWindows) { $v = [Version]$Version if ($Minimum.IsPresent) { if ($cm.Version.Major -ge $v.Major) { return } } else { if ($cm.Version.Major -eq $v.Major) { if ($cm.Version.minor -ge $v.Minor) { # okay return } } } Throw "Invalid version of $Command found '$($cm.Version)' expected $v" } else { Write-Verbose "Assert-Version: No version/Minimum check on platforms other than Windows" } } else { Throw "$Command not installed" } } <# .SYNOPSIS Get Invocation Bound parameters with default values. .DESCRIPTION Get Invocation Bound parameters with default values. $PSBoundParameters does not contain default values .PARAMETER Invocation Contains the $MyInVocation of the script / function .EXAMPLE Script with : [CmdletBinding(SupportsShouldProcess = $True)] Param( $Param1, $Param2, ) $parameters = Get-BoundParametersWithDefaultValue $MyInvocation Now call script with @parameters . ./myScript @parameters or function with @parameters myFunction @parameters #> function Get-BoundParametersWithDefaultValue { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")] [OutputType([HashTable])] param( $Invocation = $Global:MyInvocation ) $parameters = @{} foreach ($parameter in $Invocation.MyCommand.Parameters.GetEnumerator()) { try { $key = $parameter.Key $val = Get-Variable -Name $key -ErrorAction Stop | Select-Object -ExpandProperty Value -ErrorAction Stop [void]$parameters.Add($key, $val) } catch {} } return $parameters } <# .SYNOPSIS Gets an environment variable .DESCRIPTION Gets an environment variable, supports empty environment variable and case sensitivity .PARAMETER Name Name of the environment variable .PARAMETER Default Default value of the environment variable when not found .PARAMETER IgnoreCasing Ignores casing by checking ToLower and ToUpper variants #> function Get-EnvironmentVar { [OutputType([string])] param([alias('n')][string]$Name, [alias('d')][string]$Default = $null, [switch]$IgnoreCasing) $r = [Environment]::GetEnvironmentVariable($Name); if ($null -eq $r -and $IgnoreCasing.IsPresent) { $r = [Environment]::GetEnvironmentVariable($Name.ToLower()); if ($null -eq $r) { $r = [Environment]::GetEnvironmentVariable($Name.ToUpper()); } } if ($r -eq [char]0x2422) { $r = '' } if (($r -eq '') -or ($null -eq $r)) { $r = $Default } if ($r -eq '') { return $null } else { return $r } } <# .SYNOPSIS Gets property value from Object .DESCRIPTION Gets property value from Object, first checks if property exists, if not returns default value. In Set-StrictMode -Latest every property used is checked for existence --> runtime exception .PARAMETER Object Object to get property value from .PARAMETER Name Name of property .PARAMETER Default Default value if property does not exists #> function Get-PSPropertyValue { param([alias('o')][object]$Object, [alias('p')][string]$Name, [alias('d')]$Default = '') if (Test-PSProperty -o $Object -p $Name -Exact) { return $Object."$Name" } else { return $Default } } <# .SYNOPSIS Sets an environment variable .DESCRIPTION Sets an environment variable, supports empty environment variable .PARAMETER Name Name of the environment variable .PARAMETER Value Value of the environment variable .PARAMETER ToUpper Forces environment variable name to UpperCase #> function Set-EnvironmentVar { [CmdletBinding(SupportsShouldProcess = $True)] [OutputType([System.Void])] param( [alias('n')][string] $Name, [alias('v')][string] $Value, [alias('g')][switch]$Global, [switch] $ToUpper ) if ($Name) { $n = if ($ToUpper.IsPresent) { $Name.ToUpper() } else { $Name } if (!$Value) { $v = [char]0x2422 } else { $v = $Value } $g = if ($Global.IsPresent) { 'Machine' } else { 'Process' } if ($PSCmdlet.ShouldProcess('Set-EnvironmentVar')) { [Environment]::SetEnvironmentVariable($n, $v, $g ) } } } <# .SYNOPSIS Starts a shell command and waits for it to finish .DESCRIPTION Starts a shell command with arguments in working directory and waits for it to finish. Optionally supply credential under which execution will take place. .PARAMETER Command Name of executable to start .PARAMETER Arguments Arguments to pass to executable .PARAMETER Credential Credential to start process with .PARAMETER WorkingDirectory Working directory of executable .PARAMETER NoOutput Suppress output of executable .PARAMETER EnvVars Additionally supply environment variables to process .OUTPUTS Returns Process StdOut, StdErr and ExitCode .EXAMPLE Start-ShellCmd -Command 'cmd.exe' -Arguments '/c' .EXAMPLE $r = Start-ShellCmd -Command 'pwsh' -Arguments '-NonInteractive -NoLogo -OutputFormat Text -ExecutionPolicy Bypass -Command Get-Process' if ($r.ExitCode -ne 0) { throw "Invalid ExitCode returned from pwsh.exe : $($r.ExitCode)"} .EXAMPLE Start-ShellCmd -c 'pwsh' -a '-Command Get-Service' | Out-Null #> function Start-ShellCmd { [CmdletBinding(SupportsShouldProcess)] [OutputType([PSCustomObject])] param( [alias('c')] $Command, [alias('a')] $Arguments, [PSCredential] $Credential, [alias('w')] $WorkingDirectory = '', [alias('no')][switch] $NoOutput, $EnvVars, [switch] $Progress ) try { $pInfo = New-Object System.Diagnostics.ProcessStartInfo $pInfo.FileName = $Command $pInfo.RedirectStandardError = $true $pInfo.RedirectStandardOutput = $true $pInfo.UseShellExecute = $false $pInfo.Arguments = $Arguments $pInfo.ErrorDialog = $false if ((!$WorkingDirectory) -or ($WorkingDirectory -eq '')) { $pInfo.WorkingDirectory = Get-Location } else { $pInfo.WorkingDirectory = $WorkingDirectory } if ($env:SystemRoot) { $pInfo.LoadUserProfile = $true } if ($Credential) { $pInfo.UserName = $Credential.GetNetworkCredential().UserName if ($Credential.GetNetworkCredential().Domain) { $pInfo.Domain = $Credential.GetNetworkCredential().Domain } $pInfo.Password = $Credential.GetNetworkCredential().SecurePassword } if ($EnvVars) { foreach ($v in $EnvVars.GetEnumerator()) { if ($v.Key) { $pInfo.EnvironmentVariables[$v.Key] = $v.Value; } } } if ($PSCmdlet.ShouldProcess('Start-ShellCmd')) { $p = New-Object System.Diagnostics.Process $p.StartInfo = $pInfo if ($Progress.IsPresent) { $stdOutBuilder = New-Object -TypeName System.Text.StringBuilder $stdErrBuilder = New-Object -TypeName System.Text.StringBuilder $eventHandler = ` { if (![String]::IsNullOrEmpty($EventArgs.Data)) { $Event.MessageData.Builder.AppendLine($EventArgs.Data) if ($Event.MessageData.ShowOutput) { Write-Host $EventArgs.Data } } } $mdo = [PSCustomObject]@{ ShowOutput = !($NoOutput.IsPresent) Builder = $stdOutBuilder } $mde = [PSCustomObject]@{ ShowOutput = !($NoOutput.IsPresent) Builder = $stdErrBuilder } $stdOutEvent = Register-ObjectEvent -InputObject $p -Action $eventHandler -EventName 'OutputDataReceived' -MessageData $mdo $stdErrEvent = Register-ObjectEvent -InputObject $p -Action $eventHandler -EventName 'ErrorDataReceived' -MessageData $mde $p.Start() | Out-Null $handle = $p.Handle # cache handle to prevent $null ExitCode issue $p.BeginOutputReadLine() $p.BeginErrorReadLine() While (-not ($p.HasExited)) { $p.Refresh() } Unregister-Event -SourceIdentifier $stdOutEvent.Name; $stdOutEvent = $null; Unregister-Event -SourceIdentifier $stdErrEvent.Name; $stdErrEvent = $null; $so = $stdOutBuilder.ToString().TrimEnd("`r", "`n"); $se = $stdErrBuilder.ToString() } else { $p.Start() | Out-Null $handle = $p.Handle # cache handle to prevent $null ExitCode issue $so = $p.StandardOutput.ReadToEnd() $se = $p.StandardError.ReadToEnd() $p.WaitForExit() if (!($NoOutput.IsPresent)) { Write-Info "$so $se" } } Write-Debug "Start-ShellCmd: Process Handle: $handle" [PSCustomObject]@{ StdOut = $so StdErr = $se ExitCode = $p.ExitCode } $handle = $null } } catch { if ($_.Exception.Message.Contains('The stub received bad data')) { Throw "No domain name specified, add domain name to user '$($pInfo.UserName)' : $($_.Exception.Message)" } else { Throw } } } <# .SYNOPSIS Checks if property exists on Object .DESCRIPTION Checks if property exists on Object. In Set-StrictMode -Latest every property used is checked for existence --> runtime exception .PARAMETER Object Object to test for property .PARAMETER Name Name of property .PARAMETER Exact Use exact match in property name checking #> function Test-PSProperty { [OutputType([boolean])] param([alias('o')][object]$Object, [alias('p')][string]$Name, [alias('e')][switch]$Exact) try { foreach ($prop in $Object.PSObject.Properties) { if ($Exact.IsPresent) { if ($prop.Name -eq $Name) { return $true } elseif ($prop.Name -match 'Keys') { if ($prop.Value -eq $Name) { return $true } } } else { if ($prop.Name -match $Name) { return $true } elseif ($prop.Name -match 'Keys') { if ($prop.Value -match $Name) { return $true } } } } } catch { # not found } return $false } <# .SYNOPSIS Writes Info to console host. .DESCRIPTION Writes Info to console host. .PARAMETER Object Objects to display in the host. .PARAMETER NoNewline The string representations of the input objects are concatenated to form the output. No spaces or newlines are inserted between the output strings. No newline is added after the last output string. .PARAMETER Separator Specifies a separator string to insert between objects displayed by the host. .PARAMETER ForegroundColor Specifies the text color. .PARAMETER BackgroundColor Specifies the background color. #> function Write-Info { [CmdletBinding()] [OutputType([System.Void])] param( [parameter(ValueFromPipeline = $True)] $Object, [switch]$NoNewline, $Separator, $ForegroundColor, $BackgroundColor ) Begin { $parameters = @{ Object = $null } if ($NoNewline.IsPresent) { $parameters.Add('NoNewLine', $true) } if ($Separator) { $parameters.Add('Separator', $Separator) } # TODO !!EH Remap to ansi escape codes? if ($ForegroundColor) { $parameters.Add('ForegroundColor', $ForegroundColor) } if ($BackgroundColor) { $parameters.Add('BackgroundColor', $BackgroundColor) } } Process { $parameters.Object = $Object Write-Host @parameters } End { [Console]::ResetColor() } } function Expand-WorkflowActions($Actions) { $ctx = Get-RootContext $expandedActions = [System.Collections.ArrayList]@() foreach ($action in $Actions) { if ($action.Contains('*')) { foreach ($item in $ctx.Actions.GetEnumerator()) { $n = $item.Value.DisplayName if ($n -like $action) { if (!$expandedActions.Contains($n)) { [void]$expandedActions.Add($n) } } } } else { [void]$expandedActions.Add($action) } } return $expandedActions } function Get-AnsiColoredString([string]$String, [int]$Color) { if ($Global:ScriptbookSimpleHost) { return $String } else { return "`e[0;$($Color)m$($String)`e[0m" } } <# .SYNOPSIS Returns if defined Powershell function has no body, aka is empty #> function Get-IsPSFunctionDefinitionEmpty { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")] param([alias('Function')]$aFunction) try { $c = Get-Command $aFunction if ($c) { $d = $c.Definition if ($d) { return $d.Trim().Length -eq 0 } else { return $true } } } catch { # ignore } return $false } function Get-RootContext { return $Script:RootContext } function Invoke-ActionAlways { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "")] [CmdletBinding(SupportsShouldProcess = $true)] param ( $Actions, $ThrowError = $false, [switch]$Test, [switch]$Parallel ) if ($null -eq $Actions -or ($Actions.Count -eq 0)) { Write-Verbose "No actions/steps found to execute" return } foreach ($action in $Actions) { if ($action.Always -and ($action.Name -notin $script:InvokedCommands) ) { Invoke-PerformIfDefined -Command $action.Name -ThrowError $ThrowError -Test:$Test.IsPresent -WhatIf:$WhatIfPreference } } } function Invoke-ActionSequence { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "")] [CmdletBinding(SupportsShouldProcess = $true)] param ( $Actions, $ThrowError = $false, [switch]$Test, [switch]$Parallel ) if ($null -eq $Actions -or ($Actions.Count -eq 0)) { Write-Verbose "No actions/steps found to execute" return } if ($Parallel.IsPresent -and $PSVersionTable.PSVersion.Major -ge 7) { $hasDepends = $false foreach ($action in $Actions) { if ($action.Depends.Count -gt 0) { $hasDepends = $true } } Write-Experimental "Starting workflow in parallel mode" #TODO !!EH disabled for now, not working yet $hasDepends = $true if (!$HasDepends) { $mp = (Get-Module Scriptbook).Path $globalScriptVariables = Get-GlobalVarsForScriptblock -AsHashTable $rc = Get-RootContext $Actions | Where-Object { $_.NoSequence -eq $false} | ForEach-Object -Parallel { $vars = $using:globalScriptVariables foreach ($v in $vars.GetEnumerator()) { Set-Variable $v.Key -Value $v.Value -Option ReadOnly -ErrorAction Ignore } Import-Module $using:mp -Args @{ Quiet = $true } $script:RootContext = $using:rc Invoke-PerformIfDefined -Command $_.Name -ThrowError $ThrowError -Test:$Test.IsPresent -WhatIf:$WhatIfPreference } return } } # sequential foreach ($action in $Actions) { if (!$action.NoSequence) { Invoke-PerformIfDefined -Command $action.Name -ThrowError $ThrowError -Test:$Test.IsPresent -WhatIf:$WhatIfPreference } } } <# .SYNOPSIS Performs a command/action #> function Invoke-Perform { [CmdletBinding(SupportsShouldProcess = $True)] param ( [string][alias('c')]$Command, $Code = $null, [alias('d')]$Depends = $null, [alias('Parameters')]$ActionParameters, [bool]$NoReEntry, [bool][alias('AsJob')]$aAsJob, $If, [switch]$NoDepends, $NextAction, $For, [switch]$Parallel, [switch]$Container, [HashTable]$ContainerOptions, $Session, [switch]$Isolated, [switch]$Manual, [string]$TypeName, [string[]]$RequiredVariables, $Comment, [switch]$SuppressOutput, [switch]$ConfirmAction, [switch]$WhatIfAction ) $invokeErrorAction = $ErrorActionPreference # only way to set Error preference in Scriptblock $Global:ErrorActionPreference = $invokeErrorAction $dependencies = $Depends # simple depth first dependencies with attribute on functions if (-not $NoDepends.IsPresent) { if (!$Code) { if ($stepCommand = Get-Command -Name $Command -CommandType Function) { $d = $stepCommand.ScriptBlock.Attributes.Where{ $_.TypeId.Name -eq 'DependsOn' } if ($d) { $dependencies = $d.Name } } } # simple depth first dependencies foreach ($dependency in $dependencies) { if (!$dependency.StartsWith('Invoke-')) { $dependency = "Invoke-$dependency" } if ($dependency -NotIn $script:InvokedCommands) { if ($Command -notin $script:InvokedCommands) { Invoke-PerformIfDefined -Command $dependency -ThrowError $true -Manual:$Manual.IsPresent $Script:RootContext.IndentLevel += 1 } } } } # Check re-entry if (!($NoReEntry)) { if ($Command -in $script:InvokedCommands) { return; } } $cmdDisplayName = $Command.Replace('Action-', '').Replace('Invoke-', '') $skipped = $false # Re-apply PSake properties and Parameters in Scope try { if ((Test-Path variable:Script:PSakeProperties) -and $Script:PSakeProperties) { . $Script:PSakeProperties } if ((Test-Path variable:Script:PSakeInvocationParameters) -and $Script:PSakeInvocationParameters) { $Script:PSakeInvocationParameters.Keys | ForEach-Object { Set-Variable -Name $_ -Value $Script:PSakeInvocationParameters[$_] } $Script:PSakeInvocationParameters.Keys | ForEach-Object { Set-Variable -Name $_ -Value $Script:PSakeInvocationParameters[$_] -Scope Script } } if ((Test-Path variable:Script:PSakeInvocationProperties) -and $Script:PSakeInvocationProperties) { $Script:PSakeInvocationProperties.Keys | ForEach-Object { Set-Variable -Name $_ -Value $Script:PSakeInvocationProperties[$_] } $Script:PSakeInvocationProperties.Keys | ForEach-Object { Set-Variable -Name $_ -Value $Script:PSakeInvocationProperties[$_] -Scope Script } } } catch { Write-Warning "Issues in PSake properties and/or parameters" throw } # validate required variables if ($RequiredVariables -and $RequiredVariables.Count -gt 0) { $varsNotFound = [System.Collections.ArrayList]@() foreach ($var in $RequiredVariables) { If (!((Test-Path "variable:$var") -and ($null -ne (Get-Variable -Name $var)))) { [void]$varsNotFound.Add($var) } } if ($varsNotFound.Count -gt 0) { Throw "Required variable(s) '$($varsNotFound -join ',')' not found in $cmdDisplayName" } $varsNotFound = $null } # check start condition if ($If) { $ifResult = & $If if (!$ifResult) { $skipped = $true Write-ScriptLog @{action = "$($TypeName): $cmdDisplayName"; time = $(Get-Date -Format s); } -AsAction Write-Info 'Skipped:' $script:InvokedCommandsResult += @{ Name = "$cmdDisplayName"; Duration = 0; Indent = $Script:RootContext.IndentLevel; Exception = $null; HasError = $false; ReturnValue = $null; Command = $Command; Comment = $Comment; Confirm = $ConfirmAction; WhatIf = $WhatIfPreference; Skipped = $Skipped } return; } } $commandStopwatch = [System.Diagnostics.Stopwatch]::StartNew(); Write-ScriptLog @{action = "$($TypeName): $(Get-AnsiColoredString -String $cmdDisplayName -Color 36 )"; time = $(Get-Date -Format s); } -AsAction Write-ScriptLog @{action = "$cmdDisplayName-Started"; time = $(Get-Date -Format s); } -AsAction -Verbose # check if we have something to execute if (!$Code) { if (!(Get-Item function:$Command -ErrorAction SilentlyContinue)) { Throw "Required function '$Command' not found in script" } } $ex = $null $hasError = $false # determines if exception has occurred but not how exception is handled. When ErrorPreference is 'Continue' exception is null but hasError is true $codeReturn = $null Push-Location $Script:WorkflowLocation $prevInAction = $Script:RootContext.InAction $prevNestedActions = $Script:RootContext.NestedActions $prevWhatIfPreference = $WhatIfPreference $Script:RootContext.InAction = $true $Script:RootContext.NestedActions = New-Object -TypeName 'System.Collections.ArrayList' try { if ($ConfirmAction.IsPresent) { $yes = New-Object System.Management.Automation.Host.ChoiceDescription '&Yes' $no = New-Object System.Management.Automation.Host.ChoiceDescription '&No' $options = [System.Management.Automation.Host.ChoiceDescription[]]($yes, $no) $choiceRTN = $host.UI.PromptForChoice('Confirm', "Are you sure you want to perform this action '$cmdDisplayName'", $options, 1) if ( $choiceRTN -eq 1 ) { $skipped = $true Write-Info "Confirm: Skipping action '$cmdDisplayName'" return } } if ($WhatIfAction.IsPresent) { $WhatIfPreference = $true } if (!$WhatIfPreference) { if ((Test-Path variable:Script:PSakeSetupTask) -and $Script:PSakeSetupTask) { & $Script:PSakeSetupTask $ActionParameters } } # check function without code if (!$Code -and (Get-IsPSFunctionDefinitionEmpty $Command)) { $skipped = $true; return; } $script:InvokedCommands += $Command if (!$WhatIfPreference) { $beforePerform = Global:Invoke-BeforePerform -Command $Command } else { $beforePerform = $true } if ($beforePerform) { # TODO: EH!! Refactor into multiple functions/code block # execute the code or function if ($Code) { $mp = $null if (!$Isolated.IsPresent) { $mp = (Get-Module Scriptbook).Path } if ($For) { $forResult = & $For if ($Parallel.IsPresent -and $PSVersionTable.PSVersion.Major -ge 7) { $codeReturn = @() $globalScriptVariables = Get-GlobalVarsForScriptblock -Isolated:$Isolated.IsPresent -AsHashTable $codeAsString = $Code.ToString() # no scriptblock allowed in parallel ForEach :) # $using:* is by ref with RunSpaces(parallel) but copy of var in Remoting # $using:* needs to be thread-safe for parallel if ($PSCmdlet.ShouldProcess($cmdDisplayName, "Invoke")) { $forResult | ForEach-Object -Parallel { $vars = $using:globalScriptVariables foreach ($v in $vars.GetEnumerator()) { Set-Variable $v.Key -Value $v.Value -WhatIf:$False } if ($using:mp) { Import-Module $using:mp -Args @{ Quiet = $true } } $Parameters = $using:ActionParameters $Parameters = $Parameters.Clone() $Parameters.ForItem = $_ $Parameters.ForParallel = $true # use local vars Set-Variable ForItem -Value $_ -WhatIf:$False -Option Constant Set-Variable ForParallel -Value $true -WhatIf:$False -Option Constant Set-Variable Tag -Value $Parameters.Tag -WhatIf:$False -Option Constant -ErrorAction Ignore Set-Variable Name -Value $Parameters.Name -WhatIf:$False Set-Variable ActionName -Value $Parameters.ActionName -WhatIf:$False -Option ReadOnly foreach ($v in $Parameters.Parameters.GetEnumerator()) { Set-Variable $v.Key -Value $v.Value -WhatIf:$False -Option Constant -ErrorAction Ignore } $code = [Scriptblock]::Create($using:codeAsString) try { & $code $Parameters } catch { if ($using:invokeErrorAction -eq 'Continue') { Write-Host $_.Exception.Message -ForegroundColor White -BackgroundColor Red } elseif ($using:invokeErrorAction -notin 'Ignore', 'SilentlyContinue') { Throw } } } | ForEach-Object { $codeReturn += $_ } # TODO !!EH Pick-up if Exception --> hasError } } elseif ($aAsJob) { $globalScriptVariables = Get-GlobalVarsForScriptblock -Isolated:$Isolated.IsPresent -AsHashTable $codeAsString = $Code.ToString() # $using:* is by ref with RunSpaces(parallel) but copy of var in Remoting # $using:* needs to be thread-safe for parallel if ($PSCmdlet.ShouldProcess($cmdDisplayName, "Invoke")) { $forResult | ForEach-Object { $item = $_; Start-Job -ScriptBlock { $vars = $using:globalScriptVariables foreach ($v in $vars.GetEnumerator()) { Set-Variable $v.Key -Value $v.Value -WhatIf:$False } if ($using:mp) { Import-Module $using:mp -Args @{ Quiet = $true } } $Parameters = $using:ActionParameters $Parameters = $Parameters.Clone() $Parameters.ForItem = $using:item $Parameters.AsJob = $true # use local vars Set-Variable ForItem -Value $Parameters.ForItem -WhatIf:$False -Option Constant Set-Variable AsJob -Value $true -WhatIf:$False -Option Constant -ErrorAction Ignore Set-Variable Tag -Value $Parameters.Tag -WhatIf:$False -Option Constant -ErrorAction Ignore Set-Variable Name -Value $Parameters.Name -WhatIf:$False -Option Constant -ErrorAction Ignore Set-Variable ActionName -Value $Parameters.ActionName -WhatIf:$False -Option ReadOnly foreach ($v in $Parameters.Parameters.GetEnumerator()) { Set-Variable $v.Key -Value $v.Value -WhatIf:$False -Option Constant -ErrorAction Ignore } $code = [Scriptblock]::Create($using:codeAsString) try { & $code $Parameters } catch { if ($using:invokeErrorAction -eq 'Continue') { Write-Host $_.Exception.Message -ForegroundColor White -BackgroundColor Red } elseif ($using:invokeErrorAction -notin 'Ignore', 'SilentlyContinue') { Throw } } } } | Out-Null Get-Job | Wait-Job | Out-Null $codeReturn = Get-Job | Receive-Job # TODO !!EH Pick-up if Exception --> hasError Get-Job | Remove-Job | Out-Null } } else { $r = @() Set-Variable AsJob -Value $true -Scope Global -WhatIf:$False Set-Variable Tag -Value $ActionParameters.Tag -Scope Global -WhatIf:$False Set-Variable Name -Value $ActionParameters.Name -Scope Global -WhatIf:$False Set-Variable ActionName -Value $ActionParameters.ActionName -Scope Global -WhatIf:$False Set-Variable Parameters -Value $ActionParameters.Parameters -Scope Global -WhatIf:$False foreach ($v in $ActionParameters.Parameters.GetEnumerator()) { Set-Variable $v.Key -Value $v.Value -Scope Global -WhatIf:$False } foreach ($forItem in $forResult) { $ActionParameters.ForItem = $forItem $ActionParameters.ForParallel = $false Set-Variable ForItem -Value $forItem -Scope Global -WhatIf:$False if ($PSCmdlet.ShouldProcess("$cmdDisplayName with item '$($forItem)'", "Invoke")) { try { $r += & $Code $ActionParameters } catch { if ($invokeErrorAction -eq 'Continue') { Write-Host $_.Exception.Message -ForegroundColor White -BackgroundColor Red } elseif ($invokeErrorAction -notin 'Ignore', 'SilentlyContinue') { Throw } } } } $codeReturn = $r # TODO !!EH Pick-up if Exception --> hasError } } elseif ($aAsJob) { $globalScriptVariables = Get-GlobalVarsForScriptblock -Isolated:$Isolated.IsPresent -AsHashTable $codeAsString = $Code.ToString() $job = Start-Job -ScriptBlock { $vars = $using:globalScriptVariables foreach ($v in $vars.GetEnumerator()) { Set-Variable $v.Key -Value $v.Value -WhatIf:$False } if ($using:mp) { Import-Module $using:mp -Args @{ Quiet = $true } } $parameters = $using:ActionParameters Set-Variable Tag -Value $parameters.Tag -WhatIf:$False -Option Constant -ErrorAction Ignore Set-Variable Name -Value $parameters.Name -WhatIf:$False -Option Constant -ErrorAction Ignore Set-Variable ActionName -Value $parameters.ActionName -WhatIf:$False -Option ReadOnly foreach ($v in $parameters.Parameters.GetEnumerator()) { Set-Variable $v.Key -Value $v.Value -WhatIf:$False -Option Constant -ErrorAction Ignore } $code = [Scriptblock]::Create($using:codeAsString) try { & $code $parameters } catch { if ($using:invokeErrorAction -eq 'Continue') { Write-Host $_.Exception.Message -ForegroundColor White -BackgroundColor Red } elseif ($using:invokeErrorAction -notin 'Ignore', 'SilentlyContinue') { Throw } } } if ($PSCmdlet.ShouldProcess($cmdDisplayName, "Invoke")) { $job | Wait-Job | Out-Null $codeReturn = $job | Receive-Job # TODO !!EH Pick-up if Exception --> hasError $job | Remove-Job | Out-Null } } elseif ($Container.IsPresent -or ($ContainerOptions.Count -gt 0)) { #TODO !!EH No nested action/code supported yet, detect nested code and wrap in workflow of it's own or add container support to Invoke-ActionSequence if ($TypeName -eq 'Stage') { Write-Unsupported "Running Stage in Container" } else { Start-ScriptInContainer -ActionName $cmdDisplayName -ActionType $TypeName -Options $ContainerOptions -Parameters $ActionParameters -Isolated:$Isolated.IsPresent -Code $Code } } elseif ($Session) { if ($Isolated.IsPresent) { $sb = $Code } else { $globalScriptVariables = Get-GlobalVarsForScriptblock -Isolated:$Isolated.IsPresent -AsHashTable $codeAsString = $Code.ToString() $sb = { $vars = $using:globalScriptVariables foreach ($v in $vars.GetEnumerator()) { Set-Variable $v.Key -Value $v.Value } #TODO !!EH Do we need Scriptbook Module in remote, so yes install module remote (copy to remote first) # if ($using:mp) # { # Import-Module $using:mp -Args @{ Quiet = $true } # } $parameters = $using:ActionParameters Set-Variable Tag -Value $parameters.Tag -WhatIf:$False -Option Constant -ErrorAction Ignore Set-Variable Name -Value $parameters.Name -WhatIf:$False -Option Constant -ErrorAction Ignore Set-Variable ActionName -Value $parameters.ActionName -WhatIf:$False -Option ReadOnly foreach ($v in $parameters.Parameters.GetEnumerator()) { Set-Variable $v.Key -Value $v.Value -WhatIf:$False -Option Constant -ErrorAction Ignore } $code = [Scriptblock]::Create($using:codeAsString) try { & $code $parameters } catch { if ($using:invokeErrorAction -eq 'Continue') { Write-Host $_.Exception.Message -ForegroundColor White -BackgroundColor Red } elseif ($using:invokeErrorAction -notin 'Ignore', 'SilentlyContinue') { Throw } } } } if ($PSCmdlet.ShouldProcess($cmdDisplayName, "Invoke")) { try { $codeReturn = Invoke-Command -Session $Session -ScriptBlock $sb -Args $ActionParameters } catch { $hasError = $true if ($invokeErrorAction -eq 'Continue') { Write-ScriptLog $_.Exception.Message -AsError } elseif ($invokeErrorAction -notin 'Ignore', 'SilentlyContinue') { Throw } } } } else { Set-Variable AsJob -Value $true -Scope Global -WhatIf:$False Set-Variable Tag -Value $ActionParameters.Tag -Scope Global -WhatIf:$False Set-Variable Name -Value $ActionParameters.Name -Scope Global -WhatIf:$False Set-Variable ActionName -Value $ActionParameters.ActionName -Scope Global -WhatIf:$False Set-Variable Parameters -Value $ActionParameters.Parameters -Scope Global -WhatIf:$False if ($PSCmdlet.ShouldProcess($cmdDisplayName, "Invoke")) { try { $codeReturn = & $Code $ActionParameters } catch { $hasError = $true if ($invokeErrorAction -eq 'Continue') { Write-ScriptLog $_.Exception.Message -AsError } elseif ($invokeErrorAction -notin 'Ignore', 'SilentlyContinue') { Throw } } } } } else { if ($PSCmdlet.ShouldProcess($cmdDisplayName, "Invoke")) { try { $codeReturn = &"$Command" } catch { $hasError = $true if ($invokeErrorAction -eq 'Continue') { Write-ScriptLog $_.Exception.Message -AsError } elseif ($invokeErrorAction -notin 'Ignore', 'SilentlyContinue') { Throw } } } } # execute nested actions if ($Script:RootContext.NestedActions.Count -gt 0) { $Script:RootContext.IndentLevel += 1 try { Invoke-ActionSequence -Actions $Script:RootContext.NestedActions -ThrowError $true -Test:$Test.IsPresent -WhatIf:$WhatIfPreference } finally { $Script:RootContext.IndentLevel -= 1 } } # show ReturnValue(s) on console if ($codeReturn -and !$SuppressOutput.IsPresent) { if ($codeReturn -is [array]) { foreach ($line in $codeReturn) { if ($line -is [string]) { Write-Info $line } else { $line } } } else { $codeReturn } } if (!$WhatIfPreference) { Global:Invoke-AfterPerform -Command $Command if ((Test-Path variable:Script:PSakeTearDownTask) -and $Script:PSakeTearDownTask) { & $Script:PSakeTearDownTask $ActionParameters } } } } catch { $hasError = $true if ($invokeErrorAction -eq 'Stop') { $ex = $_.Exception Global:Invoke-AfterPerform -Command $Command -ErrorRecord $_ Global:Write-OnLogException -Exception $ex Throw } if ($invokeErrorAction -eq 'Continue') { $ex = $_.Exception Write-ExceptionMessage $_ -TraceLineCnt 5 Global:Invoke-AfterPerform -Command $Command -ErrorRecord $_ Global:Write-OnLogException -Exception $ex } elseif ($invokeErrorAction -eq 'ContinueSilently') { Global:Invoke-AfterPerform -Command $Command } else { # ignore Global:Invoke-AfterPerform -Command $Command } } finally { Write-ScriptLog @{action = "$cmdDisplayName-Finished"; time = $(Get-Date -Format s); } -AsError:($null -ne $ex) -AsAction -Verbose $Script:RootContext.InAction = $prevInAction $Script:RootContext.NestedActions = $prevNestedActions $indent = $Script:RootContext.IndentLevel if ($Manual.IsPresent) { $indent += 1 } $script:InvokedCommandsResult += @{ Name = "$cmdDisplayName"; Duration = $commandStopwatch.Elapsed; Indent = $indent; Exception = $ex; HasError = $hasError; ReturnValue = $codeReturn; Command = $Command; Comment = $Comment; Confirm = $ConfirmAction; WhatIf = $WhatIfPreference; Skipped = $Skipped } if ($WhatIfAction.IsPresent) { $WhatIfPreference = $prevWhatIfPreference } Pop-Location } if ($NextAction) { if ($PSCmdlet.ShouldProcess($NextAction, "Invoke")) { $action = "Action-$NextAction" Invoke-PerformIfDefined -Command $action -ThrowError $ThrowError -Test:$Test.IsPresent -WhatIf:($WhatIfPreference -or $WhatIfAction.IsPresent) } } } <# .SYNOPSIS Checks if command/action is defined in script / workflow before executing .DESCRIPTION Checks if command/action is defined in script / workflow before executing, In test mode only execute test actions from workflow #> function Invoke-PerformIfDefined { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "")] [CmdletBinding(SupportsShouldProcess = $true)] param ( [string][alias('c')]$Command, $ThrowError = $false, $ActionParameters, [bool]$NoReEntry, [bool]$AsJob = $false, [switch]$NoDepends, [switch]$Test, [switch]$Manual ) $ctx = Get-RootContext $action = $ctx.Actions[$Command.Replace('Invoke-', 'Action-')] if ($action) { if ($action.Disabled) { return } # in test mode only execute test actions from workflow # not in Test mode don't execute test actions if ($Test.IsPresent) { if ($action.TypeName -ne 'Test') { return } } else { if ($action.TypeName -eq 'Test') { return } } if ($ActionParameters) { $ap = $ActionParameters } else { $ap = $action.Parameters } if ($AsJob) { $aj = $true } else { $aj = $action.AsJob } #TODO/FIXME !!EH Put action into separate method? Invoke-Perform -Command $action.Name -Code $action.Code -Depends $action.Depends -ErrorAction $action.ErrorAction -ActionParameters @{Name = $action.DisplayName; ActionName = $action.DisplayName; Tag = $action.Tags; Parameters = $ap } -NoReEntry $NoReEntry -AsJob $aj -If $action.If -NoDepends:$NoDepends.IsPresent -NextAction $Action.NextAction -For $action.For -Parallel:$action.Parallel -Container:$action.Container -ContainerOptions:$action.ContainerOptions -Session $action.Session -Isolated:$action.Isolated -Manual:$Manual.IsPresent -TypeName $action.TypeName -RequiredVariables $action.RequiredVariables -Comment $action.Comment -SuppressOutput:$action.SuppressOutput -ConfirmAction:$action.Confirm -WhatIfAction:$action.WhatIf } else { # in test mode only execute test functions from workflow # not in Test mode don't execute test functions if ($Test.IsPresent) { if (!$Command.ToLower().Contains('test')) { return } } else { if ($Command.ToLower().Contains('test')) { return } } if (Get-Item function:$Command -ErrorAction SilentlyContinue) { Invoke-Perform -Command $Command -AsJob $AsJob -NoDepends:$NoDepends.IsPresent -WhatIf:$WhatIfPreference -TypeName 'Function' } elseif (Get-Item function:$($Command.Replace('Invoke-', '')) -ErrorAction SilentlyContinue) { Invoke-Perform -Command $Command.Replace('Invoke-', '') -AsJob $AsJob -NoDepends:$NoDepends.IsPresent -WhatIf:$WhatIfPreference -TypeName 'Function' } elseif ($ThrowError) { Throw "Action $($Command.Replace('Invoke-', '').Replace('Action-', '')) or Command $Command not found in ScriptFile(s)" } } } function New-RootContext { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [switch]$Soft ) if ($PSCmdlet.ShouldProcess("New-RootContext")) { $actions = New-Object -TypeName 'System.Collections.Generic.Dictionary[String,object]' -ArgumentList @([System.StringComparer]::InvariantCultureIgnoreCase) $actionSequence = New-Object -TypeName 'System.Collections.ArrayList' $infos = New-Object -TypeName 'System.Collections.ArrayList' $notifications = New-Object -TypeName 'System.Collections.ArrayList' if ($Soft.IsPresent) { if ($Script:RootContext) { $actions = $Script:RootContext.Actions $actionSequence = $Script:RootContext.ActionSequence $infos = $Script:RootContext.Infos $notifications = $Script:RootContext.Notifications } } New-Object PSObject -Property @{ Actions = $actions ActionSequence = $actionSequence IndentLevel = -1 NoLogging = $false Id = New-Guid InAction = $false NestedActions = New-Object -TypeName 'System.Collections.ArrayList' UniqueIdCounter = 1 Infos = $infos Notifications = $notifications } } } $Script:RootContext = New-RootContext -WhatIf:$false $ErrorActionPreference = 'Stop'; function InternalForceCultureEnglish { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")] param() try { [CultureInfo]::CurrentCulture = 'en-US' } catch {} } # default InternalForceCultureEnglish function Get-TempPath() { if ( $env:TEMP ) { return ([System.IO.DirectoryInfo]$env:TEMP).FullName } else { return '/tmp' } } function Test-FileLocked([alias('p')][parameter(Mandatory = $true)]$Path) { $f = New-Object System.IO.FileInfo $Path if ((Test-Path -Path $Path) -eq $false) { return $false } try { $oStream = $f.Open([System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) if ($oStream) { $oStream.Close() } return $false } catch { # file is locked by a process. return $true } } function Get-GlobalVarsForScriptblock { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars", "")] param([switch]$Isolated, [switch]$AsUsing, [switch]$AsHashTable) if ($Isolated.IsPresent) { if ($AsHashTable.IsPresent) { return @{} } else { return '' } } if ($AsHashTable.IsPresent) { $result = @{} Get-Variable -Scope Global | ForEach-Object { if ( (!$Global:GlobalVarNames.ContainsKey($_.Name)) -and (!($_.Name.StartsWith('_'))) ) { [void]$result.Add($_.Name, $_.Value) } } } elseif ($AsUsing.IsPresent) { [string]$result = Get-Variable -Scope Global | ForEach-Object { if ( (!$Global:GlobalVarNames.ContainsKey($_.Name)) -and (!($_.Name.StartsWith('_'))) ) { "`$$($_.Name) = `$using:$($_.Name); " } } } else { # TODO !!EH Issue with $null values and PSCustomObjects, don't work with .ToString()... # reformat via ast # create scriptblock from string with Set-Variable 'Name' -Value $null # parse scriptblock with ast and set value of var [string]$result = Get-Variable -Scope Global | ForEach-Object { if ( (!$Global:GlobalVarNames.ContainsKey($_.Name)) -and (!($_.Name.StartsWith('_'))) ) { if ($null -eq $_.Value) { "Set-Variable '$($_.Name)' -Value `$null ; " } elseif ($_.Value -is [string]) { "Set-Variable '$($_.Name)' -Value '$($_.Value)' ; " } elseif ($_.Value -is [array]) { # not working yet array to string "Set-Variable '$($_.Name)' -Value '$($_.Value)' ; " } elseif ($_.Value -is [PSCustomObject] ) { # for now, figure out $v.ToString()... "Set-Variable '$($_.Name)' -Value `$null ; " } else { "Set-Variable '$($_.Name)' -Value $($_.Value) ; " } } } } return $result } function Write-ExceptionMessage([alias('e')]$ErrorRecord, [alias('f')][switch]$Full = $false, [alias('tlc')]$TraceLineCnt = 0) { if (($VerbosePreference -eq 'Continue') -or $Full.IsPresent) { Write-Info ($ErrorRecord | Format-List * -Force | Out-String) -ForegroundColor White -BackgroundColor Red } else { Write-Info '' Write-Info 'Error:'.PadRight(78, ' ') -ForegroundColor White -BackgroundColor Red Write-Info $ErrorRecord.Exception.Message -ForegroundColor Red if ($TraceLineCnt -ne 0) { $cnt = 0; Write-Info '' Write-Info 'CallStack:'.PadRight(78, ' ') -ForegroundColor Black -BackgroundColor Yellow foreach ($line in $ErrorRecord.ScriptStackTrace.Split("`n")) { Write-Info $line -ForegroundColor Yellow $cnt++ if ($cnt -ge $TraceLineCnt) { break; } } Write-Info '' } } } function Write-Experimental($Msg) { Write-Warning "Experimental: $Msg" } function Write-Unsupported($Msg) { Write-Warning "Unsupported: $Msg" } function Get-CommentFromCode($ScriptBlock, $Script, $File, [int]$First = -1, [switch]$IncludeLineComments) { $text = $null $tokens = $errors = $null if ($ScriptBlock) { [System.Management.Automation.Language.Parser]::ParseInput($ScriptBlock.ToString(), [ref]$tokens, [ref]$errors) | Out-Null } elseif ($Script) { [System.Management.Automation.Language.Parser]::ParseInput($Script, [ref]$tokens, [ref]$errors) | Out-Null } elseif ($File) { [System.Management.Automation.Language.Parser]::ParseFile($File, [ref]$tokens, [ref]$errors) | Out-Null } else { Throw "Get-CommentFromCode: No input supplied" } $maxTokens = $First $cntTokens = 0 foreach ($token in $tokens ) { if ($token.Kind -eq 'comment') { if ($token.Text) { if ($token.Text.StartsWith('#') -and !$IncludeLineComments.IsPresent) { continue; } $txt = $token.Text -Split "`n" if ($txt.Length -gt 1) { # get indent from last line and strip $indent = $txt[$txt.Length-1].TrimEnd('#>'); if ($indent.Length -gt 0) { $txt = $txt | ForEach-Object { $_.TrimStart($indent) } } $text += $txt | Select-Object -Skip 1 -First ($txt.Count - 2) -ErrorAction Ignore } else { if ($txt.StartsWith('#')) { $text += $txt.TrimStart('# ') + "`n" } else { $text += $txt.TrimStart('<#').TrimEnd('#>') + "`n" } } } } $cntTokens++ if ( ($maxTokens -ne -1) -and ($cntTokens -ge $maxTokens) ) { break } } return $text } function Start-ScriptInContainer { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")] [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] param( $File, $Options, $Parameters, $ActionName, $ActionType = 'Action', [switch]$Isolated, [scriptblock]$Code ) if ($null -eq (Get-Command docker -ErrorAction Ignore)) { Write-Warning 'Docker not installed or found on this system' return } # determine Scriptbook module path $m = Get-Module Scriptbook if ($null -eq $m) { Throw "Scriptbook module not found in Start-ScriptInContainer" } $scriptbookModulePath = Split-Path $m.Path -Parent #TODO !!EH make this safe with correct location of current user modules # /$home/.local/share/powershell/Modules # $home\Documents\PowerShell\Modules $userModulePath = Split-Path $m.Path -Parent $userModulePath = (Resolve-Path (Join-Path $userModulePath '../..')).Path # script path if ($File) { $f = Resolve-Path $File $scriptPath = Split-Path $f -Parent $scriptName = Split-Path $f -Leaf } else { $scriptPath = Get-Location $scriptName = '' } $root = $null if ($Options.ContainsKey('Root') -and ![string]::IsNullOrEmpty($Options.Root)) { $root = Resolve-Path $Options.Root if ($File) { if ($scriptPath.Contains($root.Path)) { $replacePath = Join-Path $root.Path '/' $scriptName = Join-Path $scriptPath.Replace($replacePath, '') $scriptName } else { throw "Script $File not found in root path '$root', orphaned paths not supported." } } $scriptPath = $root.Path } # Get container Os (Windows or Linux) $platform = 'linux' $windowsContainer = $false try { $r = docker version --format json | ConvertFrom-Json if ($r) { $windowsContainer = $r.Server.Os -eq 'windows' $platform = "$($r.Server.Os)/$($r.Server.Arch)" } } catch { # circumvent erratic behavior json output docker } if ($Options.ContainsKey('Platform') -and ![string]::IsNullOrEmpty($Options.Platform)) { if ($Options.Platform.Contains('linux')) { $windowsContainer = $false $platform = $Options.Platform } elseif ($Options.Platform.Contains('windows')) { $windowsContainer = $true $platform = $Options.Platform } } $containerName = New-Guid $cImage = 'mcr.microsoft.com/dotnet/sdk:5.0' #TODO !!EH hardcoded for now, move to Import-Module? if ($Options.ContainsKey('Image') -and ![string]::IsNullOrEmpty($Options.Image)) { $cImage = $Options.Image } if ($Options.ContainsKey('Isolated')) { $Isolated = $Options.Isolated } # map scriptbook module, user modules, and script if ($windowsContainer) { $workFolderName = 'Users\Public' $volumeVars = [System.Collections.ArrayList]@('-v', "`"$($scriptPath):c:\Workflow\Scripts`"", '-v', "`"$($userModulePath):c:\Workflow\ModulePath`"", '-v', "`"$($scriptbookModulePath):c:\Workflow\Scriptbook`"") } else { $workFolderName = 'home' $volumeVars = [System.Collections.ArrayList]@('-v', "`"$($scriptPath):/Workflow/Scripts`"", '-v', "`"$($userModulePath):/Workflow/ModulePath`"", '-v', "`"$($scriptbookModulePath):/Workflow/Scriptbook`"") } if ($env:RUNNER_TOOLSDIRECTORY) { [void]$volumeVars.Add('-v'); [void]$volumeVars.Add("`"$($env:RUNNER_TOOLSDIRECTORY):/opt/hostedtoolcache`"") } $envVars = [System.Collections.ArrayList]@('-e', 'InScriptbookContainer=True', '-e', "Script=$scriptName", '-e', "Action=$ActionName" ) foreach ($item in Get-ChildItem env:SCRIPTBOOK_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); } # Add Azure DevOps & github env vars if ($env:SYSTEM_TEAMPROJECT) { foreach ($item in Get-ChildItem env:BUILD_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); } foreach ($item in Get-ChildItem env:SYSTEM_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); } foreach ($item in Get-ChildItem env:AGENT_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); } foreach ($item in Get-ChildItem env:RUNNER_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); } } if ($env:GITHUB_ACTIONS) { foreach ($item in Get-ChildItem env:GITHUB_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); } foreach ($item in Get-ChildItem env:RUNNER_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); } } if (!$File) { #TODO !!EH Issue with $null values and PSCustomObjects, don't work with .ToString(). See Get-GlobalVarsForScriptblock $variablesToAdd = Get-GlobalVarsForScriptblock } $quiet = $false if ($Options.ContainsKey('Quiet')) { $quiet = $Options.Quiet } $detailed = $false if ($Options.ContainsKey('Detailed')) { $detailed = $Options.Detailed } if ($detailed) { $quiet = $false } $importCode = @" `$inContainer = `$env:InScriptbookContainer; if (`$inContainer) { `$isolatedTag = ''; if (`$$Isolated) { `$isolatedTag = ' Isolated'; } `$typeTag = '$ActionType'; `$typeName = `$env:Action; if (`$env:Script) { `$typeTag = 'script'; `$typeName = `$env:Script; } $( if (!$quiet) { @" Write-Host ''.PadRight(78, '='); Write-Host "Running `$typeTag '`$typeName'`$isolatedTag"; Write-Host " -In Container '$cImage' On '`$([Environment]::OSVersion.VersionString)'"; Write-Host " -As `$([Environment]::UserName) With 'PowerShell `$(`$PSVersionTable.PsVersion)' At `$((Get-Date).ToString('s'))"; Write-Host ''.PadRight(78, '='); "@ } if ($detailed) { @" Write-Host 'Environment variables:' Get-ChildItem env:* | Sort-Object -Property Name | Out-String | Write-Host; "@ } ) } $( if (!$Isolated) { if ($windowsContainer) { @" `$env:PSModulePath = `$env:PSModulePath + ';c:\Workflow\ModulePath' + ';c:\Workflow\Scriptbook'; "@ } else { @" `$env:PSModulePath = `$env:PSModulePath + ':/Workflow/ModulePath' + ':/Workflow/Scriptbook'; "@ } @" Set-Location '/Workflow/Scripts'; "@ } else { if ($File) { if ($windowsContainer) { @" `$env:PSModulePath = `$env:PSModulePath + ';c:\$workFolderName\Scriptbook'; Set-Location '\$workFolderName\Scripts'; "@ } else { @" `$env:PSModulePath = `$env:PSModulePath + ':/$workFolderName/Scriptbook'; Set-Location '/$workFolderName/Scripts'; "@ } } } ) Write-Verbose "Current location: `$(Get-Location)"; $( if ($File) { # run script Write-Output "&`"./$scriptName`" -WhatIf:!$WhatIfPreference" } else { if (!$Isolated) { # import module Write-Output "Import-Module /Workflow/Scriptbook/Scriptbook.psm1 -Args @{ Quiet = `$true };" # importing vars $variablesToAdd } if ($WhatIfPreference) { Write-Output "Write-Host 'What if: Performing the operation `"Invoke`" on target `"$ActionName`"'; " Write-Output "return;" } } ) "@ $finishCode = @" `$inContainer = `$env:InScriptbookContainer; if (`$inContainer) { `$typeTag = '$ActionType'; `$typeName = `$env:Action; if (`$env:Script) { `$typeTag = 'script'; `$typeName = `$env:Script; } $( if (!$quiet) { @" Write-Host ''.PadRight(78, '='); Write-Host "Finished `$typeTag '`$typeName'"; Write-Host ''.PadRight(78, '='); "@ } ) } "@ if ($Isolated.IsPresent) { $volumeVars = @() } if ($ActionName) { $importCode = [scriptblock]::Create($importCode + "`n" + $Code.ToString() + "`n" + $finishCode) } else { $importCode = $importCode + "`n" + $finishCode } $encodedCommand = [System.Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($importCode)) $dockerContext = $null if ($Options.ContainsKey('Context') -and ![string]::IsNullOrEmpty($Options.Context)) { $dockerContext = $Options.Context } $useSeparateDockerCommands = $true if ($Options.ContainsKey('Run')) { $useSeparateDockerCommands = -not $Options.Run } $containerStarted = $false try { Write-Verbose "Running container '$containerName' with image '$cImage' in $(Get-Location)" if ($dockerContext) { $r = docker context use $dockerContext if ($LASTEXITCODE -ne 0) { Throw "Error in docker context switch $dockerContext : $LastExitCode $r" } } if ($useSeparateDockerCommands) { $r = docker create @envVars @volumeVars --platform=$platform --tty --interactive --name "$containerName" $cImage if ($LASTEXITCODE -ne 0) { Throw "Error in docker create for container '$containerName' with image '$cImage' on platform '$platform' : $LastExitCode $r" } if ($File -and $Isolated.IsPresent) { # copy script and modules when isolated docker cp "$scriptPath" "$($containerName):/$workFolderName/Scripts" if ($m.RepositorySourceLocation) { $tmp = Join-Path (Get-TempPath) (New-Guid) New-Item $tmp -ItemType Directory | Out-Null try { $sPath = Join-Path (Join-Path $tmp Scriptbook ) $m.Version Copy-Item $scriptbookModulePath $sPath -Recurse docker cp "$tmp" "$($containerName):/$workFolderName/Scriptbook" } finally { Remove-Item $tmp -Recurse -Force -ErrorAction Ignore } } else { docker cp "$scriptbookModulePath" "$($containerName):/$workFolderName/Scriptbook" } } $r = docker start "$containerName" if ($LASTEXITCODE -ne 0) { Throw "Error in docker start for container '$containerName' with image '$cImage' on platform '$platform' : $LastExitCode $r" } $containerStarted = $true $r = Start-ShellCmd -Progress -Command 'docker' -Arguments "exec `"$containerName`" pwsh -NonInteractive -NoLogo -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand $encodedCommand" if ($r.ExitCode -ne 0) { Throw "Error in docker exec for container '$containerName' with image '$cImage' on platform '$platform' : $LastExitCode" } if ([string]::IsNullOrEmpty($r.StdOut)) { Throw "No output found in 'docker exec' command" } if (![string]::IsNullOrEmpty($r.StdErr)) { Throw "Errors found in output 'docker exec' command $($r.StdErr)" } } else { docker run @envVars @volumeVars --platform=$platform --name "$containerName" $cImage pwsh -NonInteractive -NoLogo -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand $encodedCommand if ($LASTEXITCODE -ne 0) { Throw "Error in docker run for container '$containerName' with image '$cImage' on platform '$platform' : $LastExitCode" } } } finally { try { if ($containerStarted) { $r = docker stop "$containerName" if ($LASTEXITCODE -ne 0) { Throw "Error in docker stop for container '$containerName' : $LastExitCode $r" } } } finally { $r = docker container ls -a -f name=$containerName if ($LASTEXITCODE -ne 0) { $r = $containerName } # some context's don't support container ls --> always try to remove if ( "$r".Contains($containerName)) { $r = docker rm --force $containerName if ($LASTEXITCODE -ne 0) { Throw "Error in docker remove for container '$containerName' output: $r" } } } } } function Write-ScriptBlock($ScriptBlock) { Write-StringResult (Invoke-Command -ScriptBlock $ScriptBlock) } function Write-ScriptLog($Msg, [switch]$AsError, [switch]$AsWarning, [switch]$AsAction, [switch]$AsWorkflow, [switch]$AsSkipped, [switch]$Verbose) { $ctx = Get-RootContext if ($ctx.NoLogging -and ($VerbosePreference -ne 'Continue') ) { return } if ($Verbose.IsPresent -and ($VerbosePreference -ne 'Continue')) { return } $colors = @{} if ($AsError.IsPresent) { [void]$colors.Add('ForegroundColor', 'White') [void]$colors.Add('BackgroundColor', 'Red') } elseif ($AsWarning.IsPresent) { [void]$colors.Add('ForegroundColor', 'White') [void]$colors.Add('BackgroundColor', 'Yellow') } elseif ($AsAction.IsPresent) { [void]$colors.Add('ForegroundColor', 'Blue') } elseif ($AsWorkflow.IsPresent -or $AsSkipped.IsPresent) { [void]$colors.Add('ForegroundColor', 'Magenta') } if ($Msg -and $Msg.GetType().Name -eq 'HashTable') { if ($Msg.ContainsKey('action')) { $m = $Msg.action; $Msg.Remove('action'); } elseif ($Msg.ContainsKey('command')) { $m = $Msg.command; $Msg.Remove('command'); } Write-Info $m @colors; Global:Write-OnLog -Msg $m if ($VerbosePreference -eq 'Continue') { Write-Info ($Msg.GetEnumerator() | Sort-Object -Property Name | ForEach-Object { '@{0}:{1}' -f $_.key, $_.value }) @colors } } else { Write-Info $Msg @colors } } function Write-StringResult($Result) { if ($Result -is [array]) { foreach ($l in $Result) { Write-Info $l } } else { Write-Info $Result } } # SIG # Begin signature block # MIIiEgYJKoZIhvcNAQcCoIIiAzCCIf8CAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB # gjcCAQSgWzBZMDQGCisGAQQBgjcCAR4wJgIDAQAABBAfzDtgWUsITrck0sYpfvNR # AgEAAgEAAgEAAgEAAgEAMCEwCQYFKw4DAhoFAAQU4TSZg9HB2a6Q1a0eP/LLAlVQ # YSSgghw/MIIE/jCCA+agAwIBAgIQDUJK4L46iP9gQCHOFADw3TANBgkqhkiG9w0B # AQsFADByMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYD # VQQLExB3d3cuZGlnaWNlcnQuY29tMTEwLwYDVQQDEyhEaWdpQ2VydCBTSEEyIEFz # c3VyZWQgSUQgVGltZXN0YW1waW5nIENBMB4XDTIxMDEwMTAwMDAwMFoXDTMxMDEw # NjAwMDAwMFowSDELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMu # MSAwHgYDVQQDExdEaWdpQ2VydCBUaW1lc3RhbXAgMjAyMTCCASIwDQYJKoZIhvcN # AQEBBQADggEPADCCAQoCggEBAMLmYYRnxYr1DQikRcpja1HXOhFCvQp1dU2UtAxQ # tSYQ/h3Ib5FrDJbnGlxI70Tlv5thzRWRYlq4/2cLnGP9NmqB+in43Stwhd4CGPN4 # bbx9+cdtCT2+anaH6Yq9+IRdHnbJ5MZ2djpT0dHTWjaPxqPhLxs6t2HWc+xObTOK # fF1FLUuxUOZBOjdWhtyTI433UCXoZObd048vV7WHIOsOjizVI9r0TXhG4wODMSlK # XAwxikqMiMX3MFr5FK8VX2xDSQn9JiNT9o1j6BqrW7EdMMKbaYK02/xWVLwfoYer # vnpbCiAvSwnJlaeNsvrWY4tOpXIc7p96AXP4Gdb+DUmEvQECAwEAAaOCAbgwggG0 # MA4GA1UdDwEB/wQEAwIHgDAMBgNVHRMBAf8EAjAAMBYGA1UdJQEB/wQMMAoGCCsG # AQUFBwMIMEEGA1UdIAQ6MDgwNgYJYIZIAYb9bAcBMCkwJwYIKwYBBQUHAgEWG2h0 # dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAfBgNVHSMEGDAWgBT0tuEgHf4prtLk # YaWyoiWyyBc1bjAdBgNVHQ4EFgQUNkSGjqS6sGa+vCgtHUQ23eNqerwwcQYDVR0f # BGowaDAyoDCgLoYsaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL3NoYTItYXNzdXJl # ZC10cy5jcmwwMqAwoC6GLGh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9zaGEyLWFz # c3VyZWQtdHMuY3JsMIGFBggrBgEFBQcBAQR5MHcwJAYIKwYBBQUHMAGGGGh0dHA6 # Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBPBggrBgEFBQcwAoZDaHR0cDovL2NhY2VydHMu # ZGlnaWNlcnQuY29tL0RpZ2lDZXJ0U0hBMkFzc3VyZWRJRFRpbWVzdGFtcGluZ0NB # LmNydDANBgkqhkiG9w0BAQsFAAOCAQEASBzctemaI7znGucgDo5nRv1CclF0CiNH # o6uS0iXEcFm+FKDlJ4GlTRQVGQd58NEEw4bZO73+RAJmTe1ppA/2uHDPYuj1UUp4 # eTZ6J7fz51Kfk6ftQ55757TdQSKJ+4eiRgNO/PT+t2R3Y18jUmmDgvoaU+2QzI2h # F3MN9PNlOXBL85zWenvaDLw9MtAby/Vh/HUIAHa8gQ74wOFcz8QRcucbZEnYIpp1 # FUL1LTI4gdr0YKK6tFL7XOBhJCVPst/JKahzQ1HavWPWH1ub9y4bTxMd90oNcX6X # t/Q/hOvB46NJofrOp79Wz7pZdmGJX36ntI5nePk2mOHLKNpbh6aKLzCCBTEwggQZ # oAMCAQICEAqhJdbWMht+QeQF2jaXwhUwDQYJKoZIhvcNAQELBQAwZTELMAkGA1UE # BhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2lj # ZXJ0LmNvbTEkMCIGA1UEAxMbRGlnaUNlcnQgQXNzdXJlZCBJRCBSb290IENBMB4X # DTE2MDEwNzEyMDAwMFoXDTMxMDEwNzEyMDAwMFowcjELMAkGA1UEBhMCVVMxFTAT # BgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEx # MC8GA1UEAxMoRGlnaUNlcnQgU0hBMiBBc3N1cmVkIElEIFRpbWVzdGFtcGluZyBD # QTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL3QMu5LzY9/3am6gpnF # OVQoV7YjSsQOB0UzURB90Pl9TWh+57ag9I2ziOSXv2MhkJi/E7xX08PhfgjWahQA # OPcuHjvuzKb2Mln+X2U/4Jvr40ZHBhpVfgsnfsCi9aDg3iI/Dv9+lfvzo7oiPhis # EeTwmQNtO4V8CdPuXciaC1TjqAlxa+DPIhAPdc9xck4Krd9AOly3UeGheRTGTSQj # MF287DxgaqwvB8z98OpH2YhQXv1mblZhJymJhFHmgudGUP2UKiyn5HU+upgPhH+f # MRTWrdXyZMt7HgXQhBlyF/EXBu89zdZN7wZC/aJTKk+FHcQdPK/P2qwQ9d2srOlW # /5MCAwEAAaOCAc4wggHKMB0GA1UdDgQWBBT0tuEgHf4prtLkYaWyoiWyyBc1bjAf # BgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzASBgNVHRMBAf8ECDAGAQH/ # AgEAMA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEFBQcDCDB5BggrBgEF # BQcBAQRtMGswJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBD # BggrBgEFBQcwAoY3aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0 # QXNzdXJlZElEUm9vdENBLmNydDCBgQYDVR0fBHoweDA6oDigNoY0aHR0cDovL2Ny # bDQuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENBLmNybDA6oDig # NoY0aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9v # dENBLmNybDBQBgNVHSAESTBHMDgGCmCGSAGG/WwAAgQwKjAoBggrBgEFBQcCARYc # aHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzALBglghkgBhv1sBwEwDQYJKoZI # hvcNAQELBQADggEBAHGVEulRh1Zpze/d2nyqY3qzeM8GN0CE70uEv8rPAwL9xafD # DiBCLK938ysfDCFaKrcFNB1qrpn4J6JmvwmqYN92pDqTD/iy0dh8GWLoXoIlHsS6 # HHssIeLWWywUNUMEaLLbdQLgcseY1jxk5R9IEBhfiThhTWJGJIdjjJFSLK8pieV4 # H9YLFKWA1xJHcLN11ZOFk362kmf7U2GJqPVrlsD0WGkNfMgBsbkodbeZY4UijGHK # eZR+WfyMD+NvtQEmtmyl7odRIeRYYJu6DC0rbaLEfrvEJStHAgh8Sa4TtuF8QkIo # xhhWz0E0tmZdtnR79VYzIi8iNrJLokqV2PWmjlIwggVvMIIEV6ADAgECAhBI/JO0 # YFWUjTanyYqJ1pQWMA0GCSqGSIb3DQEBDAUAMHsxCzAJBgNVBAYTAkdCMRswGQYD # VQQIDBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcMB1NhbGZvcmQxGjAYBgNV # BAoMEUNvbW9kbyBDQSBMaW1pdGVkMSEwHwYDVQQDDBhBQUEgQ2VydGlmaWNhdGUg # U2VydmljZXMwHhcNMjEwNTI1MDAwMDAwWhcNMjgxMjMxMjM1OTU5WjBWMQswCQYD # VQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS0wKwYDVQQDEyRTZWN0 # aWdvIFB1YmxpYyBDb2RlIFNpZ25pbmcgUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB # AQUAA4ICDwAwggIKAoICAQCN55QSIgQkdC7/FiMCkoq2rjaFrEfUI5ErPtx94jGg # UW+shJHjUoq14pbe0IdjJImK/+8Skzt9u7aKvb0Ffyeba2XTpQxpsbxJOZrxbW6q # 5KCDJ9qaDStQ6Utbs7hkNqR+Sj2pcaths3OzPAsM79szV+W+NDfjlxtd/R8SPYID # dub7P2bSlDFp+m2zNKzBenjcklDyZMeqLQSrw2rq4C+np9xu1+j/2iGrQL+57g2e # xtmeme/G3h+pDHazJyCh1rr9gOcB0u/rgimVcI3/uxXP/tEPNqIuTzKQdEZrRzUT # dwUzT2MuuC3hv2WnBGsY2HH6zAjybYmZELGt2z4s5KoYsMYHAXVn3m3pY2MeNn9p # ib6qRT5uWl+PoVvLnTCGMOgDs0DGDQ84zWeoU4j6uDBl+m/H5x2xg3RpPqzEaDux # 5mczmrYI4IAFSEDu9oJkRqj1c7AGlfJsZZ+/VVscnFcax3hGfHCqlBuCF6yH6bbJ # DoEcQNYWFyn8XJwYK+pF9e+91WdPKF4F7pBMeufG9ND8+s0+MkYTIDaKBOq3qgdG # nA2TOglmmVhcKaO5DKYwODzQRjY1fJy67sPV+Qp2+n4FG0DKkjXp1XrRtX8ArqmQ # qsV/AZwQsRb8zG4Y3G9i/qZQp7h7uJ0VP/4gDHXIIloTlRmQAOka1cKG8eOO7F/0 # 5QIDAQABo4IBEjCCAQ4wHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw # HQYDVR0OBBYEFDLrkpr/NZZILyhAQnAgNpFcF4XmMA4GA1UdDwEB/wQEAwIBhjAP # BgNVHRMBAf8EBTADAQH/MBMGA1UdJQQMMAoGCCsGAQUFBwMDMBsGA1UdIAQUMBIw # BgYEVR0gADAIBgZngQwBBAEwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5j # b21vZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUH # AQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJ # KoZIhvcNAQEMBQADggEBABK/oe+LdJqYRLhpRrWrJAoMpIpnuDqBv0WKfVIHqI0f # TiGFOaNrXi0ghr8QuK55O1PNtPvYRL4G2VxjZ9RAFodEhnIq1jIV9RKDwvnhXRFA # Z/ZCJ3LFI+ICOBpMIOLbAffNRk8monxmwFE2tokCVMf8WPtsAO7+mKYulaEMUykf # b9gZpk+e96wJ6l2CxouvgKe9gUhShDHaMuwV5KZMPWw5c9QLhTkg4IUaaOGnSDip # 0TYld8GNGRbFiExmfS9jzpjoad+sPKhdnckcW67Y8y90z7h+9teDnRGWYpquRRPa # f9xH+9/DUp/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggYaMIIEAqADAgECAhBi # HW0MUgGeO5B5FSCJIRwKMA0GCSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgw # FgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGlj # IENvZGUgU2lnbmluZyBSb290IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEy # MzU5NTlaMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQx # KzApBgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGi # MA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shT # UxjIztNsfvxYB5UXeWUzCxEeAEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2 # A2NVDgFigOMYzB2OKhdqfWGVoYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtba # m+/36F09fy1tsB8je/RV0mIk8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP # 05ZwmRmTnAO5/arnY83jeNzhP06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9 # SJDm+qxp4VqpB3MV/h53yl41aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4Tpxtwfv # jsUedyz8rNyfQJy/aOs5b4s+ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj # 8pz44MPZ1f9+YEQIQty/NQd/2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2 # oQBMdlyh2n5HirY4jKnFH/9gRvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1Ud # IwQYMBaAFDLrkpr/NZZILyhAQnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9I # ritUpimqF6TNDDAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADAT # BgNVHSUEDDAKBggrBgEFBQcDAzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQB # MEsGA1UdHwREMEIwQKA+oDyGOmh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGln # b1B1YmxpY0NvZGVTaWduaW5nUm9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYG # CCsGAQUFBzAChjpodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWND # b2RlU2lnbmluZ1Jvb3RSNDYucDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5z # ZWN0aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6Y # dURhw1aVcdGRP4Wh60BAscjW4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIB # D0ZdOaWTsyNyBBsMLHqafvIhrCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOt # Bajjcw5+w/KeFvPYfLF/ldYpmlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0 # OhNcWbWDRF/3sBp6fWXhz7DcML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMb # OalOhOfCipnx8CaLZeVme5yELg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxA # McJszkyeiaerlphwoKx1uHRzNyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVS # Z2z76mKnzAfZxCl/3dq3dUNw4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN # /Y5JKdGvspbOrTfOXyXvmPL6E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjU # YbHHj95Ejza63zdrEcxWLDX6xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8 # X8z2Bev6SivBBOHY+uqiirZtg0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1o # DE5/L9Uo2bC5a4CH2RwwggZzMIIE26ADAgECAhB5hH3w2DVOPxRy2e6lUB5sMA0G # CSqGSIb3DQEBDAUAMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExp # bWl0ZWQxKzApBgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBS # MzYwHhcNMjEwNzI4MDAwMDAwWhcNMjQwNzI3MjM1OTU5WjBcMQswCQYDVQQGEwJO # TDERMA8GA1UEBwwIU2NoaWVkYW0xHDAaBgNVBAoME1RlZG9uIFRlY2hub2xvZ3kg # QlYxHDAaBgNVBAMME1RlZG9uIFRlY2hub2xvZ3kgQlYwggIiMA0GCSqGSIb3DQEB # AQUAA4ICDwAwggIKAoICAQDVLuU68eG+jBN7sBt4cf4ONCPcJGx/yS6sAcEi4P8l # caIOo0qDR3yn7rOO+plg58S+LJVKbshekBKpqutUejMz23kcDDWtUjto59jT1Woh # 3dcdTOG6xrpQ3SQ/noF/aOrR2nRhrZDyz3c9cT7yEl8dhbXg/F/hcBP0v5IufyLo # IJbU18I1RN+y9vgJxMvwwqJ0YCTof4SclCUTE6X6EIp97NZ+ZrKtZGMlshBCytN0 # +efVVB1XYCydDTnXyUwuQGQxxpLyZ/lq83UzvrRpNt3+PUkh/YPTjqx70sQBVnD7 # COBrsfAm9mGPo6w8q/EvplXW9qLVlbTtK0UfeQZ346xrY6Ns71MNV8qXnsVYV9jg # 3DuIa1WY9QG+anUv59QYLQ+wiFsyW0upl8M0keTSrgm5vd09zGOZegf04TcZVYf6 # ufrNQWbgsODSOZ+a4ukCb6ymm4Su9VjrVK8nDohMKbUOmFFu+lNLnqrL5yT366jq # qWbpUyiGAKb+Jw7ZG1ijEsRFCs3XHSL932czqN34ulud1W9xF28Gl36c5yJMBS1I # mRYh1sFE0P1ZV+lE8FS6dnKeRhn4p1LxC0URLST9FC7bBYE2oi2TBnOK5gjvYVc3 # ibYfLMlUlTOR6vo5pQJ/ALEvChR2l9rR6nVA9+mX+zeSQGYyILSXwHHkK7Ln1byX # xQIDAQABo4IBtzCCAbMwHwYDVR0jBBgwFoAUDyrLIIcouOxvSK4rVKYpqhekzQww # HQYDVR0OBBYEFO/lyoHjxFe9HjTxKxkltIo/eu6iMA4GA1UdDwEB/wQEAwIHgDAM # BgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMBEGCWCGSAGG+EIBAQQE # AwIEEDBKBgNVHSAEQzBBMDUGDCsGAQQBsjEBAgEDAjAlMCMGCCsGAQUFBwIBFhdo # dHRwczovL3NlY3RpZ28uY29tL0NQUzAIBgZngQwBBAEwSQYDVR0fBEIwQDA+oDyg # OoY4aHR0cDovL2NybC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGljQ29kZVNpZ25p # bmdDQVIzNi5jcmwweQYIKwYBBQUHAQEEbTBrMEQGCCsGAQUFBzAChjhodHRwOi8v # Y3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2RlU2lnbmluZ0NBUjM2LmNy # dDAjBggrBgEFBQcwAYYXaHR0cDovL29jc3Auc2VjdGlnby5jb20wGQYDVR0RBBIw # EIEOaW5mb0B0ZWRvbi5jb20wDQYJKoZIhvcNAQEMBQADggGBABZK77a7tdkZvKHu # 0Nen1iyRDa/Hdm1Pc2k3cls3vwUxJQrIQ8g6yFXZdZo/0a0IaYdgZGWYhLSpjTNn # B6LyuMWDTA2ifoZqQIRH31cE0CoT1Q+k00L58kJP4f6tcJKGMArqiFevLm04ZgnY # yPP9MLAvYtfgw3jYltLm0vT9mK+xm6T/E0GRJE/BKZOXnEDCdZkg3bV43XW/Kl5/ # kiV0FO+gYALi9PEECB68uSfmOLSFMxl1Mtu+Vl0v7if922XRz8G/UVqa49zh5hom # ewPSIS/dVTHA4cmllzYqlZLrFXX86OsHmrvkRdKa0JoRT+yyqMhnBRS+bDIkqMoD # cGLPYyr4g1NMZp4gZ0gthLCrQRmbhTdf4Vd/pFNtGrJGZGf+Y64FAg+KkUuOxCBQ # bRUKXsbX6A4aDdgLuCYCZlC6t0ah+5/j1guIX2AJ8/ZBpUCMp0zTrF+1aymEUYhx # sKYyjvqB3HdMbqryAkjmpGLypwXE/NFBFaerkS6IBeprR6S4UjGCBT0wggU5AgEB # MGgwVDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1NlY3RpZ28gTGltaXRlZDErMCkG # A1UEAxMiU2VjdGlnbyBQdWJsaWMgQ29kZSBTaWduaW5nIENBIFIzNgIQeYR98Ng1 # Tj8UctnupVAebDAJBgUrDgMCGgUAoHgwGAYKKwYBBAGCNwIBDDEKMAigAoAAoQKA # ADAZBgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgorBgEEAYI3AgELMQ4wDAYK # KwYBBAGCNwIBFTAjBgkqhkiG9w0BCQQxFgQUJ3VV9Nzke2Xz9UnUJN/Iz2aQZmww # DQYJKoZIhvcNAQEBBQAEggIAhdXajYWB6LlSdrbQ+nTrU1pUKFzABl2/0eA30bzg # NXlxQcohiRyHEM9TTqwqXaT+Io3qHOKxW1xitRANZejFSfvhWYdOlWanBbcayjyf # cgYMB7VH/YIqubO31LMixg3y7ncp463+bWGvckGjVTxyO2PKhP9UgzkibHahHnjv # 50+QyjFbmpi3nGU+Yxs09Gjfr0Pg5g8rxh3w7jbOzpjSBYVALo0jbynUIt6VORYk # gDU8N6acDjkPyek0tTs+Uja+MhAglKlaEeN94UjuUk4MJgyDQmb0wU8NaC/CVofW # jNaO3DKOWa87hT838x3rK6cC9AO+xi4F+85ZbNCN9WpYuX39YCe8RYMyt0MYq0xV # 0//7IAUoMNDjPJSyf5/mbgWIVJYp4sGCD8SKkskfWTG8/RIR6JK5yWlXXq7mECmu # jA9+Dm+OreNw35Ofao+qZqkcH6zoS9ixdT+NX2h4HyqZy7Hea/VpvIGn90/ic2P2 # eOc/JZREwD9TygFjaPon24hUWo/7F2omiSxYLS7bUs1mQlMby/RJgXejUP1l4RcE # 66YkK9vWWs3y3EGgImuNpi8TKKDCRWeKksAFFU4VlkLPz8MKZ+nVj7/TrhcmnIQ5 # fcJ4UXkFpvS687vyboXNZGyO8FCR+ccM14JDX1J7age3F3ae8/lVrCThsyEqUxgM # oKChggIwMIICLAYJKoZIhvcNAQkGMYICHTCCAhkCAQEwgYYwcjELMAkGA1UEBhMC # VVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0 # LmNvbTExMC8GA1UEAxMoRGlnaUNlcnQgU0hBMiBBc3N1cmVkIElEIFRpbWVzdGFt # cGluZyBDQQIQDUJK4L46iP9gQCHOFADw3TANBglghkgBZQMEAgEFAKBpMBgGCSqG # SIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTIxMTIwNDE2MTk1 # MFowLwYJKoZIhvcNAQkEMSIEILgDjnTpqZ8fKAhjuTx78RJu720jRKW3JvXawpKJ # d5faMA0GCSqGSIb3DQEBAQUABIIBAKwVKh/UahrK9/2TEFhztkBqDVG/lMHstiFW # dKa+OyzzBfFqopGgO1+cD3p7WAoJb1YawEtY/fYCX8SbOCjAEcg9ZhIHaiHXhDNJ # omsjQdOLKNSobYUfP4w+Wm6Tl570OU/86tpb+gpEQoLYdi7E9Vi1gomnvzLb97Kk # COdFZDZASit36uKQaGHCHacfDHcCA4B4ImMnjmljRrX+6rpOQHCDeJywTUg0b0qn # FkXX7trjzrCsUBoJHCZ3KqSoI3fPYRNxLV38PMWbRbIon5V5iqm4dcNv9i0Tsjoy # oGDWIgidfjBjuSDgkfC8uoat1z40DhTZZ4PP0sOrEXEPXv0MNeo= # SIG # End signature block |