From dc2178c831b3d1bb23744632a86eddcd389dfe87 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Mon, 26 Oct 2026 11:01:11 -0700 Subject: [PATCH 1/8] Replace PowerShell adapter validation with embedded resource schemas Remove the no-op validate operation, emit schemas for discovered resources, and preserve legacy adapter behavior. Update schema fixtures, cache versions, documentation, and regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PowerShell_adapter.dsc.resource.json | 16 +- adapters/powershell/Tests/PSAdapted.tests.ps1 | 10 + ...TestClassResource.dsc.adaptedResource.json | 36 ++- .../Tests/powershellgroup.resource.tests.ps1 | 4 + adapters/powershell/Tests/schema.tests.ps1 | 230 ++++++++++++++++++ ...indowsPowerShell_adapter.dsc.resource.json | 16 +- .../powershell/powershell.dsc.resource.json | 32 ++- .../psDscAdapter/powershell.resource.ps1 | 116 +++++++-- .../powershell/psDscAdapter/psDscAdapter.psm1 | 13 +- .../psDscAdapter/win_psDscAdapter.psm1 | 13 +- .../windowspowershell.dsc.resource.json | 31 ++- .../Microsoft/Adapter/PowerShell/index.md | 12 +- .../Adapter/WindowsPowerShell/index.md | 12 +- .../Microsoft/DSC/PowerShell/index.md | 2 +- .../Windows/WindowsPowerShell/index.md | 2 +- 15 files changed, 444 insertions(+), 101 deletions(-) create mode 100644 adapters/powershell/Tests/schema.tests.ps1 diff --git a/adapters/powershell/PowerShell_adapter.dsc.resource.json b/adapters/powershell/PowerShell_adapter.dsc.resource.json index dd36c2c8c..9fd0574cb 100644 --- a/adapters/powershell/PowerShell_adapter.dsc.resource.json +++ b/adapters/powershell/PowerShell_adapter.dsc.resource.json @@ -114,18 +114,10 @@ "input": "stdin", "return": "state" }, - "validate": { - "executable": "pwsh", - "args": [ - "-NoLogo", - "-NonInteractive", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - "$Input | ./psDscAdapter/powershell.resource.ps1 Validate" - ], - "input": "stdin" + "schema": { + "embedded": { + "type": "object" + } }, "exitCodes": { "0": "Success", diff --git a/adapters/powershell/Tests/PSAdapted.tests.ps1 b/adapters/powershell/Tests/PSAdapted.tests.ps1 index 95abef00c..835334bfc 100644 --- a/adapters/powershell/Tests/PSAdapted.tests.ps1 +++ b/adapters/powershell/Tests/PSAdapted.tests.ps1 @@ -20,6 +20,16 @@ Describe 'Tests for PS adapted manifests' { $out.actualState.Value | Should -Be 42 } + It 'Rejects input that does not match the adapted resource embedded schema' -ForEach @( + @{ Json = '{"name":42}' } + @{ Json = '{"name":"hello","value":"invalid"}' } + @{ Json = '{"name":"hello","unknown":true}' } + ) { + $null = dsc resource get -r 'PSAdaptedTestClassResource/PSAdaptedTestClass' -i $Json 2> (Join-Path $TestDrive 'schema-error.log') + $LASTEXITCODE | Should -Not -Be 0 + Get-Content -Raw (Join-Path $TestDrive 'schema-error.log') | Should -Match 'schema|properties' + } + It 'Get operation works if adapted resource is in a path with spaces' { $resourcePath = Join-Path -Path $TestDrive -ChildPath 'Path with spaces' New-Item -Path $resourcePath -ItemType Directory | Out-Null diff --git a/adapters/powershell/Tests/PSAdaptedTestClassResource.dsc.adaptedResource.json b/adapters/powershell/Tests/PSAdaptedTestClassResource.dsc.adaptedResource.json index 0a582046e..8ade3ccd9 100644 --- a/adapters/powershell/Tests/PSAdaptedTestClassResource.dsc.adaptedResource.json +++ b/adapters/powershell/Tests/PSAdaptedTestClassResource.dsc.adaptedResource.json @@ -11,25 +11,23 @@ "requireAdapter": "Microsoft.Adapter/PowerShell", "path": "PSAdaptedTestClassResource.psd1", "schema": { - "embedded": { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/resources/PSAdaptedTestClass/v0.1.0/schema.json", - "title": "PSAdaptedTestClass", - "description": "An adapted resource for testing.", - "type": "object", - "required": [], - "additionalProperties": false, - "properties": { - "name": { - "type": "string", - "title": "Name", - "description": "This is property one of the adapted resource." - }, - "value": { - "type": "integer", - "title": "Value", - "description": "The value of the adapted resource instance." - } + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/resources/PSAdaptedTestClass/v0.1.0/schema.json", + "title": "PSAdaptedTestClass", + "description": "An adapted resource for testing.", + "type": "object", + "required": [], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "This is property one of the adapted resource." + }, + "value": { + "type": "integer", + "title": "Value", + "description": "The value of the adapted resource instance." } } } diff --git a/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 b/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 index 865b74f0a..612fb8dfd 100644 --- a/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 +++ b/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 @@ -31,6 +31,10 @@ Describe 'PowerShell adapter resource tests' { ($resources | Where-Object { $_.Type -eq 'TestClassResource/TestClassResource' }).Count | Should -Be 1 ($resources | Where-Object -Property type -EQ 'TestClassResource/TestClassResource').capabilities | Should -BeIn @('get', 'set', 'test', 'export') ($resources | Where-Object -Property type -EQ 'TestClassResource/NoExport').capabilities | Should -BeIn @('get', 'set', 'test') + $resourceSchema = ($resources | Where-Object -Property type -EQ 'TestClassResource/TestClassResource').schema + $resourceSchema.type | Should -BeExactly 'object' + $resourceSchema.properties.BaseProperty.type | Should -Contain 'string' + $resourceSchema.properties.PSObject.Properties.Name | Should -Not -Contain 'NonDscProperty' } It 'Get works on class-based resource' { diff --git a/adapters/powershell/Tests/schema.tests.ps1 b/adapters/powershell/Tests/schema.tests.ps1 new file mode 100644 index 000000000..d6de8fdbc --- /dev/null +++ b/adapters/powershell/Tests/schema.tests.ps1 @@ -0,0 +1,230 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'PowerShell adapter embedded schemas' { + BeforeAll { + $adapterPath = Split-Path -Path $PSScriptRoot -Parent + $scriptPath = Join-Path $adapterPath 'psDscAdapter' 'powershell.resource.ps1' + $oldModulePath = $env:PSModulePath + $oldLocalAppData = $env:LOCALAPPDATA + $oldHome = $env:HOME + $oldRestrictedPath = $env:DSC_RESTRICTED_PATH + $oldResourcePath = $env:DSC_RESOURCE_PATH + $env:LOCALAPPDATA = $TestDrive + $env:HOME = $TestDrive + $env:PSModulePath = $TestDrive + $env:DSC_RESOURCE_PATH = $null + $env:DSC_RESTRICTED_PATH = $adapterPath + [IO.Path]::PathSeparator + $PSHOME + + $modulePath = Join-Path $TestDrive 'TestSchemaResource' + New-Item -Path $modulePath -ItemType Directory | Out-Null + @' +@{ + RootModule = 'TestSchemaResource.psm1' + ModuleVersion = '1.0.0' + DscResourcesToExport = @('TestSchemaResource') +} +'@ | Set-Content -Path (Join-Path $modulePath 'TestSchemaResource.psd1') + @' +enum SchemaEnsure { + Present + Absent +} + +class SchemaBase { + [DscProperty()] + [string] $Inherited +} + +[DscResource()] +class TestSchemaResource : SchemaBase { + [DscProperty(Key)] + [string] $Name + + [DscProperty(Mandatory)] + [string] $RequiredValue + + [DscProperty()] + [bool] $Enabled + + [DscProperty()] + [int] $Count + + [DscProperty()] + [double] $Ratio + + [DscProperty()] + [ValidateSet(1, 2)] + [int] $Selection + + [DscProperty()] + [ValidateSet('One', 'Two')] + [string] $Choice + + [DscProperty()] + [string[]] $Names + + [DscProperty()] + [SchemaEnsure] $Ensure + + [DscProperty()] + [hashtable] $Options + + [DscProperty()] + [PSCredential] $Credential + + [DscProperty()] + [SecureString] $Secret + + [string] $NotDscProperty + + [TestSchemaResource] Get() { return $this } + [void] Set() {} + [bool] Test() { return $true } +} +'@ | Set-Content -Path (Join-Path $modulePath 'TestSchemaResource.psm1') + + $resources = & $scriptPath -Operation List -ResourceType Single | ConvertFrom-Json + $resource = $resources | Where-Object -Property type -EQ 'TestSchemaResource/TestSchemaResource' + $schema = $resource.schema | ConvertTo-Json -Depth 100 -Compress + } + + AfterAll { + $env:PSModulePath = $oldModulePath + $env:LOCALAPPDATA = $oldLocalAppData + $env:HOME = $oldHome + $env:DSC_RESTRICTED_PATH = $oldRestrictedPath + $env:DSC_RESOURCE_PATH = $oldResourcePath + } + + It 'Removes validate and embeds a schema in ' -ForEach @( + @{ Manifest = 'PowerShell_adapter.dsc.resource.json' } + @{ Manifest = 'WindowsPowerShell_adapter.dsc.resource.json' } + @{ Manifest = 'powershell.dsc.resource.json' } + @{ Manifest = 'windowspowershell.dsc.resource.json' } + ) { + $manifestContent = Get-Content -Raw -Path (Join-Path $adapterPath $Manifest) | ConvertFrom-Json + $manifestContent.PSObject.Properties.Name | Should -Not -Contain 'validate' + $manifestContent.schema.embedded.type | Should -BeExactly 'object' + } + + It 'Rejects the removed Validate operation' { + $null = & pwsh -NoProfile -File $scriptPath -Operation Validate 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'Allows legacy resource instances without export filters in ' -ForEach @( + @{ Manifest = 'powershell.dsc.resource.json' } + @{ Manifest = 'windowspowershell.dsc.resource.json' } + ) { + $manifestContent = Get-Content -Raw -Path (Join-Path $adapterPath $Manifest) | ConvertFrom-Json + $legacySchema = $manifestContent.schema.embedded | ConvertTo-Json -Depth 100 + Test-Json -Json '{"resources":[{"name":"export","type":"TestSchemaResource/TestSchemaResource"}]}' -Schema $legacySchema -ErrorAction Stop | + Should -BeTrue + Test-Json -Json '{"resources":"invalid"}' -Schema $legacySchema -ErrorAction SilentlyContinue | + Should -BeFalse + } + + It 'Includes a resource schema with inherited DSC properties only' { + $resource.requireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' + $resource.schema.type | Should -BeExactly 'object' + $resource.schema.properties.PSObject.Properties.Name | Should -Contain 'Inherited' + $resource.schema.properties.PSObject.Properties.Name | Should -Not -Contain 'NotDscProperty' + $resource.schema.properties.Ensure.anyOf[0].enum | Should -Be @('Present', 'Absent') + } + + It 'Accepts valid resource data including nullable and complex properties' { + $json = '{"Name":"test","RequiredValue":"value","Enabled":true,"Count":2,"Ratio":1.5,"Names":["one"],"Ensure":"Present","Options":{"nested":{"value":1}},"Credential":{"username":"user","password":"secret"},"Secret":"secret","Inherited":null}' + Test-Json -Json $json -Schema $schema -ErrorAction Stop | Should -BeTrue + } + + It 'Preserves case-insensitive PowerShell property names' { + Test-Json -Json '{"name":"test","requiredvalue":"value","count":2}' -Schema $schema -ErrorAction Stop | + Should -BeTrue + } + + It 'Preserves case-insensitive enumeration values' { + Test-Json -Json '{"Name":"test","RequiredValue":"value","Ensure":"present","Choice":"one","Selection":1}' -Schema $schema -ErrorAction Stop | + Should -BeTrue + } + + It 'Accepts the serialized representation of a SecureString in resource output' { + Test-Json -Json '{"Name":"test","RequiredValue":"value","Secret":{"Length":6}}' -Schema $schema -ErrorAction Stop | + Should -BeTrue + } + + It 'Rejects ' -ForEach @( + @{ Reason = 'a missing key'; Json = '{"RequiredValue":"value"}' } + @{ Reason = 'a missing mandatory property'; Json = '{"Name":"test"}' } + @{ Reason = 'a non-string key'; Json = '{"Name":42,"RequiredValue":"value"}' } + @{ Reason = 'a non-boolean value'; Json = '{"Name":"test","RequiredValue":"value","Enabled":"yes"}' } + @{ Reason = 'a fractional integer'; Json = '{"Name":"test","RequiredValue":"value","Count":1.5}' } + @{ Reason = 'a non-numeric value'; Json = '{"Name":"test","RequiredValue":"value","Ratio":"bad"}' } + @{ Reason = 'an invalid array item'; Json = '{"Name":"test","RequiredValue":"value","Names":[1]}' } + @{ Reason = 'an invalid enumeration'; Json = '{"Name":"test","RequiredValue":"value","Ensure":"Other"}' } + @{ Reason = 'an invalid string validation set'; Json = '{"Name":"test","RequiredValue":"value","Choice":"Other"}' } + @{ Reason = 'an invalid numeric validation set'; Json = '{"Name":"test","RequiredValue":"value","Selection":3}' } + @{ Reason = 'a non-object credential'; Json = '{"Name":"test","RequiredValue":"value","Credential":"secret"}' } + @{ Reason = 'an incorrectly typed case-insensitive property'; Json = '{"name":"test","requiredvalue":"value","count":"bad"}' } + ) { + Test-Json -Json $Json -Schema $schema -ErrorAction SilentlyContinue | Should -BeFalse + } + + It 'Returns the same schema when listing cached resources' { + $cached = & $scriptPath -Operation List -ResourceType Single | ConvertFrom-Json | + Where-Object -Property type -EQ 'TestSchemaResource/TestSchemaResource' + Test-Json -Json '{"Name":"test","RequiredValue":"value","Count":"bad"}' -Schema ($cached.schema | ConvertTo-Json -Depth 100) -ErrorAction SilentlyContinue | + Should -BeFalse + $cached.schema.properties.Ensure.anyOf[0].enum | Should -Be @('Present', 'Absent') + } + + It 'Embeds schemas for both deprecated and single-resource adapters' { + $legacy = & $scriptPath -Operation List | ConvertFrom-Json | + Where-Object -Property type -EQ 'TestSchemaResource/TestSchemaResource' + $legacy.requireAdapter | Should -BeExactly 'Microsoft.DSC/PowerShell' + Test-Json -Json '{"Name":"test","RequiredValue":"value","Count":"bad"}' -Schema ($legacy.schema | ConvertTo-Json -Depth 100) -ErrorAction SilentlyContinue | + Should -BeFalse + } + + It 'Produces usable Windows PowerShell schemas from live and cached metadata' -Skip:(!$IsWindows) { + try { + $env:PSModulePath += [IO.Path]::PathSeparator + $PSScriptRoot + foreach ($iteration in 1..2) { + $windowsResources = & powershell -NoProfile -ExecutionPolicy Bypass -File $scriptPath -Operation List -ResourceType Single 2> (Join-Path $TestDrive 'windows-schema-error.log') + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw (Join-Path $TestDrive 'windows-schema-error.log')) + $windowsResource = $windowsResources | ConvertFrom-Json | + Where-Object -Property type -EQ 'TestSchemaResource/TestSchemaResource' + $windowsResource.requireAdapter | Should -BeExactly 'Microsoft.Adapter/WindowsPowerShell' + $windowsResource.schema.properties.Count.type | Should -BeExactly 'integer' + $windowsResource.schema.properties.Names.items.type | Should -Contain 'string' + $windowsSchema = $windowsResource.schema | ConvertTo-Json -Depth 100 + Test-Json -Json '{"name":"test","requiredvalue":"value","Ensure":"present"}' -Schema $windowsSchema -ErrorAction Stop | + Should -BeTrue + Test-Json -Json '{"Name":"test","RequiredValue":"value","Count":"bad"}' -Schema $windowsSchema -ErrorAction SilentlyContinue | + Should -BeFalse + $scriptResource = $windowsResources | ConvertFrom-Json | + Where-Object -Property type -EQ 'TestScriptBaseDSC/CredentialValidation' + $scriptResource.schema.properties.Credential.type | Should -Contain 'object' + $scriptSchema = $scriptResource.schema | ConvertTo-Json -Depth 100 + Test-Json -Json '{"Name":"test","Credential":{"username":"user","password":"secret"}}' -Schema $scriptSchema -ErrorAction Stop | + Should -BeTrue + Test-Json -Json '{"Name":"test"}' -Schema $scriptSchema -ErrorAction SilentlyContinue | + Should -BeFalse + } + } finally { + $env:PSModulePath = $TestDrive + } + } + + It 'Provides the embedded schema through the DSC CLI' { + $out = dsc resource schema -r 'TestSchemaResource/TestSchemaResource' 2> (Join-Path $TestDrive 'schema-error.log') + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw (Join-Path $TestDrive 'schema-error.log')) + ($out | ConvertFrom-Json).properties.Count.type | Should -BeExactly 'integer' + } + + It 'Rejects invalid resource input through the DSC CLI' { + $null = dsc resource get -r 'TestSchemaResource/TestSchemaResource' -i '{"Name":"test","RequiredValue":"value","Count":"bad"}' 2> (Join-Path $TestDrive 'invalid-error.log') + $LASTEXITCODE | Should -Not -Be 0 + Get-Content -Raw (Join-Path $TestDrive 'invalid-error.log') | Should -Match 'integer' + } +} diff --git a/adapters/powershell/WindowsPowerShell_adapter.dsc.resource.json b/adapters/powershell/WindowsPowerShell_adapter.dsc.resource.json index cb5c2f840..cb7354a51 100644 --- a/adapters/powershell/WindowsPowerShell_adapter.dsc.resource.json +++ b/adapters/powershell/WindowsPowerShell_adapter.dsc.resource.json @@ -97,18 +97,10 @@ "input": "stdin", "return": "state" }, - "validate": { - "executable": "powershell", - "args": [ - "-NoLogo", - "-NonInteractive", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - "$Input | ./psDscAdapter/powershell.resource.ps1 Validate" - ], - "input": "stdin" + "schema": { + "embedded": { + "type": "object" + } }, "exitCodes": { "0": "Success", diff --git a/adapters/powershell/powershell.dsc.resource.json b/adapters/powershell/powershell.dsc.resource.json index 23cd6246a..5b3c03c30 100644 --- a/adapters/powershell/powershell.dsc.resource.json +++ b/adapters/powershell/powershell.dsc.resource.json @@ -79,18 +79,26 @@ "input": "stdin", "return": "state" }, - "validate": { - "executable": "pwsh", - "args": [ - "-NoLogo", - "-NonInteractive", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - "$Input | ./psDscAdapter/powershell.resource.ps1 Validate" - ], - "input": "stdin" + "schema": { + "embedded": { + "type": "object", + "required": ["resources"], + "properties": { + "resources": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "type"], + "properties": { + "name": { "type": "string" }, + "type": { "type": "string" }, + "properties": { "type": "object" } + } + } + }, + "psmodulepath": { "type": "string" } + } + } }, "exitCodes": { "0": "Success", diff --git a/adapters/powershell/psDscAdapter/powershell.resource.ps1 b/adapters/powershell/psDscAdapter/powershell.resource.ps1 index 7d4086a2a..46e5e292a 100644 --- a/adapters/powershell/psDscAdapter/powershell.resource.ps1 +++ b/adapters/powershell/psDscAdapter/powershell.resource.ps1 @@ -2,8 +2,8 @@ # Licensed under the MIT License. [CmdletBinding()] param( - [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'Operation to perform. Choose from List, Get, Set, Test, Export, Validate, ClearCache.')] - [ValidateSet('List', 'Get', 'Set', 'Test', 'Export', 'Validate', 'ClearCache')] + [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'Operation to perform. Choose from List, Get, Set, Test, Export, ClearCache.')] + [ValidateSet('List', 'Get', 'Set', 'Test', 'Export', 'ClearCache')] [string]$Operation, [Parameter(Mandatory = $false, Position = 1, ValueFromPipeline = $true, HelpMessage = 'Configuration or resource input in JSON format.')] [string]$jsonInput = '{}', @@ -51,8 +51,8 @@ function Write-TraceQueue() { $ps = [PowerShell]::Create().AddScript({ [CmdletBinding()] param( - [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'Operation to perform. Choose from List, Get, Set, Test, Export, Validate, ClearCache.')] - [ValidateSet('List', 'Get', 'Set', 'Test', 'Export', 'Validate', 'ClearCache')] + [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'Operation to perform. Choose from List, Get, Set, Test, Export, ClearCache.')] + [ValidateSet('List', 'Get', 'Set', 'Test', 'Export', 'ClearCache')] [string]$Operation, [Parameter(Mandatory = $false, Position = 1, ValueFromPipeline = $true, HelpMessage = 'Configuration or resource input in JSON format.')] [string]$jsonInput = '{}', @@ -119,19 +119,77 @@ $ps = [PowerShell]::Create().AddScript({ } } - if ('Validate' -ne $Operation) { - Write-Debug -Debug ("jsonInput=$jsonInput") + Write-Debug -Debug ("jsonInput=$jsonInput") - # load private functions of psDscAdapter stub module - if ($PSVersionTable.PSVersion.Major -le 5) { - $psDscAdapter = Import-Module "$ScriptRoot/win_psDscAdapter.psd1" -Force -PassThru + # load private functions of psDscAdapter stub module + if ($PSVersionTable.PSVersion.Major -le 5) { + $psDscAdapter = Import-Module "$ScriptRoot/win_psDscAdapter.psd1" -Force -PassThru + } + else { + $psDscAdapter = Import-Module "$ScriptRoot/psDscAdapter.psd1" -Force -PassThru + } + + # initialize OUTPUT as array + $result = [System.Collections.Generic.List[Object]]::new() + + function ConvertTo-PropertySchema { + param( + [string]$PropertyType, + [string[]]$Values + ) + + # Windows PowerShell metadata can wrap type names in brackets. + $typeName = $PropertyType -replace '^\[(.*)\]$', '$1' + if ($typeName.EndsWith('[]')) { + return @{ + type = @('array', 'null') + items = ConvertTo-PropertySchema -PropertyType $typeName.Substring(0, $typeName.Length - 2) -Values $Values + } } - else { - $psDscAdapter = Import-Module "$ScriptRoot/psDscAdapter.psd1" -Force -PassThru + + $schema = switch -Regex ($typeName -replace '^System\.', '') { + '^(string|char|datetime|datetimeoffset|guid|uri)$' { + @{ type = @('string', 'null') } + break + } + '^(securestring|security\.securestring|timespan|version)$' { + # These accept string input but serialize as objects in resource output. + @{ type = @('string', 'object', 'null') } + break + } + '^(bool|boolean)$' { + @{ type = 'boolean' } + break + } + '^(byte|sbyte|short|ushort|int|uint|long|ulong|int16|uint16|int32|uint32|int64|uint64)$' { + @{ type = 'integer' } + break + } + '^(float|single|double|decimal)$' { + @{ type = 'number' } + break + } + '^(hashtable|collections\.hashtable|pscredential|management\.automation\.pscredential|ciminstance|microsoft\.management\.infrastructure\.ciminstance)$' { + @{ type = @('object', 'null') } + break + } + default { + # Custom and embedded types can have resource-specific JSON representations. + @{} + } } - # initialize OUTPUT as array - $result = [System.Collections.Generic.List[Object]]::new() + if ($Values.Count -gt 0) { + if ($schema.type -in @('integer', 'number')) { + $schema.enum = @($Values | ForEach-Object { [decimal]::Parse($_, [Globalization.CultureInfo]::InvariantCulture) }) + } else { + $schema.anyOf = @( + @{ enum = @($Values) } + @{ type = 'string'; pattern = '^(?i:' + (($Values | ForEach-Object { [regex]::Escape($_) }) -join '|') + ')$' } + ) + } + } + return $schema } if ($jsonInput) { @@ -200,8 +258,28 @@ $ps = [PowerShell]::Create().AddScript({ } $properties = @() + $propertySchemas = @{} + $propertyPatterns = @{} + $requiredProperties = @() foreach ($prop in $DscResourceInfo.Properties) { $properties += $prop.Name + $propertySchemas[$prop.Name] = ConvertTo-PropertySchema -PropertyType $prop.PropertyType -Values $prop.Values + $pattern = '^(?i:' + [regex]::Escape($prop.Name) + ')$' + $propertyPatterns[$pattern] = $propertySchemas[$prop.Name] + if ($prop.IsMandatory) { + # Require a matching property without making PowerShell names case sensitive. + $requiredProperties += @{ not = @{ propertyNames = @{ not = @{ pattern = $pattern } } } } + } + } + + $resourceSchema = @{ + '$schema' = 'https://json-schema.org/draft/2020-12/schema' + type = 'object' + properties = $propertySchemas + patternProperties = $propertyPatterns + } + if ($requiredProperties.Count -gt 0) { + $resourceSchema.allOf = @($requiredProperties) } # OUTPUT dsc is expecting the following properties @@ -217,6 +295,7 @@ $ps = [PowerShell]::Create().AddScript({ properties = $properties requireAdapter = $requireAdapter description = $description + schema = $resourceSchema } } } @@ -319,14 +398,8 @@ $ps = [PowerShell]::Create().AddScript({ } return $result } - 'Validate' { - # VALIDATE not implemented - - # OUTPUT - @{ valid = $true } - } Default { - Write-Error 'Unsupported operation. Please use one of the following: List, Get, Set, Test, Export, Validate' + Write-Error 'Unsupported operation. Please use one of the following: List, Get, Set, Test, Export, ClearCache' } } @@ -343,6 +416,7 @@ $ps = [PowerShell]::Create().AddScript({ [string[]] $properties [string] $requireAdapter [string] $description + [hashtable] $schema } }).AddParameter('Operation', $Operation).AddParameter('jsonInput', $jsonInput).AddParameter('ResourceType', $ResourceType).AddParameter('ResourcePath', $ResourcePath).AddParameter('ScriptRoot', $PSScriptRoot) @@ -444,5 +518,5 @@ Get-EventSubscriber | Where-Object { $_.SourceIdentifier -like 'PSAdapter.*' } | Write-TraceQueue foreach ($obj in $outputCollection) { - $obj | ConvertTo-Json -Depth 10 -Compress + $obj | ConvertTo-Json -Depth 100 -Compress } diff --git a/adapters/powershell/psDscAdapter/psDscAdapter.psm1 b/adapters/powershell/psDscAdapter/psDscAdapter.psm1 index 2aba85414..253fd8e22 100644 --- a/adapters/powershell/psDscAdapter/psDscAdapter.psm1 +++ b/adapters/powershell/psDscAdapter/psDscAdapter.psm1 @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -$script:CurrentCacheSchemaVersion = 3 +$script:CurrentCacheSchemaVersion = 4 $ErrorActionPreference = 'Stop' function Import-PSDSCModule { @@ -67,7 +67,7 @@ function Add-AstMembers { if ($attr.TypeName.Name -eq 'DscProperty') { $skipProperty = $false foreach ($attrArg in $attr.NamedArguments) { - if ($attrArg.ArgumentName -eq 'Key') { + if ($attrArg.ArgumentName -in @('Key', 'Mandatory') -and $attrArg.Argument.SafeGetValue()) { $isKeyProperty = $true break } @@ -82,6 +82,15 @@ function Add-AstMembers { $prop.Name = $property.Name $prop.PropertyType = $property.PropertyType.TypeName.Name $prop.IsMandatory = $isKeyProperty + $enumType = $AllTypeDefinitions | Where-Object { $_.IsEnum -and $_.Name -eq ($prop.PropertyType -replace '\[\]$', '') } + if ($enumType) { + $prop.Values = @($enumType.Members.Name) + } + foreach ($attr in $property.Attributes) { + if ($attr.TypeName.Name -eq 'ValidateSet') { + $prop.Values = @($attr.PositionalArguments | ForEach-Object { $_.SafeGetValue() }) + } + } $Properties.Add($prop) } } diff --git a/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 b/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 index 0cbdc416a..ce05bacbf 100644 --- a/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 +++ b/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 @@ -3,7 +3,7 @@ $global:ProgressPreference = 'SilentlyContinue' $ErrorActionPreference = 'Stop' -$script:CurrentCacheSchemaVersion = 1 +$script:CurrentCacheSchemaVersion = 2 trap { Write-Error ($_ | Format-List -Force | Out-String) @@ -713,7 +713,7 @@ function GetClassBasedProperties { if ($attr.TypeName.Name -eq 'DscProperty') { $skipProperty = $false foreach ($attrArg in $attr.NamedArguments) { - if ($attrArg.ArgumentName -eq 'Key') { + if ($attrArg.ArgumentName -in @('Key', 'Mandatory') -and $attrArg.Argument.SafeGetValue()) { $isKeyProperty = $true break } @@ -728,6 +728,15 @@ function GetClassBasedProperties { $prop.Name = $property.Name $prop.PropertyType = $property.PropertyType.TypeName.Name $prop.IsMandatory = $isKeyProperty + $enumType = $typeDefinitions | Where-Object { $_.IsEnum -and $_.Name -eq ($prop.PropertyType -replace '\[\]$', '') } + if ($enumType) { + $prop.Values = @($enumType.Members.Name) + } + foreach ($attr in $property.Attributes) { + if ($attr.TypeName.Name -eq 'ValidateSet') { + $prop.Values = @($attr.PositionalArguments | ForEach-Object { $_.SafeGetValue() }) + } + } $properties.Add($prop) } return $properties diff --git a/adapters/powershell/windowspowershell.dsc.resource.json b/adapters/powershell/windowspowershell.dsc.resource.json index b378f0b0f..ac766bb1b 100644 --- a/adapters/powershell/windowspowershell.dsc.resource.json +++ b/adapters/powershell/windowspowershell.dsc.resource.json @@ -78,17 +78,26 @@ "input": "stdin", "return": "state" }, - "validate": { - "executable": "powershell", - "args": [ - "-NoLogo", - "-NonInteractive", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - "$Input | ./psDscAdapter/powershell.resource.ps1 Validate" - ] + "schema": { + "embedded": { + "type": "object", + "required": ["resources"], + "properties": { + "resources": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "type"], + "properties": { + "name": { "type": "string" }, + "type": { "type": "string" }, + "properties": { "type": "object" } + } + } + }, + "psmodulepath": { "type": "string" } + } + } }, "exitCodes": { "0": "Success", diff --git a/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md b/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md index 60989728d..d6335423e 100644 --- a/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md +++ b/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md @@ -82,7 +82,7 @@ for each platform. | macOS | `$HOME/.dsc/PSAdapterCache.json` | | Windows | `%LOCALAPPDATA%\dsc\PSAdapterCache.json` | -The adapter versions the cache. The current version is `2`. If the version of the cache on a +The adapter versions the cache. The current version is `4`. If the version of the cache on a machine differs from the current version, the adapter refreshes the cache. The adapter checks whether the cache is stale on each run and refreshes it if: @@ -160,15 +160,19 @@ configuration document with the following exceptions: For more information about type names in DSC, see [DSC Resource fully qualified type name schema reference][07]. -1. The `properties` field for the instance is validated at runtime when the adapter tries to invoke - the adapted PSDSC resource instance. This adapter doesn't support static linting for adapted - instance properties in a configuration document. +1. Discovered resources include an embedded JSON schema describing their DSC properties. DSC uses + this schema to validate property types, key and mandatory properties, and enumeration values + before invoking the resource. You can retrieve the schema with `dsc resource schema`. Each property name must be a configurable property of the PSDSC resource. The property name isn't case sensitive. The value for each property must be valid for that property. If you specify an invalid property name or value, the adapter raises an error when it tries to invoke the resource. + Property names and enumeration values remain case insensitive. Custom and embedded property + types without a known JSON representation are validated by the underlying PowerShell resource. + The adapter doesn't implement a separate `validate` operation. + ## Exit codes The resource uses the following exit codes to report success and errors: diff --git a/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md b/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md index e1b4f0b7c..0d1c69fd7 100644 --- a/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md +++ b/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md @@ -80,7 +80,7 @@ The following table defines the cache path for the Windows platform. | :------: | :---------------------------------------------- | | Windows | `%LOCALAPPDATA%\dsc\WindowsPSAdapterCache.json` | -The adapter versions the cache. The current version is `1`. If the version of the cache on a +The adapter versions the cache. The current version is `2`. If the version of the cache on a machine differs from the current version, the adapter refreshes the cache. The adapter checks whether the cache is stale on each run and refreshes it if: @@ -170,15 +170,19 @@ configuration document with the following exceptions: For more information about type names in DSC, see [DSC Resource fully qualified type name schema reference][06]. -1. The `properties` field for the instance is validated at runtime when the adapter tries to invoke - the adapted PSDSC resource instance. This adapter doesn't support static linting for adapted - instance properties in a configuration document. +1. Discovered resources include an embedded JSON schema describing their DSC properties. DSC uses + this schema to validate property types, key and mandatory properties, and enumeration values + before invoking the resource. You can retrieve the schema with `dsc resource schema`. Each property name must be a configurable property of the PSDSC resource. The property name isn't case sensitive. The value for each property must be valid for that property. If you specify an invalid property name or value, the adapter raises an error when it tries to invoke the resource. + Property names and enumeration values remain case insensitive. Custom and embedded property + types without a known JSON representation are validated by the underlying PowerShell resource. + The adapter doesn't implement a separate `validate` operation. + ## Exit codes The adapter resource returns the following exit codes from operations: diff --git a/docs/reference/resources/Microsoft/DSC/PowerShell/index.md b/docs/reference/resources/Microsoft/DSC/PowerShell/index.md index b04db7369..6a4866532 100644 --- a/docs/reference/resources/Microsoft/DSC/PowerShell/index.md +++ b/docs/reference/resources/Microsoft/DSC/PowerShell/index.md @@ -68,7 +68,7 @@ for each platform. | macOS | `$HOME/.dsc/PSAdapterCache.json` | | Windows | `%LOCALAPPDATA%\dsc\PSAdapterCache.json` | -The adapter versions the cache. The current version is `2`. If the version of the cache on a +The adapter versions the cache. The current version is `4`. If the version of the cache on a machine differs from the current version, the adapter refreshes the cache. The adapter checks whether the cache is stale on each run and refreshes it if: diff --git a/docs/reference/resources/Microsoft/Windows/WindowsPowerShell/index.md b/docs/reference/resources/Microsoft/Windows/WindowsPowerShell/index.md index 33d17b31d..4e3a92f72 100644 --- a/docs/reference/resources/Microsoft/Windows/WindowsPowerShell/index.md +++ b/docs/reference/resources/Microsoft/Windows/WindowsPowerShell/index.md @@ -65,7 +65,7 @@ for the Windows platform. | :------: | :---------------------------------------------- | | Windows | `%LOCALAPPDATA%\dsc\WindowsPSAdapterCache.json` | -The adapter versions the cache. The current version is `1`. If the version of the cache on a +The adapter versions the cache. The current version is `2`. If the version of the cache on a machine differs from the current version, the adapter refreshes the cache. The adapter checks whether the cache is stale on each run and refreshes it if: From 228f37c5488d8f5d195154ca9eed78ae1f0c7433 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Mon, 26 Oct 2026 11:08:50 -0700 Subject: [PATCH 2/8] Preserve upstream PowerShell adapter formatting after merge Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../powershell/psDscAdapter/psDscAdapter.psm1 | 486 ++++++------ .../psDscAdapter/win_psDscAdapter.psm1 | 744 +++++++++--------- 2 files changed, 615 insertions(+), 615 deletions(-) diff --git a/adapters/powershell/psDscAdapter/psDscAdapter.psm1 b/adapters/powershell/psDscAdapter/psDscAdapter.psm1 index 9f7f9bc36..15d8dc8b0 100644 --- a/adapters/powershell/psDscAdapter/psDscAdapter.psm1 +++ b/adapters/powershell/psDscAdapter/psDscAdapter.psm1 @@ -264,181 +264,181 @@ function Invoke-DscCacheRefresh { ) process { - # if the module is already imported and has DSC resources, we can skip refreshing the cache and return the resources directly - if ($Module -and $Module.Count -eq 1) { - $importedModule = Get-Module -Name $Module -ErrorAction Ignore - if ($null -ne $importedModule -and $importedModule.ExportedDscResources.Count -gt 0) { - [dscResourceCacheEntry[]]$dscResourceCacheEntries = [System.Collections.Generic.List[Object]]::new() - $DscResources = [System.Collections.Generic.List[DscResourceInfo]]::new() - [System.Collections.Generic.List[DscResourceInfo]]$r = LoadPowerShellClassResourcesFromModule -moduleInfo $importedModule - if ($r) { - $DscResources.AddRange($r) - } - foreach ($dscResource in $DscResources) { - $moduleName = $dscResource.ModuleName - $dscResourceCacheEntries += [dscResourceCacheEntry]@{ - Type = "$moduleName/$($dscResource.Name)" - DscResourceInfo = $dscResource - LastWriteTimes = [pscustomobject]@{} # we aren't caching file timestamps in this fast path + # if the module is already imported and has DSC resources, we can skip refreshing the cache and return the resources directly + if ($Module -and $Module.Count -eq 1) { + $importedModule = Get-Module -Name $Module -ErrorAction Ignore + if ($null -ne $importedModule -and $importedModule.ExportedDscResources.Count -gt 0) { + [dscResourceCacheEntry[]]$dscResourceCacheEntries = [System.Collections.Generic.List[Object]]::new() + $DscResources = [System.Collections.Generic.List[DscResourceInfo]]::new() + [System.Collections.Generic.List[DscResourceInfo]]$r = LoadPowerShellClassResourcesFromModule -moduleInfo $importedModule + if ($r) { + $DscResources.AddRange($r) } + foreach ($dscResource in $DscResources) { + $moduleName = $dscResource.ModuleName + $dscResourceCacheEntries += [dscResourceCacheEntry]@{ + Type = "$moduleName/$($dscResource.Name)" + DscResourceInfo = $dscResource + LastWriteTimes = [pscustomobject]@{} # we aren't caching file timestamps in this fast path + } + } + return $dscResourceCacheEntries } - return $dscResourceCacheEntries } - } - $refreshCache = $false + $refreshCache = $false - $cacheFilePath = if ($IsWindows) { - # PS 6+ on Windows + $cacheFilePath = if ($IsWindows) { + # PS 6+ on Windows Join-Path -Path $env:LocalAppData -ChildPath "dsc\PSAdapterCache.json" - } - else { - # PS 6+ on Linux/Mac + } + else { + # PS 6+ on Linux/Mac Join-Path -Path $env:HOME -ChildPath ".dsc\PSAdapterCache.json" - } + } - if (Test-Path $cacheFilePath) { + if (Test-Path $cacheFilePath) { Write-Verbose -Verbose -Message "Reading from Get-DscResource cache file $cacheFilePath" $cache = Get-Content -Raw -Path $cacheFilePath | ConvertFrom-Json - if ($cache.CacheSchemaVersion -ne $script:CurrentCacheSchemaVersion) { - $refreshCache = $true + if ($cache.CacheSchemaVersion -ne $script:CurrentCacheSchemaVersion) { + $refreshCache = $true $msg = "Incompatible version of cache in file '{0}' (expected '{1}')" -f @( $cache.CacheSchemaVersion $script:CurrentCacheSchemaVersion ) Write-Verbose -Verbose -Message $msg - } - else { - $dscResourceCacheEntries = $cache.ResourceCache + } + else { + $dscResourceCacheEntries = $cache.ResourceCache - if ($dscResourceCacheEntries.Count -eq 0) { - # if there is nothing in the cache file - refresh cache - $refreshCache = $true + if ($dscResourceCacheEntries.Count -eq 0) { + # if there is nothing in the cache file - refresh cache + $refreshCache = $true Write-Debug -Debug -Message "Filtered DscResourceCache cache is empty" - } - else { + } + else { Write-Debug -Debug -Message "Checking cache for stale entries" - foreach ($cacheEntry in $dscResourceCacheEntries) { + foreach ($cacheEntry in $dscResourceCacheEntries) { - $cacheEntry.LastWriteTimes.PSObject.Properties | ForEach-Object { + $cacheEntry.LastWriteTimes.PSObject.Properties | ForEach-Object { - if (Test-Path $_.Name) { - $file_LastWriteTime = (Get-Item $_.Name).LastWriteTime - # Truncate DateTime to seconds - $file_LastWriteTime = $file_LastWriteTime.AddTicks( - ($file_LastWriteTime.Ticks % [TimeSpan]::TicksPerSecond)); + if (Test-Path $_.Name) { + $file_LastWriteTime = (Get-Item $_.Name).LastWriteTime + # Truncate DateTime to seconds + $file_LastWriteTime = $file_LastWriteTime.AddTicks( - ($file_LastWriteTime.Ticks % [TimeSpan]::TicksPerSecond)); - $cache_LastWriteTime = [DateTime]$_.Value - # Truncate DateTime to seconds - $cache_LastWriteTime = $cache_LastWriteTime.AddTicks( - ($cache_LastWriteTime.Ticks % [TimeSpan]::TicksPerSecond)); + $cache_LastWriteTime = [DateTime]$_.Value + # Truncate DateTime to seconds + $cache_LastWriteTime = $cache_LastWriteTime.AddTicks( - ($cache_LastWriteTime.Ticks % [TimeSpan]::TicksPerSecond)); - if (-not ($file_LastWriteTime.Equals($cache_LastWriteTime))) { + if (-not ($file_LastWriteTime.Equals($cache_LastWriteTime))) { Write-Debug -Debug -Message "Detected stale cache entry '$($_.Name)'" [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive' )] + $refreshCache = $true + break + } + } + else { + Write-Debug -Debug -Message "Detected non-existent cache entry '$($_.Name)'" $refreshCache = $true break } } - else { - Write-Debug -Debug -Message "Detected non-existent cache entry '$($_.Name)'" - $refreshCache = $true - break - } - } - if ($refreshCache) { break } - } + if ($refreshCache) { break } + } - if (-not $refreshCache) { + if (-not $refreshCache) { Write-Debug -Debug -Message "Checking cache for stale PSModulePath" - $m = $env:PSModulePath.Split([IO.Path]::PathSeparator, [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { Get-ChildItem -Directory -LiteralPath $_ -Depth 1 -ErrorAction Ignore } + $m = $env:PSModulePath.Split([IO.Path]::PathSeparator, [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { Get-ChildItem -Directory -LiteralPath $_ -Depth 1 -ErrorAction Ignore } - $hs_cache = [System.Collections.Generic.HashSet[string]]($cache.PSModulePaths) - $hs_live = [System.Collections.Generic.HashSet[string]]($m.FullName) - $hs_cache.SymmetricExceptWith($hs_live) - $diff = $hs_cache + $hs_cache = [System.Collections.Generic.HashSet[string]]($cache.PSModulePaths) + $hs_live = [System.Collections.Generic.HashSet[string]]($m.FullName) + $hs_cache.SymmetricExceptWith($hs_live) + $diff = $hs_cache Write-Debug -Debug -Message "PSModulePath diff '$diff'" - if ($diff.Count -gt 0) { - $refreshCache = $true + if ($diff.Count -gt 0) { + $refreshCache = $true + } } } } } - } - else { + else { Write-Verbose -Verbose -Message "Cache file not found '$cacheFilePath'" - $refreshCache = $true - } + $refreshCache = $true + } - if ($refreshCache) { + if ($refreshCache) { Write-Verbose -Verbose -Message "Constructing Get-DscResource cache" - # create a list object to store cache of Get-DscResource - [dscResourceCacheEntry[]]$dscResourceCacheEntries = [System.Collections.Generic.List[Object]]::new() + # create a list object to store cache of Get-DscResource + [dscResourceCacheEntry[]]$dscResourceCacheEntries = [System.Collections.Generic.List[Object]]::new() - $DscResources = [System.Collections.Generic.List[DscResourceInfo]]::new() + $DscResources = [System.Collections.Generic.List[DscResourceInfo]]::new() $dscResourceModulePsd1s = Get-DscModuleManifestList - if ($null -ne $dscResourceModulePsd1s) { - $modules = Get-Module -ListAvailable -Name ($dscResourceModulePsd1s) -ErrorAction Ignore - $processedModuleNames = @{} - foreach ($mod in $modules) { - if (-not ($processedModuleNames.ContainsKey($mod.Name))) { - $processedModuleNames.Add($mod.Name, $true) - - # from several modules with the same name select the one with the highest version - $selectedMod = $modules | Where-Object Name -EQ $mod.Name - if ($selectedMod.Count -gt 1) { + if ($null -ne $dscResourceModulePsd1s) { + $modules = Get-Module -ListAvailable -Name ($dscResourceModulePsd1s) -ErrorAction Ignore + $processedModuleNames = @{} + foreach ($mod in $modules) { + if (-not ($processedModuleNames.ContainsKey($mod.Name))) { + $processedModuleNames.Add($mod.Name, $true) + + # from several modules with the same name select the one with the highest version + $selectedMod = $modules | Where-Object Name -EQ $mod.Name + if ($selectedMod.Count -gt 1) { Write-Debug -Debug -Message "Found $($selectedMod.Count) modules with name '$($mod.Name)'" - $selectedMod = $selectedMod | Sort-Object -Property Version -Descending | Select-Object -First 1 - } + $selectedMod = $selectedMod | Sort-Object -Property Version -Descending | Select-Object -First 1 + } - [System.Collections.Generic.List[DscResourceInfo]]$r = LoadPowerShellClassResourcesFromModule -moduleInfo $selectedMod - if ($r) { - $DscResources.AddRange($r) + [System.Collections.Generic.List[DscResourceInfo]]$r = LoadPowerShellClassResourcesFromModule -moduleInfo $selectedMod + if ($r) { + $DscResources.AddRange($r) + } } } } - } - foreach ($dscResource in $DscResources) { - $moduleName = $dscResource.ModuleName + 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 - $lastWriteTimes = @{} - Get-ChildItem -Recurse -File -LiteralPath $dscResource.ParentPath -Include "*.ps1", "*.psd1", "*.psm1", "*.mof" -ErrorAction Ignore | ForEach-Object { - $lastWriteTimes.Add($_.FullName, $_.LastWriteTime) - } + # fill in resource files (and their last-write-times) that will be used for up-do-date checks + $lastWriteTimes = @{} + Get-ChildItem -Recurse -File -LiteralPath $dscResource.ParentPath -Include "*.ps1", "*.psd1", "*.psm1", "*.mof" -ErrorAction Ignore | ForEach-Object { + $lastWriteTimes.Add($_.FullName, $_.LastWriteTime) + } - $dscResourceCacheEntries += [dscResourceCacheEntry]@{ - Type = "$moduleName/$($dscResource.Name)" - DscResourceInfo = $dscResource - LastWriteTimes = $lastWriteTimes + $dscResourceCacheEntries += [dscResourceCacheEntry]@{ + Type = "$moduleName/$($dscResource.Name)" + DscResourceInfo = $dscResource + LastWriteTimes = $lastWriteTimes + } } - } - [dscResourceCache]$cache = [dscResourceCache]::new() - $cache.ResourceCache = $dscResourceCacheEntries - $m = $env:PSModulePath.Split([IO.Path]::PathSeparator, [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { Get-ChildItem -Directory -LiteralPath $_ -Depth 1 -ErrorAction Ignore } - $cache.PSModulePaths = $m.FullName - $cache.CacheSchemaVersion = $script:CurrentCacheSchemaVersion + [dscResourceCache]$cache = [dscResourceCache]::new() + $cache.ResourceCache = $dscResourceCacheEntries + $m = $env:PSModulePath.Split([IO.Path]::PathSeparator, [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { Get-ChildItem -Directory -LiteralPath $_ -Depth 1 -ErrorAction Ignore } + $cache.PSModulePaths = $m.FullName + $cache.CacheSchemaVersion = $script:CurrentCacheSchemaVersion - # save cache for future use - # TODO: replace this with a high-performance serializer + # save cache for future use + # TODO: replace this with a high-performance serializer Write-Debug -Debug -Message "Saving Get-DscResource cache to '$cacheFilePath'" - $jsonCache = $cache | ConvertTo-Json -Depth 90 - New-Item -Force -Path $cacheFilePath -Value $jsonCache -Type File | Out-Null - } + $jsonCache = $cache | ConvertTo-Json -Depth 90 + New-Item -Force -Path $cacheFilePath -Value $jsonCache -Type File | Out-Null + } - return $dscResourceCacheEntries + return $dscResourceCacheEntries } } @@ -452,28 +452,28 @@ function Get-DscResourceObject { ) process { - # normalize the INPUT object to an array of dscResourceObject objects - $inputObj = $jsonInput | ConvertFrom-Json - if ($type) { - $desiredState = [dscResourceObject]@{ - name = '' - type = $type - properties = $inputObj + # normalize the INPUT object to an array of dscResourceObject objects + $inputObj = $jsonInput | ConvertFrom-Json + if ($type) { + $desiredState = [dscResourceObject]@{ + name = '' + type = $type + properties = $inputObj + } } - } - else { - $desiredState = [System.Collections.Generic.List[Object]]::new() + else { + $desiredState = [System.Collections.Generic.List[Object]]::new() - $inputObj.resources | ForEach-Object -Process { - $desiredState += [dscResourceObject]@{ - name = $_.name - type = $_.type - properties = $_.properties + $inputObj.resources | ForEach-Object -Process { + $desiredState += [dscResourceObject]@{ + name = $_.name + type = $_.type + properties = $_.properties + } } } - } - return $desiredState + return $desiredState } } @@ -495,82 +495,82 @@ function Invoke-DscOperation { ) process { - $osVersion = [System.Environment]::OSVersion.VersionString + $osVersion = [System.Environment]::OSVersion.VersionString Write-Debug -Debug -Message "OS version: $osVersion" - $psVersion = $PSVersionTable.PSVersion.ToString() + $psVersion = $PSVersionTable.PSVersion.ToString() Write-Debug -Debug -Message "PowerShell version: $psVersion" - # get details from cache about the DSC resource, if it exists - $cachedDscResourceInfo = $dscResourceCache | Where-Object Type -EQ $DesiredState.type | ForEach-Object DscResourceInfo | Select-Object -First 1 + # get details from cache about the DSC resource, if it exists + $cachedDscResourceInfo = $dscResourceCache | Where-Object Type -EQ $DesiredState.type | ForEach-Object DscResourceInfo | Select-Object -First 1 - # if the resource is found in the cache, get the actual state - if ($cachedDscResourceInfo) { + # if the resource is found in the cache, get the actual state + if ($cachedDscResourceInfo) { - # formated OUTPUT of each resource - $addToActualState = [dscResourceObject]@{} + # formated OUTPUT of each resource + $addToActualState = [dscResourceObject]@{} - # set top level properties of the OUTPUT object from INPUT object - $DesiredState.psobject.properties | ForEach-Object -Process { - if ($_.TypeNameOfValue -EQ 'System.String') { $addToActualState.$($_.Name) = $DesiredState.($_.Name) } - } + # set top level properties of the OUTPUT object from INPUT object + $DesiredState.psobject.properties | ForEach-Object -Process { + if ($_.TypeNameOfValue -EQ 'System.String') { $addToActualState.$($_.Name) = $DesiredState.($_.Name) } + } - # workaround: script based resources do not validate Get parameter consistency, so we need to remove any parameters the author chose not to include in Get-TargetResource - switch ([dscResourceType]$cachedDscResourceInfo.ImplementationDetail) { + # workaround: script based resources do not validate Get parameter consistency, so we need to remove any parameters the author chose not to include in Get-TargetResource + switch ([dscResourceType]$cachedDscResourceInfo.ImplementationDetail) { - 'ClassBased' { - try { - # load powershell class from external module - $resource = GetTypeInstanceFromModule -modulename $cachedDscResourceInfo.ModuleName -classname $cachedDscResourceInfo.Name - $dscResourceInstance = $resource::New() + 'ClassBased' { + try { + # load powershell class from external module + $resource = GetTypeInstanceFromModule -modulename $cachedDscResourceInfo.ModuleName -classname $cachedDscResourceInfo.Name + $dscResourceInstance = $resource::New() - $ValidProperties = $cachedDscResourceInfo.Properties.Name + $ValidProperties = $cachedDscResourceInfo.Properties.Name Write-Debug -Debug -Message "Valid properties: $($ValidProperties | ConvertTo-Json)" - if ($DesiredState.properties) { - # set each property of $dscResourceInstance to the value of the property in the $desiredState INPUT object - $DesiredState.properties.psobject.properties | ForEach-Object -Process { - # handle input objects by converting them to a hash table - $validateProperty = $cachedDscResourceInfo.Properties | Where-Object -Property Name -EQ $_.Name - if ($_.Value -is [System.Management.Automation.PSCustomObject]) { - if ($validateProperty -and $validateProperty.PropertyType -in @('PSCredential', 'System.Management.Automation.PSCredential')) { - $hasSecureCred = - $_.Value.PSObject.Properties['secureObject'] -and - $_.Value.secureObject.Username -and - $_.Value.secureObject.Password - - $hasTextCred = - $_.Value.Username -and - $_.Value.Password - - if (-not $hasSecureCred -and -not $hasTextCred) { + if ($DesiredState.properties) { + # set each property of $dscResourceInstance to the value of the property in the $desiredState INPUT object + $DesiredState.properties.psobject.properties | ForEach-Object -Process { + # handle input objects by converting them to a hash table + $validateProperty = $cachedDscResourceInfo.Properties | Where-Object -Property Name -EQ $_.Name + if ($_.Value -is [System.Management.Automation.PSCustomObject]) { + if ($validateProperty -and $validateProperty.PropertyType -in @('PSCredential', 'System.Management.Automation.PSCredential')) { + $hasSecureCred = + $_.Value.PSObject.Properties['secureObject'] -and + $_.Value.secureObject.Username -and + $_.Value.secureObject.Password + + $hasTextCred = + $_.Value.Username -and + $_.Value.Password + + if (-not $hasSecureCred -and -not $hasTextCred) { Write-Error -Message "Credential object '$($_.Name)' requires both 'username' and 'password' properties" - exit 1 - } + exit 1 + } - if ($hasSecureCred) { + if ($hasSecureCred) { Write-Debug -Debug -Message "Credential object '$($_.Name)' - SecureObject" - $username = $_.Value.secureObject.Username - $password = $_.Value.secureObject.Password | - ConvertTo-SecureString -AsPlainText -Force + $username = $_.Value.secureObject.Username + $password = $_.Value.secureObject.Password | + ConvertTo-SecureString -AsPlainText -Force - $dscResourceInstance.$($_.Name) = - [System.Management.Automation.PSCredential]::new($username, $password) - } - elseif ($hasTextCred) { + $dscResourceInstance.$($_.Name) = + [System.Management.Automation.PSCredential]::new($username, $password) + } + elseif ($hasTextCred) { Write-Debug -Debug -Message "Credential object '$($_.Name)' - Text" - $username = $_.Value.Username - $password = $_.Value.Password | - ConvertTo-SecureString -AsPlainText -Force + $username = $_.Value.Username + $password = $_.Value.Password | + ConvertTo-SecureString -AsPlainText -Force - $dscResourceInstance.$($_.Name) = - [System.Management.Automation.PSCredential]::new($username, $password) + $dscResourceInstance.$($_.Name) = + [System.Management.Automation.PSCredential]::new($username, $password) + } } - } - else { + else { $dscResourceInstance.$($_.Name) = $_.Value.psobject.properties | ForEach-Object -Begin { [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', @@ -581,90 +581,90 @@ function Invoke-DscOperation { } -Process { $propertyHash[$_.Name] = $_.Value } -End { $propertyHash } - } - } - else { - if ($validateProperty -and $validateProperty.PropertyType -in @('SecureString', 'System.Security.SecureString') -and -not [string]::IsNullOrEmpty($_.Value)) { - $dscResourceInstance.$($_.Name) = ConvertTo-SecureString -AsPlainText $_.Value -Force - } else { - $dscResourceInstance.$($_.Name) = $_.Value - } - } - } - } - - switch ($Operation) { - 'Get' { - $Result = @{} - $raw_obj = $dscResourceInstance.Get() - $ValidProperties | ForEach-Object { - if ($raw_obj.$_ -is [System.Enum]) { - $Result[$_] = $raw_obj.$_.ToString() - + } } else { - $Result[$_] = $raw_obj.$_ + if ($validateProperty -and $validateProperty.PropertyType -in @('SecureString', 'System.Security.SecureString') -and -not [string]::IsNullOrEmpty($_.Value)) { + $dscResourceInstance.$($_.Name) = ConvertTo-SecureString -AsPlainText $_.Value -Force + } else { + $dscResourceInstance.$($_.Name) = $_.Value + } } } - $addToActualState.properties = $Result - } - 'Set' { - $dscResourceInstance.Set() - } - 'Test' { - $Result = $dscResourceInstance.Test() - $addToActualState.properties = [psobject]@{'InDesiredState' = $Result } } - 'Export' { - $t = $dscResourceInstance.GetType() - $hasFilter = $null -ne $DesiredState.properties -and - ($DesiredState.properties.PSObject.Properties | Measure-Object).Count -gt 0 - - $method = GetExportMethod -ResourceType $t -HasFilterProperties $hasFilter -ResourceTypeName $DesiredState.Type - - $resultArray = @() - if ($hasFilter) { - $raw_obj_array = $method.Invoke($null, @($dscResourceInstance)) - } else { - $raw_obj_array = $method.Invoke($null, $null) - } - foreach ($raw_obj in $raw_obj_array) { - $Result_obj = @{} + switch ($Operation) { + 'Get' { + $Result = @{} + $raw_obj = $dscResourceInstance.Get() $ValidProperties | ForEach-Object { if ($raw_obj.$_ -is [System.Enum]) { - $Result_obj[$_] = $raw_obj.$_.ToString() + $Result[$_] = $raw_obj.$_.ToString() + } else { - $Result_obj[$_] = $raw_obj.$_ + $Result[$_] = $raw_obj.$_ + } + } + $addToActualState.properties = $Result + } + 'Set' { + $dscResourceInstance.Set() + } + 'Test' { + $Result = $dscResourceInstance.Test() + $addToActualState.properties = [psobject]@{'InDesiredState' = $Result } + } + 'Export' { + $t = $dscResourceInstance.GetType() + $hasFilter = $null -ne $DesiredState.properties -and + ($DesiredState.properties.PSObject.Properties | Measure-Object).Count -gt 0 + + $method = GetExportMethod -ResourceType $t -HasFilterProperties $hasFilter -ResourceTypeName $DesiredState.Type + + $resultArray = @() + if ($hasFilter) { + $raw_obj_array = $method.Invoke($null, @($dscResourceInstance)) + } else { + $raw_obj_array = $method.Invoke($null, $null) + } + + foreach ($raw_obj in $raw_obj_array) { + $Result_obj = @{} + $ValidProperties | ForEach-Object { + if ($raw_obj.$_ -is [System.Enum]) { + $Result_obj[$_] = $raw_obj.$_.ToString() + } + else { + $Result_obj[$_] = $raw_obj.$_ + } } + $resultArray += $Result_obj } - $resultArray += $Result_obj + $addToActualState = $resultArray } - $addToActualState = $resultArray } } - } - catch { + catch { - Write-Error ('Exception: ' + $_.Exception.Message) + Write-Error ('Exception: ' + $_.Exception.Message) + exit 1 + } + } + Default { + Write-Error ('Resource ImplementationDetail not supported: ' + $cachedDscResourceInfo.ImplementationDetail) exit 1 } } - Default { - Write-Error ('Resource ImplementationDetail not supported: ' + $cachedDscResourceInfo.ImplementationDetail) - exit 1 - } - } Write-Debug -Debug -Message "Output: $($addToActualState | ConvertTo-Json -Depth 10 -Compress)" - return $addToActualState - } - else { - $dsJSON = $DesiredState | ConvertTo-Json -Depth 10 - Write-Error ('Can not find type "' + $DesiredState.type + '" for resource "' + $dsJSON + '". Please ensure that Get-DscResource returns this resource type.') - exit 1 - } + return $addToActualState + } + else { + $dsJSON = $DesiredState | ConvertTo-Json -Depth 10 + Write-Error ('Can not find type "' + $DesiredState.type + '" for resource "' + $dsJSON + '". Please ensure that Get-DscResource returns this resource type.') + exit 1 + } } } diff --git a/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 b/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 index 8b2f6d204..5642fa4b5 100644 --- a/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 +++ b/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 @@ -60,50 +60,50 @@ function Invoke-DscCacheRefresh { ) process { - $refreshCache = $false - $namedModules = [System.Collections.Generic.List[Object]]::new() - $cacheFilePath = Join-Path $env:LocalAppData "dsc\WindowsPSAdapterCache.json" + $refreshCache = $false + $namedModules = [System.Collections.Generic.List[Object]]::new() + $cacheFilePath = Join-Path $env:LocalAppData "dsc\WindowsPSAdapterCache.json" - # Repair PSModulePath to not contain empty entries - Repair-ValidPSModulePath + # Repair PSModulePath to not contain empty entries + Repair-ValidPSModulePath - if (Test-Path $cacheFilePath) { + if (Test-Path $cacheFilePath) { Write-Verbose -Verbose -Message "Reading from Get-DscResource cache file $cacheFilePath" - $cache = Get-Content -Raw $cacheFilePath | ConvertFrom-Json - if ($cache.CacheSchemaVersion -ne $script:CurrentCacheSchemaVersion) { - $refreshCache = $true + $cache = Get-Content -Raw $cacheFilePath | ConvertFrom-Json + if ($cache.CacheSchemaVersion -ne $script:CurrentCacheSchemaVersion) { + $refreshCache = $true $msg = "Incompatible version of cache in file '{0}' (expected '{1}')" -f @( $cache.CacheSchemaVersion $script:CurrentCacheSchemaVersion ) Write-Verbose -Verbose -Message $msg - } else { - $dscResourceCacheEntries = $cache.ResourceCache + } else { + $dscResourceCacheEntries = $cache.ResourceCache - if ($dscResourceCacheEntries.Count -eq 0) { - # if there is nothing in the cache file - refresh cache - $refreshCache = $true + if ($dscResourceCacheEntries.Count -eq 0) { + # if there is nothing in the cache file - refresh cache + $refreshCache = $true Write-Debug -Debug -Message "Filtered DscResourceCache cache is empty" - } else { + } else { Write-Debug -Debug -Message "Checking cache for stale PSModulePath" - $m = $env:PSModulePath.Split([IO.Path]::PathSeparator, [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { Get-ChildItem -Directory -Path $_ -Depth 1 -ErrorAction Ignore } + $m = $env:PSModulePath.Split([IO.Path]::PathSeparator, [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { Get-ChildItem -Directory -Path $_ -Depth 1 -ErrorAction Ignore } - $hs_cache = [System.Collections.Generic.HashSet[string]]($cache.PSModulePaths) - $hs_live = [System.Collections.Generic.HashSet[string]]($m.FullName) - $hs_cache.SymmetricExceptWith($hs_live) - $diff = $hs_cache + $hs_cache = [System.Collections.Generic.HashSet[string]]($cache.PSModulePaths) + $hs_live = [System.Collections.Generic.HashSet[string]]($m.FullName) + $hs_cache.SymmetricExceptWith($hs_live) + $diff = $hs_cache Write-Debug -Debug -Message "PSModulePath diff '$diff'" - # TODO: Optimise for named module refresh - if ($diff.Count -gt 0) { - $refreshCache = $true - } + # TODO: Optimise for named module refresh + if ($diff.Count -gt 0) { + $refreshCache = $true + } - if (-not $refreshCache) { + if (-not $refreshCache) { Write-Debug -Debug -Message "Checking cache for stale entries" - foreach ($cacheEntry in $dscResourceCacheEntries) { + foreach ($cacheEntry in $dscResourceCacheEntries) { foreach ($cacheEntryItem in $cacheEntry.LastWriteTimes.PSObject.Properties) { @@ -111,186 +111,186 @@ function Invoke-DscCacheRefresh { $file_LastWriteTime = (Get-Item $cacheEntryItem.Name).LastWriteTime.ToFileTime() $cache_LastWriteTime = [long]$cacheEntryItem.Value - if ($file_LastWriteTime -ne $cache_LastWriteTime) { + if ($file_LastWriteTime -ne $cache_LastWriteTime) { Write-Debug -Debug -Message "Detected stale cache entry '$($cacheEntryItem.Name)'" + $namedModules.Add($cacheEntry.DscResourceInfo.ModuleName) + break + } + } else { + Write-Debug -Debug -Message "Detected non-existent cache entry '$($cacheEntryItem.Name)'" $namedModules.Add($cacheEntry.DscResourceInfo.ModuleName) break } - } else { - Write-Debug -Debug -Message "Detected non-existent cache entry '$($cacheEntryItem.Name)'" - $namedModules.Add($cacheEntry.DscResourceInfo.ModuleName) - break } } } - } - if ($namedModules.Count -gt 0) { - $refreshCache = $true - if ($null -ne $Module) { - $namedModules.AddRange(@($Module)) - } - $namedModules = $namedModules | Sort-Object -Unique + if ($namedModules.Count -gt 0) { + $refreshCache = $true + if ($null -ne $Module) { + $namedModules.AddRange(@($Module)) + } + $namedModules = $namedModules | Sort-Object -Unique Write-Debug -Debug -Message "Module list: $($namedModules -join ', ')" + } } } - } - } else { + } else { Write-Verbose -Verbose -Message "Cache file not found '$cacheFilePath'" - $refreshCache = $true - } + $refreshCache = $true + } - if ($refreshCache) { + if ($refreshCache) { Write-Verbose -Verbose -Message "Constructing Get-DscResource cache" - # create a list object to store cache of Get-DscResource - $dscResourceCacheEntries = [System.Collections.Generic.List[dscResourceCacheEntry]]::new() + # create a list object to store cache of Get-DscResource + $dscResourceCacheEntries = [System.Collections.Generic.List[dscResourceCacheEntry]]::new() - # improve by performance by having the option to only get details for named modules - # workaround for File and SignatureValidation resources that ship in Windows + # improve by performance by having the option to only get details for named modules + # workaround for File and SignatureValidation resources that ship in Windows Write-Debug -Debug -Message "Named module count: $($namedModules.Count)" - if ($namedModules.Count -gt 0) { + if ($namedModules.Count -gt 0) { Write-Debug -Debug -Message "Modules specified, getting DSC resources from modules: $($namedModules -join ', ')" - $DscResources = [System.Collections.Generic.List[Object]]::new() - $Modules = [System.Collections.Generic.List[Object]]::new() - $filteredResources = @() - foreach ($m in $namedModules) { + $DscResources = [System.Collections.Generic.List[Object]]::new() + $Modules = [System.Collections.Generic.List[Object]]::new() + $filteredResources = @() + foreach ($m in $namedModules) { Write-Debug -Debug -Message "Getting DSC resources for module '$($m | Out-String)'" - $DscResources.AddRange(@(Get-DscResource -Module $m)) - $Modules.AddRange(@(Get-Module -Name $m -ListAvailable)) - } + $DscResources.AddRange(@(Get-DscResource -Module $m)) + $Modules.AddRange(@(Get-Module -Name $m -ListAvailable)) + } - if ('PSDesiredStateConfiguration' -in $namedModules -and $PSVersionTable.PSVersion.Major -le 5 ) { - # the resources in Windows should only load in Windows PowerShell - # workaround: the binary modules don't have a module name, so we have to special case File and SignatureValidation resources that ship in Windows - $DscResources.AddRange(@(Get-DscResource | Where-Object -Property ParentPath -eq "$env:windir\system32\Configuration\BaseRegistration")) - $filteredResources = @( - 'PSDesiredStateConfiguration/File' - 'PSDesiredStateConfiguration/SignatureValidation' - ) - } - # Grab all DSC resources to filter out of the cache - $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 { + if ('PSDesiredStateConfiguration' -in $namedModules -and $PSVersionTable.PSVersion.Major -le 5 ) { + # the resources in Windows should only load in Windows PowerShell + # workaround: the binary modules don't have a module name, so we have to special case File and SignatureValidation resources that ship in Windows + $DscResources.AddRange(@(Get-DscResource | Where-Object -Property ParentPath -eq "$env:windir\system32\Configuration\BaseRegistration")) + $filteredResources = @( + 'PSDesiredStateConfiguration/File' + 'PSDesiredStateConfiguration/SignatureValidation' + ) + } + # Grab all DSC resources to filter out of the cache + $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 { Write-Debug -Debug -Message "No modules specified, getting all DSC resources" - $DscResources = Get-DscResource - $Modules = Get-Module -ListAvailable - } + $DscResources = Get-DscResource + $Modules = Get-Module -ListAvailable + } - $psdscVersion = Get-Module PSDesiredStateConfiguration | Sort-Object -descending | Select-Object -First 1 | ForEach-Object Version + $psdscVersion = Get-Module PSDesiredStateConfiguration | Sort-Object -descending | Select-Object -First 1 | ForEach-Object Version - 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 - } + 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 + } - # we can't run this check in PSDesiredStateConfiguration 1.1 because the property doesn't exist - if ( $psdscVersion -ge '2.0.7' ) { - # only support known dscResourceType - if ([dscResourceType].GetEnumNames() -notcontains $dscResource.ImplementationDetail) { + # we can't run this check in PSDesiredStateConfiguration 1.1 because the property doesn't exist + if ( $psdscVersion -ge '2.0.7' ) { + # only support known dscResourceType + if ([dscResourceType].GetEnumNames() -notcontains $dscResource.ImplementationDetail) { Write-Warning -Message "Implementation detail not found: $($dscResource.ImplementationDetail)" - continue + continue + } } - } - # workaround: if the resource does not have a module name, get it from parent path - # workaround: modulename is not settable, so clone the object without being read-only - # workaround: we have to special case File and SignatureValidation resources that ship in Windows - $binaryBuiltInModulePaths = @( - "$env:windir\system32\Configuration\Schema\MSFT_FileDirectoryConfiguration" - "$env:windir\system32\Configuration\BaseRegistration" - ) - $DscResourceInfo = [DscResourceInfo]::new() - $dscResource.PSObject.Properties | ForEach-Object -Process { - if ($null -ne $_.Value) { - # Handle version specially to munge as semantic version - if ($_.Name -eq 'Version') { - $DscResourceInfo.$($_.Name) = ConvertTo-SemanticVersionString $_.Value + # workaround: if the resource does not have a module name, get it from parent path + # workaround: modulename is not settable, so clone the object without being read-only + # workaround: we have to special case File and SignatureValidation resources that ship in Windows + $binaryBuiltInModulePaths = @( + "$env:windir\system32\Configuration\Schema\MSFT_FileDirectoryConfiguration" + "$env:windir\system32\Configuration\BaseRegistration" + ) + $DscResourceInfo = [DscResourceInfo]::new() + $dscResource.PSObject.Properties | ForEach-Object -Process { + if ($null -ne $_.Value) { + # Handle version specially to munge as semantic version + if ($_.Name -eq 'Version') { + $DscResourceInfo.$($_.Name) = ConvertTo-SemanticVersionString $_.Value + } else { + $DscResourceInfo.$($_.Name) = $_.Value + } } else { - $DscResourceInfo.$($_.Name) = $_.Value + $DscResourceInfo.$($_.Name) = '' } - } else { - $DscResourceInfo.$($_.Name) = '' } - } - if ($dscResource.ModuleName) { - $moduleName = $dscResource.ModuleName - } elseif ($binaryBuiltInModulePaths -contains $dscResource.ParentPath) { - $moduleName = 'PSDesiredStateConfiguration' - $DscResourceInfo.Module = 'PSDesiredStateConfiguration' - $DscResourceInfo.ModuleName = 'PSDesiredStateConfiguration' - $DscResourceInfo.CompanyName = 'Microsoft Corporation' - $DscResourceInfo.Version = '1.0.0' - if ($PSVersionTable.PSVersion.Major -le 5 -and $DscResourceInfo.ImplementedAs -eq 'Binary') { - $DscResourceInfo.ImplementationDetail = 'Binary' - } - } 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 - $DscResourceInfo.ModuleName = $moduleName - # workaround: populate module version from psmoduleinfo if available - if ($moduleInfo = $Modules | Where-Object { $_.Name -eq $moduleName }) { - $moduleInfo = $moduleInfo | Sort-Object -Property Version -Descending | Select-Object -First 1 - $DscResourceInfo.Version = ConvertTo-SemanticVersionString $moduleInfo.Version + if ($dscResource.ModuleName) { + $moduleName = $dscResource.ModuleName + } elseif ($binaryBuiltInModulePaths -contains $dscResource.ParentPath) { + $moduleName = 'PSDesiredStateConfiguration' + $DscResourceInfo.Module = 'PSDesiredStateConfiguration' + $DscResourceInfo.ModuleName = 'PSDesiredStateConfiguration' + $DscResourceInfo.CompanyName = 'Microsoft Corporation' + $DscResourceInfo.Version = '1.0.0' + if ($PSVersionTable.PSVersion.Major -le 5 -and $DscResourceInfo.ImplementedAs -eq 'Binary') { + $DscResourceInfo.ImplementationDetail = 'Binary' + } + } 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 + $DscResourceInfo.ModuleName = $moduleName + # workaround: populate module version from psmoduleinfo if available + if ($moduleInfo = $Modules | Where-Object { $_.Name -eq $moduleName }) { + $moduleInfo = $moduleInfo | Sort-Object -Property Version -Descending | Select-Object -First 1 + $DscResourceInfo.Version = ConvertTo-SemanticVersionString $moduleInfo.Version + } } - } - # workaround: Use GetTypeInstanceFromModule to get the type instance from the module and validate if it is a class-based resource - try { - $classBased = GetTypeInstanceFromModule -modulename $moduleName -classname $dscResource.Name - } - catch { + # workaround: Use GetTypeInstanceFromModule to get the type instance from the module and validate if it is a class-based resource + try { + $classBased = GetTypeInstanceFromModule -modulename $moduleName -classname $dscResource.Name + } + catch { Write-Warning -Message "Failed to get type instance from module '$moduleName': $_" - } - if ($classBased -and ($classBased.CustomAttributes.AttributeType.Name -eq 'DscResourceAttribute')) { + } + if ($classBased -and ($classBased.CustomAttributes.AttributeType.Name -eq 'DscResourceAttribute')) { Write-Debug -Debug -Message "Detected class-based resource: $($dscResource.Name) => Type: $($classBased.BaseType.FullName)" - $dscResourceInfo.ImplementationDetail = 'ClassBased' - $properties = GetClassBasedProperties -filePath $dscResource.Path -className $dscResource.Name - if ($null -ne $properties) { - $DscResourceInfo.Properties = $properties + $dscResourceInfo.ImplementationDetail = 'ClassBased' + $properties = GetClassBasedProperties -filePath $dscResource.Path -className $dscResource.Name + if ($null -ne $properties) { + $DscResourceInfo.Properties = $properties + } + + $dscResourceInfo.Capabilities = GetClassBasedCapabilities -filePath $dscResource.Path -className $dscResource.Name } - $dscResourceInfo.Capabilities = GetClassBasedCapabilities -filePath $dscResource.Path -className $dscResource.Name - } + # 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 | ForEach-Object { + $lastWriteTimes.Add($_.FullName, $_.LastWriteTime.ToFileTime()) + } - # 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 | ForEach-Object { - $lastWriteTimes.Add($_.FullName, $_.LastWriteTime.ToFileTime()) + $dscResourceCacheEntries.Add([dscResourceCacheEntry]@{ + Type = "$moduleName/$($dscResource.Name)" + DscResourceInfo = $DscResourceInfo + LastWriteTimes = $lastWriteTimes + }) } - $dscResourceCacheEntries.Add([dscResourceCacheEntry]@{ - Type = "$moduleName/$($dscResource.Name)" - DscResourceInfo = $DscResourceInfo - LastWriteTimes = $lastWriteTimes - }) - } - - if ($namedModules.Count -gt 0) { - # Make sure all resource cache entries are returned - foreach ($entry in $existingDscResourceCacheEntries) { - $dscResourceCacheEntries.Add([dscResourceCacheEntry]$entry) + if ($namedModules.Count -gt 0) { + # Make sure all resource cache entries are returned + foreach ($entry in $existingDscResourceCacheEntries) { + $dscResourceCacheEntries.Add([dscResourceCacheEntry]$entry) + } } - } - [dscResourceCache]$cache = [dscResourceCache]::new() - $cache.ResourceCache = $dscResourceCacheEntries.ToArray() - $m = $env:PSModulePath.Split([IO.Path]::PathSeparator, [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { Get-ChildItem -Directory -Path $_ -Depth 1 -ErrorAction Ignore } - $cache.PSModulePaths = $m.FullName - $cache.CacheSchemaVersion = $script:CurrentCacheSchemaVersion + [dscResourceCache]$cache = [dscResourceCache]::new() + $cache.ResourceCache = $dscResourceCacheEntries.ToArray() + $m = $env:PSModulePath.Split([IO.Path]::PathSeparator, [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { Get-ChildItem -Directory -Path $_ -Depth 1 -ErrorAction Ignore } + $cache.PSModulePaths = $m.FullName + $cache.CacheSchemaVersion = $script:CurrentCacheSchemaVersion - # save cache for future use - # TODO: replace this with a high-performance serializer + # save cache for future use + # TODO: replace this with a high-performance serializer Write-Debug -Debug -Message "Saving Get-DscResource cache to '$cacheFilePath'" - $jsonCache = $cache | ConvertTo-Json -Depth 90 - New-Item -Force -Path $cacheFilePath -Value $jsonCache -Type File | Out-Null - } + $jsonCache = $cache | ConvertTo-Json -Depth 90 + New-Item -Force -Path $cacheFilePath -Value $jsonCache -Type File | Out-Null + } - return $dscResourceCacheEntries + return $dscResourceCacheEntries } } @@ -304,28 +304,28 @@ function Get-DscResourceObject { ) process { - # normalize the INPUT object to an array of dscResourceObject objects - $inputObj = $jsonInput | ConvertFrom-Json - if ($type) { - $desiredState = [dscResourceObject]@{ - name = '' - type = $type - properties = $inputObj + # normalize the INPUT object to an array of dscResourceObject objects + $inputObj = $jsonInput | ConvertFrom-Json + if ($type) { + $desiredState = [dscResourceObject]@{ + name = '' + type = $type + properties = $inputObj + } } - } - else { - $desiredState = [System.Collections.Generic.List[Object]]::new() - - $inputObj.resources | ForEach-Object -Process { - $desiredState += [dscResourceObject]@{ - name = $_.name - type = $_.type - properties = $_.properties + else { + $desiredState = [System.Collections.Generic.List[Object]]::new() + + $inputObj.resources | ForEach-Object -Process { + $desiredState += [dscResourceObject]@{ + name = $_.name + type = $_.type + properties = $_.properties + } } } - } - return $desiredState + return $desiredState } } @@ -348,99 +348,99 @@ function Invoke-DscOperation { ) process { - $osVersion = [System.Environment]::OSVersion.VersionString + $osVersion = [System.Environment]::OSVersion.VersionString Write-Debug -Debug -Message "OS version: $osVersion" - $psVersion = $PSVersionTable.PSVersion.ToString() + $psVersion = $PSVersionTable.PSVersion.ToString() Write-Debug -Debug -Message "PowerShell version: $psVersion" - $moduleVersion = Get-Module PSDesiredStateConfiguration | ForEach-Object Version + $moduleVersion = Get-Module PSDesiredStateConfiguration | ForEach-Object Version Write-Debug -Debug -Message "PSDesiredStateConfiguration module version: $moduleVersion" - # get details from cache about the DSC resource, if it exists - $cachedDscResourceInfo = $dscResourceCache | Where-Object Type -EQ $DesiredState.type | ForEach-Object DscResourceInfo | Select-Object -First 1 + # get details from cache about the DSC resource, if it exists + $cachedDscResourceInfo = $dscResourceCache | Where-Object Type -EQ $DesiredState.type | ForEach-Object DscResourceInfo | Select-Object -First 1 - # if the resource is found in the cache, get the actual state - if ($cachedDscResourceInfo) { + # if the resource is found in the cache, get the actual state + if ($cachedDscResourceInfo) { - # formated OUTPUT of each resource - $addToActualState = [dscResourceObject]@{} + # formated OUTPUT of each resource + $addToActualState = [dscResourceObject]@{} - # set top level properties of the OUTPUT object from INPUT object - $DesiredState.psobject.properties | ForEach-Object -Process { - if ($_.TypeNameOfValue -EQ 'System.String') { $addToActualState.$($_.Name) = $DesiredState.($_.Name) } - } + # set top level properties of the OUTPUT object from INPUT object + $DesiredState.psobject.properties | ForEach-Object -Process { + if ($_.TypeNameOfValue -EQ 'System.String') { $addToActualState.$($_.Name) = $DesiredState.($_.Name) } + } Write-Debug -Debug -Message "DSC resource implementation: $([dscResourceType]$cachedDscResourceInfo.ImplementationDetail)" - # workaround: script based resources do not validate Get parameter consistency, so we need to remove any parameters the author chose not to include in Get-TargetResource - switch ([dscResourceType]$cachedDscResourceInfo.ImplementationDetail) { - 'ScriptBased' { + # workaround: script based resources do not validate Get parameter consistency, so we need to remove any parameters the author chose not to include in Get-TargetResource + switch ([dscResourceType]$cachedDscResourceInfo.ImplementationDetail) { + 'ScriptBased' { - # For Linux/MacOS, only class based resources are supported and are called directly. - if ($IsLinux) { + # For Linux/MacOS, only class based resources are supported and are called directly. + if ($IsLinux) { Write-Error -Message 'Script based resources are only supported on Windows.' - exit 1 - } + exit 1 + } - # imports the .psm1 file for the DSC resource as a PowerShell module and stores the list of parameters - Import-Module -Scope Local -Name $cachedDscResourceInfo.path -Force -ErrorAction stop - $validParams = (Get-Command -Module $cachedDscResourceInfo.ResourceType -Name 'Get-TargetResource').Parameters.Keys + # imports the .psm1 file for the DSC resource as a PowerShell module and stores the list of parameters + Import-Module -Scope Local -Name $cachedDscResourceInfo.path -Force -ErrorAction stop + $validParams = (Get-Command -Module $cachedDscResourceInfo.ResourceType -Name 'Get-TargetResource').Parameters.Keys - if ($Operation -eq 'Get') { - # prune any properties that are not valid parameters of Get-TargetResource - $DesiredState.properties.psobject.properties | ForEach-Object -Process { - if ($validParams -notcontains $_.Name) { - $DesiredState.properties.psobject.properties.Remove($_.Name) + if ($Operation -eq 'Get') { + # prune any properties that are not valid parameters of Get-TargetResource + $DesiredState.properties.psobject.properties | ForEach-Object -Process { + if ($validParams -notcontains $_.Name) { + $DesiredState.properties.psobject.properties.Remove($_.Name) + } } } - } - # morph the INPUT object into a hashtable named "property" for the cmdlet Invoke-DscResource + # morph the INPUT object into a hashtable named "property" for the cmdlet Invoke-DscResource $DesiredState.properties.psobject.properties | ForEach-Object -Begin { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive')] $property = @{} } -Process { - if ($_.Value -is [System.Management.Automation.PSCustomObject]) { - $validateProperty = $cachedDscResourceInfo.Properties | Where-Object -Property Name -EQ $_.Name + if ($_.Value -is [System.Management.Automation.PSCustomObject]) { + $validateProperty = $cachedDscResourceInfo.Properties | Where-Object -Property Name -EQ $_.Name Write-Debug -Debug -Message "Property type: $($validateProperty.PropertyType)" - if ($validateProperty -and $validateProperty.PropertyType -eq '[PSCredential]') { + if ($validateProperty -and $validateProperty.PropertyType -eq '[PSCredential]') { - $hasSecureCred = - $_.Value.PSObject.Properties['secureObject'] -and - $_.Value.secureObject.Username -and - $_.Value.secureObject.Password + $hasSecureCred = + $_.Value.PSObject.Properties['secureObject'] -and + $_.Value.secureObject.Username -and + $_.Value.secureObject.Password - $hasTextCred = - $_.Value.Username -and - $_.Value.Password + $hasTextCred = + $_.Value.Username -and + $_.Value.Password - if (-not $hasSecureCred -and -not $hasTextCred) { + if (-not $hasSecureCred -and -not $hasTextCred) { Write-Error -Message "Credential object '$($_.Name)' requires both 'username' and 'password' properties" - exit 1 - } + exit 1 + } - if ($hasSecureCred) { + if ($hasSecureCred) { Write-Debug -Debug -Message "Credential object '$($_.Name)' - SecureObject" - $username = $_.Value.secureObject.Username - $password = $_.Value.secureObject.Password | - ConvertTo-SecureString -AsPlainText -Force + $username = $_.Value.secureObject.Username + $password = $_.Value.secureObject.Password | + ConvertTo-SecureString -AsPlainText -Force - $property.$($_.Name) = - [System.Management.Automation.PSCredential]::new($username, $password) - } - elseif ($hasTextCred) { + $property.$($_.Name) = + [System.Management.Automation.PSCredential]::new($username, $password) + } + elseif ($hasTextCred) { Write-Debug -Debug -Message "Credential object '$($_.Name)' - Text" - $username = $_.Value.Username - $password = $_.Value.Password | - ConvertTo-SecureString -AsPlainText -Force + $username = $_.Value.Username + $password = $_.Value.Password | + ConvertTo-SecureString -AsPlainText -Force - $property.$($_.Name) = - [System.Management.Automation.PSCredential]::new($username, $password) - } - } else { + $property.$($_.Name) = + [System.Management.Automation.PSCredential]::new($username, $password) + } + } else { $property.$($_.Name) = $_.Value.psobject.properties | ForEach-Object -Begin { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive')] $propertyHash = @{} @@ -449,26 +449,26 @@ function Invoke-DscOperation { } -End { $propertyHash } + } + } else { + $property[$_.Name] = $_.Value } - } else { - $property[$_.Name] = $_.Value } - } - # using the cmdlet the appropriate dsc module, and handle errors - try { + # using the cmdlet the appropriate dsc module, and handle errors + try { Write-Debug -Debug -Message "Module: $($cachedDscResourceInfo.ModuleName), Name: $($cachedDscResourceInfo.Name), Property: $($property | ConvertTo-Json -Compress)" - $invokeResult = Invoke-DscResource -Method $Operation -ModuleName $cachedDscResourceInfo.ModuleName -Name $cachedDscResourceInfo.Name -Property $property -ErrorAction Stop + $invokeResult = Invoke-DscResource -Method $Operation -ModuleName $cachedDscResourceInfo.ModuleName -Name $cachedDscResourceInfo.Name -Property $property -ErrorAction Stop - if ($invokeResult.GetType().Name -eq 'Hashtable') { + if ($invokeResult.GetType().Name -eq 'Hashtable') { $invokeResult.keys | ForEach-Object -Begin { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive')] $ResultProperties = @{} } -Process { $ResultProperties[$_] = $invokeResult.$_ } - } else { - # the object returned by WMI is a CIM instance with a lot of additional data. only return DSC properties + } 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 { @@ -477,69 +477,69 @@ function Invoke-DscOperation { } -Process { $ResultProperties[$_] = $invokeResult.$_ } - } + } - # set the properties of the OUTPUT object from the result of Get-TargetResource - $addToActualState.properties = $ResultProperties - } catch { + # set the properties of the OUTPUT object from the result of Get-TargetResource + $addToActualState.properties = $ResultProperties + } catch { Write-Debug -Debug -Message ($_.Exception | Format-List * -Force | Out-String) - if ($_.Exception.MessageId -eq 'DscResourceNotFound') { + if ($_.Exception.MessageId -eq 'DscResourceNotFound') { Write-Warning -Message 'For Windows PowerShell, DSC resources must be installed with scope AllUsers' - } + } Write-Error -Message "Exception: $($_.Exception.Message)" - exit 1 + exit 1 + } } - } - 'ClassBased' { - try { - # load powershell class from external module - $resource = GetTypeInstanceFromModule -modulename $cachedDscResourceInfo.ModuleName -classname $cachedDscResourceInfo.Name - $dscResourceInstance = $resource::New() + 'ClassBased' { + try { + # load powershell class from external module + $resource = GetTypeInstanceFromModule -modulename $cachedDscResourceInfo.ModuleName -classname $cachedDscResourceInfo.Name + $dscResourceInstance = $resource::New() - $ValidProperties = $cachedDscResourceInfo.Properties.Name + $ValidProperties = $cachedDscResourceInfo.Properties.Name Write-Debug -Debug -Message ($ValidProperties | ConvertTo-Json) - if ($DesiredState.properties) { - # set each property of $dscResourceInstance to the value of the property in the $desiredState INPUT object - $DesiredState.properties.psobject.properties | ForEach-Object -Process { - # handle input objects by converting them to a hash table - if ($_.Value -is [System.Management.Automation.PSCustomObject]) { - $validateProperty = $cachedDscResourceInfo.Properties | Where-Object -Property Name -EQ $_.Name + if ($DesiredState.properties) { + # set each property of $dscResourceInstance to the value of the property in the $desiredState INPUT object + $DesiredState.properties.psobject.properties | ForEach-Object -Process { + # handle input objects by converting them to a hash table + if ($_.Value -is [System.Management.Automation.PSCustomObject]) { + $validateProperty = $cachedDscResourceInfo.Properties | Where-Object -Property Name -EQ $_.Name Write-Debug -Debug -Message "Property type: $($validateProperty.PropertyType)" - if ($validateProperty.PropertyType -eq 'PSCredential') { - $hasSecureCred = - $_.Value.PSObject.Properties['secureObject'] -and - $_.Value.secureObject.Username -and - $_.Value.secureObject.Password + if ($validateProperty.PropertyType -eq 'PSCredential') { + $hasSecureCred = + $_.Value.PSObject.Properties['secureObject'] -and + $_.Value.secureObject.Username -and + $_.Value.secureObject.Password - $hasTextCred = - $_.Value.Username -and - $_.Value.Password + $hasTextCred = + $_.Value.Username -and + $_.Value.Password - if (-not $hasSecureCred -and -not $hasTextCred) { + if (-not $hasSecureCred -and -not $hasTextCred) { Write-Error -Message "Credential object '$($_.Name)' requires both 'username' and 'password' properties" - exit 1 - } + exit 1 + } - if ($hasSecureCred) { - $username = $_.Value.secureObject.Username - $password = $_.Value.secureObject.Password | - ConvertTo-SecureString -AsPlainText -Force + if ($hasSecureCred) { + $username = $_.Value.secureObject.Username + $password = $_.Value.secureObject.Password | + ConvertTo-SecureString -AsPlainText -Force - $dscResourceInstance.$($_.Name) = - [System.Management.Automation.PSCredential]::new($username, $password) - } - elseif ($hasTextCred) { - $username = $_.Value.Username - $password = $_.Value.Password | - ConvertTo-SecureString -AsPlainText -Force + $dscResourceInstance.$($_.Name) = + [System.Management.Automation.PSCredential]::new($username, $password) + } + elseif ($hasTextCred) { + $username = $_.Value.Username + $password = $_.Value.Password | + ConvertTo-SecureString -AsPlainText -Force - $dscResourceInstance.$($_.Name) = - [System.Management.Automation.PSCredential]::new($username, $password) - } + $dscResourceInstance.$($_.Name) = + [System.Management.Automation.PSCredential]::new($username, $password) + } - } else { + } else { $dscResourceInstance.$($_.Name) = $_.Value.psobject.properties | ForEach-Object -Begin { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive')] @@ -549,86 +549,86 @@ function Invoke-DscOperation { } -End { $propertyHash } - } - } else { - $dscResourceInstance.$($_.Name) = $_.Value - } - } - } - - switch ($Operation) { - 'Get' { - $Result = @{} - $raw_obj = $dscResourceInstance.Get() - $ValidProperties | ForEach-Object { - if ($raw_obj.$_ -is [System.Enum]) { - $Result[$_] = $raw_obj.$_.ToString() + } } else { - $Result[$_] = $raw_obj.$_ + $dscResourceInstance.$($_.Name) = $_.Value } } - $addToActualState.properties = $Result } - 'Set' { - $dscResourceInstance.Set() - } - 'Test' { - $Result = $dscResourceInstance.Test() - $addToActualState.properties = [psobject]@{'InDesiredState' = $Result } - } - 'Export' { - $method = ValidateMethod -operation $Operation -class $dscResourceInstance - $resultArray = @() - $raw_obj_array = $method.Invoke($null, $null) - foreach ($raw_obj in $raw_obj_array) { - $Result_obj = @{} + + switch ($Operation) { + 'Get' { + $Result = @{} + $raw_obj = $dscResourceInstance.Get() $ValidProperties | ForEach-Object { if ($raw_obj.$_ -is [System.Enum]) { - $Result_obj[$_] = $raw_obj.$_.ToString() + $Result[$_] = $raw_obj.$_.ToString() } else { - $Result_obj[$_] = $raw_obj.$_ + $Result[$_] = $raw_obj.$_ + } + } + $addToActualState.properties = $Result + } + 'Set' { + $dscResourceInstance.Set() + } + 'Test' { + $Result = $dscResourceInstance.Test() + $addToActualState.properties = [psobject]@{'InDesiredState' = $Result } + } + 'Export' { + $method = ValidateMethod -operation $Operation -class $dscResourceInstance + $resultArray = @() + $raw_obj_array = $method.Invoke($null, $null) + foreach ($raw_obj in $raw_obj_array) { + $Result_obj = @{} + $ValidProperties | ForEach-Object { + if ($raw_obj.$_ -is [System.Enum]) { + $Result_obj[$_] = $raw_obj.$_.ToString() + } else { + $Result_obj[$_] = $raw_obj.$_ + } } + $resultArray += $Result_obj } - $resultArray += $Result_obj + $addToActualState = $resultArray } - $addToActualState = $resultArray } - } - } catch { + } catch { Write-Debug -Debug -Message ($_.Exception | Format-List * -Force | Out-String) - if ($_.Exception.MessageId -eq 'DscResourceNotFound') { + if ($_.Exception.MessageId -eq 'DscResourceNotFound') { Write-Warning -Message 'For Windows PowerShell, DSC resources must be installed with scope AllUsers' - } + } Write-Error -Message "Exception: $($_.Exception.Message)" - exit 1 + exit 1 + } } - } - 'Binary' { - if ($PSVersionTable.PSVersion.Major -gt 5) { + 'Binary' { + if ($PSVersionTable.PSVersion.Major -gt 5) { Write-Debug -Debug -Message 'To use a binary resource such as File, Log, or SignatureValidation, use the Microsoft.Windows/WindowsPowerShell adapter.' - exit 1 - } + exit 1 + } - if (-not (($cachedDscResourceInfo.ImplementedAs -eq 'Binary') -and ('File', 'Log', 'SignatureValidation' -contains $cachedDscResourceInfo.Name))) { + if (-not (($cachedDscResourceInfo.ImplementedAs -eq 'Binary') -and ('File', 'Log', 'SignatureValidation' -contains $cachedDscResourceInfo.Name))) { Write-Debug -Debug -Message 'Only File, Log, and SignatureValidation are supported as Binary resources.' - exit 1 - } + exit 1 + } - # morph the INPUT object into a hashtable named "property" for the cmdlet Invoke-DscResource + # morph the INPUT object into a hashtable named "property" for the cmdlet Invoke-DscResource $DesiredState.properties.psobject.properties | ForEach-Object -Begin { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive')] $property = @{} } -Process { - if ($_.Value -is [System.Management.Automation.PSCustomObject]) { - $validateProperty = $cachedDscResourceInfo.Properties | Where-Object -Property Name -EQ $_.Name + if ($_.Value -is [System.Management.Automation.PSCustomObject]) { + $validateProperty = $cachedDscResourceInfo.Properties | Where-Object -Property Name -EQ $_.Name Write-Debug -Debug -Message "Property type: $($validateProperty.PropertyType)" - if ($validateProperty.PropertyType -eq '[PSCredential]') { - if (-not $_.Value.Username -or -not $_.Value.Password) { + if ($validateProperty.PropertyType -eq '[PSCredential]') { + if (-not $_.Value.Username -or -not $_.Value.Password) { Write-Error -Message "Credential object '$($_.Name)' requires both 'username' and 'password' properties" - exit 1 - } - $property.$($_.Name) = [System.Management.Automation.PSCredential]::new($_.Value.Username, (ConvertTo-SecureString -AsPlainText $_.Value.Password -Force)) - } else { + exit 1 + } + $property.$($_.Name) = [System.Management.Automation.PSCredential]::new($_.Value.Username, (ConvertTo-SecureString -AsPlainText $_.Value.Password -Force)) + } else { $property.$($_.Name) = $_.Value.psobject.properties | ForEach-Object -Begin { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive')] $propertyHash = @{} @@ -637,25 +637,25 @@ function Invoke-DscOperation { } -End { $propertyHash } + } + } else { + $property[$_.Name] = $_.Value } - } else { - $property[$_.Name] = $_.Value } - } - # using the cmdlet from PSDesiredStateConfiguration module in Windows - try { + # using the cmdlet from PSDesiredStateConfiguration module in Windows + try { Write-Debug -Debug -Message "Module: $($cachedDscResourceInfo.ModuleName), Name: $($cachedDscResourceInfo.Name), Property: $($property | ConvertTo-Json -Compress)" - $invokeResult = Invoke-DscResource -Method $Operation -ModuleName $cachedDscResourceInfo.ModuleName -Name $cachedDscResourceInfo.Name -Property $property - if ($invokeResult.GetType().Name -eq 'Hashtable') { + $invokeResult = Invoke-DscResource -Method $Operation -ModuleName $cachedDscResourceInfo.ModuleName -Name $cachedDscResourceInfo.Name -Property $property + if ($invokeResult.GetType().Name -eq 'Hashtable') { $invokeResult.keys | ForEach-Object -Begin { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive')] $ResultProperties = @{} } -Process { $ResultProperties[$_] = $invokeResult.$_ } - } else { - # the object returned by WMI is a CIM instance with a lot of additional data. only return DSC properties + } 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 { @@ -664,27 +664,27 @@ function Invoke-DscOperation { } -Process { $ResultProperties[$_] = $invokeResult.$_ } - } + } - # set the properties of the OUTPUT object from the result of Get-TargetResource - $addToActualState.properties = $ResultProperties - } catch { + # set the properties of the OUTPUT object from the result of Get-TargetResource + $addToActualState.properties = $ResultProperties + } catch { Write-Error -Message "Exception: $($_.Exception.Message)" - exit 1 + exit 1 + } } - } - Default { + Default { Write-Error -Message "Can not find implementation of type: $($cachedDscResourceInfo.ImplementationDetail)" - exit 1 + exit 1 + } } - } - return $addToActualState - } else { - $dsJSON = $DesiredState | ConvertTo-Json -Depth 10 + return $addToActualState + } else { + $dsJSON = $DesiredState | ConvertTo-Json -Depth 10 Write-Error -Message "Can not find type '$($DesiredState.type)' for resource '$dsJSON'. Please ensure that Get-DscResource returns this resource type." - exit 1 - } + exit 1 + } } } @@ -933,4 +933,4 @@ class DscResourceInfo { [string] $CompanyName [psobject[]] $Properties [string[]] $Capabilities -} \ No newline at end of file +} From df5887cdbcb7ab44484f2d1cf3bdb50d6f9426b6 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Mon, 26 Oct 2026 11:16:16 -0700 Subject: [PATCH 3/8] Address PowerShell adapter schema review feedback Preserve null values in nullable validation sets, restrict deeper JSON serialization to listings, and reuse indexed type definitions for enum and inheritance lookups. Clarify required-property naming and add nullable, array, and inherited-enum regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- adapters/powershell/Tests/schema.tests.ps1 | 33 +++++++++++++++++++ .../psDscAdapter/powershell.resource.ps1 | 6 +++- .../powershell/psDscAdapter/psDscAdapter.psm1 | 23 ++++++++----- .../psDscAdapter/win_psDscAdapter.psm1 | 16 +++++---- 4 files changed, 62 insertions(+), 16 deletions(-) diff --git a/adapters/powershell/Tests/schema.tests.ps1 b/adapters/powershell/Tests/schema.tests.ps1 index d6de8fdbc..be3553e19 100644 --- a/adapters/powershell/Tests/schema.tests.ps1 +++ b/adapters/powershell/Tests/schema.tests.ps1 @@ -34,6 +34,9 @@ enum SchemaEnsure { class SchemaBase { [DscProperty()] [string] $Inherited + + [DscProperty()] + [SchemaEnsure] $InheritedEnsure } [DscResource()] @@ -61,12 +64,19 @@ class TestSchemaResource : SchemaBase { [ValidateSet('One', 'Two')] [string] $Choice + [DscProperty()] + [ValidateSet('One', 'Two')] + [string[]] $Choices + [DscProperty()] [string[]] $Names [DscProperty()] [SchemaEnsure] $Ensure + [DscProperty()] + [SchemaEnsure[]] $Ensures + [DscProperty()] [hashtable] $Options @@ -131,6 +141,8 @@ class TestSchemaResource : SchemaBase { $resource.schema.properties.PSObject.Properties.Name | Should -Contain 'Inherited' $resource.schema.properties.PSObject.Properties.Name | Should -Not -Contain 'NotDscProperty' $resource.schema.properties.Ensure.anyOf[0].enum | Should -Be @('Present', 'Absent') + $resource.schema.properties.InheritedEnsure.anyOf[0].enum | Should -Be @('Present', 'Absent') + $resource.schema.properties.Ensures.items.anyOf[0].enum | Should -Be @('Present', 'Absent') } It 'Accepts valid resource data including nullable and complex properties' { @@ -148,6 +160,19 @@ class TestSchemaResource : SchemaBase { Should -BeTrue } + It 'Accepts nullable validation sets for ' -ForEach @( + @{ Case = 'a scalar'; Json = '{"Name":"test","RequiredValue":"value","Choice":null}' } + @{ Case = 'an array'; Json = '{"Name":"test","RequiredValue":"value","Choices":null}' } + @{ Case = 'array items'; Json = '{"Name":"test","RequiredValue":"value","Choices":["One",null,"two"]}' } + ) { + Test-Json -Json $Json -Schema $schema -ErrorAction Stop | Should -BeTrue + } + + It 'Preserves nested array and enumeration schemas during listing' { + $json = '{"Name":"test","RequiredValue":"value","Ensures":["Present","absent"],"InheritedEnsure":"present"}' + Test-Json -Json $json -Schema $schema -ErrorAction Stop | Should -BeTrue + } + It 'Accepts the serialized representation of a SecureString in resource output' { Test-Json -Json '{"Name":"test","RequiredValue":"value","Secret":{"Length":6}}' -Schema $schema -ErrorAction Stop | Should -BeTrue @@ -164,6 +189,10 @@ class TestSchemaResource : SchemaBase { @{ Reason = 'an invalid enumeration'; Json = '{"Name":"test","RequiredValue":"value","Ensure":"Other"}' } @{ Reason = 'an invalid string validation set'; Json = '{"Name":"test","RequiredValue":"value","Choice":"Other"}' } @{ Reason = 'an invalid numeric validation set'; Json = '{"Name":"test","RequiredValue":"value","Selection":3}' } + @{ Reason = 'an invalid array validation-set item'; Json = '{"Name":"test","RequiredValue":"value","Choices":["Other"]}' } + @{ Reason = 'an invalid enum-array item'; Json = '{"Name":"test","RequiredValue":"value","Ensures":["Other"]}' } + @{ Reason = 'a null value for a non-nullable enum'; Json = '{"Name":"test","RequiredValue":"value","Ensure":null}' } + @{ Reason = 'a null numeric validation set'; Json = '{"Name":"test","RequiredValue":"value","Selection":null}' } @{ Reason = 'a non-object credential'; Json = '{"Name":"test","RequiredValue":"value","Credential":"secret"}' } @{ Reason = 'an incorrectly typed case-insensitive property'; Json = '{"name":"test","requiredvalue":"value","count":"bad"}' } ) { @@ -175,6 +204,8 @@ class TestSchemaResource : SchemaBase { Where-Object -Property type -EQ 'TestSchemaResource/TestSchemaResource' Test-Json -Json '{"Name":"test","RequiredValue":"value","Count":"bad"}' -Schema ($cached.schema | ConvertTo-Json -Depth 100) -ErrorAction SilentlyContinue | Should -BeFalse + Test-Json -Json '{"Name":"test","RequiredValue":"value","Choice":null,"Choices":["one",null]}' -Schema ($cached.schema | ConvertTo-Json -Depth 100) -ErrorAction Stop | + Should -BeTrue $cached.schema.properties.Ensure.anyOf[0].enum | Should -Be @('Present', 'Absent') } @@ -200,6 +231,8 @@ class TestSchemaResource : SchemaBase { $windowsSchema = $windowsResource.schema | ConvertTo-Json -Depth 100 Test-Json -Json '{"name":"test","requiredvalue":"value","Ensure":"present"}' -Schema $windowsSchema -ErrorAction Stop | Should -BeTrue + Test-Json -Json '{"Name":"test","RequiredValue":"value","Choice":null,"Choices":["One",null,"two"]}' -Schema $windowsSchema -ErrorAction Stop | + Should -BeTrue Test-Json -Json '{"Name":"test","RequiredValue":"value","Count":"bad"}' -Schema $windowsSchema -ErrorAction SilentlyContinue | Should -BeFalse $scriptResource = $windowsResources | ConvertFrom-Json | diff --git a/adapters/powershell/psDscAdapter/powershell.resource.ps1 b/adapters/powershell/psDscAdapter/powershell.resource.ps1 index 358be2317..1dd9272f3 100644 --- a/adapters/powershell/psDscAdapter/powershell.resource.ps1 +++ b/adapters/powershell/psDscAdapter/powershell.resource.ps1 @@ -194,6 +194,9 @@ $ps = [PowerShell]::Create().AddScript({ @{ enum = @($Values) } @{ type = 'string'; pattern = '^(?i:' + (($Values | ForEach-Object { [regex]::Escape($_) }) -join '|') + ')$' } ) + if ('null' -in $schema.type) { + $schema.anyOf += @{ type = 'null' } + } } } return $schema @@ -525,6 +528,7 @@ Start-Sleep -Milliseconds 200 # wait a bit to ensure all events are processed b Get-EventSubscriber | Where-Object { $_.SourceIdentifier -like 'PSAdapter.*' } | ForEach-Object { Unregister-Event -SourceIdentifier $_.SourceIdentifier } # unregister before dispose Write-TraceQueue +$serializationDepth = if ($Operation -eq 'List') { 100 } else { 10 } foreach ($obj in $outputCollection) { - $obj | ConvertTo-Json -Depth 100 -Compress + $obj | ConvertTo-Json -Depth $serializationDepth -Compress } diff --git a/adapters/powershell/psDscAdapter/psDscAdapter.psm1 b/adapters/powershell/psDscAdapter/psDscAdapter.psm1 index 15d8dc8b0..bead5067b 100644 --- a/adapters/powershell/psDscAdapter/psDscAdapter.psm1 +++ b/adapters/powershell/psDscAdapter/psDscAdapter.psm1 @@ -44,15 +44,15 @@ function Get-DscModuleManifestList { function Add-AstMember { param( - $AllTypeDefinitions, + [hashtable]$TypeDefinitionsByName, $TypeAst, $Properties ) foreach ($TypeConstraint in $TypeAst.BaseTypes) { - $t = $AllTypeDefinitions | Where-Object { $_.Name -eq $TypeConstraint.TypeName.Name } + $t = $TypeDefinitionsByName[$TypeConstraint.TypeName.Name] if ($t) { - Add-AstMember -AllTypeDefinitions $AllTypeDefinitions -TypeAst $t -Properties $Properties + Add-AstMember -TypeDefinitionsByName $TypeDefinitionsByName -TypeAst $t -Properties $Properties } } @@ -62,13 +62,13 @@ function Add-AstMember { continue; } $skipProperty = $true - $isKeyProperty = $false + $isRequiredProperty = $false foreach ($attr in $property.Attributes) { if ($attr.TypeName.Name -eq 'DscProperty') { $skipProperty = $false foreach ($attrArg in $attr.NamedArguments) { if ($attrArg.ArgumentName -in @('Key', 'Mandatory') -and $attrArg.Argument.SafeGetValue()) { - $isKeyProperty = $true + $isRequiredProperty = $true break } } @@ -81,9 +81,9 @@ function Add-AstMember { [DscResourcePropertyInfo]$prop = [DscResourcePropertyInfo]::new() $prop.Name = $property.Name $prop.PropertyType = $property.PropertyType.TypeName.Name - $prop.IsMandatory = $isKeyProperty - $enumType = $AllTypeDefinitions | Where-Object { $_.IsEnum -and $_.Name -eq ($prop.PropertyType -replace '\[\]$', '') } - if ($enumType) { + $prop.IsMandatory = $isRequiredProperty + $enumType = $TypeDefinitionsByName[($prop.PropertyType -replace '\[\]$', '')] + if ($enumType.IsEnum) { $prop.Values = @($enumType.Members.Name) } foreach ($attr in $property.Attributes) { @@ -141,6 +141,11 @@ function FindAndParseResourceDefinitions { }, $false); + $typeDefinitionsByName = @{} + foreach ($typeDefinition in $typeDefinitions) { + $typeDefinitionsByName[$typeDefinition.Name] = $typeDefinition + } + $resourceList = [System.Collections.Generic.List[DscResourceInfo]]::new() foreach ($typeDefinitionAst in $typeDefinitions) { @@ -160,7 +165,7 @@ function FindAndParseResourceDefinitions { $DscResourceInfo.Properties = [System.Collections.Generic.List[DscResourcePropertyInfo]]::new() $DscResourceInfo.Capabilities = GetClassBasedCapabilities $typeDefinitionAst.Members - Add-AstMember -AllTypeDefinitions $typeDefinitions -TypeAst $typeDefinitionAst -Properties $DscResourceInfo.Properties + Add-AstMember -TypeDefinitionsByName $typeDefinitionsByName -TypeAst $typeDefinitionAst -Properties $DscResourceInfo.Properties $resourceList.Add($DscResourceInfo) } diff --git a/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 b/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 index 5642fa4b5..624607dd7 100644 --- a/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 +++ b/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 @@ -769,7 +769,11 @@ function GetClassBasedProperties { }, $false); - $typeDefinition = $typeDefinitions | Where-Object -Property Name -EQ $className + $typeDefinitionsByName = @{} + foreach ($definition in $typeDefinitions) { + $typeDefinitionsByName[$definition.Name] = $definition + } + $typeDefinition = $typeDefinitionsByName[$className] foreach ($member in $typeDefinition.Members) { $property = $member -as [System.Management.Automation.Language.PropertyMemberAst] @@ -777,13 +781,13 @@ function GetClassBasedProperties { continue; } $skipProperty = $true - $isKeyProperty = $false + $isRequiredProperty = $false foreach ($attr in $property.Attributes) { if ($attr.TypeName.Name -eq 'DscProperty') { $skipProperty = $false foreach ($attrArg in $attr.NamedArguments) { if ($attrArg.ArgumentName -in @('Key', 'Mandatory') -and $attrArg.Argument.SafeGetValue()) { - $isKeyProperty = $true + $isRequiredProperty = $true break } } @@ -796,9 +800,9 @@ function GetClassBasedProperties { [DscResourcePropertyInfo]$prop = [DscResourcePropertyInfo]::new() $prop.Name = $property.Name $prop.PropertyType = $property.PropertyType.TypeName.Name - $prop.IsMandatory = $isKeyProperty - $enumType = $typeDefinitions | Where-Object { $_.IsEnum -and $_.Name -eq ($prop.PropertyType -replace '\[\]$', '') } - if ($enumType) { + $prop.IsMandatory = $isRequiredProperty + $enumType = $typeDefinitionsByName[($prop.PropertyType -replace '\[\]$', '')] + if ($enumType.IsEnum) { $prop.Values = @($enumType.Members.Name) } foreach ($attr in $property.Attributes) { From 2bf0a08838d60d1f51580b8588284d67da0f23bc Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Tue, 27 Oct 2026 10:45:06 -0700 Subject: [PATCH 4/8] Clarify PowerShell adapter schema documentation Add schema retrieval examples, document validation-set and null behavior, and distinguish legacy wrapper validation from per-resource schemas and cached metadata. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Microsoft/Adapter/PowerShell/index.md | 11 ++++++++- .../Adapter/WindowsPowerShell/index.md | 11 ++++++++- .../Microsoft/DSC/PowerShell/index.md | 6 +++++ .../Windows/WindowsPowerShell/index.md | 24 +++++++++++++------ 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md b/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md index d6335423e..891037ec5 100644 --- a/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md +++ b/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md @@ -162,7 +162,11 @@ configuration document with the following exceptions: 1. Discovered resources include an embedded JSON schema describing their DSC properties. DSC uses this schema to validate property types, key and mandatory properties, and enumeration values - before invoking the resource. You can retrieve the schema with `dsc resource schema`. + before invoking the resource. To retrieve a resource's schema, replace the placeholders in: + + ```powershell + dsc resource schema -r '/' + ``` Each property name must be a configurable property of the PSDSC resource. The property name isn't case sensitive. The value for each property must be valid for that property. If you @@ -173,6 +177,11 @@ configuration document with the following exceptions: types without a known JSON representation are validated by the underlying PowerShell resource. The adapter doesn't implement a separate `validate` operation. + Schemas also enforce discovered `ValidateSet` constraints, including constraints on array + items. Nullable property types accept `null`, including nullable strings with a `ValidateSet` + constraint. Non-nullable enumeration and numeric types reject `null`, even when they have a + validation set. + ## Exit codes The resource uses the following exit codes to report success and errors: diff --git a/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md b/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md index 0d1c69fd7..2831b9e9f 100644 --- a/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md +++ b/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md @@ -172,7 +172,11 @@ configuration document with the following exceptions: 1. Discovered resources include an embedded JSON schema describing their DSC properties. DSC uses this schema to validate property types, key and mandatory properties, and enumeration values - before invoking the resource. You can retrieve the schema with `dsc resource schema`. + before invoking the resource. To retrieve a resource's schema, replace the placeholders in: + + ```powershell + dsc resource schema -r '/' + ``` Each property name must be a configurable property of the PSDSC resource. The property name isn't case sensitive. The value for each property must be valid for that property. If you @@ -183,6 +187,11 @@ configuration document with the following exceptions: types without a known JSON representation are validated by the underlying PowerShell resource. The adapter doesn't implement a separate `validate` operation. + Schemas also enforce discovered `ValidateSet` constraints, including constraints on array + items. Nullable property types accept `null`, including nullable strings with a `ValidateSet` + constraint. Non-nullable enumeration and numeric types reject `null`, even when they have a + validation set. + ## Exit codes The adapter resource returns the following exit codes from operations: diff --git a/docs/reference/resources/Microsoft/DSC/PowerShell/index.md b/docs/reference/resources/Microsoft/DSC/PowerShell/index.md index 6a4866532..c58dce0c2 100644 --- a/docs/reference/resources/Microsoft/DSC/PowerShell/index.md +++ b/docs/reference/resources/Microsoft/DSC/PowerShell/index.md @@ -183,6 +183,12 @@ property must be an object. The specified properties are validated at runtime wh tries to invoke the adapted PSDSC resource instance. This adapter doesn't support static linting for adapted instance properties in a configuration document. +The adapter's manifest schema validates the enclosing `resources` array, but doesn't apply each +discovered resource's schema to the nested instance properties. To use per-resource schema +validation before invocation, define resources directly in the configuration document with +[Microsoft.Adapter/PowerShell](../../Adapter/PowerShell/index.md). Neither adapter implements a +separate `validate` operation. + Each name for each property must be a configurable property of the PSDSC resource. The property name isn't case sensitive. The value for each property must be valid for that property. If you specify an invalid property name or value, the adapter raises an error when it tries to invoke the diff --git a/docs/reference/resources/Microsoft/Windows/WindowsPowerShell/index.md b/docs/reference/resources/Microsoft/Windows/WindowsPowerShell/index.md index 4e3a92f72..50357342a 100644 --- a/docs/reference/resources/Microsoft/Windows/WindowsPowerShell/index.md +++ b/docs/reference/resources/Microsoft/Windows/WindowsPowerShell/index.md @@ -116,18 +116,19 @@ The resource adapter has the following capabilities: ## Properties -Unlike standard resources, the `Microsoft.Windows/WindowsPowerShell` resource adapter doesn't have directly exposed properties -in its schema because it acts as a bridge to PowerShell DSC resource. Instead, the adapter: +The `Microsoft.Windows/WindowsPowerShell` adapter accepts a `resources` array containing nested +PowerShell DSC resource instances. Its manifest schema describes this wrapper, not the properties +of each nested resource. The adapter: -1. Dynamically discovers the property schema for each PowerShell DSC resource -2. Stores the schema properties in a cache file for improved performance in subsequent operations +1. Discovers property metadata for each PowerShell DSC resource +2. Caches the metadata to improve performance in subsequent operations 3. Passes properties to the underlying PowerShell DSC resource -The adapter maintains a cache of resource schemas at: +The adapter maintains its resource metadata cache at: - Windows: `%LOCALAPPDATA%\dsc\WindowsPSAdapterCache.json` -To list the schema properties for a PowerShell DSC resource, you can run the following command: +To list the property names for a PowerShell DSC resource, replace the placeholders in: ```powershell dsc resource list --adapter Microsoft.Windows/WindowsPowerShell / | @@ -135,7 +136,10 @@ dsc resource list --adapter Microsoft.Windows/WindowsPowerShell / Date: Tue, 10 Nov 2026 13:48:50 -0800 Subject: [PATCH 5/8] Address adapter schema review feedback Canonicalize schema and single-resource output property names, use standard required lists, and derive nullability from explicit metadata. Refresh adapter caches and update regressions and reference examples. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tests/powershellgroup.config.tests.ps1 | 21 +- .../Tests/powershellgroup.resource.tests.ps1 | 44 ++-- adapters/powershell/Tests/schema.tests.ps1 | 206 ++++++++++++++---- .../Tests/win_powershell_cache.tests.ps1 | 6 +- .../Tests/win_powershellgroup.tests.ps1 | 18 +- .../psDscAdapter/powershell.resource.ps1 | 86 ++++++-- .../powershell/psDscAdapter/psDscAdapter.psm1 | 9 +- .../psDscAdapter/win_psDscAdapter.psm1 | 9 +- .../examples/configure-a-machine.md | 116 +++++----- .../PowerShell/examples/invoke-a-resource.md | 68 +++--- .../Microsoft/Adapter/PowerShell/index.md | 16 +- .../examples/manage-a-windows-service.md | 144 +++++------- .../Adapter/WindowsPowerShell/index.md | 16 +- 13 files changed, 448 insertions(+), 311 deletions(-) diff --git a/adapters/powershell/Tests/powershellgroup.config.tests.ps1 b/adapters/powershell/Tests/powershellgroup.config.tests.ps1 index 73180e01a..a9bccc482 100644 --- a/adapters/powershell/Tests/powershellgroup.config.tests.ps1 +++ b/adapters/powershell/Tests/powershellgroup.config.tests.ps1 @@ -269,10 +269,10 @@ Describe 'PowerShell adapter resource tests' { directives: $directive$adapter properties: - Name: 'TestClassResource1' - HashTableProp: + name: 'TestClassResource1' + hashTableProp: Name: 'DSCv3' - Prop1: foo + prop1: foo "@ $out = dsc -l trace config $operation -i $yaml 2> $TestDrive/tracing.txt $text = $out | Out-String @@ -332,8 +332,8 @@ Describe 'PowerShell adapter resource tests' { - name: Class-resource credential info type: TestClassResource/TestClassResource properties: - Name: 'TestClassResource' - Credential: + name: 'TestClassResource' + credential: UserName: 'User' OtherProperty: 'Password' "@ @@ -350,8 +350,8 @@ Describe 'PowerShell adapter resource tests' { - name: Class-resource Info type: TestClassResource/TestClassResource properties: - Name: 'TestClassResource' - Ensure: 'Present' + name: 'TestClassResource' + ensure: 'Present' "@ $out = dsc config get -i $yaml | ConvertFrom-Json @@ -385,9 +385,9 @@ Describe 'PowerShell adapter resource tests' { - name: Class-resource Info type: TestClassResource/TestClassResource properties: - Name: EchoBack - Prop1: "[[this is a string literal]" - EnumProp: 'Expected' + name: EchoBack + prop1: "[[this is a string literal]" + enumProp: 'Expected' "@ $out = dsc config get -i $yaml | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 @@ -396,4 +396,3 @@ Describe 'PowerShell adapter resource tests' { $out.results.result.actualState.EnumProp | Should -BeExactly 'Expected' } } - diff --git a/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 b/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 index 612fb8dfd..87cdc6670 100644 --- a/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 +++ b/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 @@ -33,13 +33,13 @@ Describe 'PowerShell adapter resource tests' { ($resources | Where-Object -Property type -EQ 'TestClassResource/NoExport').capabilities | Should -BeIn @('get', 'set', 'test') $resourceSchema = ($resources | Where-Object -Property type -EQ 'TestClassResource/TestClassResource').schema $resourceSchema.type | Should -BeExactly 'object' - $resourceSchema.properties.BaseProperty.type | Should -Contain 'string' + $resourceSchema.properties.baseProperty.type | Should -BeExactly 'string' $resourceSchema.properties.PSObject.Properties.Name | Should -Not -Contain 'NonDscProperty' } It 'Get works on class-based resource' { - $r = "{'Name':'TestClassResource1'}" | dsc resource get -r 'TestClassResource/TestClassResource' -f - + $r = "{'name':'TestClassResource1'}" | dsc resource get -r 'TestClassResource/TestClassResource' -f - $LASTEXITCODE | Should -Be 0 $res = $r | ConvertFrom-Json $res.actualState.Prop1 | Should -BeExactly 'ValueForProp1' @@ -52,31 +52,31 @@ Describe 'PowerShell adapter resource tests' { It 'Get uses enum names on class-based resource' { - $r = "{'Name':'TestClassResource1'}" | dsc resource get -r 'TestClassResource/TestClassResource' -f - + $r = "{'name':'TestClassResource1'}" | dsc resource get -r 'TestClassResource/TestClassResource' -f - $LASTEXITCODE | Should -Be 0 $res = $r | ConvertFrom-Json $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 - + $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.Keys -ccontains 'name' | Should -BeTrue + $res.actualState.Keys -ccontains 'prop1' | Should -BeTrue + $res.actualState.Keys -ccontains 'enumProp' | Should -BeTrue + $res.actualState.Keys -ccontains 'ensure' | Should -BeTrue + $res.actualState.ContainsKey('hashTableProp') | Should -BeFalse + $res.actualState.ContainsKey('credential') | Should -BeFalse + $res.actualState.ContainsKey('baseProperty') | Should -BeFalse + $res.actualState.ContainsKey('hiddenDscProperty') | Should -BeFalse $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 - + $r = "{'name':'TestClassResource1','prop1':'ValueForProp1'}" | dsc resource test -r 'TestClassResource/TestClassResource' -f - $LASTEXITCODE | Should -Be 0 $res = $r | ConvertFrom-Json $res.InDesiredState | Should -Be $True -Because $r @@ -84,7 +84,7 @@ Describe 'PowerShell adapter resource tests' { It 'Set works on class-based resource' { - $r = "{'Name':'TestClassResource1','Prop1':'ValueForProp1'}" | dsc resource set -r 'TestClassResource/TestClassResource' -f - + $r = "{'name':'TestClassResource1','prop1':'ValueForProp1'}" | dsc resource set -r 'TestClassResource/TestClassResource' -f - $LASTEXITCODE | Should -Be 0 $res = $r | ConvertFrom-Json $res.afterState.Prop1 | Should -BeExactly 'ValueForProp1' @@ -377,7 +377,7 @@ Describe 'PowerShell adapter resource tests' { } It 'Can process a key-value pair object' { - $r = '{"HashTableProp":{"Name":"DSCv3"},"Name":"TestClassResource1"}' | dsc resource get -r 'TestClassResource/TestClassResource' -f - + $r = '{"hashTableProp":{"Name":"DSCv3"},"name":"TestClassResource1"}' | dsc resource get -r 'TestClassResource/TestClassResource' -f - $LASTEXITCODE | Should -Be 0 $res = $r | ConvertFrom-Json $res.actualState.HashTableProp.Name | Should -Be 'DSCv3' @@ -396,7 +396,7 @@ Describe 'PowerShell adapter resource tests' { } It 'Can process SecureString property' { - $r = '{"Name":"TestClassResource1","SecureStringProp":"MySecretValue"}' | dsc resource get -r 'TestClassResource/TestClassResource' -f - + $r = '{"name":"TestClassResource1","secureStringProp":"MySecretValue"}' | dsc resource get -r 'TestClassResource/TestClassResource' -f - $LASTEXITCODE | Should -Be 0 $res = $r | ConvertFrom-Json $res.actualState.SecureStringProp | Should -Not -BeNullOrEmpty @@ -404,42 +404,42 @@ Describe 'PowerShell adapter resource tests' { Context 'Tracing works' { It 'Error messages come from Write-Error' { - $null = dsc -l error resource set -r TestClassResource/StreamResource -i '{"Name":"TestClassResource1"}' 2> $TestDrive/error.log + $null = dsc -l error resource set -r TestClassResource/StreamResource -i '{"name":"TestClassResource1"}' 2> $TestDrive/error.log $logContent = Get-Content -Path $TestDrive/error.log -Raw $LASTEXITCODE | Should -Be 0 -Because $logContent $logContent | Should -Match 'ERROR .*? This is an Error message' -Because $logContent } It 'Warning messages come from Write-Warning' { - $null = "{'Name':'TestClassResource1','Prop1':'ValueForProp1'}" | dsc -l warn resource get -r 'TestClassResource/StreamResource' -f - 2> $TestDrive/warning.log + $null = "{'name':'TestClassResource1','prop1':'ValueForProp1'}" | dsc -l warn resource get -r 'TestClassResource/StreamResource' -f - 2> $TestDrive/warning.log $logContent = Get-Content -Path $TestDrive/warning.log -Raw $LASTEXITCODE | Should -Be 0 -Because $logContent $logContent | Should -Match 'WARN .*? This is a Warning message' -Because $logContent } It 'Info messages come from Write-Host' { - $null = "{'Name':'TestClassResource1'}" | dsc -l info resource test -r 'TestClassResource/StreamResource' -f - 2> $TestDrive/verbose.log + $null = "{'name':'TestClassResource1'}" | dsc -l info resource test -r 'TestClassResource/StreamResource' -f - 2> $TestDrive/verbose.log $logContent = Get-Content -Path $TestDrive/verbose.log -Raw $LASTEXITCODE | Should -Be 0 -Because $logContent $logContent | Should -Match 'INFO .*? This is a Host message' -Because $logContent } It 'Debug messages come from Write-Verbose' { - $null = "{'Name':'TestClassResource1'}" | dsc -l debug resource set -r 'TestClassResource/StreamResource' -f - 2> $TestDrive/debug.log + $null = "{'name':'TestClassResource1'}" | dsc -l debug resource set -r 'TestClassResource/StreamResource' -f - 2> $TestDrive/debug.log $logContent = Get-Content -Path $TestDrive/debug.log -Raw $LASTEXITCODE | Should -Be 0 -Because $logContent $logContent | Should -Match 'DEBUG .*? This is a Verbose message' -Because $logContent } It 'Trace messages come from Write-Debug' { - $null = dsc -l trace resource set -r TestClassResource/StreamResource -i '{"Name":"TestClassResource1"}' 2> $TestDrive/trace.log + $null = dsc -l trace resource set -r TestClassResource/StreamResource -i '{"name":"TestClassResource1"}' 2> $TestDrive/trace.log $logContent = Get-Content -Path $TestDrive/trace.log -Raw $LASTEXITCODE | Should -Be 0 -Because $logContent $logContent | Should -Match 'TRACE .*? This is a Debug message' -Because $logContent } It 'Trace messages come from Write-Information' { - $null = dsc -l trace resource test -r TestClassResource/StreamResource -i '{"Name":"TestClassResource1"}' 2> $TestDrive/trace_info.log + $null = dsc -l trace resource test -r TestClassResource/StreamResource -i '{"name":"TestClassResource1"}' 2> $TestDrive/trace_info.log $logContent = Get-Content -Path $TestDrive/trace_info.log -Raw $LASTEXITCODE | Should -Be 0 -Because $logContent $logContent | Should -Match 'INFO .*? This is an Information message' -Because $logContent diff --git a/adapters/powershell/Tests/schema.tests.ps1 b/adapters/powershell/Tests/schema.tests.ps1 index be3553e19..8ee42e8c8 100644 --- a/adapters/powershell/Tests/schema.tests.ps1 +++ b/adapters/powershell/Tests/schema.tests.ps1 @@ -22,7 +22,7 @@ Describe 'PowerShell adapter embedded schemas' { @{ RootModule = 'TestSchemaResource.psm1' ModuleVersion = '1.0.0' - DscResourcesToExport = @('TestSchemaResource') + DscResourcesToExport = @('TestSchemaResource', 'TestNullableResource') } '@ | Set-Content -Path (Join-Path $modulePath 'TestSchemaResource.psd1') @' @@ -86,17 +86,62 @@ class TestSchemaResource : SchemaBase { [DscProperty()] [SecureString] $Secret + [DscProperty()] + [string] $IPAddress + + [DscProperty()] + [string] ${Resource-Name} + [string] $NotDscProperty [TestSchemaResource] Get() { return $this } [void] Set() {} [bool] Test() { return $true } } + +[DscResource()] +class TestNullableResource { + [DscProperty(Key)] + [string] $Name + + [DscProperty(Mandatory)] + [ValidateSet(1, 2)] + [Nullable[int]] $RequiredCount + + [DscProperty(Mandatory)] + [Nullable[SchemaEnsure]] $RequiredEnsure + + [DscProperty()] + [System.Nullable[bool]] $Enabled + + [DscProperty()] + [ValidateSet(1, 2)] + [Nullable[int]] $Selection + + [DscProperty()] + [Nullable[SchemaEnsure]] $Ensure + + [DscProperty()] + [AllowNull()] + [ValidateSet('One', 'Two')] + [string] $Choice + + [DscProperty()] + [System.Management.Automation.AllowNullAttribute()] + [ValidateSet('One', 'Two')] + [string[]] $Choices + + [TestNullableResource] Get() { return $this } + [void] Set() {} + [bool] Test() { return $true } +} '@ | Set-Content -Path (Join-Path $modulePath 'TestSchemaResource.psm1') $resources = & $scriptPath -Operation List -ResourceType Single | ConvertFrom-Json $resource = $resources | Where-Object -Property type -EQ 'TestSchemaResource/TestSchemaResource' $schema = $resource.schema | ConvertTo-Json -Depth 100 -Compress + $nullableResource = $resources | Where-Object -Property type -EQ 'TestSchemaResource/TestNullableResource' + $nullableSchema = $nullableResource.schema | ConvertTo-Json -Depth 100 -Compress } AfterAll { @@ -138,74 +183,106 @@ class TestSchemaResource : SchemaBase { It 'Includes a resource schema with inherited DSC properties only' { $resource.requireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' $resource.schema.type | Should -BeExactly 'object' - $resource.schema.properties.PSObject.Properties.Name | Should -Contain 'Inherited' - $resource.schema.properties.PSObject.Properties.Name | Should -Not -Contain 'NotDscProperty' + $resource.schema.properties.PSObject.Properties.Name | Should -Contain 'inherited' + $resource.schema.properties.PSObject.Properties.Name | Should -Not -Contain 'notDscProperty' $resource.schema.properties.Ensure.anyOf[0].enum | Should -Be @('Present', 'Absent') $resource.schema.properties.InheritedEnsure.anyOf[0].enum | Should -Be @('Present', 'Absent') $resource.schema.properties.Ensures.items.anyOf[0].enum | Should -Be @('Present', 'Absent') } - It 'Accepts valid resource data including nullable and complex properties' { - $json = '{"Name":"test","RequiredValue":"value","Enabled":true,"Count":2,"Ratio":1.5,"Names":["one"],"Ensure":"Present","Options":{"nested":{"value":1}},"Credential":{"username":"user","password":"secret"},"Secret":"secret","Inherited":null}' + It 'Accepts valid resource data including complex properties' { + $json = '{"name":"test","requiredValue":"value","enabled":true,"count":2,"ratio":1.5,"names":["one"],"ensure":"Present","options":{"nested":{"value":1}},"credential":{"username":"user","password":"secret"},"secret":"secret","inherited":"value"}' Test-Json -Json $json -Schema $schema -ErrorAction Stop | Should -BeTrue } - It 'Preserves case-insensitive PowerShell property names' { - Test-Json -Json '{"name":"test","requiredvalue":"value","count":2}' -Schema $schema -ErrorAction Stop | - Should -BeTrue + It 'Canonicalizes alphanumeric names without duplicating property schemas' { + $resource.properties | Should -Contain 'ipAddress' + $resource.properties | Should -Contain 'Resource-Name' + $propertyNames = $resource.schema.properties.PSObject.Properties.Name + $propertyNames -ccontains 'requiredValue' | Should -BeTrue + $propertyNames -ccontains 'ipAddress' | Should -BeTrue + $propertyNames -ccontains 'Resource-Name' | Should -BeTrue + $propertyNames -ccontains 'RequiredValue' | Should -BeFalse + $resource.schema.required | Should -Be @('name', 'requiredValue') + $resource.schema.PSObject.Properties.Name | Should -Not -Contain 'patternProperties' + $resource.schema.PSObject.Properties.Name | Should -Not -Contain 'allOf' } It 'Preserves case-insensitive enumeration values' { - Test-Json -Json '{"Name":"test","RequiredValue":"value","Ensure":"present","Choice":"one","Selection":1}' -Schema $schema -ErrorAction Stop | + Test-Json -Json '{"name":"test","requiredValue":"value","ensure":"present","choice":"one","selection":1}' -Schema $schema -ErrorAction Stop | Should -BeTrue } - It 'Accepts nullable validation sets for ' -ForEach @( - @{ Case = 'a scalar'; Json = '{"Name":"test","RequiredValue":"value","Choice":null}' } - @{ Case = 'an array'; Json = '{"Name":"test","RequiredValue":"value","Choices":null}' } - @{ Case = 'array items'; Json = '{"Name":"test","RequiredValue":"value","Choices":["One",null,"two"]}' } + It 'Accepts explicit nullability for ' -ForEach @( + @{ Case = 'required nullable value types'; Json = '{"name":"test","requiredCount":null,"requiredEnsure":null}' } + @{ Case = 'an AllowNull scalar'; Json = '{"name":"test","requiredCount":1,"requiredEnsure":"present","choice":null}' } + @{ Case = 'an AllowNull array'; Json = '{"name":"test","requiredCount":1,"requiredEnsure":"Present","choices":null}' } + @{ Case = 'non-null optional nullable values'; Json = '{"name":"test","requiredCount":2,"requiredEnsure":"Absent","enabled":true,"selection":1,"ensure":"present","choices":["one","Two"]}' } + ) { + Test-Json -Json $Json -Schema $nullableSchema -ErrorAction Stop | Should -BeTrue + } + + It 'Rejects nullable metadata misuse for ' -ForEach @( + @{ Reason = 'a missing required nullable property'; Json = '{"name":"test","requiredEnsure":null}' } + @{ Reason = 'a null optional nullable boolean'; Json = '{"name":"test","requiredCount":1,"requiredEnsure":null,"enabled":null}' } + @{ Reason = 'a null optional nullable integer'; Json = '{"name":"test","requiredCount":1,"requiredEnsure":null,"selection":null}' } + @{ Reason = 'a null optional nullable enum'; Json = '{"name":"test","requiredCount":1,"requiredEnsure":null,"ensure":null}' } + @{ Reason = 'a null item in an AllowNull array'; Json = '{"name":"test","requiredCount":1,"requiredEnsure":null,"choices":["One",null]}' } + @{ Reason = 'an invalid required nullable numeric validation set'; Json = '{"name":"test","requiredCount":3,"requiredEnsure":null}' } + @{ Reason = 'an invalid required nullable enum'; Json = '{"name":"test","requiredCount":1,"requiredEnsure":"Other"}' } + @{ Reason = 'an invalid optional nullable numeric validation set'; Json = '{"name":"test","requiredCount":1,"requiredEnsure":null,"selection":3}' } ) { - Test-Json -Json $Json -Schema $schema -ErrorAction Stop | Should -BeTrue + Test-Json -Json $Json -Schema $nullableSchema -ErrorAction Ignore | Should -BeFalse } It 'Preserves nested array and enumeration schemas during listing' { - $json = '{"Name":"test","RequiredValue":"value","Ensures":["Present","absent"],"InheritedEnsure":"present"}' + $json = '{"name":"test","requiredValue":"value","ensures":["Present","absent"],"inheritedEnsure":"present"}' Test-Json -Json $json -Schema $schema -ErrorAction Stop | Should -BeTrue } It 'Accepts the serialized representation of a SecureString in resource output' { - Test-Json -Json '{"Name":"test","RequiredValue":"value","Secret":{"Length":6}}' -Schema $schema -ErrorAction Stop | + Test-Json -Json '{"name":"test","requiredValue":"value","secret":{"Length":6}}' -Schema $schema -ErrorAction Stop | Should -BeTrue } It 'Rejects ' -ForEach @( - @{ Reason = 'a missing key'; Json = '{"RequiredValue":"value"}' } - @{ Reason = 'a missing mandatory property'; Json = '{"Name":"test"}' } - @{ Reason = 'a non-string key'; Json = '{"Name":42,"RequiredValue":"value"}' } - @{ Reason = 'a non-boolean value'; Json = '{"Name":"test","RequiredValue":"value","Enabled":"yes"}' } - @{ Reason = 'a fractional integer'; Json = '{"Name":"test","RequiredValue":"value","Count":1.5}' } - @{ Reason = 'a non-numeric value'; Json = '{"Name":"test","RequiredValue":"value","Ratio":"bad"}' } - @{ Reason = 'an invalid array item'; Json = '{"Name":"test","RequiredValue":"value","Names":[1]}' } - @{ Reason = 'an invalid enumeration'; Json = '{"Name":"test","RequiredValue":"value","Ensure":"Other"}' } - @{ Reason = 'an invalid string validation set'; Json = '{"Name":"test","RequiredValue":"value","Choice":"Other"}' } - @{ Reason = 'an invalid numeric validation set'; Json = '{"Name":"test","RequiredValue":"value","Selection":3}' } - @{ Reason = 'an invalid array validation-set item'; Json = '{"Name":"test","RequiredValue":"value","Choices":["Other"]}' } - @{ Reason = 'an invalid enum-array item'; Json = '{"Name":"test","RequiredValue":"value","Ensures":["Other"]}' } - @{ Reason = 'a null value for a non-nullable enum'; Json = '{"Name":"test","RequiredValue":"value","Ensure":null}' } - @{ Reason = 'a null numeric validation set'; Json = '{"Name":"test","RequiredValue":"value","Selection":null}' } - @{ Reason = 'a non-object credential'; Json = '{"Name":"test","RequiredValue":"value","Credential":"secret"}' } - @{ Reason = 'an incorrectly typed case-insensitive property'; Json = '{"name":"test","requiredvalue":"value","count":"bad"}' } + @{ Reason = 'a missing key'; Json = '{"requiredValue":"value"}' } + @{ Reason = 'a missing mandatory property'; Json = '{"name":"test"}' } + @{ Reason = 'a non-string key'; Json = '{"name":42,"requiredValue":"value"}' } + @{ Reason = 'a non-boolean value'; Json = '{"name":"test","requiredValue":"value","enabled":"yes"}' } + @{ Reason = 'a fractional integer'; Json = '{"name":"test","requiredValue":"value","count":1.5}' } + @{ Reason = 'a non-numeric value'; Json = '{"name":"test","requiredValue":"value","ratio":"bad"}' } + @{ Reason = 'an invalid array item'; Json = '{"name":"test","requiredValue":"value","names":[1]}' } + @{ Reason = 'an invalid enumeration'; Json = '{"name":"test","requiredValue":"value","ensure":"Other"}' } + @{ Reason = 'an invalid string validation set'; Json = '{"name":"test","requiredValue":"value","choice":"Other"}' } + @{ Reason = 'an invalid numeric validation set'; Json = '{"name":"test","requiredValue":"value","selection":3}' } + @{ Reason = 'an invalid array validation-set item'; Json = '{"name":"test","requiredValue":"value","choices":["Other"]}' } + @{ Reason = 'an invalid enum-array item'; Json = '{"name":"test","requiredValue":"value","ensures":["Other"]}' } + @{ Reason = 'a null value for a non-nullable enum'; Json = '{"name":"test","requiredValue":"value","ensure":null}' } + @{ Reason = 'a null numeric validation set'; Json = '{"name":"test","requiredValue":"value","selection":null}' } + @{ Reason = 'a null string'; Json = '{"name":"test","requiredValue":"value","inherited":null}' } + @{ Reason = 'a null string validation set'; Json = '{"name":"test","requiredValue":"value","choice":null}' } + @{ Reason = 'a null array'; Json = '{"name":"test","requiredValue":"value","names":null}' } + @{ Reason = 'a null array item'; Json = '{"name":"test","requiredValue":"value","choices":["One",null]}' } + @{ Reason = 'a null object'; Json = '{"name":"test","requiredValue":"value","options":null}' } + @{ Reason = 'a null SecureString'; Json = '{"name":"test","requiredValue":"value","secret":null}' } + @{ Reason = 'a non-object credential'; Json = '{"name":"test","requiredValue":"value","credential":"secret"}' } + @{ Reason = 'noncanonical required property casing'; Json = '{"name":"test","RequiredValue":"value"}' } ) { - Test-Json -Json $Json -Schema $schema -ErrorAction SilentlyContinue | Should -BeFalse + Test-Json -Json $Json -Schema $schema -ErrorAction Ignore | Should -BeFalse } It 'Returns the same schema when listing cached resources' { - $cached = & $scriptPath -Operation List -ResourceType Single | ConvertFrom-Json | - Where-Object -Property type -EQ 'TestSchemaResource/TestSchemaResource' - Test-Json -Json '{"Name":"test","RequiredValue":"value","Count":"bad"}' -Schema ($cached.schema | ConvertTo-Json -Depth 100) -ErrorAction SilentlyContinue | + $cachedResources = & $scriptPath -Operation List -ResourceType Single | ConvertFrom-Json + $cached = $cachedResources | Where-Object -Property type -EQ 'TestSchemaResource/TestSchemaResource' + Test-Json -Json '{"name":"test","requiredValue":"value","count":"bad"}' -Schema ($cached.schema | ConvertTo-Json -Depth 100) -ErrorAction Ignore | Should -BeFalse - Test-Json -Json '{"Name":"test","RequiredValue":"value","Choice":null,"Choices":["one",null]}' -Schema ($cached.schema | ConvertTo-Json -Depth 100) -ErrorAction Stop | + $cachedNullable = $cachedResources | Where-Object -Property type -EQ 'TestSchemaResource/TestNullableResource' + $cachedNullableSchema = $cachedNullable.schema | ConvertTo-Json -Depth 100 + Test-Json -Json '{"name":"test","requiredCount":null,"requiredEnsure":null,"choice":null,"choices":null}' -Schema $cachedNullableSchema -ErrorAction Stop | Should -BeTrue + Test-Json -Json '{"name":"test","requiredCount":1,"requiredEnsure":null,"enabled":null}' -Schema $cachedNullableSchema -ErrorAction Ignore | + Should -BeFalse $cached.schema.properties.Ensure.anyOf[0].enum | Should -Be @('Present', 'Absent') } @@ -213,7 +290,7 @@ class TestSchemaResource : SchemaBase { $legacy = & $scriptPath -Operation List | ConvertFrom-Json | Where-Object -Property type -EQ 'TestSchemaResource/TestSchemaResource' $legacy.requireAdapter | Should -BeExactly 'Microsoft.DSC/PowerShell' - Test-Json -Json '{"Name":"test","RequiredValue":"value","Count":"bad"}' -Schema ($legacy.schema | ConvertTo-Json -Depth 100) -ErrorAction SilentlyContinue | + Test-Json -Json '{"name":"test","requiredValue":"value","count":"bad"}' -Schema ($legacy.schema | ConvertTo-Json -Depth 100) -ErrorAction Ignore | Should -BeFalse } @@ -229,19 +306,24 @@ class TestSchemaResource : SchemaBase { $windowsResource.schema.properties.Count.type | Should -BeExactly 'integer' $windowsResource.schema.properties.Names.items.type | Should -Contain 'string' $windowsSchema = $windowsResource.schema | ConvertTo-Json -Depth 100 - Test-Json -Json '{"name":"test","requiredvalue":"value","Ensure":"present"}' -Schema $windowsSchema -ErrorAction Stop | + Test-Json -Json '{"name":"test","requiredValue":"value","ensure":"present"}' -Schema $windowsSchema -ErrorAction Stop | Should -BeTrue - Test-Json -Json '{"Name":"test","RequiredValue":"value","Choice":null,"Choices":["One",null,"two"]}' -Schema $windowsSchema -ErrorAction Stop | + $windowsNullable = $windowsResources | ConvertFrom-Json | + Where-Object -Property type -EQ 'TestSchemaResource/TestNullableResource' + $windowsNullableSchema = $windowsNullable.schema | ConvertTo-Json -Depth 100 + Test-Json -Json '{"name":"test","requiredCount":null,"requiredEnsure":null,"choice":null,"choices":null}' -Schema $windowsNullableSchema -ErrorAction Stop | Should -BeTrue - Test-Json -Json '{"Name":"test","RequiredValue":"value","Count":"bad"}' -Schema $windowsSchema -ErrorAction SilentlyContinue | + Test-Json -Json '{"name":"test","requiredCount":1,"requiredEnsure":null,"enabled":null}' -Schema $windowsNullableSchema -ErrorAction Ignore | + Should -BeFalse + Test-Json -Json '{"name":"test","requiredValue":"value","count":"bad"}' -Schema $windowsSchema -ErrorAction Ignore | Should -BeFalse $scriptResource = $windowsResources | ConvertFrom-Json | Where-Object -Property type -EQ 'TestScriptBaseDSC/CredentialValidation' $scriptResource.schema.properties.Credential.type | Should -Contain 'object' $scriptSchema = $scriptResource.schema | ConvertTo-Json -Depth 100 - Test-Json -Json '{"Name":"test","Credential":{"username":"user","password":"secret"}}' -Schema $scriptSchema -ErrorAction Stop | + Test-Json -Json '{"name":"test","credential":{"username":"user","password":"secret"}}' -Schema $scriptSchema -ErrorAction Stop | Should -BeTrue - Test-Json -Json '{"Name":"test"}' -Schema $scriptSchema -ErrorAction SilentlyContinue | + Test-Json -Json '{"name":"test"}' -Schema $scriptSchema -ErrorAction Ignore | Should -BeFalse } } finally { @@ -256,8 +338,44 @@ class TestSchemaResource : SchemaBase { } It 'Rejects invalid resource input through the DSC CLI' { - $null = dsc resource get -r 'TestSchemaResource/TestSchemaResource' -i '{"Name":"test","RequiredValue":"value","Count":"bad"}' 2> (Join-Path $TestDrive 'invalid-error.log') + $null = dsc resource get -r 'TestSchemaResource/TestSchemaResource' -i '{"name":"test","requiredValue":"value","count":"bad"}' 2> (Join-Path $TestDrive 'invalid-error.log') $LASTEXITCODE | Should -Not -Be 0 Get-Content -Raw (Join-Path $TestDrive 'invalid-error.log') | Should -Match 'integer' } + + It 'Returns schema-conforming canonical state for ' -ForEach @( + @{ Operation = 'get'; State = 'actualState' } + @{ Operation = 'test'; State = 'actualState' } + @{ Operation = 'set'; State = 'afterState' } + ) { + $json = '{"name":"test","requiredValue":"value","selection":1,"choice":"One","ipAddress":"192.0.2.1","Resource-Name":"unchanged"}' + $out = dsc resource $Operation -r 'TestSchemaResource/TestSchemaResource' -i $json 2> (Join-Path $TestDrive 'operation-error.log') + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw (Join-Path $TestDrive 'operation-error.log')) + $actualState = ($out | ConvertFrom-Json).$State + $actualState.PSObject.Properties.Name -ccontains 'name' | Should -BeTrue + $actualState.PSObject.Properties.Name -ccontains 'ipAddress' | Should -BeTrue + $actualState.'Resource-Name' | Should -BeExactly 'unchanged' + $actualState.PSObject.Properties.Name | Should -Not -Contain 'options' + Test-Json -Json ($actualState | ConvertTo-Json -Depth 100) -Schema $schema -ErrorAction Stop | Should -BeTrue + if ($Operation -eq 'test') { + $actualState._inDesiredState | Should -BeTrue + } + } + + It 'Preserves explicitly allowed nulls and omits unset optional nullable values in output' { + $json = '{"name":"test","requiredCount":2,"requiredEnsure":null,"choice":"One"}' + $out = dsc resource test -r 'TestSchemaResource/TestNullableResource' -i $json 2> (Join-Path $TestDrive 'nullable-output-error.log') + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw (Join-Path $TestDrive 'nullable-output-error.log')) + $actualState = ($out | ConvertFrom-Json).actualState + $propertyNames = $actualState.PSObject.Properties.Name + $actualState.requiredCount | Should -Be 2 + foreach ($name in 'requiredEnsure', 'choices') { + $propertyNames -ccontains $name | Should -BeTrue + $actualState.$name | Should -BeNullOrEmpty + } + foreach ($name in 'enabled', 'selection', 'ensure') { + $propertyNames | Should -Not -Contain $name + } + Test-Json -Json ($actualState | ConvertTo-Json -Depth 100) -Schema $nullableSchema -ErrorAction Stop | Should -BeTrue + } } diff --git a/adapters/powershell/Tests/win_powershell_cache.tests.ps1 b/adapters/powershell/Tests/win_powershell_cache.tests.ps1 index 5cb908807..601484701 100644 --- a/adapters/powershell/Tests/win_powershell_cache.tests.ps1 +++ b/adapters/powershell/Tests/win_powershell_cache.tests.ps1 @@ -52,7 +52,7 @@ Describe 'WindowsPowerShell adapter resource tests - requires elevated permissio $testFile = "$testdrive\test.txt" 'test' | Set-Content -Path $testFile -Force - $r = '{"DestinationPath":"' + $testFile.replace('\', '\\') + '"}' | dsc resource get -r 'PSDesiredStateConfiguration/File' -f - + $r = '{"destinationPath":"' + $testFile.replace('\', '\\') + '"}' | dsc resource get -r 'PSDesiredStateConfiguration/File' -f - $LASTEXITCODE | Should -Be 0 $res = $r | ConvertFrom-Json $res.actualState.DestinationPath | Should -Be "$testFile" @@ -61,7 +61,7 @@ Describe 'WindowsPowerShell adapter resource tests - requires elevated permissio It 'Set works on Binary "File" resource' { $testFile = "$testdrive\test.txt" - $null = '{"DestinationPath":"' + $testFile.replace('\', '\\') + '", type: File, contents: HelloWorld, Ensure: present}' | dsc resource set -r 'PSDesiredStateConfiguration/File' -f - + $null = '{"destinationPath":"' + $testFile.replace('\', '\\') + '", type: File, contents: HelloWorld, ensure: present}' | dsc resource set -r 'PSDesiredStateConfiguration/File' -f - $LASTEXITCODE | Should -Be 0 Get-Content -Raw -Path $testFile | Should -Be "HelloWorld" } @@ -70,7 +70,7 @@ Describe 'WindowsPowerShell adapter resource tests - requires elevated permissio $testFile = "$testdrive\test.txt" 'test' | Set-Content -Path $testFile -Force - $r = '{"GetScript": "@{result = $(Get-Content ' + $testFile.replace('\', '\\') + ')}", "SetScript": "throw", "TestScript": "throw"}' | dsc resource get -r 'PSDesiredStateConfiguration/Script' -f - + $r = '{"getScript": "@{result = $(Get-Content ' + $testFile.replace('\', '\\') + ')}", "setScript": "throw", "testScript": "throw"}' | dsc resource get -r 'PSDesiredStateConfiguration/Script' -f - $LASTEXITCODE | Should -Be 0 $res = $r | ConvertFrom-Json $res.actualState.result | Should -Be 'test' diff --git a/adapters/powershell/Tests/win_powershellgroup.tests.ps1 b/adapters/powershell/Tests/win_powershellgroup.tests.ps1 index f3967e024..54f055ee4 100644 --- a/adapters/powershell/Tests/win_powershellgroup.tests.ps1 +++ b/adapters/powershell/Tests/win_powershellgroup.tests.ps1 @@ -378,8 +378,8 @@ resources: - name: Cred test type: PSClassResource/PSClassResource properties: - Name: Test - Credential: + name: Test + credential: UserName: 'MyUser' Password: 'MyPassword' '@ @@ -395,8 +395,8 @@ resources: - name: Service info type: PsDesiredStateConfiguration/Service properties: - Name: Spooler - Credential: + name: Spooler + credential: UserName: 'User' OtherProperty: 'Password' '@ @@ -473,16 +473,16 @@ $out | Should -BeNullOrEmpty } It 'Get works with class-based PS DSC resources' { - $out = dsc resource get -r PSClassResource/PSClassResource --input (@{Name = 'TestName' } | ConvertTo-Json) 2> "$testdrive/error.log" | ConvertFrom-Json + $out = dsc resource get -r PSClassResource/PSClassResource --input (@{name = 'TestName' } | ConvertTo-Json) 2> "$testdrive/error.log" | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path "$testdrive/error.log" -Raw | Out-String) $out.actualState.Name | Should -Be 'TestName' $out.actualState.Ensure | Should -Be 'Present' $propCount = $out.actualState | Get-Member -MemberType NoteProperty - $propCount.Count | Should -Be 3 -Because ($out | Out-String) + $propCount.Count | Should -Be 2 -Because ($out | Out-String) } It 'Set works with class-based PS DSC resources' { - $out = dsc resource set -r PSClassResource/PSClassResource --input (@{Name = 'TestName'; Credential = @{"UserName" = "MyUser"; "Password" = "MyPassword"} } | ConvertTo-Json) 2> "$testdrive/error.log" | ConvertFrom-Json + $out = dsc resource set -r PSClassResource/PSClassResource --input (@{name = 'TestName'; credential = @{"UserName" = "MyUser"; "Password" = "MyPassword"} } | ConvertTo-Json) 2> "$testdrive/error.log" | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path "$testdrive/error.log" -Raw | Out-String) $out.changedProperties.Count | Should -Be 0 -Because ($out | ConvertTo-Json -Depth 10 | Out-String) } @@ -516,8 +516,8 @@ $out | Should -BeNullOrEmpty directives: $directive$adapter properties: - Name: TestInstance - Credential: + name: TestInstance + credential: UserName: 'MyUser' Password: 'MyPassword' "@ diff --git a/adapters/powershell/psDscAdapter/powershell.resource.ps1 b/adapters/powershell/psDscAdapter/powershell.resource.ps1 index 1dd9272f3..657f4cb8b 100644 --- a/adapters/powershell/psDscAdapter/powershell.resource.ps1 +++ b/adapters/powershell/psDscAdapter/powershell.resource.ps1 @@ -139,29 +139,65 @@ $ps = [PowerShell]::Create().AddScript({ # initialize OUTPUT as array $result = [System.Collections.Generic.List[Object]]::new() + function ConvertTo-CanonicalPropertyName { + param([string]$Name) + + if ($Name -cmatch '^[A-Za-z0-9]+$') { + # Preserve acronym boundaries, for example IPAddress -> ipAddress. + $prefix = [regex]::Match($Name, '^[A-Z]+(?=[A-Z][a-z]|[0-9]|$)|^[A-Z]').Value + return $prefix.ToLowerInvariant() + $Name.Substring($prefix.Length) + } + return $Name + } + + function Test-PropertyAllowsNull { + param($Property) + + return $Property.AllowNull -or ($Property.IsMandatory -and $Property.PropertyType -match '^\[?(?:System\.)?Nullable\[') + } + + function ConvertTo-ResourceState { + param( + [System.Collections.IDictionary]$State, + $Properties + ) + + $canonicalState = @{} + foreach ($name in $State.Keys) { + $property = $Properties | Where-Object -Property Name -EQ $name | Select-Object -First 1 + if ($null -eq $State[$name] -and -not $property.IsMandatory -and -not (Test-PropertyAllowsNull -Property $property)) { + continue + } + $canonicalState[(ConvertTo-CanonicalPropertyName -Name $name)] = $State[$name] + } + return $canonicalState + } + function ConvertTo-PropertySchema { param( [string]$PropertyType, - [string[]]$Values + [string[]]$Values, + [bool]$AllowNull ) # Windows PowerShell metadata can wrap type names in brackets. $typeName = $PropertyType -replace '^\[(.*)\]$', '$1' + $typeName = $typeName -replace '^(?:System\.)?Nullable\[(.*)\]$', '$1' if ($typeName.EndsWith('[]')) { return @{ - type = @('array', 'null') + type = if ($AllowNull) { @('array', 'null') } else { 'array' } items = ConvertTo-PropertySchema -PropertyType $typeName.Substring(0, $typeName.Length - 2) -Values $Values } } $schema = switch -Regex ($typeName -replace '^System\.', '') { '^(string|char|datetime|datetimeoffset|guid|uri)$' { - @{ type = @('string', 'null') } + @{ type = 'string' } break } '^(securestring|security\.securestring|timespan|version)$' { # These accept string input but serialize as objects in resource output. - @{ type = @('string', 'object', 'null') } + @{ type = @('string', 'object') } break } '^(bool|boolean)$' { @@ -177,7 +213,7 @@ $ps = [PowerShell]::Create().AddScript({ break } '^(hashtable|collections\.hashtable|pscredential|management\.automation\.pscredential|ciminstance|microsoft\.management\.infrastructure\.ciminstance)$' { - @{ type = @('object', 'null') } + @{ type = 'object' } break } default { @@ -186,15 +222,22 @@ $ps = [PowerShell]::Create().AddScript({ } } + if ($AllowNull -and $schema.ContainsKey('type')) { + $schema.type = @($schema.type) + 'null' + } + if ($Values.Count -gt 0) { - if ($schema.type -in @('integer', 'number')) { + if ($schema.type -contains 'integer' -or $schema.type -contains 'number') { $schema.enum = @($Values | ForEach-Object { [decimal]::Parse($_, [Globalization.CultureInfo]::InvariantCulture) }) + if ($AllowNull) { + $schema.enum += $null + } } else { $schema.anyOf = @( @{ enum = @($Values) } @{ type = 'string'; pattern = '^(?i:' + (($Values | ForEach-Object { [regex]::Escape($_) }) -join '|') + ')$' } ) - if ('null' -in $schema.type) { + if ($AllowNull) { $schema.anyOf += @{ type = 'null' } } } @@ -269,16 +312,14 @@ $ps = [PowerShell]::Create().AddScript({ $properties = @() $propertySchemas = @{} - $propertyPatterns = @{} $requiredProperties = @() foreach ($prop in $DscResourceInfo.Properties) { - $properties += $prop.Name - $propertySchemas[$prop.Name] = ConvertTo-PropertySchema -PropertyType $prop.PropertyType -Values $prop.Values - $pattern = '^(?i:' + [regex]::Escape($prop.Name) + ')$' - $propertyPatterns[$pattern] = $propertySchemas[$prop.Name] + $propertyName = ConvertTo-CanonicalPropertyName -Name $prop.Name + $properties += $propertyName + $allowNull = Test-PropertyAllowsNull -Property $prop + $propertySchemas[$propertyName] = ConvertTo-PropertySchema -PropertyType $prop.PropertyType -Values $prop.Values -AllowNull $allowNull if ($prop.IsMandatory) { - # Require a matching property without making PowerShell names case sensitive. - $requiredProperties += @{ not = @{ propertyNames = @{ not = @{ pattern = $pattern } } } } + $requiredProperties += $propertyName } } @@ -286,10 +327,9 @@ $ps = [PowerShell]::Create().AddScript({ '$schema' = 'https://json-schema.org/draft/2020-12/schema' type = 'object' properties = $propertySchemas - patternProperties = $propertyPatterns } if ($requiredProperties.Count -gt 0) { - $resourceSchema.allOf = @($requiredProperties) + $resourceSchema.required = @($requiredProperties) } # OUTPUT dsc is expecting the following properties @@ -321,6 +361,8 @@ $ps = [PowerShell]::Create().AddScript({ if ($null -eq $dscResourceCache) { throw ("DSC resource '{0}' module not found." -f $ResourceType) } + $resourceProperties = ($dscResourceCache | Where-Object -Property Type -EQ $ResourceType | + Select-Object -First 1).DscResourceInfo.Properties $desiredState = $psDscAdapter.invoke( { param($jsonInput, $type) Get-DscResourceObject -jsonInput $jsonInput -type $type }, $jsonInput, $ResourceType ) if ($null -eq $desiredState) { @@ -343,18 +385,16 @@ $ps = [PowerShell]::Create().AddScript({ $actualState = $psDscAdapter.Invoke( { param($ds, $dscResourceCache) Invoke-DscOperation -Operation 'Get' -DesiredState $ds -dscResourceCache $dscResourceCache }, $desiredState, $dscResourceCache) } - if ($Operation -eq 'Test') { - $actualState.Properties | Add-Member -MemberType NoteProperty -Name _inDesiredState -Value $inDesiredState -Force - } - if ($Operation -eq 'Export') { foreach ($instance in $actualState) { - $instance + ConvertTo-ResourceState -State $instance -Properties $resourceProperties } exit 0 } - - $result = $actualState.Properties + $result = ConvertTo-ResourceState -State $actualState.Properties -Properties $resourceProperties + if ($Operation -eq 'Test') { + $result['_inDesiredState'] = $inDesiredState + } return $result } diff --git a/adapters/powershell/psDscAdapter/psDscAdapter.psm1 b/adapters/powershell/psDscAdapter/psDscAdapter.psm1 index bead5067b..c018e03b3 100644 --- a/adapters/powershell/psDscAdapter/psDscAdapter.psm1 +++ b/adapters/powershell/psDscAdapter/psDscAdapter.psm1 @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -$script:CurrentCacheSchemaVersion = 4 +$script:CurrentCacheSchemaVersion = 5 $ErrorActionPreference = 'Stop' function Import-PSDSCModule { @@ -82,11 +82,15 @@ function Add-AstMember { $prop.Name = $property.Name $prop.PropertyType = $property.PropertyType.TypeName.Name $prop.IsMandatory = $isRequiredProperty - $enumType = $TypeDefinitionsByName[($prop.PropertyType -replace '\[\]$', '')] + $enumTypeName = $prop.PropertyType -replace '^(?:System\.)?Nullable\[(.*)\]$', '$1' -replace '\[\]$', '' + $enumType = $TypeDefinitionsByName[$enumTypeName] if ($enumType.IsEnum) { $prop.Values = @($enumType.Members.Name) } foreach ($attr in $property.Attributes) { + if ($attr.TypeName.Name -match '^(System\.Management\.Automation\.)?AllowNull(Attribute)?$') { + $prop.AllowNull = $true + } if ($attr.TypeName.Name -eq 'ValidateSet') { $prop.Values = @($attr.PositionalArguments | ForEach-Object { $_.SafeGetValue() }) } @@ -748,6 +752,7 @@ class DscResourcePropertyInfo { [string] $Name [string] $PropertyType [bool] $IsMandatory + [bool] $AllowNull [System.Collections.Generic.List[string]] $Values } diff --git a/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 b/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 index 624607dd7..99057aeec 100644 --- a/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 +++ b/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 @@ -3,7 +3,7 @@ $global:ProgressPreference = 'SilentlyContinue' $ErrorActionPreference = 'Stop' -$script:CurrentCacheSchemaVersion = 2 +$script:CurrentCacheSchemaVersion = 3 trap { Write-Error -Message ($_ | Format-List -Force | Out-String) @@ -801,11 +801,15 @@ function GetClassBasedProperties { $prop.Name = $property.Name $prop.PropertyType = $property.PropertyType.TypeName.Name $prop.IsMandatory = $isRequiredProperty - $enumType = $typeDefinitionsByName[($prop.PropertyType -replace '\[\]$', '')] + $enumTypeName = $prop.PropertyType -replace '^(?:System\.)?Nullable\[(.*)\]$', '$1' -replace '\[\]$', '' + $enumType = $typeDefinitionsByName[$enumTypeName] if ($enumType.IsEnum) { $prop.Values = @($enumType.Members.Name) } foreach ($attr in $property.Attributes) { + if ($attr.TypeName.Name -match '^(System\.Management\.Automation\.)?AllowNull(Attribute)?$') { + $prop.AllowNull = $true + } if ($attr.TypeName.Name -eq 'ValidateSet') { $prop.Values = @($attr.PositionalArguments | ForEach-Object { $_.SafeGetValue() }) } @@ -919,6 +923,7 @@ class DscResourcePropertyInfo { [string] $Name [string] $PropertyType [bool] $IsMandatory + [bool] $AllowNull [System.Collections.Generic.List[string]] $Values } diff --git a/docs/reference/resources/Microsoft/Adapter/PowerShell/examples/configure-a-machine.md b/docs/reference/resources/Microsoft/Adapter/PowerShell/examples/configure-a-machine.md index 679d66df0..1779a8def 100644 --- a/docs/reference/resources/Microsoft/Adapter/PowerShell/examples/configure-a-machine.md +++ b/docs/reference/resources/Microsoft/Adapter/PowerShell/examples/configure-a-machine.md @@ -35,13 +35,13 @@ resources: - name: Windows Terminal type: Microsoft.WinGet.DSC/WinGetPackage properties: - Id: Microsoft.WindowsTerminal - Ensure: "[parameters('ensureTools')]" + id: Microsoft.WindowsTerminal + ensure: "[parameters('ensureTools')]" - name: Visual Studio Code type: Microsoft.WinGet.DSC/WinGetPackage properties: - Id: Microsoft.VisualStudioCode - Ensure: "[parameters('ensureTools')]" + id: Microsoft.VisualStudioCode + ensure: "[parameters('ensureTools')]" ``` ## Setup @@ -96,20 +96,19 @@ results: type: Microsoft.WinGet.DSC/WinGetPackage result: desiredState: - Id: Microsoft.WindowsTerminal - Ensure: Present + id: Microsoft.WindowsTerminal + ensure: Present actualState: - Version: null - MatchOption: EqualsCaseInsensitive - UseLatest: false - InstallMode: Silent - Id: Microsoft.WindowsTerminal - Ensure: Absent - Source: '' + matchOption: EqualsCaseInsensitive + useLatest: false + installMode: Silent + id: Microsoft.WindowsTerminal + ensure: Absent + source: '' _inDesiredState: false inDesiredState: false differingProperties: - - Ensure + - ensure - executionInformation: duration: PT7.6445836S metadata: @@ -119,20 +118,19 @@ results: type: Microsoft.WinGet.DSC/WinGetPackage result: desiredState: - Id: Microsoft.VisualStudioCode - Ensure: Present + id: Microsoft.VisualStudioCode + ensure: Present actualState: - UseLatest: false - Version: null - Source: '' - Ensure: Absent - Id: Microsoft.VisualStudioCode - MatchOption: EqualsCaseInsensitive - InstallMode: Silent + useLatest: false + source: '' + ensure: Absent + id: Microsoft.VisualStudioCode + matchOption: EqualsCaseInsensitive + installMode: Silent _inDesiredState: false inDesiredState: false differingProperties: - - Ensure + - ensure messages: [] hadErrors: false ``` @@ -160,26 +158,25 @@ results: type: Microsoft.WinGet.DSC/WinGetPackage result: beforeState: - InstallMode: Silent - UseLatest: false - Source: '' - Id: Microsoft.WindowsTerminal - Ensure: Absent - Version: null - MatchOption: EqualsCaseInsensitive + installMode: Silent + useLatest: false + source: '' + id: Microsoft.WindowsTerminal + ensure: Absent + matchOption: EqualsCaseInsensitive afterState: - Version: 1.24.10921.0 - UseLatest: true - Source: winget - InstallMode: Silent - Ensure: Present - MatchOption: EqualsCaseInsensitive - Id: Microsoft.WindowsTerminal + version: 1.24.10921.0 + useLatest: true + source: winget + installMode: Silent + ensure: Present + matchOption: EqualsCaseInsensitive + id: Microsoft.WindowsTerminal changedProperties: - - Version - - UseLatest - - Source - - Ensure + - version + - useLatest + - source + - ensure - executionInformation: duration: PT11.6464059S metadata: @@ -189,26 +186,25 @@ results: type: Microsoft.WinGet.DSC/WinGetPackage result: beforeState: - UseLatest: false - Ensure: Absent - Id: Microsoft.VisualStudioCode - MatchOption: EqualsCaseInsensitive - InstallMode: Silent - Source: '' - Version: null + useLatest: false + ensure: Absent + id: Microsoft.VisualStudioCode + matchOption: EqualsCaseInsensitive + installMode: Silent + source: '' afterState: - Id: Microsoft.VisualStudioCode - MatchOption: EqualsCaseInsensitive - Source: winget - Version: 1.119.0 - InstallMode: Silent - Ensure: Present - UseLatest: true + id: Microsoft.VisualStudioCode + matchOption: EqualsCaseInsensitive + source: winget + version: 1.119.0 + installMode: Silent + ensure: Present + useLatest: true changedProperties: - - Source - - Version - - Ensure - - UseLatest + - source + - version + - ensure + - useLatest messages: [] hadErrors: false ``` diff --git a/docs/reference/resources/Microsoft/Adapter/PowerShell/examples/invoke-a-resource.md b/docs/reference/resources/Microsoft/Adapter/PowerShell/examples/invoke-a-resource.md index b3a4439d3..bd81aa929 100644 --- a/docs/reference/resources/Microsoft/Adapter/PowerShell/examples/invoke-a-resource.md +++ b/docs/reference/resources/Microsoft/Adapter/PowerShell/examples/invoke-a-resource.md @@ -77,8 +77,8 @@ whether the Windows Terminal package is installed: ```powershell $resource = 'Microsoft.WinGet.DSC/WinGetPackage' $instance = @{ - Id = 'Microsoft.WindowsTerminal' - Ensure = 'Present' + id = 'Microsoft.WindowsTerminal' + ensure = 'Present' } | ConvertTo-Json -Compress dsc resource test --resource $resource --input $instance @@ -88,23 +88,22 @@ When the package isn't installed, DSC returns the following result: ```yaml desiredState: - Id: Microsoft.WindowsTerminal - Ensure: Present + id: Microsoft.WindowsTerminal + ensure: Present actualState: - Id: Microsoft.WindowsTerminal - InstallMode: Silent - Version: null - Ensure: Absent - MatchOption: EqualsCaseInsensitive - Source: '' - UseLatest: false + id: Microsoft.WindowsTerminal + installMode: Silent + ensure: Absent + matchOption: EqualsCaseInsensitive + source: '' + useLatest: false _inDesiredState: false inDesiredState: false differingProperties: -- Ensure +- ensure ``` -The `inDesiredState` field is `false` and `differingProperties` shows that `Ensure` differs between +The `inDesiredState` field is `false` and `differingProperties` shows that `ensure` differs between the desired state and the actual state. ## Set an instance to the desired state @@ -114,8 +113,8 @@ Use the [`dsc config set`][04] command to enforce the desired state for the reso ```powershell $resource = 'Microsoft.WinGet.DSC/WinGetPackage' $instance = @{ - Id = 'Microsoft.WindowsTerminal' - Ensure = 'Present' + id = 'Microsoft.WindowsTerminal' + ensure = 'Present' } | ConvertTo-Json -Compress dsc resource set --resource $resource --input $instance @@ -125,26 +124,25 @@ When the resource installs the package, DSC returns the following result: ```yaml beforeState: - Ensure: Absent - UseLatest: false - InstallMode: Silent - Version: null - Id: Microsoft.WindowsTerminal - MatchOption: EqualsCaseInsensitive - Source: '' + ensure: Absent + useLatest: false + installMode: Silent + id: Microsoft.WindowsTerminal + matchOption: EqualsCaseInsensitive + source: '' afterState: - Version: 1.24.10921.0 - MatchOption: EqualsCaseInsensitive - Ensure: Present - Id: Microsoft.WindowsTerminal - UseLatest: true - InstallMode: Silent - Source: winget + version: 1.24.10921.0 + matchOption: EqualsCaseInsensitive + ensure: Present + id: Microsoft.WindowsTerminal + useLatest: true + installMode: Silent + source: winget changedProperties: -- Version -- Ensure -- UseLatest -- Source +- version +- ensure +- useLatest +- source ``` ## Cleanup @@ -154,8 +152,8 @@ To remove the installed package, define the desired state for the `Ensure` prope ```powershell $resource = 'Microsoft.WinGet.DSC/WinGetPackage' $instance = @{ - Id = 'Microsoft.WindowsTerminal' - Ensure = 'Absent' + id = 'Microsoft.WindowsTerminal' + ensure = 'Absent' } | ConvertTo-Json -Compress dsc resource set --resource $resource --input $instance diff --git a/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md b/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md index 891037ec5..2834b9c60 100644 --- a/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md +++ b/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md @@ -168,19 +168,23 @@ configuration document with the following exceptions: dsc resource schema -r '/' ``` - Each property name must be a configurable property of the PSDSC resource. The property name - isn't case sensitive. The value for each property must be valid for that property. If you + Each property name must be a configurable property of the PSDSC resource. Schemas use camelCase + property names, preserving names that contain nonalphanumeric characters. Use the spelling + shown in the schema, including for key and mandatory properties listed in `required`. If you specify an invalid property name or value, the adapter raises an error when it tries to invoke the resource. - Property names and enumeration values remain case insensitive. Custom and embedded property + Enumeration values remain case insensitive. Custom and embedded property types without a known JSON representation are validated by the underlying PowerShell resource. The adapter doesn't implement a separate `validate` operation. Schemas also enforce discovered `ValidateSet` constraints, including constraints on array - items. Nullable property types accept `null`, including nullable strings with a `ValidateSet` - constraint. Non-nullable enumeration and numeric types reject `null`, even when they have a - validation set. + items. For known JSON types, properties accept explicit `null` only when they have an + `[AllowNull()]` attribute or are both required and declared with a `Nullable[]` + type. Optional nullable value types can be omitted but don't accept explicit `null`. The adapter + returns camelCase property names and omits unset optional null properties unless they explicitly + allow null. Allowing a null array doesn't allow null array items, and allowing null doesn't + bypass validation sets for non-null values. ## Exit codes diff --git a/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/examples/manage-a-windows-service.md b/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/examples/manage-a-windows-service.md index fd226f520..b89a7e5c2 100644 --- a/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/examples/manage-a-windows-service.md +++ b/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/examples/manage-a-windows-service.md @@ -32,8 +32,8 @@ resources: directives: requireAdapter: Microsoft.Adapter/WindowsPowerShell properties: - Name: Spooler - StartupType: Automatic + name: Spooler + startupType: Automatic ``` Copy the configuration document and save it as `spooler.dsc.yaml`. @@ -106,39 +106,31 @@ results: type: PSDesiredStateConfiguration/Service result: desiredState: - Name: Spooler - StartupType: Automatic + name: Spooler + startupType: Automatic actualState: - Status: null - Description: This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers. - DisplayName: Print Spooler - ResourceId: null - PsDscRunAsCredential: null - Name: Spooler - Credential: null - PSComputerName: localhost - ConfigurationName: null - Ensure: null - DependsOn: null - SourceInfo: null - BuiltInAccount: LocalSystem - StartupType: Manual - State: Running - ModuleVersion: '1.1' - ModuleName: PSDesiredStateConfiguration - Path: C:\WINDOWS\System32\spoolsv.exe - Dependencies: + description: This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers. + displayName: Print Spooler + name: Spooler + psComputerName: localhost + builtInAccount: LocalSystem + startupType: Manual + state: Running + moduleVersion: '1.1' + moduleName: PSDesiredStateConfiguration + path: C:\WINDOWS\System32\spoolsv.exe + dependencies: - RPCSS - http _inDesiredState: false inDesiredState: false differingProperties: - - StartupType + - startupType messages: [] hadErrors: false ``` -The `inDesiredState` field is `false` and `differingProperties` shows that `StartupType` differs. +The `inDesiredState` field is `false` and `differingProperties` shows that `startupType` differs. ## Ensure a service is running with automatic startup @@ -178,51 +170,35 @@ results: type: PSDesiredStateConfiguration/Service result: beforeState: - Status: null - Description: This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers. - DisplayName: Print Spooler - ResourceId: null - PsDscRunAsCredential: null - Name: Spooler - Credential: null - PSComputerName: localhost - ConfigurationName: null - Ensure: null - DependsOn: null - SourceInfo: null - BuiltInAccount: LocalSystem - StartupType: Manual - State: Running - ModuleVersion: '1.1' - ModuleName: PSDesiredStateConfiguration - Path: C:\WINDOWS\System32\spoolsv.exe - Dependencies: + description: This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers. + displayName: Print Spooler + name: Spooler + psComputerName: localhost + builtInAccount: LocalSystem + startupType: Manual + state: Running + moduleVersion: '1.1' + moduleName: PSDesiredStateConfiguration + path: C:\WINDOWS\System32\spoolsv.exe + dependencies: - RPCSS - http afterState: - Status: null - Description: This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers. - DisplayName: Print Spooler - ResourceId: null - PsDscRunAsCredential: null - Name: Spooler - Credential: null - PSComputerName: localhost - ConfigurationName: null - Ensure: null - DependsOn: null - SourceInfo: null - BuiltInAccount: LocalSystem - StartupType: Automatic - State: Running - ModuleVersion: '1.1' - ModuleName: PSDesiredStateConfiguration - Path: C:\WINDOWS\System32\spoolsv.exe - Dependencies: + description: This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers. + displayName: Print Spooler + name: Spooler + psComputerName: localhost + builtInAccount: LocalSystem + startupType: Automatic + state: Running + moduleVersion: '1.1' + moduleName: PSDesiredStateConfiguration + path: C:\WINDOWS\System32\spoolsv.exe + dependencies: - RPCSS - http changedProperties: - - StartupType + - startupType messages: [] hadErrors: false ``` @@ -261,28 +237,20 @@ results: type: PSDesiredStateConfiguration/Service result: desiredState: - Name: Spooler - StartupType: Automatic + name: Spooler + startupType: Automatic actualState: - Status: null - Description: This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers. - DisplayName: Print Spooler - ResourceId: null - PsDscRunAsCredential: null - Name: Spooler - Credential: null - PSComputerName: localhost - ConfigurationName: null - Ensure: null - DependsOn: null - SourceInfo: null - BuiltInAccount: LocalSystem - StartupType: Automatic - State: Running - ModuleVersion: '1.1' - ModuleName: PSDesiredStateConfiguration - Path: C:\WINDOWS\System32\spoolsv.exe - Dependencies: + description: This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers. + displayName: Print Spooler + name: Spooler + psComputerName: localhost + builtInAccount: LocalSystem + startupType: Automatic + state: Running + moduleVersion: '1.1' + moduleName: PSDesiredStateConfiguration + path: C:\WINDOWS\System32\spoolsv.exe + dependencies: - RPCSS - http _inDesiredState: true @@ -298,9 +266,9 @@ To stop the service and set startup type to manual, use the [`dsc resource set`] ```powershell dsc resource set PSDesiredStateConfiguration/Service --input @' -Name: Spooler -State: Stopped -StartupType: Manual +name: Spooler +state: Stopped +startupType: Manual '@ ``` diff --git a/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md b/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md index 2831b9e9f..d585bba2c 100644 --- a/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md +++ b/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md @@ -178,19 +178,23 @@ configuration document with the following exceptions: dsc resource schema -r '/' ``` - Each property name must be a configurable property of the PSDSC resource. The property name - isn't case sensitive. The value for each property must be valid for that property. If you + Each property name must be a configurable property of the PSDSC resource. Schemas use camelCase + property names, preserving names that contain nonalphanumeric characters. Use the spelling + shown in the schema, including for key and mandatory properties listed in `required`. If you specify an invalid property name or value, the adapter raises an error when it tries to invoke the resource. - Property names and enumeration values remain case insensitive. Custom and embedded property + Enumeration values remain case insensitive. Custom and embedded property types without a known JSON representation are validated by the underlying PowerShell resource. The adapter doesn't implement a separate `validate` operation. Schemas also enforce discovered `ValidateSet` constraints, including constraints on array - items. Nullable property types accept `null`, including nullable strings with a `ValidateSet` - constraint. Non-nullable enumeration and numeric types reject `null`, even when they have a - validation set. + items. For known JSON types, properties accept explicit `null` only when they have an + `[AllowNull()]` attribute or are both required and declared with a `Nullable[]` + type. Optional nullable value types can be omitted but don't accept explicit `null`. The adapter + returns camelCase property names and omits unset optional null properties unless they explicitly + allow null. Allowing a null array doesn't allow null array items, and allowing null doesn't + bypass validation sets for non-null values. ## Exit codes From 36543c0f395516f8693b7c5cdf01afb31ac094ce Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 10 Nov 2026 14:54:49 -0800 Subject: [PATCH 6/8] Fix adapter schema CI test fixtures Use canonical input names in discovery and Windows cache tests, assert successful lookup-table invocations, and isolate a MOF-compatible Windows schema fixture while retaining modern punctuation-name coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- adapters/powershell/Tests/schema.tests.ps1 | 10 +++++++++- .../powershell/Tests/win_powershell_cache.tests.ps1 | 4 ++-- dsc/tests/discovery/dsc_adapter.tests.ps1 | 3 ++- dsc/tests/discovery/dsc_discovery.tests.ps1 | 6 ++++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/adapters/powershell/Tests/schema.tests.ps1 b/adapters/powershell/Tests/schema.tests.ps1 index 8ee42e8c8..6cf55f7c1 100644 --- a/adapters/powershell/Tests/schema.tests.ps1 +++ b/adapters/powershell/Tests/schema.tests.ps1 @@ -296,7 +296,15 @@ class TestNullableResource { It 'Produces usable Windows PowerShell schemas from live and cached metadata' -Skip:(!$IsWindows) { try { - $env:PSModulePath += [IO.Path]::PathSeparator + $PSScriptRoot + $windowsModuleRoot = Join-Path $TestDrive 'WindowsModules' + $windowsModulePath = Join-Path $windowsModuleRoot 'TestSchemaResource' + New-Item -Path $windowsModuleRoot -ItemType Directory | Out-Null + Copy-Item -Path $modulePath -Destination $windowsModulePath -Recurse + # Windows PowerShell's MOF parser cannot represent hyphenated property names. + $windowsModuleFile = Join-Path $windowsModulePath 'TestSchemaResource.psm1' + (Get-Content -Raw $windowsModuleFile).Replace('${Resource-Name}', '$ResourceName') | + Set-Content -Path $windowsModuleFile + $env:PSModulePath = $windowsModuleRoot + [IO.Path]::PathSeparator + $PSScriptRoot foreach ($iteration in 1..2) { $windowsResources = & powershell -NoProfile -ExecutionPolicy Bypass -File $scriptPath -Operation List -ResourceType Single 2> (Join-Path $TestDrive 'windows-schema-error.log') $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw (Join-Path $TestDrive 'windows-schema-error.log')) diff --git a/adapters/powershell/Tests/win_powershell_cache.tests.ps1 b/adapters/powershell/Tests/win_powershell_cache.tests.ps1 index 601484701..f039cd125 100644 --- a/adapters/powershell/Tests/win_powershell_cache.tests.ps1 +++ b/adapters/powershell/Tests/win_powershell_cache.tests.ps1 @@ -168,7 +168,7 @@ resources: - name: TestPSRepository type: PSTestModule/TestPSRepository properties: - Name: NuGet + name: NuGet dependsOn: - "[resourceId('Microsoft.Windows/WindowsPowerShell', 'File')]" - "[resourceId('Microsoft.DSC/Assertion', 'File present')]" @@ -177,7 +177,7 @@ resources: $filePath = "$testdrive\test.assertion.dsc.resource.yaml" $yaml | Set-Content -Path $filePath -Force dsc config test -f $filePath 2> "$TestDrive/error.txt" - $LASTEXITCODE | Should -Be 2 + $LASTEXITCODE | Should -Be 2 -Because (Get-Content -Raw "$TestDrive/error.txt") $cache = Get-Content -Path $cacheFilePath_v5 -Raw | ConvertFrom-Json $cache.ResourceCache.Type | Should -Contain 'PSTestModule/TestPSRepository' diff --git a/dsc/tests/discovery/dsc_adapter.tests.ps1 b/dsc/tests/discovery/dsc_adapter.tests.ps1 index 694cff549..297904678 100644 --- a/dsc/tests/discovery/dsc_adapter.tests.ps1 +++ b/dsc/tests/discovery/dsc_adapter.tests.ps1 @@ -191,7 +191,7 @@ Describe 'Tests for adapter support' { - name: Test2 type: TestClassResource/TestClassResource properties: - Name: 'Bye' + name: 'Bye' directives: requireAdapter: Microsoft.Adapter/PowerShell '@ @@ -204,6 +204,7 @@ Describe 'Tests for adapter support' { $out.results[1].type | Should -BeExactly 'TestClassResource/TestClassResource' $out.results[1].Name | Should -Be 'Test2' $out.results[1].result.actualState.Name | Should -BeExactly 'Bye' + $out.results[1].result.actualState.PSObject.Properties.Name -ccontains 'name' | Should -BeTrue "$TestDrive/error.log" | Should -FileContentMatch "Invoking get for 'Microsoft.DSC/PowerShell'" -Because (Get-Content $TestDrive/error.log | Out-String) "$TestDrive/error.log" | Should -FileContentMatch "Invoking get for 'Microsoft.Adapter/PowerShell'" -Because (Get-Content $TestDrive/error.log | Out-String) } diff --git a/dsc/tests/discovery/dsc_discovery.tests.ps1 b/dsc/tests/discovery/dsc_discovery.tests.ps1 index 029d69e0a..e944d77d1 100644 --- a/dsc/tests/discovery/dsc_discovery.tests.ps1 +++ b/dsc/tests/discovery/dsc_discovery.tests.ps1 @@ -135,7 +135,8 @@ Describe 'tests for resource discovery' { $TestClassResourcePath = Resolve-Path "$PSScriptRoot/../../../adapters/powershell/Tests" $env:DSC_RESTRICTED_PATH = $null $env:PSModulePath += [System.IO.Path]::PathSeparator + $TestClassResourcePath - "{'Name':'TestClassResource1'}" | dsc resource get -r 'TestClassResource/TestClassResource' -f - | Out-Null + "{'name':'TestClassResource1'}" | dsc resource get -r 'TestClassResource/TestClassResource' -f - | Out-Null + $LASTEXITCODE | Should -Be 0 Test-Path $script:lookupTableFilePath -PathType Leaf | Should -BeTrue $script:lookupTableFilePath | Should -FileContentMatchExactly 'testclassresource/testclassresource' @@ -159,7 +160,8 @@ Describe 'tests for resource discovery' { "$TestDrive/tracing.txt" | Should -FileContentMatchExactly "Saving lookup table" -Because (Get-Content -Raw "$TestDrive/tracing.txt") # second invocation (without an update) should use but not save adapter lookup table - "{'Name':'TestClassResource1'}" | dsc -l trace resource get -r 'TestClassResource/TestClassResource' -f - 2> $TestDrive/tracing.txt + "{'name':'TestClassResource1'}" | dsc -l trace resource get -r 'TestClassResource/TestClassResource' -f - 2> $TestDrive/tracing.txt + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/tracing.txt") "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly "Saving lookup table" -Because (Get-Content -Raw "$TestDrive/tracing.txt") # third invocation (with an update) should save updated adapter lookup table From 09b189a177ea9190501b6cb1e6582cc244883d98 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 10 Nov 2026 17:08:35 -0800 Subject: [PATCH 7/8] Use MOF-compatible enum types in Windows schema fixture Keep nullable module-defined enums in modern adapter coverage, but use ordinary enums for Windows PowerShell discovery. Run compatible fixture schema assertions on every platform and retain Windows-only script resource checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- adapters/powershell/Tests/schema.tests.ps1 | 38 +++++++++++++--------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/adapters/powershell/Tests/schema.tests.ps1 b/adapters/powershell/Tests/schema.tests.ps1 index 6cf55f7c1..77bfd3e15 100644 --- a/adapters/powershell/Tests/schema.tests.ps1 +++ b/adapters/powershell/Tests/schema.tests.ps1 @@ -294,23 +294,26 @@ class TestNullableResource { Should -BeFalse } - It 'Produces usable Windows PowerShell schemas from live and cached metadata' -Skip:(!$IsWindows) { + It 'Produces usable platform-compatible schemas from live and cached metadata' { try { $windowsModuleRoot = Join-Path $TestDrive 'WindowsModules' $windowsModulePath = Join-Path $windowsModuleRoot 'TestSchemaResource' New-Item -Path $windowsModuleRoot -ItemType Directory | Out-Null Copy-Item -Path $modulePath -Destination $windowsModulePath -Recurse - # Windows PowerShell's MOF parser cannot represent hyphenated property names. + # The MOF parser rejects hyphenated names and nullable enums defined in the module. $windowsModuleFile = Join-Path $windowsModulePath 'TestSchemaResource.psm1' - (Get-Content -Raw $windowsModuleFile).Replace('${Resource-Name}', '$ResourceName') | + (Get-Content -Raw $windowsModuleFile).Replace('${Resource-Name}', '$ResourceName'). + Replace('[Nullable[SchemaEnsure]]', '[SchemaEnsure]') | Set-Content -Path $windowsModuleFile $env:PSModulePath = $windowsModuleRoot + [IO.Path]::PathSeparator + $PSScriptRoot + $executable = if ($IsWindows) { 'powershell' } else { 'pwsh' } + $expectedAdapter = if ($IsWindows) { 'Microsoft.Adapter/WindowsPowerShell' } else { 'Microsoft.Adapter/PowerShell' } foreach ($iteration in 1..2) { - $windowsResources = & powershell -NoProfile -ExecutionPolicy Bypass -File $scriptPath -Operation List -ResourceType Single 2> (Join-Path $TestDrive 'windows-schema-error.log') + $windowsResources = & $executable -NoProfile -ExecutionPolicy Bypass -File $scriptPath -Operation List -ResourceType Single 2> (Join-Path $TestDrive 'windows-schema-error.log') $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw (Join-Path $TestDrive 'windows-schema-error.log')) $windowsResource = $windowsResources | ConvertFrom-Json | Where-Object -Property type -EQ 'TestSchemaResource/TestSchemaResource' - $windowsResource.requireAdapter | Should -BeExactly 'Microsoft.Adapter/WindowsPowerShell' + $windowsResource.requireAdapter | Should -BeExactly $expectedAdapter $windowsResource.schema.properties.Count.type | Should -BeExactly 'integer' $windowsResource.schema.properties.Names.items.type | Should -Contain 'string' $windowsSchema = $windowsResource.schema | ConvertTo-Json -Depth 100 @@ -319,20 +322,25 @@ class TestNullableResource { $windowsNullable = $windowsResources | ConvertFrom-Json | Where-Object -Property type -EQ 'TestSchemaResource/TestNullableResource' $windowsNullableSchema = $windowsNullable.schema | ConvertTo-Json -Depth 100 - Test-Json -Json '{"name":"test","requiredCount":null,"requiredEnsure":null,"choice":null,"choices":null}' -Schema $windowsNullableSchema -ErrorAction Stop | + $windowsNullable.schema.properties.requiredEnsure.anyOf[0].enum | Should -Be @('Present', 'Absent') + Test-Json -Json '{"name":"test","requiredCount":null,"requiredEnsure":"Present","choice":null,"choices":null}' -Schema $windowsNullableSchema -ErrorAction Stop | Should -BeTrue - Test-Json -Json '{"name":"test","requiredCount":1,"requiredEnsure":null,"enabled":null}' -Schema $windowsNullableSchema -ErrorAction Ignore | + Test-Json -Json '{"name":"test","requiredCount":1,"requiredEnsure":"Present","enabled":null}' -Schema $windowsNullableSchema -ErrorAction Ignore | Should -BeFalse - Test-Json -Json '{"name":"test","requiredValue":"value","count":"bad"}' -Schema $windowsSchema -ErrorAction Ignore | + Test-Json -Json '{"name":"test","requiredCount":1,"requiredEnsure":null}' -Schema $windowsNullableSchema -ErrorAction Ignore | Should -BeFalse - $scriptResource = $windowsResources | ConvertFrom-Json | - Where-Object -Property type -EQ 'TestScriptBaseDSC/CredentialValidation' - $scriptResource.schema.properties.Credential.type | Should -Contain 'object' - $scriptSchema = $scriptResource.schema | ConvertTo-Json -Depth 100 - Test-Json -Json '{"name":"test","credential":{"username":"user","password":"secret"}}' -Schema $scriptSchema -ErrorAction Stop | - Should -BeTrue - Test-Json -Json '{"name":"test"}' -Schema $scriptSchema -ErrorAction Ignore | + Test-Json -Json '{"name":"test","requiredValue":"value","count":"bad"}' -Schema $windowsSchema -ErrorAction Ignore | Should -BeFalse + if ($IsWindows) { + $scriptResource = $windowsResources | ConvertFrom-Json | + Where-Object -Property type -EQ 'TestScriptBaseDSC/CredentialValidation' + $scriptResource.schema.properties.Credential.type | Should -Contain 'object' + $scriptSchema = $scriptResource.schema | ConvertTo-Json -Depth 100 + Test-Json -Json '{"name":"test","credential":{"username":"user","password":"secret"}}' -Schema $scriptSchema -ErrorAction Stop | + Should -BeTrue + Test-Json -Json '{"name":"test"}' -Schema $scriptSchema -ErrorAction Ignore | + Should -BeFalse + } } } finally { $env:PSModulePath = $TestDrive From 24c46a87c1f9441f321d7f68d9dfb585653eab94 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Sat, 14 Nov 2026 10:43:22 -0800 Subject: [PATCH 8/8] Address latest PowerShell adapter schema feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 11 ++ .../PowerShell_adapter.dsc.resource.json | 3 + adapters/powershell/Tests/schema.tests.ps1 | 139 +++++++++++++++++- ...indowsPowerShell_adapter.dsc.resource.json | 3 + .../psDscAdapter/powershell.resource.ps1 | 120 ++++++++++----- .../powershell/psDscAdapter/psDscAdapter.psm1 | 21 ++- .../psDscAdapter/win_psDscAdapter.psm1 | 96 +++++++----- .../Microsoft/Adapter/PowerShell/index.md | 30 +++- .../Adapter/WindowsPowerShell/index.md | 31 +++- .../Microsoft/DSC/PowerShell/index.md | 2 +- .../Windows/WindowsPowerShell/index.md | 2 +- 11 files changed, 364 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc5d85e86..c4cf8cc21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,17 @@ changes since the last release, see the [diff on GitHub][unreleased]. ### Changed +- **Breaking change:** PowerShell adapters now expose embedded resource schemas instead of an + always-successful `validate` operation. Resource input names must use the spelling reported by + `dsc resource schema -r '/'`, including camelCase key, mandatory, and + optional names. Unknown names and schema-invalid values are rejected before invocation. + Single-resource get, set, test, and export output uses camelCase names and omits unset optional + null properties unless explicitly nullable. Deprecated full-configuration wrapper output is + unchanged. Migrate existing configurations and output consumers to the generated schema's + property names and nullability. + + Related PR: [#1775](https://github.com/PowerShell/DSC/pull/1775) + - Extensions that implement the `secret` capability must now define the `args` property for the `secret` command with exactly one secret name input argument (`nameArg`) and at most one vault input argument (`vaultArg`). Previously, an extension could omit the secret name input argument, diff --git a/adapters/powershell/PowerShell_adapter.dsc.resource.json b/adapters/powershell/PowerShell_adapter.dsc.resource.json index 9fd0574cb..9c1087629 100644 --- a/adapters/powershell/PowerShell_adapter.dsc.resource.json +++ b/adapters/powershell/PowerShell_adapter.dsc.resource.json @@ -93,6 +93,9 @@ "return": "state" }, "export": { + "schema": { + "embedded": { "type": "object" } + }, "executable": "pwsh", "args": [ "-NoLogo", diff --git a/adapters/powershell/Tests/schema.tests.ps1 b/adapters/powershell/Tests/schema.tests.ps1 index 77bfd3e15..e5078f769 100644 --- a/adapters/powershell/Tests/schema.tests.ps1 +++ b/adapters/powershell/Tests/schema.tests.ps1 @@ -32,6 +32,9 @@ enum SchemaEnsure { } class SchemaBase { + [DscProperty(Key)] + [string] $Name + [DscProperty()] [string] $Inherited @@ -41,9 +44,6 @@ class SchemaBase { [DscResource()] class TestSchemaResource : SchemaBase { - [DscProperty(Key)] - [string] $Name - [DscProperty(Mandatory)] [string] $RequiredValue @@ -64,6 +64,16 @@ class TestSchemaResource : SchemaBase { [ValidateSet('One', 'Two')] [string] $Choice + [DscProperty()] + [System.Management.Automation.ValidateSetAttribute('One', 'Two', IgnoreCase = $false)] + [string] $ExactChoice + + [DscProperty(NotConfigurable)] + [string] $Observed + + [DscProperty()] + [SchemaBase] $Details + [DscProperty()] [ValidateSet('One', 'Two')] [string[]] $Choices @@ -97,6 +107,14 @@ class TestSchemaResource : SchemaBase { [TestSchemaResource] Get() { return $this } [void] Set() {} [bool] Test() { return $true } + + static [object[]] Export() { + return @([pscustomobject]@{ Name = 'export'; RequiredValue = 'value'; Selection = 1; Choice = 'One' }) + } + + static [object[]] Export([TestSchemaResource] $Filter) { + return @([pscustomobject]@{ Name = $Filter.Name; RequiredValue = 'value'; Selection = 1; Choice = 'One' }) + } } [DscResource()] @@ -142,6 +160,11 @@ class TestNullableResource { $schema = $resource.schema | ConvertTo-Json -Depth 100 -Compress $nullableResource = $resources | Where-Object -Property type -EQ 'TestSchemaResource/TestNullableResource' $nullableSchema = $nullableResource.schema | ConvertTo-Json -Depth 100 -Compress + $ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$null, [ref]$null) + foreach ($functionName in @('ConvertTo-CanonicalPropertyName', 'Test-PropertyAllowsNull', 'ConvertTo-ResourceState', 'ConvertTo-PropertySchema', 'ConvertTo-ResourceSchema')) { + $functionAst = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $functionName }, $true) + . ([scriptblock]::Create($functionAst.Extent.Text)) + } } AfterAll { @@ -213,6 +236,83 @@ class TestNullableResource { Should -BeTrue } + It 'Honors qualified case-sensitive validation attributes and read-only metadata' { + $resource.schema.properties.observed.readOnly | Should -BeTrue + $resource.schema.properties.details.type | Should -BeExactly 'object' + Test-Json -Json '{"name":"test","requiredValue":"value","exactChoice":"One","details":{}}' -Schema $schema -ErrorAction Stop | + Should -BeTrue + Test-Json -Json '{"name":"test","requiredValue":"value","exactChoice":"one"}' -Schema $schema -ErrorAction Ignore | + Should -BeFalse + Test-Json -Json '{"name":"test","requiredValue":"value","details":"invalid"}' -Schema $schema -ErrorAction Ignore | + Should -BeFalse + } + + It 'Warns and retains numeric type validation for nonnumeric value maps' { + $warnings = @() + $numericSchema = ConvertTo-PropertySchema -PropertyType 'int' -Values @('1', 'Other') -WarningVariable warnings 3>$null + $warnings | Should -HaveCount 1 + $numericSchema.ContainsKey('enum') | Should -BeFalse + Test-Json -Json '2' -Schema ($numericSchema | ConvertTo-Json) -ErrorAction Stop | Should -BeTrue + Test-Json -Json '"Other"' -Schema ($numericSchema | ConvertTo-Json) -ErrorAction Ignore | Should -BeFalse + } + + It 'Normalizes object export states without removing legitimate class properties' { + $state = ConvertTo-ResourceState -State ([pscustomobject]@{ Name = 'test'; DependsOn = 'value' }) -Properties @() + $state.name | Should -BeExactly 'test' + $state.dependsOn | Should -BeExactly 'value' + $mofState = ConvertTo-ResourceState -State @{ Name = 'test'; DependsOn = 'LCM'; PsDscRunAsCredential = 'LCM' } -Properties @() -ExcludeLcmProperties $true + $mofState.Keys | Should -Not -Contain 'dependsOn' + $mofState.Keys | Should -Not -Contain 'psDscRunAsCredential' + } + + It 'Filters only LCM metadata from script-based schemas' { + $metadata = @{ + ImplementationDetail = 'ScriptBased' + Properties = @( + [pscustomobject]@{ Name = 'Name'; PropertyType = '[string]'; IsMandatory = $true } + [pscustomobject]@{ Name = 'DependsOn'; PropertyType = '[string[]]' } + [pscustomobject]@{ Name = 'PsDscRunAsCredential'; PropertyType = '[PSCredential]' } + [pscustomobject]@{ Name = 'State'; PropertyType = '[string]'; Values = @('Running', 'Stopped') } + ) + } + $mofSchema = ConvertTo-ResourceSchema -ResourceInfo $metadata -Type 'Test/Script' + $mofSchema.properties.Keys | Should -Not -Contain 'dependsOn' + $mofSchema.properties.Keys | Should -Not -Contain 'psDscRunAsCredential' + Test-Json -Json '{"name":"test","state":"running"}' -Schema ($mofSchema | ConvertTo-Json -Depth 100) -ErrorAction Stop | Should -BeTrue + $metadata.ImplementationDetail = 'ClassBased' + $classSchema = ConvertTo-ResourceSchema -ResourceInfo $metadata -Type 'Test/Class' + $classSchema.properties.Keys | Should -Contain 'dependsOn' + $classSchema.properties.Keys | Should -Contain 'psDscRunAsCredential' + } + + It 'Isolates a schema-generation failure and warns with the resource type' { + Mock ConvertTo-PropertySchema { + param($PropertyType) + if ($PropertyType -eq 'BrokenType') { throw 'Invalid metadata' } + return @{ type = 'string' } + } + $warnings = @() + $broken = ConvertTo-ResourceSchema -ResourceInfo @{ Properties = @(@{ Name = 'Name'; PropertyType = 'BrokenType' }) } -Type 'Test/Broken' -WarningVariable warnings 3>$null + $healthy = ConvertTo-ResourceSchema -ResourceInfo @{ Properties = @(@{ Name = 'Name'; PropertyType = 'string'; IsMandatory = $true }) } -Type 'Test/Healthy' + $warnings | Should -HaveCount 1 + $warnings[0].Message | Should -Match 'Test/Broken.*Invalid metadata' + Test-Json -Json '{"unknown":"allowed"}' -Schema ($broken | ConvertTo-Json) -ErrorAction Stop | Should -BeTrue + $healthy.required | Should -Be @('name') + $healthy.properties.name.type | Should -BeExactly 'string' + } + + It 'Discovers inherited keys and attribute metadata through the Windows AST reader' { + $windowsAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $adapterPath 'psDscAdapter/win_psDscAdapter.psm1'), [ref]$null, [ref]$null) + $propertyClass = $windowsAst.Find({ param($node) $node -is [System.Management.Automation.Language.TypeDefinitionAst] -and $node.Name -eq 'DscResourcePropertyInfo' }, $true) + $reader = $windowsAst.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'GetClassBasedProperties' }, $true) + $readProperties = [scriptblock]::Create('param($Path)' + "`n" + $propertyClass.Extent.Text + "`n" + $reader.Extent.Text + "`n" + 'GetClassBasedProperties -filePath $Path -className TestSchemaResource') + $windowsProperties = & $readProperties (Join-Path $modulePath 'TestSchemaResource.psd1') + ($windowsProperties | Where-Object Name -EQ Name).IsMandatory | Should -BeTrue + ($windowsProperties | Where-Object Name -EQ Observed).IsReadOnly | Should -BeTrue + ($windowsProperties | Where-Object Name -EQ ExactChoice).IgnoreCase | Should -BeFalse + ($windowsProperties | Where-Object Name -EQ Details).IsClass | Should -BeTrue + } + It 'Accepts explicit nullability for ' -ForEach @( @{ Case = 'required nullable value types'; Json = '{"name":"test","requiredCount":null,"requiredEnsure":null}' } @{ Case = 'an AllowNull scalar'; Json = '{"name":"test","requiredCount":1,"requiredEnsure":"present","choice":null}' } @@ -268,6 +368,8 @@ class TestNullableResource { @{ Reason = 'a null SecureString'; Json = '{"name":"test","requiredValue":"value","secret":null}' } @{ Reason = 'a non-object credential'; Json = '{"name":"test","requiredValue":"value","credential":"secret"}' } @{ Reason = 'noncanonical required property casing'; Json = '{"name":"test","RequiredValue":"value"}' } + @{ Reason = 'noncanonical optional property casing'; Json = '{"name":"test","requiredValue":"value","Count":2}' } + @{ Reason = 'an unknown property'; Json = '{"name":"test","requiredValue":"value","unknown":"value"}' } ) { Test-Json -Json $Json -Schema $schema -ErrorAction Ignore | Should -BeFalse } @@ -316,6 +418,8 @@ class TestNullableResource { $windowsResource.requireAdapter | Should -BeExactly $expectedAdapter $windowsResource.schema.properties.Count.type | Should -BeExactly 'integer' $windowsResource.schema.properties.Names.items.type | Should -Contain 'string' + $windowsResource.schema.required | Should -Contain 'name' + $windowsResource.schema.properties.observed.readOnly | Should -BeTrue $windowsSchema = $windowsResource.schema | ConvertTo-Json -Depth 100 Test-Json -Json '{"name":"test","requiredValue":"value","ensure":"present"}' -Schema $windowsSchema -ErrorAction Stop | Should -BeTrue @@ -335,6 +439,8 @@ class TestNullableResource { $scriptResource = $windowsResources | ConvertFrom-Json | Where-Object -Property type -EQ 'TestScriptBaseDSC/CredentialValidation' $scriptResource.schema.properties.Credential.type | Should -Contain 'object' + $scriptResource.schema.properties.PSObject.Properties.Name | Should -Not -Contain 'dependsOn' + $scriptResource.schema.properties.PSObject.Properties.Name | Should -Not -Contain 'psDscRunAsCredential' $scriptSchema = $scriptResource.schema | ConvertTo-Json -Depth 100 Test-Json -Json '{"name":"test","credential":{"username":"user","password":"secret"}}' -Schema $scriptSchema -ErrorAction Stop | Should -BeTrue @@ -353,12 +459,39 @@ class TestNullableResource { ($out | ConvertFrom-Json).properties.Count.type | Should -BeExactly 'integer' } + It 'Preserves embedded schemas in adapter-filtered CLI listing' { + $out = dsc resource list 'TestSchemaResource/TestSchemaResource' --adapter Microsoft.Adapter/PowerShell 2> (Join-Path $TestDrive 'list-error.log') + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw (Join-Path $TestDrive 'list-error.log')) + $listed = $out | ConvertFrom-Json + $listed.schema.type | Should -BeExactly 'object' + $listed.schema.required | Should -Be @('name', 'requiredValue') + } + + It 'Exports canonical schema-conforming instances with ' -ForEach @( + @{ FilterName = 'no filter'; Arguments = @(); ExpectedName = 'export' } + @{ FilterName = 'a partial filter'; Arguments = @('-i', '{"name":"filtered"}'); ExpectedName = 'filtered' } + ) { + $out = dsc resource export -r 'TestSchemaResource/TestSchemaResource' @Arguments 2> (Join-Path $TestDrive 'export-error.log') + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw (Join-Path $TestDrive 'export-error.log')) + $exported = ($out | ConvertFrom-Json).resources[0].properties + $exported.name | Should -BeExactly $ExpectedName + $exported.PSObject.Properties.Name -ccontains 'Name' | Should -BeFalse + Test-Json -Json ($exported | ConvertTo-Json -Depth 100) -Schema $schema -ErrorAction Stop | Should -BeTrue + } + It 'Rejects invalid resource input through the DSC CLI' { $null = dsc resource get -r 'TestSchemaResource/TestSchemaResource' -i '{"name":"test","requiredValue":"value","count":"bad"}' 2> (Join-Path $TestDrive 'invalid-error.log') $LASTEXITCODE | Should -Not -Be 0 Get-Content -Raw (Join-Path $TestDrive 'invalid-error.log') | Should -Match 'integer' } + It 'Allows configuration metadata without passing it to the PowerShell resource' { + $json = '{"$schema":"https://aka.ms/dsc/schemas/v3/bundled/config/document.json","resources":[{"name":"test","type":"TestSchemaResource/TestSchemaResource","metadata":{"test":"value"},"properties":{"name":"test","requiredValue":"value","selection":1,"choice":"One"}}]}' + $out = dsc config get -i $json 2> (Join-Path $TestDrive 'metadata-error.log') + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw (Join-Path $TestDrive 'metadata-error.log')) + ($out | ConvertFrom-Json).results[0].result.actualState.name | Should -BeExactly 'test' + } + It 'Returns schema-conforming canonical state for ' -ForEach @( @{ Operation = 'get'; State = 'actualState' } @{ Operation = 'test'; State = 'actualState' } diff --git a/adapters/powershell/WindowsPowerShell_adapter.dsc.resource.json b/adapters/powershell/WindowsPowerShell_adapter.dsc.resource.json index cb7354a51..b7d3cd11f 100644 --- a/adapters/powershell/WindowsPowerShell_adapter.dsc.resource.json +++ b/adapters/powershell/WindowsPowerShell_adapter.dsc.resource.json @@ -80,6 +80,9 @@ "return": "state" }, "export": { + "schema": { + "embedded": { "type": "object" } + }, "executable": "powershell", "args": [ "-NoLogo", diff --git a/adapters/powershell/psDscAdapter/powershell.resource.ps1 b/adapters/powershell/psDscAdapter/powershell.resource.ps1 index 657f4cb8b..56bf00015 100644 --- a/adapters/powershell/psDscAdapter/powershell.resource.ps1 +++ b/adapters/powershell/psDscAdapter/powershell.resource.ps1 @@ -158,26 +158,35 @@ $ps = [PowerShell]::Create().AddScript({ function ConvertTo-ResourceState { param( - [System.Collections.IDictionary]$State, - $Properties + $State, + $Properties, + [bool]$ExcludeLcmProperties ) $canonicalState = @{} - foreach ($name in $State.Keys) { + $stateProperties = if ($State -is [System.Collections.IDictionary]) { $State.Keys } else { $State.PSObject.Properties.Name } + foreach ($name in $stateProperties) { + if ($ExcludeLcmProperties -and $name -in @('DependsOn', 'PsDscRunAsCredential')) { + continue + } + $value = if ($State -is [System.Collections.IDictionary]) { $State[$name] } else { $State.$name } $property = $Properties | Where-Object -Property Name -EQ $name | Select-Object -First 1 - if ($null -eq $State[$name] -and -not $property.IsMandatory -and -not (Test-PropertyAllowsNull -Property $property)) { + if ($null -eq $value -and -not $property.IsMandatory -and -not (Test-PropertyAllowsNull -Property $property)) { continue } - $canonicalState[(ConvertTo-CanonicalPropertyName -Name $name)] = $State[$name] + $canonicalState[(ConvertTo-CanonicalPropertyName -Name $name)] = $value } return $canonicalState } function ConvertTo-PropertySchema { + [CmdletBinding()] param( [string]$PropertyType, [string[]]$Values, - [bool]$AllowNull + [bool]$AllowNull, + [bool]$IsClass, + [bool]$IgnoreCase = $true ) # Windows PowerShell metadata can wrap type names in brackets. @@ -186,7 +195,7 @@ $ps = [PowerShell]::Create().AddScript({ if ($typeName.EndsWith('[]')) { return @{ type = if ($AllowNull) { @('array', 'null') } else { 'array' } - items = ConvertTo-PropertySchema -PropertyType $typeName.Substring(0, $typeName.Length - 2) -Values $Values + items = ConvertTo-PropertySchema -PropertyType $typeName.Substring(0, $typeName.Length - 2) -Values $Values -IsClass $IsClass -IgnoreCase $IgnoreCase } } @@ -218,7 +227,7 @@ $ps = [PowerShell]::Create().AddScript({ } default { # Custom and embedded types can have resource-specific JSON representations. - @{} + if ($IsClass) { @{ type = 'object' } } else { @{} } } } @@ -228,15 +237,23 @@ $ps = [PowerShell]::Create().AddScript({ if ($Values.Count -gt 0) { if ($schema.type -contains 'integer' -or $schema.type -contains 'number') { - $schema.enum = @($Values | ForEach-Object { [decimal]::Parse($_, [Globalization.CultureInfo]::InvariantCulture) }) - if ($AllowNull) { - $schema.enum += $null + $numericValues = [System.Collections.Generic.List[decimal]]::new() + foreach ($value in $Values) { + $parsed = [decimal]0 + if (-not [decimal]::TryParse($value, [Globalization.NumberStyles]::Number, [Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)) { + Write-Warning -Message "Ignoring nonnumeric value map for property type '$PropertyType': '$value'. Numeric type validation is retained." + return $schema + } + $numericValues.Add($parsed) } + $schema.enum = @($numericValues) + if ($AllowNull) { $schema.enum += $null } } else { - $schema.anyOf = @( - @{ enum = @($Values) } - @{ type = 'string'; pattern = '^(?i:' + (($Values | ForEach-Object { [regex]::Escape($_) }) -join '|') + ')$' } - ) + # Enum values provide editor completions; the pattern accepts case variants. + $schema.anyOf = @(@{ enum = @($Values) }) + if ($IgnoreCase) { + $schema.anyOf += @{ type = 'string'; pattern = '^(?i:' + (($Values | ForEach-Object { [regex]::Escape($_) }) -join '|') + ')$' } + } if ($AllowNull) { $schema.anyOf += @{ type = 'null' } } @@ -245,6 +262,48 @@ $ps = [PowerShell]::Create().AddScript({ return $schema } + function ConvertTo-ResourceSchema { + [CmdletBinding()] + param($ResourceInfo, [string]$Type) + + try { + $propertySchemas = @{ + '_inDesiredState' = @{ type = 'boolean'; readOnly = $true } + } + $requiredProperties = @() + foreach ($prop in $ResourceInfo.Properties) { + if ($ResourceInfo.ImplementationDetail -eq 'ScriptBased' -and $prop.Name -in @('DependsOn', 'PsDscRunAsCredential')) { + continue + } + $propertyName = ConvertTo-CanonicalPropertyName -Name $prop.Name + $allowNull = Test-PropertyAllowsNull -Property $prop + $propertySchemas[$propertyName] = ConvertTo-PropertySchema -PropertyType $prop.PropertyType -Values $prop.Values -AllowNull $allowNull -IsClass ([bool]$prop.IsClass) -IgnoreCase ($null -eq $prop.IgnoreCase -or $prop.IgnoreCase) + if ($prop.IsReadOnly) { + $propertySchemas[$propertyName].readOnly = $true + } + if ($prop.IsMandatory) { + $requiredProperties += $propertyName + } + } + $schema = @{ + '$schema' = 'https://json-schema.org/draft/2020-12/schema' + type = 'object' + properties = $propertySchemas + additionalProperties = $false + } + if ($requiredProperties.Count -gt 0) { + $schema.required = @($requiredProperties) + } + return $schema + } catch { + Write-Warning -Message "Failed to generate schema for '$Type': $_" + return @{ + '$schema' = 'https://json-schema.org/draft/2020-12/schema' + type = 'object' + } + } + } + if ($jsonInput) { if ($jsonInput -ne '{}') { $inputobj_pscustomobj = $jsonInput | ConvertFrom-Json @@ -311,26 +370,13 @@ $ps = [PowerShell]::Create().AddScript({ } $properties = @() - $propertySchemas = @{} - $requiredProperties = @() foreach ($prop in $DscResourceInfo.Properties) { - $propertyName = ConvertTo-CanonicalPropertyName -Name $prop.Name - $properties += $propertyName - $allowNull = Test-PropertyAllowsNull -Property $prop - $propertySchemas[$propertyName] = ConvertTo-PropertySchema -PropertyType $prop.PropertyType -Values $prop.Values -AllowNull $allowNull - if ($prop.IsMandatory) { - $requiredProperties += $propertyName + if ($DscResourceInfo.ImplementationDetail -eq 'ScriptBased' -and $prop.Name -in @('DependsOn', 'PsDscRunAsCredential')) { + continue } + $properties += ConvertTo-CanonicalPropertyName -Name $prop.Name } - - $resourceSchema = @{ - '$schema' = 'https://json-schema.org/draft/2020-12/schema' - type = 'object' - properties = $propertySchemas - } - if ($requiredProperties.Count -gt 0) { - $resourceSchema.required = @($requiredProperties) - } + $resourceSchema = ConvertTo-ResourceSchema -ResourceInfo $DscResourceInfo -Type $dscResource.Type # OUTPUT dsc is expecting the following properties [resourceOutput]@{ @@ -361,8 +407,10 @@ $ps = [PowerShell]::Create().AddScript({ if ($null -eq $dscResourceCache) { throw ("DSC resource '{0}' module not found." -f $ResourceType) } - $resourceProperties = ($dscResourceCache | Where-Object -Property Type -EQ $ResourceType | - Select-Object -First 1).DscResourceInfo.Properties + $resourceInfo = ($dscResourceCache | Where-Object -Property Type -EQ $ResourceType | + Select-Object -First 1).DscResourceInfo + $resourceProperties = $resourceInfo.Properties + $excludeLcmProperties = $resourceInfo.ImplementationDetail -eq 'ScriptBased' $desiredState = $psDscAdapter.invoke( { param($jsonInput, $type) Get-DscResourceObject -jsonInput $jsonInput -type $type }, $jsonInput, $ResourceType ) if ($null -eq $desiredState) { @@ -387,11 +435,11 @@ $ps = [PowerShell]::Create().AddScript({ if ($Operation -eq 'Export') { foreach ($instance in $actualState) { - ConvertTo-ResourceState -State $instance -Properties $resourceProperties + ConvertTo-ResourceState -State $instance -Properties $resourceProperties -ExcludeLcmProperties $excludeLcmProperties } exit 0 } - $result = ConvertTo-ResourceState -State $actualState.Properties -Properties $resourceProperties + $result = ConvertTo-ResourceState -State $actualState.Properties -Properties $resourceProperties -ExcludeLcmProperties $excludeLcmProperties if ($Operation -eq 'Test') { $result['_inDesiredState'] = $inDesiredState } diff --git a/adapters/powershell/psDscAdapter/psDscAdapter.psm1 b/adapters/powershell/psDscAdapter/psDscAdapter.psm1 index c018e03b3..17adf1cb6 100644 --- a/adapters/powershell/psDscAdapter/psDscAdapter.psm1 +++ b/adapters/powershell/psDscAdapter/psDscAdapter.psm1 @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -$script:CurrentCacheSchemaVersion = 5 +$script:CurrentCacheSchemaVersion = 6 $ErrorActionPreference = 'Stop' function Import-PSDSCModule { @@ -63,13 +63,16 @@ function Add-AstMember { } $skipProperty = $true $isRequiredProperty = $false + $isReadOnly = $false foreach ($attr in $property.Attributes) { - if ($attr.TypeName.Name -eq 'DscProperty') { + if ($attr.TypeName.Name -match '^(System\.Management\.Automation\.)?DscProperty(Attribute)?$') { $skipProperty = $false foreach ($attrArg in $attr.NamedArguments) { if ($attrArg.ArgumentName -in @('Key', 'Mandatory') -and $attrArg.Argument.SafeGetValue()) { $isRequiredProperty = $true - break + } + if ($attrArg.ArgumentName -eq 'NotConfigurable' -and $attrArg.Argument.SafeGetValue()) { + $isReadOnly = $true } } } @@ -82,17 +85,24 @@ function Add-AstMember { $prop.Name = $property.Name $prop.PropertyType = $property.PropertyType.TypeName.Name $prop.IsMandatory = $isRequiredProperty + $prop.IsReadOnly = $isReadOnly $enumTypeName = $prop.PropertyType -replace '^(?:System\.)?Nullable\[(.*)\]$', '$1' -replace '\[\]$', '' $enumType = $TypeDefinitionsByName[$enumTypeName] if ($enumType.IsEnum) { $prop.Values = @($enumType.Members.Name) } + $prop.IsClass = $null -ne $enumType -and -not $enumType.IsEnum foreach ($attr in $property.Attributes) { if ($attr.TypeName.Name -match '^(System\.Management\.Automation\.)?AllowNull(Attribute)?$') { $prop.AllowNull = $true } - if ($attr.TypeName.Name -eq 'ValidateSet') { + if ($attr.TypeName.Name -match '^(System\.Management\.Automation\.)?ValidateSet(Attribute)?$') { $prop.Values = @($attr.PositionalArguments | ForEach-Object { $_.SafeGetValue() }) + foreach ($argument in $attr.NamedArguments) { + if ($argument.ArgumentName -eq 'IgnoreCase') { + $prop.IgnoreCase = $argument.Argument.SafeGetValue() + } + } } } $Properties.Add($prop) @@ -753,6 +763,9 @@ class DscResourcePropertyInfo { [string] $PropertyType [bool] $IsMandatory [bool] $AllowNull + [bool] $IsReadOnly + [bool] $IsClass + [bool] $IgnoreCase = $true [System.Collections.Generic.List[string]] $Values } diff --git a/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 b/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 index 99057aeec..172f9ba1e 100644 --- a/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 +++ b/adapters/powershell/psDscAdapter/win_psDscAdapter.psm1 @@ -3,7 +3,7 @@ $global:ProgressPreference = 'SilentlyContinue' $ErrorActionPreference = 'Stop' -$script:CurrentCacheSchemaVersion = 3 +$script:CurrentCacheSchemaVersion = 4 trap { Write-Error -Message ($_ | Format-List -Force | Out-String) @@ -775,46 +775,73 @@ function GetClassBasedProperties { } $typeDefinition = $typeDefinitionsByName[$className] - foreach ($member in $typeDefinition.Members) { - $property = $member -as [System.Management.Automation.Language.PropertyMemberAst] - if (($null -eq $property) -or ($property.IsStatic)) { - continue; + $pendingTypes = [System.Collections.Generic.Queue[object]]::new() + $pendingTypes.Enqueue($typeDefinition) + $visitedTypes = @{} + $orderedTypes = [System.Collections.Generic.List[object]]::new() + while ($pendingTypes.Count -gt 0) { + $currentType = $pendingTypes.Dequeue() + if ($null -eq $currentType -or $visitedTypes.ContainsKey($currentType.Name)) { + continue } - $skipProperty = $true - $isRequiredProperty = $false - foreach ($attr in $property.Attributes) { - if ($attr.TypeName.Name -eq 'DscProperty') { - $skipProperty = $false - foreach ($attrArg in $attr.NamedArguments) { - if ($attrArg.ArgumentName -in @('Key', 'Mandatory') -and $attrArg.Argument.SafeGetValue()) { - $isRequiredProperty = $true - break + $visitedTypes[$currentType.Name] = $true + $orderedTypes.Insert(0, $currentType) + foreach ($baseType in $currentType.BaseTypes) { + $pendingTypes.Enqueue($typeDefinitionsByName[$baseType.TypeName.Name]) + } + } + foreach ($currentType in $orderedTypes) { + foreach ($member in $currentType.Members) { + $property = $member -as [System.Management.Automation.Language.PropertyMemberAst] + if (($null -eq $property) -or ($property.IsStatic)) { + continue; + } + $skipProperty = $true + $isRequiredProperty = $false + $isReadOnly = $false + foreach ($attr in $property.Attributes) { + if ($attr.TypeName.Name -match '^(System\.Management\.Automation\.)?DscProperty(Attribute)?$') { + $skipProperty = $false + foreach ($attrArg in $attr.NamedArguments) { + if ($attrArg.ArgumentName -in @('Key', 'Mandatory') -and $attrArg.Argument.SafeGetValue()) { + $isRequiredProperty = $true + } + if ($attrArg.ArgumentName -eq 'NotConfigurable' -and $attrArg.Argument.SafeGetValue()) { + $isReadOnly = $true + } } } } - } - if ($skipProperty) { - continue; - } + if ($skipProperty) { + continue; + } - [DscResourcePropertyInfo]$prop = [DscResourcePropertyInfo]::new() - $prop.Name = $property.Name - $prop.PropertyType = $property.PropertyType.TypeName.Name - $prop.IsMandatory = $isRequiredProperty - $enumTypeName = $prop.PropertyType -replace '^(?:System\.)?Nullable\[(.*)\]$', '$1' -replace '\[\]$', '' - $enumType = $typeDefinitionsByName[$enumTypeName] - if ($enumType.IsEnum) { - $prop.Values = @($enumType.Members.Name) - } - foreach ($attr in $property.Attributes) { - if ($attr.TypeName.Name -match '^(System\.Management\.Automation\.)?AllowNull(Attribute)?$') { - $prop.AllowNull = $true + [DscResourcePropertyInfo]$prop = [DscResourcePropertyInfo]::new() + $prop.Name = $property.Name + $prop.PropertyType = $property.PropertyType.TypeName.Name + $prop.IsMandatory = $isRequiredProperty + $prop.IsReadOnly = $isReadOnly + $enumTypeName = $prop.PropertyType -replace '^(?:System\.)?Nullable\[(.*)\]$', '$1' -replace '\[\]$', '' + $enumType = $typeDefinitionsByName[$enumTypeName] + if ($enumType.IsEnum) { + $prop.Values = @($enumType.Members.Name) } - if ($attr.TypeName.Name -eq 'ValidateSet') { - $prop.Values = @($attr.PositionalArguments | ForEach-Object { $_.SafeGetValue() }) + $prop.IsClass = $null -ne $enumType -and -not $enumType.IsEnum + foreach ($attr in $property.Attributes) { + if ($attr.TypeName.Name -match '^(System\.Management\.Automation\.)?AllowNull(Attribute)?$') { + $prop.AllowNull = $true + } + if ($attr.TypeName.Name -match '^(System\.Management\.Automation\.)?ValidateSet(Attribute)?$') { + $prop.Values = @($attr.PositionalArguments | ForEach-Object { $_.SafeGetValue() }) + foreach ($argument in $attr.NamedArguments) { + if ($argument.ArgumentName -eq 'IgnoreCase') { + $prop.IgnoreCase = $argument.Argument.SafeGetValue() + } + } + } } + $properties.Add($prop) } - $properties.Add($prop) } return $properties } @@ -924,6 +951,9 @@ class DscResourcePropertyInfo { [string] $PropertyType [bool] $IsMandatory [bool] $AllowNull + [bool] $IsReadOnly + [bool] $IsClass + [bool] $IgnoreCase = $true [System.Collections.Generic.List[string]] $Values } diff --git a/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md b/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md index 2834b9c60..d956ff999 100644 --- a/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md +++ b/docs/reference/resources/Microsoft/Adapter/PowerShell/index.md @@ -82,7 +82,7 @@ for each platform. | macOS | `$HOME/.dsc/PSAdapterCache.json` | | Windows | `%LOCALAPPDATA%\dsc\PSAdapterCache.json` | -The adapter versions the cache. The current version is `4`. If the version of the cache on a +The adapter versions the cache. The current version is `6`. If the version of the cache on a machine differs from the current version, the adapter refreshes the cache. The adapter checks whether the cache is stale on each run and refreshes it if: @@ -168,14 +168,18 @@ configuration document with the following exceptions: dsc resource schema -r '/' ``` - Each property name must be a configurable property of the PSDSC resource. Schemas use camelCase + Schemas use camelCase property names, preserving names that contain nonalphanumeric characters. Use the spelling - shown in the schema, including for key and mandatory properties listed in `required`. If you - specify an invalid property name or value, the adapter raises an error when it tries to invoke - the resource. - - Enumeration values remain case insensitive. Custom and embedded property - types without a known JSON representation are validated by the underlying PowerShell resource. + shown in the schema, including for optional properties and key and mandatory properties listed + in `required`. DSC rejects unknown names, noncanonical casing, and schema-invalid values before + invoking the resource. The schema includes DSC's `_inDesiredState` test output property. DSC + omits `_metadata` injection when the resource schema rejects it. Class properties marked + `DscProperty(NotConfigurable)` are annotated `readOnly`. + + Enumeration values remain case insensitive unless `ValidateSet` specifies `IgnoreCase = $false`. + Module-defined class properties use object schemas without nested member constraints. External + enums and types without a known JSON representation are validated by the underlying PowerShell + resource. The adapter doesn't implement a separate `validate` operation. Schemas also enforce discovered `ValidateSet` constraints, including constraints on array @@ -186,6 +190,16 @@ configuration document with the following exceptions: allow null. Allowing a null array doesn't allow null array items, and allowing null doesn't bypass validation sets for non-null values. + **Migration:** This is a breaking change from case-insensitive input names and PascalCase output. + Update configurations and output consumers to use the names and nullability reported by + `dsc resource schema`. Deprecated full-configuration wrapper output is unchanged. + + Export filters can omit key and mandatory properties; the resource validates its own filters. + Exported instances must still conform to the full resource schema. If resource metadata prevents + schema generation, the adapter logs a warning naming the resource and exposes a permissive object + schema so other resources remain discoverable. Nonnumeric numeric value maps also produce a + warning; numeric type validation remains in effect without the invalid enum constraint. + ## Exit codes The resource uses the following exit codes to report success and errors: diff --git a/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md b/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md index d585bba2c..351ff4e7a 100644 --- a/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md +++ b/docs/reference/resources/Microsoft/Adapter/WindowsPowerShell/index.md @@ -80,7 +80,7 @@ The following table defines the cache path for the Windows platform. | :------: | :---------------------------------------------- | | Windows | `%LOCALAPPDATA%\dsc\WindowsPSAdapterCache.json` | -The adapter versions the cache. The current version is `2`. If the version of the cache on a +The adapter versions the cache. The current version is `4`. If the version of the cache on a machine differs from the current version, the adapter refreshes the cache. The adapter checks whether the cache is stale on each run and refreshes it if: @@ -178,14 +178,19 @@ configuration document with the following exceptions: dsc resource schema -r '/' ``` - Each property name must be a configurable property of the PSDSC resource. Schemas use camelCase + Schemas use camelCase property names, preserving names that contain nonalphanumeric characters. Use the spelling - shown in the schema, including for key and mandatory properties listed in `required`. If you - specify an invalid property name or value, the adapter raises an error when it tries to invoke - the resource. - - Enumeration values remain case insensitive. Custom and embedded property - types without a known JSON representation are validated by the underlying PowerShell resource. + shown in the schema, including for optional properties and key and mandatory properties listed + in `required`. DSC rejects unknown names, noncanonical casing, and schema-invalid values before + invoking the resource. The schema includes DSC's `_inDesiredState` test output property. DSC + omits `_metadata` injection when the resource schema rejects it. Class properties marked + `DscProperty(NotConfigurable)` are annotated `readOnly`. + Script-based resource schemas omit the LCM-only `DependsOn` and `PsDscRunAsCredential` properties. + + Enumeration values remain case insensitive unless `ValidateSet` specifies `IgnoreCase = $false`. + Module-defined class properties use object schemas without nested member constraints. External + enums and types without a known JSON representation are validated by the underlying PowerShell + resource. The adapter doesn't implement a separate `validate` operation. Schemas also enforce discovered `ValidateSet` constraints, including constraints on array @@ -196,6 +201,16 @@ configuration document with the following exceptions: allow null. Allowing a null array doesn't allow null array items, and allowing null doesn't bypass validation sets for non-null values. + **Migration:** This is a breaking change from case-insensitive input names and PascalCase output. + Update configurations and output consumers to use the names and nullability reported by + `dsc resource schema`. Deprecated full-configuration wrapper output is unchanged. + + Export filters can omit key and mandatory properties; the resource validates its own filters. + Exported instances must still conform to the full resource schema. If resource metadata prevents + schema generation, the adapter logs a warning naming the resource and exposes a permissive object + schema so other resources remain discoverable. Nonnumeric numeric value maps also produce a + warning; numeric type validation remains in effect without the invalid enum constraint. + ## Exit codes The adapter resource returns the following exit codes from operations: diff --git a/docs/reference/resources/Microsoft/DSC/PowerShell/index.md b/docs/reference/resources/Microsoft/DSC/PowerShell/index.md index c58dce0c2..8e38505b4 100644 --- a/docs/reference/resources/Microsoft/DSC/PowerShell/index.md +++ b/docs/reference/resources/Microsoft/DSC/PowerShell/index.md @@ -68,7 +68,7 @@ for each platform. | macOS | `$HOME/.dsc/PSAdapterCache.json` | | Windows | `%LOCALAPPDATA%\dsc\PSAdapterCache.json` | -The adapter versions the cache. The current version is `4`. If the version of the cache on a +The adapter versions the cache. The current version is `6`. If the version of the cache on a machine differs from the current version, the adapter refreshes the cache. The adapter checks whether the cache is stale on each run and refreshes it if: diff --git a/docs/reference/resources/Microsoft/Windows/WindowsPowerShell/index.md b/docs/reference/resources/Microsoft/Windows/WindowsPowerShell/index.md index 50357342a..052342dfa 100644 --- a/docs/reference/resources/Microsoft/Windows/WindowsPowerShell/index.md +++ b/docs/reference/resources/Microsoft/Windows/WindowsPowerShell/index.md @@ -65,7 +65,7 @@ for the Windows platform. | :------: | :---------------------------------------------- | | Windows | `%LOCALAPPDATA%\dsc\WindowsPSAdapterCache.json` | -The adapter versions the cache. The current version is `2`. If the version of the cache on a +The adapter versions the cache. The current version is `4`. If the version of the cache on a machine differs from the current version, the adapter refreshes the cache. The adapter checks whether the cache is stale on each run and refreshes it if: