diff --git a/build.ps1 b/build.ps1 index 4e5ba9c5..a69cb635 100644 --- a/build.ps1 +++ b/build.ps1 @@ -324,10 +324,13 @@ if (!$Clippy -and !$SkipBuild) { if ($Test) { $failed = $false - $FullyQualifiedName = @{ModuleName="PSDesiredStateConfiguration";ModuleVersion="2.0.7"} - if (-not(Get-Module -ListAvailable -FullyQualifiedName $FullyQualifiedName)) - { "Installing module PSDesiredStateConfiguration 2.0.7" - Install-PSResource -Name PSDesiredStateConfiguration -Version 2.0.7 -Repository PSGallery -TrustRepository + if ($IsWindows) { + # PSDesiredStateConfiguration module is needed for Microsoft.Windows/WindowsPowerShell adapter + $FullyQualifiedName = @{ModuleName="PSDesiredStateConfiguration";ModuleVersion="2.0.7"} + if (-not(Get-Module -ListAvailable -FullyQualifiedName $FullyQualifiedName)) + { "Installing module PSDesiredStateConfiguration 2.0.7" + Install-PSResource -Name PSDesiredStateConfiguration -Version 2.0.7 -Repository PSGallery -TrustRepository + } } if (-not(Get-Module -ListAvailable -Name Pester)) diff --git a/powershell-adapter/Tests/powershellgroup.config.tests.ps1 b/powershell-adapter/Tests/powershellgroup.config.tests.ps1 index 269b6d4e..cd152d69 100644 --- a/powershell-adapter/Tests/powershellgroup.config.tests.ps1 +++ b/powershell-adapter/Tests/powershellgroup.config.tests.ps1 @@ -24,7 +24,7 @@ Describe 'PowerShell adapter resource tests' { Remove-Item -Force -ea SilentlyContinue -Path $cacheFilePath } - It 'Get works on config with class-based resources' -Skip:(!$IsWindows){ + It 'Get works on config with class-based resources' { $r = Get-Content -Raw $pwshConfigPath | dsc config get $LASTEXITCODE | Should -Be 0 @@ -33,7 +33,7 @@ Describe 'PowerShell adapter resource tests' { $res.results[0].result.actualState.result[0].properties.EnumProp | Should -BeExactly 'Expected' } - It 'Test works on config with class-based resources' -Skip:(!$IsWindows){ + It 'Test works on config with class-based resources' { $r = Get-Content -Raw $pwshConfigPath | dsc config test $LASTEXITCODE | Should -Be 0 @@ -41,7 +41,7 @@ Describe 'PowerShell adapter resource tests' { $res.results[0].result.actualState.result[0] | Should -Not -BeNull } - It 'Set works on config with class-based resources' -Skip:(!$IsWindows){ + It 'Set works on config with class-based resources' { $r = Get-Content -Raw $pwshConfigPath | dsc config set $LASTEXITCODE | Should -Be 0 @@ -49,7 +49,7 @@ Describe 'PowerShell adapter resource tests' { $res.results.result.afterState.result[0].type | Should -Be "TestClassResource/TestClassResource" } - It 'Export works on config with class-based resources' -Skip:(!$IsWindows){ + It 'Export works on config with class-based resources' { $yaml = @' $schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json @@ -71,20 +71,21 @@ Describe 'PowerShell adapter resource tests' { $res.resources[0].properties.result[0].Prop1 | Should -Be "Property of object1" } - It 'Custom psmodulepath in config works' -Skip:(!$IsWindows){ + It 'Custom psmodulepath in config works' { $OldPSModulePath = $env:PSModulePath Copy-Item -Recurse -Force -Path "$PSScriptRoot/TestClassResource" -Destination $TestDrive Rename-Item -Path "$PSScriptRoot/TestClassResource" -NewName "_TestClassResource" try { + $psmp = "`$env:PSModulePath"+[System.IO.Path]::PathSeparator+$TestDrive $yaml = @" `$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json resources: - name: Working with class-based resources type: Microsoft.DSC/PowerShell properties: - psmodulepath: `$env:PSModulePath;$TestDrive + psmodulepath: $psmp resources: - name: Class-resource Info type: TestClassResource/TestClassResource @@ -104,7 +105,7 @@ Describe 'PowerShell adapter resource tests' { } } - It 'DSCConfigRoot macro is working when config is from a file' -Skip:(!$IsWindows){ + It 'DSCConfigRoot macro is working when config is from a file' { $yaml = @" `$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json @@ -129,7 +130,7 @@ Describe 'PowerShell adapter resource tests' { $res.results.result.actualState.result.properties.Prop1 | Should -Be $TestDrive } - It 'DSC_CONFIG_ROOT env var is cwd when config is piped from stdin' -Skip:(!$IsWindows){ + It 'DSC_CONFIG_ROOT env var is cwd when config is piped from stdin' { $yaml = @" `$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json diff --git a/powershell-adapter/Tests/powershellgroup.resource.tests.ps1 b/powershell-adapter/Tests/powershellgroup.resource.tests.ps1 index 5dd3dcb1..edf83910 100644 --- a/powershell-adapter/Tests/powershellgroup.resource.tests.ps1 +++ b/powershell-adapter/Tests/powershellgroup.resource.tests.ps1 @@ -23,16 +23,15 @@ Describe 'PowerShell adapter resource tests' { Remove-Item -Force -ea SilentlyContinue -Path $cacheFilePath } - It 'Discovery includes class-based and script-based resources ' -Skip:(!$IsWindows){ + It 'Discovery includes class-based resources' { - $r = dsc resource list * -a Microsoft.DSC/PowerShell + $r = dsc resource list '*' -a Microsoft.DSC/PowerShell $LASTEXITCODE | Should -Be 0 $resources = $r | ConvertFrom-Json ($resources | ? {$_.Type -eq 'TestClassResource/TestClassResource'}).Count | Should -Be 1 - ($resources | ? {$_.Type -eq 'PSTestModule/TestPSRepository'}).Count | Should -Be 1 } - It 'Get works on class-based resource' -Skip:(!$IsWindows){ + It 'Get works on class-based resource' { $r = "{'Name':'TestClassResource1'}" | dsc resource get -r 'TestClassResource/TestClassResource' $LASTEXITCODE | Should -Be 0 @@ -40,7 +39,7 @@ Describe 'PowerShell adapter resource tests' { $res.actualState.result.properties.Prop1 | Should -BeExactly 'ValueForProp1' } - It 'Get uses enum names on class-based resource' -Skip:(!$IsWindows){ + It 'Get uses enum names on class-based resource' { $r = "{'Name':'TestClassResource1'}" | dsc resource get -r 'TestClassResource/TestClassResource' $LASTEXITCODE | Should -Be 0 @@ -48,7 +47,7 @@ Describe 'PowerShell adapter resource tests' { $res.actualState.result.properties.EnumProp | Should -BeExactly 'Expected' } - It 'Test works on class-based resource' -Skip:(!$IsWindows){ + It 'Test works on class-based resource' { $r = "{'Name':'TestClassResource1','Prop1':'ValueForProp1'}" | dsc resource test -r 'TestClassResource/TestClassResource' $LASTEXITCODE | Should -Be 0 @@ -56,7 +55,7 @@ Describe 'PowerShell adapter resource tests' { $res.actualState.result.properties.InDesiredState | Should -Be $True } - It 'Set works on class-based resource' -Skip:(!$IsWindows){ + It 'Set works on class-based resource' { $r = "{'Name':'TestClassResource1','Prop1':'ValueForProp1'}" | dsc resource set -r 'TestClassResource/TestClassResource' $LASTEXITCODE | Should -Be 0 @@ -64,7 +63,7 @@ Describe 'PowerShell adapter resource tests' { $res.afterState.result | Should -Not -BeNull } - It 'Export works on PS class-based resource' -Skip:(!$IsWindows){ + It 'Export works on PS class-based resource' { $r = dsc resource export -r TestClassResource/TestClassResource $LASTEXITCODE | Should -Be 0 @@ -74,7 +73,7 @@ Describe 'PowerShell adapter resource tests' { $res.resources[0].properties.result[0].Prop1 | Should -Be "Property of object1" } - It 'Get --all works on PS class-based resource' -Skip:(!$IsWindows){ + It 'Get --all works on PS class-based resource' { $r = dsc resource get --all -r TestClassResource/TestClassResource $LASTEXITCODE | Should -Be 0 diff --git a/powershell-adapter/psDscAdapter/powershell.resource.ps1 b/powershell-adapter/psDscAdapter/powershell.resource.ps1 index 2cb8d99c..6a9f6bce 100644 --- a/powershell-adapter/psDscAdapter/powershell.resource.ps1 +++ b/powershell-adapter/psDscAdapter/powershell.resource.ps1 @@ -9,6 +9,24 @@ param( [string]$jsonInput = '@{}' ) +function Write-DscTrace { + param( + [Parameter(Mandatory = $false)] + [ValidateSet('Error', 'Warn', 'Info', 'Debug', 'Trace')] + [string]$Operation = 'Debug', + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string]$Message + ) + + $trace = @{$Operation = $Message } | ConvertTo-Json -Compress + $host.ui.WriteErrorLine($trace) +} + +# Adding some debug info to STDERR +'PSVersion=' + $PSVersionTable.PSVersion.ToString() | Write-DscTrace +'PSPath=' + $PSHome | Write-DscTrace +'PSModulePath=' + $env:PSModulePath | Write-DscTrace + if ('Validate' -ne $Operation) { # write $jsonInput to STDERR for debugging $trace = @{'Debug' = 'jsonInput=' + $jsonInput } | ConvertTo-Json -Compress @@ -21,14 +39,15 @@ if ('Validate' -ne $Operation) { else { $psDscAdapter = Import-Module "$PSScriptRoot/psDscAdapter.psd1" -Force -PassThru } - # initialize OUTPUT as array $result = [System.Collections.Generic.List[Object]]::new() } if ($jsonInput) { - $inputobj_pscustomobj = $jsonInput | ConvertFrom-Json + if ($jsonInput -ne '@{}') { + $inputobj_pscustomobj = $jsonInput | ConvertFrom-Json + } $new_psmodulepath = $inputobj_pscustomobj.psmodulepath if ($new_psmodulepath) { @@ -48,6 +67,7 @@ switch ($Operation) { $DscResourceInfo = $dscResource.DscResourceInfo # Provide a way for existing resources to specify their capabilities, or default to Get, Set, Test + # TODO: for perf, it is better to take capabilities from psd1 in Invoke-DscCacheRefresh, not by extra call to Get-Module if ($DscResourceInfo.ModuleName) { $module = Get-Module -Name $DscResourceInfo.ModuleName -ListAvailable | Sort-Object -Property Version -Descending | Select-Object -First 1 if ($module.PrivateData.PSData.DscCapabilities) { @@ -82,7 +102,7 @@ switch ($Operation) { [resourceOutput]@{ type = $dscResource.Type kind = 'Resource' - version = $DscResourceInfo.version.ToString() + version = [string]$DscResourceInfo.version capabilities = $capabilities path = $DscResourceInfo.Path directory = $DscResourceInfo.ParentPath @@ -159,14 +179,3 @@ class resourceOutput { [string] $requireAdapter [string] $description } - -# Adding some debug info to STDERR -$trace = @{'Debug' = 'PSVersion=' + $PSVersionTable.PSVersion.ToString() } | ConvertTo-Json -Compress -$host.ui.WriteErrorLine($trace) -$trace = @{'Debug' = 'PSPath=' + $PSHome } | ConvertTo-Json -Compress -$host.ui.WriteErrorLine($trace) -$m = Get-Command 'Get-DscResource' -$trace = @{'Debug' = 'Module=' + $m.Source.ToString() } | ConvertTo-Json -Compress -$host.ui.WriteErrorLine($trace) -$trace = @{'Debug' = 'PSModulePath=' + $env:PSModulePath } | ConvertTo-Json -Compress -$host.ui.WriteErrorLine($trace) \ No newline at end of file diff --git a/powershell-adapter/psDscAdapter/psDscAdapter.psm1 b/powershell-adapter/psDscAdapter/psDscAdapter.psm1 index 6bd234f2..a4e5824e 100644 --- a/powershell-adapter/psDscAdapter/psDscAdapter.psm1 +++ b/powershell-adapter/psDscAdapter/psDscAdapter.psm1 @@ -20,6 +20,170 @@ function Import-PSDSCModule { $PSDesiredStateConfiguration = Import-Module $m -Force -PassThru } +function Get-DSCResourceModules +{ + $listPSModuleFolders = $env:PSModulePath.Split([IO.Path]::PathSeparator) + $dscModulePsd1List = [System.Collections.Generic.HashSet[System.String]]::new() + foreach ($folder in $listPSModuleFolders) + { + if (!(Test-Path $folder)) + { + continue + } + + foreach($moduleFolder in Get-ChildItem $folder -Directory) + { + $addModule = $false + foreach($psd1 in Get-ChildItem -Recurse -Filter "$($moduleFolder.Name).psd1" -Path $moduleFolder.fullname -Depth 2) + { + $containsDSCResource = select-string -LiteralPath $psd1 -pattern '^[^#]*\bDscResourcesToExport\b.*' + if($null -ne $containsDSCResource) + { + $dscModulePsd1List.Add($psd1) | Out-Null + break + } + } + } + } + + return $dscModulePsd1List +} + +function FindAndParseResourceDefinitions +{ + [CmdletBinding(HelpUri = '')] + param( + [Parameter(Mandatory = $true)] + [string]$filePath + ) + + if (-not (Test-Path $filePath)) + { + return + } + + if (([System.IO.Path]::GetExtension($filePath) -ne ".psm1") -and ([System.IO.Path]::GetExtension($filePath) -ne ".ps1")) + { + return + } + + "Loading resources from '$filePath'" | Write-DscTrace -Operation Trace + #TODO: Handle class inheritance + #TODO: Ensure embedded instances in properties are working correctly + [System.Management.Automation.Language.Token[]] $tokens = $null + [System.Management.Automation.Language.ParseError[]] $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($filePath, [ref]$tokens, [ref]$errors) + foreach($e in $errors) + { + $e | Out-String | Write-DscTrace -Operation Error + } + + $resourceDefinitions = $ast.FindAll( + { + $typeAst = $args[0] -as [System.Management.Automation.Language.TypeDefinitionAst] + if ($typeAst) + { + foreach($a in $typeAst.Attributes) + { + if ($a.TypeName.Name -eq 'DscResource') + { + return $true; + } + } + } + + return $false; + }, + $false); + + $resourceList = [System.Collections.Generic.List[DscResourceInfo]]::new() + + foreach($typeDefinitionAst in $resourceDefinitions) + { + $DscResourceInfo = [DscResourceInfo]::new() + $DscResourceInfo.Name = $typeDefinitionAst.Name + $DscResourceInfo.ResourceType = $typeDefinitionAst.Name + $DscResourceInfo.FriendlyName = $typeDefinitionAst.Name + $DscResourceInfo.ImplementationDetail = 'ClassBased' + $DscResourceInfo.Module = $filePath + $DscResourceInfo.Path = $filePath + #TODO: ModuleName, Version and ParentPath should be taken from psd1 contents + $DscResourceInfo.ModuleName = [System.IO.Path]::GetFileNameWithoutExtension($filePath) + $DscResourceInfo.ParentPath = [System.IO.Path]::GetDirectoryName($filePath) + + $DscResourceInfo.Properties = [System.Collections.Generic.List[DscResourcePropertyInfo]]::new() + foreach ($member in $typeDefinitionAst.Members) + { + $property = $member -as [System.Management.Automation.Language.PropertyMemberAst] + if (($property -eq $null) -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 + } + } + } + } + if ($skipProperty) + { + continue; + } + + [DscResourcePropertyInfo]$prop = [DscResourcePropertyInfo]::new() + $prop.Name = $property.Name + $prop.PropertyType = $property.PropertyType.TypeName.Name + $prop.IsMandatory = $isKeyProperty + $DscResourceInfo.Properties.Add($prop) + } + + $resourceList.Add($DscResourceInfo) + } + + return $resourceList +} + +function LoadPowerShellClassResourcesFromModule +{ + [CmdletBinding(HelpUri = '')] + param( + [Parameter(Mandatory = $true)] + [PSModuleInfo]$moduleInfo + ) + + if ($moduleInfo.RootModule) + { + $scriptPath = Join-Path $moduleInfo.ModuleBase $moduleInfo.RootModule + } + else + { + $scriptPath = $moduleInfo.Path; + } + + $Resources = FindAndParseResourceDefinitions $scriptPath + + if ($moduleInfo.NestedModules) + { + foreach ($nestedModule in $moduleInfo.NestedModules) + { + $resourcesOfNestedModules = LoadPowerShellClassResourcesFromModule $nestedModule + $Resources.AddRange($resourcesOfNestedModules) + } + } + + return $Resources +} + <# public function Invoke-DscCacheRefresh .SYNOPSIS This function caches the results of the Get-DscResource call to optimize performance. @@ -46,12 +210,8 @@ function Invoke-DscCacheRefresh { # PS 6+ on Windows Join-Path $env:LocalAppData "dsc\PSAdapterCache.json" } else { - # either WinPS or PS 6+ on Linux/Mac - if ($PSVersionTable.PSVersion.Major -le 5) { - Join-Path $env:LocalAppData "dsc\WindowsPSAdapterCache.json" - } else { - Join-Path $env:HOME ".dsc" "PSAdapterCache.json" - } + # PS 6+ on Linux/Mac + Join-Path $env:HOME ".dsc" "PSAdapterCache.json" } if (Test-Path $cacheFilePath) { @@ -113,33 +273,18 @@ function Invoke-DscCacheRefresh { # create a list object to store cache of Get-DscResource [dscResourceCacheEntry[]]$dscResourceCacheEntries = [System.Collections.Generic.List[Object]]::new() - Import-PSDSCModule - $DscResources = Get-DscResource - - foreach ($dscResource in $DscResources) { - # resources that shipped in Windows should only be used with Windows PowerShell - if ($dscResource.ParentPath -like "$env:windir\System32\*" -and $PSVersionTable.PSVersion.Major -gt 5) { - continue - } - - if ( $dscResource.ImplementationDetail ) { - # only support known dscResourceType - if ([dscResourceType].GetEnumNames() -notcontains $dscResource.ImplementationDetail) { - 'WARNING: implementation detail not found: ' + $dscResource.ImplementationDetail | Write-DscTrace - continue - } - } - - $DscResourceInfo = [DscResourceInfo]::new() - $dscResource.PSObject.Properties | ForEach-Object -Process { - if ($null -ne $_.Value) { - $DscResourceInfo.$($_.Name) = $_.Value - } - else { - $DscResourceInfo.$($_.Name) = '' - } + $DscResources = [System.Collections.Generic.List[DscResourceInfo]]::new() + $dscResourceModulePsd1s = Get-DSCResourceModules + if($null -ne $dscResourceModulePsd1s) { + $modules = Get-Module -ListAvailable -Name ($dscResourceModulePsd1s) + foreach ($mod in $modules) + { + [System.Collections.Generic.List[DscResourceInfo]]$r = LoadPowerShellClassResourcesFromModule -moduleInfo $mod + $DscResources.AddRange($r) } + } + foreach ($dscResource in $DscResources) { $moduleName = $dscResource.ModuleName # fill in resource files (and their last-write-times) that will be used for up-do-date checks @@ -150,7 +295,7 @@ function Invoke-DscCacheRefresh { $dscResourceCacheEntries += [dscResourceCacheEntry]@{ Type = "$moduleName/$($dscResource.Name)" - DscResourceInfo = $DscResourceInfo + DscResourceInfo = $dscResource LastWriteTimes = $lastWriteTimes } } @@ -342,6 +487,14 @@ 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 @@ -355,5 +508,5 @@ class DscResourceInfo { [string] $ParentPath [string] $ImplementedAs [string] $CompanyName - [psobject[]] $Properties + [System.Collections.Generic.List[DscResourcePropertyInfo]] $Properties }