M365DSCTools.psm1

#Region './Private/Merge-Array.ps1' 0
<#
 .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
#>

function Merge-Array
{
    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('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' 80
#Region './Private/Merge-Hashtable.ps1' 0
<#
 .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
#>

function Merge-Hashtable
{
    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' 0
<#
 .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' 0
<#
 .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'
#>

function Add-ModulesToBlobStorage
{
    [CmdletBinding()]
    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
    )

    $script:level++
    Write-LogEntry -Message 'Upload Microsoft365DSC module dependencies to storage container' -Level $script:level

    $script:level++
    Write-LogEntry -Message "Connecting to storage account '$StorageAccountName'" -Level $script:level
    $storageAcc = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -Name $StorageAccountName

    Write-LogEntry -Message 'Retrieving storage account context' -Level $script:level
    $context = $storageAcc.Context

    Write-LogEntry -Message 'Checking dependencies' -Level $script:level
    $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-LogEntry -Message 'Downloading dependencies' -Level $script:level
        $script:level++

        $savePath = Join-Path -Path $env:TEMP -ChildPath 'M365DSCModules'
        if ((Test-Path -Path $savePath) -eq $false) {
            $null = New-Item -Path $savePath -ItemType 'Directory'
        }

        Write-LogEntry -Message ('Saving module {0} (v{1})' -f $m365Module.Name, $m365Module.Version.ToString()) -Level $script:level
        Save-Module -Name $m365Module.Name -RequiredVersion $m365Module.Version.ToString() -Path $savePath

        $data = Import-PowerShellDataFile -Path $dependenciesPath
        foreach ($dependency in $data.Dependencies) {
            Write-LogEntry -Message ('Saving module {0} (v{1})' -f $dependency.ModuleName, $dependency.RequiredVersion) -Level $script:level
            Save-Module -Name $dependency.ModuleName -RequiredVersion $dependency.RequiredVersion -Path $savePath
        }
        $script:level--

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

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

        Write-LogEntry -Message 'Removing temporary components' -Level $script:level
        Remove-Item -Path $savePath -Recurse -Confirm:$false -Force
        Remove-Item -Path $zipFilePath -Confirm:$false
    }
    else {
        Write-LogEntry -Message '[ERROR] Dependencies\Manifest.psd1 file not found' -Level $script:level
    }
    $script:level--
    $script:level--
}
#EndRegion './Public/Add-ModulesToBlobStorage.ps1' 112
#Region './Public/Get-ModulesFromBlobStorage.ps1' 0
<#
 .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
#>

function Get-ModulesFromBlobStorage
{
    [CmdletBinding()]
    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
    )

    $script:level++
    Write-LogEntry -Message "Download dependencies from storage container for Microsoft365DSC v$Version." -Level $script:level

    $script:level++
    Write-LogEntry -Message "Connecting to storage account '$StorageAccountName'" -Level $script:level
    $storageAcc = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -Name $StorageAccountName

    Write-LogEntry -Message 'Retrieving storage account context' -Level $script:level
    $context = $storageAcc.Context

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

    Write-LogEntry -Message 'Downloading blob contents from the container' -Level $script:level
    $prefix = 'M365DSCDependencies-' + ($Version -replace '\.', '_')
    $blobContent = Get-AzStorageBlob -Container $ContainerName -Context $context -Prefix $prefix

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

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

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

            Write-LogEntry "Deploying module $($module.Name)" -Level $script:level
            $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
            $script:level--
        }

        Write-LogEntry -Message 'Removing temporary components' -Level $script:level
        Remove-Item -Path $extractPath -Recurse -Confirm:$false
        Remove-Item -Path $destination -Recurse -Confirm:$false
    }
    $script:level--
    $script:level--
    $script:level--
}
#EndRegion './Public/Get-ModulesFromBlobStorage.ps1' 120
#Region './Public/Merge-DataFile.ps1' 0
<#
 .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
#>

function Merge-DataFile
{
    [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