TD.Util.psm1


<#
.SYNOPSIS
Get the Azure DevOps Personal Access Token from Azure Devops Hosted Agent (In build/deploy) or the Windows Credential Store
 
.DESCRIPTION
Get the Azure DevOps Personal Access Token from Azure Devops Hosted Agent (In build/deploy) or the Windows Credential Store. This function is MS Windows only when running local.
 
.PARAMETER Url
Url of the Azure DevOps subscription like https://(mycompany)@dev.azure.com/(mycompany)
 
.Example
$token = Get-AzureDevOpsAccessToken 'https://mycompany@dev.azure.com/mycompany')
#>

function Get-AzureDevOpsAccessToken([Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$Url)
{
    Write-Verbose "Get-AzureDevOpsAccessToken $Url"

    $token = $env:SYSTEM_ACCESSTOKEN
    if ([string]::IsNullOrEmpty($token))
    {
        if ($env:windir)
        {
            if (-not(Get-Module CredentialManager -ListAvailable)) { Install-Module CredentialManager -Scope CurrentUser -Force }
            Import-Module CredentialManager
            $credential = Get-StoredCredential -Target "git:$Url"
            if ($null -eq $credential)
            {
                Throw "No Azure DevOps credentials found in credential store"
            }
            Write-Verbose "Using Azure DevOps Access Token from Windows Credential Store"
            $token = $credential.GetNetworkCredential().Password
        }
        else
        {
            Write-Warning "Unable to resolve Azure DevOps (ADO) Credential on platforms other than Windows"
        }
    }
    return $token
}
<#
.SYNOPSIS
Get the Azure DevOps Credentials from Azure Devops Hosted Agent (In build/deploy) or the Windows Credential Store
 
.DESCRIPTION
Get the Azure DevOps Credentials from Azure Devops Hosted Agent (In build/deploy) or the Windows Credential Store. This function is MS Windows only when running local.
 
.PARAMETER Url
Url of the Azure DevOps subscription like https://(mycompany)@dev.azure.com/(mycompany)
 
.Example
$cred = Get-AzureDevOpsCredential 'https://mycompany@dev.azure.com/mycompany')
#>

