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 dd36c2c8c..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", @@ -114,18 +117,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.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 865b74f0a..87cdc6670 100644 --- a/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 +++ b/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 @@ -31,11 +31,15 @@ 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 -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' @@ -48,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 @@ -80,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' @@ -373,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' @@ -392,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 @@ -400,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 new file mode 100644 index 000000000..e5078f769 --- /dev/null +++ b/adapters/powershell/Tests/schema.tests.ps1 @@ -0,0 +1,530 @@ +# 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', 'TestNullableResource') +} +'@ | Set-Content -Path (Join-Path $modulePath 'TestSchemaResource.psd1') + @' +enum SchemaEnsure { + Present + Absent +} + +class SchemaBase { + [DscProperty(Key)] + [string] $Name + + [DscProperty()] + [string] $Inherited + + [DscProperty()] + [SchemaEnsure] $InheritedEnsure +} + +[DscResource()] +class TestSchemaResource : SchemaBase { + [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()] + [System.Management.Automation.ValidateSetAttribute('One', 'Two', IgnoreCase = $false)] + [string] $ExactChoice + + [DscProperty(NotConfigurable)] + [string] $Observed + + [DscProperty()] + [SchemaBase] $Details + + [DscProperty()] + [ValidateSet('One', 'Two')] + [string[]] $Choices + + [DscProperty()] + [string[]] $Names + + [DscProperty()] + [SchemaEnsure] $Ensure + + [DscProperty()] + [SchemaEnsure[]] $Ensures + + [DscProperty()] + [hashtable] $Options + + [DscProperty()] + [PSCredential] $Credential + + [DscProperty()] + [SecureString] $Secret + + [DscProperty()] + [string] $IPAddress + + [DscProperty()] + [string] ${Resource-Name} + + [string] $NotDscProperty + + [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()] +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 + $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 { + $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') + $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 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 '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 | + 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}' } + @{ 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 $nullableSchema -ErrorAction Ignore | Should -BeFalse + } + + 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 + } + + 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 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"}' } + @{ 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 + } + + It 'Returns the same schema when listing cached resources' { + $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 + $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') + } + + 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 Ignore | + Should -BeFalse + } + + 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 + # 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'). + 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 = & $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 $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 + $windowsNullable = $windowsResources | ConvertFrom-Json | + Where-Object -Property type -EQ 'TestSchemaResource/TestNullableResource' + $windowsNullableSchema = $windowsNullable.schema | ConvertTo-Json -Depth 100 + $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":"Present","enabled":null}' -Schema $windowsNullableSchema -ErrorAction Ignore | + Should -BeFalse + Test-Json -Json '{"name":"test","requiredCount":1,"requiredEnsure":null}' -Schema $windowsNullableSchema -ErrorAction Ignore | + Should -BeFalse + 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' + $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 + Test-Json -Json '{"name":"test"}' -Schema $scriptSchema -ErrorAction Ignore | + 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 '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' } + @{ 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..f039cd125 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' @@ -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/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/WindowsPowerShell_adapter.dsc.resource.json b/adapters/powershell/WindowsPowerShell_adapter.dsc.resource.json index cb5c2f840..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", @@ -97,18 +100,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 22d864cd9..56bf00015 100644 --- a/adapters/powershell/psDscAdapter/powershell.resource.ps1 +++ b/adapters/powershell/psDscAdapter/powershell.resource.ps1 @@ -6,8 +6,8 @@ '' )] 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 = '{}', @@ -31,13 +31,13 @@ function Write-DscTrace { ) process { - $trace = @{$Operation.ToLower() = $Message } + $trace = @{$Operation.ToLower() = $Message } - if ($Now) { - $host.ui.WriteErrorLine(($trace | ConvertTo-Json -Compress -Depth 10)) - } else { - $traceQueue.Enqueue($trace) - } + if ($Now) { + $host.ui.WriteErrorLine(($trace | ConvertTo-Json -Compress -Depth 10)) + } else { + $traceQueue.Enqueue($trace) + } } } @@ -57,8 +57,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 = '{}', @@ -126,19 +126,182 @@ $ps = [PowerShell]::Create().AddScript({ } } - if ('Validate' -ne $Operation) { - Write-Debug -Debug -Message "jsonInput=$jsonInput" + Write-Debug -Debug -Message "jsonInput=$jsonInput" + + # 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-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( + $State, + $Properties, + [bool]$ExcludeLcmProperties + ) + + $canonicalState = @{} + $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 $value -and -not $property.IsMandatory -and -not (Test-PropertyAllowsNull -Property $property)) { + continue + } + $canonicalState[(ConvertTo-CanonicalPropertyName -Name $name)] = $value + } + return $canonicalState + } + + function ConvertTo-PropertySchema { + [CmdletBinding()] + param( + [string]$PropertyType, + [string[]]$Values, + [bool]$AllowNull, + [bool]$IsClass, + [bool]$IgnoreCase = $true + ) + + # Windows PowerShell metadata can wrap type names in brackets. + $typeName = $PropertyType -replace '^\[(.*)\]$', '$1' + $typeName = $typeName -replace '^(?:System\.)?Nullable\[(.*)\]$', '$1' + if ($typeName.EndsWith('[]')) { + return @{ + type = if ($AllowNull) { @('array', 'null') } else { 'array' } + items = ConvertTo-PropertySchema -PropertyType $typeName.Substring(0, $typeName.Length - 2) -Values $Values -IsClass $IsClass -IgnoreCase $IgnoreCase + } + } + + $schema = switch -Regex ($typeName -replace '^System\.', '') { + '^(string|char|datetime|datetimeoffset|guid|uri)$' { + @{ type = 'string' } + break + } + '^(securestring|security\.securestring|timespan|version)$' { + # These accept string input but serialize as objects in resource output. + @{ type = @('string', 'object') } + 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' } + break + } + default { + # Custom and embedded types can have resource-specific JSON representations. + if ($IsClass) { @{ type = 'object' } } else { @{} } + } + } - # load private functions of psDscAdapter stub module - if ($PSVersionTable.PSVersion.Major -le 5) { - $psDscAdapter = Import-Module "$ScriptRoot/win_psDscAdapter.psd1" -Force -PassThru + if ($AllowNull -and $schema.ContainsKey('type')) { + $schema.type = @($schema.type) + 'null' } - else { - $psDscAdapter = Import-Module "$ScriptRoot/psDscAdapter.psd1" -Force -PassThru + + if ($Values.Count -gt 0) { + if ($schema.type -contains 'integer' -or $schema.type -contains 'number') { + $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 { + # 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' } + } + } } + return $schema + } + + function ConvertTo-ResourceSchema { + [CmdletBinding()] + param($ResourceInfo, [string]$Type) - # initialize OUTPUT as array - $result = [System.Collections.Generic.List[Object]]::new() + 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) { @@ -208,8 +371,12 @@ $ps = [PowerShell]::Create().AddScript({ $properties = @() foreach ($prop in $DscResourceInfo.Properties) { - $properties += $prop.Name + if ($DscResourceInfo.ImplementationDetail -eq 'ScriptBased' -and $prop.Name -in @('DependsOn', 'PsDscRunAsCredential')) { + continue + } + $properties += ConvertTo-CanonicalPropertyName -Name $prop.Name } + $resourceSchema = ConvertTo-ResourceSchema -ResourceInfo $DscResourceInfo -Type $dscResource.Type # OUTPUT dsc is expecting the following properties [resourceOutput]@{ @@ -224,6 +391,7 @@ $ps = [PowerShell]::Create().AddScript({ properties = $properties requireAdapter = $requireAdapter description = $description + schema = $resourceSchema } } } @@ -239,6 +407,10 @@ $ps = [PowerShell]::Create().AddScript({ if ($null -eq $dscResourceCache) { throw ("DSC resource '{0}' module not found." -f $ResourceType) } + $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) { @@ -261,18 +433,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 -ExcludeLcmProperties $excludeLcmProperties } exit 0 } - - $result = $actualState.Properties + $result = ConvertTo-ResourceState -State $actualState.Properties -Properties $resourceProperties -ExcludeLcmProperties $excludeLcmProperties + if ($Operation -eq 'Test') { + $result['_inDesiredState'] = $inDesiredState + } return $result } @@ -326,14 +496,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' } } @@ -350,6 +514,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) @@ -451,6 +616,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 10 -Compress + $obj | ConvertTo-Json -Depth $serializationDepth -Compress } diff --git a/adapters/powershell/psDscAdapter/psDscAdapter.psm1 b/adapters/powershell/psDscAdapter/psDscAdapter.psm1 index 5a79104ce..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 = 3 +$script:CurrentCacheSchemaVersion = 6 $ErrorActionPreference = 'Stop' function Import-PSDSCModule { @@ -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,14 +62,17 @@ function Add-AstMember { continue; } $skipProperty = $true - $isKeyProperty = $false + $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 -eq 'Key') { - $isKeyProperty = $true - break + if ($attrArg.ArgumentName -in @('Key', 'Mandatory') -and $attrArg.Argument.SafeGetValue()) { + $isRequiredProperty = $true + } + if ($attrArg.ArgumentName -eq 'NotConfigurable' -and $attrArg.Argument.SafeGetValue()) { + $isReadOnly = $true } } } @@ -81,7 +84,27 @@ function Add-AstMember { [DscResourcePropertyInfo]$prop = [DscResourcePropertyInfo]::new() $prop.Name = $property.Name $prop.PropertyType = $property.PropertyType.TypeName.Name - $prop.IsMandatory = $isKeyProperty + $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 -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) } } @@ -132,6 +155,11 @@ function FindAndParseResourceDefinitions { }, $false); + $typeDefinitionsByName = @{} + foreach ($typeDefinition in $typeDefinitions) { + $typeDefinitionsByName[$typeDefinition.Name] = $typeDefinition + } + $resourceList = [System.Collections.Generic.List[DscResourceInfo]]::new() foreach ($typeDefinitionAst in $typeDefinitions) { @@ -151,7 +179,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) } @@ -734,6 +762,10 @@ class DscResourcePropertyInfo { [string] $Name [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 86331d245..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 = 1 +$script:CurrentCacheSchemaVersion = 4 trap { Write-Error -Message ($_ | Format-List -Force | Out-String) @@ -769,35 +769,79 @@ function GetClassBasedProperties { }, $false); - $typeDefinition = $typeDefinitions | Where-Object -Property Name -EQ $className - - foreach ($member in $typeDefinition.Members) { - $property = $member -as [System.Management.Automation.Language.PropertyMemberAst] - if (($null -eq $property) -or ($property.IsStatic)) { - continue; + $typeDefinitionsByName = @{} + foreach ($definition in $typeDefinitions) { + $typeDefinitionsByName[$definition.Name] = $definition + } + $typeDefinition = $typeDefinitionsByName[$className] + + $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 + } + $visitedTypes[$currentType.Name] = $true + $orderedTypes.Insert(0, $currentType) + foreach ($baseType in $currentType.BaseTypes) { + $pendingTypes.Enqueue($typeDefinitionsByName[$baseType.TypeName.Name]) } - $skipProperty = $true - $isKeyProperty = $false - foreach ($attr in $property.Attributes) { - if ($attr.TypeName.Name -eq 'DscProperty') { - $skipProperty = $false - foreach ($attrArg in $attr.NamedArguments) { - if ($attrArg.ArgumentName -eq 'Key') { - $isKeyProperty = $true - break + } + 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 = $isKeyProperty - $properties.Add($prop) + [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) + } + $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) + } } return $properties } @@ -906,6 +950,10 @@ class DscResourcePropertyInfo { [string] $Name [string] $PropertyType [bool] $IsMandatory + [bool] $AllowNull + [bool] $IsReadOnly + [bool] $IsClass + [bool] $IgnoreCase = $true [System.Collections.Generic.List[string]] $Values } @@ -924,4 +972,4 @@ class DscResourceInfo { [string] $CompanyName [psobject[]] $Properties [string[]] $Capabilities -} \ No newline at end of file +} 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/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 60989728d..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 `2`. 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: @@ -160,14 +160,45 @@ 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. To retrieve a resource's schema, replace the placeholders in: - 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. + ```powershell + dsc resource schema -r '/' + ``` + + Schemas use camelCase + property names, preserving names that contain nonalphanumeric characters. Use the spelling + 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 + 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. + + **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 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 e1b4f0b7c..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 `1`. 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: @@ -170,14 +170,46 @@ 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. To retrieve a resource's schema, replace the placeholders in: - 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. + ```powershell + dsc resource schema -r '/' + ``` + + Schemas use camelCase + property names, preserving names that contain nonalphanumeric characters. Use the spelling + 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 + 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. + + **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 diff --git a/docs/reference/resources/Microsoft/DSC/PowerShell/index.md b/docs/reference/resources/Microsoft/DSC/PowerShell/index.md index b04db7369..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 `2`. 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: @@ -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 33d17b31d..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 `1`. 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: @@ -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 / $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