M365DSCTools.psm1

#Region './Private/Convert-M365WorkloadName.ps1' -1

$DSC_Workloads = @{
    'AzureAD'            = 'AAD'
    'AzureDevOps'        = 'ADO'
    'Azure'              = 'Azure'
    'Defender'           = 'Defender'
    'Exchange'           = 'EXO'
    'Fabric'             = 'Fabric'
    'Intune'             = 'Intune'
    'Office365'          = 'O365'
    'OneDrive'           = 'OD'
    'Planner'            = 'Planner'
    'PowerPlatform'      = 'PP'
    'SecurityCompliance' = 'SC'
    'Sentinel'           = 'Sentinel'
    'SharePoint'         = 'SPO'
    'Teams'              = 'Teams'
}

function Convert-M365WorkloadName
{
<#
 .Synopsis
  Converts a M365 workload name to a short name

 .Description
  This function converts a M365 workload name to a short name.
  For example AzureAD to AAD.

 .Parameter Name
  The Name that needs to be converted

 .Example
   Convert-M365WorkloadName -Name 'AzureAD'
#>

    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $True)]
        [string]
        $Name
    )

    process
    {
        if ($DSC_Workloads.keys -contains $Name)
        {
            return ($DSC_Workloads[$Name])
        }
    }
}
#EndRegion './Private/Convert-M365WorkloadName.ps1' 51
#Region './Private/Get-RefNodeExampleData.ps1' -1

function Get-RefNodeExampleData
{
<#
 .Synopsis
  Retrieves the details of the requested item from the referenced example data

 .Description
  This function retrieves the details of the item from the referenced example data.

 .Parameter Node
  The Leadnode that needs to be retrieved

 .Parameter ReferenceObject
  The ReferenceObject that contains the example data

 .Example
   Get-RefNodeExampleData -Node $leafnode -ReferenceObject $exampleData
#>

    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $True)]
        $Node,

        [Parameter(Mandatory = $true)]
        $ReferenceObject
    )

    process
    {
        $ExecuteCommand = '$ReferenceObject.{0}' -f $($Node.Path -Replace '\[\d*\](?=(\.|$))')
        $Result = $($ExecutionContext.InvokeCommand.InvokeScript('{0}' -f $ExecuteCommand ))

        if ($result)
        {
            if ($Node.GetType().FullName -eq 'PSLeafNode')
            {
                $ArrayResult = $Result.split('|').foreach{ $_.trim() }
                $LeafNode = @{}
                if ( $ArrayResult[0])
                {
                    $LeafNode.add('Type', $ArrayResult[0])
                }
                if ( $ArrayResult[1])
                {
                    $LeafNode.add('Required', $ArrayResult[1])
                }
                if ( $ArrayResult[2])
                {
                    $LeafNode.add('Description', $ArrayResult[2])
                }
                if ( $ArrayResult[3])
                {
                    $LeafNode.add('ValidateSet', "'" + ( $ArrayResult[3] -Replace '\s*\/\s*', "', '") + "'"  )
                }
                return $LeafNode
            }
            else
            {
                return $Result
            }
            return $null
        }
    }
}
#EndRegion './Private/Get-RefNodeExampleData.ps1' 68
#Region './Private/Invoke-APRestApi.ps1' -1

function Invoke-APRestApi
{
    <#
.SYNOPSIS
    Executes an API call to Azure DevOps.

.DESCRIPTION
    This function executes an API call to Azure DevOps using the provided method, headers, and body.

.PARAMETER Uri
    The URI to the Azure DevOps API.

.PARAMETER Method
    The HTTP method to be used for the API call.

.PARAMETER Headers
    The headers to be used for the API call.

.PARAMETER Body
    The body to be used for the API call.

.EXAMPLE
    $headers = New-Object 'System.Collections.Generic.Dictionary[[String],[String]]'
    $authToken = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$($PAT)"))
    $headers.Add("Authorization", ("Basic {0}" -f $authToken))

    $devOpsOrgUrl = 'https://dev.azure.com/{0}' -f $Organization
    $devOpsProjectUrl = '{0}/{1}' -f $devOpsOrgUrl, $Project
    $apiVersionString = "api-version=$ApiVersion"
    $envUrl = '{0}/_apis/distributedtask/environments?{1}' -f $devOpsProjectUrl, $apiVersionString
    $currentEnvironments = Invoke-APRestApi -Uri $envUrl -Method 'GET' -Headers $headers
#>

    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Uri,

        [Parameter(Mandatory = $true)]
        [ValidateSet('GET', 'POST', 'PATCH')]
        [System.String]
        $Method,

        [Parameter()]
        [System.Collections.Generic.Dictionary[[String], [String]]]
        $Headers,

        [Parameter()]
        [System.String]
        $Body

    )

    try
    {
        $params = @{
            Uri         = $Uri
            Method      = $Method
            ContentType = 'application/json;charset=utf-8'
        }

        if ($PSBoundParameters.ContainsKey('Headers'))
        {
            $params.Headers = $Headers
        }

        if ($PSBoundParameters.ContainsKey('Body'))
        {
            $params.Body = $Body
        }

        $result = Invoke-RestMethod @params
        return $result
    }
    catch
    {
        Write-Log -Object "[ERROR] Error occurred when connecting to Azure DevOps API: $($_.Exception.Message)" -Failure
        throw
    }
}
#EndRegion './Private/Invoke-APRestApi.ps1' 82
#Region './Private/Merge-Array.ps1' -1

function Merge-Array
{
<#
 .Synopsis
  Merges two arrays into one new array

 .Description
  This function merges two arrays into one new one.
  The values in the Merge array are overwriting any existing
  values in the Reference array.

 .Parameter Reference
  The Reference array that is used as the starting point

 .Parameter Merge
  The Merge array that will be merged into the Reference array.

 .Example
   # Merges the Merge array into the Reference array
   $reference = @(1,2,3,4,5,6,7,8,9,10)
   $merge = @(11,12,13,14,15,16,17,18,19,20)

   Merge-Array -Reference $reference -Merge $merge
#>

    param
    (
        [Parameter(Mandatory = $true)]
        [System.Array]
        $Reference,

        [Parameter(Mandatory = $true)]
        [System.Array]
        $Merge
    )

    $script:level++
    Write-LogEntry -Message "Processing array: $($Merge.Count) items" -Level $script:level

    foreach ($item in $Merge)
    {
        switch ($item.GetType().FullName)
        {
            'System.Collections.Hashtable'
            {
                $refItem = $Reference | Where-Object -FilterScript {
                    ($_.ContainsKey('UniqueId') -and $_.UniqueId -eq $item.UniqueId) -or `
                    ($_.ContainsKey('Identity') -and $_.Identity -eq $item.Identity) -or `
                    ($_.ContainsKey('Id') -and $_.Id -eq $item.Id) -or `
                    ($_.ContainsKey('SettingDefinitionId') -and $_.SettingDefinitionId -eq $item.SettingDefinitionId) -or `
                    ($_.ContainsKey('NodeName') -and $_.NodeName -eq $item.NodeName)
                }

                if ($null -eq $refItem)
                {
                    # Add item
                    Write-LogEntry -Message " Hashtable doesn't exist in Reference. Adding." -Level $script:level
                    $Reference += $item
                }
                else
                {
                    # Compare item
                    $script:level++
                    Write-LogEntry -Message 'Hashtable exists in Reference. Merging.' -Level $script:level
                    $refItem = Merge-Hashtable -Reference $refItem -Merge $item
                    $script:level--
                }
            }
            Default
            {
                if ($Reference -notcontains $item)
                {
                    $Reference += $item
                }
            }
        }
    }
    $script:level--

    return $Reference
}
#EndRegion './Private/Merge-Array.ps1' 81
#Region './Private/Merge-Hashtable.ps1' -1

function Merge-Hashtable
{
<#
 .Synopsis
  Merges two hashtables

 .Description
  This function merges two hashtables into one new one.
  The values in the Merge hashtable are overwriting any existing
  values in the Reference hashtable.

 .Parameter Reference
  The Reference hashtable that is used as the starting point

 .Parameter Merge
  The Merge hashtable that will be merged into the Reference hashtable.

 .Example
   # Merges the Merge file into the Reference file
   $reference = @{
         'Key1' = 'Value1'
         'Key2' = 'Value2'
         'Key3' = @{
              'Key3.1' = 'Value3.1'
              'Key3.2' = 'Value3.2'
         }
   }
   $merge = @{
         'Key1' = 'ValueNew'
         'Key3' = @{
              'Key3.2' = 'ValueNew'
              'Key3.3' = 'Value3.3'
         }
   }

   Merge-Hashtable -Reference $reference -Merge $merge
#>

    param
    (
        [Parameter(Mandatory = $true)]
        [System.Collections.Hashtable]
        $Reference,

        [Parameter(Mandatory = $true)]
        [System.Collections.Hashtable]
        $Merge
    )

    $script:level++
    $items = $Merge.GetEnumerator()
    foreach ($item in $items)
    {
        $itemKey = $item.Key
        $itemData = $item.Value
        Write-LogEntry -Message "Processing: $itemKey" -Level $script:level
        switch ($itemData.GetType().FullName)
        {
            'System.Collections.Hashtable'
            {
                # Check if item exists in the reference
                if ($Reference.ContainsKey($itemKey) -eq $false)
                {
                    # item does not exist, add item
                    Write-LogEntry -Message ' Key missing in Merge object, adding key' -Level $script:level
                    $Reference.Add($itemKey, $itemData)
                }
                else
                {
                    $script:level++
                    Write-LogEntry -Message 'Key exists in Merge object, checking child items' -Level $script:level
                    $Reference.$itemKey = Merge-Hashtable -Reference $Reference.$itemKey -Merge $itemData
                    $script:level--
                }
            }
            'System.Object[]'
            {
                if ($null -eq $Reference.$itemKey -or $Reference.$itemKey.Count -eq 0)
                {
                    $Reference.$itemKey = $itemData
                }
                else
                {
                    $Reference.$itemKey = [Array](Merge-Array -Reference $Reference.$itemKey -Merge $itemData)
                }
            }
            Default
            {
                if ($Reference.$itemKey -ne $itemData)
                {
                    $Reference.$itemKey = $itemData
                }
            }
        }
    }
    $script:level--

    return $Reference
}
#EndRegion './Private/Merge-Hashtable.ps1' 99
#Region './Private/Write-LogEntry.ps1' -1

<#
 .Synopsis
  Writes a log entry to the console, including a timestamp

 .Description
  This function writes a log entry to the console, including a
  timestamp of the current time.

 .Parameter Message
  The message that has to be written to the console.

 .Parameter Level
  The number of spaces the message has to be indented.

 .Example
  Write-LogEntry -Message 'This is a log entry'

 .Example
  Write-LogEntry -Message 'This is an indented log entry' -Level 1
#>

function Write-LogEntry
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Using Write-Host to force output to the screen instead of into the pipeline.')]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Message,

        [Parameter()]
        [System.Int32]
        $Level = 0
    )

    $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    $indentation = ' ' * $Level
    $output = '[{0}] - {1}{2}' -f $timestamp, $indentation, $Message

    Write-Host -Object $output
}
#EndRegion './Private/Write-LogEntry.ps1' 42
#Region './Public/Add-ModulesToBlobStorage.ps1' -1

