dev #2

Merged
Torsten merged 3 commits from dev into main 2026-07-03 10:00:00 +00:00
8 changed files with 408 additions and 76 deletions
Showing only changes of commit fcbe61902f - Show all commits

View File

@@ -0,0 +1,22 @@
function Get-ConfigurationDataDictionaryValue {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true)]
[System.Collections.IDictionary]
$Dictionary,
[Parameter(Mandatory=$true)]
[string]
$Key,
[Parameter(Mandatory=$false)]
[AllowNull()]
$DefaultValue = $null
)
if(Test-ConfigurationDataDictionaryKey -Dictionary $Dictionary -Key $Key){
return $Dictionary[$Key]
}
return $DefaultValue
}

View File

@@ -0,0 +1,31 @@
function Get-ConfigurationDataTemplateExtends {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true)]
[System.Collections.IDictionary]
$ConfigurationData
)
$Extends = $null
if(Test-ConfigurationDataDictionaryKey -Dictionary $ConfigurationData -Key "Metadata"){
$Metadata = Get-ConfigurationDataDictionaryValue -Dictionary $ConfigurationData -Key "Metadata"
if($Metadata -is [System.Collections.IDictionary] -and (Test-ConfigurationDataDictionaryKey -Dictionary $Metadata -Key "Extends")){
$Extends = Get-ConfigurationDataDictionaryValue -Dictionary $Metadata -Key "Extends"
}
}
if($null -eq $Extends -and (Test-ConfigurationDataDictionaryKey -Dictionary $ConfigurationData -Key "Extends")){
$Extends = Get-ConfigurationDataDictionaryValue -Dictionary $ConfigurationData -Key "Extends"
}
if($null -eq $Extends){
return @()
}
if($Extends -is [System.Array] -and $Extends -isnot [string]){
return @($Extends)
}
return @($Extends)
}

View File

@@ -0,0 +1,42 @@
function Get-ConfigurationDataTemplateMergeFile {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true)]
[string]
$Path,
[Parameter(Mandatory=$false)]
[hashtable]
$Visited = @{}
)
$ResolvedPath = Resolve-ConfigurationDataTemplatePath -Path $Path
if($Visited.ContainsKey($ResolvedPath)){
$Chain = @($Visited.Keys + $ResolvedPath) -join " -> "
throw "Circular configuration data template inheritance detected: $Chain"
}
$Visited[$ResolvedPath] = $true
$ConfigurationData = Import-PowerShellDataFile -Path $ResolvedPath
if($ConfigurationData -isnot [System.Collections.IDictionary]){
throw "Configuration data template [$ResolvedPath] must contain a hashtable."
}
$Files = @()
$BasePath = Split-Path -Path $ResolvedPath -Parent
foreach($ParentPath in @(Get-ConfigurationDataTemplateExtends -ConfigurationData $ConfigurationData)){
if([string]::IsNullOrWhiteSpace([string]$ParentPath)){
continue
}
$ResolvedParentPath = Resolve-ConfigurationDataTemplatePath -Path ([string]$ParentPath) -BasePath $BasePath
$Files += @(Get-ConfigurationDataTemplateMergeFile -Path $ResolvedParentPath -Visited $Visited)
}
$Files += $ResolvedPath
$Visited.Remove($ResolvedPath)
return @($Files)
}

View File

@@ -0,0 +1,49 @@
function Merge-ConfigurationDataTemplateFile {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true)]
[string[]]
$Path,
[Parameter(Mandatory=$false)]
[switch]
$PassThru
)
$MergeFiles = @()
$AddedFiles = @{}
foreach($Item in $Path){
foreach($File in @(Get-ConfigurationDataTemplateMergeFile -Path $Item)){
if(-not $AddedFiles.ContainsKey($File)){
$MergeFiles += $File
$AddedFiles[$File] = $true
}
}
}
$MergedConfigurationData = $null
foreach($File in $MergeFiles){
$TemplateData = Import-PowerShellDataFile -Path $File
if($null -eq $MergedConfigurationData){
$MergedConfigurationData = Copy-ConfigurationDataValue -Value $TemplateData
if($MergedConfigurationData -is [System.Collections.IDictionary] -and (Test-ConfigurationDataDictionaryKey -Dictionary $MergedConfigurationData -Key "Metadata")){
$MergedConfigurationData.Remove("Metadata")
}
}else{
$MergedConfigurationData = Merge-DSCConfigurationData -Template $MergedConfigurationData -Deployment $TemplateData
if($MergedConfigurationData -is [System.Collections.IDictionary] -and (Test-ConfigurationDataDictionaryKey -Dictionary $MergedConfigurationData -Key "Metadata")){
$MergedConfigurationData.Remove("Metadata")
}
}
}
if($PassThru){
return [PSCustomObject]@{
Files = $MergeFiles
ConfigurationData = $MergedConfigurationData
}
}
return $MergedConfigurationData
}

