Skip to content

Fix property discovery on class-based resources WinPS adapter #879

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jul 6, 2025
16 changes: 16 additions & 0 deletions powershell-adapter/Tests/powershellgroup.resource.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ Describe 'PowerShell adapter resource tests' {
$res.actualState.EnumProp | Should -BeExactly 'Expected'
}

It 'Get should return the correct properties on class-based resource' {
$r = "{'Name':'TestClassResource1'}" | dsc resource get -r 'TestClassResource/TestClassResource' -f -
$LASTEXITCODE | Should -Be 0
$res = $r | ConvertFrom-Json -AsHashtable
$res.actualState.ContainsKey('Name') | Should -Be $True
$res.actualState.ContainsKey('Prop1') | Should -Be $True
$res.actualState.ContainsKey('HashTableProp') | Should -Be $True
$res.actualState.ContainsKey('EnumProp') | Should -Be $True
$res.actualState.ContainsKey('Credential') | Should -Be $True
$res.actualState.ContainsKey('Ensure') | Should -Be $True
$res.actualState.ContainsKey('BaseProperty') | Should -Be $True
$res.actualState.ContainsKey('HiddenDscProperty') | Should -Be $True
$res.actualState.ContainsKey('NonDscProperty') | Should -Be $False
$res.actualState.ContainsKey('HiddenNonDscProperty') | Should -Be $False
}

