diff --git a/Private/Get-ConfigurationDataDictionaryValue.ps1 b/Private/Get-ConfigurationDataDictionaryValue.ps1 new file mode 100644 index 0000000..ae2da54 --- /dev/null +++ b/Private/Get-ConfigurationDataDictionaryValue.ps1 @@ -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 +} diff --git a/Private/Get-ConfigurationDataTemplateExtends.ps1 b/Private/Get-ConfigurationDataTemplateExtends.ps1 new file mode 100644 index 0000000..0432a0d --- /dev/null +++ b/Private/Get-ConfigurationDataTemplateExtends.ps1 @@ -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) +} diff --git a/Private/Get-ConfigurationDataTemplateMergeFile.ps1 b/Private/Get-ConfigurationDataTemplateMergeFile.ps1 new file mode 100644 index 0000000..790a42c --- /dev/null +++ b/Private/Get-ConfigurationDataTemplateMergeFile.ps1 @@ -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) +} diff --git a/Private/Merge-ConfigurationDataTemplateFile.ps1 b/Private/Merge-ConfigurationDataTemplateFile.ps1 new file mode 100644 index 0000000..d2bb578 --- /dev/null +++ b/Private/Merge-ConfigurationDataTemplateFile.ps1 @@ -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 +} diff --git a/Private/Resolve-ConfigurationDataTemplatePath.ps1 b/Private/Resolve-ConfigurationDataTemplatePath.ps1 new file mode 100644 index 0000000..7608a18 --- /dev/null +++ b/Private/Resolve-ConfigurationDataTemplatePath.ps1 @@ -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 +} diff --git a/Private/Test-ConfigurationDataDictionaryKey.ps1 b/Private/Test-ConfigurationDataDictionaryKey.ps1 new file mode 100644 index 0000000..5be4e9b --- /dev/null +++ b/Private/Test-ConfigurationDataDictionaryKey.ps1 @@ -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) +} diff --git a/Public/Merge-DSCConfigurationData.ps1 b/Public/Merge-DSCConfigurationData.ps1 index 414a3b1..edcea57 100644 --- a/Public/Merge-DSCConfigurationData.ps1 +++ b/Public/Merge-DSCConfigurationData.ps1 @@ -1,98 +1,124 @@ 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 ) - if($Output -eq $null){ - $Output = $Deployment + begin { + $TemplatePath = @() } - 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))) - } - }elseif($Property.Value -is [System.Collections.Specialized.OrderedDictionary]){ - Write-Verbose "Key [$($Property.Name)] is a Ordered Dictionary" - 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))) - } - }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 "Array is defined in Deployment" + process { + if($PSCmdlet.ParameterSetName -eq "Path"){ + $TemplatePath += @($Path) + return + } - $TemplateItems = for($i=0;$i -lt $Property.Value.Count; $i++){ - $SearchKeyNames = @(Get-ConfigurationDataArrayMergeKeyNames -ArrayName $Property.Name -Item $Property.Value[$i]) - [PSCustomObject]@{ - Value = $Property.Value[$i] - SearchKeyNames = $SearchKeyNames - IsWildcard = Test-ConfigurationDataItemWildcard -Item $Property.Value[$i] -KeyNames $SearchKeyNames - } + if($Output -eq $null){ + $Output = $Deployment + } + + foreach($Property in $Template.GetEnumerator()){ + if($Property.Name -eq "Metadata"){ + Write-Verbose "Skipping metadata block during configuration data merge" + continue + } + + 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] + }else{ + Write-Verbose "Key [$($Property.Name)] is not present in Deployment Data" + $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 [$(@(Get-ConfigurationDataDictionaryValue -Dictionary $Deployment -Key $Property.Name -DefaultValue @()).Count)]" + if(Test-ConfigurationDataDictionaryKey -Dictionary $Deployment -Key $Property.Name){ + Write-Verbose "Array is defined in Deployment" - $TemplateItems = @($TemplateItems | Where-Object { -not $_.IsWildcard }) + @($TemplateItems | Where-Object { $_.IsWildcard }) - - foreach($TemplateItem in $TemplateItems){ - $SearchKeyNames = @($TemplateItem.SearchKeyNames) - - 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) - continue - } - - if($TemplateItem.IsWildcard){ - $MatchingDeploymentItems = @($Deployment.$($Property.Name) | Where-Object { Test-ConfigurationDataItemHasKeys -Item $_ -KeyNames $SearchKeyNames }) - }else{ - $MatchingDeploymentItems = @($Deployment.$($Property.Name) | 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] - Merge-DSCConfigurationData -Template $TemplateItem.Value -Deployment $DeploymentItem -Output $OutputItem | Out-Null + $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]@{ + Value = $Property.Value[$i] + SearchKeyNames = $SearchKeyNames + IsWildcard = Test-ConfigurationDataItemWildcard -Item $Property.Value[$i] -KeyNames $SearchKeyNames } - }else{ + } + + $TemplateItems = @($TemplateItems | Where-Object { -not $_.IsWildcard }) + @($TemplateItems | Where-Object { $_.IsWildcard }) + + foreach($TemplateItem in $TemplateItems){ + $SearchKeyNames = @($TemplateItem.SearchKeyNames) + + 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) + continue + } + if($TemplateItem.IsWildcard){ - Write-Verbose "Wildcard item [$(Format-ConfigurationDataMergeKey -Item $TemplateItem.Value -KeyNames $SearchKeyNames)] in [$($Property.Name)] matched no deployment items" + $MatchingDeploymentItems = @($DeploymentArray | Where-Object { Test-ConfigurationDataItemHasKeys -Item $_ -KeyNames $SearchKeyNames }) }else{ - Write-Verbose "Pair [$(Format-ConfigurationDataMergeKey -Item $TemplateItem.Value -KeyNames $SearchKeyNames)] not present" - $Output.$($Property.Name) += ,(Copy-ConfigurationDataValue -Value $TemplateItem.Value) + $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] + Merge-DSCConfigurationData -Template $TemplateItem.Value -Deployment $DeploymentItem -Output $OutputItem | Out-Null + } + }else{ + if($TemplateItem.IsWildcard){ + 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) + } } } + }else{ + Write-Verbose "Array is not defined in Deployment" + $Output[$Property.Name] = Copy-ConfigurationDataValue -Value $Template[$Property.Name] } }else{ - Write-Verbose "Array is not defined in Deployment" - $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){ + Write-Verbose "$($Property.Name) is a String or Integer 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 + } } - return $Output } diff --git a/Tests/Extends.Tests.ps1 b/Tests/Extends.Tests.ps1 new file mode 100644 index 0000000..4781804 --- /dev/null +++ b/Tests/Extends.Tests.ps1 @@ -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 + } +}