function Add-ModulesToBlobStorage
{
    <#
.SYNOPSIS
    Downloads all Microsoft365DSC dependencies and uploads these to an Azure Blob Storage

.DESCRIPTION
    This function checks which dependencies the used version of Microsoft365DSC
    requires and downloads these from the PowerShell Gallery. The dependencies
    are then packaged into a zip file and uploaded to an Azure Blob Storage.

.PARAMETER ResourceGroupName
    The Azure Resource Group Name where the Storage Account is located

.PARAMETER StorageAccountName
    The name of the Storage Account where the zip file will be uploaded to

.PARAMETER ContainerName
    The name of the Container where the zip file will be uploaded to

.EXAMPLE
    Add-ModulesToBlobStorage -ResourceGroupName 'MyResourceGroup' -StorageAccountName 'MyStorageAccount' -ContainerName 'MyContainer'
#>

    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param
    (
        # [Parameter(Mandatory = $true)]
        # [System.String]
        # $SubscriptionName,

        [Parameter(Mandatory = $true)]
        [System.String]
        $ResourceGroupName,

        [Parameter(Mandatory = $true)]
        [System.String]
        $StorageAccountName,

        [Parameter(Mandatory = $true)]
        [System.String]
        $ContainerName
    )

    Write-Log -Object 'Upload Microsoft365DSC module dependencies to storage container'

    $isPowerShellCore = $PSVersionTable.PSEdition -eq 'Core'

    Write-Log -Object "Connecting to storage account '$StorageAccountName'"
    $storageAcc = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -Name $StorageAccountName

    Write-Log -Object 'Retrieving storage account context'
    $context = $storageAcc.Context

    Write-Log -Object 'Checking dependencies'
    $m365Module = Get-Module -Name Microsoft365DSC -ListAvailable | Sort-Object -Property Version -Descending | Select-Object -First 1
    $modulePath = Split-Path -Path $m365Module.Path -Parent

    $versionString = $m365Module.Version.ToString() -replace '\.', '_'

    $dependenciesPath = Join-Path -Path $modulePath -ChildPath 'Dependencies\Manifest.psd1'

    if (Test-Path -Path $dependenciesPath)
    {
        Write-Log -Object 'Downloading dependencies'

        $destination = Join-Path -Path $env:TEMP -ChildPath 'M365DSCModules'
        $savePath = Join-Path -Path $destination -ChildPath $m365Module.Version.ToString()
        if (Test-Path -Path $savePath)
        {
            Write-Log -Object "$savePath already exists. Removing!"
            Remove-Item -Path $savePath -Recurse -Confirm:$false
        }
        $null = New-Item -Path $savePath -ItemType 'Directory'

        Write-Log -Object ('Saving module {0} (v{1})' -f $m365Module.Name, $m365Module.Version.ToString())
        Save-Module -Name $m365Module.Name -RequiredVersion $m365Module.Version.ToString() -Path $savePath

        $data = Import-PowerShellDataFile -Path $dependenciesPath
        foreach ($dependency in $data.Dependencies)
        {
            if ($dependency.PowerShellCore -and -not $isPowerShellCore)
            {
                Write-Verbose -Message "Skipping module {$($dependency.ModuleName)} as it is not compatible with Windows PowerShell."
                continue
            }
            elseif ($dependency.PowerShellCore -eq $false -and $isPowerShellCore)
            {
                Write-Verbose -Message "Skipping module {$($dependency.ModuleName)} as it is not compatible with PowerShell Core."
                continue
            }

            Write-Log -Object ('Saving module {0} (v{1})' -f $dependency.ModuleName, $dependency.RequiredVersion)
            Save-Module -Name $dependency.ModuleName -RequiredVersion $dependency.RequiredVersion -Path $savePath
        }

        Write-Log -Object 'Packaging Zip file'
        $zipFileName = "M365DSCDependencies-$versionString.zip"
        $zipFilePath = Join-Path -Path $env:TEMP -ChildPath $zipFileName
        if ((Test-Path -Path $zipFilePath))
        {
            Write-Log -Object "$zipFileName already exist on disk. Removing!"
            Remove-Item -Path $zipFilePath -Confirm:$false
        }
        Compress-Archive -Path $savePath\* -DestinationPath $zipFilePath

        Write-Log -Object 'Uploading Zip file'
        $blobContent = Get-AzStorageBlob -Container $ContainerName -Context $context -Prefix $zipFileName
        if ($null -ne $blobContent)
        {
            Write-Log -Object "$zipFileName already exist in the Blob Storage. Removing!"
            $blobContent | Remove-AzStorageBlob
        }
        $null = Set-AzStorageBlobContent -Container $ContainerName -File $zipFilePath -Context $context -Force

        Write-Log -Object 'Removing temporary components'
        Remove-Item -Path $savePath -Recurse -Confirm:$false -Force
        Remove-Item -Path $zipFilePath -Confirm:$false
    }
    else
    {
        Write-Log -Object '[ERROR] Dependencies\Manifest.psd1 file not found' -Failure
        return $false
    }

    return $true
}
#EndRegion './Public/Add-ModulesToBlobStorage.ps1' 128
#Region './Public/Convert-M365DSCExportToPowerShellDataFile.ps1' -1

$DSC_ExcludeKeys = @(
    'ResourceInstanceName',
    'ResourceName',
    'ApplicationId',
    'CertificateThumbprint',
    'TenantId',
    'IsSingleInstance',
    'CIMInstance'
)

function Convert-M365DSCExportToPowerShellDataFile
{
<#
.SYNOPSIS
    Converts a Microsoft365DSC export into a PowerShell data file.

.DESCRIPTION
    This function converts a Microsoft365DSC export in .ps1 format into
    a PowerShell data file (.psd1) format that complies with the structure
    used in the M365DSC.CompositeResources module.

    It uses the function New-M365DSCReportFromConfiguration to convert the
    export into JSON before converting it into a PowerShell data file.

.PARAMETER Workload
    The Workload for which you want to convert the export.

.PARAMETER SourceFile
    The file which contains the Microsoft365DSC export.

.PARAMETER ResultFolder
    The folder to which the converted file is written to.

.EXAMPLE
    Convert-M365DSCExportToPowerShellDataFile `
        -Workload Office365 `
        -SourceFile '.\Exports\O365\O365.ps1' `
        -ResultFolder '.\Results'
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases', '', Scope = 'Function')]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [ValidateSet( 'AzureAD', 'Exchange', 'Intune', 'Office365', 'OneDrive', 'Planner', 'PowerPlatform', 'SecurityCompliance', 'SharePoint', 'Teams')]
        $Workload,

        [Parameter(Mandatory = $true, ValueFromPipeline = $True)]
        $SourceFile,

        [Parameter(Mandatory = $true)]
        $ResultFolder
    )

    begin
    {
        # Test if the ObjectGraphTools module is loaded and the class is available
        if (-not ([System.Management.Automation.PSTypeName]'PSNode').Type)
        {
            Import-Module ObjectGraphTools -Force
        }

        Class DSCConversion
        {
            [string]$Resource_Name
            [string]$Composite_Resource_Name
            [hashtable[]]$Resource_Objects
        }

        # Fix bug second run
        Set-M365DSCTelemetryOption -Enabled $false
    }

    process
    {
        $SourceFile_BaseName = (Get-Item $SourceFile).BaseName
        $Path_JsonReport = '{0}_M365DSCReport.json' -f $( Join-Path -Path $ResultFolder -ChildPath $SourceFile_BaseName)
        $Path_CompositeConfig = '{0}.psd1' -f $( Join-Path -Path $ResultFolder -ChildPath $SourceFile_BaseName)

        New-Item -ItemType Directory -Force -Path $ResultFolder | Out-Null

        '--- Create composite config for M365 DSC ---' | Write-Log
        'Workload : {0}' -f $Workload | Write-Log
        'SourceFile : {0}' -f $SourceFile | Write-Log
        'ResultFolder : {0}' -f $ResultFolder | Write-Log


        # Create M365DSCReport
        $OriginalProgressPreference = $Global:ProgressPreference
        $Global:ProgressPreference = 'SilentlyContinue'
        New-M365DSCReportFromConfiguration -Type JSON -ConfigurationPath $SourceFile -OutputPath $Path_JsonReport
        $Global:ProgressPreference = $OriginalProgressPreference

        # load M365DSCReport
        $Obj_Export = Get-Content $Path_JsonReport | ConvertFrom-Json

        # Load Example data from module M365DSC.CompositeResources
        $M365DSCCRModule = Get-Module -ListAvailable M365DSC.CompositeResources | Sort-Object -Property Version | Select-Object -Last 1
        $Obj_M365DataExample = Import-PSDataFile -Path (Join-Path -Path ($M365DSCCRModule.Path | Split-Path) -ChildPath 'M365ConfigurationDataExample.psd1').ToString()

        # Group Object
        $Obj_Export_Groups = $Obj_Export | Group-Object 'resourcename'
        'Found Grouped Items : {0}' -f $Obj_Export_Groups.count | Write-Log

        $Obj_Grouped = @(
            foreach ($Obj_Export_Group in $Obj_Export_Groups)
            {
                $Obj_Conversion = [DSCConversion]::new()
                $Obj_Conversion.Resource_Name = $Obj_Export_Group.name
                $Composite_Resource = $Obj_M365DataExample.NonNodeData.$Workload.GetEnumerator() | Where-Object {
                    $_.Name -match [regex]('^{0}[s]*$' -f ($Obj_Export_Group.Name `
                                -replace "^$($Workload | Convert-M365WorkLoadName )" `
                                -replace '(?<!y)$', '[s]*' `
                                -replace 'y$', '(y|ies)' `
                                -replace 'Policy', 'Policies' `
                                -replace 'Profile', 'Profiles'
                        ) ) }
                $Obj_Conversion.Composite_Resource_Name = $Composite_Resource.Name

                Foreach ($Group in $Obj_Export_Group.group)
                {
                    $Obj_Conversion.Resource_Objects += ($Group | Copy-ObjectGraph -MapAs hashtable )
                }
                #Filter
                if ($Obj_Conversion.Composite_Resource_Name)
                {
                    $Obj_Conversion
                }
            }
        )

        # Compose file
        $Obj_Result = @{NonNodeData = @{$Workload = @{} } }

        foreach ( $Collection in $Obj_Grouped )
        {
            $Obj_Result.NonNodeData.$workload += @{$Collection.Composite_Resource_Name = @() }
            foreach ($Resource in $Collection.Resource_Objects)
            {
                $Obj_Result.NonNodeData.$workload.($Collection.Composite_Resource_Name) += $Resource
            }
        }

        # Get All leaf nodes
        $InputNode = $Obj_Result | Get-Node
        $LeafNodes = $InputNode | Get-ChildNode -Recurse -Leaf

        # Exclude Keys
        $LeafNodes.where{ $_.Name -in $DSC_ExcludeKeys }.foreach{ $_.ParentNode.value.remove($_.name) }
        $DSC_ExcludeKeys.foreach{ 'Remove excluded key: {0}' -f $_ | Write-Log }

        # Fix type Int after export ( bug? commandlet New-M365DSCReportFromConfiguration )
        $Int_Nodes = $LeafNodes.where{ (Get-RefNodeExampleData -Node $_ -ReferenceObject $Obj_M365DataExample).type -in ('SInt32', 'UInt32', 'UInt64') }
        $Int_Nodes.ForEach{ $_.Value = [int]$_.Value }

        # Sort-object
        $Obj_Result = $Obj_Result | Sort-ObjectGraph -PrimaryKey 'NodeName', 'Identity', 'UniqueId', 'SettingDefinitionId' -MaxDepth 20

        # Check if data is available
        if ($Obj_Result.NonNodeData.$Workload)
        {
            $Obj_Result | ConvertTo-Expression -Depth 20 -Expand 20 | Out-File $Path_CompositeConfig -Force -Confirm:$false -Encoding UTF8
            'Result Composite config created: {0}' -f $Path_CompositeConfig | Write-Log
        }
        else
        {
            'No valid data in result ' | Write-Log -Failure
        }
    }

    end
    {
        # Cleaning
        if (Test-Path -Path $Path_JsonReport)
        {
            Remove-Item $Path_JsonReport -Confirm:$false -Force
        }
    }
}
#EndRegion './Public/Convert-M365DSCExportToPowerShellDataFile.ps1' 180
#Region './Public/Copy-Object.ps1' -1

