From fb9209456bc7402a7891f6708f52651dd350eab8 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Fri, 3 Jul 2026 11:59:18 +0200 Subject: [PATCH] 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' + } }