From c93f4ebc5c2e416ff6e6942f8fdd5995b01756e2 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Fri, 3 Jul 2026 12:34:48 +0200 Subject: [PATCH] Add Test-DSCConfigurationDataDeployment function and related enhancements --- Merge-DSCConfigurationData.psd1 | 3 +- Merge-DSCConfigurationData.psm1 | 3 +- .../Get-ConfigurationDataSourceFileInfo.ps1 | 34 +++++ .../Resolve-ConfigurationDataSourcePath.ps1 | 19 +++ Public/New-DSCConfigurationDataDeployment.ps1 | 6 +- .../Test-DSCConfigurationDataDeployment.ps1 | 125 ++++++++++++++++++ Tests/Extends.Tests.ps1 | 59 +++++++++ 7 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 Private/Get-ConfigurationDataSourceFileInfo.ps1 create mode 100644 Private/Resolve-ConfigurationDataSourcePath.ps1 create mode 100644 Public/Test-DSCConfigurationDataDeployment.ps1 diff --git a/Merge-DSCConfigurationData.psd1 b/Merge-DSCConfigurationData.psd1 index 8e84beb..4ff3f27 100644 --- a/Merge-DSCConfigurationData.psd1 +++ b/Merge-DSCConfigurationData.psd1 @@ -9,7 +9,8 @@ FunctionsToExport = @( "Merge-DSCConfigurationData", "Export-PowerShellDataFile", - "New-DSCConfigurationDataDeployment" + "New-DSCConfigurationDataDeployment", + "Test-DSCConfigurationDataDeployment" ) CmdletsToExport = @() VariablesToExport = @() diff --git a/Merge-DSCConfigurationData.psm1 b/Merge-DSCConfigurationData.psm1 index 45a98a4..884bdf7 100644 --- a/Merge-DSCConfigurationData.psm1 +++ b/Merge-DSCConfigurationData.psm1 @@ -11,5 +11,6 @@ foreach($File in @($Private + $Public)){ Export-ModuleMember -Function @( "Merge-DSCConfigurationData", "Export-PowerShellDataFile", - "New-DSCConfigurationDataDeployment" + "New-DSCConfigurationDataDeployment", + "Test-DSCConfigurationDataDeployment" ) diff --git a/Private/Get-ConfigurationDataSourceFileInfo.ps1 b/Private/Get-ConfigurationDataSourceFileInfo.ps1 new file mode 100644 index 0000000..ccd96df --- /dev/null +++ b/Private/Get-ConfigurationDataSourceFileInfo.ps1 @@ -0,0 +1,34 @@ +function Get-ConfigurationDataSourceFileInfo { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [string] + $Path, + + [Parameter(Mandatory=$true)] + [string] + $BasePath, + + [Parameter(Mandatory=$false)] + [switch] + $UseRelativePath + ) + + $ResolvedPath = Resolve-ConfigurationDataTemplatePath -Path $Path -BasePath $BasePath + $Item = Get-Item -LiteralPath $ResolvedPath + $Hash = Get-FileHash -LiteralPath $ResolvedPath -Algorithm SHA256 + + $DisplayPath = if($UseRelativePath){ + ConvertTo-ConfigurationDataRelativePath -Path $ResolvedPath -BasePath $BasePath + }else{ + $ResolvedPath + } + + [ordered]@{ + Path = $DisplayPath + Hash = $Hash.Hash + Algorithm = $Hash.Algorithm + Size = $Item.Length + LastWriteTimeUtc = $Item.LastWriteTimeUtc.ToString("o") + } +} diff --git a/Private/Resolve-ConfigurationDataSourcePath.ps1 b/Private/Resolve-ConfigurationDataSourcePath.ps1 new file mode 100644 index 0000000..da96512 --- /dev/null +++ b/Private/Resolve-ConfigurationDataSourcePath.ps1 @@ -0,0 +1,19 @@ +function Resolve-ConfigurationDataSourcePath { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [string] + $Path, + + [Parameter(Mandatory=$true)] + [string] + $DeploymentPath + ) + + $BasePath = Split-Path -Path (Repair-ConfigurationDataPathEncoding -Path $DeploymentPath) -Parent + if([string]::IsNullOrWhiteSpace($BasePath)){ + $BasePath = (Get-Location).Path + } + + return Resolve-ConfigurationDataTemplatePath -Path $Path -BasePath $BasePath +} diff --git a/Public/New-DSCConfigurationDataDeployment.ps1 b/Public/New-DSCConfigurationDataDeployment.ps1 index c9ff185..62fff88 100644 --- a/Public/New-DSCConfigurationDataDeployment.ps1 +++ b/Public/New-DSCConfigurationDataDeployment.ps1 @@ -91,11 +91,7 @@ function New-DSCConfigurationDataDeployment { }) $SourceFiles = @($MergeResult.Files | ForEach-Object { - if($UseRelativePaths){ - ConvertTo-ConfigurationDataRelativePath -Path $_ -BasePath $OutputDirectory - }else{ - $_ - } + Get-ConfigurationDataSourceFileInfo -Path $_ -BasePath $OutputDirectory -UseRelativePath:$UseRelativePaths }) $ConfigurationData["Metadata"] = [ordered]@{ diff --git a/Public/Test-DSCConfigurationDataDeployment.ps1 b/Public/Test-DSCConfigurationDataDeployment.ps1 new file mode 100644 index 0000000..cdfed21 --- /dev/null +++ b/Public/Test-DSCConfigurationDataDeployment.ps1 @@ -0,0 +1,125 @@ +function Test-DSCConfigurationDataDeployment { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [string] + $Path, + + [Parameter(Mandatory=$false)] + [switch] + $PassThru + ) + + $ResolvedPath = Repair-ConfigurationDataPathEncoding -Path $Path + $Issues = @() + + if(-not (Test-Path -LiteralPath $ResolvedPath -PathType Leaf)){ + $Issues += [PSCustomObject]@{ + Severity = "Error" + Code = "DeploymentNotFound" + Path = $ResolvedPath + Message = "Deployment file was not found." + } + }else{ + try { + $DeploymentData = Import-PowerShellDataFile -LiteralPath $ResolvedPath + } + catch { + $Issues += [PSCustomObject]@{ + Severity = "Error" + Code = "DeploymentImportFailed" + Path = $ResolvedPath + Message = $_.Exception.Message + } + } + } + + if($null -ne $DeploymentData){ + if(-not (Test-ConfigurationDataDictionaryKey -Dictionary $DeploymentData -Key "Metadata")){ + $Issues += [PSCustomObject]@{ Severity = "Error"; Code = "MetadataMissing"; Path = $ResolvedPath; Message = "Metadata block is missing." } + }elseif($DeploymentData.Metadata.TemplateType -ne "Deployment"){ + $Issues += [PSCustomObject]@{ Severity = "Error"; Code = "InvalidTemplateType"; Path = $ResolvedPath; Message = "Metadata.TemplateType must be [Deployment]." } + } + + foreach($Key in @("Parameters", "Variables", "Resources")){ + if(-not (Test-ConfigurationDataDictionaryKey -Dictionary $DeploymentData -Key $Key)){ + $Issues += [PSCustomObject]@{ Severity = "Error"; Code = "$Key`Missing"; Path = $ResolvedPath; Message = "Required block [$Key] is missing." } + } + } + + if((Test-ConfigurationDataDictionaryKey -Dictionary $DeploymentData -Key "Resources") -and + (-not (Test-ConfigurationDataDictionaryKey -Dictionary $DeploymentData.Resources -Key "AllNodes"))){ + $Issues += [PSCustomObject]@{ Severity = "Error"; Code = "AllNodesMissing"; Path = $ResolvedPath; Message = "Resources.AllNodes is missing." } + } + + if((Test-ConfigurationDataDictionaryKey -Dictionary $DeploymentData -Key "Resources") -and + (Test-ConfigurationDataDictionaryKey -Dictionary $DeploymentData.Resources -Key "AllNodes")){ + $NodeNames = @{} + foreach($Node in @($DeploymentData.Resources.AllNodes)){ + if(-not (Test-ConfigurationDataDictionaryKey -Dictionary $Node -Key "NodeName") -or [string]::IsNullOrWhiteSpace([string]$Node.NodeName)){ + $Issues += [PSCustomObject]@{ Severity = "Error"; Code = "NodeNameMissing"; Path = $ResolvedPath; Message = "An AllNodes item has no NodeName." } + continue + } + + $NodeName = [string]$Node.NodeName + if($NodeNames.ContainsKey($NodeName)){ + $Issues += [PSCustomObject]@{ Severity = "Error"; Code = "DuplicateNodeName"; Path = $ResolvedPath; Message = "Duplicate NodeName [$NodeName]." } + }else{ + $NodeNames[$NodeName] = $true + } + } + } + + if($DeploymentData.Metadata -is [System.Collections.IDictionary] -and + (Test-ConfigurationDataDictionaryKey -Dictionary $DeploymentData.Metadata -Key "Sources") -and + $DeploymentData.Metadata.Sources -is [System.Collections.IDictionary] -and + (Test-ConfigurationDataDictionaryKey -Dictionary $DeploymentData.Metadata.Sources -Key "Files")){ + foreach($Source in @($DeploymentData.Metadata.Sources.Files)){ + $SourcePath = if($Source -is [System.Collections.IDictionary]){ [string]$Source.Path } else { [string]$Source } + if([string]::IsNullOrWhiteSpace($SourcePath)){ + $Issues += [PSCustomObject]@{ Severity = "Warning"; Code = "SourcePathMissing"; Path = $ResolvedPath; Message = "A source file entry has no path." } + continue + } + + try { + $ActualPath = Resolve-ConfigurationDataSourcePath -Path $SourcePath -DeploymentPath $ResolvedPath + } + catch { + $Issues += [PSCustomObject]@{ Severity = "Warning"; Code = "SourceMissing"; Path = $SourcePath; Message = $_.Exception.Message } + continue + } + + if($Source -is [System.Collections.IDictionary] -and (Test-ConfigurationDataDictionaryKey -Dictionary $Source -Key "Hash")){ + $Algorithm = [string](Get-ConfigurationDataDictionaryValue -Dictionary $Source -Key "Algorithm" -DefaultValue "SHA256") + $ActualHash = Get-FileHash -LiteralPath $ActualPath -Algorithm $Algorithm + if($ActualHash.Hash -ne [string]$Source.Hash){ + $Issues += [PSCustomObject]@{ + Severity = "Warning" + Code = "SourceChanged" + Path = $SourcePath + Message = "Source file hash has changed." + ExpectedHash = [string]$Source.Hash + ActualHash = $ActualHash.Hash + Algorithm = $Algorithm + } + } + } + } + }else{ + $Issues += [PSCustomObject]@{ Severity = "Warning"; Code = "SourceMetadataMissing"; Path = $ResolvedPath; Message = "Metadata.Sources.Files is missing." } + } + } + + $Result = [PSCustomObject]@{ + Path = $ResolvedPath + IsValid = @($Issues | Where-Object { $_.Severity -eq "Error" }).Count -eq 0 + IsStale = @($Issues | Where-Object { $_.Code -in @("SourceChanged", "SourceMissing") }).Count -gt 0 + Issues = $Issues + } + + if($PassThru){ + return $Result + } + + return $Result.IsValid -and (-not $Result.IsStale) +} diff --git a/Tests/Extends.Tests.ps1 b/Tests/Extends.Tests.ps1 index 26dc8e9..abd5a2d 100644 --- a/Tests/Extends.Tests.ps1 +++ b/Tests/Extends.Tests.ps1 @@ -212,6 +212,8 @@ Describe 'Merge-DSCConfigurationData Extends' { Required = $true } } + Variables = @{ + } } '@ | Set-Content -Path $DefaultPath -Encoding UTF8 @@ -250,6 +252,8 @@ Describe 'Merge-DSCConfigurationData Extends' { $DeploymentPath | Should Exist $Result.ConfigurationData.Metadata.TemplateType | Should Be 'Deployment' $Result.ConfigurationData.Metadata.Sources.Files.Count | Should Be 2 + $Result.ConfigurationData.Metadata.Sources.Files[0].Hash.Length | Should Be 64 + $Result.ConfigurationData.Metadata.Sources.Files[0].Algorithm | Should Be 'SHA256' $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 @@ -257,6 +261,10 @@ Describe 'Merge-DSCConfigurationData Extends' { $Imported = Import-PowerShellDataFile -Path $DeploymentPath $Imported.Metadata.Name | Should Be 'Contoso-Test' $Imported.Metadata.Sources.Templates.Count | Should Be 1 + + $Validation = Test-DSCConfigurationDataDeployment -Path $DeploymentPath -PassThru + $Validation.IsValid | Should Be $true + $Validation.IsStale | Should Be $false } It 'exports materialized deployment data as JSON' { @@ -271,6 +279,8 @@ Describe 'Merge-DSCConfigurationData Extends' { Value = 'contoso.local' } } + Variables = @{ + } } '@ | Set-Content -Path $DefaultPath -Encoding UTF8 @@ -288,4 +298,53 @@ Describe 'Merge-DSCConfigurationData Extends' { $Imported.Metadata.TemplateType | Should Be 'Deployment' $Imported.Parameters.DomainFQDN.Value | Should Be 'contoso.local' } + + It 'detects changed deployment source files by hash' { + $DefaultPath = Join-Path -Path $TestDrive -ChildPath 'Default.psd1' + $DeploymentPath = Join-Path -Path $TestDrive -ChildPath 'Deployment_123.psd1' + + @' +@{ + Parameters = @{ + DomainFQDN = @{ + Type = 'string' + Value = 'contoso.local' + } + } + Variables = @{ + } +} +'@ | Set-Content -Path $DefaultPath -Encoding UTF8 + + New-DSCConfigurationDataDeployment ` + -Path $DeploymentPath ` + -Name 'Contoso-Test' ` + -DeploymentId '123' ` + -SourceTemplatePath $DefaultPath ` + -AllNodes @( + @{ + NodeName = 'Node01' + } + ) ` + -Export ` + -Force + + @' +@{ + Parameters = @{ + DomainFQDN = @{ + Type = 'string' + Value = 'changed.local' + } + } + Variables = @{ + } +} +'@ | Set-Content -Path $DefaultPath -Encoding UTF8 + + $Validation = Test-DSCConfigurationDataDeployment -Path $DeploymentPath -PassThru + $Validation.IsValid | Should Be $true + $Validation.IsStale | Should Be $true + @($Validation.Issues | Where-Object { $_.Code -eq 'SourceChanged' }).Count | Should Be 1 + } } -- 2.39.5