From dbd69b8bc7a762b32351de5f63d0dccee539703a Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Mon, 14 Sep 2026 16:10:59 -0500 Subject: [PATCH 1/3] (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 e00cd0212..82d6d1ee4 100644 --- a/src/dsc/psresourcelist.dsc.resource.json +++ b/src/dsc/psresourcelist.dsc.resource.json @@ -92,7 +92,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", @@ -115,7 +115,7 @@ }, "$defs": { "Scope": { - "type": ["string", "null"], + "type": "string", "title": "Scope", "description": "Scope of the resource installation.", "enum": [ @@ -133,12 +133,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", @@ -148,7 +148,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 928b50dfc7384ca81c4a09e84077f528c98ff95f Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Mon, 14 Sep 2026 16:22:05 -0500 Subject: [PATCH 2/3] (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 | 131 +++++++++++++++++++++++++++++++++----- 1 file changed, 114 insertions(+), 17 deletions(-) diff --git a/src/dsc/psresourceget.ps1 b/src/dsc/psresourceget.ps1 index ea452d522..e32e66a80 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)] @@ -97,15 +99,61 @@ 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 + } + + return $data + } + + [OrderedDictionary] ToData() { + return $this.ToData($false) + } + [string] ToJson() { - $retVal = ($this | Select-Object -ExcludeProperty _inDesiredState | 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() { - return ($this | 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) } } @@ -154,24 +202,53 @@ class PSResourceList { return $true } - [string] ToJson() { - $resourceJson = if ($this.resources) { ($this.resources | ForEach-Object { $_.ToJson() }) -join ',' } else { '' } - $resourceJson = "[$resourceJson]" - $jsonString = "{'repositoryName': '$($this.repositoryName)','resources': $resourceJson}" - $jsonString = $jsonString -replace "'", '"' - $retVal = $jsonString | ConvertFrom-Json | ConvertTo-Json -Compress -EnumsAsStrings + [OrderedDictionary] ToData([bool]$forTest) { + $data = [OrderedDictionary]::new() + if (-not [string]::IsNullOrEmpty($this.repositoryName)) { + $data['repositoryName'] = $this.repositoryName + } + if ($this.resources) { + [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) + } - 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 + [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 - $jsonForTest = $this | ConvertTo-Json -Compress -Depth 5 -EnumsAsStrings - Write-Trace -message "Serialized JSON: $jsonForTest" -level trace - return $jsonForTest + $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) } } @@ -213,8 +290,28 @@ 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 c5bd5cb6848ce2555ff58a0a3dd9ab95d647bbe6 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Mon, 14 Sep 2026 16:29:26 -0500 Subject: [PATCH 3/3] (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 e32e66a80..9bc4466fa 100644 --- a/src/dsc/psresourceget.ps1 +++ b/src/dsc/psresourceget.ps1 @@ -624,9 +624,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" @@ -844,6 +847,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, @@ -891,24 +910,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