Skip to content

Commit 0d28cdb

Browse files
authored
Add WhatIf functionality to PSResourceList operations (#2004)
1 parent 74849e1 commit 0d28cdb

3 files changed

Lines changed: 245 additions & 18 deletions

File tree

‎src/dsc/psresourceget.ps1‎

Lines changed: 122 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ param(
1010
[ValidateSet('get', 'set', 'test', 'delete', 'export')]
1111
[string]$Operation,
1212
[Parameter(ValueFromPipeline)]
13-
$stdinput
13+
$stdinput,
14+
[switch]$WhatIf
1415
)
1516

1617
enum Scope {
@@ -42,6 +43,7 @@ class PSResource {
4243
[bool]$preRelease
4344
[bool]$_exist
4445
[bool]$_inDesiredState
46+
[object]$_metadata
4547

4648
PSResource([string]$name, [string]$version, [Scope]$scope, [string]$repositoryName, [bool]$preRelease) {
4749
$this.name = $name
@@ -98,14 +100,18 @@ class PSResource {
98100
}
99101

100102
[string] ToJson() {
101-
$retVal = ($this | Select-Object -ExcludeProperty _inDesiredState | ConvertTo-Json -Compress -EnumsAsStrings)
103+
[string[]]$excludeProps = @('_inDesiredState')
104+
if ($null -eq $this._metadata) { $excludeProps += '_metadata' }
105+
$retVal = ($this | Select-Object -ExcludeProperty $excludeProps | ConvertTo-Json -Compress -EnumsAsStrings)
102106
Write-Trace -message "Serializing PSResource to JSON. Name: $($this.name), Version: $($this.version), Scope: $($this.scope), RepositoryName: $($this.repositoryName), PreRelease: $($this.preRelease), _exist: $($this._exist)" -level debug
103107
Write-Trace -message "Serialized JSON: $retVal" -level trace
104108
return $retVal
105109
}
106110

107111
[string] ToJsonForTest() {
108-
return ($this | ConvertTo-Json -Compress -Depth 5 -EnumsAsStrings)
112+
[string[]]$excludeProps = @()
113+
if ($null -eq $this._metadata) { $excludeProps += '_metadata' }
114+
return ($this | Select-Object -ExcludeProperty $excludeProps | ConvertTo-Json -Compress -Depth 5 -EnumsAsStrings)
109115
}
110116
}
111117

@@ -155,23 +161,42 @@ class PSResourceList {
155161
}
156162

157163
[string] ToJson() {
158-
$resourceJson = if ($this.resources) { ($this.resources | ForEach-Object { $_.ToJson() }) -join ',' } else { '' }
159-
$resourceJson = "[$resourceJson]"
160-
$jsonString = "{'repositoryName': '$($this.repositoryName)','resources': $resourceJson}"
161-
$jsonString = $jsonString -replace "'", '"'
162-
$retVal = $jsonString | ConvertFrom-Json | ConvertTo-Json -Compress -EnumsAsStrings
163-
164+
## Assign the array directly so that an empty list serializes as [] rather than null
165+
[object[]]$resourceObjects = @()
166+
if ($this.resources) {
167+
$resourceObjects = @($this.resources | ForEach-Object {
168+
[string[]]$excludeProps = @('_inDesiredState')
169+
if ($null -eq $_._metadata) { $excludeProps += '_metadata' }
170+
$_ | Select-Object -ExcludeProperty $excludeProps
171+
})
172+
}
173+
$retVal = [ordered]@{
174+
repositoryName = $this.repositoryName
175+
resources = $resourceObjects
176+
} | ConvertTo-Json -Compress -Depth 5 -EnumsAsStrings
164177
Write-Trace -message "Serializing PSResourceList to JSON. RepositoryName: $($this.repositoryName), TrustedRepository: $($this.trustedRepository), Resources count: $($this.resources.Count)" -level debug
165178
Write-Trace -message "Serialized JSON: $retVal" -level trace
166-
167179
return $retVal
168180
}
169181

170182
[string] ToJsonForTest() {
171183
Write-Trace -message "Serializing PSResourceList to JSON for test output. RepositoryName: $($this.repositoryName), TrustedRepository: $($this.trustedRepository), Resources count: $($this.resources.Count)" -level debug
172-
$jsonForTest = $this | ConvertTo-Json -Compress -Depth 5 -EnumsAsStrings
173-
Write-Trace -message "Serialized JSON: $jsonForTest" -level trace
174-
return $jsonForTest
184+
[object[]]$resourceObjects = @()
185+
if ($this.resources) {
186+
$resourceObjects = @($this.resources | ForEach-Object {
187+
[string[]]$excludeProps = @()
188+
if ($null -eq $_._metadata) { $excludeProps += '_metadata' }
189+
if ($excludeProps.Count -gt 0) { $_ | Select-Object -ExcludeProperty $excludeProps } else { $_ }
190+
})
191+
}
192+
$retVal = [ordered]@{
193+
repositoryName = $this.repositoryName
194+
resources = $resourceObjects
195+
trustedRepository = $this.trustedRepository
196+
_inDesiredState = $this._inDesiredState
197+
} | ConvertTo-Json -Compress -Depth 5 -EnumsAsStrings
198+
Write-Trace -message "Serialized JSON: $retVal" -level trace
199+
return $retVal
175200
}
176201
}
177202

@@ -353,21 +378,27 @@ function GetPSResourceList {
353378
$preferred = $matchingResources | Where-Object {
354379
try { SatisfiesVersion -version $_.Version -versionRange $inputResource.Version } catch { $false }
355380
} | Select-Object -First 1
356-
} else {
381+
}
382+
elseif (-not ($resolvedResources | Where-Object { $_.Name -eq $inputResource.Name })) {
383+
# No version constraint: any installed version means the resource exists.
384+
# Only record the first match so that one input resource maps to one current resource.
385+
Write-Trace -message "No version constraint for input: $($inputResource.Name). Treating installed version $($matchingResources[0].Version) as a match." -level debug
357386
$preferred = $matchingResources | Select-Object -First 1
358387
}
359388

360389
if ($preferred) {
361390
Write-Trace -message "Resource '$($inputResource.Name)' version '$($preferred.Version)' satisfies requested range '$($inputResource.Version)'." -level debug
362391
$resolvedResources += $preferred
363-
} else {
392+
}
393+
else {
364394
# Installed but doesn't satisfy the version range - report actual installed version with _exist = false
365395
$fallback = $matchingResources | Select-Object -First 1
366396
Write-Trace -message "Resource '$($inputResource.Name)' installed at '$($fallback.Version)' does not satisfy requested range '$($inputResource.Version)'. Reporting _exist = false." -level debug
367397
$fallback._exist = $false
368398
$resolvedResources += $fallback
369399
}
370-
} else {
400+
}
401+
else {
371402
Write-Trace -message "Resource '$($inputResource.Name)' is not installed. Reporting _exist = false." -level debug
372403
$resolvedResources += [PSResource]::new($inputResource.Name)
373404
}
@@ -548,11 +579,84 @@ function ExportOperation {
548579
}
549580
}
550581