function Copy-Object
{
<#
.SYNOPSIS
    Creates a full copy of an object, like a hashtable.

.DESCRIPTION
    This function creates a full copy of an object like a hashtable,
    without it having any reference to the original object.

.PARAMETER Object
    The object to be copied.

.EXAMPLE
    Copy-Object -Object @{ 'Key' = 'Value' }
#>

    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Object]
        $Object
    )

    $memStream = New-Object IO.MemoryStream
    $formatter = New-Object Runtime.Serialization.Formatters.Binary.BinaryFormatter
    $formatter.Serialize($memStream, $Object)
    $memStream.Position = 0
    $result = $formatter.Deserialize($memStream)

    return $result
}
#EndRegion './Public/Copy-Object.ps1' 33
#Region './Public/Get-EnvironmentsGenericInfo.ps1' -1

function Get-EnvironmentsGenericInfo
{
<#
.SYNOPSIS
    Returns an overview of the CICD sections of all Generic data files.

.DESCRIPTION
    This function inventories all '<Environment>#Generic.psd1' data files
    and returns this as one hashtable.

    It can be used to generate the pipeline yaml and Azure DevOps
    environments.

.PARAMETER Path
    The path to the environments data files.

.EXAMPLE
    Get-EnvironmentsGenericInfo -Path 'C:\Data\Environments'
#>

    [CmdletBinding()]
    [OutputType([System.Collections.HashTable])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Path
    )

    $result = @{}

    if (Test-Path -Path $Path)
    {
        $pattern = '*#Generic.psd1'

        $genericFiles = Get-ChildItem -Path $Path -Filter $pattern -Recurse

        if ($genericFiles.Count -ne 0)
        {
            foreach ($genericFile in $genericFiles)
            {
                $environment = $genericFile.Directory.BaseName
                $genericInfo = Import-PowerShellDataFile -Path $genericFile.FullName
                $result.$environment += @{
                    DependsOn = $genericInfo.NonNodeData.Environment.CICD.DependsOn
                    Branch    = $genericInfo.NonNodeData.Environment.CICD.UseCodeBranch
                    Approvers = $genericInfo.NonNodeData.Environment.CICD.Approvers
                }
            }
        }
        else
        {
            Write-Log -Object "No files found in '$Path' that match the pattern '$pattern'." -Failure
        }
    }
    else
    {
        Write-Log -Object "The path '$Path' does not exist." -Failure
    }

    return $result
}
#EndRegion './Public/Get-EnvironmentsGenericInfo.ps1' 62
#Region './Public/Get-ModulesFromBlobStorage.ps1' -1

function Get-ModulesFromBlobStorage
{
<#
.SYNOPSIS
    Downloads all Microsoft365DSC dependencies from an Azure Blob Storage

.DESCRIPTION
    This function downloads the zipped dependency modules corresponding to the
    required Microsoft365DSC version from an Azure Blob Storage, if available.
    The dependencies are then unzipped and copied to the PowerShell Modules folder.

.PARAMETER ResourceGroupName
    The Azure Resource Group Name where the Storage Account is located

.PARAMETER StorageAccountName
    The name of the Storage Account where the zip file will be downloaded from

.PARAMETER ContainerName
    The name of the Container where the zip file will be downloaded from

.PARAMETER Version
    The version of the Microsoft365DSC module for which the prerequisites should be retrieved

.EXAMPLE
    Get-ModulesFromBlobStorage -ResourceGroupName 'MyResourceGroup' -StorageAccountName 'MyStorageAccount' -ContainerName 'MyContainer' -Version 1.23.530.1
#>

    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ResourceGroupName,

        [Parameter(Mandatory = $true)]
        [System.String]
        $StorageAccountName,

        [Parameter(Mandatory = $true)]
        [System.String]
        $ContainerName,

        [Parameter(Mandatory = $true)]
        [System.String]
        $Version
    )

    Write-Log -Object "Download dependencies from storage container for Microsoft365DSC v$Version."

    Write-Log -Object "Connecting to storage account '$StorageAccountName'"
    $storageAcc = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -Name $StorageAccountName

    Write-Log -Object 'Retrieving storage account context'
    $context = $storageAcc.Context

    Write-Log -Object 'Checking download folder existence'
    $destination = Join-Path -Path $env:TEMP -ChildPath 'M365DSCModules'
    if ((Test-Path -Path $destination) -eq $false)
    {
        Write-Log -Object "Creating destination folder: '$destination'"
        $null = New-Item -ItemType Directory -Path $destination
    }

    Write-Log -Object 'Downloading blob contents from the container'
    $prefix = 'M365DSCDependencies-' + ($Version -replace '\.', '_')
    $blobContent = Get-AzStorageBlob -Container $ContainerName -Context $context -Prefix $prefix

    if ($null -eq $blobContent)
    {
        Write-Log -Object "[ERROR] No files found that match the pattern: '$prefix'" -Failure
        return $false
    }
    else
    {
        Write-Log -Object "Downloading $($blobContent.Name) to $destination"
        $downloadFile = Join-Path -Path $destination -ChildPath $blobContent.Name
        if (Test-Path -Path $downloadFile)
        {
            Write-Log -Object "$downloadFile already exists. Removing!"
            Remove-Item -Path $downloadFile -Confirm:$false
        }
        $null = Get-AzStorageBlobContent -Container $ContainerName -Context $context -Blob $blobContent.Name -Destination $destination -Force

        Write-Log -Object "Extracting $($blobContent.Name)"
        $extractPath = Join-Path -Path $destination -ChildPath $Version.ToString()
        if (Test-Path -Path $extractPath)
        {
            Write-Log -Object "$extractPath already exists. Removing!"
            Remove-Item -Path $extractPath -Recurse -Confirm:$false
        }
        Expand-Archive -Path $downloadFile -DestinationPath $extractPath

        Write-Log -Object "Copying modules in $extractPath to 'C:\Program Files\WindowsPowerShell\Modules'"
        $downloadedModules = Get-ChildItem -Path $extractPath -Directory -ErrorAction SilentlyContinue
        foreach ($module in $downloadedModules)
        {
            $PSModulePath = Join-Path -Path "$($env:ProgramFiles)/WindowsPowerShell/Modules" -ChildPath $module.Name
            if (Test-Path -Path $PSModulePath)
            {
                Write-Log -Object "Removing existing module $($module.Name)"
                Remove-Item -Include '*' -Path $PSModulePath -Recurse -Force
            }

            Write-Log -Object "Deploying module $($module.Name)"
            $modulePath = Join-Path -Path $extractPath -ChildPath $module.Name
            $PSModulesPath = Join-Path -Path "$($env:ProgramFiles)/WindowsPowerShell" -ChildPath 'Modules'
            Copy-Item -Path $modulePath -Destination $PSModulesPath -Recurse -Container -Force
        }

        Write-Log -Object 'Removing temporary components'
        Remove-Item -Path $extractPath -Recurse -Confirm:$false
        Remove-Item -Path $destination -Recurse -Confirm:$false
    }
    return $true
}
#EndRegion './Public/Get-ModulesFromBlobStorage.ps1' 116
#Region './Public/Import-PSDataFile.ps1' -1

function Import-PSDataFile
{
<#
.SYNOPSIS
    Imports a PowerShell Data File, without restriction on the file size.

.DESCRIPTION
    This function imports PowerShell data files into a hashtable. It also
    validates the file to ensure that it is a valid PowerShell Data File.

    This function replaces the default Import-PowerShellDataFile function,
    since that has issues with files larger than 500 keys.

.PARAMETER Path
    The path to the PSD1 file that will be imported.

.EXAMPLE
    Import-PSDataFile -Path 'C:\Temp\reference.psd1'
#>

    [CmdletBinding()]
    [OutputType([System.Collections.HashTable])]
    param
    (
        [Parameter(Mandatory = $true)]
        [Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformation()]
        [System.Collections.HashTable]
        $Path
    )

    return $Path
}
#EndRegion './Public/Import-PSDataFile.ps1' 32
#Region './Public/Merge-DataFile.ps1' -1

function Merge-DataFile
{
<#
.SYNOPSIS
    Merges two PowerShell Data File hashtables

.DESCRIPTION
    This function merges two PowerShell Data file hashtables into one new
    one. The values in the Merge hashtable are overwriting any existing
    values in the Reference hashtable.

.PARAMETER Reference
    The Reference hashtable that is used as the starting point

.PARAMETER Merge
    The Merge hashtable that will be merged into the Reference hashtable.

.EXAMPLE
    # Merges the Merge file into the Reference file
    $reference = Import-PowerShellDataFile -Path 'reference.psd1'
    $merge = Import-PowerShellDataFile -Path 'merge.psd1'

    Merge-DataFile -Reference $reference -Merge $merge
#>

    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Collections.Hashtable]
        $Reference,

        [Parameter(Mandatory = $true)]
        [System.Collections.Hashtable]
        $Merge
    )

    Begin
    {
        $script:level = 0

        Write-LogEntry -Message 'Starting Data Merge' -Level $script:level
        $ref = $Reference.Clone()
        $mer = $Merge.Clone()
    }

    Process
    {
        $result = Merge-Hashtable -Reference $ref -Merge $mer
    }

    End
    {
        Write-LogEntry -Message 'Data Merge Completed' -Level $script:level

        return $result
    }
}
#EndRegion './Public/Merge-DataFile.ps1' 58
#Region './Public/Set-ADOEnvironment.ps1' -1

function Set-ADOEnvironment
{
    <#
.SYNOPSIS
    Checks if specified environments exist in Azure DevOps and creates them if they don't.

.DESCRIPTION
    This function checks if the specified environments exist in Azure DevOps and creates
    them if they don't. It also checks if other configurations are set, like approvers
    pipeline permissions, etc.

.PARAMETER Organization
    The name of the DevOps organization.

.PARAMETER Project
    The name of the project in the DevOps organization.

.PARAMETER ApiVersion
    The name of the to be used API version.

.PARAMETER PAT
    The Personal Access Token to be used for authentication (if required).

.PARAMETER TargetEnvironments
    The list of environments that should exist in the DevOps project.

.PARAMETER Approvers
    The list of approvers for each environment.

.PARAMETER DeploymentPipeline
    The name of the pipeline that should be granted permissions to access
    the environment.

.PARAMETER ExecutionOrder
    The execution order of the approvals.

.PARAMETER Instructions
    The instructions to be displayed to the approvers.

.PARAMETER MinRequiredApprovers
    The minimum number of approvers required.

.PARAMETER RequesterCannotBeApprover
    If the requester cannot be an approver.

.PARAMETER Timeout
    The timeout for the approval.

.EXAMPLE
    $environmentsConfig = @{
        'testenv' = @(
            @{
                Principal = 'user@domain.com'
                Type = 'User'
            }
            @{
                Principal = '[DevOps Project]\Project Administrators'
                Type = 'Group'
            }
        )
        'testenv2' = @(
            @{
                Principal = 'admin@contoso.com'
                Type = 'User'
            }
            @{
                Principal = '[DSC Project]\Project Administrators'
                Type = 'Group'
            }
        )
    }

    Set-ADOEnvironment `
        -Organization 'myorg' `
        -Project 'myproject' `
        -TargetEnvironments $environmentsConfig.Keys `
        -Approvers $environmentsConfig `
        -DeploymentPipeline 'mypipeline' `
        -PAT '<pat>'
#>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([System.Boolean])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Organization,

        [Parameter(Mandatory = $true)]
        [System.String]
        $Project,

        [Parameter()]
        [System.String]
        $ApiVersion = '7.1-preview.1',

        [Parameter()]
        [System.String]
        $PAT,

        [Parameter(Mandatory = $true)]
        [System.Array]
        $TargetEnvironments,

        [Parameter(Mandatory = $true)]
        [System.Collections.Hashtable]
        $Approvers,

        [Parameter(Mandatory = $true)]
        [System.String]
        $DeploymentPipeline,

        [Parameter()]
        [System.String]
        $ExecutionOrder = 'anyOrder',

        [Parameter()]
        [System.String]
        $Instructions = 'Please approve if you agree with the deployment.',

        [Parameter()]
        [System.Int32]
        $MinRequiredApprovers = 1,

        [Parameter()]
        [System.Boolean]
        $RequesterCannotBeApprover = $false,

        [Parameter()]
        [System.Int32]
        $Timeout = 14400
    )

    #region Variables
    $devOpsVsspsOrgUrl = 'https://vssps.dev.azure.com/{0}' -f $Organization
    $devOpsOrgUrl = 'https://dev.azure.com/{0}' -f $Organization
    $devOpsProjectUrl = '{0}/{1}' -f $devOpsOrgUrl, $Project
    $apiVersionString = "api-version=$ApiVersion"

    $default = @{
        ExecutionOrder            = $ExecutionOrder
        Instructions              = $Instructions
        MinRequiredApprovers      = $MinRequiredApprovers
        RequesterCannotBeApprover = $RequesterCannotBeApprover
        Timeout                   = $Timeout
    }

    $approversDetails = @{}
    #endregion Variables

    #region Script
    Write-Log -Object 'Starting Pipeline Environments check'

    Write-Log -Object 'Creating Authorization token'
    $headers = New-Object 'System.Collections.Generic.Dictionary[[String],[String]]'

    if ($PSBoundParameters.ContainsKey('PAT'))
    {
        Write-Log -Object ' Parameter PAT is specified, using that to authenticate'
        $authToken = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$($PAT)"))
        $headers.Add('Authorization', ('Basic {0}' -f $authToken))
    }
    else
    {
        Write-Log -Object ' Parameter PAT is NOT specified, using environment variable SYSTEM_ACCESSTOKEN to authenticate'
        $headers.Add('Authorization', ('Bearer {0}' -f $env:SYSTEM_ACCESSTOKEN))
    }

    # https://learn.microsoft.com/en-us/rest/api/azure/devops/distributedtask/environments/list?view=azure-devops-rest-7.1
    Write-Log -Object 'Retrieving all environments'
    $envUrl = '{0}/_apis/distributedtask/environments?{1}' -f $devOpsProjectUrl, $apiVersionString
    $currentEnvironments = Invoke-APRestApi -Uri $envUrl -Method 'GET' -Headers $headers

    $currentEnvironmentNames = $currentEnvironments.value | Select-Object -ExpandProperty Name

    Write-Log -Object "Check the differences between current ($($currentEnvironments.Count)) and target environments ($($TargetEnvironments.Count))"
    $createEnvironments = @()
    if ($null -eq $currentEnvironments -or $currentEnvironments.Count -eq 0)
    {
        $createEnvironments = $TargetEnvironments
    }
    else
    {
        $envDifferences = Compare-Object -ReferenceObject $TargetEnvironments -DifferenceObject $currentEnvironmentNames #-IncludeEqual
        switch ($envDifferences)
        {
            { $_.SideIndicator -eq '<=' }
            {
                $envName = $_.InputObject
                Write-Log -Object "Environment does not exist: $($envName)"
                $createEnvironments += $envName
            }
            { $_.SideIndicator -eq '=>' }
            {
                Write-Log -Object "Environment is not specified in target environments: $($_.InputObject)" -Warning
            }
        }
    }

    Write-Log -Object 'Creating all new environments'
    foreach ($environment in $createEnvironments)
    {
        Write-Log -Object " Creating new environment: $environment"
        $obj = @{
            name        = $environment
            description = "Environment for $($environment)"
        }
        $requestBody = ConvertTo-Json -InputObject $obj -Depth 10

        if ($PSCmdlet.ShouldProcess($environment, 'Create Environment'))
        {
            $null = Invoke-APRestApi -Uri $envUrl -Method 'POST' -Headers $headers -Body $requestBody
        }
    }

    # https://learn.microsoft.com/en-us/rest/api/azure/devops/graph/users/list?view=azure-devops-rest-7.1
    Write-Log -Object 'Getting all users'
    $usersUrl = '{0}/_apis/graph/users?{1}' -f $devOpsVsspsOrgUrl, $apiVersionString
    $allUsers = Invoke-APRestApi -Uri $usersUrl -Method 'GET' -Headers $headers

    Write-Log -Object 'Getting all groups'
    $groupsUrl = '{0}/_apis/graph/groups?{1}' -f $devOpsVsspsOrgUrl, $apiVersionString
    $allGroups = Invoke-APRestApi -Uri $groupsUrl -Method 'GET' -Headers $headers

    Write-Log -Object 'Getting details of approvers'
    foreach ($environment in $Approvers.GetEnumerator())
    {
        Write-Log -Object "Processing approvers for environment: $($environment.Key)"
        $envApprovers = $environment.Value

        $approversDetails.$($environment.Key) = @()
        foreach ($approver in $envApprovers | Where-Object { $_.Type -eq 'User' })
        {
            Write-Log -Object " Processing: '$($approver.Principal)'"
            $approveUser = $allUsers.value | Where-Object -FilterScript { $_.PrincipalName -eq $approver.Principal }
            if ($null -eq $approveUser)
            {
                Write-Log -Object " Approval User '$($approver.Principal)' not found!" -Failure
                return $false
            }

            $userDisplayName = $approveUser.displayName

            $storagekeyUrl = '{0}/_apis/graph/storagekeys/{2}?{1}' -f $devOpsVsspsOrgUrl, $apiVersionString, $approveUser.descriptor
            $userStoragekey = Invoke-APRestApi -Uri $storagekeyUrl -Method 'GET' -Headers $headers

            if ($null -eq $userStoragekey)
            {
                Write-Log -Object ' User descriptor not found!' -Failure
                return $false
            }

            $approversDetails.$($environment.Key) += [PSCustomObject]@{
                DisplayName = $userDisplayName
                Descriptor  = $userStoragekey.value
            }
        }

        foreach ($approver in $envApprovers | Where-Object { $_.Type -eq 'Group' })
        {
            Write-Log -Object " Processing: '$($approver.Principal)'"
            $approveGroup = $allGroups.value | Where-Object -FilterScript { $_.PrincipalName -eq $approver.Principal }
            if ($null -eq $approveGroup)
            {
                Write-Log -Object " [ERROR] Approval Group '$($approver.Principal)' not found!" -Failure
                return $false
            }

            $groupDisplayName = $approveGroup.PrincipalName

            $storagekeyUrl = '{0}/_apis/graph/storagekeys/{2}?{1}' -f $devOpsVsspsOrgUrl, $apiVersionString, $approveGroup.descriptor
            $groupStoragekey = Invoke-APRestApi -Uri $storagekeyUrl -Method 'GET' -Headers $headers

            if ($null -eq $groupStoragekey)
            {
                Write-Log -Object ' Group descriptor not found!' -Failure
                return $false
            }

            $approversDetails.$($environment.Key) += [PSCustomObject]@{
                DisplayName = $groupDisplayName
                Descriptor  = $groupStoragekey.value
            }
        }
    }

    Write-Log -Object "Get Pipeline info for pipeline '$DeploymentPipeline'"
    $pipelineUrl = '{0}/_apis/pipelines?{1}' -f $devOpsProjectUrl, $apiVersionString
    $pipelines = $null
    $pipelines = Invoke-APRestApi -Uri $pipelineUrl -Method 'GET' -Headers $headers
    if ($null -eq $pipelines -or $pipelines.count -eq 0)
    {
        Write-Log -Object ' Pipeline not found' -Failure
        return $false
    }
    $pipeline = $pipelines.value | Where-Object { $_.name -eq $deploymentPipeline }

    # Retrieve all environments, including newly created ones.
    Write-Log -Object 'Refreshing all environments'
    $currentEnvironments = Invoke-APRestApi -Uri $envUrl -Method 'GET' -Headers $headers

    foreach ($environment in $currentEnvironments.value)
    {
        Write-Log -Object "Checking config for '$($environment.Name)'"
        $envId = $environment.id
        $envName = $environment.Name

        if ($TargetEnvironments -notcontains $envName)
        {
            Write-Log -Object " Environment '$envName' is not in the target environments, skipping."
            continue
        }

        # https://learn.microsoft.com/en-us/rest/api/azure/devops/approvalsandchecks/check-configurations/get?view=azure-devops-rest-7.1
        $envChecksUrl = '{0}/_apis/pipelines/checks/configurations?resourceType=environment&resourceId={2}&{1}' -f $devOpsProjectUrl, $apiVersionString, $envId
        $envChecks = $null
        $envChecks = Invoke-APRestApi -Uri $envChecksUrl -Method 'GET' -Headers $headers

        if ($null -ne $envChecks)
        {
            if ($envChecks.Count -ne 0)
            {
                Write-Log -Object ' Approval configured, checking configuration.'
                $checkId = $envChecks.value.Id

                $checkUrl = "{0}/_apis/pipelines/checks/configurations/{2}?`$expand=settings&{1}" -f $devOpsProjectUrl, $apiVersionString, $checkId
                $checkInfo = Invoke-APRestApi -Uri $checkUrl -Method 'GET' -Headers $headers
                if ($null -ne $checkInfo)
                {
                    $settings = $checkInfo.settings
                    $obj = @{
                        id       = $checkId
                        type     = @{
                            id   = '8C6F20A7-A545-4486-9777-F762FAFE0D4D'
                            name = 'Approval'
                        }
                        settings = @{
                            approvers                 = @()
                            blockApprovers            = @()
                            executionOrder            = $default.ExecutionOrder
                            instructions              = $default.Instructions
                            minRequiredApprovers      = $default.MinRequiredApprovers
                            requesterCannotBeApprover = $default.RequesterCannotBeApprover
                        }
                        resource = @{
                            type = 'environment'
                            id   = $envId
                            name = $envName
                        }
                        timeout  = $default.Timeout
                    }
                    $updateCheck = $false

                    if ($settings.instructions -ne $default.Instructions)
                    {
                        Write-Log -Object " Parameter Instructions changed, updating. Old: $($settings.instructions), New: $($default.Instructions)"
                        $updateCheck = $true
                    }

                    if ($settings.requesterCannotBeApprover -ne $default.RequesterCannotBeApprover)
                    {
                        Write-Log -Object " Parameter RequesterCannotBeApprover changed, updating. Old: $($settings.requesterCannotBeApprover), New: $($default.RequesterCannotBeApprover)"
                        $updateCheck = $true
                    }

                    if ($settings.executionOrder -ne $default.ExecutionOrder)
                    {
                        Write-Log -Object " Parameter ExecutionOrder changed, updating. Old: $($settings.executionOrder), New: $($default.ExecutionOrder)"
                        $updateCheck = $true
                    }

                    if ($settings.minRequiredApprovers -ne $default.MinRequiredApprovers)
                    {
                        Write-Log -Object " Parameter MinRequiredApprovers changed, updating. Old: $($settings.minRequiredApprovers), New: $($default.MinRequiredApprovers)"
                        $updateCheck = $true
                    }

                    if ($checkInfo.timeout -ne $default.Timeout)
                    {
                        Write-Log -Object " Parameter TimeOut changed, updating. Old: $($checkInfo.timeout), New: $($default.Timeout)"
                        $updateCheck = $true
                    }

                    if ($settings.approvers.Count -ne 0)
                    {
                        $approversDiff = Compare-Object -ReferenceObject $settings.approvers.id -DifferenceObject $approversDetails.$envName.Descriptor
                        if ($null -ne $approversDiff)
                        {
                            Write-Log -Object ' Approvers changed, updating.'
                            $updateCheck = $true
                        }
                    }
                    else
                    {
                        Write-Log -Object ' Approvers changed, updating.'
                        $updateCheck = $true
                    }

                    foreach ($approver in $approversDetails.$envName)
                    {
                        $obj.settings.approvers += @{
                            displayName = $approver.DisplayName
                            id          = $approver.Descriptor
                        }
                    }

                    if ($updateCheck -eq $true)
                    {
                        Write-Log -Object ' Updating check configuration'
                        $requestBody = ConvertTo-Json -InputObject $obj -Depth 10

                        $configUrl = '{0}/_apis/pipelines/checks/configurations/{2}?{1}' -f $devOpsProjectUrl, $apiVersionString, $checkId
                        if ($PSCmdlet.ShouldProcess('Configurations', 'Configure approvals'))
                        {
                            $null = Invoke-APRestApi -Uri $configUrl -Method 'PATCH' -Headers $headers -Body $requestBody
                        }
                    }
                }
                else
                {
                    Write-Log -Object ' No check information found!'
                }
            }
            else
            {
                Write-Log -Object ' No approval configured, configuring.'
                $obj = @{
                    type     = @{
                        id   = '8C6F20A7-A545-4486-9777-F762FAFE0D4D'
                        name = 'Approval'
                    }
                    settings = @{
                        approvers                 = @()
                        blockApprovers            = @()
                        executionOrder            = $default.ExecutionOrder
                        instructions              = $default.Instructions
                        minRequiredApprovers      = $default.MinRequiredApprovers
                        requesterCannotBeApprover = $default.RequesterCannotBeApprover
                    }
                    resource = @{
                        type = 'environment'
                        id   = $envId
                        name = $envName
                    }
                    timeout  = $default.Timeout
                }

                foreach ($approver in $approversDetails.$envName)
                {
                    $obj.settings.approvers += @{
                        displayName = $approver.DisplayName
                        id          = $approver.Descriptor
                    }
                }

                $requestBody = ConvertTo-Json -InputObject $obj -Depth 10

                Write-Log -Object ' Creating check'
                $configUrl = '{0}/_apis/pipelines/checks/configurations?{1}' -f $devOpsProjectUrl, $apiVersionString
                if ($PSCmdlet.ShouldProcess('Configurations', 'Create approvals'))
                {
                    $null = Invoke-APRestApi -Uri $configUrl -Method 'POST' -Headers $headers -Body $requestBody
                }
            }
        }
        else
        {
            Write-Log -Object ' Error while retrieving Environment Checks' -Failure
            return $false
        }

        Write-Log -Object ' Checking pipeline permissions to environment'
        $permissionsUrl = '{0}/_apis/pipelines/pipelinepermissions/environment/{2}?{1}' -f $devOpsProjectUrl, $apiVersionString, $envId
        $permissionsChecks = Invoke-APRestApi -Uri $permissionsUrl -Method 'GET' -Headers $headers

        if ($permissionsChecks.pipelines.count -eq 0)
        {
            Write-Log -Object ' Permissions not provided. Granting permissions!'
            $body = "{ 'pipelines':[{'id': $($pipeline.id), 'authorized': true}] }"
            if ($PSCmdlet.ShouldProcess($DeploymentPipeline, 'Granting pipeline permissions'))
            {
                $null = Invoke-APRestApi -Uri $permissionsUrl -Method 'PATCH' -Headers $headers -Body $body
            }
        }
        else
        {
            Write-Log -Object ' Permissions provided. Checking if correct pipeline!'
            foreach ($permission in $permissionsChecks.pipelines)
            {
                if ($permission.id -ne $pipeline.id -or $permission.authorized -ne $true)
                {
                    $body = "{ 'pipelines':[{'id': $($pipeline.id), 'authorized': true}] }"
                    if ($PSCmdlet.ShouldProcess($DeploymentPipeline, 'Granting pipeline permissions'))
                    {
                        $null = Invoke-APRestApi -Uri $permissionsUrl -Method 'PATCH' -Headers $headers -Body $body
                    }
                }
            }
        }
    }

    Write-Log -Object 'Completed Pipeline Environments check'
    return $true
    #endregion Script
}
#EndRegion './Public/Set-ADOEnvironment.ps1' 506
#Region './Public/Set-PipelineYaml.ps1' -1

function Set-PipelineYaml
{
<#
.SYNOPSIS
    Updates the environments parameter in the pipeline Yaml with the provided environments info.

.DESCRIPTION
    This function updates the environments parameter in the provided pipeline Yaml
    with the provided environments info.

.PARAMETER YamlPath
    The path to the pipeline Yaml file that has to get updated.

.PARAMETER EnvironmentsInfo
    The environment details that is used to update the environments parameter.

.EXAMPLE
    $envInfo = @{
        Dev = @{
            DependsOn = ''
            Branch = 'dev'
        }
        Test = @{
            DependsOn = 'Dev'
            Branch = 'main'
        }
        Acceptance = @{
            DependsOn = 'Test'
            Branch = 'main'
        }
        Production = @{
            DependsOn = 'Acceptance'
            Branch = 'main'
        }
    }

    Set-PipelineYaml `
        -YamlPath 'C:\Source\Demo\Pipelines\template.yaml' `
        -EnvironmentsInfo = $envInfo
#>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([System.Boolean])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $YamlPath,

        [Parameter(Mandatory = $true)]
        [System.Collections.Hashtable]
        $EnvironmentsInfo
    )

    if (Test-Path -Path $YamlPath)
    {
        $yamlContent = Get-Content -Path $YamlPath -Raw
        $yamlObj = ConvertFrom-Yaml -Yaml $yamlContent -Ordered

        if ($yamlObj.Keys -contains 'parameters')
        {
            $envParameter = $yamlObj.parameters | Where-Object -FilterScript { $_.Name -eq 'Environments' }
            if ($null -ne $envParameter)
            {
                $defaults = $envParameter.default

                if ($null -eq $defaults)
                {
                    $defaults = @()
                    $currentEnvs = @()
                }
                else
                {
                    $currentEnvs = $defaults.Name
                }
                [Array]$targetEnvs = $EnvironmentsInfo.Keys

                $diff = Compare-Object -ReferenceObject $currentEnvs -DifferenceObject $targetEnvs -IncludeEqual

                switch ($diff)
                {
                    { $_.SideIndicator -eq '=>' }
                    {
                        $envName = $_.InputObject
                        Write-Log -Object "Adding '$envName' to the pipeline Yaml file"
                        $dependsOn = $EnvironmentsInfo.$envName.DependsOn
                        if ([String]::IsNullOrEmpty($dependsOn))
                        {
                            $dependsOn = $null
                        }
                        $defaults += [Ordered]@{
                            Name      = $envName
                            DependsOn = $dependsOn
                            Branch    = $EnvironmentsInfo.$envName.Branch
                        }
                        continue
                    }
                    { $_.SideIndicator -eq '<=' }
                    {
                        $envName = $_.InputObject
                        Write-Log -Object "Removing '$envName' from the pipeline Yaml file"
                        $defaults = $defaults | Where-Object { $_.Name -ne $envName }
                        continue
                    }
                    { $_.SideIndicator -eq '==' }
                    {
                        $envName = $_.InputObject
                        Write-Log -Object "Updating '$envName' in the pipeline Yaml file"
                        $updateEnv = $defaults | Where-Object { $_.Name -eq $envName }
                        $dependsOn = $EnvironmentsInfo.$envName.DependsOn
                        if ([String]::IsNullOrEmpty($dependsOn))
                        {
                            $dependsOn = $null
                        }
                        $updateEnv.DependsOn = $dependsOn
                        $updateEnv.Branch = $EnvironmentsInfo.$envName.Branch
                        continue
                    }
                }

                $envParameter.default = $defaults
                if ($PSCmdlet.ShouldProcess($YamlPath, 'Update Yaml file'))
                {
                    ConvertTo-Yaml $yamlObj | Out-File -FilePath $YamlPath -Encoding utf8 -Force
                }
            }
            else
            {
                Write-Log "Specified Yaml '$YamlPath' does not have an 'Environments' parameter!" -Failure
                return $false
            }
        }
        else
        {
            Write-Log "Specified Yaml '$YamlPath' does not have a 'parameters' value!" -Failure
            return $false
        }
        return $true
    }
    else
    {
        Write-Log "Specified YamlPath '$YamlPath' does not exist!" -Failure
        return $false
    }
}
#EndRegion './Public/Set-PipelineYaml.ps1' 145
#Region './Public/Test-IfModulesInBlobStorage.ps1' -1

function Test-IfModulesInBlobStorage
{
<#
.SYNOPSIS
    Tests if a package with Microsoft365DSC dependencies exists in an Azure Blob Storage

.DESCRIPTION
    This function tests if the zipped dependency modules corresponding to the
    required Microsoft365DSC version exists in the provided Azure Blob Storage.

.PARAMETER ResourceGroupName
    The Azure Resource Group Name where the Storage Account is located

.PARAMETER StorageAccountName
    The name of the Storage Account where the zip file will be downloaded from

.PARAMETER ContainerName
    The name of the Container where the zip file will be downloaded from

.PARAMETER Version
    The version of the Microsoft365DSC module for which the prerequisites should be tested

.EXAMPLE
    Test-IfModulesInBlobStorage -ResourceGroupName 'MyResourceGroup' -StorageAccountName 'MyStorageAccount' -ContainerName 'MyContainer' -Version 1.23.530.1
#>

    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ResourceGroupName,

        [Parameter(Mandatory = $true)]
        [System.String]
        $StorageAccountName,

        [Parameter(Mandatory = $true)]
        [System.String]
        $ContainerName,

        [Parameter(Mandatory = $true)]
        [System.String]
        $Version
    )

    Write-Log -Object "Test if dependencies exist in storage container for Microsoft365DSC v$Version."

    Write-Log -Object "Connecting to storage account '$StorageAccountName'"
    $storageAcc = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -Name $StorageAccountName

    Write-Log -Object 'Retrieving storage account context'
    $context = $storageAcc.Context

    Write-Log -Object 'Downloading blob contents from the container'
    $prefix = 'M365DSCDependencies-' + ($Version -replace '\.', '_')
    $blobContent = Get-AzStorageBlob -Container $ContainerName -Context $context -Prefix $prefix

    if ($null -eq $blobContent)
    {
        Write-Log -Object "[ERROR] No files found that match the pattern: '$prefix'"
        return $false
    }
    else
    {
        Write-Log -Object "[ERROR] No files found that match the pattern: '$prefix'" -Failure
        return $true
    }
}
#EndRegion './Public/Test-IfModulesInBlobStorage.ps1' 70
#Region './Public/Test-M365DSCPowershellDataFile.ps1' -1

