Modules/M365DSCExportUtil.psm1
|
$Script:M365DSCStringReplacementMap = @{} $Script:M365DSCMandatoryKeyCache = @{} $Script:M365DSCCompiledRegexCache = @{} $Script:M365DSCAuthenticationParameterSet = @{ ServicePrincipalWithThumbprint = @('ApplicationId', 'CertificateThumbprint', 'TenantId') ServicePrincipalWithSecret = @('ApplicationId', 'ApplicationSecret', 'TenantId') ServicePrincipalWithPath = @('ApplicationId', 'CertificatePath', 'CertificatePassword', 'TenantId') CredentialsWithTenantId = @('Credential', 'TenantId') CredentialsWithApplicationId = @('Credential', 'ApplicationId') Credentials = @('Credential') ManagedIdentity = @('ManagedIdentity', 'TenantId') AccessTokens = @('AccessTokens', 'TenantId') } $templatesPath = Join-Path -Path $PSScriptRoot -ChildPath 'M365DSCRelationTemplates.json' $jsonContent = Get-Content -Path $templatesPath -Raw | ConvertFrom-Json $Script:RelationTemplates = @{ templates = @{} } foreach ($template in $jsonContent.templates.psobject.Properties) { $Script:RelationTemplates.templates[$template.Name] = $template.Value } $allResourcesArgumentCompleter = Get-ChildItem -Path ($PSScriptRoot + '/../DscResources/') -Recurse -Filter '*.psm1' -File | Foreach-Object { $_.Name -replace 'MSFT_', '' -replace '.psm1', '' } Register-ArgumentCompleter -CommandName Export-M365DSCConfiguration -ParameterName Components -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $resources = $allResourcesArgumentCompleter -like "$wordToComplete*" foreach ($resource in $resources) { [System.Management.Automation.CompletionResult]::new($resource, $resource, 'ParameterValue', $resource) } } <# .SYNOPSIS Exports tenant configuration to Microsoft365DSC configuration content. .DESCRIPTION Entry point for ReverseDSC export. Validates authentication inputs, resolves target resources, executes extraction, and returns the generated configuration content. .PARAMETER LaunchWebUI Indicates that the export Web UI should be launched. .PARAMETER Path Specifies the output path for exported configuration files. .PARAMETER FileName Specifies the output configuration file name. .PARAMETER ConfigurationName Specifies the generated DSC configuration name. .PARAMETER Components Specifies component names to export. .PARAMETER ExcludeComponents Specifies component names to exclude. .PARAMETER Workloads Specifies workloads used to derive components. .PARAMETER Mode Specifies the export mode. .PARAMETER GenerateInfo Indicates whether informational metadata should be generated in export output. .PARAMETER Filters Specifies resource-level filters used during export. .PARAMETER ApplicationId Specifies the application id used for app-based authentication. .PARAMETER TenantId Specifies the tenant id or tenant domain used for authentication. .PARAMETER ApplicationSecret Specifies the application secret used for app-based authentication. .PARAMETER CertificateThumbprint Specifies the certificate thumbprint used for app-based authentication. .PARAMETER Credential Specifies delegated credentials used for authentication. .PARAMETER CertificatePassword Specifies the password used to read the certificate file. .PARAMETER CertificatePath Specifies the certificate file path used for app-based authentication. .PARAMETER ManagedIdentity Indicates that managed identity authentication should be used. .PARAMETER AccessTokens Specifies one or more pre-acquired access tokens. .PARAMETER SubscriptionId Specifies the Azure subscription id used by Azure resources. .PARAMETER Validate Indicates whether the exported configuration should be validated. .PARAMETER Parallel Indicates whether export should execute in parallel. .PARAMETER TokenReplacement Specifies token replacement mappings applied to exported content. .PARAMETER WithStatistics Indicates whether export statistics should be collected. .PARAMETER IncludeDependencies Indicates whether dependency extraction and DependsOn generation should run. .EXAMPLE PS> Export-M365DSCConfiguration -Components @("AADApplication", "AADConditionalAccessPolicy", "AADGroupsSettings") -Credential $Credential .EXAMPLE PS> Export-M365DSCConfiguration -Mode 'Default' -ApplicationId '2560bb7c-bc85-415f-a799-841e10ec4f9a' -TenantId 'contoso.sharepoint.com' -ApplicationSecret 'abcdefghijkl' .EXAMPLE PS> Export-M365DSCConfiguration -Components @("AADApplication", "AADConditionalAccessPolicy", "AADGroupsSettings") -Credential $Credential -Path 'C:\DSC' -FileName 'MyConfig.ps1' .EXAMPLE PS> Export-M365DSCConfiguration -Credential $Credential -Filters @{AADApplication = "DisplayName eq 'MyApp'"} -TokenReplacement @{ 'alternate-email.onmicrosoft.com' = 'AlternateEmail' } .EXAMPLE PS> Export-M365DSCConfiguration -Workloads @("SPO") -ExcludeComponents @("SPOPropertyBag") -Credential $Credential .EXAMPLE PS> Export-M365DSCConfiguration -Workloads @("SPO") -ApplicationId $clientId -TenantId $tenantName -CertificateThumbprint $certThumbprint -IncludeDependencies .FUNCTIONALITY Public #> function Export-M365DSCConfiguration { [CmdletBinding(DefaultParameterSetName = 'Export')] param ( [Parameter(ParameterSetName = 'WebUI')] [Switch] $LaunchWebUI, [Parameter(ParameterSetName = 'Export')] [System.String] $Path, [Parameter(ParameterSetName = 'Export')] [System.String] $FileName, [Parameter(ParameterSetName = 'Export')] [System.String] $ConfigurationName, [Parameter(ParameterSetName = 'Export')] [System.String[]] $Components, [Parameter(ParameterSetName = 'Export')] [System.String[]] $ExcludeComponents, [Parameter(ParameterSetName = 'Export')] [ValidateSet('AAD', 'ADO', 'AZURE', 'COMMERCE', 'DEFENDER', 'EXO', 'FABRIC', 'INTUNE', 'O365', 'OD', 'PLANNER', 'PP', 'SC', 'SENTINEL', 'SH', 'SPO', 'TEAMS', 'VIVA')] [System.String[]] $Workloads, [Parameter(ParameterSetName = 'Export')] [ValidateSet('Default', 'Full')] [System.String] $Mode = 'Default', [Parameter(ParameterSetName = 'Export')] [System.Boolean] $GenerateInfo = $false, [Parameter(ParameterSetName = 'Export')] [System.Collections.Hashtable] $Filters, [Parameter(ParameterSetName = 'Export')] [System.String] $ApplicationId, [Parameter(ParameterSetName = 'Export')] [ValidateScript({ $invalid = $false if ([System.Guid]::TryParse($_, [ref][System.Guid]::Empty)) { throw 'Please provide the tenant name (e.g., contoso.onmicrosoft.com) for TenantId instead of its GUID.' } $invalid = $_ -notmatch '.onmicrosoft.' if ($invalid) { Write-Warning -Message 'We recommend providing the TenantId property in the format of <tenant>.onmicrosoft.*' } return $true })] [System.String] $TenantId, [Parameter(ParameterSetName = 'Export')] [System.String] $ApplicationSecret, [Parameter(ParameterSetName = 'Export')] [System.String] $CertificateThumbprint, [Parameter(ParameterSetName = 'Export')] [System.Management.Automation.PSCredential] $Credential, [Parameter(ParameterSetName = 'Export')] [System.Management.Automation.PSCredential] $CertificatePassword, [Parameter(ParameterSetName = 'Export')] [System.String] $CertificatePath, [Parameter(ParameterSetName = 'Export')] [Switch] $ManagedIdentity, [Parameter(ParameterSetName = 'Export')] [System.String[]] $AccessTokens, [Parameter(ParameterSetName = 'Export')] [ValidateScript({ $Workloads -contains 'AZURE' -or ($Components -like "Azure*").Count -gt 0 })] [System.String] $SubscriptionId, [Parameter(ParameterSetName = 'Export')] [Switch] $Validate, [Parameter(ParameterSetName = 'Export')] [Switch] $Parallel, [Parameter(ParameterSetName = 'Export')] [System.Collections.Hashtable] $TokenReplacement, [Parameter(ParameterSetName = 'Export')] [Switch] $WithStatistics, [Parameter(ParameterSetName = 'Export')] [Switch] $IncludeDependencies ) if ($IncludeDependencies.IsPresent) { Write-Warning -Message "The -IncludeDependencies parameter is currently in preview. Please review the generated configuration to ensure it captures the dependencies as expected. If you encounter any issues or have feedback, please report it at https://github.com/Microsoft365DSC/Microsoft365DSC." } $currentStartDateTime = [System.DateTime]::Now $Global:M365DSCExportInProgress = $true $Global:MaximumFunctionCount = 32767 Clear-M365DSCHostMessageCache # Define the exported resource instances' names Global variable $Global:M365DSCExportedResourceInstancesNames = [System.Collections.Generic.HashSet[System.String]]::new([System.StringComparer]::OrdinalIgnoreCase) # Clear performance caches for fresh export $Script:M365DSCMandatoryKeyCache = @{} $Script:M365DSCCompiledRegexCache = @{} # Define the exported resource instances registry for DependsOn tracking $Global:M365DSCExportedResourceInstances = @{} # Define the export dependencies collector $Global:M365DSCExportDependencies = @() # LaunchWebUI specified, launching that now if ($LaunchWebUI) { Write-Output -InputObject "Launching web page 'https://export.microsoft365dsc.com'" explorer 'https://export.microsoft365dsc.com' return } # Suppress Progress overlays $Global:ProgressPreference = 'SilentlyContinue' # Check ErrorActionPreference - Azure DevOps and other Pipeline environments set it to 'Stop' by default if ($ErrorActionPreference -eq 'Stop' -and -not $PSBoundParameters.ContainsKey('ErrorAction')) { $ErrorActionPreference = 'Continue' } ##### FIRST CHECK AUTH PARAMETERS if ($PSBoundParameters.ContainsKey('Credential') -eq $true -and ` -not [System.String]::IsNullOrEmpty($Credential)) { if ($Credential.Username -notmatch '.onmicrosoft.') { Write-Warning -Message 'We recommend providing the username in the format of <tenant>.onmicrosoft.* for the Credential property.' } } if ($PSBoundParameters.ContainsKey('CertificatePath') -eq $true -and ` $PSBoundParameters.ContainsKey('CertificatePassword') -eq $false) { throw 'You have to specify CertificatePassword when you specify CertificatePath' } if ($PSBoundParameters.ContainsKey('CertificatePassword') -eq $true -and ` $PSBoundParameters.ContainsKey('CertificatePath') -eq $false) { throw 'You have to specify CertificatePath when you specify CertificatePassword' } if ($PSBoundParameters.ContainsKey('ApplicationId') -eq $true -and ` $PSBoundParameters.ContainsKey('Credential') -eq $false -and ` $PSBoundParameters.ContainsKey('TenantId') -eq $false) { throw 'You have to specify TenantId when you specify ApplicationId' } if ($PSBoundParameters.ContainsKey('ApplicationId') -eq $true -and ` $PSBoundParameters.ContainsKey('TenantId') -eq $true -and ` $PSBoundParameters.ContainsKey('Credential') -eq $false -and ` ($PSBoundParameters.ContainsKey('CertificateThumbprint') -eq $false -and ` $PSBoundParameters.ContainsKey('ApplicationSecret') -eq $false -and ` $PSBoundParameters.ContainsKey('CertificatePath') -eq $false)) { throw 'You have to specify ApplicationSecret, CertificateThumbprint or CertificatePath when you specify ApplicationId/TenantId' } if (($PSBoundParameters.ContainsKey('ApplicationId') -eq $false -or ` $PSBoundParameters.ContainsKey('TenantId') -eq $false) -and ` ($PSBoundParameters.ContainsKey('Credential') -eq $false -and ` $PSBoundParameters.ContainsKey('CertificateThumbprint') -eq $true -or ` $PSBoundParameters.ContainsKey('ApplicationSecret') -eq $true -or ` $PSBoundParameters.ContainsKey('CertificatePath') -eq $true)) { throw 'You have to specify ApplicationId and TenantId when you specify ApplicationSecret, CertificateThumbprint or CertificatePath' } # Default to Credential if no authentication mechanism were provided if ($PSBoundParameters.ContainsKey('Credential') -eq $false -and ` $ManagedIdentity.IsPresent -eq $false -and ` $PSBoundParameters.ContainsKey('ApplicationId') -eq $false -and ` $PSBoundParameters.ContainsKey('AccessTokens') -eq $false) { $Credential = Get-Credential } #region Telemetry $data = [System.Collections.Generic.Dictionary[[System.String], [System.Object]]]::new() $data.Add('Path', [System.String]::IsNullOrEmpty($Path)) $data.Add('FileName', $null -ne [System.String]::IsNullOrEmpty($FileName)) $data.Add('Components', $Components) $data.Add('Workloads', $Workloads) #endregion Confirm-M365DSCDependencies # Make sure we are not connected to Microsoft Graph on another tenant # except if connected through MSCloudLoginAssistant - it will handle the connection try { Confirm-M365DSCLoadedModule -ModuleName 'Microsoft.Graph.Authentication' $currentConnectionProfile = Get-MSCloudLoginConnectionProfile -Workload 'MicrosoftGraph' if ($null -ne (Get-MgContext) -and -not $currentConnectionProfile.Connected) { Disconnect-MgGraph -ErrorAction Stop | Out-Null Reset-MSCloudLoginConnectionProfileContext -Workload 'MicrosoftGraph' } } catch { Write-Verbose -Message 'No existing connections to Microsoft Graph' } $Tenant = Get-M365DSCTenantNameFromParameterSet -ParameterSet $PSBoundParameters $Script:ConnectionMode = Get-M365DSCAuthenticationMode $PSBoundParameters $data.Add('Tenant', $Tenant) $currentExportID = (New-Guid).ToString() $data.Add('M365DSCExportId', $currentExportID) $data.Add('ConnectionMode', $Script:ConnectionMode) $telemetryParams = Get-M365DSCTelemetryConnectionParameter # Define connection to Graph parameters because it is required by the telemetry. if ($null -eq $telemetryParams -or ` ($null -ne $telemetryParams -and ` $telemetryParams.Keys.Count -eq 0)) { $telemetryParams = @{ Credential = $Credential ApplicationId = $ApplicationId TenantId = $TenantId ApplicationSecret = $ApplicationSecret CertificateThumbprint = $CertificateThumbprint CertificatePassword = $CertificatePassword CertificatePath = $CertificatePath Identity = $ManagedIdentity.IsPresent AccessTokens = $AccessTokens } Set-M365DSCTelemetryConnectionParameter -Parameters $telemetryParams } Add-M365DSCTelemetryEvent -Type 'ExportInitiated' -Data $data Initialize-M365DSCAllResourcesDictionary if ($PSBoundParameters.ContainsKey('TokenReplacement')) { Set-M365DSCStringReplacementMap -Map $TokenReplacement } $resourceSettings = Get-M365DSCResourceSettings if ($null -ne $Workloads) { Write-M365DSCHost -Message "Exporting Microsoft 365 configuration for Workloads: $($Workloads -join ', ')" Start-M365DSCConfigurationExtract -Credential $Credential ` -Workloads $Workloads ` -ExcludeComponents $ExcludeComponents ` -Mode $Mode ` -Path $Path -FileName $FileName ` -ConfigurationName $ConfigurationName ` -ApplicationId $ApplicationId ` -ApplicationSecret $ApplicationSecret ` -TenantId $TenantId ` -CertificateThumbprint $CertificateThumbprint ` -CertificatePath $CertificatePath ` -CertificatePassword $CertificatePassword ` -ManagedIdentity:$ManagedIdentity.IsPresent ` -AccessTokens $AccessTokens ` -SubscriptionId $SubscriptionId ` -GenerateInfo $GenerateInfo ` -Filters $Filters ` -Validate:$Validate.IsPresent ` -Parallel:$Parallel.IsPresent ` -ResourceSettings $resourceSettings ` -ErrorAction $ErrorActionPreference ` -WithStatistics:$WithStatistics.IsPresent ` -IncludeDependencies:$IncludeDependencies.IsPresent } elseif ($null -ne $Components) { Write-M365DSCHost -Message "Exporting Microsoft 365 configuration for Components: $($Components -join ', ')" Start-M365DSCConfigurationExtract -Credential $Credential ` -Components $Components ` -ExcludeComponents $ExcludeComponents ` -Path $Path -FileName $FileName ` -ConfigurationName $ConfigurationName ` -ApplicationId $ApplicationId ` -ApplicationSecret $ApplicationSecret ` -TenantId $TenantId ` -CertificateThumbprint $CertificateThumbprint ` -CertificatePath $CertificatePath ` -CertificatePassword $CertificatePassword ` -ManagedIdentity:$ManagedIdentity.IsPresent ` -AccessTokens $AccessTokens ` -SubscriptionId $SubscriptionId ` -GenerateInfo $GenerateInfo ` -Filters $Filters ` -Validate:$Validate.IsPresent ` -Parallel:$Parallel.IsPresent ` -ResourceSettings $resourceSettings ` -ErrorAction $ErrorActionPreference ` -WithStatistics:$WithStatistics.IsPresent ` -IncludeDependencies:$IncludeDependencies.IsPresent } elseif ($null -ne $Mode) { Write-M365DSCHost -Message "Exporting Microsoft 365 configuration for Mode: $Mode" Start-M365DSCConfigurationExtract -Credential $Credential ` -Mode $Mode ` -ExcludeComponents $ExcludeComponents ` -Path $Path -FileName $FileName ` -ConfigurationName $ConfigurationName ` -ApplicationId $ApplicationId ` -ApplicationSecret $ApplicationSecret ` -TenantId $TenantId ` -CertificateThumbprint $CertificateThumbprint ` -CertificatePath $CertificatePath ` -CertificatePassword $CertificatePassword ` -ManagedIdentity:$ManagedIdentity.IsPresent ` -AccessTokens $AccessTokens ` -SubscriptionId $SubscriptionId ` -GenerateInfo $GenerateInfo ` -AllComponents ` -Filters $Filters ` -Validate:$Validate.IsPresent ` -Parallel:$Parallel.IsPresent ` -ResourceSettings $resourceSettings ` -ErrorAction $ErrorActionPreference ` -WithStatistics:$WithStatistics.IsPresent ` -IncludeDependencies:$IncludeDependencies.IsPresent } # Clear the exported resource instances' names Global variable $Global:M365DSCExportedResourceInstancesNames = $null $Global:M365DSCExportedResourceInstances = $null $Global:M365DSCExportDependencies = $null $Global:M365DSCExportInProgress = $false $data = [System.Collections.Generic.Dictionary[[System.String], [System.Object]]]::new() if ([System.String]::IsNullOrEmpty($data.Tenant) -and -not [System.String]::IsNullOrEmpty($TenantId)) { $data.Add('Tenant', $TenantId) } else { $data.Add('Tenant', $Tenant) } $data.Add('M365DSCExportId', $currentExportID) $data.Add('ConnectionMode', $Script:ConnectionMode) $timeTaken = [System.DateTime]::Now.Subtract($currentStartDateTime) $data.Add('TotalSeconds', $timeTaken.TotalSeconds) Add-M365DSCTelemetryEvent -Type 'ExportCompleted' -Data $data } <# .SYNOPSIS Returns exportable resource names for a selected export mode. .DESCRIPTION Filters resource settings by mode and optionally excludes configuration resources when Full mode is used. .PARAMETER Mode Specifies the export mode used to select resources. .PARAMETER ExcludeConfigurationResources Indicates that configuration-only resources should be excluded in Full mode. .EXAMPLE Get-M365DSCResourcesByExportMode -Mode 'Default' This command retrieves all resources that are available in the Default export mode. .EXAMPLE Get-M365DSCResourcesByExportMode -Mode 'Full' This command retrieves all resources that are available in the Full export mode. .OUTPUTS System.String[] #> function Get-M365DSCResourcesByExportMode { [CmdletBinding()] [OutputType([System.String[]])] param ( [Parameter(Mandatory = $true)] [ValidateSet('Default', 'Full')] [System.String] $Mode, [Parameter(Mandatory = $false)] [switch] $ExcludeConfigurationResources ) $resourceSettings = Get-M365DSCResourceSettings $resources = [System.Collections.Generic.List[System.String]]::new($resourceSettings.Keys.Count) foreach ($resource in $resourceSettings.Keys) { if ($Mode -eq 'Default' -and $resourceSettings[$resource].mode -eq 'Configuration') { $resources.Add($resource) } elseif ($Mode -eq 'Full') { if ($ExcludeConfigurationResources -and $resourceSettings[$resource].mode -eq 'Configuration') { continue } $resources.Add($resource) } } return $resources.ToArray() } <# .SYNOPSIS Builds DSC resource block content for a single exported resource instance. .DESCRIPTION Converts resource export results into DSC text content. It normalizes authentication fields, handles escaping rules, and emits resource block content for the target module. .PARAMETER ResourceName Specifies the resource name being rendered. .PARAMETER ConnectionMode Specifies the resolved authentication connection mode. .PARAMETER ModulePath Specifies the path to the resource module used during export rendering. .PARAMETER Results Specifies exported resource values to render. .PARAMETER Credential Specifies delegated credentials used for contextual rendering. .PARAMETER NoEscape Specifies property names that should not be string-escaped. .PARAMETER SkipAuthenticationUpdate Indicates that authentication fields should not be transformed. .PARAMETER AllowVariablesInStrings Indicates that variable placeholders may be preserved inside strings. .PARAMETER RawResults Specifies the original unprocessed export result values. .OUTPUTS System.String #> function Get-M365DSCExportContentForResource { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.String] $ResourceName, [Parameter(Mandatory = $true)] [System.String] [ValidateSet('ServicePrincipalWithThumbprint', 'ServicePrincipalWithSecret', 'ServicePrincipalWithPath', 'CredentialsWithTenantId', 'CredentialsWithApplicationId', 'Credentials', 'ManagedIdentity', 'AccessTokens')] $ConnectionMode, [Parameter(Mandatory = $true)] [System.String] $ModulePath, [Parameter(Mandatory = $true)] [System.Collections.Hashtable] $Results, [Parameter()] [System.Management.Automation.PSCredential] $Credential, [Parameter()] [System.String[]] $NoEscape, [Parameter()] [switch] $SkipAuthenticationUpdate, [Parameter()] [switch] $AllowVariablesInStrings, [Parameter()] [System.Collections.Hashtable] $RawResults ) $OrganizationName = '' if ($ConnectionMode -like 'ServicePrincipal*' -or ` $ConnectionMode -eq 'ManagedIdentity') { $OrganizationName = $Results.TenantId } elseif ($null -ne $Credential.UserName) { $OrganizationName = $Credential.UserName.Split('@')[1] } else { $OrganizationName = '' } if (-not $SkipAuthenticationUpdate) { $withoutAuthentication = Update-M365DSCExportAuthenticationResults -ConnectionMode $ConnectionMode ` -Results $Results $Results = $withoutAuthentication.Results $NoEscape += $withoutAuthentication.NoEscape } $NoEscape = $NoEscape | Select-Object -Unique $primaryKey = '' if ($Script:M365DSCMandatoryKeyCache.ContainsKey($ResourceName)) { $Keys = $Script:M365DSCMandatoryKeyCache[$ResourceName] } else { $Resource = (Get-M365DSCAllResourcesDictionary).$ResourceName $Keys = $Resource.Properties.Where({ $_.IsMandatory }) | Select-Object -ExpandProperty Name if ($null -eq $Keys) { $moduleFullName = 'MSFT_' + $ResourceName if (-not (Get-Module $moduleFullName)) { $m365dscModuleBase = (Get-Module -Name 'Microsoft365DSC').ModuleBase $moduleFullNamePath = Join-Path -Path $m365dscModuleBase -ChildPath "DscResources/$moduleFullName/$moduleFullName.psm1" Import-Module $moduleFullNamePath -Force } $cmdInfo = Get-Command $moduleFullName\Get-TargetResource -ErrorAction SilentlyContinue $Keys = $cmdInfo.Parameters.Values.Where({ $_.ParameterSets.Values.IsMandatory }).Name } $Script:M365DSCMandatoryKeyCache[$ResourceName] = $Keys } if ($Keys.Contains('IsSingleInstance')) { $primaryKey = '' } elseif ($Keys.Contains('DisplayName') -and -not [System.String]::IsNullOrEmpty($Results.DisplayName)) { $primaryKey = $Results.DisplayName } elseif ($Keys.Contains('Name')) { $primaryKey = $Results.Name } elseif ($Keys.Contains('Title')) { $primaryKey = $Results.Title } elseif ($Keys.Contains('Identity')) { $primaryKey = $Results.Identity } elseif ($Keys.Contains('Id')) { $primaryKey = $Results.Id } elseif ($Keys.Contains('CDNType')) { $primaryKey = $Results.CDNType } elseif ($Keys.Contains('WorkspaceName')) { $primaryKey = $Results.WorkspaceName } elseif ($Keys.Contains('OrganizationName')) { $primaryKey = $Results.OrganizationName } elseif ($Keys.Contains('DomainName')) { $primaryKey = $Results.DomainName } elseif ($Keys.Contains('UserPrincipalName')) { $primaryKey = $Results.UserPrincipalName } if ([String]::IsNullOrEmpty($primaryKey) -and -not $Keys.Contains('IsSingleInstance')) { foreach ($Key in $Keys) { $primaryKey += $Results.$Key } } $instanceName = $ResourceName if (-not [System.String]::IsNullOrEmpty($primaryKey)) { if ($AllowVariablesInStrings) { $primaryKey = $primaryKey.Replace('`', '``').Replace('"', '`"') } else { $primaryKey = $primaryKey.Replace('`', '``').Replace('$', '`$').Replace('"', '`"') } $primaryKey = Update-M365DSCSpecialCharacters -String $primaryKey $instanceName += "-$primaryKey" } if ($Results.ContainsKey('Workload')) { $instanceName += "-$($Results.Workload)" } # Check to see if a resource with this exact name was already exported, if so, append a number to the end. $i = 2 $tempName = $instanceName if ($null -eq $Global:M365DSCExportedResourceInstancesNames) { $Global:M365DSCExportedResourceInstancesNames = [System.Collections.Generic.HashSet[System.String]]::new([System.StringComparer]::OrdinalIgnoreCase) } while ($null -ne $Global:M365DSCExportedResourceInstancesNames -and ` $Global:M365DSCExportedResourceInstancesNames.Contains($tempName)) { $tempName = $instanceName + '-' + $i.ToString() $i++ } $instanceName = $tempName [void]$Global:M365DSCExportedResourceInstancesNames.Add($tempName) # Register this instance in the dependency tracking registry if ($null -ne $Global:M365DSCExportedResourceInstances) { $registryKey = "[$ResourceName]$instanceName" $Global:M365DSCExportedResourceInstances[$registryKey] = @{ InstanceName = $instanceName ResourceName = $ResourceName PrimaryKey = $primaryKey Results = $Results } } # Resolve cross-resource relations and register dependencies if ($null -ne $Global:M365DSCExportDependencies) { $resolveResults = $Results if ($null -ne $RawResults) { $resolveResults = $RawResults } Resolve-M365DSCExportRelations -ResourceName $ResourceName ` -InstanceName $instanceName ` -Results $resolveResults } $content = [System.Text.StringBuilder]::new() [void]$content.Append(" $ResourceName `"$instanceName`"`r`n") [void]$content.Append(" {`r`n") $partialContent = Get-DSCBlock -Params $Results -ModulePath $ModulePath -NoEscape $NoEscape -AllowVariablesInStrings:$AllowVariablesInStrings if ($partialContent.ToLower().IndexOf($OrganizationName.ToLower()) -gt 0) { if (-not $Script:M365DSCCompiledRegexCache.ContainsKey("OrgColon_$OrganizationName")) { $Script:M365DSCCompiledRegexCache["OrgColon_$OrganizationName"] = [regex]::new([regex]::Escape($OrganizationName + ':'), 'IgnoreCase, Compiled') $Script:M365DSCCompiledRegexCache["OrgAt_$OrganizationName"] = [regex]::new([regex]::Escape('@' + $OrganizationName), 'IgnoreCase, Compiled') $Script:M365DSCCompiledRegexCache["Org_$OrganizationName"] = [regex]::new([regex]::Escape($OrganizationName), 'IgnoreCase, Compiled') } $partialContent = $Script:M365DSCCompiledRegexCache["OrgColon_$OrganizationName"].Replace($partialContent, "`$(`$OrganizationName):") $partialContent = $Script:M365DSCCompiledRegexCache["OrgAt_$OrganizationName"].Replace($partialContent, "@`$OrganizationName") $partialContent = $Script:M365DSCCompiledRegexCache["Org_$OrganizationName"].Replace($partialContent, "`$OrganizationName") } # Apply additional string to variable replacements from mapping if ($Global:M365DSCStringReplacementMap) { Set-M365DSCStringReplacementMap -Map $Global:M365DSCStringReplacementMap } if ($null -ne $Script:M365DSCStringReplacementMap -and $Script:M365DSCStringReplacementMap.Count -gt 0) { foreach ($entry in $Script:M365DSCStringReplacementMap.GetEnumerator()) { $target = $entry.Key $varName = $entry.Value if ([System.String]::IsNullOrEmpty($target) -or [System.String]::IsNullOrEmpty($varName)) { Write-Verbose -Message "Skipping invalid string replacement map entry: Key = '$target', VariableName = '$varName'" continue } # Skip if already handled as OrganizationName if ($OrganizationName -and ($target -ieq $OrganizationName)) { Write-Verbose -Message "Skipping replacement for target [$target] because it matches the OrganizationName: '$OrganizationName'" continue } if ($partialContent.ToLower().IndexOf($target.ToLower()) -gt 0) { $cacheKeyBase = "Map_$target" if (-not $Script:M365DSCCompiledRegexCache.ContainsKey("${cacheKeyBase}_colon")) { $Script:M365DSCCompiledRegexCache["${cacheKeyBase}_colon"] = [regex]::new([regex]::Escape($target + ':'), 'IgnoreCase, Compiled') $Script:M365DSCCompiledRegexCache["${cacheKeyBase}_at"] = [regex]::new([regex]::Escape('@' + $target), 'IgnoreCase, Compiled') $Script:M365DSCCompiledRegexCache["${cacheKeyBase}_plain"] = [regex]::new([regex]::Escape($target), 'IgnoreCase, Compiled') } $partialContent = $Script:M365DSCCompiledRegexCache["${cacheKeyBase}_colon"].Replace($partialContent, "`$(`$ConfigurationData.NonNodeData.$varName):") $partialContent = $Script:M365DSCCompiledRegexCache["${cacheKeyBase}_at"].Replace($partialContent, "@`$(`$ConfigurationData.NonNodeData.$varName)") $partialContent = $Script:M365DSCCompiledRegexCache["${cacheKeyBase}_plain"].Replace($partialContent, "`$(`$ConfigurationData.NonNodeData.$varName)") } } } [void]$content.Append($partialContent) [void]$content.Append(" }`r`n") return $content.ToString() } <# .SYNOPSIS Updates the export string replacement map. .DESCRIPTION Merges replacement entries into the module string replacement map and optionally clears existing mappings first. .PARAMETER Map Specifies replacement mappings where key is source text and value is replacement token. .PARAMETER Clear Indicates that existing mappings should be cleared before applying Map. #> function Set-M365DSCStringReplacementMap { [CmdletBinding()] param( [Parameter()] [System.Collections.Hashtable] $Map, [Parameter()] [switch] $Clear ) if ($Clear) { $Script:M365DSCStringReplacementMap = @{} } if ($PSBoundParameters.ContainsKey('Map')) { foreach ($key in $Map.Keys) { $Script:M365DSCStringReplacementMap[$key] = $Map[$key] } } } <# .SYNOPSIS Returns the current export string replacement map. .DESCRIPTION Returns a clone of the in-memory map used for token replacement in exported content. .OUTPUTS System.Collections.Hashtable #> function Get-M365DSCStringReplacementMap { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param() return $Script:M365DSCStringReplacementMap.Clone() } <# .SYNOPSIS Joins split DSC configuration files into a single configuration content block. .DESCRIPTION This function is used to join two or more M365DSC configurations into a single configuration. The function reads the configuration from the specified paths and combines them into a single configuration. Please note that the function won't be updating the authentication parameters if they differ between the configurations. Make sure that the authentication parameters are the same over all configurations. .PARAMETER ConfigurationFile Specifies the base configuration file name. .PARAMETER ConfigurationPath Specifies the folder containing configuration files to merge. .EXAMPLE Join-M365DSCConfiguration -ConfigurationFile 'M365TenantConfig.ps1' -ConfigurationPath 'D:\testbed' This example joins the 'M365TenantConfig.ps1' file with all the configuration files in the 'D:\testbed' directory. .FUNCTIONALITY Public #> function Join-M365DSCConfiguration { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $ConfigurationFile, [Parameter(Mandatory = $true)] [string] $ConfigurationPath ) if ($ConfigurationFile -notlike '*.ps1') { throw 'The ConfigurationFile parameter must be a .ps1 file.' } if (-not (Test-Path -Path $ConfigurationPath)) { throw 'The ConfigurationPath parameter must be a valid path.' } $ConfigurationFilePath = Join-Path -Path $ConfigurationPath -ChildPath $ConfigurationFile $ConfigurationPath = Join-Path -Path $ConfigurationPath -ChildPath '*' $baseConfiguration = ConvertTo-DSCObject -Path $ConfigurationFilePath $additionalConfigurations = Get-Item -Path $ConfigurationPath -Filter *.ps1 -Exclude $ConfigurationFile | ForEach-Object { ConvertTo-DSCObject -Path $_.FullName } $combinedArray = @($baseConfiguration) + @($additionalConfigurations) $combinedConfiguration = ConvertFrom-DSCObject -DSCResources $combinedArray # Indent all lines by 8 spaces to match the indentation of the configuration file $combinedConfiguration = $combinedConfiguration -replace '(?m)^', ' ' $combinedConfiguration = $combinedConfiguration.TrimEnd() # Remove everything in the "Node localhost" part in the configuration file, while excluding the last two closing brackets $content = Get-Content -Path $ConfigurationFilePath -Raw $content = $content -replace '(?s)(?<=Node localhost\s*\{)(.*\s{8}\}?)(?=\s*\})', '' # Append the combined configuration after the "Node localhost" part in the configuration file $content = $content -replace '(?s)(?<=Node localhost\s*\{)', "`r`n$combinedConfiguration" return $content } <# .SYNOPSIS Splits a large DSC configuration file into smaller files. .DESCRIPTION Parses Node localhost resource blocks and writes chunked configuration files based on maximum file size and optional resource count limits. .PARAMETER Path Specifies the source configuration file path. .PARAMETER OutputFolder Specifies the destination folder for split files. .PARAMETER MaxFileSizeMB Specifies the maximum file size per output file in megabytes. .PARAMETER MaxResources Specifies the maximum number of resource blocks per output file. .EXAMPLE Split-M365DSCConfiguration -Path 'C:\Configs\M365TenantConfig.ps1' -OutputFolder 'C:\Configs\Split' -MaxFileSizeMB 2 -MaxResources 50 This example splits the 'M365TenantConfig.ps1' file into smaller files, each with a maximum size of 2 MB and a maximum of 50 resources, saving them in the 'C:\Configs\Split' folder. .FUNCTIONALITY Public #> function Split-M365DSCConfiguration { param ( [Parameter(Mandatory = $true)] [System.String] $Path, [Parameter()] [System.String] $OutputFolder = (Split-Path $Path), [Parameter()] [System.Double] $MaxFileSizeMB = 3, [Parameter()] [System.Int32] $MaxResources = 0 # 0 = ignore resource count limit ) $fileContent = Get-Content -Encoding utf8 -Path $Path -Raw # Extract content inside "Node localhost { ... }" $pattern = 'Node localhost\s*{([\s\S]*)\s+}(\r|\n)+\s+}' $nodeMatch = [regex]::Match($fileContent, $pattern) if (-not $nodeMatch.Success) { throw "Could not find a 'Node localhost { ... }' block in file: $Path" } $nodeContent = $nodeMatch.Groups[1].Value # Extract header (everything before Node localhost) $header = ($fileContent -split 'Node localhost')[0] + "Node localhost`n {`n" $footer = "`n }`n}`n`nM365TenantConfig -ConfigurationData .\ConfigurationData.psd1" # Split into DSC resource text blocks using brace-depth parsing $resources = @() $lines = $nodeContent -split "`r?`n" $currentResource = [System.Text.StringBuilder]::new() $braceDepth = 0 $insideResource = $false for ($i = 0; $i -lt $lines.Count; $i++) { $line = $lines[$i] # Detect resource start if (-not $insideResource -and $line.Trim() -match '^[a-zA-Z0-9_]+\s+"[^"]+"') { $insideResource = $true $null = $currentResource.Clear() $null = $currentResource.AppendLine($line) # Calculate brace depth $braceDepth = ($line -split '{').Count - ($line -split '}').Count continue } if ($insideResource) { $null = $currentResource.AppendLine($line) # Adjust brace depth based on line content $braceDepth += ($line -split '{').Count - ($line -split '}').Count # End of resource block if ($braceDepth -le 0) { $resources += ' ' + $currentResource.ToString().Trim() $insideResource = $false } } } if (-not $resources) { throw 'No DSC resources found in the Node block.' } # Splitting logic $i = 1 $currentGroup = @() $currentSize = 0 $maxBytes = $MaxFileSizeMB * 1MB foreach ($res in $resources) { # Calculate size of the resource in bytes $resBytes = [System.Text.Encoding]::UTF8.GetByteCount($res) $resourceCountLimitReached = ($MaxResources -gt 0 -and $currentGroup.Count -ge $MaxResources) $sizeLimitReached = ($currentSize + $resBytes) -gt $maxBytes # Write current group if limits are reached if (($sizeLimitReached -or $resourceCountLimitReached) -and $currentGroup.Count -gt 0) { $outPath = Join-Path $OutputFolder ('M365TenantConfig_{0}.ps1' -f $i) $configText = $header + ($currentGroup -join "`n") + $footer Set-Content -Path $outPath -Value $configText -Encoding UTF8 -Force Write-M365DSCHost -Message "Created: $outPath" -CommitWrite $i++ $currentGroup = @() $currentSize = 0 } $currentGroup += $res $currentSize += $resBytes } # Write final group if ($currentGroup.Count -gt 0) { $outPath = Join-Path $OutputFolder ('M365TenantConfig_{0}.ps1' -f $i) $configText = $header + ($currentGroup -join "`n`n") + $footer Set-Content -Path $outPath -Value $configText -Encoding UTF8 -Force Write-M365DSCHost -Message "Created: $outPath" -CommitWrite } } <# .SYNOPSIS Normalizes authentication fields in exported resource results. .DESCRIPTION Transforms authentication-related properties into configuration-data references and returns updated results with no-escape property metadata. .PARAMETER ConnectionMode Specifies the authentication mode used for transformation rules. .PARAMETER Results Specifies exported resource values to normalize. .OUTPUTS System.Collections.Hashtable #> function Update-M365DSCExportAuthenticationResults { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( [Parameter(Mandatory = $true)] [System.String] [ValidateSet('ServicePrincipalWithThumbprint', 'ServicePrincipalWithSecret', 'ServicePrincipalWithPath', 'CredentialsWithTenantId', 'CredentialsWithApplicationId', 'Credentials', 'ManagedIdentity', 'AccessTokens')] $ConnectionMode, [Parameter(Mandatory = $true)] [System.Collections.Hashtable] $Results ) $noEscape = @() if ($Results.ContainsKey('ManagedIdentity') -and -not $Results.ManagedIdentity) { $Results.Remove('ManagedIdentity') } if ($ConnectionMode -in @('Credentials', 'CredentialsWithTenantId')) { $Results.Credential = '$CredsCredential' $noEscape += 'Credential' # Credentials mode removes TenantId; CredentialsWithTenantId keeps it. $keysToRemove = @('ApplicationId', 'ApplicationSecret', 'CertificateThumbprint', 'CertificatePath', 'CertificatePassword') if ($ConnectionMode -eq 'Credentials') { $keysToRemove += 'TenantId' } foreach ($key in $keysToRemove) { if ($Results.ContainsKey($key)) { $Results.Remove($key) | Out-Null } } } else { # Handle Credential based on CredentialsWithApplicationId mode if ($Results.ContainsKey('Credential')) { if ($ConnectionMode -eq 'CredentialsWithApplicationId') { $Results.Credential = '$CredsCredential' $noEscape += 'Credential' } else { $Results.Remove('Credential') | Out-Null } } # Keys that map to a simple ConfigurationData reference when non-empty $configDataKeys = @('ApplicationId', 'CertificateThumbprint', 'CertificatePath', 'TenantId') foreach ($key in $configDataKeys) { if (-not [System.String]::IsNullOrEmpty($Results.$key)) { $Results.$key = "`$ConfigurationData.NonNodeData.$key" $noEscape += $key } else { try { $Results.Remove($key) | Out-Null } catch { Write-Verbose -Message "Error removing $key from Update-M365DSCExportAuthenticationResults" } } } # ApplicationSecret gets a PSCredential wrapper if (-not [System.String]::IsNullOrEmpty($Results.ApplicationSecret)) { $Results.ApplicationSecret = "New-Object System.Management.Automation.PSCredential ('ApplicationSecret', (ConvertTo-SecureString `$ConfigurationData.NonNodeData.ApplicationSecret -AsPlainText -Force))" $noEscape += 'ApplicationSecret' } else { try { $Results.Remove('ApplicationSecret') | Out-Null } catch { Write-Verbose -Message 'Error removing ApplicationSecret from Update-M365DSCExportAuthenticationResults' } } # CertificatePassword gets resolved as credentials if ($null -ne $Results.CertificatePassword) { $Results.CertificatePassword = '$CredsCertificatePassword' $noEscape += 'CertificatePassword' } else { try { $Results.Remove('CertificatePassword') | Out-Null } catch { Write-Verbose -Message 'Error removing CertificatePassword from Update-M365DSCExportAuthenticationResults' } } if ($null -ne $Results.AccessTokens) { $Results.AccessTokens = "`$ConfigurationData.NonNodeData.AccessTokens" $noEscape += 'AccessTokens' } } return @{ Results = $Results NoEscape = $noEscape } } <# .SYNOPSIS Registers a discovered resource dependency during export. .DESCRIPTION Adds a source-target dependency record to the global export dependency collector. .PARAMETER SourceInstanceName Specifies the source resource instance name. .PARAMETER SourceResourceName Specifies the source resource type name. .PARAMETER TargetResourceType Specifies the target resource type name. .PARAMETER TargetKey Specifies the target key value used to resolve the dependency target instance. #> function Register-M365DSCExportDependency { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.String] $SourceInstanceName, [Parameter(Mandatory = $true)] [System.String] $SourceResourceName, [Parameter(Mandatory = $true)] [System.String] $TargetResourceType, [Parameter(Mandatory = $true)] [System.String] $TargetKey ) if ($null -ne $Global:M365DSCExportDependencies) { $Global:M365DSCExportDependencies += @{ SourceInstanceName = $SourceInstanceName SourceResourceName = $SourceResourceName TargetResourceType = $TargetResourceType TargetKey = $TargetKey } } } <# .SYNOPSIS Resolves relation templates into concrete export dependencies. .DESCRIPTION Evaluates configured relation templates for the resource instance and registers dependencies for referenced target resources. .PARAMETER ResourceName Specifies the source resource type name. .PARAMETER InstanceName Specifies the source resource instance name. .PARAMETER Results Specifies exported property values used to evaluate relation definitions. #> function Resolve-M365DSCExportRelations { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.String] $ResourceName, [Parameter(Mandatory = $true)] [System.String] $InstanceName, [Parameter(Mandatory = $true)] [System.Collections.Hashtable] $Results ) # Determine relations from template $relations = @() foreach ($template in $Script:RelationTemplates.templates.GetEnumerator()) { if ($template.Value.resources.Contains($ResourceName)) { $resourceRelations = $template.Value.relations foreach ($relation in $resourceRelations) { if ($null -ne $relation.'$ref') { $templateName = $relation.'$ref'.Split("/")[-1] $relations += $Script:RelationTemplates.templates.$templateName.relations continue } $relations += $relation } } } if ($relations.Count -eq 0) { return } foreach ($relation in $relations) { $propertyValue = $Results $splittedProperty = $relation.property.Split('.') for ($i = 0; $i -lt $splittedProperty.Count; $i++) { $propertyName = $splittedProperty[$i] if ($propertyValue -is [System.Array]) { if ($propertyValue.Count -eq 0) { continue } $found = $false $propertyValue | Foreach-Object { if ($_ -is [System.Collections.IDictionary] -and $_.Contains($propertyName)) { $found = $true } } if (-not $found) { continue } } else { if (-not $propertyValue.ContainsKey($propertyName)) { continue } } $propertyValue = $propertyValue.$propertyName if ($null -eq $propertyValue) { continue } } # Handle array of complex objects (e.g., Assignments) if ($propertyValue -is [System.Array]) { foreach ($item in $propertyValue) { $targetKey = Get-M365DSCRelationTargetKey -Item $item -Relation $relation if (-not [System.String]::IsNullOrEmpty($targetKey)) { Register-M365DSCExportDependency -SourceInstanceName $InstanceName ` -SourceResourceName $ResourceName ` -TargetResourceType $relation.targetResource ` -TargetKey $targetKey } } } elseif ($propertyValue -is [System.Collections.IDictionary] -or $propertyValue -is [Microsoft.Management.Infrastructure.CimInstance]) { $targetKey = Get-M365DSCRelationTargetKey -Item $propertyValue -Relation $relation if (-not [System.String]::IsNullOrEmpty($targetKey)) { Register-M365DSCExportDependency -SourceInstanceName $InstanceName ` -SourceResourceName $ResourceName ` -TargetResourceType $relation.targetResource ` -TargetKey $targetKey } } else { # Simple string property referencing a target resource key directly Register-M365DSCExportDependency -SourceInstanceName $InstanceName ` -SourceResourceName $ResourceName ` -TargetResourceType $relation.targetResource ` -TargetKey $propertyValue.ToString() } } } <# .Description Extracts the target key value from a complex item based on the relation definition. .Functionality Internal #> function Get-M365DSCRelationTargetKey { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.Object] $Item, [Parameter(Mandatory = $true)] [System.Object] $Relation ) # If there's a condition, check it first if (-not [System.String]::IsNullOrEmpty($Relation.condition)) { $conditionMet = Test-M365DSCRelationCondition -Item $Item -Condition $Relation.condition if (-not $conditionMet) { return $null } } # Extract the child property value $childProperty = $Relation.childProperty $value = $null if ($Item -is [System.Collections.IDictionary]) { if ($Item.Contains($childProperty)) { $value = $Item[$childProperty] } } elseif ($null -ne $Item) { $value = $Item.$childProperty } if ([System.String]::IsNullOrEmpty($value)) { return $null } return $value.ToString() } <# .Description Evaluates a simple condition expression against a complex object item. Supports: "propertyName in ['value1', 'value2']" .Functionality Internal #> function Test-M365DSCRelationCondition { [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter(Mandatory = $true)] [System.Object] $Item, [Parameter(Mandatory = $true)] [System.String] $Condition ) # Parse "propertyName in ['value1', 'value2']" pattern if ($Condition -match "^(\w+)\s+in\s+\[(.+)\]$") { $propName = $Matches[1] $valuesString = $Matches[2] $allowedValues = $valuesString -split ',\s*' | ForEach-Object { $_.Trim().Trim("'").Trim('"') } $itemValue = $null if ($Item -is [System.Collections.Hashtable]) { if ($Item.ContainsKey($propName)) { $itemValue = $Item[$propName] } } elseif ($null -ne $Item) { $itemValue = $Item.$propName } if ($null -eq $itemValue) { return $false } return ($allowedValues -contains $itemValue.ToString()) } # Unknown condition format - default to true return $true } <# .SYNOPSIS Injects DependsOn statements into exported DSC content. .DESCRIPTION Resolves collected dependencies to exported instances, injects DependsOn arrays into source resource blocks, and generates minimal stub blocks for unresolved targets. .PARAMETER DSCContent Specifies the exported DSC content to enrich with dependency data. .OUTPUTS System.String #> function Add-M365DSCExportDependsOn { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.String] $DSCContent ) if ($null -eq $Global:M365DSCExportDependencies -or $Global:M365DSCExportDependencies.Count -eq 0) { return $DSCContent } if ($null -eq $Global:M365DSCExportedResourceInstances) { return $DSCContent } # Build a lookup from target resource+key to instance reference $targetLookup = @{} foreach ($entry in $Global:M365DSCExportedResourceInstances.GetEnumerator()) { $inst = $entry.Value $lookupKey = "$($inst.ResourceName)|$($inst.PrimaryKey)" $targetLookup[$lookupKey] = $entry.Key } # Group dependencies by source instance $dependenciesBySource = @{} $unresolvedTargets = @{} foreach ($dep in $Global:M365DSCExportDependencies) { $lookupKey = "$($dep.TargetResourceType)|$($dep.TargetKey)" $targetRef = $targetLookup[$lookupKey] if ($null -ne $targetRef) { # Target was exported - add DependsOn reference $sourceRef = "[$($dep.SourceResourceName)]$($dep.SourceInstanceName)" if (-not $dependenciesBySource.ContainsKey($sourceRef)) { $dependenciesBySource[$sourceRef] = @() } if ($dependenciesBySource[$sourceRef] -notcontains $targetRef) { $dependenciesBySource[$sourceRef] += $targetRef } } else { # Target was NOT exported - needs a stub $stubKey = "$($dep.TargetResourceType)|$($dep.TargetKey)" if (-not $unresolvedTargets.ContainsKey($stubKey)) { $unresolvedTargets[$stubKey] = @{ ResourceType = $dep.TargetResourceType TargetKey = $dep.TargetKey } } # Still record the dependency for injection after stub is created $sourceRef = "[$($dep.SourceResourceName)]$($dep.SourceInstanceName)" if (-not $dependenciesBySource.ContainsKey($sourceRef)) { $dependenciesBySource[$sourceRef] = @() } $stubInstanceName = "$($dep.TargetResourceType)-$($dep.TargetKey)" $stubRef = "[$($dep.TargetResourceType)]$stubInstanceName" if ($dependenciesBySource[$sourceRef] -notcontains $stubRef) { $dependenciesBySource[$sourceRef] += $stubRef } } } # Inject DependsOn into each source block foreach ($sourceEntry in $dependenciesBySource.GetEnumerator()) { $sourceRef = $sourceEntry.Key $targets = $sourceEntry.Value # Parse resource name and instance name from "[ResourceName]InstanceName" if ($sourceRef -match '^\[([^\]]+)\](.+)$') { $srcResourceName = $Matches[1] $srcInstanceName = $Matches[2] # Build DependsOn line $dependsOnEntries = Get-M365DSCArrayFromProperty -PropertyValue ($targets | ForEach-Object { "`"$_`"" }) -ElementType ([System.String]) if ($dependsOnEntries.Count -eq 1) { $dependsOnLine = " DependsOn = @($($dependsOnEntries[0]))" } else { $dependsOnLine = " DependsOn = @($($dependsOnEntries -join ', '))" } # Find the closing brace of this resource block and inject DependsOn before it $blockPattern = " $srcResourceName `"$srcInstanceName`"" $blockStart = $DSCContent.IndexOf($blockPattern) if ($blockStart -ge 0) { # Find the closing " }" for this block $searchFrom = $blockStart + $blockPattern.Length $closingBrace = $DSCContent.IndexOf("`r`n }`r`n", $searchFrom) if ($closingBrace -gt 0) { $DSCContent = $DSCContent.Insert($closingBrace + 1, $dependsOnLine) } } } } # Generate stub blocks for unresolved targets if ($unresolvedTargets.Count -gt 0) { $stubContent = Get-M365DSCMinimalExportBlocks -UnresolvedTargets $unresolvedTargets if (-not [System.String]::IsNullOrEmpty($stubContent)) { # Insert stubs before the closing " }" of the Node block # Just after the last resource block's closing brace $nodeClose = $DSCContent.LastIndexOf(" }`r`n") if ($nodeClose -gt 0) { $DSCContent = $DSCContent.Insert($nodeClose + 11, $stubContent) } } } return $DSCContent } <# .SYNOPSIS Builds minimal DSC stub blocks for unresolved dependency targets. .DESCRIPTION Generates placeholder resource blocks with mandatory keys and authentication fields so unresolved dependency references can still compile. .PARAMETER UnresolvedTargets Specifies unresolved target definitions keyed by resource type and target key. .OUTPUTS System.String #> function Get-M365DSCMinimalExportBlocks { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.Collections.Hashtable] $UnresolvedTargets ) $stubBuilder = [System.Text.StringBuilder]::new() [void]$stubBuilder.Append("`r`n # Dependency stubs - minimal resource blocks for referenced resources`r`n") $dictionary = $null try { $dictionary = Get-M365DSCAllResourcesDictionary } catch { Write-Verbose -Message "Unable to load resource dictionary for stub generation: $_" return '' } foreach ($target in $UnresolvedTargets.GetEnumerator()) { $resourceType = $target.Value.ResourceType $targetKey = $target.Value.TargetKey $instanceName = "$resourceType-$targetKey" # Get key properties from the resource dictionary $resourceInfo = $null if ($null -ne $dictionary -and $dictionary.ContainsKey($resourceType)) { $resourceInfo = $dictionary[$resourceType] } [void]$stubBuilder.Append(" $resourceType `"$instanceName`"`r`n") [void]$stubBuilder.Append(" {`r`n") if ($null -ne $resourceInfo) { $keyProps = $resourceInfo.Properties | Where-Object -Property IsMandatory -EQ $true foreach ($prop in $keyProps) { if ($prop.Name -eq 'IsSingleInstance') { [void]$stubBuilder.Append(" IsSingleInstance = `"Yes`"`r`n") } elseif ($prop.Name -eq 'MailEnabled') { [void]$stubBuilder.Append(" $($prop.Name) = `$false`r`n") } elseif ($prop.Name -eq 'SecurityEnabled') { [void]$stubBuilder.Append(" $($prop.Name) = `$true`r`n") } elseif ($prop.Name -in @('DisplayName', 'MailNickName', 'Name', 'Title', 'Identity', 'Id')) { [void]$stubBuilder.Append(" $($prop.Name) = `"$targetKey`"`r`n") } } foreach ($prop in $Script:M365DSCAuthenticationParameterSet.$($Script:ConnectionMode)) { if ($prop -eq 'ManagedIdentity') { [void]$stubBuilder.Append(" $($prop) = `$true`r`n") } else { [void]$stubBuilder.Append(" $($prop) = `$ConfigurationData.NonNodeData.$($prop)`r`n") } } } else { # Fallback: assume DisplayName is the key [void]$stubBuilder.Append(" DisplayName = `"$targetKey`"`r`n") } [void]$stubBuilder.Append(" Ensure = `"Present`"`r`n") [void]$stubBuilder.Append(" }`r`n") } return $stubBuilder.ToString() } Export-ModuleMember -Function @( 'Export-M365DSCConfiguration', 'Get-M365DSCExportContentForResource', 'Get-M365DSCResourcesByExportMode', 'Join-M365DSCConfiguration', 'Split-M365DSCConfiguration', 'Set-M365DSCStringReplacementMap', 'Get-M365DSCStringReplacementMap', 'Update-M365DSCExportAuthenticationResults', 'Register-M365DSCExportDependency', 'Resolve-M365DSCExportRelations', 'Add-M365DSCExportDependsOn', 'Get-M365DSCMinimalExportBlocks' ) |