function Get-AzureDevOpsCredential([Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$Url)
{
    Write-Verbose "Get-AzureDevOpsCredential $Url"

    $token = $env:SYSTEM_ACCESSTOKEN
    if ([string]::IsNullOrEmpty($token)) 
    {
        if ($env:windir)
        {
            if (-not(Get-Module CredentialManager -ListAvailable)) { Install-Module CredentialManager -Scope CurrentUser -Force }
            Import-Module CredentialManager
            $credential = Get-StoredCredential -Target "git:$Url"
            if ($null -eq $credential)
            {
                Throw "No Azure DevOps credentials found. It should be passed in via env:SYSTEM_ACCESSTOKEN."
            }
            Write-Verbose "Using Azure DevOps Access Token from Windows Credential Store"
        }
        else
        {
            Write-Warning "Unable to resolve Azure DevOps (ADO) Credential on platforms other than Windows"
        }
    }
    else
    {
        Write-Verbose "Using Azure DevOps Access Token from Hosted Agent"
        $secureToken = $token | ConvertTo-SecureString -AsPlainText -Force
        $credential = New-Object System.Management.Automation.PSCredential(".", $secureToken)
    }
    return $credential
}
<#
.SYNOPSIS
Import PowerShell module(s) and if not found install them from Azure DevOps Artifacts
 
.DESCRIPTION
Import PowerShell module(s) and if not found install them from Azure DevOps Artifacts
 
.PARAMETER PackageSource
Azure DevOps packagesource name
 
.PARAMETER Modules
Array of modules to import
 
.PARAMETER Credential
Credentials to access feed
 
.PARAMETER Latest
Always import latest modules
 
.EXAMPLE
Register-AzureDevOpsPackageSource -Name myFeed -Url https://pkgs.dev.azure.com/myCompany/_packaging/myFeed/nuget/v2
Import-AzureDevOpsModules -PackageSource 'myFeed' -Modules @('myModule') -Latest
#>

function Import-AzureDevOpsModules([Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$PackageSource, [Parameter(Mandatory = $true)]$Modules, [System.Management.Automation.PSCredential]$Credential, [Switch]$Latest)
{
    Write-Verbose "Import-AzureDevOpsModules '$Modules' from $PackageSource"

    foreach ($module in $Modules)
    {
        if (-not (Get-Module -ListAvailable -Name $module) -or $Latest.IsPresent)
        {
            Install-Module $module -Repository $PackageSource -Scope CurrentUser -Force -AllowClobber -Credential $Credential
        }
        else
        {
            Import-Module $module
        }
    }
}
<#
.SYNOPSIS
Publish the PowerShell Package to the Azure Devops Feed / Artifacts
 
.DESCRIPTION
Publish the PowerShell Package to the Azure Devops Feed / Artifacts. Depends on nuget.exe installed and in environment path.
 
Strategy:
- Register feed with nuget
- Register local temp feed to use Powershell Publish-Module command
- Publish locally created module to feed with nuget.exe
 
.PARAMETER ModuleName
Name of the PowerShell Module to publish
 
.PARAMETER ModulePath
Root path of the module
 
.PARAMETER Feedname
Name of the Azure DevOps feed
 
.PARAMETER FeedUrl
Url of the Azure DevOps feed
 
.PARAMETER AccessToken
Personal AccessToken used for Azure DevOps Feed push/publish
 
.Example
Publish-PackageToAzureDevOps -ModuleName 'MyModule' -ModulePath './Output' -Feedname 'MyFeed' -FeedUrl 'https://pkgs.dev.azure.com/mycompany/_packaging/MyFeed/nuget/v2' -AccessToken 'sasasasa'
 
#>

function Publish-PackageToAzureDevOps([Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$ModuleName, $ModulePath = './Output', [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$Feedname, [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$FeedUrl, [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$AccessToken)
{
    Write-Verbose "Publish-PackageToAzureDevOps $ModuleName"

    $packageSource = $Feedname
    $packageFeedUrl = $FeedUrl

    $deployPath = Join-Path $ModulePath $ModuleName

    # register nuget feed
    $nuGet = (Get-Command 'nuget').Source
    &$nuGet sources Remove -Name $packageSource
    [string]$r = &$nuGet sources
    if (!($r.Contains($packageSource)))
    {
        # add as NuGet feed
        Write-Verbose "Add NuGet source"
        &$nuGet sources Add -Name $packageSource -Source $packageFeedUrl -username "." -password $AccessToken
    }

    # get module version
    $manifestFile = "./$ModuleName/$ModuleName.psd1"
    $manifest = Import-PowerShellDataFile -Path $manifestFile
    $version = $manifest.Item('ModuleVersion')
    if (!$version) { Throw "No module version found in $manifestFile" } else { Write-Host "$moduleName version: $version" }

    $tmpFeedPath = Join-Path ([System.IO.Path]::GetTempPath()) "$(New-Guid)-Localfeed"
    New-Item -Path $tmpFeedPath -ItemType Directory -ErrorAction Ignore -Force | Out-Null
    try 
    {
        # register temp feed for export package
        # maybe need to use temp name for feed !!
        if (Get-PSRepository -Name LocalFeed -ErrorAction Ignore)
        {
            Unregister-PSRepository -Name LocalFeed
        }
        Register-PSRepository -Name LocalFeed -SourceLocation $tmpFeedPath -PublishLocation $tmpFeedPath -InstallationPolicy Trusted
        try
        {
            # publish to temp feed
            $packageName = "$moduleName.$version.nupkg"
            $package = (Join-Path $tmpFeedPath $packageName)
            Write-Verbose "Publish Module $package"
            Publish-Module -Path $deployPath -Repository LocalFeed -Force -ErrorAction Ignore
            if (!(Test-Path $package))
            {
                Throw "Nuget package $package not created"
            }

            # publish package from tmp/local feed to PS feed
            Write-Verbose "Push package $packageName in $tmpFeedPath"
            Push-Location $tmpFeedPath
            try 
            {
                nuget push $packageName -source $packageSource -Apikey Az -NonInteractive
                if ($LastExitCode -ne 0)
                {
                    Throw "Error pushing nuget package $packageName to feed $packageSource ($packageFeedUrl)"
                }
            }
            finally
            {
                Pop-Location    
            }
        }
        finally
        {
            Unregister-PSRepository -Name LocalFeed -ErrorAction Ignore
        }
    }
    finally 
    {
        Remove-Item -Path $tmpFeedPath -Force -Recurse
    } 
}
<#
.SYNOPSIS
Registers a package source from AzureDevOps Feed / Artifacts
 
.DESCRIPTION
Registers a package source from AzureDevOps Feed /Artifacts. If already found removes reference first.
 
.PARAMETER Name
Name of package source
 
.PARAMETER Url
Url of package feed
 
.PARAMETER Credential
Credentials to access feed
 
.Example
Register-AzureDevOpsPackageSource -Name myFeed -Url https://pkgs.dev.azure.com/myCompany/_packaging/myFeed/nuget/v2
#>

function Register-AzureDevOpsPackageSource([Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$Name, [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$Url, [System.Management.Automation.PSCredential]$Credential)
{
    Write-Verbose "Register-AzureDevOpsPackageSource $Name"

    if ($Credential)
    {
        Write-Verbose "Performing Credential check..."
        try 
        {
            Invoke-RestMethod -Uri $Url -Credential $Credential | Out-Null # check for access to artifacts with credential
        }
        catch
        {
            Throw "Register-AzureDevOpsPackageSource error for $Url : $($_.Exception.Message)"
        }
    }
    
    try
    {
        if (Get-PSRepository -Name $Name -ErrorAction Ignore) { Unregister-PSRepository -Name $Name }
        Register-PSRepository -Name $Name -SourceLocation $Url -InstallationPolicy Trusted -Credential $Credential
    }
    catch
    {
        if ($env:windir)
        {
            if ($_.Exception.Message -eq "The property 'Name' cannot be found on this object. Verify that the property exists.")
            {                
                Write-Warning "Maybe invalid PSRepositories.xml detected in 'C:\Users\$($env:USERNAME)\AppData\Local\Microsoft\Windows\PowerShell\PowerShellGet', check file for correctness"
            }
        }
        Write-Host $_
        Throw
    }
}
<#
.SYNOPSIS
Get Azure Keyvault secrets and add them to token collection
 
.DESCRIPTION
Get secrets from Azure Keyvault and add them to token collection, use default logged-in account to Azure or try to get it from 'az cli'
 
.PARAMETER Vault
Name of the Azure KeyVault
 
.PARAMETER Tokens
Hashtable to add secrets to
 
.PARAMETER SubscriptionId
Azure Subscription ID
 
.Example
$Tokens = @{}
Add-TokensFromAzureKeyVault -Vault 'MyVaultName' -Tokens $Tokens -SubscriptionId 'mySubscriptionId'
#>

function Add-TokensFromAzureKeyVault([Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$Vault, [Parameter(Mandatory = $true)]$Tokens, $SubscriptionId)
{
    Write-Verbose "Add-TokensFromAzureKeyVault"
    Write-Verbose " Vault: $Vault"
    Write-Verbose " SubscriptionId: $SubscriptionId"

    function Add-Secret($Name, $Value)
    {
        if (!$Tokens.ContainsKey($Name))
        {
            Write-Host "Adding secret $Name : ******* to Token Store"
            $Tokens.Add($Name, $Value)
        }
    }

    Connect-ToAzure

    if ($SubscriptionId)
    {
        Select-AzureDefaultSubscription -SubscriptionId $SubscriptionId
    }

    $warning = (Get-Item Env:\SuppressAzurePowerShellBreakingChangeWarnings -ErrorAction Ignore) -eq 'true'
    Set-Item Env:\SuppressAzurePowerShellBreakingChangeWarnings "true"
    try
    {
        try
        {
            $secrets = Get-AzKeyVaultSecret -VaultName $Vault
            foreach ($secret in $secrets)
            {
                $s = Get-AzKeyVaultSecret -VaultName $Vault -Name $secret.Name -ErrorAction Ignore
                if ($s)
                {
                    #$pass = $s.SecretValue | ConvertFrom-SecureString -AsPlainText
                    $cred = New-Object System.Management.Automation.PSCredential($secret.Name, $s.SecretValue)
                    Add-Secret $secret.Name $cred
                }
            }
        }   
        catch
        {
            # Generic KeyVaultErrorException
            if ($_.Exception.Message.Contains("Operation returned an invalid status code 'Forbidden'"))
            {
                Write-Warning "Check if your service principal '$(([Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile).DefaultContext.Account.Id)' has Secret and Certificate Permissions (List,Get) for this KeyVault '$Vault'. Check the Vaults Access Policies"
            }
            Throw
        }
    }
    finally
    {
        Set-Item Env:\SuppressAzurePowerShellBreakingChangeWarnings $warning
    }
}
<#
.SYNOPSIS
Assert if logged-in to Azure with powershell Az modules
 
.DESCRIPTION
Assert if logged-in to Azure with powershell Az modules
 
.Example
Assert-AzureConnected
#>

function Assert-AzureConnected
{
    Initialize-AzureModules

    $azProfile = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile
    if (-not $azProfile.Accounts.Count)
    {
        Throw "Powershell Az error: Ensure you are logged in."
    }
}
<#
.SYNOPSIS
Connect to Azure with Powershell Az modules
 
.DESCRIPTION
Connect to Azure with Powershell Az modules, use 'az cli' as fallback to connect
 
.PARAMETER Force
Always re-authenticated when used
 
.Example
Connect-ToAzure
#>

function Connect-ToAzure([Switch]$Force)
{
    Write-Verbose "Connect-ToAzure"

    # check already logged-in to Azure
    if (!(Test-AzureConnected) -or $Force.IsPresent)
    {
        # try to find logged-in user via az cli if installed
        Write-Verbose 'Connect to azure with Azure Cli configuration'
        try
        {
            $token = $(az account get-access-token --query accessToken --output tsv)
            $id = $(az account show --query user.name --output tsv)
            if ($token -and $id)
            {
                Connect-AzAccount -AccessToken $token -AccountId $id -Scope Process
            }
        }                
        catch
        {
            # use default, already connected user in this session
        }
    }

    Assert-AzureConnected

    $azProfile = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile
    Write-Verbose "Az Account: $($azProfile.DefaultContext.Account.Id)"
    Write-Verbose "Az Subscription: $($azProfile.DefaultContext.Subscription.Name) - $($azProfile.DefaultContext.Subscription.Id)"
}
<#
.SYNOPSIS
Initializes (install or import) the Azure Az modules into current Powershell session
 
.DESCRIPTION
Initializes (install or import) the Azure Az modules into current Powershell session
 
.Example
Initialize-AzureModules
#>

function Initialize-AzureModules
{
    if ($Global:AzureInitialized) { return }

    if ($null -eq (Get-Module -ListAvailable 'Az'))
    {
        Write-Host "Installing Az modules, can take some time."
        Install-Module -Name Az -AllowClobber -Scope CurrentUser -Repository PSGallery -Force
    }
    else
    {
        if (!(Get-Module -Name Az))
        {
            Import-Module Az -Scope local -Force
        }
    }    
    if ($null -eq (Get-Module -ListAvailable 'Az.Accounts'))
    {
        Install-Module -Name Az.Accounts -AllowClobber -Scope CurrentUser -Repository PSGallery -Force
    }
    else
    {
        if (!(Get-Module -Name Az.Accounts))
        {
            Import-Module Az.Accounts -Scope local -Force
        }    
    }    
    if ($null -eq (Get-Module -ListAvailable 'Az.KeyVault'))
    {
        Install-Module -Name Az.KeyVault -AllowClobber -Scope CurrentUser -Repository PSGallery -Force
    }
    else
    {
        if (!(Get-Module -Name Az.KeyVault))
        {
            Import-Module Az.KeyVault -Scope local -Force
        }    
    }    
    $Global:AzureInitialized = $true
}

$Global:AzureInitialized = $false
<#
.SYNOPSIS
Select the Azure default subscription
 
.DESCRIPTION
Select the Azure default subscription
 
.PARAMETER SubscriptionId
The Azure subscription Id
 
.Example
Select-AzureDefaultSubscription -SubscriptionId 'myid'
#>

function Select-AzureDefaultSubscription([Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$SubscriptionId)
{
    Assert-AzureConnected

    $ctxList = Get-AzContext -ListAvailable
    foreach ($ctx in $ctxList)
    {
        if ($ctx.Subscription.Id -eq $SubscriptionId)
        {
            Write-Verbose "Select context: $($ctx.Name)"
            Select-AzContext -Name $ctx.Name
            return
        }
    }
    Throw "Azure subscription '$SubscriptionId' not found"
}
<#
.SYNOPSIS
Test if logged-in to Azure with powershell Az modules
 
.DESCRIPTION
Test if logged-in to Azure with powershell Az modules
 
.Example
Test-AzureConnected
#>

function Test-AzureConnected
{
    Initialize-AzureModules

    try
    {  
        $azProfile = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile
        return !(-not $azProfile.Accounts.Count)
    }
    catch
    {
        return $false
    }
}
<#
.SYNOPSIS
Get tokens from config repository and add them to token collection
 
.DESCRIPTION
Get tokens from xml config repository and add them to token collection
 
.PARAMETER ConfigPath
Root path of the xml config files
 
.PARAMETER Tokens
Hashtable to add tokens to
 
.PARAMETER Env
Token environment filter, filter the tokens by environent like local, develop, test etc...
 
.Example
$Tokens = @{}
Add-TokensFromConfig -ConfigPath "$PSScriptRoot/config" -Tokens $Tokens -Env 'local'
#>

function Add-TokensFromConfig([Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$ConfigPath, [Parameter(Mandatory = $true)]$Tokens, [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$Env, $Module)
{
    function Add-Var($Nodes, $NameProp = 'name', $ValueProp = 'value', $Prefix)
    {
        foreach ($node in $Nodes)
        {
            $name = ''
            $value = $null

            if (Test-PSProperty $node $NameProp)
            {
                $name = $node."$NameProp"
            }

            if (Test-PSProperty $node $ValueProp)
            {
                $value = Get-PSPropertyValue $node "$ValueProp"
                if ($value -and $value.StartsWith('$'))
                {
                    $value = Invoke-Expression "Write-Output `"$($value)`""
                }    
            }

            $pre = $Prefix

            if ($node.LocalName -eq 'node')
            {
                if ($node.ParentNode.ParentNode.name -ne $Env)
                {
                    continue
                }
            }
            elseif ($node.LocalName -eq 'system-user')
            {
                if ($node.ParentNode.LocalName -eq 'application')
                {
                    $name = ''
                    $pre = "$Prefix$($node.ParentNode.name)"
                }
            }

            if ($pre)
            {
                $kn = "$pre$name"
                Write-Host "Adding variable $kn : $value to Token Store"
                if (!$Tokens.ContainsKey($kn))
                {
                    $Tokens.Add($kn, $value)
                }
            }
            else
            {
                if (!$Tokens.ContainsKey($name))
                {
                    Write-Host "Adding variable $name : $value to Token Store"
                    $Tokens.Add($name, $value)
                }
            }
        }
    }

    function Add-Modules($Nodes, $Module)
    {
        foreach ($node in $Nodes)
        {
            if ($Module -and ($node.name -ne $Module))
            {
                continue
            }

            Write-Host "Adding module $($node.name) to Token Store"
            $Tokens.Add("module-$($node.name)", $node.name)
            $Tokens.Add("module-$($node.name)-role", (Get-PSPropertyValue $node role))
            $Tokens.Add("module-$($node.name)-depends", (Get-PSPropertyValue $node depends))
            $Tokens.Add("module-$($node.name)-folder", (Get-PSPropertyValue $node folder))
            $nodeApps = $node.SelectNodes(".//application")
            foreach ($nodeApp in $NodeApps)
            {
                Write-Host "Adding module $($node.name) application $($nodeApp.name) to Token Store"
                $Tokens.Add("module-$($node.name)-application-$($nodeApp.name)", $nodeApp.name)
                $Tokens.Add("module-$($node.name)-application-$($nodeApp.name)-type", (Get-PSPropertyValue $nodeApp type))
                $Tokens.Add("module-$($node.name)-application-$($nodeApp.name)-role", (Get-PSPropertyValue $nodeApp role))
                $Tokens.Add("module-$($node.name)-application-$($nodeApp.name)-service", (Get-PSPropertyValue $nodeApp service))
                $Tokens.Add("module-$($node.name)-application-$($nodeApp.name)-exe", (Get-PSPropertyValue $nodeApp exe))
                $Tokens.Add("module-$($node.name)-application-$($nodeApp.name)-dotnet-version", (Get-PSPropertyValue $nodeApp 'dotnet-version'))
                if (!$Tokens.ContainsKey("application-$($nodeApp.name)"))
                {
                    $Tokens.Add("application-$($nodeApp.name)", $($node.name))
                }
                else
                {
                    Write-Warning "Duplicate application name '$("application-$($nodeApp.name)")' found"
                }
            }
        }
    }

    $modules = @()
    Get-ChildItem "$ConfigPath\*.xml" -Recurse | ForEach-Object {
        $doc = [xml] (Get-Content $_.Fullname)
        $nodes = $doc.SelectNodes("//variable[@environment='$Env' or not(@environment)]")
        Add-Var $nodes
        $nodes = $doc.SelectNodes("//node")
        if ($nodes.Count -gt 0)
        {
            Add-Var $nodes -NameProp 'role' -ValueProp 'name' -Prefix 'node-'
        }
        $nodes = $doc.SelectNodes("//service[@environment='$Env' or not(@environment)]")
        if ($nodes.Count -gt 0)
        {
            Add-Var $nodes -Prefix 'service-'
            Add-Var $nodes -Prefix 'service-cert-hash-' -ValueProp 'cert-hash'
            Add-Var $nodes -Prefix 'service-type-' -ValueProp 'type'
            Add-Var $nodes -Prefix 'service-healthcheck-' -ValueProp 'healthcheck'
            Add-Var $nodes -Prefix 'service-healthcheck-type-' -ValueProp 'healthcheck-type'
            Add-Var $nodes -Prefix 'service-healthcheck-interval-' -ValueProp 'healthcheck-interval'
        }
        $nodes = $doc.SelectNodes("//system-user[@environment='$Env' or not(@environment)]")
        if ($nodes.Count -gt 0)
        {
            Add-Var $nodes -NameProp 'system-user' -ValueProp 'name' -Prefix 'system-user-'
        }
        $nodes = $doc.SelectNodes("//module")
        if ($nodes.Count -gt 0)
        {
            Add-Modules -Nodes $nodes -Module $Module
            $nodes | ForEach-Object { $modules += $_.name }
        }
        $envNode = $doc.SelectSingleNode("//environment[@name='$Env']")
        if ($envNode)
        {
            $Tokens.Add('env-name', $envNode.'name')
            $Tokens.Add('env-group', (Get-PSPropertyValue $envNode 'group'))
            $Tokens.Add('env-name-short', (Get-PSPropertyValue $envNode 'name-short'))
            $Tokens.Add('env-name-suffix', (Get-PSPropertyValue $envNode 'name-suffix'))
            $Tokens.Add('env-type', (Get-PSPropertyValue $envNode 'type'))
            $Tokens.Add('env-active', (Get-PSPropertyValue $envNode 'active'))
            $Tokens.Add('env-domain', (Get-PSPropertyValue $envNode 'domain'))
            $Tokens.Add('env-domain-full', (Get-PSPropertyValue $envNode 'domain-full'))
            $Tokens.Add('env-domain-description', (Get-PSPropertyValue $envNode 'description'))
            $Tokens.Add('env-domain-owner', (Get-PSPropertyValue $envNode 'owner'))
            $Tokens.Add('env-domain-notes', (Get-PSPropertyValue $envNode 'notes'))
            $Tokens.Add('env-ps-remote-user', (Get-PSPropertyValue $envNode 'ps-remote-user'))
            $Tokens.Add('env-subscription-id', (Get-PSPropertyValue $envNode 'subscription-id'))
            $Tokens.Add('env-vault', (Get-PSPropertyValue $envNode 'vault'))
        }
    }
    $Tokens.Add('modules', $modules)
}
<#
.SYNOPSIS
Convert the tokens in file to their actual values
 
.DESCRIPTION
Convert the tokens in file to their actual values
 
.PARAMETER FileName
Name of the file to convert
 
.PARAMETER PrefixToken
Token prefix
 
.PARAMETER SuffixToken
Token suffix
 
.PARAMETER DestFileName
File name of converted file
 
.PARAMETER ShowTokensUsed
Switch to echo tokens replaced
 
.PARAMETER SecondPass
Switch to signal that same file is used in multiple conversions
 
.PARAMETER Tokens
Hashtable to add tokens to
 
.Example
$Tokens = @{}
Add-TokensFromConfig -ConfigPath "$PSScriptRoot/config" -Tokens $Tokens -Env 'local'
 
Get-ChildItem .\$ConfigLocation\*.* | ForEach-Object {
    $destFile = Join-Path $ArtifactsLocation $_.Name
    Convert-TokensInFile -FileName $_.Fullname -DestFileName $destFile -Tokens $Tokens
}
 
#>

function Convert-TokensInFile([Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$FileName, $PrefixToken = '__', $SuffixToken = '__', $DestFileName, [Switch]$ShowTokensUsed, [Switch]$SecondPass, $Tokens)
{
    if (!$DestFileName) { $DestFileName = $FileName }

    if (Test-Path $FileName)
    {
        $regex = [regex] "${PrefixToken}((?:(?!${SuffixToken}).)*)${SuffixToken}"
        $content = [System.IO.File]::ReadAllText($FileName);
        if (!$Tokens) 
        {
            $Tokens = @{}
        }
        $script:cnt = 0
        $callback = {
            param([System.Text.RegularExpressions.Match] $Match)
            $value = $Match.Groups[1].Value

            # check env first
            $newTokenValue = [Environment]::GetEnvironmentVariable($value)
            if ($null -eq $newTokenValue)
            {
                if ($Tokens.ContainsKey($value))
                {
                    $v = $Tokens[$value]
                    if ($null -eq $v)
                    {
                        $v = ''
                    }
                    if ($v -is [PSCredential])
                    {
                        $newTokenValue = $v.GetNetworkCredential().Password
                    }
                    else
                    {
                        $newTokenValue = $v.ToString()

                        # detect expression in variable
                        if ($newTokenValue.StartsWith('$'))
                        {
                            $newTokenValue = Invoke-Expression "Write-Output `"$($newTokenValue)`""
                        }                    
                    }
                }
            }
            if ($null -eq $newTokenValue)
            {
                $script:HasReplaceVarErrors = $true;
                Write-Warning "Token not found in replace: '$value'"
                return ""
            }

            $script:cnt++
            if ($ShowTokensUsed.IsPresent -or ($Global:VerbosePreference -eq 'Continue'))
            {
                Write-Host "Replacing token '$value' with '$newTokenValue'"
            }
            return $newTokenValue
        }

        $content = $regex.Replace($content, $callback)

        New-Item -ItemType Directory (Split-Path -Path $DestFileName) -Force -ErrorAction Ignore | Out-Null
        Set-Content -Path $DestFileName -Value $content -Encoding UTF8

        if ( ($Global:VerbosePreference -eq 'Continue') -or ( ($script:cnt -gt 0) -and $ShowTokensUsed.IsPresent ) )
        {
            if ($SecondPass.IsPresent -and ($script:cnt -eq 0) )
            {
                Write-Host "$($script:cnt) Tokens replaced in '$FileName'"
            }
            else
            {
                Write-Host "$($script:cnt) Tokens replaced in '$FileName'"
            }
        }
    }
    else
    {
        Throw "Convert-TokensInFile error: file not found '$FileName'"
    }
}
<#
.SYNOPSIS
Send a msg to Slack Channel
 
.DESCRIPTION
Send a msg to Slack Channel via the Incoming Webhook integration App. See in slack: Browse Apps / Custom Integrations / Incoming WebHook or see notes below
 
.PARAMETER Msg
The message to send
 
.PARAMETER Channel
The Channel to send to
 
.PARAMETER Username
The user of the message
 
.PARAMETER IconUrl
The url of the icon to display in the message, otherwise use emoji
 
.PARAMETER Emoji
The emoji to use like ':ghost:' or ':bom:' see slack documentation for more Emoji. Use IconUrl for custom emoji
 
.PARAMETER AsUser
Send msg as this User
 
.PARAMETER Token
The Incoming WebHook Token
 
.PARAMETER Attachments
The json structured attachment. See Slack documentation
 
like
    $attachment = @{
        fallback = $msg
        pretext = "Sample message: <http://url_to_task|Test out Slack message attachments>"
        color = "danger" # good, warning
        fields = @(
            @{
              title = "[Alert]]"
              value = "This is much easier than I thought it would be. <https://www.sample.com/logo.png>|Logo"
              short = "false"
             }
        )
    }
 
 
.Example
Send-ToSlack -m 'Hello' -c 'TestChannel' -u 'me' -e ':bomb:' -t 'mytoken...'
 
.NOTES
 
for documentation about configuring Slack/Acquire token see
https://api.slack.com/messaging/webhooks
or https://api.slack.com/legacy/custom-integrations
#>


function Send-ToSlack ([alias('m')]$Msg, [alias('c')]$Channel, [alias('u')]$Username, [alias('iu')]$IconUrl, [alias('e')]$Emoji, [alias('a')][Switch]$AsUser, [alias('t')]$Token, $Attachments)
{
    $slackUri = "https://hooks.slack.com/services/$Token"

    if ($Channel -and !($Channel.StartsWith('@'))) { $channel = "#$Channel" } else { $channel = $Channel }

    $body = @{
        channel    = $channel
        username   = $Username
        text       = $Msg
        icon_url   = $IconUrl
        icon_emoji = $Emoji
    }

    if ($null -eq $Emoji) { $body.Remove('icon_emoji') }
    if ($null -eq $IconUrl ) { $body.Remove('icon_url') }

    if ($Attachments)
    {
        [void]$body.Add('attachments', $Attachments)
    }

    try
    {
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        $response = Invoke-RestMethod -Uri $slackUri -Method Post -Body ($body | ConvertTo-Json -Compress -Depth 10) -ContentType 'application/json'
    }
    catch
    {
        Throw "Send-ToSlack error: $($_.Exception.Message)"
    }

    if ($response -ne 'ok')
    {
        Throw "Send-ToSlack error: $($response)"
    }
}
<#
.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
{
    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
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
{
    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
}
$tdUtilModule = 'TD.Util'
$manifest = Test-ModuleManifest -Path (Join-Path (Split-Path $MyInvocation.MyCommand.Path) "$tdUtilModule.psd1") -WarningAction SilentlyContinue
Write-Host "$tdUtilModule Version $($manifest.Version.ToString()) by $($manifest.Author)"
Write-Host "Proudly created in Schiedam (NLD), $($manifest.Copyright)";


# SIG # Begin signature block
# MIIiEgYJKoZIhvcNAQcCoIIiAzCCIf8CAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB
# gjcCAQSgWzBZMDQGCisGAQQBgjcCAR4wJgIDAQAABBAfzDtgWUsITrck0sYpfvNR
# AgEAAgEAAgEAAgEAAgEAMCEwCQYFKw4DAhoFAAQUHU4Y7qeQHCqASkFnI6RKurJt
# nf+gghw/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
# KwYBBAGCNwIBFTAjBgkqhkiG9w0BCQQxFgQUnB+et4Ifwg88bgA+Ec5sWAg2Rc0w
# DQYJKoZIhvcNAQEBBQAEggIAaE/f8YYmfzAa2hfghjNe9N1QcDf+IcDtfHZd7A0K
# fMaH0oh07xw9QDWMdfxrH3p7gm+Aaoj05veuBIv1riQXRuMErb6HlO5pMmtLGXfY
# SLVwrwJTPODU2oCYAqd7HAjuJRNyH/FSLYfvDcPdEdJgYJL6NQz5uOCKRh92PdAQ
# IDKFL3YCpF30YZYkytVoosDNnVte9HajK06zJFk5kVI9+7l7RBbULSAcj0eFKyw+
# nGevuROgF/3fS/We7Del9xtJaNp+Pk/xjvo/mRdrCVzkUo0VC4dP2+ZkS1JlkKkY
# My4wAlCbvzpQtjF70ioMBQ6t5nh++RDFNVvbyFz7HL52P5h4+5Ue1poWVhTsCz/2
# QxGswoTceM8IhZ0aWzkYSPCTT//Kz7wk/UsY9MZQU/ouYMZNfwFuW46paUozQ4p0
# 8zTOeBt7YTJPgxzgDM461ID3DpNQfNw8tZ1OGJsvj+aDD4Ry270yu5V1MVpEhfID
# PrDoeqQhmGHqIfTXwSpmrm3mwOWpC7A9gURRywGJCD6jIryk3dwHav36XPH0zE3t
# Nt02tUsy7ic0RoOZ0c9HAg8toypJ4OHyCopodYP5UvGFmw66kEZIHVmCu3g1kOuL
# Vn5nGANZr4NTS0LwcMI1pey8C4Agp7dA57qZSqiRreF0muiXOx/kAxc2Fs+sQF1d
# AhShggIwMIICLAYJKoZIhvcNAQkGMYICHTCCAhkCAQEwgYYwcjELMAkGA1UEBhMC
# VVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0
# LmNvbTExMC8GA1UEAxMoRGlnaUNlcnQgU0hBMiBBc3N1cmVkIElEIFRpbWVzdGFt
# cGluZyBDQQIQDUJK4L46iP9gQCHOFADw3TANBglghkgBZQMEAgEFAKBpMBgGCSqG
# SIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTIxMDkyMjE3NDgy
# NlowLwYJKoZIhvcNAQkEMSIEIHWpfLORuJywIQGcRHDI3IbmboLLW7s2ovZHhigf
# ssp0MA0GCSqGSIb3DQEBAQUABIIBAGxVwGaSxa5zyNJHvgqfULimrpRr/LWlIeLW
# g7jJ8bTUdRU3MEeamBJu5r8UIzME/kxMoeHVtsHxLlCuYb7+kMoEEfUIJRfQDgwi
# kHr4lwg85xx0Q3uc5kUB4gHrjefMdEzKxA7zXLfpK9fY9SrR8S7LzqTMP+Uz/UAS
# ClAj/hD4IkyoOj7Hhde1B27kHNwXKOqhCZMrQB2knIIW4ohnn4RuYHBGGEuqHDEu
# xBXoOqpCTB1oL5dM69UGeAKtLbVVLY77Oo82KXri3LGkSwXTMuwgcals7v+3svHW
# kCQnIIb6tLZ78D+KirOtzu7uxRKmWPJzonYWUqjEEbP5F3S+b9M=
# SIG # End signature block