Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG/v3.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# v3 Changelog

## [v3.1.10]

- MOF instance properties follow ordinal name order, identical across processes and editions.

## [v3.1.9]

- The MOF instance alias counter is separated from a type name that ends in a digit. Appending the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

RootModule = 'PSDesiredStateConfiguration.psm1'

ModuleVersion = '3.1.9'
ModuleVersion = '3.1.10'

CompatiblePSEditions = @('Desktop', 'Core')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
RootModule = 'M365DSC.PSDesiredStateConfiguration.psm1'

# Version number of this module.
ModuleVersion = '3.1.9'
ModuleVersion = '3.1.10'

# Supported PSEditions
CompatiblePSEditions = @('Desktop', 'Core')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,25 @@ function IsDomainUser()
$true
}

function Get-DictionaryEntrySortedByKey
{
[OutputType([System.Collections.DictionaryEntry])]
param (
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[System.Collections.IDictionary]
$Dictionary
)

$keys = [System.Object[]]@($Dictionary.Keys)
$names = [System.String[]]@(foreach ($key in $keys) { [System.String]$key })
[System.Array]::Sort([System.Array]$names, [System.Array]$keys, [System.Collections.IComparer][System.StringComparer]::Ordinal)
foreach ($key in $keys)
{
[System.Collections.DictionaryEntry]::new($key, $Dictionary[$key])
}
}