It 'Test works on class-based resource' {

$r = "{'Name':'TestClassResource1','Prop1':'ValueForProp1'}" | dsc resource test -r 'TestClassResource/TestClassResource' -f -
Expand Down
150 changes: 102 additions & 48 deletions powershell-adapter/psDscAdapter/win_psDscAdapter.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ function Write-DscTrace {
if ($PSVersionTable.PSVersion.Major -gt 5) {
$m = Get-Module PSDesiredStateConfiguration -ListAvailable | Sort-Object -Descending | Select-Object -First 1
$PSDesiredStateConfiguration = Import-Module $m -Force -PassThru
}
else {
} else {
$env:PSModulePath = "$env:windir\System32\WindowsPowerShell\v1.0\Modules;$env:PSModulePath"
$PSDesiredStateConfiguration = Import-Module -Name 'PSDesiredStateConfiguration' -RequiredVersion '1.1' -Force -PassThru -ErrorAction stop -ErrorVariable $importModuleError
if (-not [string]::IsNullOrEmpty($importModuleError)) {
Expand Down Expand Up @@ -63,16 +62,14 @@ function Invoke-DscCacheRefresh {
if ($cache.CacheSchemaVersion -ne $script:CurrentCacheSchemaVersion) {
$refreshCache = $true
"Incompatible version of cache in file '" + $cache.CacheSchemaVersion + "' (expected '" + $script:CurrentCacheSchemaVersion + "')" | Write-DscTrace
}
else {
} else {
$dscResourceCacheEntries = $cache.ResourceCache

if ($dscResourceCacheEntries.Count -eq 0) {
# if there is nothing in the cache file - refresh cache
$refreshCache = $true
"Filtered DscResourceCache cache is empty" | Write-DscTrace
}
else {
} else {
"Checking cache for stale PSModulePath" | Write-DscTrace

$m = $env:PSModulePath -split [IO.Path]::PathSeparator | ForEach-Object { Get-ChildItem -Directory -Path $_ -Depth 1 -ErrorAction Ignore }
Expand Down Expand Up @@ -104,8 +101,7 @@ function Invoke-DscCacheRefresh {
$namedModules.Add($cacheEntry.DscResourceInfo.ModuleName)
break
}
}
else {
} else {
"Detected non-existent cache entry '$($_.Name)'" | Write-DscTrace
$namedModules.Add($cacheEntry.DscResourceInfo.ModuleName)
break
Expand All @@ -123,8 +119,7 @@ function Invoke-DscCacheRefresh {
}
}
}
}
else {
} else {
"Cache file not found '$cacheFilePath'" | Write-DscTrace
$refreshCache = $true
}
Expand Down Expand Up @@ -159,8 +154,7 @@ function Invoke-DscCacheRefresh {
$filteredResources += $dscResources | Where-Object -Property ModuleName -NE $null | ForEach-Object { [System.String]::Concat($_.ModuleName, '/', $_.Name) }
# Exclude the one module that was passed in as a parameter
$existingDscResourceCacheEntries = @($cache.ResourceCache | Where-Object -Property Type -NotIn $filteredResources)
}
else {
} else {
# if no module is specified, get all resources
$DscResources = Get-DscResource
$Modules = Get-Module -ListAvailable
Expand Down Expand Up @@ -194,16 +188,14 @@ function Invoke-DscCacheRefresh {
$dscResource.PSObject.Properties | ForEach-Object -Process {
if ($null -ne $_.Value) {
$DscResourceInfo.$($_.Name) = $_.Value
}
else {
} else {
$DscResourceInfo.$($_.Name) = ''
}
}

if ($dscResource.ModuleName) {
$moduleName = $dscResource.ModuleName
}
elseif ($binaryBuiltInModulePaths -contains $dscResource.ParentPath) {
} elseif ($binaryBuiltInModulePaths -contains $dscResource.ParentPath) {
$moduleName = 'PSDesiredStateConfiguration'
$DscResourceInfo.Module = 'PSDesiredStateConfiguration'
$DscResourceInfo.ModuleName = 'PSDesiredStateConfiguration'
Expand All @@ -212,8 +204,7 @@ function Invoke-DscCacheRefresh {
if ($PSVersionTable.PSVersion.Major -le 5 -and $DscResourceInfo.ImplementedAs -eq 'Binary') {
$DscResourceInfo.ImplementationDetail = 'Binary'
}
}
elseif ($binaryBuiltInModulePaths -notcontains $dscResource.ParentPath -and $null -ne $dscResource.ParentPath) {
} elseif ($binaryBuiltInModulePaths -notcontains $dscResource.ParentPath -and $null -ne $dscResource.ParentPath) {
# workaround: populate module name from parent path that is three levels up
$moduleName = Split-Path $dscResource.ParentPath | Split-Path | Split-Path -Leaf
$DscResourceInfo.Module = $moduleName
Expand All @@ -230,11 +221,15 @@ function Invoke-DscCacheRefresh {
if ($classBased -and ($classBased.CustomAttributes.AttributeType.Name -eq 'DscResourceAttribute')) {
"Detected class-based resource: $($dscResource.Name) => Type: $($classBased.BaseType.FullName)" | Write-DscTrace
$dscResourceInfo.ImplementationDetail = 'ClassBased'
$properties = GetClassBasedProperties -filePath $dscResource.Path -className $dscResource.Name
if ($null -ne $properties) {
$DscResourceInfo.Properties = $properties
}
}

# fill in resource files (and their last-write-times) that will be used for up-do-date checks
$lastWriteTimes = @{}
Get-ChildItem -Recurse -File -Path $dscResource.ParentPath -Include "*.ps1", "*.psd1", "*.psm1", "*.mof" -ea Ignore | % {
Get-ChildItem -Recurse -File -Path $dscResource.ParentPath -Include "*.ps1", "*.psd1", "*.psm1", "*.mof" -ea Ignore | ForEach-Object {
$lastWriteTimes.Add($_.FullName, $_.LastWriteTime.ToFileTime())
}

Expand All @@ -254,7 +249,7 @@ function Invoke-DscCacheRefresh {

[dscResourceCache]$cache = [dscResourceCache]::new()
$cache.ResourceCache = $dscResourceCacheEntries.ToArray()
$m = $env:PSModulePath -split [IO.Path]::PathSeparator | % { Get-ChildItem -Directory -Path $_ -Depth 1 -ea SilentlyContinue }
$m = $env:PSModulePath -split [IO.Path]::PathSeparator | ForEach-Object { Get-ChildItem -Directory -Path $_ -Depth 1 -ea SilentlyContinue }
$cache.PSModulePaths = $m.FullName
$cache.CacheSchemaVersion = $script:CurrentCacheSchemaVersion

Expand Down Expand Up @@ -361,12 +356,10 @@ function Invoke-DscOperation {
exit 1
}
$property.$($_.Name) = [System.Management.Automation.PSCredential]::new($_.Value.Username, (ConvertTo-SecureString -AsPlainText $_.Value.Password -Force))
}
else {
} else {
$property.$($_.Name) = $_.Value.psobject.properties | ForEach-Object -Begin { $propertyHash = @{} } -Process { $propertyHash[$_.Name] = $_.Value } -End { $propertyHash }
}
}
else {
} else {
$property[$_.Name] = $_.Value
}
}
Expand All @@ -378,16 +371,14 @@ function Invoke-DscOperation {

if ($invokeResult.GetType().Name -eq 'Hashtable') {
$invokeResult.keys | ForEach-Object -Begin { $ResultProperties = @{} } -Process { $ResultProperties[$_] = $invokeResult.$_ }
}
else {
} else {
# the object returned by WMI is a CIM instance with a lot of additional data. only return DSC properties
$invokeResult.psobject.Properties.name | Where-Object { 'CimClass', 'CimInstanceProperties', 'CimSystemProperties' -notcontains $_ } | ForEach-Object -Begin { $ResultProperties = @{} } -Process { $ResultProperties[$_] = $invokeResult.$_ }
}

# set the properties of the OUTPUT object from the result of Get-TargetResource
$addToActualState.properties = $ResultProperties
}
catch {
} catch {
$_.Exception | Format-List * -Force | Out-String | Write-DscTrace -Operation Debug
if ($_.Exception.MessageId -eq 'DscResourceNotFound') {
Write-DscTrace -Operation Warn -Message 'For Windows PowerShell, DSC resources must be installed with scope AllUsers'
Expand Down Expand Up @@ -418,12 +409,10 @@ function Invoke-DscOperation {
exit 1
}
$dscResourceInstance.$($_.Name) = [System.Management.Automation.PSCredential]::new($_.Value.Username, (ConvertTo-SecureString -AsPlainText $_.Value.Password -Force))
}
else {
} else {
$dscResourceInstance.$($_.Name) = $_.Value.psobject.properties | ForEach-Object -Begin { $propertyHash = @{} } -Process { $propertyHash[$_.Name] = $_.Value } -End { $propertyHash }
}
}
else {
} else {
$dscResourceInstance.$($_.Name) = $_.Value
}
}
Expand All @@ -436,8 +425,7 @@ function Invoke-DscOperation {
$ValidProperties | ForEach-Object {
if ($raw_obj.$_ -is [System.Enum]) {
$Result[$_] = $raw_obj.$_.ToString()
}
else {
} else {
$Result[$_] = $raw_obj.$_
}
}
Expand All @@ -459,8 +447,7 @@ function Invoke-DscOperation {
$ValidProperties | ForEach-Object {
if ($raw_obj.$_ -is [System.Enum]) {
$Result_obj[$_] = $raw_obj.$_.ToString()
}
else {
} else {
$Result_obj[$_] = $raw_obj.$_
}
}
Expand All @@ -469,8 +456,7 @@ function Invoke-DscOperation {
$addToActualState = $resultArray
}
}
}
catch {
} catch {
$_.Exception | Format-List * -Force | Out-String | Write-DscTrace -Operation Debug
if ($_.Exception.MessageId -eq 'DscResourceNotFound') {
Write-DscTrace -Operation Warn -Message 'For Windows PowerShell, DSC resources must be installed with scope AllUsers'
Expand Down Expand Up @@ -500,12 +486,10 @@ function Invoke-DscOperation {
exit 1
}
$property.$($_.Name) = [System.Management.Automation.PSCredential]::new($_.Value.Username, (ConvertTo-SecureString -AsPlainText $_.Value.Password -Force))
}
else {
} else {
$property.$($_.Name) = $_.Value.psobject.properties | ForEach-Object -Begin { $propertyHash = @{} } -Process { $propertyHash[$_.Name] = $_.Value } -End { $propertyHash }
}
}
else {
} else {
$property[$_.Name] = $_.Value
}
}
Expand All @@ -516,16 +500,14 @@ function Invoke-DscOperation {
$invokeResult = Invoke-DscResource -Method $Operation -ModuleName $cachedDscResourceInfo.ModuleName -Name $cachedDscResourceInfo.Name -Property $property
if ($invokeResult.GetType().Name -eq 'Hashtable') {
$invokeResult.keys | ForEach-Object -Begin { $ResultProperties = @{} } -Process { $ResultProperties[$_] = $invokeResult.$_ }
}
else {
} else {
# the object returned by WMI is a CIM instance with a lot of additional data. only return DSC properties
$invokeResult.psobject.Properties.name | Where-Object { 'CimClass', 'CimInstanceProperties', 'CimSystemProperties' -notcontains $_ } | ForEach-Object -Begin { $ResultProperties = @{} } -Process { $ResultProperties[$_] = $invokeResult.$_ }
}

# set the properties of the OUTPUT object from the result of Get-TargetResource
$addToActualState.properties = $ResultProperties
}
catch {
} catch {
'Exception: ' + $_.Exception.Message | Write-DscTrace -Operation Error
exit 1
}
Expand All @@ -537,8 +519,7 @@ function Invoke-DscOperation {
}

return $addToActualState
}
else {
} else {
$dsJSON = $DesiredState | ConvertTo-Json -Depth 10
'Can not find type "' + $DesiredState.type + '" for resource "' + $dsJSON + '". Please ensure that Get-DscResource returns this resource type.' | Write-DscTrace -Operation Error
exit 1
Expand Down Expand Up @@ -584,6 +565,72 @@ function ValidateMethod {
return $method
}

function GetClassBasedProperties {
param (
[Parameter(Mandatory = $true)]
[string] $filePath,

[Parameter(Mandatory = $true)]
[string] $className
)

if (".psd1" -notcontains ([System.IO.Path]::GetExtension($filePath))) {
return @('get', 'set', 'test')
}

$module = Import-Module $filePath -PassThru -Force -ErrorAction Ignore

$properties = [System.Collections.Generic.List[DscResourcePropertyInfo]]::new()

if (Test-Path $module.Path -ErrorAction Ignore) {
[System.Management.Automation.Language.Token[]] $tokens = $null
[System.Management.Automation.Language.ParseError[]] $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($module.Path, [ref]$tokens, [ref]$errors)
foreach ($e in $errors) {
$e | Out-String | Write-DscTrace -Operation Warn
}

$typeDefinitions = $ast.FindAll(
{
$typeAst = $args[0] -as [System.Management.Automation.Language.TypeDefinitionAst]
return $null -ne $typeAst;
},
$false);

$typeDefinition = $typeDefinitions | Where-Object -Property Name -EQ $className

foreach ($member in $typeDefinition.Members) {
$property = $member -as [System.Management.Automation.Language.PropertyMemberAst]
if (($null -eq $property) -or ($property.IsStatic)) {
continue;
}
$skipProperty = $true
$isKeyProperty = $false
foreach ($attr in $property.Attributes) {
if ($attr.TypeName.Name -eq 'DscProperty') {
$skipProperty = $false
foreach ($attrArg in $attr.NamedArguments) {
if ($attrArg.ArgumentName -eq 'Key') {
$isKeyProperty = $true
break
}
}
}
}
if ($skipProperty) {
continue;
}

[DscResourcePropertyInfo]$prop = [DscResourcePropertyInfo]::new()
$prop.Name = $property.Name
$prop.PropertyType = $property.PropertyType.TypeName.Name
$prop.IsMandatory = $isKeyProperty
$properties.Add($prop)
}
return $properties
}
}

# cached resource
class dscResourceCacheEntry {
[string] $Type
Expand Down Expand Up @@ -612,6 +659,13 @@ enum dscResourceType {
Composite
}

class DscResourcePropertyInfo {
[string] $Name
[string] $PropertyType
[bool] $IsMandatory
[System.Collections.Generic.List[string]] $Values
}

# dsc resource type (settable clone)
class DscResourceInfo {
[dscResourceType] $ImplementationDetail
Expand Down
Loading