M365DSCTools.psm1
#Region './Private/Convert-M365WorkloadName.ps1' -1 $DSC_Workloads = @{ 'AzureAD' = 'AAD' 'Exchange' = 'EXO' 'Intune' = 'Intune' 'Office365' = 'O365' 'OneDrive' = 'OD' 'Planner' = 'Planner' 'PowerPlatform' = 'PP' 'SecurityCompliance' = 'SC' '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' 46 #Region './Private/Convert-PesterType.ps1' -1 function Convert-PesterType { <# .Synopsis Converts variable types used in DSC to types that can be used in Pester tests .Description This function converts types that are used in DSC to types that can be used in Pester tests. For example DSC supports an SInt32 type, which is unknown to Pester. .Parameter Type The Type that needs to be converted .Example Convert-PesterType -Type 'SInt32' #> [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [string] $Type ) 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'" } 'StringArray' { return "should -BeOfType 'String'" } 'Guid' { return "Test-IsGuid | should -Be 'True'" } default { return "should -BeOfType '$type'" } } } #EndRegion './Private/Convert-PesterType.ps1' 58 #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.PathName -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('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' -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/Test-IsGuid.ps1' -1 function Test-IsGuid { <# .Synopsis Tests is a string is a valid GUID .Description This function tests if a provided string is actually a valid GUID. .Parameter StringGuid String that needs to get tested if it is a valid GUID .Example Test-IsGuid -StringGuid '4756d311-220b-4e1d-ae47-8718a08ad16c' #> [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory = $true, ValueFromPipeLine = $True)] [string] $StringGuid ) process { $ObjectGuid = [System.Guid]::empty return [System.Guid]::TryParse($StringGuid,[System.Management.Automation.PSReference]$ObjectGuid) # Returns True if successfully parsed } } #EndRegion './Private/Test-IsGuid.ps1' 31 #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' 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) { 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' 115 #Region './Public/Convert-M365DSCExportToPowerShellDataFile.ps1' -1 $DSC_ExcludeKeys = @( 'ResourceInstanceName', 'ResourceName', 'ApplicationId', 'CertificateThumbprint', 'TenantId', 'IsSingleInstance' ) 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 (Join-Path -Path ($M365DSCCRModule.Path | Split-Path) -ChildPath 'M365ConfigurationDataExample.psd1') # 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 = Get-Node -InputObject $Obj_Result $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' -MaxDepth 20 # Check if data is available if ($Obj_Result.NonNodeData.$Workload) { $Obj_Result | ConvertTo-Expression -Depth 20 -Expand 20 -IndentSize 4 | Out-File $Path_CompositeConfig -Force -Confirm:$false -Encoding ascii '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' 179 #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. .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 ) #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 = 'anyOrder' Instructions = 'Please approve if you agree with the deployment.' MinRequiredApprovers = 1 RequesterCannotBeApprover = $true Timeout = 14400 } $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 # 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' 465 #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 -OutFile $YamlPath -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-M365PowerShellDataFile.ps1' -1 function Test-M365PowershellDataFile { <# .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 InputObject The object that contains the data object that needs to be tested .Parameter PesterScript Specify if the created Pester scripts will be displayed or not. .Parameter Exclude_Required All required items that have to be ignored, for example the UniqueID parameter. .Parameter Exclude_AvailableAsResource All items that are available as a resource and have to be ignored. .Example Test-M365PowershellDataFile -InputObject $M365DSCData -PesterScript #> [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $True)] $InputObject, [Parameter()] [Switch] $PesterScript, [Parameter()] [System.Array] $Exclude_Required, [Parameter()] [System.Array] $Exclude_AvailableAsResource ) 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 } } process { $Leaf_Parents = [System.Collections.Generic.List[String]]::new() # Load Example data from module M365DSC.CompositeResources $Obj_M365DataExample = Import-PSDataFile -Path (((Get-Module -ListAvailable M365DSC.CompositeResources).path | Split-Path) + '\M365ConfigurationDataExample.psd1') $Node_Root = Get-Node -InputObject $InputObject $Pester_Config = @( '#Requires -Modules Pester' "Describe '--- Check M365-DSC-CompositeResources configuration ---' {" #NonNodeData $Node_NonNodeData = $Node_Root._('NonNodedata') ($Node_NonNodeData | Get-ChildNode).ForEach{ " Context '{0}' {{" -f $_.name ($_ | Get-ChildNode).foreach{ " It '{0}' {{" -f $_.name ($_ | Get-ChildNode -Recurse -Leaf).ForEach{ # First Time in this parent Leaf node if ( $Leaf_Parents -notcontains ($_.parentnode.PathName) ) { $Leaf_Parents.add($_.parentnode.PathName) $Obj_Ref = Get-RefNodeExampleData -node $_.ParentNode -ReferenceObject $Obj_M365DataExample if ($null -ne $Obj_Ref) { $Required = $Obj_Ref.GetEnumerator() | Where-Object { $_.value -like '*| Required |*' } $Available = $_.parentnode.ChildNodes.name foreach ( $Item in $Required.name ) { if ( $Available -notcontains $Item -and $Exclude_Required -notContains $item ) { # if ( $Available -notcontains $Item -and $Exclude -notContains $item ) { if ($item -eq 'UniqueId') { #'" [-] Fix UniqueID "| write-host -ForegroundColor Yellow' -f $_.parentnode.pathname, $Item } " `$InputObject.{0}.{1} | Should -Not -BeNullOrEmpty -Because 'parameter {1} is required for {0}'" -f $_.parentnode.pathname, $Item } } } } if ($_.value -and ($Exclude_AvailableAsResource -notcontains $_.name )) { $Obj_Ref = Get-RefNodeExampleData -node $_ -ReferenceObject $Obj_M365DataExample # Type validation if ($Obj_Ref.type) { " `$InputObject.{0} | {1}" -f $_.Pathname, $(Convert-PesterType $Obj_Ref.type) } # validationSet validation if ($Obj_Ref.ValidateSet) { " `$InputObject.{0} | should -beIn {1}" -f $_.Pathname, $Obj_Ref.ValidateSet } # No Ref data if ( -not $Obj_Ref.type) { '" [-] {0} [not availabe as a Composite Resource]"| write-host -ForegroundColor darkyellow' -f $_.Pathname } } } ' }' } ' }' } '}' ) # Save for execute $Pester_Script = New-TemporaryFile | Rename-Item -NewName { [IO.Path]::ChangeExtension($_, '.tests.ps1') } -PassThru $Pester_Config | Out-File $Pester_Script -Force -Confirm:$false -Encoding:ascii # Show Result Pester Script in a VScode window if ($PesterScript) { psedit $Pester_Script } # Execute pester script $Params = [ordered]@{ Path = $Pester_Script } $Container = New-PesterContainer @Params $Configuration = [PesterConfiguration]@{ Run = @{ Container = $Container PassThru = $true } Should = @{ ErrorAction = "continue" } Output = @{ Verbosity = 'Detailed' StackTraceVerbosity = "Firstline" } } $result = Invoke-Pester -Configuration $Configuration # Clean temp file Remove-Item -Path $Pester_Script -Force -ErrorAction SilentlyContinue return $result } } #EndRegion './Public/Test-M365PowerShellDataFile.ps1' 179 #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 |