Modules/M365DSCModuleMgmt.psm1
|
$Script:IsPowerShellCore = $PSVersionTable.PSEdition -eq 'Core' $Script:IsPsResourceGetAvailable = $null -ne (Get-Module -Name Microsoft.PowerShell.PSResourceGet -ListAvailable) $Script:M365DSCDependenciesValidated = $false $Script:M365DSCGraphShimLoaded = $false $Script:M365DSCVerboseScopeDepth = 0 <# .DESCRIPTION Reads every resource's settings.json, keyed by resource name without the MSFT_ prefix. A built module carries them folded into ResourcePermissions.json. The individual files are only opened in a working tree that has not been built yet. .FUNCTIONALITY Internal #> function Import-M365DSCResourceSettings { [CmdletBinding()] param() $settings = [System.Collections.Generic.Dictionary[System.String, System.Object]]::new([System.StringComparer]::OrdinalIgnoreCase) $aggregatePath = Join-Path -Path $PSScriptRoot -ChildPath '../ResourcePermissions.json' if (Test-Path -Path $aggregatePath) { $aggregate = [System.IO.File]::ReadAllText($aggregatePath) | ConvertFrom-Json foreach ($resource in $aggregate.PSObject.Properties) { $settings.Add($resource.Name, $resource.Value) } return , $settings } $dscResourcesFolder = Join-Path -Path $PSScriptRoot -ChildPath '../DscResources' if (-not (Test-Path -Path $dscResourcesFolder)) { Write-Verbose -Message "Neither '$aggregatePath' nor '$dscResourcesFolder' exists, resource settings will be empty." return , $settings } Write-Verbose -Message "No aggregate at '$aggregatePath', reading each resource's settings.json instead." foreach ($file in (Get-ChildItem -Path $dscResourcesFolder -Filter 'settings.json' -Recurse -File)) { $resourceName = (Split-Path -Path $file.DirectoryName -Leaf).Replace('MSFT_', '') $settings.Add($resourceName, ([System.IO.File]::ReadAllText($file.FullName) | ConvertFrom-Json)) } return , $settings } <# .DESCRIPTION This function performs the one-time initialization of the M365DSC dependency and resource-settings metadata used throughout this module. It is wrapped in a function (rather than run as top-level module code) so that the scratch/intermediate variables it uses are released once it returns, instead of being pinned for the lifetime of the session as module-scope variables. .FUNCTIONALITY Internal #> function Initialize-M365DSCModuleMgmt { [CmdletBinding()] param() if ($null -eq $Script:M365DSCDependencies) { $Script:M365DSCDependencies = [System.Collections.Generic.Dictionary[System.String, System.Object]]::new([System.StringComparer]::OrdinalIgnoreCase) $Script:M365DSCDevDependencies = [System.Collections.Generic.Dictionary[System.String, System.Object]]::new([System.StringComparer]::OrdinalIgnoreCase) $dependencies = (Import-PowerShellDataFile "$PSScriptRoot/../Dependencies/Manifest.psd1").Dependencies $devDependencies = (Import-PowerShellDataFile "$PSScriptRoot/../Dependencies/DevManifest.psd1").Dependencies foreach ($dependency in $dependencies) { # TODO: Review again once ModuleFast can work with additional properties # https://github.com/microsoft/Microsoft365DSC/pull/6726 # https://github.com/ykuijs/M365DSC_CICD/issues/53 if ($dependency.ModuleName -eq 'PnP.PowerShell') { $dependency.DependsOn = @('Microsoft.Graph.Authentication') } $Script:M365DSCDependencies.Add($dependency.ModuleName, $dependency) } foreach ($devDependency in $devDependencies) { $Script:M365DSCDevDependencies.Add($devDependency.ModuleName, $devDependency) } $commandToModuleMap = @{} $Script:M365DSCResourceSettings = [System.Collections.Generic.Dictionary[System.String, System.Object]]::new([System.StringComparer]::OrdinalIgnoreCase) $allResourceSettings = Import-M365DSCResourceSettings foreach ($entry in $allResourceSettings.GetEnumerator()) { $jsonContent = $entry.Value foreach ($commandMap in ($jsonContent.commands | Where-Object -Property module -NotIn $Script:M365DSCDevDependencies.Keys)) { $commandToModuleMap[$commandMap.module] += @($commandMap.cmdlets) } $Script:M365DSCResourceSettings.Add($entry.Key, @{ requiredModules = $jsonContent.requiredModules | Where-Object { $_ -notin $Script:M365DSCDevDependencies.Keys } mode = $jsonContent.mode }) } Write-Verbose -Message "Loading current configuration from config.json" $Script:M365DSCValidatedDependencies = [System.Collections.Generic.List[System.String]]::new($Script:M365DSCDependencies.Count + $Script:M365DSCDevDependencies.Count) $configAsPsCustomObject = Get-Content -Path "$PSScriptRoot/../config.json" | ConvertFrom-Json $configAsHashtable = @{} foreach ($property in $configAsPsCustomObject.PSObject.Properties) { $configAsHashtable.Add($property.Name, $property.Value) } $Script:CurrentConfiguration = $configAsHashtable $globalRequiredModules = $Script:CurrentConfiguration.requiredModules foreach ($entry in $commandToModuleMap.GetEnumerator()) { $sortedFunctions = @($globalRequiredModules.$($entry.Key)) + @($entry.Value) | Sort-Object -Unique $Script:M365DSCDependencies[$entry.Key].Commands = $sortedFunctions } $Script:M365DSCRequiredModules = @($globalRequiredModules.psobject.Properties.Name) $Script:M365DSCRequiredModulesLoaded = $false } } Initialize-M365DSCModuleMgmt <# .SYNOPSIS Returns resource settings metadata loaded by module management. .DESCRIPTION Returns the in-memory dictionary of resource settings built from each resource settings.json file. #> function Get-M365DSCResourceSettings { [CmdletBinding()] param() return $Script:M365DSCResourceSettings } <# .SYNOPSIS Returns one resource's settings.json content. .DESCRIPTION Returns everything a resource declares about itself, being its permissions, roles, required modules, commands and mode. Returns $null for a resource the module carries no settings for. .PARAMETER ResourceName Name of the resource, with or without its MSFT_ prefix. #> function Get-M365DSCResourceSetting { [CmdletBinding()] [OutputType([System.Object])] param ( [Parameter(Mandatory = $true)] [System.String] $ResourceName ) if ($null -eq $Script:M365DSCAllResourceSettings) { $Script:M365DSCAllResourceSettings = Import-M365DSCResourceSettings } $settings = $null $null = $Script:M365DSCAllResourceSettings.TryGetValue($ResourceName.Replace('MSFT_', ''), [ref] $settings) return $settings } <# .SYNOPSIS Returns globally required module names for Microsoft365DSC. .DESCRIPTION Returns the module names defined as required in the module configuration. #> function Get-M365DSCRequiredModules { [CmdletBinding()] param() return $Script:M365DSCRequiredModules } <# .SYNOPSIS Sets the required-modules-loaded state flag. .DESCRIPTION Updates the module-scope flag used to track whether required modules were loaded in the current session. .PARAMETER Value Specifies the new loaded state value. #> function Set-M365DSCRequiredModulesLoaded { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Boolean]$Value ) $Script:M365DSCRequiredModulesLoaded = $Value } <# .SYNOPSIS Returns whether required modules are marked as loaded. .DESCRIPTION Returns the module-scope boolean state indicating whether required modules were loaded. #> function Test-IsM365DSCRequiredModulesLoaded { [CmdletBinding()] param() return $Script:M365DSCRequiredModulesLoaded } <# .SYNOPSIS Returns the current Microsoft365DSC module configuration. .DESCRIPTION Returns a cloned hashtable of the loaded module configuration values. .OUTPUTS System.Collections.Hashtable #> function Get-M365DSCModuleConfiguration { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param() return $Script:CurrentConfiguration.Clone() } <# .SYNOPSIS Updates a single Microsoft365DSC module configuration value. .DESCRIPTION Sets a configuration entry in the current module configuration hashtable. .PARAMETER Key Specifies the configuration key to update. .PARAMETER Value Specifies the value to assign to the configuration key. #> function Set-M365DSCModuleConfiguration { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.String] $Key, [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [AllowEmptyString()] [AllowNull()] [System.Object] $Value ) $Script:CurrentConfiguration.$Key = $Value } <# .SYNOPSIS Validates that required Microsoft365DSC dependencies are installed. .DESCRIPTION Checks dependency health and throws when required module versions are missing. Validation can be skipped by session flags already used by Microsoft365DSC. .FUNCTIONALITY Internal #> function Confirm-M365DSCDependencies { [CmdletBinding()] param() if (-not $Script:M365DSCDependenciesValidated -and ($null -eq $Global:M365DSCSkipDependenciesValidation -or -not $Global:M365DSCSkipDependenciesValidation)) { Write-Verbose -Message 'Dependencies were not already validated.' Test-CodePage $result = Update-M365DSCDependencies -ValidateOnly if ($result.Length -gt 0) { $ErrorMessage = "The following dependencies need updating:`r`n" foreach ($invalidDependency in $result) { $ErrorMessage += ' * ' + $invalidDependency.ModuleName + "`r`n" } $ErrorMessage += 'Please run Update-M365DSCDependencies as Administrator. ' $Script:M365DSCDependenciesValidated = $false Add-M365DSCEvent -Message $ErrorMessage -EntryType 'Error' ` -EventID 1 -Source $($MyInvocation.MyCommand.Source) ` -TenantId $tenantIdValue throw $ErrorMessage } else { Write-Verbose -Message 'Dependencies were all successfully validated.' $Script:M365DSCDependenciesValidated = $true } } else { Write-Debug -Message 'Dependencies were already successfully validated.' } } <# .SYNOPSIS Assigns $VerbosePreference inside a set of module scopes. .DESCRIPTION Skips any scope that refuses the assignment instead of failing the caller. .PARAMETER Scope Specifies the modules whose session state receives the value. .PARAMETER Preference Specifies the value to assign. .FUNCTIONALITY Internal #> function Set-M365DSCVerbosePreferenceInScope { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [System.Management.Automation.PSModuleInfo[]] $Scope, [Parameter(Mandatory = $true)] [System.String] $Preference ) $value = $Preference try { $value = [System.Management.Automation.ActionPreference] [System.Enum]::Parse( [System.Management.Automation.ActionPreference], $Preference, $true) } catch { $value = $Preference } foreach ($module in $Scope) { if ($null -eq $module.SessionState) { continue } try { $module.SessionState.PSVariable.Set('VerbosePreference', $value) } catch { continue } } } <# .SYNOPSIS Confines the verbose stream to the Microsoft365DSC module scopes. .DESCRIPTION Write-Verbose resolves $VerbosePreference through the scope chain of the module it runs in. Silencing the global scope and raising it again inside the Microsoft365DSC scopes keeps the messages Microsoft365DSC writes and drops those of every dependency. The ambient value comes from the global scope. The caller's own $VerbosePreference is unusable as the source because an outer call has already overridden it in every Microsoft365DSC scope. Restore levels the per-scope values with the global one. An optimized module scope refuses variable removal, so the values stay in place. .PARAMETER Preference Specifies the preference the Microsoft365DSC scopes run with. Defaults to the global scope. .PARAMETER ModuleName Specifies which module scopes keep the preference. Defaults to every Microsoft365DSC scope. .PARAMETER Restore Specifies the previous preference to restore. .EXAMPLE PS> $previous = Set-M365DSCVerboseScope PS> try { Invoke-Something } finally { $null = Set-M365DSCVerboseScope -Restore $previous } .FUNCTIONALITY Internal .OUTPUTS System.String #> function Set-M365DSCVerboseScope { [CmdletBinding(DefaultParameterSetName = 'Apply')] [OutputType([System.String])] param( [Parameter(ParameterSetName = 'Apply')] [System.String] $Preference, [Parameter(ParameterSetName = 'Apply')] [System.String[]] $ModuleName, [Parameter(Mandatory = $true, ParameterSetName = 'Restore')] [AllowNull()] [System.String] $Restore ) $rootModule = Get-Module -Name 'Microsoft365DSC' if ($null -eq $rootModule) { return $global:VerbosePreference } $scopes = @($rootModule) + @($rootModule.NestedModules) if ($PSCmdlet.ParameterSetName -eq 'Restore') { $Script:M365DSCVerboseScopeDepth-- if ($Script:M365DSCVerboseScopeDepth -gt 0) { return $Restore } $global:VerbosePreference = $Restore Set-M365DSCVerbosePreferenceInScope -Scope $scopes -Preference $Restore return $Restore } if ($PSBoundParameters.ContainsKey('ModuleName') -and $ModuleName.Count -gt 0) { $scopes = $scopes | Where-Object -FilterScript { $ModuleName -contains $_.Name } } $Script:M365DSCVerboseScopeDepth++ if ($Script:M365DSCVerboseScopeDepth -gt 1) { return $global:VerbosePreference } $previous = $global:VerbosePreference if (-not $PSBoundParameters.ContainsKey('Preference')) { $Preference = $previous } $global:VerbosePreference = 'SilentlyContinue' Set-M365DSCVerbosePreferenceInScope -Scope $scopes -Preference $Preference return $previous } <# .SYNOPSIS Imports a module without its load-time output reaching the verbose stream. .DESCRIPTION An imported module resolves $VerbosePreference from the global scope, so -Verbose:$false on Import-Module leaves every "Importing cmdlet" and "Exporting function" line in place. Lowering the global preference for the duration of the call is the only thing that silences them. .PARAMETER Parameters Specifies the parameters to splat onto Import-Module. .EXAMPLE PS> Import-M365DSCDependencyModule -Parameters @{ Name = 'Microsoft.Graph.Authentication' } .FUNCTIONALITY Internal #> function Import-M365DSCDependencyModule { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.Hashtable] $Parameters ) $VerbosePreference = 'SilentlyContinue' $previousVerbosePreference = $global:VerbosePreference $global:VerbosePreference = 'SilentlyContinue' try { Import-Module @Parameters } finally { $global:VerbosePreference = $previousVerbosePreference } } <# .SYNOPSIS Ensures a dependency module is loaded at the required version. .DESCRIPTION Loads dependency modules on demand, validates versions, and recursively validates dependency chains. For Graph typed modules, it applies the Microsoft365DSC Graph shim behavior. .PARAMETER ModuleName Specifies the dependency module name to validate and load. .EXAMPLE PS> Confirm-M365DSCLoadedModule -ModuleName 'Microsoft.Graph.Authentication' .FUNCTIONALITY Internal #> function Confirm-M365DSCLoadedModule { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.String] $ModuleName ) if ($Script:M365DSCValidatedDependencies.Contains($ModuleName)) { Write-Verbose -Message "Module '$ModuleName' has already been validated." return } # Graph Shim intercept: Replace typed Graph SDK # sub-modules with the lightweight M365DSCGraphShim module that wraps # Invoke-MgGraphRequest. Microsoft.Graph.Authentication is always loaded # natively because it provides Connect-MgGraph and the underlying HTTP client. if ($ModuleName -like 'Microsoft.Graph.*' -and $ModuleName -ne 'Microsoft.Graph.Authentication') { if (-not $Script:M365DSCGraphShimLoaded) { Write-Verbose -Message "Graph Shim enabled: importing M365DSCGraphShim instead of '$ModuleName'." # Ensure Microsoft.Graph.Authentication is loaded first Confirm-M365DSCLoadedModule -ModuleName 'Microsoft.Graph.Authentication' Import-M365DSCDependencyModule -Parameters @{ Name = "$PSScriptRoot/M365DSCGraphShim.psd1" Global = $true Force = $true DisableNameChecking = $true Function = '*' Cmdlet = @() Variable = @() Alias = @() } $Script:M365DSCGraphShimLoaded = $true } else { Write-Verbose -Message "Graph Shim already loaded, skipping import for '$ModuleName'." } $Script:M365DSCValidatedDependencies.Add($ModuleName) return } $manifestModule = $Script:M365DSCDependencies[$ModuleName] if ($null -ne $manifestModule.DependsOn -and $manifestModule.DependsOn.Count -gt 0) { foreach ($dependency in $manifestModule.DependsOn) { Write-Verbose -Message "Validating dependency '$dependency' for module '$ModuleName'." Confirm-M365DSCLoadedModule -ModuleName $dependency } } $loadedModule = Get-Module -Name $ModuleName if ($null -eq $loadedModule) { Write-Verbose -Message "Module '$ModuleName' is not loaded. Importing it now." $importModuleSplat = @{ Name = $ModuleName RequiredVersion = $manifestModule.RequiredVersion Global = $true Alias = @() Cmdlet = @() Variable = @() DisableNameChecking = $true } if ($manifestModule.Commands.Count -gt 0) { $importModuleSplat.Add('Function', $manifestModule.Commands) $importModuleSplat.Cmdlet = $manifestModule.Commands } Import-M365DSCDependencyModule -Parameters $importModuleSplat Write-Verbose -Message "Module '$ModuleName' with version '$($manifestModule.RequiredVersion)' has been imported." } elseif ($loadedModule.Version -ne $manifestModule.RequiredVersion) { Write-Verbose -Message "Module '$ModuleName' is loaded but the version '$($loadedModule.Version)' does not match the required version '$($manifestModule.RequiredVersion)'." Remove-Module -Name $ModuleName -Force -ErrorAction SilentlyContinue Write-Verbose -Message "Unloaded module '$ModuleName' with version '$($loadedModule.Version)'." Import-M365DSCDependencyModule -Parameters @{ Name = $ModuleName RequiredVersion = $manifestModule.RequiredVersion Global = $true Alias = @() Cmdlet = @() Variable = @() DisableNameChecking = $true } Write-Verbose -Message "Re-imported module '$ModuleName' with version '$($manifestModule.RequiredVersion)'." } else { Write-Verbose -Message "Module '$ModuleName' is already loaded." } if (-not $Script:M365DSCValidatedDependencies.Contains($ModuleName)) { $Script:M365DSCValidatedDependencies.Add($ModuleName) } } <# .SYNOPSIS Validates dependencies required by a DSC resource module. .DESCRIPTION Resolves required modules from resource settings and validates each dependency before resource execution. .PARAMETER ModuleName Specifies the DSC resource module name whose dependencies should be validated. .EXAMPLE PS> Confirm-M365DSCModuleDependency -ModuleName 'MSFT_AADApplication' .FUNCTIONALITY Internal #> function Confirm-M365DSCModuleDependency { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.String] $ModuleName ) $Global:MaximumFunctionCount = 32767 if ($Global:IsTestEnvironment -or (Get-M365DSCModuleConfiguration).skipModuleDependencyValidation) { Write-Verbose -Message "Skipping module dependency validation in test environment for module '$ModuleName'." return } $modulesToCheck = $Script:M365DSCResourceSettings[$ModuleName.Replace('MSFT_', '')].requiredModules foreach ($module in $modulesToCheck) { Write-Verbose -Message "Validating module dependency: $($module)" Confirm-M365DSCLoadedModule -ModuleName $module } Write-Verbose -Message "All dependencies for module '$ModuleName' have been validated." } <# .SYNOPSIS Checks whether newer versions exist for configured dependencies. .DESCRIPTION Queries the gallery for each configured dependency and reports modules with newer available versions. .EXAMPLE PS> Test-M365DSCDependenciesForNewVersions .FUNCTIONALITY Public #> function Test-M365DSCDependenciesForNewVersions { [CmdletBinding()] param () $i = 1 Import-Module PowerShellGet -Force foreach ($dependency in $Script:M365DSCDependencies.Values.GetEnumerator()) { Write-Progress -Activity 'Scanning Dependencies' -PercentComplete ($i / $Script:M365DSCDependencies.Count * 100) try { $moduleInGallery = Find-Module $dependency.ModuleName [array]$moduleInstalled = Get-Module $dependency.ModuleName -ListAvailable | Select-Object Version if ($moduleInstalled) { $modules = $moduleInstalled | Sort-Object Version -Descending } $moduleInstalled = $modules[0] if (-not $modules -or [Version]($moduleInGallery.Version) -gt [Version]($moduleInstalled[0].Version)) { Write-Host "New version of {$($dependency.ModuleName)} is available {$($moduleInGallery.Version)}" } } catch { Write-Host $_ Write-Host "New version of {$($dependency.ModuleName)} is available" } $i++ } # The progress bar seems to hang sometimes. Make sure it is no longer displayed. Write-Progress -Activity 'Scanning Dependencies' -Completed } <# .SYNOPSIS Validates the installed Microsoft365DSC module version. .DESCRIPTION Compares local and gallery module versions and reports when a newer Microsoft365DSC module is available. .EXAMPLE PS> Test-M365DSCModuleValidity .FUNCTIONALITY Public #> function Test-M365DSCModuleValidity { [CmdletBinding()] param() if ($Script:IsM365DSCModuleValidated) { Write-Verbose -Message 'The Microsoft365DSC module has already been validated in this session.' Write-Verbose -Message 'If you have updated the module, please restart your PowerShell session to re-validate.' return } if ($env:AZUREPS_HOST_ENVIRONMENT -like 'AzureAutomation*') { $message = 'Skipping check for newer version of Microsoft365DSC due to Azure Automation Environment restrictions.' Write-Verbose -Message $message return } # Validate if only one installation of the module is present and that it's the latest version available if ($Script:IsPsResourceGetAvailable) { $latestVersion = (Find-PSResource -Name 'Microsoft365DSC' -Repository 'PSGallery').Version | Sort-Object -Descending | Select-Object -First 1 } else { $latestVersion = (Find-Module -Name 'Microsoft365DSC' -Includes 'DSCResource').Version } $localVersion = (Get-Module -Name 'Microsoft365DSC').Version if ($latestVersion -gt $localVersion) { Write-Host "There is a newer version of the 'Microsoft365DSC' module available on the gallery." Write-Host "To update the module and it's dependencies, run the following command:" Write-Host 'Update-M365DSCModule' -ForegroundColor Blue } $Script:IsM365DSCModuleValidated = $true } <# .SYNOPSIS Removes outdated Microsoft365DSC module and dependency versions. .DESCRIPTION Scans installed module versions and removes outdated Microsoft365DSC and dependency versions while preserving required versions. .EXAMPLE PS> Uninstall-M365DSCOutdatedDependencies .FUNCTIONALITY Public #> function Uninstall-M365DSCOutdatedDependencies { [CmdletBinding()] param() try { $InformationPreference = 'Continue' [array]$microsoft365DscModules = Get-Module Microsoft365DSC -ListAvailable $outdatedMicrosoft365DscModules = $microsoft365DscModules | Sort-Object -Property Version | Select-Object -SkipLast 1 foreach ($module in $outdatedMicrosoft365DscModules) { try { Write-Information -MessageData "Uninstalling $($module.Name) Version {$($module.Version)}" if (Test-Path -Path $($module.Path)) { Remove-Item $($module.ModuleBase) -Force -Recurse -ErrorAction Stop } } catch { $message = "Could not uninstall $($module.Name) Version $($module.Version)" if ($_.Exception.Message -like "*Access to the path* is denied*" -and ($Scope -eq "AllUsers") -and ($PSEdition -eq 'Desktop' -or $IsWindows) -and -not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { $message += ' You need to run this command as a local administrator.' } New-M365DSCLogEntry -Message $message ` -Exception $_ ` -Source $($MyInvocation.MyCommand.Source) Write-Error -Message $message -ErrorAction Continue } } $allDependenciesExceptAuth = $Script:M365DSCDependencies.Values.GetEnumerator().Where({ $_.ModuleName -ne 'Microsoft.Graph.Authentication' }) $i = 1 foreach ($dependency in $allDependenciesExceptAuth) { Write-Progress -Activity 'Scanning Dependencies' -PercentComplete ($i / $allDependenciesExceptAuth.Count * 100) try { if ($dependency.PowerShellCore -and -not $Script:IsPowerShellCore) { Write-Verbose -Message "Skipping module {$($dependency.ModuleName)} as it is managed by PowerShell Core." continue } elseif ($dependency.PowerShellCore -eq $false -and $Script:IsPowerShellCore) { Write-Verbose -Message "Skipping module {$($dependency.ModuleName)} as it is managed by Windows PowerShell." continue } $found = Get-Module $dependency.ModuleName -ListAvailable | Where-Object -Property Version -NE $dependency.RequiredVersion foreach ($foundModule in $found) { try { Write-Information -MessageData "Uninstalling $($foundModule.Name) Version {$($foundModule.Version)}" if (Test-Path -Path $($foundModule.Path)) { Remove-Item $($foundModule.ModuleBase) -Force -Recurse -ErrorAction Stop } } catch { $message = "Could not uninstall $($foundModule.Name) Version $($foundModule.Version)" if ($_.Exception.Message -like "*Access to the path* is denied*" -and ($PSEdition -eq 'Desktop' -or $IsWindows) -and ($Scope -eq "AllUsers") -and -not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { $message += ' You need to run this command as a local administrator.' } New-M365DSCLogEntry -Message $message ` -Exception $_ ` -Source $($MyInvocation.MyCommand.Source) Write-Error -Message $message -ErrorAction Continue } } } catch { Write-Error -Message "Could not uninstall {$($dependency.ModuleName)}" -ErrorAction Continue } $i++ } } catch { New-M365DSCLogEntry -Message 'Error uninstalling outdated dependencies:' ` -Exception $_ ` -Source $($MyInvocation.MyCommand.Source) Write-Error $_ } $authModule = $Script:M365DSCDependencies['Microsoft.Graph.Authentication'] try { Write-Information -MessageData 'Checking Microsoft.Graph.Authentication' $found = Get-Module $authModule.ModuleName -ListAvailable | Where-Object -Property Version -NE $authModule.RequiredVersion foreach ($foundModule in $found) { try { Write-Information -MessageData "Uninstalling $($foundModule.Name) version {$($foundModule.Version)}" if (Test-Path -Path $($foundModule.Path)) { Remove-Item $($foundModule.ModuleBase) -Force -Recurse -ErrorAction Stop } } catch { $message = "Could not uninstall $($foundModule.Name) Version $($foundModule.Version)" if ($_.Exception.Message -like "*Access to the path* is denied*" -and ($PSEdition -eq 'Desktop' -or $IsWindows) -and ($Scope -eq "AllUsers") -and -not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { $message += ' You need to run this command as a local administrator.' } New-M365DSCLogEntry -Message $message ` -Exception $_ ` -Source $($MyInvocation.MyCommand.Source) Write-Error -Message $message -ErrorAction Continue } } } catch { Write-Error -Message "Could not uninstall {$($dependency.ModuleName)}" -ErrorAction Continue } } <# .SYNOPSIS Determines whether a module version is present on the module path. .DESCRIPTION Probes every PSModulePath root for the manifest of the requested module version. This answers the common case without enumerating the metadata of every installed module. .PARAMETER ModuleName Specifies the name of the module to look for. .PARAMETER RequiredVersion Specifies the version the module directory must carry. .FUNCTIONALITY Internal .OUTPUTS System.Boolean #> function Test-M365DSCDependencyManifestPath { [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter(Mandatory = $true)] [System.String] $ModuleName, [Parameter(Mandatory = $true)] [System.String] $RequiredVersion ) if ($null -eq $Script:M365DSCModulePathRoots) { $Script:M365DSCModulePathRoots = @($env:PSModulePath -split [System.IO.Path]::PathSeparator | Where-Object -FilterScript { -not [System.String]::IsNullOrWhiteSpace($_) }) } foreach ($root in $Script:M365DSCModulePathRoots) { if (Test-Path -Path (Join-Path -Path $root -ChildPath "$ModuleName\$RequiredVersion\$ModuleName.psd1")) { return $true } } return $false } <# .SYNOPSIS Installs or validates Microsoft365DSC dependencies. .DESCRIPTION Validates, installs, or force-refreshes dependency modules according to manifest requirements and selected installation options. .PARAMETER Force Indicates that dependencies should be reinstalled even when required versions are present. .PARAMETER ValidateOnly Indicates that only validation should run and missing dependencies should be returned. .PARAMETER Scope Specifies the installation scope for dependency modules. .PARAMETER Proxy Specifies the proxy server used for module installation requests. .PARAMETER Repository Specifies the repository used to install dependencies. .PARAMETER UsePowerShellGet Indicates that Install-Module should be used instead of Install-PSResource. .PARAMETER Development Indicates that development dependencies should also be processed. .EXAMPLE PS> Update-M365DSCDependencies .EXAMPLE PS> Update-M365DSCDependencies -Force .EXAMPLE PS> Update-M365DSCDependencies -Scope CurrentUser .FUNCTIONALITY Public #> function Update-M365DSCDependencies { [CmdletBinding()] param ( [Parameter()] [Switch] $Force, [Parameter()] [Switch] $ValidateOnly, [Parameter()] [ValidateSet("CurrentUser", "AllUsers")] $Scope = "AllUsers", [Parameter()] [System.String] $Proxy, [Parameter()] [System.String] $Repository = 'PSGallery', [Parameter()] [switch] $UsePowerShellGet, [Parameter()] [Switch] $Development ) try { $InformationPreference = 'Continue' $i = 1 $returnValue = @() $params = @{} if (-not [System.String]::IsNullOrEmpty($Proxy)) { $params.Add('Proxy', $Proxy) } # Check if PSResourceGet is installed or not if (-not $Script:IsPsResourceGetAvailable) { Write-Warning -Message 'Microsoft.PowerShell.PSResourceGet is not installed, installing it now...' try { Install-Module -Name Microsoft.PowerShell.PSResourceGet -Scope $Scope -AllowClobber @params -Force -ErrorAction Stop -Repository PSGallery $Script:IsPsResourceGetAvailable = $true } catch { Write-Warning -Message "Failed to install Microsoft.PowerShell.PSResourceGet, continuing without it..." } } $scopedIsPsResourceGetAvailable = $Script:IsPsResourceGetAvailable if ($params.ContainsKey('Proxy')) { Write-Information -MessageData "Falling back to Install-Module because Install-PSResource does not support a proxy" $scopedIsPsResourceGetAvailable = $false } $dependencies = [System.Object[]]::new($Script:M365DSCDependencies.Count + $Script:M365DSCDevDependencies.Count) $Script:M365DSCDependencies.Values.CopyTo($dependencies, 0) if ($Development) { $Script:M365DSCDevDependencies.Values.CopyTo($dependencies, $Script:M365DSCDependencies.Count) } # $null comparison is correct in that way because the left-hand side is always an array and the right-hand side is a single value foreach ($dependency in ($dependencies -ne $null)) { if (-not $ValidateOnly) { Write-Progress -Activity 'Scanning dependencies' -PercentComplete ($i / $dependencies.Count * 100) } try { if (-not $Force) { if ($dependency.PowerShellCore -and -not $Script:IsPowerShellCore) { Write-Verbose -Message "The dependency {$($dependency.ModuleName)} requires PowerShell Core. Skipping." continue } elseif ($dependency.PowerShellCore -eq $false -and $Script:IsPowerShellCore -and $IsWindows) { Write-Verbose -Message "The dependency {$($dependency.ModuleName)} requires Windows PowerShell. Skipping." continue } $found = Test-M365DSCDependencyManifestPath -ModuleName $dependency.ModuleName -RequiredVersion $dependency.RequiredVersion if (-not $found) { $found = Get-Module $dependency.ModuleName -ListAvailable | Where-Object -Property Version -EQ $dependency.RequiredVersion } if (-not $found) { $found = Get-PSResource -Name $dependency.ModuleName -Version $dependency.RequiredVersion -Scope $Scope -ErrorAction SilentlyContinue } } if ((-not $found -or $Force) -and -not $ValidateOnly) { $errorFound = $false try { if (($PSEdition -eq 'Desktop' -or $IsWindows) -and (-not(([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator))) -and ($Scope -eq "AllUsers")) { Write-Error 'Cannot update the dependencies for Microsoft365DSC. You need to run this command as a local administrator.' $errorFound = $true } } catch { Write-Verbose -Message "Couldn't retrieve Windows Principal. One possible cause is that the current environment is not a Windows OS." } if (-not $errorFound) { if (-not $dependency.PowerShellCore -and $Script:IsPowerShellCore -and $IsWindows) { Write-Warning "The dependency {$($dependency.ModuleName)} does not support PowerShell Core. Please run Update-M365DSCDependencies in Windows PowerShell." continue } elseif ($dependency.PowerShellCore -and -not $Script:IsPowerShellCore) { Write-Warning "The dependency {$($dependency.ModuleName)} requires PowerShell Core. Please run Update-M365DSCDependencies in PowerShell Core." continue } Remove-Module $dependency.ModuleName -Force -ErrorAction SilentlyContinue if ($dependency.ModuleName -like 'Microsoft.Graph*') { Remove-Module 'Microsoft.Graph.Authentication' -Force -ErrorAction SilentlyContinue } Remove-Module $dependency.ModuleName -Force -ErrorAction SilentlyContinue if ($scopedIsPsResourceGetAvailable -and -not $UsePowerShellGet) { Write-Information -MessageData "Using Install-PSResource to install $($dependency.ModuleName) with version {$($dependency.RequiredVersion)}" Install-PSResource -Name $dependency.ModuleName -Version $dependency.RequiredVersion -Scope $Scope -AcceptLicense -SkipDependencyCheck -TrustRepository -Repository $Repository } else { Write-Information -MessageData "Using Install-Module to install $($dependency.ModuleName) with version {$($dependency.RequiredVersion)}" Install-Module $dependency.ModuleName -RequiredVersion $dependency.RequiredVersion -AllowClobber -Force -Scope "$Scope" @Params -Repository $Repository } } } if ($dependency.ExplicitLoading) { Remove-Module $dependency.ModuleName -Force -ErrorAction SilentlyContinue if ($dependency.Prefix) { Import-M365DSCDependencyModule -Parameters @{ Name = $dependency.ModuleName Global = $true Prefix = $dependency.Prefix Force = $true DisableNameChecking = $true } } else { Import-M365DSCDependencyModule -Parameters @{ Name = $dependency.ModuleName Global = $true Force = $true Alias = @() Cmdlet = @() Variable = @() DisableNameChecking = $true } } } if (-not $found -and $validateOnly) { $returnValue += $dependency } } catch { Write-Error -Message "Could not update or import {$($dependency.ModuleName)}: $($_.Exception.Message)" -ErrorAction Continue } $i++ } # The progress bar seems to hang sometimes. Make sure it is no longer displayed. Write-Progress -Activity 'Scanning dependencies' -Completed if ($ValidateOnly) { return $returnValue } } catch { New-M365DSCLogEntry -Message 'Error updating dependencies:' ` -Exception $_ ` -Source $($MyInvocation.MyCommand.Source) Write-Error $_ -ErrorAction Continue } } <# .SYNOPSIS Updates Microsoft365DSC and refreshes dependency state. .DESCRIPTION Updates the Microsoft365DSC module, reloads the latest installed version, updates dependencies, and optionally removes outdated versions. .PARAMETER Scope Specifies the installation scope used for update operations. .PARAMETER Proxy Specifies the proxy server used for update operations. .PARAMETER BaseRepository Specifies the repository used to update the Microsoft365DSC module. .PARAMETER DependencyRepository Specifies the repository used to update dependencies. .PARAMETER NoUninstall Indicates that outdated module and dependency versions should not be removed. .EXAMPLE PS> Update-M365DSCModule .EXAMPLE PS> Update-M365DSCModule -Scope CurrentUser .EXAMPLE PS> Update-M365DSCModule -Scope AllUsers .FUNCTIONALITY Public #> function Update-M365DSCModule { [CmdletBinding()] param( [Parameter()] [ValidateSet("CurrentUser", "AllUsers")] $Scope = "AllUsers", [Parameter()] [System.String] $Proxy, [Parameter()] [System.String] $BaseRepository = 'PSGallery', [Parameter()] [System.String] $DependencyRepository = 'PSGallery', [Parameter()] [switch] $NoUninstall ) $params = @{} $unloadModule = $true if (-not [System.String]::IsNullOrEmpty($proxy)) { $params.Add('Proxy', $Proxy) } try { Update-Module -Name 'Microsoft365DSC' @Params -ErrorAction Stop } catch { if ($_.Exception.Message -like "*Module 'Microsoft365DSC' was not installed by using Install-Module*") { Write-Verbose -Message "The Microsoft365DSC module might have been installed with Install-PSResource" if ($Script:IsPsResourceGetAvailable) { Write-Verbose -Message "Updating the Microsoft365DSC module using Update-PSResource..." try { Update-PSResource -Name 'Microsoft365DSC' -Scope $Scope ` -TrustRepository -AcceptLicense -SkipDependencyCheck ` -Repository $BaseRepository -ErrorAction Stop } catch { if ($_.Exception.Message -like "*No installed packages*") { Write-Verbose -Message "Microsoft365DSC was neither installed using Install-Module nor Install-PSResource. Trying to install it now..." Install-PSResource -Name 'Microsoft365DSC' -Scope $Scope ` -TrustRepository -AcceptLicense -SkipDependencyCheck ` -Repository $BaseRepository -ErrorAction Stop } else { New-M365DSCLogEntry -Message 'Error Updating Module:' ` -Exception $_ ` -Source $($MyInvocation.MyCommand.Source) throw } } } } elseif ($_.Exception.Message -like "*was not updated because no valid module was found*") { Write-Verbose -Message "No valid module was found to update." $unloadModule = $false } } try { if ($unloadModule) { Write-Verbose -Message "Unloading all instances of the Microsoft365DSC module from the current PowerShell session." Remove-Module Microsoft365DSC -Force } Write-Verbose -Message "Retrieving all versions of the Microsoft365DSC installed on the machine." [Array]$instances = Get-Module Microsoft365DSC -ListAvailable | Sort-Object -Property Version -Descending if ($instances.Length -gt 0) { Write-Verbose -Message "Loading version {$($instances[0].Version.ToString())} of the Microsoft365DSC module from {$($instances[0].ModuleBase)}" Import-Module Microsoft365DSC -RequiredVersion $instances[0].Version.ToString() -Force } } catch { New-M365DSCLogEntry -Message 'Error Updating Module:' ` -Exception $_ ` -Source $($MyInvocation.MyCommand.Source) throw $_ } Update-M365DSCDependencies -Scope $Scope -Proxy $Proxy -Repository $DependencyRepository if (-not $NoUninstall) { Uninstall-M365DSCOutdatedDependencies } } Export-ModuleMember -Function @( 'Confirm-M365DSCDependencies', 'Confirm-M365DSCLoadedModule', 'Confirm-M365DSCModuleDependency', 'Get-M365DSCModuleConfiguration', 'Get-M365DSCRequiredModules', 'Get-M365DSCResourceSetting', 'Get-M365DSCResourceSettings', 'Import-M365DSCDependencyModule', 'Set-M365DSCVerboseScope', 'Set-M365DSCModuleConfiguration', 'Set-M365DSCRequiredModulesLoaded', 'Test-IsM365DSCRequiredModulesLoaded', 'Test-M365DSCDependenciesForNewVersions', 'Test-M365DSCModuleValidity', 'Uninstall-M365DSCOutdatedDependencies', 'Update-M365DSCDependencies', 'Update-M365DSCModule' ) |