SchemaCache.ps1
|
# Line based cache. Line 1 holds the header with the keyword index, line 2 the source file # list, every later line one keyword. $script:SchemaCacheFormatVersion = 2 function Get-DscSchemaCacheUserPath { [OutputType([System.String])] param ( [Parameter(Mandatory)] [System.String] $ModuleName, [Parameter(Mandatory)] [System.Version] $ModuleVersion, [Parameter(Mandatory)] [System.String] $Fingerprint ) $root = Join-Path ([System.Environment]::GetFolderPath('LocalApplicationData')) 'M365DSC.PSDesiredStateConfiguration\SchemaCache' $safeFingerprint = $Fingerprint.Replace(':', '_') Join-Path $root "${ModuleName}_${ModuleVersion}_${safeFingerprint}.json" } function ConvertTo-DscKeywordSchemaObject { param ( [Parameter(Mandatory)] [System.Management.Automation.Language.DynamicKeyword] $Keyword ) $properties = @{} foreach ($item in $Keyword.Properties.GetEnumerator()) { $property = $item.Value $valueMap = @() if ($property.ValueMap) { foreach ($entry in $property.ValueMap.GetEnumerator()) { $valueMap += @{ key = $entry.Key; value = $entry.Value } } } $properties[$item.Key] = @{ name = $property.Name typeConstraint = $property.TypeConstraint mandatory = $property.Mandatory isKey = $property.IsKey attributes = @($property.Attributes) values = @($property.Values) valueMap = $valueMap } } @{ keyword = $Keyword.Keyword resourceName = $Keyword.ResourceName implementingModule = $Keyword.ImplementingModule implementingModuleVersion = if ($Keyword.ImplementingModuleVersion) { $Keyword.ImplementingModuleVersion.ToString() } else { $null } nameMode = $Keyword.NameMode.ToString() bodyMode = $Keyword.BodyMode.ToString() directCall = $Keyword.DirectCall metaStatement = $Keyword.MetaStatement properties = $properties } } function ConvertFrom-DscKeywordSchemaObject { [OutputType([System.Management.Automation.Language.DynamicKeyword])] param ( [Parameter(Mandatory)] $SchemaObject ) $keyword = New-Object -TypeName System.Management.Automation.Language.DynamicKeyword $keyword.Keyword = $SchemaObject.keyword $keyword.ResourceName = $SchemaObject.resourceName $keyword.ImplementingModule = $SchemaObject.implementingModule if ($SchemaObject.implementingModuleVersion) { $keyword.ImplementingModuleVersion = [System.Version]$SchemaObject.implementingModuleVersion } $keyword.NameMode = [System.Management.Automation.Language.DynamicKeywordNameMode]$SchemaObject.nameMode $keyword.BodyMode = [System.Management.Automation.Language.DynamicKeywordBodyMode]$SchemaObject.bodyMode $keyword.DirectCall = [bool]$SchemaObject.directCall $keyword.MetaStatement = [bool]$SchemaObject.metaStatement foreach ($item in $SchemaObject.properties.PSObject.Properties) { $source = $item.Value $property = New-Object -TypeName System.Management.Automation.Language.DynamicKeywordProperty $property.Name = $source.name $property.TypeConstraint = $source.typeConstraint $property.Mandatory = [bool]$source.mandatory $property.IsKey = [bool]$source.isKey foreach ($attribute in @($source.attributes)) { if ($null -ne $attribute) { $property.Attributes.Add($attribute) } } foreach ($value in @($source.values)) { if ($null -ne $value) { $property.Values.Add($value) } } foreach ($mapEntry in @($source.valueMap)) { if ($null -ne $mapEntry) { $property.ValueMap[[System.String]$mapEntry.key] = $mapEntry.value } } $keyword.Properties.Add($item.Name, $property) } $keyword } function Get-DscModuleSourceEntry { [OutputType([System.Object[]])] param ( [Parameter(Mandatory)] [System.String] $ModuleBase ) $entries = New-Object -TypeName 'System.Collections.Generic.List[System.Object]' foreach ($pattern in '*.psm1', '*.psd1', '*.mof') { foreach ($file in [System.IO.Directory]::EnumerateFiles($ModuleBase, $pattern, [System.IO.SearchOption]::AllDirectories)) { $entries.Add([PSCustomObject]@{ Path = $file.Substring($ModuleBase.Length).TrimStart('\', '/') Length = [System.IO.FileInfo]::new($file).Length }) } } @($entries | Sort-Object -Property Path) } function ConvertTo-DscSourceFingerprint { [OutputType([System.String])] param ( [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Object[]] $Entry ) $total = [long]0 $builder = New-Object -TypeName System.Text.StringBuilder foreach ($item in $Entry) { $total += [long]$item.Length $null = $builder.Append($item.Path.ToLowerInvariant()).Append('|').Append([System.String]$item.Length).Append("`n") } $sha = [System.Security.Cryptography.SHA256]::Create() try { $hash = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($builder.ToString())) } finally { $sha.Dispose() } $hex = [System.BitConverter]::ToString($hash, 0, 8).Replace('-', '').ToLowerInvariant() '{0}:{1}:{2}' -f $Entry.Count, $total, $hex } function Read-DscSchemaCacheFile { param ( [Parameter(Mandatory)] [System.String] $Path ) $lines = [System.IO.File]::ReadAllLines($Path) if ($lines.Count -eq 0) { throw "Schema cache '$Path' is empty." } $header = ConvertFrom-Json -InputObject $lines[0] if ($null -eq $header.formatVersion -or $null -eq $header.module) { throw "Schema cache '$Path' has no header." } if ($header.formatVersion -eq $script:SchemaCacheFormatVersion -and $lines.Count -lt 2) { throw "Schema cache '$Path' is truncated." } [PSCustomObject]@{ Path = $Path formatVersion = $header.formatVersion generator = $header.generator module = $header.module index = $header.index keywordCount = $header.keywordCount resourceCount = $header.resourceCount Lines = $lines } } function Get-DscSchemaCacheSourceList { param ( [Parameter(Mandatory)] $Cache ) ConvertFrom-Json -InputObject $Cache.Lines[1] } function Get-DscSchemaCacheKeyword { [OutputType([System.Management.Automation.Language.DynamicKeyword])] param ( [Parameter(Mandatory)] $Cache, [Parameter(Mandatory)] [System.String] $Name ) $entry = $Cache.index.PSObject.Properties[$Name] if ($null -eq $entry) { return $null } ConvertFrom-DscKeywordSchemaObject -SchemaObject (ConvertFrom-Json -InputObject $Cache.Lines[[int]$entry.Value]) } function Get-DscSchemaCacheKeywordName { [OutputType([string[]])] param ( [Parameter(Mandatory)] $Cache ) @($Cache.index.PSObject.Properties | ForEach-Object { $_.Name }) } function Export-DscSchemaCache { [CmdletBinding()] param ( [Parameter(Mandatory, ParameterSetName = 'ByName', Position = 0)] [System.String] $ModuleName, [Parameter(ParameterSetName = 'ByName')] [System.Version] $RequiredVersion, [Parameter(Mandatory, ParameterSetName = 'ByModuleInfo')] [System.Management.Automation.PSModuleInfo] $Module, [System.String] $OutputPath ) if ($PSCmdlet.ParameterSetName -eq 'ByName') { $candidates = Get-Module -ListAvailable -Name $ModuleName | Sort-Object -Property Version -Descending if ($RequiredVersion) { $candidates = $candidates | Where-Object -Property Version -EQ $RequiredVersion } $Module = $candidates | Select-Object -First 1 if (-not $Module) { throw "Module '$ModuleName' $(if ($RequiredVersion) { "version $RequiredVersion " })was not found." } } Reset-DscKeywordState $defaultFunctions = New-Object -TypeName 'System.Collections.Generic.Dictionary[System.String, ScriptBlock]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) [Microsoft.PowerShell.DesiredStateConfiguration.Internal.DscClassCache]::LoadDefaultCimKeywords($defaultFunctions) $defaultKeywordNames = New-Object -TypeName 'System.Collections.Generic.HashSet[System.String]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) foreach ($keyword in [Microsoft.PowerShell.DesiredStateConfiguration.Internal.DscClassCache]::GetCachedKeywords()) { $null = $defaultKeywordNames.Add($keyword.Keyword) } $resources = New-Object -TypeName 'System.Collections.Generic.List[System.String]' $resources.Add('*') $throwaway = New-Object -TypeName 'System.Collections.Generic.Dictionary[System.String, ScriptBlock]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) $null = [Microsoft.PowerShell.DesiredStateConfiguration.Internal.DscClassCache]::ImportClassResourcesFromModule($Module, $resources, $throwaway) $dscResourcesPath = Join-Path $Module.ModuleBase 'DscResources' if (Test-Path $dscResourcesPath) { foreach ($resourceDirectory in [System.IO.Directory]::EnumerateDirectories($dscResourcesPath)) { $resourceName = Split-Path $resourceDirectory -Leaf if (-not (Test-Path (Join-Path $resourceDirectory "$resourceName.schema.mof"))) { continue } $schemaFilePath = $null $keywordErrors = New-Object -TypeName 'System.Collections.ObjectModel.Collection[System.Exception]' $null = [Microsoft.PowerShell.DesiredStateConfiguration.Internal.DscClassCache]::ImportCimKeywordsFromModule( $Module, $resourceName, [ref] $schemaFilePath, $throwaway, $keywordErrors) foreach ($keywordError in $keywordErrors) { Write-Warning -Message "Schema of resource '$resourceName' was not imported: $($keywordError.Message)" } } } $keywords = New-Object -TypeName 'System.Collections.Generic.List[System.Object]' foreach ($keyword in [Microsoft.PowerShell.DesiredStateConfiguration.Internal.DscClassCache]::GetCachedKeywords()) { if (-not $defaultKeywordNames.Contains($keyword.Keyword)) { $keywords.Add((ConvertTo-DscKeywordSchemaObject -Keyword $keyword)) } } Reset-DscKeywordState $entries = Get-DscModuleSourceEntry -ModuleBase $Module.ModuleBase $fingerprint = ConvertTo-DscSourceFingerprint -Entry $entries $sources = [ordered]@{} foreach ($item in $entries) { $sources[$item.Path] = @{ length = $item.Length sha256 = (Get-FileHash -Path (Join-Path $Module.ModuleBase $item.Path) -Algorithm SHA256).Hash } } $index = [ordered]@{} $resourceCount = 0 for ($i = 0; $i -lt $keywords.Count; $i++) { $index[[System.String]$keywords[$i].keyword] = $i + 2 if ($keywords[$i].nameMode -eq 'NameRequired') { $resourceCount++ } } $header = [ordered]@{ formatVersion = $script:SchemaCacheFormatVersion generator = @{ psdscVersion = $ExecutionContext.SessionState.Module.Version.ToString() psVersion = $PSVersionTable.PSVersion.ToString() } module = @{ name = $Module.Name version = $Module.Version.ToString() fingerprint = $fingerprint } resourceCount = $resourceCount keywordCount = $keywords.Count index = $index } if (-not $OutputPath) { $OutputPath = Join-Path $Module.ModuleBase 'DscSchemaCache.json' } $directory = Split-Path $OutputPath -Parent if ($directory -and -not (Test-Path $directory)) { $null = New-Item -ItemType Directory -Force -Path $directory } $lines = New-Object -TypeName 'System.Collections.Generic.List[System.String]' $lines.Add((ConvertTo-Json -InputObject $header -Depth 6 -Compress)) $lines.Add((ConvertTo-Json -InputObject $sources -Depth 4 -Compress)) foreach ($keyword in $keywords) { $lines.Add((ConvertTo-Json -InputObject $keyword -Depth 12 -Compress)) } [System.IO.File]::WriteAllLines($OutputPath, $lines, [System.Text.UTF8Encoding]::new($false)) Write-Verbose -Message "Schema cache for $($Module.Name) $($Module.Version) written to $OutputPath ($($keywords.Count) keywords)." [PSCustomObject]@{ ModuleName = $Module.Name ModuleVersion = $Module.Version ResourceCount = $resourceCount KeywordCount = $keywords.Count Fingerprint = $fingerprint Path = $OutputPath } } Export-ModuleMember -Function Export-DscSchemaCache function Test-DscSchemaCache { [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory)] [System.String] $ModulePath, [Parameter(Mandatory)] [System.String] $CachePath, [switch] $Detailed ) if (-not (Test-Path $CachePath)) { Write-Warning -Message "Schema cache '$CachePath' does not exist." return $false } $manifestPath = if ([System.IO.Path]::GetExtension($ModulePath) -eq '.psd1') { $ModulePath } else { Get-ChildItem -Path $ModulePath -Filter '*.psd1' | Select-Object -First 1 -ExpandProperty FullName } $moduleBase = Split-Path $manifestPath -Parent $manifest = Import-PowerShellDataFile -Path $manifestPath try { $cache = Read-DscSchemaCacheFile -Path $CachePath } catch { Write-Warning -Message "Schema cache '$CachePath' could not be read: $($_.Exception.Message)" return $false } $valid = $true if ($cache.formatVersion -ne $script:SchemaCacheFormatVersion) { Write-Warning -Message "Cache format version $($cache.formatVersion) is not the supported version $script:SchemaCacheFormatVersion." return $false } if ($cache.module.version -ne $manifest.ModuleVersion) { Write-Warning -Message "Cache is for module version $($cache.module.version); manifest declares $($manifest.ModuleVersion)." $valid = $false } $sources = Get-DscSchemaCacheSourceList -Cache $cache foreach ($entry in $sources.PSObject.Properties) { $file = Join-Path $moduleBase $entry.Name if (-not (Test-Path $file)) { Write-Warning -Message "File in cache no longer exists: $($entry.Name)" $valid = $false } elseif ($Detailed -and (Get-FileHash -Path $file -Algorithm SHA256).Hash -ne $entry.Value.sha256) { Write-Warning -Message "File changed since cache generation: $($entry.Name)" $valid = $false } } $valid } Export-ModuleMember -Function Test-DscSchemaCache # A file that appears next to the module later must not invalidate the cache. Only the # recorded files count. function Get-DscSchemaCacheSourceFingerprint { [OutputType([System.String])] param ( [Parameter(Mandatory)] [System.String] $ModuleBase, [Parameter(Mandatory)] $Cache ) $sources = Get-DscSchemaCacheSourceList -Cache $Cache $entries = New-Object -TypeName 'System.Collections.Generic.List[System.Object]' foreach ($entry in $sources.PSObject.Properties) { $file = Join-Path $ModuleBase $entry.Name if (-not [System.IO.File]::Exists($file)) { return $null } $entries.Add([PSCustomObject]@{ Path = $entry.Name; Length = [System.IO.FileInfo]::new($file).Length }) } if ($entries.Count -eq 0) { return $null } ConvertTo-DscSourceFingerprint -Entry @($entries | Sort-Object -Property Path) } function Get-DscSchemaCache { param ( [Parameter(Mandatory)] $Module, [string[]] $SchemaCachePath ) $fingerprint = Get-DscModuleFingerprint -Module $Module $candidates = @() if ($SchemaCachePath) { $candidates += $SchemaCachePath } $candidates += (Join-Path $Module.ModuleBase 'DscSchemaCache.json') $candidates += Get-DscSchemaCacheUserPath -ModuleName $Module.Name -ModuleVersion $Module.Version -Fingerprint $fingerprint foreach ($candidate in $candidates) { if (-not (Test-Path $candidate)) { continue } try { $cache = Read-DscSchemaCacheFile -Path $candidate } catch { Write-Warning -Message "Schema cache '$candidate' could not be read: $($_.Exception.Message)" continue } if ($cache.formatVersion -ne $script:SchemaCacheFormatVersion) { Write-Verbose -Message "Schema cache '$candidate' has format version $($cache.formatVersion), expected $script:SchemaCacheFormatVersion." continue } if ($cache.module.name -ne $Module.Name -or $cache.module.version -ne $Module.Version.ToString()) { Write-Warning -Message "Schema cache '$candidate' is for $($cache.module.name) $($cache.module.version), not $($Module.Name) $($Module.Version)." continue } if ($cache.module.fingerprint -ne $fingerprint) { $recorded = Get-DscSchemaCacheSourceFingerprint -ModuleBase $Module.ModuleBase -Cache $cache if ($cache.module.fingerprint -ne $recorded) { Write-Verbose -Message "Schema cache '$candidate' is stale (module files changed)." continue } } return $cache } $null } function New-DscSchemaCacheForModule { param ( [Parameter(Mandatory)] $Module ) $moduleInfo = $Module if ($Module -isnot [System.Management.Automation.PSModuleInfo]) { $moduleInfo = Get-Module -ListAvailable -Name $Module.Path | Select-Object -First 1 if ($null -eq $moduleInfo) { throw "Module manifest '$($Module.Path)' could not be loaded for schema discovery." } } $fingerprint = Get-DscModuleFingerprint -Module $moduleInfo $userPath = Get-DscSchemaCacheUserPath -ModuleName $moduleInfo.Name -ModuleVersion $moduleInfo.Version -Fingerprint $fingerprint Write-Verbose -Message "Generating schema cache for $($moduleInfo.Name) $($moduleInfo.Version) (one-time operation)." $null = Export-DscSchemaCache -Module $moduleInfo -OutputPath $userPath Get-DscSchemaCache -Module $moduleInfo -SchemaCachePath $userPath } |