From f799c4adcc5fdd0e60ce667a377cf94da89ef898 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 21 Apr 2026 12:25:33 +0200 Subject: [PATCH 01/10] adding samples --- .gitignore | 2 + sample/Deployment.psd1 | 111 +++++++++++++++++++++++++++++ sample/Template.psd1 | 157 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 .gitignore create mode 100644 sample/Deployment.psd1 create mode 100644 sample/Template.psd1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552c980 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.git +.sample \ No newline at end of file diff --git a/sample/Deployment.psd1 b/sample/Deployment.psd1 new file mode 100644 index 0000000..3c65743 --- /dev/null +++ b/sample/Deployment.psd1 @@ -0,0 +1,111 @@ +@{ + Templates = @( + @{ + TemplateName = "HZD Template" + TemplatePath = "./Configurations/HZD.psd1" + }, + @{ + TemplateName = "SQL Server Template" + TemplatePath = "./Configurations/Services/Template-SQLServer.psd1" + }, + @{ + TemplateName = "Umgebungs Template" + TemplatePath = "./Configurations/Umgebungen/Template-Test.psd1" + } + ) + + AllNodes = @( + @{ + NodeName = "JURZMAZSSQL901" + } + ) + + NonNodeData =@{ + Services = @{ + SQLServer = @{ + Instances = @( + @{ + Name = 'MSSQLSERVER' + + ServiceAccounts = @{ + SQLEngineAccount = @{ + ServiceAccountName = "gmsaE2Af901" + AccountType = "Group" + Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" + ManagedPasswordPrincipals = @( + @{ + GroupName = "sicE2AfE2ADB901" + Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" + } + ) + } + SQLAgentAccount = @{ + ServiceAccountName = "gmsaE2Aa901" + AccountType = "Group" + Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" + ManagedPasswordPrincipals = @( + @{ + GroupName = "sicE2AfE2ADB901" + Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" + } + ) + } + } + AlwaysOn = @{ + StartIndex = "B" + AlwaysOnGroups = @( + @{ + Name = "TestAG" + FailoverMode ="Automatic" + SeedingMode = "Automatic" + Listener = @{ + Provision = $true + IpAddress = "'10.96.x.x/255.255.255.0','10.96.x.x/255.255.255.0'" + Port = 1433 + } + + } + ) + } + + ConfigurationOptions= @() + + AdditionalScripts = @() + }, + @{ + Name = 'MSSQLSERVERTEST' + + ServiceAccounts = @{ + SQLEngineAccount = @{ + ServiceAccountName = "gmsaE2Af901" + AccountType = "Group" + Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" + ManagedPasswordPrincipals = @( + @{ + GroupName = "sicE2AfE2ADB901" + Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" + } + ) + } + SQLAgentAccount = @{ + ServiceAccountName = "gmsaE2Aa901" + AccountType = "Group" + Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" + ManagedPasswordPrincipals = @( + @{ + GroupName = "sicE2AfE2ADB901" + Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" + } + ) + } + } + + ConfigurationOptions= @() + + AdditionalScripts = @() + } + ) + } + } + } +} \ No newline at end of file diff --git a/sample/Template.psd1 b/sample/Template.psd1 new file mode 100644 index 0000000..938a3be --- /dev/null +++ b/sample/Template.psd1 @@ -0,0 +1,157 @@ +@{ + NonNodeData =@{ + Services = @{ + SQLServer = @{ + General = @{ + Version = "" + Release = "2022" + SourcePath = "\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\Binaries" + DestinationPath = "C:\HZD\SQL_Install" + } + Basic = @{ + Registry = @( + @{ + Key = "HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Lsa" + ValueName = "DisableLoopbackCheck" + ValueData = 1 + ValueType = "Dword" + }, + @{ + Ensure = "Present" + Key = "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL16.MSSQLSERVER\SQLServerAgent" + ValueName = "MsxEncryptChannelOptions" + ValueData = 1 + ValueType = "Dword" + } + + @{ + Ensure = 'Present' + Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL16.MSSQLSERVER\SQLServerAgent' + ValueName = 'ErrorLogFile' + ValueData = 'D:\MSSQL16.MSSQLSERVER\MSSQL\Log\SQLAGENT.OUT' + ValueType = 'String' + }, + @{ + Ensure = 'Present' + Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL16.MSSQLSERVER\SQLServerAgent' + ValueName = 'WorkingDirectory' + ValueData = 'D:\MSSQL16.MSSQLSERVER\MSSQL\JOBS' + ValueType = 'String' + } + ) + WindowsFeatures = @( + @{ + Name = "RSAT-AD-PowerShell" + } + ) + Modules = @( + @{ + Name = "SQLServer" + AllowClobber = $true + } + ) + } + Instances = @( + @{ + Name = '*' + Features = 'SQLENGINE,FULLTEXT' + SQLSysAdminAccounts = 'sicDBServer_ServerAdmins' + Directories = @{ + InstallSharedDir = 'D:\Microsoft SQL Server' + InstallSharedWOWDir = 'D:\Microsoft SQL Server (x86)' + InstanceDir = 'D:\Microsoft SQL Server' + InstallSQLDataDir = 'D:' + SQLSysAdminAccounts = 'sicDBServer_ServerAdmins' + SQLBackupDir = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\Backups' + SQLUserDBDir = '' + SQLUserDBLogDir = '' + SQLTempDBDir = '' + SQLTempDBLogDir = '' + } + + Drives = @{ + } + + MountPoints = @{ + } + + Memory = @{ + UseDynamic = $true + minValue = 1024 + maxValue = 8192 + } + + ServiceAccounts = @{ + } + + AlwaysOn = @{ + } + + ConfigurationOptions= @( + @{ + OptionName = 'show advanced options' + OptionValue = '1' + }, + @{ + OptionName = 'cost threshold for parallelism' + OptionValue = '50' + }, + @{ + OptionName = 'blocked process threshold (s)' + OptionValue = '30' + }, + @{ + OptionName = 'backup compression default' + OptionValue = '1' + } + + ) + + AdditionalScripts = @( + + @{ + ScriptName = 'AdminDB' + ScriptPath = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\SQL_Scripts\AdminDB' + DependsOn = '[SqlSetup]Instance-MSSQLSERVER' + }, + + @{ + ScriptName = 'RenameSA' + ScriptPath = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\SQL_Scripts\RenameSA' + DependsOn = '[SqlScript]AdminDB' + + }, + @{ + ScriptName = 'Maintenance_Solution' + ScriptPath = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\SQL_Scripts\Maintenance_Solutions' + DependsOn = '[SqlScript]RenameSA' + }, + + @{ + ScriptName = 'WhoIsActive' + ScriptPath = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\SQL_Scripts\WhoIsActive_Monitoring' + DependsOn = '[SqlScript]Maintenance_Solution' + }, + + + @{ + ScriptName = 'Alerts' + ScriptPath = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\SQL_Scripts\Alerts_Email' + DependsOn = '[SqlScript]WhoIsActive' + }, + + + @{ + ScriptName = 'TempDB' + ScriptPath = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\SQL_Scripts\TempDB_Size' + DependsOn = '[SqlScript]Alerts' + + } + + ) + } + ) + } + } + } +} \ No newline at end of file From 7c9b854aec6163792354402fbb6d545c68403474 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 21 Apr 2026 12:51:25 +0200 Subject: [PATCH 02/10] adding samples --- .gitignore | 3 +- Merge-ConfigurationData.ps1 | 281 ++++++++++++++++++++++++++++++++++-- sample/Deployment.psd1 | 111 -------------- sample/Template.psd1 | 157 -------------------- 4 files changed, 271 insertions(+), 281 deletions(-) delete mode 100644 sample/Deployment.psd1 delete mode 100644 sample/Template.psd1 diff --git a/.gitignore b/.gitignore index 552c980..942936b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .git -.sample \ No newline at end of file +.sample +.output \ No newline at end of file diff --git a/Merge-ConfigurationData.ps1 b/Merge-ConfigurationData.ps1 index 79fddec..e5f1a8d 100644 --- a/Merge-ConfigurationData.ps1 +++ b/Merge-ConfigurationData.ps1 @@ -24,7 +24,7 @@ $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)) + $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" @@ -33,7 +33,7 @@ $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)) + $Output.Add($($Property.Name),(Copy-ConfigurationDataValue -Value $Template.$($Property.Name))) } }elseif($Property.Value -is [System.Array]){ Write-Verbose "$($Property.Name) is ein Array" @@ -41,25 +41,56 @@ 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 + + $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 = @($Deployment.$($Property.Name) | Where-Object { Test-ConfigurationDataItemHasKeys -Item $_ -KeyNames $SearchKeyNames }) }else{ - Write-Verbose "Pair $($Key.Name) - $($Key.Value) not present" - $Output.$($Property.Name) += $Property.Value[$i] + $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-ConfigurationData -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) = $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,$Template.($Property.Name)) + $Output.Add($Property.Name,(Copy-ConfigurationDataValue -Value $Template.($Property.Name))) }elseif($Deployment.$($Property.Name) -ne $Property.Value){ @@ -67,4 +98,230 @@ } } return $Output -} \ No newline at end of file +} + +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 +} + +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] +} + +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 @() +} + +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] +} + +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 +} + +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 +} + +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 +} + +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 "*" +} + +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/sample/Deployment.psd1 b/sample/Deployment.psd1 deleted file mode 100644 index 3c65743..0000000 --- a/sample/Deployment.psd1 +++ /dev/null @@ -1,111 +0,0 @@ -@{ - Templates = @( - @{ - TemplateName = "HZD Template" - TemplatePath = "./Configurations/HZD.psd1" - }, - @{ - TemplateName = "SQL Server Template" - TemplatePath = "./Configurations/Services/Template-SQLServer.psd1" - }, - @{ - TemplateName = "Umgebungs Template" - TemplatePath = "./Configurations/Umgebungen/Template-Test.psd1" - } - ) - - AllNodes = @( - @{ - NodeName = "JURZMAZSSQL901" - } - ) - - NonNodeData =@{ - Services = @{ - SQLServer = @{ - Instances = @( - @{ - Name = 'MSSQLSERVER' - - ServiceAccounts = @{ - SQLEngineAccount = @{ - ServiceAccountName = "gmsaE2Af901" - AccountType = "Group" - Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" - ManagedPasswordPrincipals = @( - @{ - GroupName = "sicE2AfE2ADB901" - Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" - } - ) - } - SQLAgentAccount = @{ - ServiceAccountName = "gmsaE2Aa901" - AccountType = "Group" - Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" - ManagedPasswordPrincipals = @( - @{ - GroupName = "sicE2AfE2ADB901" - Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" - } - ) - } - } - AlwaysOn = @{ - StartIndex = "B" - AlwaysOnGroups = @( - @{ - Name = "TestAG" - FailoverMode ="Automatic" - SeedingMode = "Automatic" - Listener = @{ - Provision = $true - IpAddress = "'10.96.x.x/255.255.255.0','10.96.x.x/255.255.255.0'" - Port = 1433 - } - - } - ) - } - - ConfigurationOptions= @() - - AdditionalScripts = @() - }, - @{ - Name = 'MSSQLSERVERTEST' - - ServiceAccounts = @{ - SQLEngineAccount = @{ - ServiceAccountName = "gmsaE2Af901" - AccountType = "Group" - Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" - ManagedPasswordPrincipals = @( - @{ - GroupName = "sicE2AfE2ADB901" - Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" - } - ) - } - SQLAgentAccount = @{ - ServiceAccountName = "gmsaE2Aa901" - AccountType = "Group" - Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" - ManagedPasswordPrincipals = @( - @{ - GroupName = "sicE2AfE2ADB901" - Path = "OU=DBA,OU=Dienste_Verfahren,OU=Administration,DC=justiz,DC=hessen,DC=de" - } - ) - } - } - - ConfigurationOptions= @() - - AdditionalScripts = @() - } - ) - } - } - } -} \ No newline at end of file diff --git a/sample/Template.psd1 b/sample/Template.psd1 deleted file mode 100644 index 938a3be..0000000 --- a/sample/Template.psd1 +++ /dev/null @@ -1,157 +0,0 @@ -@{ - NonNodeData =@{ - Services = @{ - SQLServer = @{ - General = @{ - Version = "" - Release = "2022" - SourcePath = "\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\Binaries" - DestinationPath = "C:\HZD\SQL_Install" - } - Basic = @{ - Registry = @( - @{ - Key = "HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Lsa" - ValueName = "DisableLoopbackCheck" - ValueData = 1 - ValueType = "Dword" - }, - @{ - Ensure = "Present" - Key = "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL16.MSSQLSERVER\SQLServerAgent" - ValueName = "MsxEncryptChannelOptions" - ValueData = 1 - ValueType = "Dword" - } - - @{ - Ensure = 'Present' - Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL16.MSSQLSERVER\SQLServerAgent' - ValueName = 'ErrorLogFile' - ValueData = 'D:\MSSQL16.MSSQLSERVER\MSSQL\Log\SQLAGENT.OUT' - ValueType = 'String' - }, - @{ - Ensure = 'Present' - Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL16.MSSQLSERVER\SQLServerAgent' - ValueName = 'WorkingDirectory' - ValueData = 'D:\MSSQL16.MSSQLSERVER\MSSQL\JOBS' - ValueType = 'String' - } - ) - WindowsFeatures = @( - @{ - Name = "RSAT-AD-PowerShell" - } - ) - Modules = @( - @{ - Name = "SQLServer" - AllowClobber = $true - } - ) - } - Instances = @( - @{ - Name = '*' - Features = 'SQLENGINE,FULLTEXT' - SQLSysAdminAccounts = 'sicDBServer_ServerAdmins' - Directories = @{ - InstallSharedDir = 'D:\Microsoft SQL Server' - InstallSharedWOWDir = 'D:\Microsoft SQL Server (x86)' - InstanceDir = 'D:\Microsoft SQL Server' - InstallSQLDataDir = 'D:' - SQLSysAdminAccounts = 'sicDBServer_ServerAdmins' - SQLBackupDir = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\Backups' - SQLUserDBDir = '' - SQLUserDBLogDir = '' - SQLTempDBDir = '' - SQLTempDBLogDir = '' - } - - Drives = @{ - } - - MountPoints = @{ - } - - Memory = @{ - UseDynamic = $true - minValue = 1024 - maxValue = 8192 - } - - ServiceAccounts = @{ - } - - AlwaysOn = @{ - } - - ConfigurationOptions= @( - @{ - OptionName = 'show advanced options' - OptionValue = '1' - }, - @{ - OptionName = 'cost threshold for parallelism' - OptionValue = '50' - }, - @{ - OptionName = 'blocked process threshold (s)' - OptionValue = '30' - }, - @{ - OptionName = 'backup compression default' - OptionValue = '1' - } - - ) - - AdditionalScripts = @( - - @{ - ScriptName = 'AdminDB' - ScriptPath = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\SQL_Scripts\AdminDB' - DependsOn = '[SqlSetup]Instance-MSSQLSERVER' - }, - - @{ - ScriptName = 'RenameSA' - ScriptPath = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\SQL_Scripts\RenameSA' - DependsOn = '[SqlScript]AdminDB' - - }, - @{ - ScriptName = 'Maintenance_Solution' - ScriptPath = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\SQL_Scripts\Maintenance_Solutions' - DependsOn = '[SqlScript]RenameSA' - }, - - @{ - ScriptName = 'WhoIsActive' - ScriptPath = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\SQL_Scripts\WhoIsActive_Monitoring' - DependsOn = '[SqlScript]Maintenance_Solution' - }, - - - @{ - ScriptName = 'Alerts' - ScriptPath = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\SQL_Scripts\Alerts_Email' - DependsOn = '[SqlScript]WhoIsActive' - }, - - - @{ - ScriptName = 'TempDB' - ScriptPath = '\\JURZMAZSFIL02.justiz.hessen.de\DbaDSCSources\SQL_Scripts\TempDB_Size' - DependsOn = '[SqlScript]Alerts' - - } - - ) - } - ) - } - } - } -} \ No newline at end of file From e10ab48bb42835004bb30a9dd61d41b5e4afcbfd Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 21 Apr 2026 13:02:51 +0200 Subject: [PATCH 03/10] Creating Module --- Merge-ConfigurationData.ps1 | 330 +----------------- Merge-DSCConfigurationData.psd1 | 24 ++ Merge-DSCConfigurationData.psm1 | 13 + Private/Copy-ConfigurationDataValue.ps1 | 37 ++ Private/Format-ConfigurationDataMergeKey.ps1 | 16 + ...et-ConfigurationDataArrayMergeKeyNames.ps1 | 30 ++ .../Get-ConfigurationDataArraySearchItem.ps1 | 24 ++ Private/Get-ConfigurationDataItemValue.ps1 | 20 ++ .../Test-ConfigurationDataItemContainsKey.ps1 | 20 ++ Private/Test-ConfigurationDataItemHasKeys.ps1 | 18 + .../Test-ConfigurationDataItemKeyMatch.ps1 | 32 ++ .../Test-ConfigurationDataItemWildcard.ps1 | 20 ++ Public/Merge-ConfigurationData.ps1 | 98 ++++++ Tests/Merge-ConfigurationData.Tests.ps1 | 132 +++++++ 14 files changed, 493 insertions(+), 321 deletions(-) create mode 100644 Merge-DSCConfigurationData.psd1 create mode 100644 Merge-DSCConfigurationData.psm1 create mode 100644 Private/Copy-ConfigurationDataValue.ps1 create mode 100644 Private/Format-ConfigurationDataMergeKey.ps1 create mode 100644 Private/Get-ConfigurationDataArrayMergeKeyNames.ps1 create mode 100644 Private/Get-ConfigurationDataArraySearchItem.ps1 create mode 100644 Private/Get-ConfigurationDataItemValue.ps1 create mode 100644 Private/Test-ConfigurationDataItemContainsKey.ps1 create mode 100644 Private/Test-ConfigurationDataItemHasKeys.ps1 create mode 100644 Private/Test-ConfigurationDataItemKeyMatch.ps1 create mode 100644 Private/Test-ConfigurationDataItemWildcard.ps1 create mode 100644 Public/Merge-ConfigurationData.ps1 create mode 100644 Tests/Merge-ConfigurationData.Tests.ps1 diff --git a/Merge-ConfigurationData.ps1 b/Merge-ConfigurationData.ps1 index e5f1a8d..f04aad9 100644 --- a/Merge-ConfigurationData.ps1 +++ b/Merge-ConfigurationData.ps1 @@ -1,327 +1,15 @@ -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 - } +$ModuleRoot = $PSScriptRoot - 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),(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-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),(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" - - $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 = @($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-ConfigurationData -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($null -eq $Deployment.$($Property.Name)){ - - $Output.Add($Property.Name,(Copy-ConfigurationDataValue -Value $Template.($Property.Name))) - - }elseif($Deployment.$($Property.Name) -ne $Property.Value){ - - } - } - } - return $Output +if([String]::IsNullOrWhiteSpace($ModuleRoot)){ + throw "This compatibility loader must be dot-sourced from a file path. Use Import-Module './Merge-DSCConfigurationData.psd1' for module usage." } -function Copy-ConfigurationDataValue { - [CmdletBinding()] - Param( - [AllowNull()] - $Value - ) +$PrivatePath = Join-Path -Path $ModuleRoot -ChildPath "Private" +$PublicPath = Join-Path -Path $ModuleRoot -ChildPath "Public" - if($null -eq $Value){ - return $null - } +$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) - 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 -} - -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] -} - -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 @() -} - -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] -} - -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 -} - -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 -} - -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 -} - -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 "*" -} - -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 ", ") +foreach($File in @($Private + $Public)){ + . $File.FullName } diff --git a/Merge-DSCConfigurationData.psd1 b/Merge-DSCConfigurationData.psd1 new file mode 100644 index 0000000..53b5a0f --- /dev/null +++ b/Merge-DSCConfigurationData.psd1 @@ -0,0 +1,24 @@ +@{ + RootModule = "Merge-DSCConfigurationData.psm1" + ModuleVersion = "0.1.0" + 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-ConfigurationData" + ) + 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..46660ff --- /dev/null +++ b/Merge-DSCConfigurationData.psm1 @@ -0,0 +1,13 @@ +$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-ConfigurationData" +) 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-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/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/Merge-ConfigurationData.ps1 b/Public/Merge-ConfigurationData.ps1 new file mode 100644 index 0000000..38afef3 --- /dev/null +++ b/Public/Merge-ConfigurationData.ps1 @@ -0,0 +1,98 @@ +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),(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-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),(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" + + $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 = @($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-ConfigurationData -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($null -eq $Deployment.$($Property.Name)){ + $Output.Add($Property.Name,(Copy-ConfigurationDataValue -Value $Template.($Property.Name))) + }elseif($Deployment.$($Property.Name) -ne $Property.Value){ + } + } + } + return $Output +} diff --git a/Tests/Merge-ConfigurationData.Tests.ps1 b/Tests/Merge-ConfigurationData.Tests.ps1 new file mode 100644 index 0000000..1d73b2a --- /dev/null +++ b/Tests/Merge-ConfigurationData.Tests.ps1 @@ -0,0 +1,132 @@ +$script:ModuleRoot = Split-Path -Parent $PSScriptRoot +$script:SampleRoot = Join-Path -Path $script:ModuleRoot -ChildPath ".sample" + +Import-Module (Join-Path -Path $script:ModuleRoot -ChildPath "Merge-DSCConfigurationData.psd1") -Force + +Describe "Merge-ConfigurationData" { + It "merges wildcard instance templates into all deployment instances" { + $Template = Import-PowerShellDataFile (Join-Path -Path $script:SampleRoot -ChildPath "Template.psd1") + $Deployment = Import-PowerShellDataFile (Join-Path -Path $script:SampleRoot -ChildPath "Deployment.psd1") + + $Result = Merge-ConfigurationData -Template $Template -Deployment $Deployment + $Instances = @($Result.NonNodeData.Services.SQLServer.Instances) + + $Instances.Count | Should Be 2 + ($Instances.Name -contains "*") | Should Be $false + + foreach($Name in @("MSSQLSERVER", "MSSQLSERVERTEST")){ + $Instance = $Instances | Where-Object { $_.Name -eq $Name } + + $Instance | Should Not BeNullOrEmpty + $Instance.Features | Should Be "SQLENGINE,FULLTEXT" + $Instance.ConfigurationOptions.Count | Should Be 4 + $Instance.AdditionalScripts.Count | Should Be 6 + $Instance.Memory | Should Not BeNullOrEmpty + $Instance.Directories | Should Not BeNullOrEmpty + } + } + + It "does not merge an exact instance template into another instance name" { + $Template = @{ + NonNodeData = @{ + Services = @{ + SQLServer = @{ + Instances = @( + @{ + Name = "MSSQLSERVER" + Marker = "exact-only" + } + ) + } + } + } + } + + $Deployment = @{ + NonNodeData = @{ + Services = @{ + SQLServer = @{ + Instances = @( + @{ Name = "MSSQLSERVER" }, + @{ Name = "MSSQLSERVERTEST" } + ) + } + } + } + } + + $Result = Merge-ConfigurationData -Template $Template -Deployment $Deployment + $Instances = @($Result.NonNodeData.Services.SQLServer.Instances) + + ($Instances | Where-Object { $_.Name -eq "MSSQLSERVER" }).Marker | Should Be "exact-only" + ($Instances | Where-Object { $_.Name -eq "MSSQLSERVERTEST" }).Marker | Should BeNullOrEmpty + } + + It "uses the name-key fallback for unknown arrays" { + $Template = @{ + Databases = @( + @{ + DatabaseName = "AppDb" + RecoveryModel = "Full" + } + ) + } + + $Deployment = @{ + Databases = @( + @{ + DatabaseName = "AppDb" + Owner = "dbo" + }, + @{ + DatabaseName = "LogDb" + Owner = "dbo" + } + ) + } + + $Result = Merge-ConfigurationData -Template $Template -Deployment $Deployment + $Databases = @($Result.Databases) + + ($Databases | Where-Object { $_.DatabaseName -eq "AppDb" }).RecoveryModel | Should Be "Full" + ($Databases | Where-Object { $_.DatabaseName -eq "LogDb" }).RecoveryModel | Should BeNullOrEmpty + } + + It "uses Key and ValueName together for registry array merges" { + $Template = @{ + Basic = @{ + Registry = @( + @{ + Key = "K1" + ValueName = "Enabled" + Default = "Template-K1" + }, + @{ + Key = "K2" + ValueName = "Enabled" + Default = "Template-K2" + } + ) + } + } + + $Deployment = @{ + Basic = @{ + Registry = @( + @{ + Key = "K1" + ValueName = "Enabled" + ValueData = "Deployment-K1" + } + ) + } + } + + $Result = Merge-ConfigurationData -Template $Template -Deployment $Deployment + $Registry = @($Result.Basic.Registry) + + ($Registry | Where-Object { $_.Key -eq "K1" }).Default | Should Be "Template-K1" + ($Registry | Where-Object { $_.Key -eq "K1" }).ValueData | Should Be "Deployment-K1" + ($Registry | Where-Object { $_.Key -eq "K2" }).Default | Should Be "Template-K2" + } +} From aa5d5eea28547ca33286afc95413e185beb8962a Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 21 Apr 2026 13:07:57 +0200 Subject: [PATCH 04/10] adding Pester Tests --- .gitignore | 2 +- Tests/Merge-ConfigurationData.Tests.ps1 | 132 ------------------------ 2 files changed, 1 insertion(+), 133 deletions(-) delete mode 100644 Tests/Merge-ConfigurationData.Tests.ps1 diff --git a/.gitignore b/.gitignore index 942936b..877df7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ .git .sample -.output \ No newline at end of file +.tests \ No newline at end of file diff --git a/Tests/Merge-ConfigurationData.Tests.ps1 b/Tests/Merge-ConfigurationData.Tests.ps1 deleted file mode 100644 index 1d73b2a..0000000 --- a/Tests/Merge-ConfigurationData.Tests.ps1 +++ /dev/null @@ -1,132 +0,0 @@ -$script:ModuleRoot = Split-Path -Parent $PSScriptRoot -$script:SampleRoot = Join-Path -Path $script:ModuleRoot -ChildPath ".sample" - -Import-Module (Join-Path -Path $script:ModuleRoot -ChildPath "Merge-DSCConfigurationData.psd1") -Force - -Describe "Merge-ConfigurationData" { - It "merges wildcard instance templates into all deployment instances" { - $Template = Import-PowerShellDataFile (Join-Path -Path $script:SampleRoot -ChildPath "Template.psd1") - $Deployment = Import-PowerShellDataFile (Join-Path -Path $script:SampleRoot -ChildPath "Deployment.psd1") - - $Result = Merge-ConfigurationData -Template $Template -Deployment $Deployment - $Instances = @($Result.NonNodeData.Services.SQLServer.Instances) - - $Instances.Count | Should Be 2 - ($Instances.Name -contains "*") | Should Be $false - - foreach($Name in @("MSSQLSERVER", "MSSQLSERVERTEST")){ - $Instance = $Instances | Where-Object { $_.Name -eq $Name } - - $Instance | Should Not BeNullOrEmpty - $Instance.Features | Should Be "SQLENGINE,FULLTEXT" - $Instance.ConfigurationOptions.Count | Should Be 4 - $Instance.AdditionalScripts.Count | Should Be 6 - $Instance.Memory | Should Not BeNullOrEmpty - $Instance.Directories | Should Not BeNullOrEmpty - } - } - - It "does not merge an exact instance template into another instance name" { - $Template = @{ - NonNodeData = @{ - Services = @{ - SQLServer = @{ - Instances = @( - @{ - Name = "MSSQLSERVER" - Marker = "exact-only" - } - ) - } - } - } - } - - $Deployment = @{ - NonNodeData = @{ - Services = @{ - SQLServer = @{ - Instances = @( - @{ Name = "MSSQLSERVER" }, - @{ Name = "MSSQLSERVERTEST" } - ) - } - } - } - } - - $Result = Merge-ConfigurationData -Template $Template -Deployment $Deployment - $Instances = @($Result.NonNodeData.Services.SQLServer.Instances) - - ($Instances | Where-Object { $_.Name -eq "MSSQLSERVER" }).Marker | Should Be "exact-only" - ($Instances | Where-Object { $_.Name -eq "MSSQLSERVERTEST" }).Marker | Should BeNullOrEmpty - } - - It "uses the name-key fallback for unknown arrays" { - $Template = @{ - Databases = @( - @{ - DatabaseName = "AppDb" - RecoveryModel = "Full" - } - ) - } - - $Deployment = @{ - Databases = @( - @{ - DatabaseName = "AppDb" - Owner = "dbo" - }, - @{ - DatabaseName = "LogDb" - Owner = "dbo" - } - ) - } - - $Result = Merge-ConfigurationData -Template $Template -Deployment $Deployment - $Databases = @($Result.Databases) - - ($Databases | Where-Object { $_.DatabaseName -eq "AppDb" }).RecoveryModel | Should Be "Full" - ($Databases | Where-Object { $_.DatabaseName -eq "LogDb" }).RecoveryModel | Should BeNullOrEmpty - } - - It "uses Key and ValueName together for registry array merges" { - $Template = @{ - Basic = @{ - Registry = @( - @{ - Key = "K1" - ValueName = "Enabled" - Default = "Template-K1" - }, - @{ - Key = "K2" - ValueName = "Enabled" - Default = "Template-K2" - } - ) - } - } - - $Deployment = @{ - Basic = @{ - Registry = @( - @{ - Key = "K1" - ValueName = "Enabled" - ValueData = "Deployment-K1" - } - ) - } - } - - $Result = Merge-ConfigurationData -Template $Template -Deployment $Deployment - $Registry = @($Result.Basic.Registry) - - ($Registry | Where-Object { $_.Key -eq "K1" }).Default | Should Be "Template-K1" - ($Registry | Where-Object { $_.Key -eq "K1" }).ValueData | Should Be "Deployment-K1" - ($Registry | Where-Object { $_.Key -eq "K2" }).Default | Should Be "Template-K2" - } -} From 59d884c0619d08c6afd51ad5d23b62b0bd2f689d Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 21 Apr 2026 14:03:44 +0200 Subject: [PATCH 05/10] Rename Function to Merge-DSCConfigurationData --- Merge-DSCConfigurationData.psd1 | 2 +- Merge-DSCConfigurationData.psm1 | 2 +- ...nfigurationData.ps1 => Merge-DSCConfigurationData.ps1} | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) rename Public/{Merge-ConfigurationData.ps1 => Merge-DSCConfigurationData.ps1} (90%) diff --git a/Merge-DSCConfigurationData.psd1 b/Merge-DSCConfigurationData.psd1 index 53b5a0f..1892346 100644 --- a/Merge-DSCConfigurationData.psd1 +++ b/Merge-DSCConfigurationData.psd1 @@ -7,7 +7,7 @@ Description = "Merges DSC configuration data from templates and deployment data." PowerShellVersion = "5.1" FunctionsToExport = @( - "Merge-ConfigurationData" + "Merge-DSCConfigurationData" ) CmdletsToExport = @() VariablesToExport = @() diff --git a/Merge-DSCConfigurationData.psm1 b/Merge-DSCConfigurationData.psm1 index 46660ff..6b916e2 100644 --- a/Merge-DSCConfigurationData.psm1 +++ b/Merge-DSCConfigurationData.psm1 @@ -9,5 +9,5 @@ foreach($File in @($Private + $Public)){ } Export-ModuleMember -Function @( - "Merge-ConfigurationData" + "Merge-DSCConfigurationData" ) diff --git a/Public/Merge-ConfigurationData.ps1 b/Public/Merge-DSCConfigurationData.ps1 similarity index 90% rename from Public/Merge-ConfigurationData.ps1 rename to Public/Merge-DSCConfigurationData.ps1 index 38afef3..0a7d1c6 100644 --- a/Public/Merge-ConfigurationData.ps1 +++ b/Public/Merge-DSCConfigurationData.ps1 @@ -1,4 +1,4 @@ -function Merge-ConfigurationData { +function Merge-DSCConfigurationData { [CmdletBinding()] Param( [Parameter(Mandatory=$true)] @@ -21,7 +21,7 @@ function Merge-ConfigurationData { 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) + $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))) @@ -30,7 +30,7 @@ function Merge-ConfigurationData { 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) + $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))) @@ -71,7 +71,7 @@ function Merge-ConfigurationData { if($MatchingDeploymentItems.Count -gt 0){ foreach($DeploymentItem in $MatchingDeploymentItems){ $OutputItem = @($Output.$($Property.Name) | Where-Object { Test-ConfigurationDataItemKeyMatch -Left $_ -Right $DeploymentItem -KeyNames $SearchKeyNames })[0] - Merge-ConfigurationData -Template $TemplateItem.Value -Deployment $DeploymentItem -Output $OutputItem | Out-Null + Merge-DSCConfigurationData -Template $TemplateItem.Value -Deployment $DeploymentItem -Output $OutputItem | Out-Null } }else{ if($TemplateItem.IsWildcard){ From c267d78cfbf634fcf34639bf657ec402d4b57ef8 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 21 Apr 2026 15:28:25 +0200 Subject: [PATCH 06/10] =?UTF-8?q?Removed=20Legacy=20Kompatibilit=C3=A4ts-L?= =?UTF-8?q?oader.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Merge-ConfigurationData.ps1 | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 Merge-ConfigurationData.ps1 diff --git a/Merge-ConfigurationData.ps1 b/Merge-ConfigurationData.ps1 deleted file mode 100644 index f04aad9..0000000 --- a/Merge-ConfigurationData.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -$ModuleRoot = $PSScriptRoot - -if([String]::IsNullOrWhiteSpace($ModuleRoot)){ - throw "This compatibility loader must be dot-sourced from a file path. Use Import-Module './Merge-DSCConfigurationData.psd1' for module usage." -} - -$PrivatePath = Join-Path -Path $ModuleRoot -ChildPath "Private" -$PublicPath = Join-Path -Path $ModuleRoot -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 -} From 0afc9dd1e258af6e81e0088bc12fa4942fd5ad64 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 21 Apr 2026 15:52:02 +0200 Subject: [PATCH 07/10] Version 1.0.0 --- Merge-DSCConfigurationData.psd1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Merge-DSCConfigurationData.psd1 b/Merge-DSCConfigurationData.psd1 index 1892346..5d0ef2d 100644 --- a/Merge-DSCConfigurationData.psd1 +++ b/Merge-DSCConfigurationData.psd1 @@ -1,6 +1,6 @@ @{ RootModule = "Merge-DSCConfigurationData.psm1" - ModuleVersion = "0.1.0" + ModuleVersion = "1.0.0" GUID = "c1c7e70d-9049-4eaa-a3c9-44a424c35ef5" Author = "Torsten Brendgen" Copyright = "(c) Torsten Brendgen. All rights reserved." From 735bfc9f61bd9ecb397045c32ed1f15bc55e24a0 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Sat, 27 Jun 2026 00:40:27 +0200 Subject: [PATCH 08/10] Update Deployment parameter to be optional with a default value --- Public/Merge-DSCConfigurationData.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Public/Merge-DSCConfigurationData.ps1 b/Public/Merge-DSCConfigurationData.ps1 index 0a7d1c6..414a3b1 100644 --- a/Public/Merge-DSCConfigurationData.ps1 +++ b/Public/Merge-DSCConfigurationData.ps1 @@ -4,9 +4,9 @@ function Merge-DSCConfigurationData { [Parameter(Mandatory=$true)] [System.Collections.Hashtable] $Template, - [Parameter(Mandatory=$true)] + [Parameter(Mandatory=$false)] [System.Collections.Hashtable] - $Deployment, + $Deployment = @{}, [Parameter(Mandatory=$false)] [System.Collections.Hashtable] $Output From fcbe61902f51ce67adfe2ac086855886a77e616a Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Fri, 3 Jul 2026 10:34:27 +0200 Subject: [PATCH 09/10] Add functions for configuration data handling and extend template merging capabilities --- .../Get-ConfigurationDataDictionaryValue.ps1 | 22 +++ .../Get-ConfigurationDataTemplateExtends.ps1 | 31 +++ ...Get-ConfigurationDataTemplateMergeFile.ps1 | 42 +++++ .../Merge-ConfigurationDataTemplateFile.ps1 | 49 +++++ .../Resolve-ConfigurationDataTemplatePath.ps1 | 24 +++ .../Test-ConfigurationDataDictionaryKey.ps1 | 18 ++ Public/Merge-DSCConfigurationData.ps1 | 178 ++++++++++-------- Tests/Extends.Tests.ps1 | 120 ++++++++++++ 8 files changed, 408 insertions(+), 76 deletions(-) create mode 100644 Private/Get-ConfigurationDataDictionaryValue.ps1 create mode 100644 Private/Get-ConfigurationDataTemplateExtends.ps1 create mode 100644 Private/Get-ConfigurationDataTemplateMergeFile.ps1 create mode 100644 Private/Merge-ConfigurationDataTemplateFile.ps1 create mode 100644 Private/Resolve-ConfigurationDataTemplatePath.ps1 create mode 100644 Private/Test-ConfigurationDataDictionaryKey.ps1 create mode 100644 Tests/Extends.Tests.ps1 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 + } +} From fb9209456bc7402a7891f6708f52651dd350eab8 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Fri, 3 Jul 2026 11:59:18 +0200 Subject: [PATCH 10/10] Update module version to 1.0.1 and add new functions for exporting and deploying DSC configuration data --- Merge-DSCConfigurationData.psd1 | 8 +- Merge-DSCConfigurationData.psm1 | 4 +- ...onvertTo-ConfigurationDataRelativePath.ps1 | 29 +++ Private/ConvertTo-PowerShellDataFileText.ps1 | 64 +++++++ .../Repair-ConfigurationDataPathEncoding.ps1 | 21 +++ .../Resolve-ConfigurationDataTemplatePath.ps1 | 27 ++- Public/Export-PowerShellDataFile.ps1 | 32 ++++ Public/New-DSCConfigurationDataDeployment.ps1 | 143 +++++++++++++++ Tests/Extends.Tests.ps1 | 171 ++++++++++++++++++ 9 files changed, 491 insertions(+), 8 deletions(-) create mode 100644 Private/ConvertTo-ConfigurationDataRelativePath.ps1 create mode 100644 Private/ConvertTo-PowerShellDataFileText.ps1 create mode 100644 Private/Repair-ConfigurationDataPathEncoding.ps1 create mode 100644 Public/Export-PowerShellDataFile.ps1 create mode 100644 Public/New-DSCConfigurationDataDeployment.ps1 diff --git a/Merge-DSCConfigurationData.psd1 b/Merge-DSCConfigurationData.psd1 index 5d0ef2d..8e84beb 100644 --- a/Merge-DSCConfigurationData.psd1 +++ b/Merge-DSCConfigurationData.psd1 @@ -1,13 +1,15 @@ -@{ +@{ RootModule = "Merge-DSCConfigurationData.psm1" - ModuleVersion = "1.0.0" + 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" + "Merge-DSCConfigurationData", + "Export-PowerShellDataFile", + "New-DSCConfigurationDataDeployment" ) CmdletsToExport = @() VariablesToExport = @() diff --git a/Merge-DSCConfigurationData.psm1 b/Merge-DSCConfigurationData.psm1 index 6b916e2..45a98a4 100644 --- a/Merge-DSCConfigurationData.psm1 +++ b/Merge-DSCConfigurationData.psm1 @@ -9,5 +9,7 @@ foreach($File in @($Private + $Public)){ } Export-ModuleMember -Function @( - "Merge-DSCConfigurationData" + "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/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 index 7608a18..cf68b3c 100644 --- a/Private/Resolve-ConfigurationDataTemplatePath.ps1 +++ b/Private/Resolve-ConfigurationDataTemplatePath.ps1 @@ -10,12 +10,31 @@ function Resolve-ConfigurationDataTemplatePath { $BasePath = (Get-Location).Path ) - $CandidatePath = $Path - if(-not [System.IO.Path]::IsPathRooted($CandidatePath)){ - $CandidatePath = Join-Path -Path $BasePath -ChildPath $CandidatePath + $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 + } } - $ResolvedPath = Resolve-Path -Path $CandidatePath -ErrorAction SilentlyContinue if($null -eq $ResolvedPath){ throw "Configuration data template [$Path] was not found. Base path [$BasePath]." } 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/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 index 4781804..26dc8e9 100644 --- a/Tests/Extends.Tests.ps1 +++ b/Tests/Extends.Tests.ps1 @@ -117,4 +117,175 @@ Describe 'Merge-DSCConfigurationData Extends' { { 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' + } }