diff --git a/wmi-adapter/Tests/wmi.tests.ps1 b/wmi-adapter/Tests/wmi.tests.ps1 index 4b697083a..555ce4552 100644 --- a/wmi-adapter/Tests/wmi.tests.ps1 +++ b/wmi-adapter/Tests/wmi.tests.ps1 @@ -52,10 +52,60 @@ Describe 'WMI adapter resource tests' { $LASTEXITCODE | Should -Be 0 $r | Should -Not -BeNullOrEmpty $res = $r | ConvertFrom-Json + $res.results[1].result.actualState.result[0].properties.Name | Should -Not -BeNullOrEmpty $res.results[1].result.actualState.result[0].properties.BootupState | Should -BeNullOrEmpty $res.results[1].result.actualState.result[1].properties.Caption | Should -Not -BeNullOrEmpty $res.results[1].result.actualState.result[1].properties.BuildNumber | Should -BeNullOrEmpty - $res.results[1].result.actualState.result[4].properties.AdapterType | Should -BeLike "Ethernet*" + $res.results[1].result.actualState.result[4].properties.Name | Should -Not -BeNullOrEmpty + } + + It 'Set does not work without input for resource' -Skip:(!$IsWindows) { + $out = dsc resource set --resource root.cimv2/Win32_Environment --input '{}' 2>$TestDrive/error.log + $out | Should -BeNullOrEmpty + (Get-Content $TestDrive/error.log -Raw) | Should -BeLike "*No valid properties found in the CIM class 'Win32_Environment' for the provided properties.*" + } + + It 'Set does not work without a key property' -Skip:(!$IsWindows) { + $i = @{ + VariableValue = "TestValue" + UserName = ("{0}\{1}" -f $env:USERDOMAIN, $env:USERNAME) # Read-only property is key, but we require a key property to be set + } | ConvertTo-Json + + $out = dsc resource set -r root.cimv2/Win32_Environment -i $i 2>$TestDrive/error2.log + $out | Should -BeNullOrEmpty + (Get-Content $TestDrive/error2.log -Raw) | Should -BeLike "*All properties specified in the CIM class 'Win32_Environment' are read-only, which is not supported.*" + } + + It 'Set works on a WMI resource' -Skip:(!$IsWindows) { + $i = @{ + UserName = ("{0}\{1}" -f $env:USERDOMAIN, $env:USERNAME) # Read-only key property required + Name = 'test' + VariableValue = 'test' + } | ConvertTo-Json + + $r = dsc resource set -r root.cimv2/Win32_Environment -i $i + $LASTEXITCODE | Should -Be 0 + + $res = $r | ConvertFrom-Json + $res.afterState.Name | Should -Be 'test' + $res.afterState.VariableValue | Should -Be 'test' + $res.afterState.UserName | Should -Be ("{0}\{1}" -f $env:USERDOMAIN, $env:USERNAME) + } + + It 'Update works on a WMI resource' -Skip:(!$IsWindows) { + $i = @{ + UserName = ("{0}\{1}" -f $env:USERDOMAIN, $env:USERNAME) # Read-only key property required + Name = 'test' + VariableValue = 'update' + } | ConvertTo-Json + + $r = dsc resource set -r root.cimv2/Win32_Environment -i $i + $LASTEXITCODE | Should -Be 0 + + $res = $r | ConvertFrom-Json + $res.afterState.Name | Should -Be 'test' + $res.afterState.VariableValue | Should -Be 'update' + $res.afterState.UserName | Should -Be ("{0}\{1}" -f $env:USERDOMAIN, $env:USERNAME) } } diff --git a/wmi-adapter/wmi.dsc.resource.json b/wmi-adapter/wmi.dsc.resource.json index ee80f7a59..d37ca2dd8 100644 --- a/wmi-adapter/wmi.dsc.resource.json +++ b/wmi-adapter/wmi.dsc.resource.json @@ -35,6 +35,20 @@ ], "input": "stdin" }, + "set": { + "executable": "powershell", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$Input | ./wmi.resource.ps1 Set" + ], + "input": "stdin", + "implementsPretest": false + }, "validate": { "executable": "powershell", "args": [ diff --git a/wmi-adapter/wmi.resource.ps1 b/wmi-adapter/wmi.resource.ps1 index 726b5d556..102db4d34 100644 --- a/wmi-adapter/wmi.resource.ps1 +++ b/wmi-adapter/wmi.resource.ps1 @@ -10,7 +10,7 @@ param( ) # Import private functions -$wmiAdapter = Import-Module "$PSScriptRoot/wmiAdapter.psm1" -Force -PassThru +$wmiAdapter = Import-Module "$PSScriptRoot\wmiAdapter.psm1" -Force -PassThru if ('Validate' -ne $Operation) { # initialize OUTPUT as array diff --git a/wmi-adapter/wmiAdapter.psm1 b/wmi-adapter/wmiAdapter.psm1 index 3b4919974..a45dc13f5 100644 --- a/wmi-adapter/wmiAdapter.psm1 +++ b/wmi-adapter/wmiAdapter.psm1 @@ -35,6 +35,189 @@ function Get-DscResourceObject { return $desiredState } +function GetValidCimProperties { + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [Microsoft.Management.Infrastructure.CimClass]$CimClass, + + [Parameter(Mandatory = $true)] + $ClassName, + + [Parameter()] + [object]$Properties, + + [Parameter()] + [switch] $SkipReadOnly, + + [Parameter()] + [switch] $ValidateKeyProperty + ) + + $availableProperties = $CimClass.CimClassProperties | Where-Object -Property Name -in $Properties.psobject.Properties.name + $validatedProperties = [System.Collections.Generic.List[Array]]::new() + + $keyProperties = $availableProperties | Where-Object {$_.Flags.Hasflag([Microsoft.Management.Infrastructure.CimFlags]::Key)} + + + if ($null -eq $availableProperties) { + "No valid properties found in the CIM class '$ClassName' for the provided properties." | Write-DscTrace -Operation Error + exit 1 + } + + if ($ValidateKeyProperty.IsPresent) { + # Check if any key property is also read-only + if ($keyProperties.Count -eq 0) { + "No key properties found in the CIM class '$ClassName'." | Write-DscTrace -Operation Error + exit 1 + } + $readOnlyKeyProps = $keyProperties | Where-Object { $_.Flags.HasFlag([Microsoft.Management.Infrastructure.CimFlags]::ReadOnly) } + + if ($readOnlyKeyProps.Count -eq $keyProperties.Count) { + "All properties specified in the CIM class '$ClassName' are read-only, which is not supported." | Write-DscTrace -Operation Error + exit 1 + } + } + + # Check if the provided properties match the available properties in the CIM class + # If the count of provided properties does not match the available properties, we log a warning but continue + if ($properties.psobject.Properties.name.count -ne $availableProperties.Count) { + $inputPropertyNames = $properties.psobject.Properties.Name + $availablePropertyNames = $availableProperties.Name + + $missingProperties = $inputPropertyNames | Where-Object { $_ -notin $availablePropertyNames } + if ($missingProperties) { + foreach ($missing in $missingProperties) { + "Property '$missing' was provided but not found in the CIM class '$($CimClass.ClassName)'." | Write-DscTrace -Operation Warn + } + } + } + + $validatedProperties.Add($availableProperties) + + if ($SkipReadOnly.IsPresent) { + $availableProperties = foreach ($prop in $availableProperties) { + [string[]]$flags = $prop.Flags.ToString().Split(",").Trim() + if ($null -ne $properties.$($prop.Name)) { + # Filter out read-only properties if SkipReadOnly is specified + if ($flags -notcontains 'ReadOnly') { + $prop + } + } else { + # Return $prop as if there is an empty value provided as property, we are not going to a WHERE clause + $prop + } + } + + return $availableProperties + } + + return $validatedProperties +} + +function BuildWmiQuery { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ClassName, + + [Parameter(Mandatory = $true)] + [array]$Properties, + + [Parameter(Mandatory = $true)] + [psobject]$DesiredStateProperties, + + [Parameter()] + [switch]$KeyPropertiesOnly + ) + + $targetProperties = if ($KeyPropertiesOnly.IsPresent) { + $Properties | Where-Object {$_.Flags.HasFlag([Microsoft.Management.Infrastructure.CimFlags]::Key)} + } else { + $Properties + } + + if ($targetProperties.Count -eq 0) { + return $null + } + + $query = "SELECT $($targetProperties.Name -join ',') FROM $ClassName" + $whereClause = " WHERE " + $useWhere = $false + $isFirst = $true + + foreach ($property in $targetProperties) { + if ($null -ne $DesiredStateProperties.$($property.Name)) { + $useWhere = $true + if ($isFirst) { + $isFirst = $false + } else { + $whereClause += " AND " + } + + if ($property.CimType -eq "String") { + $whereClause += "$($property.Name) = '$($DesiredStateProperties.$($property.Name))'" + } else { + $whereClause += "$($property.Name) = $($DesiredStateProperties.$($property.Name))" + } + } + } + + if ($useWhere) { + $query += $whereClause + } + + return $query +} + +function GetWmiInstance { + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [psobject]$DesiredState + ) + + $type_fields = $DesiredState.type -split "/" + $wmi_namespace = $type_fields[0].Replace('.', '\') + $wmi_classname = $type_fields[1] + + $class = Get-CimClass -Namespace $wmi_namespace -ClassName $wmi_classname -ErrorAction Stop + + if ($DesiredState.properties) { + $properties = GetValidCimProperties -CimClass $class -ClassName $wmi_classname -Properties $DesiredState.properties -SkipReadOnly + + $query = BuildWmiQuery -ClassName $wmi_classname -Properties $properties -DesiredStateProperties $DesiredState.properties + + if ($query) { + "Query: $query" | Write-DscTrace -Operation Debug + $wmi_instances = Get-CimInstance -Namespace $wmi_namespace -Query $query -ErrorAction Ignore -ErrorVariable err + + if ($null -eq $wmi_instances) { + "No WMI instances found using query '$query'. Retrying with key properties only." | Write-DscTrace -Operation Debug + $keyQuery = BuildWmiQuery -ClassName $wmi_classname -Properties $properties -DesiredStateProperties $DesiredState.properties -KeyPropertiesOnly + + if ($keyQuery) { + $wmi_instances = Get-CimInstance -Namespace $wmi_namespace -Query $keyQuery -ErrorAction Ignore -ErrorVariable err + if ($null -eq $wmi_instances) { + "No WMI instances found using key properties query '$keyQuery'." | Write-DscTrace -Operation Debug + } + } + } + } + } else { + $wmi_instances = Get-CimInstance -Namespace $wmi_namespace -ClassName $wmi_classname -ErrorAction Ignore -ErrorVariable Err + } + + if ($err) { + "Error retrieving WMI instances: $($err.Exception.Message)" | Write-DscTrace -Operation Error + exit 1 + } + + return $wmi_instances +} + function GetCimSpace { [CmdletBinding()] param @@ -58,47 +241,12 @@ function GetCimSpace { foreach ($r in $DesiredState) { - $type_fields = $r.type -split "/" - $wmi_namespace = $type_fields[0].Replace('.', '\') - $wmi_classname = $type_fields[1] - switch ($Operation) { 'Get' { - # TODO: identify key properties and add WHERE clause to the query - if ($r.properties) { - $query = "SELECT $($r.properties.psobject.properties.name -join ',') FROM $wmi_classname" - $where = " WHERE " - $useWhere = $false - $first = $true - foreach ($property in $r.properties.psobject.properties) { - # TODO: validate property against the CIM class to give better error message - if ($null -ne $property.value) { - $useWhere = $true - if ($first) { - $first = $false - } else { - $where += " AND " - } - - if ($property.TypeNameOfValue -eq "System.String") { - $where += "$($property.Name) = '$($property.Value)'" - } else { - $where += "$($property.Name) = $($property.Value)" - } - } - } - if ($useWhere) { - $query += $where - } - "Query: $query" | Write-DscTrace -Operation Debug - $wmi_instances = Get-CimInstance -Namespace $wmi_namespace -Query $query -ErrorAction Stop - } else { - $wmi_instances = Get-CimInstance -Namespace $wmi_namespace -ClassName $wmi_classname -ErrorAction Stop - } + $wmi_instances = GetWmiInstance -DesiredState $DesiredState if ($wmi_instances) { $instance_result = [ordered]@{} - # TODO: for a `Get`, they key property must be provided so a specific instance is returned rather than just the first $wmi_instance = $wmi_instances[0] # for 'Get' we return just first matching instance; for 'export' we return all instances $wmi_instance.psobject.properties | ForEach-Object { if (($_.Name -ne "type") -and (-not $_.Name.StartsWith("Cim"))) { @@ -113,17 +261,51 @@ function GetCimSpace { } $addToActualState.properties = $instance_result - + $result += $addToActualState + } else { + "No WMI instances found for type '$($r.type)'." | Write-DscTrace -Operation Debug + $addToActualState.properties = $null $result += $addToActualState } - } 'Set' { - # TODO: implement set + $wmi_instance = GetCimInstanceProperties -DesiredState $r + $properties = @{} + $wmi_instance.Properties | ForEach-Object { + if ($r.properties.psobject.properties.name -contains $_.Name) { + $properties[$_.Name] = $r.properties.$($_.Name) + } + } + + $readOnlyProperties = $wmi_instance.Properties | Where-Object {$_.Flags.HasFlag([Microsoft.Management.Infrastructure.CimFlags]::ReadOnly)} + + if ($null -eq $wmi_instance.CimInstance) { + $instance = New-CimInstance -Namespace $wmi_instance.Namespace -ClassName $wmi_instance.ClassName -Property $properties -ErrorAction Ignore -ErrorVariable err + } else { + # When calling Set-CimInstance, the read-only properties needs to be filtered out + if ($readOnlyProperties) { + foreach ($prop in $readOnlyProperties) { + if ($properties.ContainsKey($prop.Name)) { + $properties.Remove($prop.Name) | Out-Null + } + } + } + $wmi_instance.CimInstance | Set-CimInstance -Property $properties -ErrorAction Ignore -ErrorVariable err | Out-Null + } + + $addToActualState = [dscResourceObject]@{ + name = $r.name + type = $r.type + properties = $null + } + + $result += $addToActualState } 'Test' { # TODO: implement test + "Test operation is not implemented for WMI/CIM methods." | Write-DscTrace -Operation Error + exit 1 } } } @@ -131,6 +313,34 @@ function GetCimSpace { return $result } +function GetCimInstanceProperties { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [dscResourceObject]$DesiredState + ) + + $className = $DesiredState.type.Split("/")[-1] + $namespace = $DesiredState.type.Split("/")[0].Replace(".", "/") + + $cimClass = Get-CimClass -Namespace $namespace -ClassName $className + + if ($null -eq $cimClass) { + "Class '$className' not found in namespace '$namespace'." | Write-DscTrace -Operation Error + exit 1 + } + + $validatedProperties = GetValidCimProperties -CimClass $cimClass -ClassName $className -Properties $DesiredState.properties -ValidateKeyProperty + + $cimInstance = GetWmiInstance -DesiredState $DesiredState + + return @{ + CimInstance = $cimInstance + Properties = $validatedProperties + ClassName = $className + Namespace = $namespace + } +} function Invoke-DscWmi { [CmdletBinding()] @@ -146,17 +356,7 @@ function Invoke-DscWmi { $DesiredState ) - switch ($Operation) { - 'Get' { - $addToActualState = GetCimSpace -Operation $Operation -DesiredState $DesiredState - } - 'Set' { - # TODO: Implement Set operation - } - 'Test' { - # TODO: Implement Test operation - } - } + $addToActualState = GetCimSpace -Operation $Operation -DesiredState $DesiredState return $addToActualState }