function Test-M365DSCPowershellDataFile
{
    <#
    .Synopsis
    Tests the specified object against the information defined in the ExampleData
    from the M365DSC.CompositeResources module.

    .Description
    This function tests the specified object against the information defined in the
    ExampleData from the M365DSC.CompositeResources module. It creates a Pester test
    to check if the specified data and types are correct, as specified in the example
    data.

    .Parameter Test
    Specifies the Tests. Allowed values are:
    'TypeValue','Required','Mandatory'

    .Parameter InputObject
    The object that contains the data object that needs to be tested.

    .Parameter MandatoryObject
    The object that contains the Mandatory data.

    .Parameter MandatoryAction
    Action Type for test Mandatory 'Present', 'Absent'

    .Parameter ExcludeAvailableAsResource
    All items that are available as a resource and have to be ignored. ( wildcards can be uses )

    .Parameter ExcludeRequired
    Required items have to be ignored. ( no wildcards )

    .Parameter PesterVerbosity
    Specifies the verbosity level of the output. Allowed values are:
    None', 'Detailed', 'Diagnostic'. Default is 'Detailed'.

    .Parameter PesterStackTraceVerbosity
    Specifies the verbosity level of the output. Allowed values are:
    'None', 'FirstLine', 'Filtered', 'Full'. Default is 'Firstline'.

    .Parameter PesterShowScript
    If specified, the generated Pester script will be opened in an editor.

    .Parameter PesterOutputObject
    If specified, the executed Pester script result will returned.

    .Example
    $InputObject = Import-PSDataFile -path '%Filename%.psd'

    Test-M365DSCPowershellDataFile -Test TypeValue, Required `
    -InputObject $InputObject `
    -ExcludeAvailableAsResource *CimInstance, *UniqueID, *IsSingleInstance `
    -ExcludeRequired CimInstance, UniqueID `
    -PesterShowScript

    .Example
    $InputObject = Import-PSDataFile -path '%Filename_InputObject%.psd1'
    $MandatoryObject = Import-PSDataFile -path '$Filename_MandatoryObject%.psd1'

    Test-M365DSCPowershellDataFile -Test TypeValue, Required, Mandatory `
    -InputObject $InputObject `
    -MandatoryObject $MandatoryObject `
    -MandatoryAction Present `
    -ExcludeAvailableAsResource *CimInstance, *UniqueID, *IsSingleInstance `
    -ExcludeRequired CimInstance, UniqueID `
    -PesterShowScript


    .NOTES
    This function requires Modules: M365DSC.CompositeResources, ObjectGraphTools
    #>


    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Bug powershell, Vars are declared')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Vars are used in sub functions')]

    param
    (
        [Parameter(Mandatory = $true)]
        [ValidateSet('TypeValue', 'Required', 'Mandatory')]
        [String[]]
        $Test,

        [Parameter(Mandatory = $True)]
        [System.Object]
        $InputObject,

        [Parameter(Mandatory = $False )]
        [System.Object]
        $MandatoryObject,

        [Parameter(Mandatory = $False )]
        [ValidateSet('Present', 'Absent')]
        [string]
        $MandatoryAction,

        [Parameter(Mandatory = $False)]
        [String[]]
        $ExcludeAvailableAsResource,

        [Parameter(Mandatory = $False)]
        [String[]]
        $ExcludeRequired,

        [Parameter(Mandatory = $False)]
        [ValidateSet('None', 'Detailed', 'Diagnostic')]
        [String]
        $PesterVerbosity = 'Detailed',

        [Parameter(Mandatory = $False)]
        [ValidateSet('None', 'FirstLine', 'Filtered', 'Full')]
        [String]
        $PesterStackTraceVerbosity = 'FirstLine',

        [Parameter(Mandatory = $False)]
        [Switch]
        $PesterShowScript,

        [Parameter(Mandatory = $False)]
        [Switch]
        $PesterOutputObject
    )

    begin
    {
        # Test if the ObjectGraphTools module is loaded and the class is available
        if (-not ([System.Management.Automation.PSTypeName]'PSNode').Type)
        {
            Import-Module ObjectGraphTools -Force
        }

        # Function to display elapsed and total time
        function ShowElapsed
        {
            param
            (
                [Switch]
                $reset
            )

            if ($reset)
            {
                $script:totalTime = Get-Date; $script:elapsedTime = Get-Date
            }

            if (-not $script:totalTime)
            {
                $script:totalTime = Get-Date
            }

            if (-not $script:elapsedTime)
            {
                $script:elapsedTime = Get-Date
            }

            $result = '^ Elapsed: {0} TotalTime: {1} seconds' -f "$(($(Get-Date) - $elapsedTime).TotalSeconds) Seconds".PadRight(30, ' ') , ($(Get-Date) - $totalTime).TotalSeconds
            $script:elapsedTime = Get-Date

            return $result
        }
        # Function to test if a string is a valid GUID
        function Test-IsGuid
        {
            [CmdletBinding()]
            [OutputType([bool])]
            param
            (
                [Parameter(Mandatory = $true, ValueFromPipeline = $True)]
                [string]
                $stringGuid
            )

            process
            {
                try
                {
                    $objectGuid = [System.Guid]::Empty
                    return [System.Guid]::TryParse($stringGuid, [ref]$objectGuid)
                }
                catch
                {
                    Write-Error "An error occurred while checking the GUID format: $_"
                    return $false
                }
            }
        }

        # Function to generate Pester "should" commands based on type
        function Pester_Type_Should_Command
        {
            [CmdletBinding()]
            [OutputType([System.String])]
            param
            (
                [Parameter(Mandatory = $true)]
                [string]
                $type
            )

            try
            {
                switch ($type) {
                    'SInt32' { return "Should -Match '^\d+$' -Because 'Must be a positive Integer'" }
                    'SInt64' { return "Should -Match '^\d+$' -Because 'Must be a positive Integer'" }
                    'UInt32' { return "Should -BeOfType 'Int'" }
                    'UInt64' { return "Should -BeOfType 'Int'" }
                    'Guid' { return "Test-IsGuid | Should -Be 'True'" }
                    default { return "Should -BeOfType '$type'" }
                }
            }
            catch
            {
                Write-Error "An error occurred while generating Pester 'should' assertion: $_"
                return $null
            }
        }

        # Class M365DSC reference values
        class M365DSC_Reference_Values
        {
            [string]$type
            [string]$required
            [String]$description
            [String]$validateSet

            M365DSC_Reference_Values([string]$inputString) {
                [array]$result = $inputString.split('|').foreach{ $_.trim() }
                $this.type = $result[0]
                $this.required = $result[1]
                $this.description = $result[2]
                $this.validateSet = $result[3].foreach{ "'" + ($result[3] -Replace '\s*\/\s*', "', '") + "'" }
            }
        }

        function Create_Pesternodes_Mandatory
        {
            param
            (
                [Parameter()]
                [psnode]
                $MandatoryObject
            )
            $MandatoryLeafs = $MandatoryObject | get-childnode -Recurse -Leaf
            $MandatoryLeafs.foreach{
                if ($MandatoryAction -eq 'Absent')
                {
                    "`$inputObject.{0} | Should -BeNullOrEmpty -Because 'Denied Mandatory Setting'" -f $_.Path
                }
                else
                {
                    "`$inputObject.{0} | Should -Be {1} -Because 'Mandatory Setting'" -f $_.Path, $_.Value
                }
            }
        }

        function Create_PesterNode
        {
            param
            (
                [Parameter()]
                [psnode]
                $nodeObject,

                [Parameter()]
                [switch]
                $recursive
            )

            $refNodePath = '{0}' -f $($nodeObject.Path) -replace '\[\d+\]', '[0]'
            $objRefNode = $ht[$refNodePath]

            # Exclude nodes that match the patterns defined in $excludeAvailableAsResource
            foreach ($Exclude in  $excludeAvailableAsResource)
            {
                if ($nodeObject.Path -like $Exclude)
                {
                    "#`$inputObject.{0}" -f $nodeObject.Path
                    return
                }
            }

            # No Composite Resource available
            if ($null -eq $objRefNode)
            {
                "`$inputObject.{0} | Should -BeNullOrEmpty -Because 'Not available as Composite Resource'" -f $nodeObject.Path
                return
            }

            if ($nodeObject -is [psCollectionNode])
            {
                # Check Folder Type
                if ($Typevalue)
                {
                    [Bool]$isHashTable = $( $objRefNode.valueType.name -eq 'HashTable')
                    if ($isHashTable)
                    {
                        "`$inputObject.{0} -is [HashTable] | Should -BeTrue" -f $nodeObject.Path
                    }
                    else
                    {
                        "`$inputObject.{0} -is [Array] | Should -BeTrue " -f $nodeObject.Path
                    }
                }

                # Check for required
                if ($Required)
                {
                    $objRequiredNodes = $htRequired["$($refNodePath)"]
                    if ($objRequiredNodes)
                    {
                        foreach ($objRequiredNode in $objRequiredNodes)
                        {
                            if ($objRequiredNode.Path -eq 'NonNodeData.Environment.CICD.DependsOn')
                            {
                                # NonNodeData.Environment.CICD.DependsOn is required, but can be an empty value
                                "`$inputObject.{0}{1} | Should -Not -Be `$null -Because 'Required setting'" -f $nodeObject.Path, $(if ( $objRequiredNode.Name -ne 0 ) { ".$($objRequiredNode.Name)" })
                            }
                            else
                            {
                                if ($objRequiredNode.Name -notin $excludeRequired)
                                {
                                    "`$inputObject.{0}{1} | Should -Not -BeNullOrEmpty -Because 'Required setting'" -f $nodeObject.Path, $(if ( $objRequiredNode.Name -ne 0 ) { ".$($objRequiredNode.Name)" })
                                }
                                else
                                {
                                    "#`$inputObject.{0}{1} | Should -Not -BeNullOrEmpty -Because 'Required setting'" -f $nodeObject.Path, $(if ( $objRequiredNode.Name -ne 0 ) { ".$($objRequiredNode.Name)" })
                                }
                            }
                        }
                    }
                }

                # Recursively process child nodes if exsists
                $childs = $nodeObject | Get-ChildNode
                foreach ($node in $childs)
                {
                    if ($recursive)
                    {
                        Create_PesterNode -nodeObject $node -recursive
                    }
                }

            }
            else
            {
                # LeafNode
                if ($Typevalue)
                {
                    $objRefNodeValue = [M365DSC_Reference_Values]::new($objRefNode.Value)
                    # Type Validation
                    if ($objRefNodeValue.type)
                    {
                        "`$inputObject.{0} | {1}" -f $nodeObject.path , $(Pester_Type_Should_Command $objRefNodeValue.type)
                    }

                    # ValidationSet Validation
                    if ($objRefNodeValue.validateSet)
                    {
                        "`$inputObject.{0} | Should -BeIn {1}" -f $nodeObject.path, $objRefNodeValue.validateSet
                    }
                }
            }
        }
    }

    process
    {
        $Typevalue = $False
        $Required = $False
        $Mandatory = $False

        if ($Test -contains 'TypeValue')
        {
            $Typevalue = $true
        }
        if ($Test -contains 'Required')
        {
            $Required = $True
        }
        if ($Test -contains 'Mandatory')
        {
            $Mandatory = $True
            if (-not $MandatoryObject -or -not $MandatoryAction)
            {
                throw 'Mandatory test needs the parameters MandatoryObject and MandatoryAction'
            }
        }

        ShowElapsed -Reset | Out-Null
        'Test(s) selected: {0}' -f $( $Test -join ', ') | Write-Log
        'Load Example data from module M365DSC.CompositeResources' | Write-Log

        $Module = Get-Module M365DSC.CompositeResources
        if ($null -eq $Module)
        {
            $Module = Get-Module -ListAvailable M365DSC.CompositeResources
            if ($null -eq $Module)
            {
                throw "Cannot find module 'M365DSC.CompositeResources'"
            }
        }
        $pathM365DataExample = Join-Path -Path ($Module.path | Split-Path) -ChildPath 'M365ConfigurationDataExample.psd1'
        $objM365DataExample = Import-PSDataFile -path $pathM365DataExample.ToString()

        ShowElapsed | Write-Log -Debug

        'Create Hashtables for reference data ' | Write-Log
        [hashtable]$ht = @{}
        [hashtable]$htRequired = @{}
        $nodeObject = $inputObject | Get-ChildNode
        foreach ($node in $nodeObject)
        {
            foreach ($node in $($node | Get-ChildNode))
            {
                # Create Hashtabel Exampledata
                $objM365DataExample | Get-node("$($node.path)") | Get-ChildNode -Recurse -IncludeSelf | ForEach-Object {
                    $ht["$($_.path)"] = $_
                    # Create HashTable Required
                    if ($Required)
                    {
                        if ($_ -is [PSLeafnode])
                        {
                            if ($_.value -match '\| Required \|')
                            {
                                $parentPath = $_.parentnode.path.ToString()
                                if (-not $htRequired.Contains("$parentPath"))
                                {
                                    $htRequired["$parentPath"] = [System.Collections.Generic.List[psnode]]::new()
                                }
                                $htRequired["$parentPath"].add( $_ )
                            }
                        }
                    }
                }
            }
        }

        ShowElapsed | Write-Log -Debug

        'Create pester rules' | Write-Log

        $pesterConfig = @(
            '#Requires -Modules Pester'
            'Describe ''--- Check M365-DSC-CompositeResources configuration ---'' {'
            if ($Typevalue -or $Required)
            {
                ' Context ''AllNodes'' {'

                ' }'
                ' Context ''NonNodeData'' {'
                foreach ($workload in ($inputObject | Get-Node 'NonNodeData' | Get-ChildNode))
                {
                    ' Context ''{0}'' {{' -f $workload.Path
                    ' It ''{0}'' {{' -f $workload.Path
                    Create_PesterNode -nodeObject $workload | ForEach-Object { ' {0}' -f $_ }
                    ' }'
                    if ($workload -is [psCollectionNode])
                    {
                        foreach ($workloadFolder in ($workload | Get-ChildNode))
                        {
                            ' It ''{0}'' {{' -f $workloadFolder.Path
                            Create_PesterNode -nodeObject $workloadFolder -recursive | ForEach-Object { ' {0}' -f $_ }
                            ' }'
                        }
                    }
                    ' }'
                }
                ' }'
            }
            if ($Mandatory)
            {
                ' Context ''Mandatory'' {'
                ' It ''Mandatory'' {'
                Create_Pesternodes_Mandatory -MandatoryObject $($MandatoryObject | get-node) | ForEach-Object { ' {0}' -f $_ }
                ' }'
                ' }'
            }
            '}'
        )

        ShowElapsed | Write-Log -Debug

        # Remove empty lines from the $pesterConfig variable
        $pesterConfig = $pesterConfig | Where-Object { $_.Trim() -ne '' }

        try
        {
            # Ensure that $pesterConfig is defined before continuing
            if (-not $pesterConfig)
            {
                throw "The variable `\$pesterConfig` is not defined."
            }

            # Create a temporary Pester script file for test execution
            $pesterScriptPath = [System.IO.Path]::ChangeExtension((New-TemporaryFile).FullName, '.tests.ps1')
            $pesterConfig | Out-File -FilePath $pesterScriptPath -Force -Confirm:$false -Encoding ascii

            # Open the generated Pester script in Visual Studio Code
            if ($pesterShowScript)
            {
                psedit $pesterScriptPath
            }

            # Set parameters for the Pester container that manages the test execution
            $pesterParams = [ordered]@{ Path = $pesterScriptPath }
            $pesterContainer = New-PesterContainer @pesterParams

            # Execute the Pester tests and store the results in the $pesterResult variable
            $pesterConfiguration = [PesterConfiguration]@{
                Run    = @{ Container = $pesterContainer; PassThru = $true }
                Should = @{ ErrorAction = 'Continue' }
                Output = @{ Verbosity = $pesterVerbosity ; StackTraceVerbosity = $pesterStackTraceVerbosity }
            }

            # Execute Pester tests and store results
            'Execute pesterscript' | Write-Log
            $pesterResult = Invoke-Pester -Configuration $pesterConfiguration
            if ($PesterOutputObject)
            {
                return $pesterResult
            }
        }
        catch
        {
            Write-Error "An error occurred: $_"
        }
        finally
        {
            # Remove the temporary Pester script file after the tests have been executed
            if (Test-Path -Path $pesterScriptPath)
            {
                Remove-Item -Path $pesterScriptPath -Force -ErrorAction SilentlyContinue
            }

            ShowElapsed | Write-Log -Verbose
            # Log the test results, with failure handling if any tests have failed
            if ($pesterResult.FailedCount -gt 0)
            {
                $splat = @{ Failure = $true }
            }
            else
            {
                $splat = @{}
            }
            'Pester[{0}] Tests:{1} Passed:{2} Failed:{3} ' -f $pesterResult.version, $pesterResult.TotalCount, $pesterResult.PassedCount, $pesterResult.FailedCount | Write-Log @splat
        }

        ShowElapsed | Write-Log -Debug
    }
}
#EndRegion './Public/Test-M365DSCPowershellDataFile.ps1' 551
#Region './Public/Write-Log.ps1' -1