View File

@@ -0,0 +1,24 @@
function Resolve-ConfigurationDataTemplatePath {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true)]
[string]
$Path,
[Parameter(Mandatory=$false)]
[string]
$BasePath = (Get-Location).Path
)
$CandidatePath = $Path
if(-not [System.IO.Path]::IsPathRooted($CandidatePath)){
$CandidatePath = Join-Path -Path $BasePath -ChildPath $CandidatePath
}
$ResolvedPath = Resolve-Path -Path $CandidatePath -ErrorAction SilentlyContinue
if($null -eq $ResolvedPath){
throw "Configuration data template [$Path] was not found. Base path [$BasePath]."
}
return $ResolvedPath.ProviderPath
}

View File

@@ -0,0 +1,18 @@
function Test-ConfigurationDataDictionaryKey {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true)]
[System.Collections.IDictionary]
$Dictionary,
[Parameter(Mandatory=$true)]
[string]
$Key
)
if($Dictionary -is [System.Collections.Specialized.OrderedDictionary]){
return $Dictionary.Contains($Key)
}
return $Dictionary.ContainsKey($Key)
}

View File

@@ -1,47 +1,65 @@
function Merge-DSCConfigurationData {
[CmdletBinding()]
[CmdletBinding(DefaultParameterSetName="Data")]
Param(
[Parameter(Mandatory=$true)]
[System.Collections.Hashtable]
[Parameter(Mandatory=$true, ParameterSetName="Data")]
[System.Collections.IDictionary]
$Template,
[Parameter(Mandatory=$false)]
[System.Collections.Hashtable]
[Parameter(Mandatory=$false, ParameterSetName="Data")]
[System.Collections.IDictionary]
$Deployment = @{},
[Parameter(Mandatory=$false)]
[System.Collections.Hashtable]
$Output
[Parameter(Mandatory=$false, ParameterSetName="Data")]
[System.Collections.IDictionary]
$Output,
[Parameter(Mandatory=$true, ParameterSetName="Path", ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
[Alias("FullName")]
[string[]]
$Path,
[Parameter(Mandatory=$false, ParameterSetName="Path")]
[switch]
$PassThru
)
begin {
$TemplatePath = @()
}
process {
if($PSCmdlet.ParameterSetName -eq "Path"){
$TemplatePath += @($Path)
return
}
if($Output -eq $null){
$Output = $Deployment
}
foreach($Property in $Template.GetEnumerator()){
if($Property.Value -is [System.Collections.Hashtable]){
Write-Verbose "Key [$($Property.Name)] is a Hashtable"
if($null -ne $Deployment.$($Property.Name)){
Write-Verbose "Key [$($Property.Name)] is present in Deployment Data"
$Output.($Property.Name) = Merge-DSCConfigurationData -Template $Template.$($Property.Name) -Deployment $Deployment.$($Property.Name) -Output $Output.$($Property.Name)
}else{
Write-Verbose "Key [$($Property.Name)] is not present in Deployment Data"
$Output.Add($($Property.Name),(Copy-ConfigurationDataValue -Value $Template.$($Property.Name)))
if($Property.Name -eq "Metadata"){
Write-Verbose "Skipping metadata block during configuration data merge"
continue
}
}elseif($Property.Value -is [System.Collections.Specialized.OrderedDictionary]){
Write-Verbose "Key [$($Property.Name)] is a Ordered Dictionary"
if($null -ne $Deployment.$($Property.Name)){
if($Property.Value -is [System.Collections.IDictionary]){
Write-Verbose "Key [$($Property.Name)] is a Dictionary"
if(Test-ConfigurationDataDictionaryKey -Dictionary $Deployment -Key $Property.Name){
Write-Verbose "Key [$($Property.Name)] is present in Deployment Data"
$Output.($Property.Name) = Merge-DSCConfigurationData -Template $Template.$($Property.Name) -Deployment $Deployment.$($Property.Name) -Output $Output.$($Property.Name)
$Output[$Property.Name] = Merge-DSCConfigurationData -Template $Template[$Property.Name] -Deployment $Deployment[$Property.Name] -Output $Output[$Property.Name]
}else{
Write-Verbose "Key [$($Property.Name)] is not present in Deployment Data"
$Output.Add($($Property.Name),(Copy-ConfigurationDataValue -Value $Template.$($Property.Name)))
$Output.Add($Property.Name,(Copy-ConfigurationDataValue -Value $Template[$Property.Name]))
}
}elseif($Property.Value -is [System.Array]){
Write-Verbose "$($Property.Name) is ein Array"
Write-Verbose "Total Items in Template Array [$($Property.Value.Count)]"
Write-Verbose "Total Items in Deployment Array [$($Deployment.$($Property.Name).Count)]"
if($null -ne $Deployment.$($Property.Name)){
Write-Verbose "Total Items in Deployment Array [$(@(Get-ConfigurationDataDictionaryValue -Dictionary $Deployment -Key $Property.Name -DefaultValue @()).Count)]"
if(Test-ConfigurationDataDictionaryKey -Dictionary $Deployment -Key $Property.Name){
Write-Verbose "Array is defined in Deployment"
$DeploymentArray = Get-ConfigurationDataDictionaryValue -Dictionary $Deployment -Key $Property.Name
$TemplateItems = for($i=0;$i -lt $Property.Value.Count; $i++){
$SearchKeyNames = @(Get-ConfigurationDataArrayMergeKeyNames -ArrayName $Property.Name -Item $Property.Value[$i])
[PSCustomObject]@{
@@ -58,19 +76,19 @@ function Merge-DSCConfigurationData {
if($SearchKeyNames.Count -eq 0){
Write-Verbose "No matching name key found for item in [$($Property.Name)]"
$Output.$($Property.Name) += ,(Copy-ConfigurationDataValue -Value $TemplateItem.Value)
$Output[$Property.Name] += ,(Copy-ConfigurationDataValue -Value $TemplateItem.Value)
continue
}
if($TemplateItem.IsWildcard){
$MatchingDeploymentItems = @($Deployment.$($Property.Name) | Where-Object { Test-ConfigurationDataItemHasKeys -Item $_ -KeyNames $SearchKeyNames })
$MatchingDeploymentItems = @($DeploymentArray | Where-Object { Test-ConfigurationDataItemHasKeys -Item $_ -KeyNames $SearchKeyNames })
}else{
$MatchingDeploymentItems = @($Deployment.$($Property.Name) | Where-Object { Test-ConfigurationDataItemKeyMatch -Left $_ -Right $TemplateItem.Value -KeyNames $SearchKeyNames })
$MatchingDeploymentItems = @($DeploymentArray | Where-Object { Test-ConfigurationDataItemKeyMatch -Left $_ -Right $TemplateItem.Value -KeyNames $SearchKeyNames })
}
if($MatchingDeploymentItems.Count -gt 0){
foreach($DeploymentItem in $MatchingDeploymentItems){
$OutputItem = @($Output.$($Property.Name) | Where-Object { Test-ConfigurationDataItemKeyMatch -Left $_ -Right $DeploymentItem -KeyNames $SearchKeyNames })[0]
$OutputItem = @($Output[$Property.Name] | Where-Object { Test-ConfigurationDataItemKeyMatch -Left $_ -Right $DeploymentItem -KeyNames $SearchKeyNames })[0]
Merge-DSCConfigurationData -Template $TemplateItem.Value -Deployment $DeploymentItem -Output $OutputItem | Out-Null
}
}else{
@@ -78,21 +96,29 @@ function Merge-DSCConfigurationData {
Write-Verbose "Wildcard item [$(Format-ConfigurationDataMergeKey -Item $TemplateItem.Value -KeyNames $SearchKeyNames)] in [$($Property.Name)] matched no deployment items"
}else{
Write-Verbose "Pair [$(Format-ConfigurationDataMergeKey -Item $TemplateItem.Value -KeyNames $SearchKeyNames)] not present"
$Output.$($Property.Name) += ,(Copy-ConfigurationDataValue -Value $TemplateItem.Value)
$Output[$Property.Name] += ,(Copy-ConfigurationDataValue -Value $TemplateItem.Value)
}
}
}
}else{
Write-Verbose "Array is not defined in Deployment"
$Output.$($Property.Name) = Copy-ConfigurationDataValue -Value $Template.$($Property.Name)
$Output[$Property.Name] = Copy-ConfigurationDataValue -Value $Template[$Property.Name]
}
}else{
Write-Verbose "$($Property.Name) is a String or Integer Value"
if($null -eq $Deployment.$($Property.Name)){
$Output.Add($Property.Name,(Copy-ConfigurationDataValue -Value $Template.($Property.Name)))
}elseif($Deployment.$($Property.Name) -ne $Property.Value){
if(-not (Test-ConfigurationDataDictionaryKey -Dictionary $Deployment -Key $Property.Name)){
$Output.Add($Property.Name,(Copy-ConfigurationDataValue -Value $Template[$Property.Name]))
}elseif($Deployment[$Property.Name] -ne $Property.Value){
}
}
}
return $Output
}
end {
if($PSCmdlet.ParameterSetName -eq "Path"){
return Merge-ConfigurationDataTemplateFile -Path $TemplatePath -PassThru:$PassThru
}
}
}

120
Tests/Extends.Tests.ps1 Normal file
View File

@@ -0,0 +1,120 @@
$script:ModuleRoot = Split-Path -Parent $PSScriptRoot
$script:ModuleManifest = Join-Path -Path $script:ModuleRoot -ChildPath 'Merge-DSCConfigurationData.psd1'
Import-Module $script:ModuleManifest -Force
Describe 'Merge-DSCConfigurationData Extends' {
It 'keeps the existing hashtable merge behavior' {
$Template = @{
Parameters = @{
DomainFQDN = @{
Type = 'string'
Required = $true
DefaultValue = 'example.local'
}
}
}
$Deployment = @{
Parameters = @{
DomainFQDN = @{
Value = 'contoso.local'
}
}
}
$Result = Merge-DSCConfigurationData -Template $Template -Deployment $Deployment
$Result.Parameters.DomainFQDN.Type | Should Be 'string'
$Result.Parameters.DomainFQDN.Required | Should Be $true
$Result.Parameters.DomainFQDN.Value | Should Be 'contoso.local'
}
It 'merges a child template with Metadata.Extends' {
$DefaultPath = Join-Path -Path $TestDrive -ChildPath 'Default.psd1'
$ChildPath = Join-Path -Path $TestDrive -ChildPath 'Contoso.psd1'
@'
@{
Metadata = @{
TemplateType = 'Environment'
Name = 'Default'
}
Parameters = @{
DomainFQDN = @{
Type = 'string'
Required = $true
DefaultValue = 'example.local'
}
DomainNetBIOS = @{
Type = 'string'
Required = $true
}
}
Resources = @{
NonNodeData = @{
RequiredModules = @(
@{
Name = 'ActiveDirectoryDsc'
Version = '6.7.1'
}
)
}
}
}
'@ | Set-Content -Path $DefaultPath -Encoding UTF8
@'
@{
Metadata = @{
TemplateType = 'Environment'
Name = 'Contoso'
Extends = './Default.psd1'
}
Parameters = @{
DomainFQDN = @{
Value = 'contoso.local'
}
DomainNetBIOS = @{
Value = 'CONTOSO'
}
}
}
'@ | Set-Content -Path $ChildPath -Encoding UTF8
$Result = Merge-DSCConfigurationData -Path $ChildPath -PassThru
$Result.Files.Count | Should Be 2
[System.IO.Path]::GetFileName($Result.Files[0]) | Should Be 'Default.psd1'
[System.IO.Path]::GetFileName($Result.Files[1]) | Should Be 'Contoso.psd1'
$Result.ConfigurationData.Parameters.DomainFQDN.Type | Should Be 'string'
$Result.ConfigurationData.Parameters.DomainFQDN.Value | Should Be 'contoso.local'
$Result.ConfigurationData.Parameters.DomainNetBIOS.Required | Should Be $true
$Result.ConfigurationData.Parameters.DomainNetBIOS.Value | Should Be 'CONTOSO'
$Result.ConfigurationData.Resources.NonNodeData.RequiredModules[0].Name | Should Be 'ActiveDirectoryDsc'
$Result.ConfigurationData.ContainsKey('Metadata') | Should Be $false
}
It 'detects circular Metadata.Extends references' {
$APath = Join-Path -Path $TestDrive -ChildPath 'A.psd1'
$BPath = Join-Path -Path $TestDrive -ChildPath 'B.psd1'
@'
@{
Metadata = @{
Extends = './B.psd1'
}
}
'@ | Set-Content -Path $APath -Encoding UTF8
@'
@{
Metadata = @{
Extends = './A.psd1'
}
}
'@ | Set-Content -Path $BPath -Encoding UTF8
{ Merge-DSCConfigurationData -Path $APath } | Should Throw
}
}