#
# Utility routine to render a property
# as a string in MOF syntax.
Expand Down Expand Up @@ -325,7 +344,7 @@ function stringify ($Value, $asArray = $false, $targetType = [string])
elseif ($Value -is [System.Collections.Hashtable])
{
# Collect the individual strings
$elementsAsStrings = foreach ($p in $Value.GetEnumerator())
$elementsAsStrings = foreach ($p in (Get-DictionaryEntrySortedByKey -Dictionary $Value))
{
ConvertTo-MOFInstance MSFT_KeyValuePair @{
Key = $p.Key
Expand Down Expand Up @@ -559,7 +578,7 @@ function ConvertTo-MOFInstance
{
if ($Properties -and $Properties.Count)
{
foreach ($p in $Properties.GetEnumerator())
foreach ($p in (Get-DictionaryEntrySortedByKey -Dictionary $Properties))
{
Write-Debug -Message " Generating property data for '$($p.Name)' = '$($p.Value)'"
$targetTypeName = $PropertyTypes[$p.Name].TypeConstraint
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The fork is rebased onto the upstream v2.0.7 pure-script lineage, with a couple

- Dual-edition support (Windows PowerShell 5.1 and PowerShell 7) from a single module.
- Deterministic MOF output: no `Author`/`GenerationDate`/`GenerationHost` stamps, LF line endings,
UTF-8 without BOM on both editions.
UTF-16LE with BOM (the encoding the DSC engine requires) on both editions.
- A fast compilation host (`Invoke-DscFastCompile`) backed by a persistent JSON schema cache:
fresh-process compiles between 5x and 10x faster than a default run.
- Upstream MOF emission fixes [#127](https://github.com/PowerShell/PSDesiredStateConfiguration/pull/127)
Expand Down
20 changes: 11 additions & 9 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,20 +591,22 @@ any error writes nothing.

## Deterministic output

The same configuration compiles to identical bytes on every run, machine, user and edition, and three
The same configuration compiles to identical bytes on every run, machine, user and edition, and four
decisions make that true.

- No generated by banner, and no `Author`, `GenerationDate` or `GenerationHost` fields in the
configuration document.
- LF line endings inside the document, because the instance text is assembled with `\n`.
- Every file written through one helper, `Write-MofDocumentFile`, as UTF-8 without a byte order mark.
Output redirection would produce UTF-16 on Windows PowerShell 5.1 and UTF-8 on PowerShell 7 for the
same document.

Property **order** within an instance is the one thing that still moves, since it follows hashtable
enumeration and can differ between editions. `tools/benchmarks/Compare-DscMofOutput.ps1` normalizes
ordering, instance aliases, whitespace outside strings and the volatile document fields, which makes
it the right tool for comparing output across paths or editions.
- Every file written through one helper, `Write-MofDocumentFile`, as UTF-16LE with a byte order mark,
the encoding the DSC engine requires, ending in one CRLF. Output redirection would produce UTF-16 on
Windows PowerShell 5.1 and UTF-8 on PowerShell 7 for the same document.
- Instance properties, and the key value pairs of a hashtable property, in ordinal key order through
`Get-DictionaryEntrySortedByKey`. Hashtable enumeration order differs between editions and, on
PowerShell 7, between processes, since string hash codes are seeded per process.

`tools/benchmarks/Compare-DscMofOutput.ps1` normalizes ordering, instance aliases, whitespace outside
strings and the volatile document fields, which makes it the right tool for comparing output of the
standard path with the fast host, whose `SourceInfo` line numbers differ.

## The other exported commands

Expand Down
149 changes: 149 additions & 0 deletions test/FastHost.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1103,3 +1103,152 @@ Configuration ForceCfg
}
}
}

Describe 'Deterministic MOF output' {
BeforeAll {
# PowerShell 7 seeds string hash codes per process.
$script:PSDscDeterminismProbe = Join-Path $TestDrive 'probe-determinism.ps1'
Set-Content -Path $script:PSDscDeterminismProbe -Encoding UTF8 -Value @'
param
(
[string] $Repo,
[string] $Out,
[string] $Mode
)

$separator = [System.IO.Path]::PathSeparator
$env:PSModulePath = (Join-Path $Repo 'test\TestModules') + $separator + $env:PSModulePath
Import-Module (Join-Path $Repo 'M365DSC.PSDesiredStateConfiguration\M365DSC.PSDesiredStateConfiguration.psd1') -Force

$configurationText = @(
'Configuration DeterministicCfg'
'{'
' param ([PSCredential] $Credential)'
' Import-DscResource -ModuleName xTestClassResource'
' Import-DscResource -ModuleName xTestCredentialResource'
' Node localhost'
' {'
' xTestClassResource r1'
' {'
" Name = 'r1'"
" Value = 'v1'"
" Settings = 's1'"
" Ensure = 'Present'"
" sArray = @('a', 'b')"
' bValue = $true'
' sInt32Value = 5'
' uInt16Value = 7'
' Real64Value = 1.5'
" HashTableValue = @{ k1 = 'v1'; k2 = 'v2'; k3 = 'v3'; alpha = 'a'; beta = 'b'; gamma = 'g' }"
" EmbClassObj = EmbClass { EmbClassStr1 = 'emb1'; User = 'u1' }"
' }'
' xTestCredentialResource c1'
' {'
" Name = 'c1'"
" Value = 'cv'"
' Credential = $Credential'
" DependsOn = '[xTestClassResource]r1'"
' }'
' }'
'}'
) -join [Environment]::NewLine

$credential = [PSCredential]::new('det-user', (ConvertTo-SecureString -String 'Det3rm1n1sm!' -AsPlainText -Force))
$configurationData = @{ AllNodes = @(@{ NodeName = 'localhost'; PSDscAllowPlainTextPassword = $true }) }

if ($Mode -eq 'Fast')
{
$null = Invoke-DscFastCompile -ScriptText $configurationText -OutputPath $Out -NoFallback `
-Parameters @{ Credential = $credential } -ConfigurationData $configurationData
}
else
{
Invoke-Expression $configurationText
$null = DeterministicCfg -OutputPath $Out -Credential $credential -ConfigurationData $configurationData
}

[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes((Join-Path $Out 'localhost.mof')))
'@

function Invoke-PSDscDeterminismProbe
{
param
(
[string] $Shell,
[string] $Mode,
[string] $Name
)

$output = & $Shell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script:PSDscDeterminismProbe `
-Repo $script:PSDscRepoRoot -Out (Join-Path $TestDrive $Name) -Mode $Mode
$encoded = @($output) | Where-Object { $_ -is [System.String] -and $_ -match '^[A-Za-z0-9+/]{16,}={0,2}$' } | Select-Object -Last 1
if (-not $encoded)
{
throw "The $Mode probe on '$Shell' produced no MOF: $($output -join [Environment]::NewLine)"
}
[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String($encoded))
}

function Get-PSDscMofInstancePropertyName
{
param
(
[string] $Mof,
[string] $ClassName
)

$match = [regex]::Match($Mof, "instance of $ClassName as [^\n]*\n\{\n(?<body>.*?)(?=instance of |\z)", [System.Text.RegularExpressions.RegexOptions]::Singleline)
$match.Success | Should -Be $true
@([regex]::Matches($match.Groups['body'].Value, '(?m)^\s*(\w+)\s*=') | ForEach-Object { $_.Groups[1].Value })
}

$script:PSDscCurrentShell = (Get-Process -Id $PID).Path
}

It 'writes byte-identical MOF from two separate processes on the <Mode> path' -TestCases @(
@{ Mode = 'Fast' }
@{ Mode = 'Standard' }
) {
$first = Invoke-PSDscDeterminismProbe -Shell $script:PSDscCurrentShell -Mode $Mode -Name "$Mode-first"
$second = Invoke-PSDscDeterminismProbe -Shell $script:PSDscCurrentShell -Mode $Mode -Name "$Mode-second"

$second | Should -BeExactly $first
}

It 'writes the properties of every instance in ordinal name order on the <Mode> path' -TestCases @(
@{ Mode = 'Fast' }
@{ Mode = 'Standard' }
) {
$mof = Invoke-PSDscDeterminismProbe -Shell $script:PSDscCurrentShell -Mode $Mode -Name "$Mode-order"

foreach ($className in 'xTestClassResource', 'EmbClass', 'MSFT_Credential', 'xTestCredentialResource')
{
$names = @(Get-PSDscMofInstancePropertyName -Mof $mof -ClassName $className |
Where-Object { $_ -notin 'DependsOn', 'ConfigurationName' })
$sorted = [string[]]$names.Clone()
[System.Array]::Sort($sorted, [System.StringComparer]::Ordinal)
($names -join ',') | Should -BeExactly ($sorted -join ',') -Because "the $className instance lists its properties in name order"
}

$keys = @([regex]::Matches($mof, 'instance of MSFT_KeyValuePair as [^\n]*\n\{\nKey = "(\w+)"') | ForEach-Object { $_.Groups[1].Value })
($keys -join ',') | Should -BeExactly 'alpha,beta,gamma,k1,k2,k3'
}

It 'writes byte-identical MOF on Windows PowerShell 5.1 and PowerShell 7 on the <Mode> path' -TestCases @(
@{ Mode = 'Fast' }
@{ Mode = 'Standard' }
) {
$desktop = Get-Command -Name 'powershell' -CommandType Application -ErrorAction Ignore | Select-Object -First 1
$core = Get-Command -Name 'pwsh' -CommandType Application -ErrorAction Ignore | Select-Object -First 1
if ($null -eq $desktop -or $null -eq $core)
{
Set-ItResult -Skipped -Because 'both Windows PowerShell 5.1 and PowerShell 7 are required'
return
}

$desktopMof = Invoke-PSDscDeterminismProbe -Shell $desktop.Source -Mode $Mode -Name "$Mode-desktop"
$coreMof = Invoke-PSDscDeterminismProbe -Shell $core.Source -Mode $Mode -Name "$Mode-core"

$coreMof | Should -BeExactly $desktopMof
}
}