function Write-Log
{
<#
.SYNOPSIS
    Dispatches log information

.DESCRIPTION
    Write log information to the console so that it can be picked up by the deployment system
    The information written to the (host) display uses the following format:

    yyyy-MM-dd HH:mm:ss [Labels[]]<ScriptName>: <Message>

    Where:
    * yyyy-MM-dd HH:mm:ss is the sortable date/time where the log entry occurred
    * [Labels[]] represents one or more of the following colored labels:
        [ERROR]
        [FAILURE]
        [WARNING]
        [INFO]
        [DEBUG]
        [VERBOSE]
        [WHATIF]
        Note that each label could be combined with another label except for the [ERROR] and [FAILURE]
        which are exclusive and the [INFO] label which only set if none of the other labels applies
        (See also the -Warning and -Failure parameter)
    * <ScriptName> represents the script that called this Write-Log cmdlet
    * <Message> is a string representation of the -Object parameter
        Note that if the -Object contains an [ErrorRecord] type, the error label is set and the error
        record is output in a single line:

        at <LineNumber> char:<Offset> <Error Statement> <Error Message>

        Where:
        * <LineNumber> represents the line where the error occurred
        * <Offset> represents the offset in the line where the error occurred
        * <Error Statement> represents the statement that caused the error
        * <error message> represents the description of the error

.PARAMETER Object
    Writes the object as a string to the host from a script or command.
    If the object is of an [ErrorRecord] type, the [ERROR] label will be added and the error
    name and position are written to the host from a script or command unless the $ErrorPreference
    is set to SilentlyContinue.

.PARAMETER Warning
    Writes warning messages to the host from a script or command unless the $WarningPreference
    is set to SilentlyContinue.

.PARAMETER Failure
    Writes failure messages to the host from a script or command unless the $ErrorPreference
    is set to SilentlyContinue.

    Note that the common parameters -Debug and -Verbose have a simular behavor as the -Warning
    and -Failure Parameter and will not be shown if the corresponding $<name>preference variable
    is set to 'SilentlyContinue'.

.PARAMETER Path
    The path to a log file. If set, all the output is also sent to a log file for all the following
    log commands. Use an empty path to stop file logging for the current session: `-Path ''`

    Note that environment variables (as e.g. '%Temp%\My.Log') are expanded.

.PARAMETER Tee
    Logs (displays) the output and also sends it down the pipeline.

.PARAMETER WriteActivity
    By default, the current activity (message) is only exposed (using the Write-Progress cmdlet)
    when it is invoked from the deployment system. This switch (-WriteActivity or -WriteActivity:$False)
    will overrule the default behavior.

.PARAMETER WriteEvent
    When set, this cmdlet will also write the message to the Windows Application EventLog.
    Where:
    * If the [EventSource] parameter is ommited, the Source will be "Automation"
    * The Category represents the concerned labels:
        Info = 0
        Verbose = 1
        Debug = 2
        WhatIf = 4
        Warning = 8
        Failure = 16
        Error = 32
    * The Message is a string representation of the object
    * If [EventId] parameter is ommited, the EventID will be a 32bit hashcode based on the message
    * EventType is "Error" in case of an error or when the -Failure parameter is set,
        otherwise "Warning" if the -Warning parameter is set and "Information" by default.

    Note 1: logging Windows Events, requires elevated rights if the event source does not yet exist.
    Note 2: This parameter is not required if the [EventSource] - or [EventId] parameter is supplied.

.PARAMETER EventSource
    When defined, this cmdlet will also write the message to the given EventSource in the
    Windows Application EventLog. For details see the [WriteEvent] parameter.

.PARAMETER EventId
    When defined, this cmdlet will also write the message Windows Application EventLog using the
    specified EventId. For details see the [WriteEvent] parameter.

.PARAMETER Type
    This parameter will show if the log information is from type INFO, WARNING or Error.
    * Warning: this parameter is depleted, use the corresponding switch as e.g. `-Warning`.

.PARAMETER Message
    This parameter contains the message that wil be shown.
    * Warning: this parameter is depleted, use the `-Object` parameter instead.

.PARAMETER FilePath
    This parameter contains the log file path.
    * Warning: this parameter is depleted, use the `-Path` parameter instead.

.EXAMPLE
    # Log a message

    Displays the following entry and updates the progress activity in the deployment system:

        Write-Log 'Deploying VM'
        2022-08-10 11:56:12 [INFO] MyScript: Deploying VM

.EXAMPLE
    # Log and save a warning

    Displays `File not found` with a `[WARNING]` as shown below, updates the progress activity
    in the deployment system. Besides, it writes the warning to the file: c:\temp\log.txt and
    create and add an entry to the EventLog.

        Write-Log -Warning 'File not found' -Path c:\temp\log.txt -WriteEvent
        2022-08-10 12:03:51 [WARNING] MyScript: File not found

.EXAMPLE
    # Log and capture a message

    Displays `my message` as shown below and capture the message in the `$Log` variable.

        $Log = Write-Log 'My message' -Tee
        2022-08-10 12:03:51 [INFO] MyScript: File not found
#>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')]
    [CmdletBinding(DefaultParameterSetName = 'Warning')]
    param
    (
        [Parameter(ParameterSetName = 'Warning', Position = 0, ValueFromPipeline = $true)]
        [Parameter(ParameterSetName = 'Failure', Position = 0, ValueFromPipeline = $true)]
        $Object,

        [Parameter(ParameterSetName = 'Warning')]
        [switch] $Warning,

        [Parameter(ParameterSetName = 'Failure')]
        [switch] $Failure,

        [Parameter(ParameterSetName = 'Warning')]
        [Parameter(ParameterSetName = 'Failure')]
        [string] $Path,

        [Parameter(ParameterSetName = 'Warning')]
        [Parameter(ParameterSetName = 'Failure')]
        [switch] $WriteActivity,

        [Parameter(ParameterSetName = 'Warning')]
        [Parameter(ParameterSetName = 'Failure')]
        [switch] $WriteEvent,

        [Parameter(ParameterSetName = 'Warning')]
        [Parameter(ParameterSetName = 'Failure')]
        [string] $EventSource = 'Automation',

        [Parameter(ParameterSetName = 'Warning')]
        [Parameter(ParameterSetName = 'Failure')]
        [int] $EventId = -1,

        [Parameter(ParameterSetName = 'Warning')]
        [Parameter(ParameterSetName = 'Failure')]
        [switch] $Tee,

        [Parameter(ParameterSetName = 'Legacy', Position = 0, Mandatory = $true)]
        [Validateset('INFO', 'WARNING', 'ERROR', 'DEBUG')]
        [Alias('LogType')][string] $Type,

        [Parameter(ParameterSetName = 'Legacy', Position = 1, Mandatory = $true)]
        [string]$Message,

        [Parameter(ParameterSetName = 'Legacy')]
        [Alias('LogPath')][string] $FilePath
    )

    begin
    {
        if (!$Global:WriteLog)
        {
            $Global:WriteLog = @{}
        }

        $PSCallStack = Get-PSCallStack
        $Commands = @($PSCallStack.Command)
        $Me = $Commands[0]
        $Caller = if ($Commands -gt 1)
        {
            $Commands[1..($Commands.Length)].where({ $_ -ne $Me }, 'First')
        }
        if (!$Caller)
        {
            $Caller = ''
        } # Prevent that the array index evaluates to null.
        $MeAgain = $Commands -gt 2 -and $Commands[2] -eq $Me

        if (!$Global:WriteLog.Contains($Caller))
        {
            # if ($PSCmdlet.ParameterSetName -eq 'Legacy') {
            # Write-Log -Warning "Use the new implementation: $($MyInvocation.MyCommand) [-Warning|-Failure] 'message'"
            # }
            $Global:WriteLog[$Caller] = @{}
        }

        if ($PSCmdlet.ParameterSetName -eq 'Legacy')
        {

            switch ($Type)
            {
                'INFO'
                {
                    $TypeColor = 'Green'; $ThrowError = $false
                }
                'WARNING'
                {
                    $TypeColor = 'Yellow'; $ThrowError = $false
                }
                'DEBUG'
                {
                    $TypeColor = 'Cyan'; $ThrowError = $false
                }
                'ERROR'
                {
                    $TypeColor = 'Red'; $ThrowError = $true
                }
            }

            $ChunksEntry = $(Get-Date -Format '[dd-MM-yyyy][HH:mm:ss]') + $('[' + $Type.padright(7) + '] ')

            # Exit script if "$Type -eq "DEBUG" -and $VerbosePreference -eq "SilentlyContinue"
            if ($Type -eq 'DEBUG' -and $VerbosePreference -eq 'SilentlyContinue')
            {
                return
            }

            Write-Host $ChunksEntry -ForegroundColor $TypeColor -NoNewline
            if ($ThrowError)
            {
                Write-Error $Message
            }
            else
            {
                Write-Host $Message
            }

            if ($FilePath)
            {
                Try
                {
                    $($ChunksEntry + $Message) | Out-File -FilePath $FilePath -Append
                }
                Catch
                {
                    Write-Log -Warning "Can not write to logfile $FilePath"
                }
            }
        }
        else
        {
            [Flags()] enum EventFlag
            {
                Info = 0
                Verbose = 1
                Debug = 2
                WhatIf = 4
                Warning = 8
                Failure = 16
                Error = 32
            }

            $IsVerbose = $PSBoundParameters.Verbose.IsPresent
            $VerboseMode = $IsVerbose -and $PSCmdlet.SessionState.PSVariable.Get('VerbosePreference').Value -ne 'SilentlyContinue'

            $IsDebug = $PSBoundParameters.Debug.IsPresent
            $DebugMode = $IsDebug -and $PSCmdlet.SessionState.PSVariable.Get('DebugPreference').Value -ne 'SilentlyContinue'

            $WhatIfMode = $PSCmdlet.SessionState.PSVariable.Get('WhatIfPreference').Value

            $WriteEvent = $WriteEvent -or $PSBoundParameters.ContainsKey('EventSource') -or $PSBoundParameters.ContainsKey('EventID')
            if ($PSBoundParameters.ContainsKey('Path'))
            {
                $Global:WriteLog[$Caller].Path = [System.Environment]::ExpandEnvironmentVariables($Path)
            } # Reset with: -Path ''
        }

        function WriteLog
        {
            if ($Failure -and !$Object)
            {
                $Object = if ($Error.Count)
                {
                    $Error[0]
                }
                else
                {
                    '<No error found>'
                }
            }

            $IsError = $Object -is [System.Management.Automation.ErrorRecord]

            $Category = [EventFlag]::new(); $EventType = 'Information'
            if ($ErrorPreference -ne 'SilentlyContinue' -and $IsError)
            {
                $Category += [EventFlag]::Error
            }
            if ($ErrorPreference -ne 'SilentlyContinue' -and $Failure)
            {
                $Category += [EventFlag]::Failure
            }
            if ($WarningPreference -ne 'SilentlyContinue' -and $Warning)
            {
                $Category += [EventFlag]::Warning
            }
            if ($IsDebug)
            {
                $Category += [EventFlag]::Debug
            }
            if ($IsVerbose)
            {
                $Category += [EventFlag]::Verbose
            }
            if ($WhatIfMode)
            {
                $Category += [EventFlag]::WhatIf
            }
            $IsInfo = !$Category

            $ColorText = [System.Collections.Generic.List[HashTable]]::new()
            $ColorText.Add( @{ Object = Get-Date -Format 'yyyy-MM-dd HH:mm:ss ' } )

            if ($IsError)
            {
                $ColorText.Add(@{ BackgroundColor = 'Red'; ForegroundColor = 'Black'; Object = '[ERROR]' })
            }
            elseif ($Failure)
            {
                $ColorText.Add(@{ BackgroundColor = 'Red'; ForegroundColor = 'Black'; Object = '[FAILURE]' })
            }
            if ($Warning)
            {
                $ColorText.Add(@{ BackgroundColor = 'Yellow'; ForegroundColor = 'Black'; Object = '[WARNING]' })
            }
            if ($IsInfo)
            {
                $ColorText.Add(@{ BackgroundColor = 'Green'; ForegroundColor = 'Black'; Object = '[INFO]' })
            }
            if ($IsDebug)
            {
                $ColorText.Add(@{ BackgroundColor = 'Cyan'; ForegroundColor = 'Black'; Object = '[DEBUG]' })
            }
            if ($IsVerbose)
            {
                $ColorText.Add(@{ BackgroundColor = 'Blue'; ForegroundColor = 'Black'; Object = '[VERBOSE]' })
            }
            if ($WhatIfMode)
            {
                $ColorText.Add(@{ BackgroundColor = 'Magenta'; ForegroundColor = 'Black'; Object = '[WHATIF]' })
            }

            if ($Caller -and $Caller -ne '<ScriptBlock>')
            {
                $ColorText.Add( @{ Object = " $($Caller):" } )
            }

            $ColorText.Add( @{ Object = ' ' } )
            if ($IsError)
            {
                $Info = $Object.InvocationInfo
                $ColorText.Add(@{ BackgroundColor = 'Black'; ForegroundColor = 'Red'; Object = " $Object" })
                $ColorText.Add(@{ Object = " at $($Info.ScriptName) line:$($Info.ScriptLineNumber) char:$($Info.OffsetInLine) " })
                $ColorText.Add(@{ BackgroundColor = 'Black'; ForegroundColor = 'White'; Object = $Info.Line.Trim() })
            }
            elseif ($Failure)
            {
                $ColorText.Add(@{ ForegroundColor = 'Red'; Object = $Object; BackgroundColor = 'Black' })
            }
            elseif ($Warning)
            {
                $ColorText.Add(@{ ForegroundColor = 'Yellow'; Object = $Object })
            }
            elseif ($DebugMode)
            {
                $ColorText.Add(@{ ForegroundColor = 'Cyan'; Object = $Object })
            }
            elseif ($VerboseMode)
            {
                $ColorText.Add(@{ ForegroundColor = 'Green'; Object = $Object })
            }
            else
            {
                $ColorText.Add(@{ Object = $Object })
            }

            foreach ($ColorItem in $ColorText)
            {
                Write-Host -NoNewline @ColorItem
            }
            Write-Host # New line

            if ($Tee)
            {
                -Join $ColorText.Object
            }
            $Message = -Join $ColorText[1..99].Object # Skip the date/time
            if ($WriteActivity)
            {
                Write-Progress -Activity $Message
            }
            if ($WriteEvent)
            {
                $SourceExists = Try
                {
                    [System.Diagnostics.EventLog]::SourceExists($EventSource)
                }
                Catch
                {
                    $False
                }
                if (!$SourceExists)
                {
                    $WindowsIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
                    $WindowsPrincipal = [System.Security.Principal.WindowsPrincipal]::new($WindowsIdentity)
                    if ($WindowsPrincipal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator))
                    {
                        New-EventLog -LogName 'Application' -Source $EventSource
                        $SourceExists = Try
                        {
                            [System.Diagnostics.EventLog]::SourceExists($EventSource)
                        }
                        Catch
                        {
                            $False
                        }
                    }
                    else
                    {
                        Write-Log -Warning "The EventLog ""$EventSource"" should exist or administrator rights are required"
                    }
                }
                if ($SourceExists)
                {
                    if ($EventID -eq -1)
                    {
                        $EventID = if ($Null -ne $Object)
                        {
                            "$Object".GetHashCode() -bAnd 0xffff
                        }
                        Else
                        {
                            0
                        }
                    }
                    $EventType =
                    if ($Category.HasFlag([EventFlag]::Error))
                    {
                        'Error'
                    }
                    elseif ($Category.HasFlag([EventFlag]::Failure))
                    {
                        'Error'
                    }
                    elseif ($Category.HasFlag([EventFlag]::Warning))
                    {
                        'Warning'
                    }
                    else
                    {
                        'Information'
                    }
                    Write-EventLog -LogName 'Application' -Source $EventSource -Category $Category -EventId $EventId -EntryType $EventType -Message $Message
                }
            }
            if ($Global:WriteLog[$Caller].Path)
            {
                Try
                {
                    Add-Content -Path $Global:WriteLog[$Caller].Path -Value (-Join $ColorText.Object)
                }
                Catch
                {
                    Write-Log -Warning "Can not write to logfile $FilePath"
                }
            }
        }
    }

    process
    {
        if ($PSCmdlet.ParameterSetName -ne 'Legacy' -and !$MeAgain)
        {
            if (!$IsVerbose -and !$IsDebug)
            {
                WriteLog
            }
            elseif ($VerboseMode)
            {
                WriteLog
            }
            elseif ($DebugMode)
            {
                WriteLog
            }
        }
    }
}
#EndRegion './Public/Write-Log.ps1' 518