diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..877df7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.git +.sample +.tests \ No newline at end of file diff --git a/Merge-ConfigurationData.ps1 b/Merge-ConfigurationData.ps1 deleted file mode 100644 index 79fddec..0000000 --- a/Merge-ConfigurationData.ps1 +++ /dev/null @@ -1,70 +0,0 @@ -function Merge-ConfigurationData { - [CmdletBinding()] - Param( - [Parameter(Mandatory=$true)] - [System.Collections.Hashtable] - $Template, - [Parameter(Mandatory=$true)] - [System.Collections.Hashtable] - $Deployment, - [Parameter(Mandatory=$false)] - [System.Collections.Hashtable] - $Output - ) - - 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-ConfigurationData -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),$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-ConfigurationData -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),$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" - for($i=0;$i -lt $Property.Value.Count; $i++){ - - $SearchItem = $($Property.Value[$i].GetEnumerator() | Where-Object {($_.Value -is [String]) -and ($_.Name -like "*Name")})[0] - if($($Deployment.$($Property.Name) | ? { $_.($SearchItem.Name) -eq $SearchItem.Value })){ - Merge-ConfigurationData -Template $Property.Value[$i] -Deployment $($Deployment.$($Property.Name) | ? { $_.($SearchItem.Name) -eq $SearchItem.Value }) -Output $($Output.$($Property.Name) | ? { $_.($SearchItem.Name) -eq $SearchItem.Value }) | Out-Null - }else{ - Write-Verbose "Pair $($Key.Name) - $($Key.Value) not present" - $Output.$($Property.Name) += $Property.Value[$i] - } - } - }else{ - Write-Verbose "Array is not defined in Deployment" - $Output.$($Property.Name) = $Template.$($Property.Name) - } - }else{ - Write-Verbose "$($Property.Name) is a String or Integer Value" - if($null -eq $Deployment.$($Property.Name)){ - - $Output.Add($Property.Name,$Template.($Property.Name)) - - }elseif($Deployment.$($Property.Name) -ne $Property.Value){ - - } - } - } - return $Output -} \ No newline at end of file diff --git a/Merge-DSCConfigurationData.psd1 b/Merge-DSCConfigurationData.psd1 new file mode 100644 index 0000000..8e84beb --- /dev/null +++ b/Merge-DSCConfigurationData.psd1 @@ -0,0 +1,26 @@ +@{ + RootModule = "Merge-DSCConfigurationData.psm1" + ModuleVersion = "1.0.1" + GUID = "c1c7e70d-9049-4eaa-a3c9-44a424c35ef5" + Author = "Torsten Brendgen" + Copyright = "(c) Torsten Brendgen. All rights reserved." + Description = "Merges DSC configuration data from templates and deployment data." + PowerShellVersion = "5.1" + FunctionsToExport = @( + "Merge-DSCConfigurationData", + "Export-PowerShellDataFile", + "New-DSCConfigurationDataDeployment" + ) + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @( + "DSC", + "ConfigurationData" + ) + ReleaseNotes = "Initial module layout." + } + } +} diff --git a/Merge-DSCConfigurationData.psm1 b/Merge-DSCConfigurationData.psm1 new file mode 100644 index 0000000..45a98a4 --- /dev/null +++ b/Merge-DSCConfigurationData.psm1 @@ -0,0 +1,15 @@ +$PrivatePath = Join-Path -Path $PSScriptRoot -ChildPath "Private" +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath "Public" + +$Private = @(Get-ChildItem -Path $PrivatePath -Filter "*.ps1" -File -ErrorAction Stop | Sort-Object -Property FullName) +$Public = @(Get-ChildItem -Path $PublicPath -Filter "*.ps1" -File -ErrorAction Stop | Sort-Object -Property FullName) + +foreach($File in @($Private + $Public)){ + . $File.FullName +} + +Export-ModuleMember -Function @( + "Merge-DSCConfigurationData", + "Export-PowerShellDataFile", + "New-DSCConfigurationDataDeployment" +) diff --git a/Private/ConvertTo-ConfigurationDataRelativePath.ps1 b/Private/ConvertTo-ConfigurationDataRelativePath.ps1 new file mode 100644 index 0000000..c99fac4 --- /dev/null +++ b/Private/ConvertTo-ConfigurationDataRelativePath.ps1 @@ -0,0 +1,29 @@ +function ConvertTo-ConfigurationDataRelativePath { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [string] + $Path, + + [Parameter(Mandatory=$true)] + [string] + $BasePath + ) + + $ResolvedPath = Repair-ConfigurationDataPathEncoding -Path $Path + $ResolvedBasePath = Repair-ConfigurationDataPathEncoding -Path $BasePath + + try { + $PathUri = [System.Uri]::new((Resolve-Path -LiteralPath $ResolvedPath).ProviderPath) + $BaseUriPath = [System.IO.Path]::GetFullPath($ResolvedBasePath) + if(-not $BaseUriPath.EndsWith([System.IO.Path]::DirectorySeparatorChar)){ + $BaseUriPath += [System.IO.Path]::DirectorySeparatorChar + } + + $BaseUri = [System.Uri]::new($BaseUriPath) + return [System.Uri]::UnescapeDataString($BaseUri.MakeRelativeUri($PathUri).ToString()).Replace('/', [System.IO.Path]::DirectorySeparatorChar) + } + catch { + return $ResolvedPath + } +} diff --git a/Private/ConvertTo-PowerShellDataFileText.ps1 b/Private/ConvertTo-PowerShellDataFileText.ps1 new file mode 100644 index 0000000..c2fd90b --- /dev/null +++ b/Private/ConvertTo-PowerShellDataFileText.ps1 @@ -0,0 +1,64 @@ +function ConvertTo-PowerShellDataFileText { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [AllowNull()] + $InputObject, + + [Parameter(Mandatory=$false)] + [int] + $Indent = 0 + ) + + $IndentText = " " * $Indent + $ChildIndent = $Indent + 4 + $ChildIndentText = " " * $ChildIndent + + if($null -eq $InputObject){ + return '$null' + } + + if($InputObject -is [bool]){ + if($InputObject){ + return '$true' + } + + return '$false' + } + + if($InputObject -is [int] -or $InputObject -is [long] -or $InputObject -is [decimal] -or $InputObject -is [double]){ + return ([string]$InputObject) + } + + if($InputObject -is [string]){ + return "'$($InputObject.Replace("'", "''"))'" + } + + if($InputObject -is [System.Collections.IDictionary]){ + $Lines = @("@{") + foreach($Key in $InputObject.Keys){ + $KeyText = "'$(([string]$Key).Replace("'", "''"))'" + $ValueText = ConvertTo-PowerShellDataFileText -InputObject $InputObject[$Key] -Indent $ChildIndent + $Lines += "$ChildIndentText$KeyText = $ValueText" + } + $Lines += "$IndentText}" + return ($Lines -join [Environment]::NewLine) + } + + if($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]){ + $Items = @($InputObject) + if($Items.Count -eq 0){ + return '@()' + } + + $Lines = @("@(") + foreach($Item in $Items){ + $ValueText = ConvertTo-PowerShellDataFileText -InputObject $Item -Indent $ChildIndent + $Lines += "$ChildIndentText$ValueText" + } + $Lines += "$IndentText)" + return ($Lines -join [Environment]::NewLine) + } + + return "'$(([string]$InputObject).Replace("'", "''"))'" +} diff --git a/Private/Copy-ConfigurationDataValue.ps1 b/Private/Copy-ConfigurationDataValue.ps1 new file mode 100644 index 0000000..30bcefd --- /dev/null +++ b/Private/Copy-ConfigurationDataValue.ps1 @@ -0,0 +1,37 @@ +function Copy-ConfigurationDataValue { + [CmdletBinding()] + Param( + [AllowNull()] + $Value + ) + + if($null -eq $Value){ + return $null + } + + if($Value -is [System.Collections.Specialized.OrderedDictionary]){ + $Copy = [ordered]@{} + foreach($Entry in $Value.GetEnumerator()){ + $Copy[$Entry.Name] = Copy-ConfigurationDataValue -Value $Entry.Value + } + return $Copy + } + + if($Value -is [System.Collections.Hashtable]){ + $Copy = @{} + foreach($Entry in $Value.GetEnumerator()){ + $Copy[$Entry.Name] = Copy-ConfigurationDataValue -Value $Entry.Value + } + return $Copy + } + + if($Value -is [System.Array]){ + $Copy = @() + foreach($Item in $Value){ + $Copy += ,(Copy-ConfigurationDataValue -Value $Item) + } + return ,$Copy + } + + return $Value +} diff --git a/Private/Format-ConfigurationDataMergeKey.ps1 b/Private/Format-ConfigurationDataMergeKey.ps1 new file mode 100644 index 0000000..1715810 --- /dev/null +++ b/Private/Format-ConfigurationDataMergeKey.ps1 @@ -0,0 +1,16 @@ +function Format-ConfigurationDataMergeKey { + [CmdletBinding()] + Param( + [AllowNull()] + $Item, + [Parameter(Mandatory=$true)] + [String[]] + $KeyNames + ) + + $Pairs = foreach($KeyName in $KeyNames){ + "$KeyName=$(Get-ConfigurationDataItemValue -Item $Item -KeyName $KeyName)" + } + + return ($Pairs -join ", ") +} diff --git a/Private/Get-ConfigurationDataArrayMergeKeyNames.ps1 b/Private/Get-ConfigurationDataArrayMergeKeyNames.ps1 new file mode 100644 index 0000000..8c512eb --- /dev/null +++ b/Private/Get-ConfigurationDataArrayMergeKeyNames.ps1 @@ -0,0 +1,30 @@ +function Get-ConfigurationDataArrayMergeKeyNames { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [String] + $ArrayName, + [AllowNull()] + $Item + ) + + $MergeKeyMap = @{ + Instances = @("Name") + ConfigurationOptions = @("OptionName") + AdditionalScripts = @("ScriptName") + Templates = @("TemplateName") + AllNodes = @("NodeName") + Registry = @("Key","ValueName") + } + + if($MergeKeyMap.ContainsKey($ArrayName)){ + return $MergeKeyMap[$ArrayName] + } + + $SearchItem = Get-ConfigurationDataArraySearchItem -Item $Item + if($null -ne $SearchItem){ + return @($SearchItem.Name) + } + + return @() +} diff --git a/Private/Get-ConfigurationDataArraySearchItem.ps1 b/Private/Get-ConfigurationDataArraySearchItem.ps1 new file mode 100644 index 0000000..a1ed7cc --- /dev/null +++ b/Private/Get-ConfigurationDataArraySearchItem.ps1 @@ -0,0 +1,24 @@ +function Get-ConfigurationDataArraySearchItem { + [CmdletBinding()] + Param( + [AllowNull()] + $Item + ) + + if($null -eq $Item){ + return $null + } + + if(-not ( + ($Item -is [System.Collections.Hashtable]) -or + ($Item -is [System.Collections.Specialized.OrderedDictionary]) + )){ + return $null + } + + return @( + $Item.GetEnumerator() | + Where-Object { ($_.Value -is [String]) -and ($_.Name -like "*Name") } | + Select-Object -First 1 + )[0] +} 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-ConfigurationDataItemValue.ps1 b/Private/Get-ConfigurationDataItemValue.ps1 new file mode 100644 index 0000000..e1bb764 --- /dev/null +++ b/Private/Get-ConfigurationDataItemValue.ps1 @@ -0,0 +1,20 @@ +function Get-ConfigurationDataItemValue { + [CmdletBinding()] + Param( + [AllowNull()] + $Item, + [Parameter(Mandatory=$true)] + [String] + $KeyName + ) + + if(-not (Test-ConfigurationDataItemContainsKey -Item $Item -KeyName $KeyName)){ + return $null + } + + if($Item -is [System.Collections.IDictionary]){ + return $Item[$KeyName] + } + + return $Item.$KeyName +} 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/Repair-ConfigurationDataPathEncoding.ps1 b/Private/Repair-ConfigurationDataPathEncoding.ps1 new file mode 100644 index 0000000..9ee0c91 --- /dev/null +++ b/Private/Repair-ConfigurationDataPathEncoding.ps1 @@ -0,0 +1,21 @@ +function Repair-ConfigurationDataPathEncoding { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [AllowEmptyString()] + [string] + $Path + ) + + if($Path -notmatch "[ÃÂ]"){ + return $Path + } + + try { + $Bytes = [System.Text.Encoding]::GetEncoding(1252).GetBytes($Path) + return [System.Text.Encoding]::UTF8.GetString($Bytes) + } + catch { + return $Path + } +} diff --git a/Private/Resolve-ConfigurationDataTemplatePath.ps1 b/Private/Resolve-ConfigurationDataTemplatePath.ps1 new file mode 100644 index 0000000..cf68b3c --- /dev/null +++ b/Private/Resolve-ConfigurationDataTemplatePath.ps1 @@ -0,0 +1,43 @@ +function Resolve-ConfigurationDataTemplatePath { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [string] + $Path, + + [Parameter(Mandatory=$false)] + [string] + $BasePath = (Get-Location).Path + ) + + $CandidatePaths = @() + foreach($CurrentPath in @($Path, (Repair-ConfigurationDataPathEncoding -Path $Path))){ + if([string]::IsNullOrWhiteSpace($CurrentPath)){ + continue + } + + $CandidatePath = $CurrentPath + $CandidateBasePath = Repair-ConfigurationDataPathEncoding -Path $BasePath + if(-not [System.IO.Path]::IsPathRooted($CandidatePath)){ + $CandidatePath = Join-Path -Path $CandidateBasePath -ChildPath $CandidatePath + } + + if($CandidatePaths -notcontains $CandidatePath){ + $CandidatePaths += $CandidatePath + } + } + + $ResolvedPath = $null + foreach($CandidatePath in $CandidatePaths){ + $ResolvedPath = Resolve-Path -LiteralPath $CandidatePath -ErrorAction SilentlyContinue + if($null -ne $ResolvedPath){ + break + } + } + + 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/Private/Test-ConfigurationDataItemContainsKey.ps1 b/Private/Test-ConfigurationDataItemContainsKey.ps1 new file mode 100644 index 0000000..952689c --- /dev/null +++ b/Private/Test-ConfigurationDataItemContainsKey.ps1 @@ -0,0 +1,20 @@ +function Test-ConfigurationDataItemContainsKey { + [CmdletBinding()] + Param( + [AllowNull()] + $Item, + [Parameter(Mandatory=$true)] + [String] + $KeyName + ) + + if($null -eq $Item){ + return $false + } + + if($Item -is [System.Collections.IDictionary]){ + return $Item.Contains($KeyName) + } + + return $null -ne $Item.PSObject.Properties[$KeyName] +} diff --git a/Private/Test-ConfigurationDataItemHasKeys.ps1 b/Private/Test-ConfigurationDataItemHasKeys.ps1 new file mode 100644 index 0000000..f140d80 --- /dev/null +++ b/Private/Test-ConfigurationDataItemHasKeys.ps1 @@ -0,0 +1,18 @@ +function Test-ConfigurationDataItemHasKeys { + [CmdletBinding()] + Param( + [AllowNull()] + $Item, + [Parameter(Mandatory=$true)] + [String[]] + $KeyNames + ) + + foreach($KeyName in $KeyNames){ + if(-not (Test-ConfigurationDataItemContainsKey -Item $Item -KeyName $KeyName)){ + return $false + } + } + + return $true +} diff --git a/Private/Test-ConfigurationDataItemKeyMatch.ps1 b/Private/Test-ConfigurationDataItemKeyMatch.ps1 new file mode 100644 index 0000000..1b1479d --- /dev/null +++ b/Private/Test-ConfigurationDataItemKeyMatch.ps1 @@ -0,0 +1,32 @@ +function Test-ConfigurationDataItemKeyMatch { + [CmdletBinding()] + Param( + [AllowNull()] + $Left, + [AllowNull()] + $Right, + [Parameter(Mandatory=$true)] + [String[]] + $KeyNames + ) + + if($KeyNames.Count -eq 0){ + return $false + } + + foreach($KeyName in $KeyNames){ + if(-not (Test-ConfigurationDataItemContainsKey -Item $Left -KeyName $KeyName)){ + return $false + } + + if(-not (Test-ConfigurationDataItemContainsKey -Item $Right -KeyName $KeyName)){ + return $false + } + + if((Get-ConfigurationDataItemValue -Item $Left -KeyName $KeyName) -ne (Get-ConfigurationDataItemValue -Item $Right -KeyName $KeyName)){ + return $false + } + } + + return $true +} diff --git a/Private/Test-ConfigurationDataItemWildcard.ps1 b/Private/Test-ConfigurationDataItemWildcard.ps1 new file mode 100644 index 0000000..4676f49 --- /dev/null +++ b/Private/Test-ConfigurationDataItemWildcard.ps1 @@ -0,0 +1,20 @@ +function Test-ConfigurationDataItemWildcard { + [CmdletBinding()] + Param( + [AllowNull()] + $Item, + [Parameter(Mandatory=$true)] + [String[]] + $KeyNames + ) + + if($KeyNames.Count -ne 1){ + return $false + } + + if(-not (Test-ConfigurationDataItemContainsKey -Item $Item -KeyName $KeyNames[0])){ + return $false + } + + return (Get-ConfigurationDataItemValue -Item $Item -KeyName $KeyNames[0]) -eq "*" +} diff --git a/Public/Export-PowerShellDataFile.ps1 b/Public/Export-PowerShellDataFile.ps1 new file mode 100644 index 0000000..3674c7e --- /dev/null +++ b/Public/Export-PowerShellDataFile.ps1 @@ -0,0 +1,32 @@ +function Export-PowerShellDataFile { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true, ValueFromPipeline=$true)] + [System.Collections.IDictionary] + $InputObject, + + [Parameter(Mandatory=$true)] + [string] + $Path, + + [Parameter(Mandatory=$false)] + [switch] + $Force + ) + + process { + $ResolvedPath = Repair-ConfigurationDataPathEncoding -Path $Path + + if((Test-Path -LiteralPath $ResolvedPath -PathType Leaf) -and (-not $Force)){ + throw "File [$ResolvedPath] already exists. Use -Force to overwrite it." + } + + $Parent = Split-Path -Path $ResolvedPath -Parent + if(-not [string]::IsNullOrWhiteSpace($Parent) -and -not (Test-Path -LiteralPath $Parent)){ + New-Item -Path $Parent -ItemType Directory -Force | Out-Null + } + + $Text = ConvertTo-PowerShellDataFileText -InputObject $InputObject + Set-Content -LiteralPath $ResolvedPath -Value $Text -Encoding UTF8 + } +} diff --git a/Public/Merge-DSCConfigurationData.ps1 b/Public/Merge-DSCConfigurationData.ps1 new file mode 100644 index 0000000..edcea57 --- /dev/null +++ b/Public/Merge-DSCConfigurationData.ps1 @@ -0,0 +1,124 @@ +function Merge-DSCConfigurationData { + [CmdletBinding(DefaultParameterSetName="Data")] + Param( + [Parameter(Mandatory=$true, ParameterSetName="Data")] + [System.Collections.IDictionary] + $Template, + + [Parameter(Mandatory=$false, ParameterSetName="Data")] + [System.Collections.IDictionary] + $Deployment = @{}, + + [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.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" + + $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 + } + } + + $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 = @($DeploymentArray | Where-Object { Test-ConfigurationDataItemHasKeys -Item $_ -KeyNames $SearchKeyNames }) + }else{ + $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 "$($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 + } + } +} diff --git a/Public/New-DSCConfigurationDataDeployment.ps1 b/Public/New-DSCConfigurationDataDeployment.ps1 new file mode 100644 index 0000000..c9ff185 --- /dev/null +++ b/Public/New-DSCConfigurationDataDeployment.ps1 @@ -0,0 +1,143 @@ +function New-DSCConfigurationDataDeployment { + [CmdletBinding(SupportsShouldProcess=$true)] + Param( + [Parameter(Mandatory=$false)] + [string] + $Path, + + [Parameter(Mandatory=$true)] + [string] + $Name, + + [Parameter(Mandatory=$true)] + [string] + $DeploymentId, + + [Parameter(Mandatory=$true)] + [Alias("SourceTemplates")] + [string[]] + $SourceTemplatePath, + + [Parameter(Mandatory=$false)] + [hashtable[]] + $AllNodes = @(), + + [Parameter(Mandatory=$false)] + [System.Collections.IDictionary] + $DeploymentData = @{}, + + [Parameter(Mandatory=$false)] + [switch] + $UseRelativePaths, + + [Parameter(Mandatory=$false)] + [switch] + $Export, + + [Parameter(Mandatory=$false)] + [ValidateSet("PowerShellDataFile", "Json")] + [string] + $Format = "PowerShellDataFile", + + [Parameter(Mandatory=$false)] + [switch] + $Force, + + [Parameter(Mandatory=$false)] + [switch] + $PassThru + ) + + if($Export -and [string]::IsNullOrWhiteSpace($Path)){ + throw "Parameter [Path] is required when [Export] is used." + } + + $ResolvedOutputPath = $null + if(-not [string]::IsNullOrWhiteSpace($Path)){ + $ResolvedOutputPath = Repair-ConfigurationDataPathEncoding -Path $Path + } + + $OutputDirectory = if([string]::IsNullOrWhiteSpace($ResolvedOutputPath)){ + (Get-Location).Path + }else{ + Split-Path -Path $ResolvedOutputPath -Parent + } + + if([string]::IsNullOrWhiteSpace($OutputDirectory)){ + $OutputDirectory = (Get-Location).Path + } + + $MergeResult = Merge-DSCConfigurationData -Path $SourceTemplatePath -PassThru + $MergedTemplateData = $MergeResult.ConfigurationData + + $DeploymentOverride = Copy-ConfigurationDataValue -Value $DeploymentData + if($AllNodes.Count -gt 0){ + if(-not (Test-ConfigurationDataDictionaryKey -Dictionary $DeploymentOverride -Key "Resources")){ + $DeploymentOverride["Resources"] = @{} + } + + $DeploymentOverride["Resources"]["AllNodes"] = $AllNodes + } + + $ConfigurationData = Merge-DSCConfigurationData -Template $MergedTemplateData -Deployment $DeploymentOverride + + $TemplatePaths = @($SourceTemplatePath | ForEach-Object { + $Resolved = Resolve-ConfigurationDataTemplatePath -Path $_ + if($UseRelativePaths){ + ConvertTo-ConfigurationDataRelativePath -Path $Resolved -BasePath $OutputDirectory + }else{ + $Resolved + } + }) + + $SourceFiles = @($MergeResult.Files | ForEach-Object { + if($UseRelativePaths){ + ConvertTo-ConfigurationDataRelativePath -Path $_ -BasePath $OutputDirectory + }else{ + $_ + } + }) + + $ConfigurationData["Metadata"] = [ordered]@{ + TemplateType = "Deployment" + Name = $Name + DeploymentId = $DeploymentId + GeneratedOn = (Get-Date).ToString("s") + Sources = [ordered]@{ + Templates = $TemplatePaths + Files = $SourceFiles + } + } + + if(-not $Export){ + return $ConfigurationData + } + + if($PSCmdlet.ShouldProcess($ResolvedOutputPath, "Export DSC configuration data deployment as [$Format]")){ + switch($Format){ + "PowerShellDataFile" { + Export-PowerShellDataFile -InputObject $ConfigurationData -Path $ResolvedOutputPath -Force:$Force + } + "Json" { + if((Test-Path -LiteralPath $ResolvedOutputPath -PathType Leaf) -and (-not $Force)){ + throw "File [$ResolvedOutputPath] already exists. Use -Force to overwrite it." + } + + $Parent = Split-Path -Path $ResolvedOutputPath -Parent + if(-not [string]::IsNullOrWhiteSpace($Parent) -and -not (Test-Path -LiteralPath $Parent)){ + New-Item -Path $Parent -ItemType Directory -Force | Out-Null + } + + $ConfigurationData | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $ResolvedOutputPath -Encoding UTF8 + } + } + } + + if($PassThru){ + return [PSCustomObject]@{ + Path = $ResolvedOutputPath + Files = $MergeResult.Files + ConfigurationData = $ConfigurationData + } + } +} diff --git a/Tests/Extends.Tests.ps1 b/Tests/Extends.Tests.ps1 new file mode 100644 index 0000000..26dc8e9 --- /dev/null +++ b/Tests/Extends.Tests.ps1 @@ -0,0 +1,291 @@ +$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 + } + + It 'exports importable PSD1 files with quoted metadata keys' { + $Path = Join-Path -Path $TestDrive -ChildPath 'Export.psd1' + + @{ + Metadata = @{ + Description = @{ + 'de-DE' = 'Deutsch' + 'en-US' = 'English' + } + } + } | Export-PowerShellDataFile -Path $Path -Force + + $Data = Import-PowerShellDataFile -Path $Path + $Data.Metadata.Description['de-DE'] | Should Be 'Deutsch' + $Data.Metadata.Description['en-US'] | Should Be 'English' + } + + It 'creates materialized deployment data without exporting by default' { + $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 + } + } + Resources = @{ + AllNodes = @( + @{ + NodeName = '*' + PSDSCAllowPlainTextPassword = $true + } + ) + } +} +'@ | Set-Content -Path $DefaultPath -Encoding UTF8 + + @' +@{ + Metadata = @{ + TemplateType = 'Environment' + Name = 'Contoso' + Extends = './Default.psd1' + } + Parameters = @{ + DomainFQDN = @{ + Value = 'contoso.local' + } + } +} +'@ | Set-Content -Path $ChildPath -Encoding UTF8 + + $Result = New-DSCConfigurationDataDeployment ` + -Name 'Contoso-Test' ` + -DeploymentId '123' ` + -SourceTemplatePath $ChildPath ` + -AllNodes @( + @{ + NodeName = 'Node01' + RunCentralAdministration = $true + } + ) + + $Result.Metadata.TemplateType | Should Be 'Deployment' + $Result.Metadata.Sources.Files.Count | Should Be 2 + $Result.Parameters.DomainFQDN.Value | Should Be 'contoso.local' + $Result.Resources.AllNodes[0].NodeName | Should Be 'Node01' + $Result.Resources.AllNodes[0].PSDSCAllowPlainTextPassword | Should Be $true + } + + It 'exports materialized deployment data as PSD1' { + $DefaultPath = Join-Path -Path $TestDrive -ChildPath 'Default.psd1' + $ChildPath = Join-Path -Path $TestDrive -ChildPath 'Contoso.psd1' + $DeploymentPath = Join-Path -Path $TestDrive -ChildPath 'Deployments\Deployment_123.psd1' + + @' +@{ + Metadata = @{ + TemplateType = 'Environment' + Name = 'Default' + } + Parameters = @{ + DomainFQDN = @{ + Type = 'string' + Required = $true + } + } +} +'@ | Set-Content -Path $DefaultPath -Encoding UTF8 + + @' +@{ + Metadata = @{ + TemplateType = 'Environment' + Name = 'Contoso' + Extends = './Default.psd1' + } + Parameters = @{ + DomainFQDN = @{ + Value = 'contoso.local' + } + } +} +'@ | Set-Content -Path $ChildPath -Encoding UTF8 + + $Result = New-DSCConfigurationDataDeployment ` + -Path $DeploymentPath ` + -Name 'Contoso-Test' ` + -DeploymentId '123' ` + -SourceTemplatePath $ChildPath ` + -AllNodes @( + @{ + NodeName = 'Node01' + RunCentralAdministration = $true + } + ) ` + -UseRelativePaths ` + -Export ` + -Format PowerShellDataFile ` + -Force ` + -PassThru + + $DeploymentPath | Should Exist + $Result.ConfigurationData.Metadata.TemplateType | Should Be 'Deployment' + $Result.ConfigurationData.Metadata.Sources.Files.Count | Should Be 2 + $Result.ConfigurationData.Parameters.DomainFQDN.Value | Should Be 'contoso.local' + $Result.ConfigurationData.Resources.AllNodes[0].NodeName | Should Be 'Node01' + $Result.ConfigurationData.Resources.AllNodes[0].RunCentralAdministration | Should Be $true + + $Imported = Import-PowerShellDataFile -Path $DeploymentPath + $Imported.Metadata.Name | Should Be 'Contoso-Test' + $Imported.Metadata.Sources.Templates.Count | Should Be 1 + } + + It 'exports materialized deployment data as JSON' { + $DefaultPath = Join-Path -Path $TestDrive -ChildPath 'Default.psd1' + $DeploymentPath = Join-Path -Path $TestDrive -ChildPath 'Deployment_123.json' + + @' +@{ + Parameters = @{ + DomainFQDN = @{ + Type = 'string' + Value = 'contoso.local' + } + } +} +'@ | Set-Content -Path $DefaultPath -Encoding UTF8 + + New-DSCConfigurationDataDeployment ` + -Path $DeploymentPath ` + -Name 'Contoso-Test' ` + -DeploymentId '123' ` + -SourceTemplatePath $DefaultPath ` + -Export ` + -Format Json ` + -Force + + $DeploymentPath | Should Exist + $Imported = Get-Content -Path $DeploymentPath -Raw | ConvertFrom-Json + $Imported.Metadata.TemplateType | Should Be 'Deployment' + $Imported.Parameters.DomainFQDN.Value | Should Be 'contoso.local' + } +}