551-
function SetPSResourceList {
582+
function WhatIfPSResourceList {
552583
param(
553584
$inputObj
554585
)
555586

587+
$repositoryName = $inputObj.repositoryName
588+
$currentState = GetPSResourceList -inputObj $inputObj
589+
$projectedResources = @()
590+
$inputObj.resources | ForEach-Object {
591+
$resourceDesiredState = ConvertInputToPSResource -inputObj $_ -repositoryName $repositoryName
592+
$name = $resourceDesiredState.name
593+
$version = $resourceDesiredState.version
594+
$scope = if ($resourceDesiredState.scope) { $resourceDesiredState.scope } else { [Scope]'CurrentUser' }
595+
$currentResource = $currentState.resources | Where-Object { $_.name -eq $name } | Select-Object -First 1
596+
597+
if (-not $resourceDesiredState._exist -and $null -ne $currentResource -and $currentResource._exist) {
598+
$msg = "Would uninstall resource '$name'"
599+
Write-Trace -message "WhatIf: $msg." -level debug
600+
$resource = [PSResource]::new(
601+
$currentResource.name,
602+
$currentResource.version,
603+
$currentResource.scope,
604+
$currentResource.repositoryName,
605+
$currentResource.preRelease
606+
)
607+
$resource._exist = $false
608+
$resource._metadata = [pscustomobject]@{ whatIf = @($msg) }
609+
$projectedResources += $resource
610+
}
611+
elseif ($resourceDesiredState._exist -and ($null -eq $currentResource -or -not $currentResource._exist)) {
612+
$versionStr = if ($version) { $version } else { 'latest' }
613+
$msg = "Would install resource '$name' version '$versionStr'"
614+
Write-Trace -message "WhatIf: $msg." -level debug
615+
$resource = [PSResource]::new($name, $versionStr, [Scope]$scope, $repositoryName, $resourceDesiredState.preRelease)
616+
$resource._metadata = [pscustomobject]@{ whatIf = @($msg) }
617+
$projectedResources += $resource
618+
}
619+
else {
620+
Write-Trace -message "WhatIf: Resource '$name' is already in desired state." -level debug
621+
if ($null -ne $currentResource) {
622+
$projectedResources += $currentResource
623+
}
624+
else {
625+
$projectedResources += $resourceDesiredState
626+
}
627+
}
628+
}
629+
630+
## Report the same failures a real set operation would hit before installing anything
631+
$installRequired = @($projectedResources | Where-Object { $_._exist -and $null -ne $_._metadata }).Count -gt 0
632+
if ($installRequired) {
633+
$psRepository = Get-PSResourceRepository -Name $repositoryName -ErrorAction SilentlyContinue
634+
635+
if (-not $psRepository) {
636+
Write-Trace -level error -message "Repository '$repositoryName' not found. Cannot install resources."
637+
exit [ExitCode]::RepositoryNotFound
638+
}
639+
640+
if (-not $psRepository.Trusted -and -not $inputObj.trustedRepository) {
641+
Write-Trace -level error -message "Repository '$repositoryName' is not trusted. Cannot install resources."
642+
exit [ExitCode]::RepositoryNotTrusted
643+
}
644+
}
645+
646+
$list = [PSResourceList]::new($repositoryName, $projectedResources, $currentState.trustedRepository)
647+
$list.ToJson()
648+
}
649+
650+
function SetPSResourceList {
651+
param(
652+
$inputObj,
653+
[switch]$WhatIf
654+
)
655+
656+
if ($WhatIf) {
657+
return WhatIfPSResourceList -inputObj $inputObj
658+
}
659+
556660
$repositoryName = $inputObj.repositoryName
557661
$resourcesToUninstall = @()
558662
$resourcesToInstall = [System.Collections.Generic.Dictionary[string, psobject]]::new()
@@ -707,7 +811,7 @@ function SetOperation {
707811
Write-Trace -level error -message "Set operation is not implemented for PSResource resource."
708812
exit [ExitCode]::SetNotImplemented
709813
}
710-
'psresourcelist' { return SetPSResourceList -inputObj $inputObj }
814+
'psresourcelist' { return SetPSResourceList -inputObj $inputObj -WhatIf:$WhatIf }
711815
default {
712816
Write-Trace -level error -message "Unknown ResourceType: $ResourceType"
713817
exit [ExitCode]::UnknownResourceType

‎src/dsc/psresourcelist.dsc.resource.json‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@
3737
"input": "stdin",
3838
"return": "stateAndDiff"
3939
},
40+
"whatIf": {
41+
"executable": "pwsh",
42+
"args": [
43+
"-NoLogo",
44+
"-NonInteractive",
45+
"-NoProfile",
46+
"-ExecutionPolicy",
47+
"Bypass",
48+
"-Command",
49+
"$Input | ./psresourceget.ps1 -resourcetype 'psresourcelist' -operation set -WhatIf; exit $LASTEXITCODE"
50+
],
51+
"input": "stdin",
52+
"return": "state"
53+
},
4054
"export": {
4155
"executable": "pwsh",
4256
"args": [
@@ -161,6 +175,20 @@
161175
},
162176
"_inDesiredState": {
163177
"$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/inDesiredState.json"
178+
},
179+
"_metadata": {
180+
"type": "object",
181+
"title": "Metadata",
182+
"description": "Metadata populated during what-if operations.",
183+
"readOnly": true,
184+
"additionalProperties": false,
185+
"properties": {
186+
"whatIf": {
187+
"type": "array",
188+
"description": "Messages describing what the set operation would do.",
189+
"items": { "type": "string" }
190+
}
191+
}
164192
}
165193
}
166194
},

‎test/DscResource/PSResourceGetDSCResource.Tests.ps1‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,3 +670,98 @@ Describe "Error code tests" -Tags 'CI' {
670670
$out[-1] | Should -BeLike '*Could not install one or more resources (during set operation)*'
671671
}
672672
}
673+
674+
Describe 'PSResourceList what-if tests' -Tags 'CI' {
675+
BeforeAll {
676+
SetupDsc
677+
SetupTestRepos
678+
679+
$isOnWindowsPowerShell = $PSVersionTable.PSVersion.Major -lt 6
680+
$originalDefaultParameterValues = $PSDefaultParameterValues.Clone()
681+
$PSDefaultParameterValues['it:skip'] = $isOnWindowsPowerShell
682+
}
683+
684+
AfterAll {
685+
$global:PSDefaultParameterValues = $originalDefaultParameterValues
686+
Get-RevertPSResourceRepositoryFile
687+
}
688+
689+
It 'What-if install does not modify the system' {
690+
Uninstall-PSResource -Name $script:testModuleName -ErrorAction SilentlyContinue
691+
Uninstall-PSResource -Name $script:testModuleName2 -ErrorAction SilentlyContinue
692+
693+
$config_yaml = @"
694+
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
695+
resources:
696+
- name: PSResourceList what-if install
697+
type: Microsoft.PowerShell.PSResourceGet/PSResourceList
698+
properties:
699+
repositoryName: $script:localRepo
700+
trustedRepository: true
701+
resources:
702+
- name: $script:testModuleName
703+
version: '5.0.0'
704+
- name: $script:testModuleName2
705+
version: '5.0.0'
706+
"@
707+
708+
$out = & $script:dscExe config set --what-if --input $config_yaml 2>$TestDrive/error.log | ConvertFrom-Json
709+
$LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw)
710+
711+
$result = $out.results.result[0].afterState
712+
$result.repositoryName | Should -BeExactly $script:localRepo
713+
$result.resources.Count | Should -Be 2
714+
$result.resources[0]._metadata.whatIf[0] | Should -Match 'Would install'
715+
$result.resources[1]._metadata.whatIf[0] | Should -Match 'Would install'
716+
717+
Get-PSResource -Name $script:testModuleName -ErrorAction SilentlyContinue | Should -BeNullOrEmpty
718+
Get-PSResource -Name $script:testModuleName2 -ErrorAction SilentlyContinue | Should -BeNullOrEmpty
719+
}
720+
721+
It 'What-if uninstall does not modify the system' {
722+
Install-PSResource -Name $script:testModuleName -Version '5.0.0' -Repository $script:localRepo -TrustRepository -Reinstall
723+
724+
$config_yaml = @"
725+
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
726+
resources:
727+
- name: PSResourceList what-if uninstall
728+
type: Microsoft.PowerShell.PSResourceGet/PSResourceList
729+
properties:
730+
repositoryName: $script:localRepo
731+
resources:
732+
- name: $script:testModuleName
733+
_exist: false
734+
"@
735+
736+
$out = & $script:dscExe config set --what-if --input $config_yaml 2>$TestDrive/error.log | ConvertFrom-Json
737+
$LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw)
738+
739+
$result = $out.results.result[0].afterState
740+
$result.resources[0]._exist | Should -BeFalse
741+
$result.resources[0]._metadata.whatIf[0] | Should -Match 'Would uninstall'
742+
743+
Get-PSResource -Name $script:testModuleName -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
744+
}
745+
746+
It 'What-if returns no metadata for resources already in desired state' {
747+
Install-PSResource -Name $script:testModuleName -Version '5.0.0' -Repository $script:localRepo -TrustRepository -Reinstall
748+
749+
$config_yaml = @"
750+
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
751+
resources:
752+
- name: PSResourceList what-if in desired state
753+
type: Microsoft.PowerShell.PSResourceGet/PSResourceList
754+
properties:
755+
repositoryName: $script:localRepo
756+
resources:
757+
- name: $script:testModuleName
758+
version: '5.0.0'
759+
"@
760+
761+
$out = & $script:dscExe config set --what-if --input $config_yaml 2>$TestDrive/error.log | ConvertFrom-Json
762+
$LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw)
763+
764+
$result = $out.results.result[0].afterState
765+
$result.resources[0]._metadata | Should -BeNullOrEmpty
766+
}
767+
}

0 commit comments

Comments
 (0)