From d76992658bf3768ee4d2fdfde507aab9b39aa22a Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Mon, 14 Sep 2026 16:10:59 -0500 Subject: [PATCH 1/4] (DSC) Idiomaticize resource schemas Prior to this change, there were a few issues with the DSC resource schemas regarding the idiomatic definitions and semantics of JSON schema: 1. Optional properties should not define the `type` keyword with `null` as a valid data type unless `null` is an explicitly modeled state for the property. In JSON Schema, the absence of a property in an object is semantically distinct from a property explicitly set to `null`. Instead, we control the optionality of properties through the `required` or `dependentRequired` keywords. 1. The schema for `Microsoft.PowerShell.PSResourceGet/Repository` didn't define `required` at the top-level. Instead, it used the `allOf` keyword to indicate whether the `name` and `uri` properties are required or just `name`. When a property is always required, we should hoist it to the top-level `required` keyword array. We can still use the `allOf` to conditionally extend the schema as needed. Parsing a JSON Schema is difficult for consumers, moreso when it requires unrolling schema composition. We should surface as much information to the consumer in the simplest form possible. To address these issues, this change: 1. Removes `null` from the `type` keyword for optional properties that don't explicitly model `null` as a valid state. 1. Updates the `Microsoft.PowerShell.PSResourceGet/Repository` schema to define `required` at the top-level to indicate that `name` is always required and simplifies the `allOf` keyword for the remaining case (when `_exist` is `true` then `uri` is required). --- src/dsc/psresourcelist.dsc.resource.json | 10 +++++----- src/dsc/repository.dsc.resource.json | 11 +++-------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/dsc/psresourcelist.dsc.resource.json b/src/dsc/psresourcelist.dsc.resource.json index 6681c9d72..99ccce8de 100644 --- a/src/dsc/psresourcelist.dsc.resource.json +++ b/src/dsc/psresourcelist.dsc.resource.json @@ -106,7 +106,7 @@ "repositoryName": { "title": "Repository Name", "description": "The name of the repository from where the resources are acquired.", - "type": ["string", "null"] + "type": "string" }, "trustedRepository": { "title": "Trusted Repository", @@ -129,7 +129,7 @@ }, "$defs": { "Scope": { - "type": ["string", "null"], + "type": "string", "title": "Scope", "description": "Scope of the resource installation.", "enum": [ @@ -147,12 +147,12 @@ "name": { "title": "Name", "description": "The name of the resource.", - "type": ["string", "null"] + "type": "string" }, "version": { "title": "Version", "description": "The version range of the resource.", - "type": ["string", "null"] + "type": "string" }, "scope": { "title": "Scope", @@ -162,7 +162,7 @@ "repositoryName": { "title": "Repository Name", "description": "The name of the repository from where the resource is acquired.", - "type": ["string", "null"] + "type": "string" }, "preRelease": { "title": "Pre-Release version", diff --git a/src/dsc/repository.dsc.resource.json b/src/dsc/repository.dsc.resource.json index d5a7c4218..7babf4631 100644 --- a/src/dsc/repository.dsc.resource.json +++ b/src/dsc/repository.dsc.resource.json @@ -74,23 +74,18 @@ "description": "A PowerShell Resource repository from where to acquire the resources.", "type": "object", "additionalProperties": false, + "required": ["name"], "allOf": [ { "if": { "properties": { "_exist": { - "const": false + "const": true } } }, "then": { "required": [ - "name" - ] - }, - "else": { - "required": [ - "name", "uri" ] } @@ -105,7 +100,7 @@ "uri": { "title": "URI", "description": "The URI of the repository.", - "type": ["string", "null"], + "type": "string", "format": "uri" }, "trusted": { From ff1d04b5614e991e296cee15edcf56472c06e4f4 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Mon, 14 Sep 2026 16:22:05 -0500 Subject: [PATCH 2/4] (DSC) Update resource script for idiomatic serialization Prior to this change, the resource script serialized the data by just converting the instances to JSON (stripping `_inDesiredState` for non-test operations). This caused the resource to serialize `null` for properties that weren't defined on the instance, causing a mismatch with the idiomatic schema. This change updates the serialization logic to omit undefined optional fields from the serialized output by: 1. Defining the `ToData` method to convert instances to a data representation that omits undefined optional fields while preserving property order for predictable serialization. The method defines two overloads: - `ToData([bool$forTest)`: Converts the instance to a data representation, omitting undefined optional fields. If `$forTest` is `$false`, the method doesn't insert `_inDesiredState`. - `ToData()`: Convenience overload to call `ToData($false)`. 1. Standardizing the serialization trace messaging and extracting into reusable methods. --- src/dsc/psresourceget.ps1 | 159 ++++++++++++++++++++++++++++---------- 1 file changed, 119 insertions(+), 40 deletions(-) diff --git a/src/dsc/psresourceget.ps1 b/src/dsc/psresourceget.ps1 index 9c8752722..d09e42c4c 100644 --- a/src/dsc/psresourceget.ps1 +++ b/src/dsc/psresourceget.ps1 @@ -1,6 +1,8 @@ ## Copyright (c) Microsoft Corporation. All rights reserved. ## Licensed under the MIT License. +using namespace System.Collections.Specialized + [CmdletBinding()] param( [Parameter(Mandatory = $true)] @@ -99,19 +101,65 @@ class PSResource { return $retValue } + [OrderedDictionary] ToData([bool]$forTest) { + $data = [OrderedDictionary]::new() + + $data.name = $this.name + if (-not [string]::IsNullOrEmpty($this.version)) { + $data.version = $this.version + } + $data.scope = $this.scope + if (-not [string]::IsNullOrEmpty($this.repositoryName)) { + $data.repositoryName = $this.repositoryName + } + $data.preRelease = $this.preRelease + $data._exist = $this._exist + + if ($forTest) { + $data._inDesiredState = $this._inDesiredState + } + + if ($null -ne $this._metadata) { + $data._metadata = $this._metadata + } + + return $data + } + + [OrderedDictionary] ToData() { + return $this.ToData($false) + } + [string] ToJson() { - [string[]]$excludeProps = @('_inDesiredState') - if ($null -eq $this._metadata) { $excludeProps += '_metadata' } - $retVal = ($this | Select-Object -ExcludeProperty $excludeProps | ConvertTo-Json -Compress -EnumsAsStrings) - Write-Trace -message "Serializing PSResource to JSON. Name: $($this.name), Version: $($this.version), Scope: $($this.scope), RepositoryName: $($this.repositoryName), PreRelease: $($this.preRelease), _exist: $($this._exist)" -level debug - Write-Trace -message "Serialized JSON: $retVal" -level trace + $retVal = $this.ToData() | ConvertTo-Json -Compress -EnumsAsStrings + $this.WriteSerializationTrace($retVal) return $retVal } [string] ToJsonForTest() { - [string[]]$excludeProps = @() - if ($null -eq $this._metadata) { $excludeProps += '_metadata' } - return ($this | Select-Object -ExcludeProperty $excludeProps | ConvertTo-Json -Compress -Depth 5 -EnumsAsStrings) + $retVal = $this.ToData($true) | ConvertTo-Json -Compress -Depth 5 -EnumsAsStrings + $this.WriteSerializationTrace($retVal, $true) + return $retVal + } + + [void] WriteSerializationTrace([string]$json, [bool]$forTest) { + $pairs = @( + "Name: $($this.name)" + "Version: $($this.version)" + "Scope: $($this.scope)" + "RepositoryName: $($this.repositoryName)" + "PreRelease: $($this.preRelease)" + "_exist: $($this._exist)" + ) + if ($forTest) { + $pairs += "_inDesiredState: $($this._inDesiredState)" + } + Write-Trace -message "Serializing PSResource to JSON. $($pairs -join ', ')" -level debug + Write-Trace -message "Serialized JSON: $json" -level trace + } + + [void] WriteSerializationTrace([string]$json) { + $this.WriteSerializationTrace($json, $false) } } @@ -160,44 +208,54 @@ class PSResourceList { return $true } - [string] ToJson() { - ## Assign the array directly so that an empty list serializes as [] rather than null - [object[]]$resourceObjects = @() + [OrderedDictionary] ToData([bool]$forTest) { + $data = [OrderedDictionary]::new() + if (-not [string]::IsNullOrEmpty($this.repositoryName)) { + $data['repositoryName'] = $this.repositoryName + } if ($this.resources) { - $resourceObjects = @($this.resources | ForEach-Object { - [string[]]$excludeProps = @('_inDesiredState') - if ($null -eq $_._metadata) { $excludeProps += '_metadata' } - $_ | Select-Object -ExcludeProperty $excludeProps - }) - } - $retVal = [ordered]@{ - repositoryName = $this.repositoryName - resources = $resourceObjects - } | ConvertTo-Json -Compress -Depth 5 -EnumsAsStrings - Write-Trace -message "Serializing PSResourceList to JSON. RepositoryName: $($this.repositoryName), TrustedRepository: $($this.trustedRepository), Resources count: $($this.resources.Count)" -level debug - Write-Trace -message "Serialized JSON: $retVal" -level trace + [OrderedDictionary[]] $resourceData = $this.resources | ForEach-Object { $_.ToData($forTest) } + $data['resources'] = $resourceData + } + if ($this.trustedRepository) { + $data['trustedRepository'] = $this.trustedRepository + } + if ($forTest) { + $data['_inDesiredState'] = $this._inDesiredState + } + return $data + } + [OrderedDictionary] ToData() { + return $this.ToData($false) + } + + [string] ToJson() { + $retVal = $this.ToData() | ConvertTo-Json -Compress -EnumsAsStrings + $this.WriteSerializationTrace($retVal) + return $retVal } [string] ToJsonForTest() { - Write-Trace -message "Serializing PSResourceList to JSON for test output. RepositoryName: $($this.repositoryName), TrustedRepository: $($this.trustedRepository), Resources count: $($this.resources.Count)" -level debug - [object[]]$resourceObjects = @() - if ($this.resources) { - $resourceObjects = @($this.resources | ForEach-Object { - [string[]]$excludeProps = @() - if ($null -eq $_._metadata) { $excludeProps += '_metadata' } - if ($excludeProps.Count -gt 0) { $_ | Select-Object -ExcludeProperty $excludeProps } else { $_ } - }) - } - $retVal = [ordered]@{ - repositoryName = $this.repositoryName - resources = $resourceObjects - trustedRepository = $this.trustedRepository - _inDesiredState = $this._inDesiredState - } | ConvertTo-Json -Compress -Depth 5 -EnumsAsStrings - Write-Trace -message "Serialized JSON: $retVal" -level trace + $retVal = $this.ToData($true) | ConvertTo-Json -Compress -EnumsAsStrings + $this.WriteSerializationTrace($retVal, $true) return $retVal } + + [void] WriteSerializationTrace([string]$json, [bool]$forTest) { + $preamble = 'Serializing PSResourceList to JSON' + $preamble += $forTest ? ' for test output.' : '.' + $pairs = @( + "repositoryName: $($this.repositoryName)" + "trustedRepository: $($this.trustedRepository)" + "resourcesCount: $($this.resources.Count)" + ) + Write-Trace -message "$preamble $($pairs -join ', ')" -level debug + Write-Trace -message "Serialized JSON: $json" -level trace + } + [void] WriteSerializationTrace([string]$json) { + $this.WriteSerializationTrace($json, $false) + } } class Repository { @@ -238,8 +296,29 @@ class Repository { $this.repositoryType = 'Unknown' } + [OrderedDictionary] ToData([bool]$forTest) { + $data = [OrderedDictionary]::new() + $data['name'] = $this.name + if (-not [string]::IsNullOrEmpty($this.uri)) { + $data['uri'] = $this.uri + } + $data['trusted'] = $this.trusted + $data['priority'] = $this.priority + if (-not [string]::IsNullOrEmpty($this.repositoryType)) { + $data['repositoryType'] = $this.repositoryType + } + $data['_exist'] = $this._exist + + + return $data + } + [OrderedDictionary] ToData() { + return $this.ToData($false) + } + [string] ToJson() { - return ($this | ConvertTo-Json -Compress -EnumsAsStrings) + $retVal = $this.ToData() | ConvertTo-Json -Compress -EnumsAsStrings + return $retVal } } From af94cc112df52e1629e77d6135fc65d9666e83f9 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Mon, 14 Sep 2026 16:29:26 -0500 Subject: [PATCH 3/4] (DSC) Handle when a scope has no installed resources Prior to this change, the `PSResourceList` resource would fail during `export` operations when a scope had no installed resources because it would emit an empty/invalid item. The non-terminating error for no resources in a scope was also erroneously emitted. This change: 1. Defines the `GetAllPsResourcesInScope` function as a wrapper around `Get-PsResource` to retrieve all resources in a given scope, returning an empty array if no resources are found and erroring if the command fails for any other reason. 1. Updates `PopulatePSResourceListObject` to handle empty resource arrays gracefully, emitting an info message instead of invalid data. --- src/dsc/psresourceget.ps1 | 65 +++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/src/dsc/psresourceget.ps1 b/src/dsc/psresourceget.ps1 index d09e42c4c..cff3e9b3c 100644 --- a/src/dsc/psresourceget.ps1 +++ b/src/dsc/psresourceget.ps1 @@ -647,9 +647,12 @@ function ExportOperation { exit [ExitCode]::ExportNotImplemented } 'psresourcelist' { - $currentUserPSResources = Get-PSResource - $allUsersPSResources = Get-PSResource -Scope AllUsers - PopulatePSResourceListObject -allUsersPSResources $allUsersPSResources -currentUserPSResources $currentUserPSResources + $populatingParams = @{ + currentUserPSResources = GetAllPsResourcesInScope -Scope CurrentUser + allUsersPSResources = GetAllPsResourcesInScope -Scope AllUsers + } + + PopulatePSResourceListObject @populatingParams } default { Write-Trace -level error -message "Unknown ResourceType: $ResourceType" @@ -940,6 +943,22 @@ function DeleteOperation { } } +function GetAllPsResourcesInScope { + param( + [Scope]$Scope + ) + + try { + Get-PSResource -Scope $Scope -ErrorAction Stop + } catch [Microsoft.PowerShell.PSResourceGet.UtilClasses.ResourceNotFoundException] { + # Everything is fine, there's just no installed resources + @() + } catch { + Write-Trace -level error -message "Failed to get PSResources for '$Scope' scope: $_" + @() + } +} + function PopulatePSResourceListObjectByRepository { param ( $resourcesExist, @@ -994,24 +1013,32 @@ function PopulatePSResourceListObject { $allPSResources = @() - $allPSResources += $allUsersPSResources | ForEach-Object { - return [PSResource]::new( - $_.Name, - $_.Version, - [Scope]"AllUsers", - $_.Repository, - $_.PreRelease ? $true : $false - ) + if ($allUsersPSResources.count -gt 1) { + $allPSResources += $allUsersPSResources | ForEach-Object { + return [PSResource]::new( + $_.Name, + $_.Version, + [Scope]"AllUsers", + $_.Repository, + $_.PreRelease ? $true : $false + ) + } + } else { + Write-Trace -level info "No PSResources found for AllUsers scope." } - $allPSResources += $currentUserPSResources | ForEach-Object { - return [PSResource]::new( - $_.Name, - $_.Version, - [Scope]"CurrentUser", - $_.Repository, - $_.PreRelease ? $true : $false - ) + if ($currentUserPSResources.count -gt 1) { + $allPSResources += $currentUserPSResources | ForEach-Object { + return [PSResource]::new( + $_.Name, + $_.Version, + [Scope]"CurrentUser", + $_.Repository, + $_.PreRelease ? $true : $false + ) + } + } else { + Write-Trace -level info "No PSResources found for CurrentUser scope." } $repoGrps = $allPSResources | Group-Object -Property repositoryName From fb8ec7274bfcf27f03d686e5bd8ac67a518c0979 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Mon, 19 Oct 2026 11:20:14 -0500 Subject: [PATCH 4/4] Address review comments --- src/dsc/psresourceget.ps1 | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/dsc/psresourceget.ps1 b/src/dsc/psresourceget.ps1 index cff3e9b3c..57fe1dbcf 100644 --- a/src/dsc/psresourceget.ps1 +++ b/src/dsc/psresourceget.ps1 @@ -104,23 +104,25 @@ class PSResource { [OrderedDictionary] ToData([bool]$forTest) { $data = [OrderedDictionary]::new() - $data.name = $this.name + $data['name'] = $this.name if (-not [string]::IsNullOrEmpty($this.version)) { - $data.version = $this.version + $data['version'] = $this.version } - $data.scope = $this.scope + $data['scope'] = $this.scope if (-not [string]::IsNullOrEmpty($this.repositoryName)) { - $data.repositoryName = $this.repositoryName + $data['repositoryName'] = $this.repositoryName + } else { + $data['repositoryName'] = "" } - $data.preRelease = $this.preRelease - $data._exist = $this._exist + $data['preRelease'] = $this.preRelease + $data['_exist'] = $this._exist if ($forTest) { - $data._inDesiredState = $this._inDesiredState + $data['_inDesiredState'] = $this._inDesiredState } if ($null -ne $this._metadata) { - $data._metadata = $this._metadata + $data['_metadata'] = $this._metadata } return $data @@ -212,6 +214,8 @@ class PSResourceList { $data = [OrderedDictionary]::new() if (-not [string]::IsNullOrEmpty($this.repositoryName)) { $data['repositoryName'] = $this.repositoryName + } else { + $data['repositoryName'] = "" } if ($this.resources) { [OrderedDictionary[]] $resourceData = $this.resources | ForEach-Object { $_.ToData($forTest) } @@ -302,8 +306,10 @@ class Repository { if (-not [string]::IsNullOrEmpty($this.uri)) { $data['uri'] = $this.uri } - $data['trusted'] = $this.trusted - $data['priority'] = $this.priority + if ($this._exist) { + $data['trusted'] = $this.trusted + $data['priority'] = $this.priority + } if (-not [string]::IsNullOrEmpty($this.repositoryType)) { $data['repositoryType'] = $this.repositoryType } @@ -1013,7 +1019,7 @@ function PopulatePSResourceListObject { $allPSResources = @() - if ($allUsersPSResources.count -gt 1) { + if (@($allUsersPSResources).count -gt 0) { $allPSResources += $allUsersPSResources | ForEach-Object { return [PSResource]::new( $_.Name, @@ -1027,7 +1033,7 @@ function PopulatePSResourceListObject { Write-Trace -level info "No PSResources found for AllUsers scope." } - if ($currentUserPSResources.count -gt 1) { + if (@($currentUserPSResources).count -gt 0) { $allPSResources += $currentUserPSResources | ForEach-Object { return [PSResource]::new( $_.Name,