From d1a374a3624c915ee36fd1826848ddc20cade880 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sun, 22 Mar 2026 13:41:44 +0100 Subject: [PATCH 1/4] Initial setup --- .../Microsoft.PowerShell.DSC.psd1 | 49 ++ .../Microsoft.PowerShell.DSC.psm1 | 465 ++++++++++++++++++ .../Fixtures/MultiResource/MultiResource.psd1 | 17 + .../Fixtures/MultiResource/MultiResource.psm1 | 69 +++ .../Tests/Fixtures/NoDscResource.psm1 | 15 + .../SimpleResource/SimpleResource.psd1 | 17 + .../SimpleResource/SimpleResource.psm1 | 25 + .../Tests/Fixtures/StandaloneResource.ps1 | 22 + .../New-DscAdaptedResourceManifest.Tests.ps1 | 338 +++++++++++++ 9 files changed, 1017 insertions(+) create mode 100644 tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 create mode 100644 tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psd1 create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psm1 create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/Fixtures/NoDscResource.psm1 create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psd1 create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psm1 create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/Fixtures/StandaloneResource.ps1 create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/New-DscAdaptedResourceManifest.Tests.ps1 diff --git a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 new file mode 100644 index 000000000..4b469fcb7 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'Microsoft.PowerShell.DSC.psm1' + +# Version number of this module. +ModuleVersion = '0.0.1' + +# ID used to uniquely identify this module +GUID = 'f4a5e270-0e6b-4f6a-b08a-3a1d2c7e9b4d' + +# Author of this module +Author = 'Microsoft Corporation' + +# Company or vendor of this module +CompanyName = 'Microsoft Corporation' + +# Copyright statement for this module +Copyright = '(c) Microsoft Corporation. All rights reserved.' + +# Description of the functionality provided by this module +Description = 'Creates adapted resource manifests from class-based PowerShell DSC resources.' + +# Minimum version of the PowerShell engine required by this module +PowerShellVersion = '7.0' + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = @( + 'New-DscAdaptedResourceManifest' +) + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +CmdletsToExport = @() + +# Variables to export from this module +VariablesToExport = @() + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = @() + +PrivateData = @{ + PSData = @{ + ProjectUri = 'https://github.com/PowerShell/DSC' + } +} +} diff --git a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 new file mode 100644 index 000000000..4751f7da8 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 @@ -0,0 +1,465 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$ErrorActionPreference = 'Stop' + +$script:AdaptedResourceSchemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' +$script:JsonSchemaUri = 'https://json-schema.org/draft/2020-12/schema' +$script:DefaultAdapter = 'Microsoft.DSC/PowerShell' + +#region Classes + +class DscAdaptedResourceManifestSchema { + [hashtable] $Embedded +} + +class DscAdaptedResourceManifest { + [string] $Schema + [string] $Type + [string] $Kind + [string] $Version + [string[]] $Capabilities + [string] $Description + [string] $Author + [string] $RequireAdapter + [string] $Path + [DscAdaptedResourceManifestSchema] $ManifestSchema + + [string] ToJson() { + $manifest = [ordered]@{ + '$schema' = $this.Schema + type = $this.Type + kind = $this.Kind + version = $this.Version + capabilities = $this.Capabilities + description = $this.Description + author = $this.Author + requireAdapter = $this.RequireAdapter + path = $this.Path + schema = [ordered]@{ + embedded = $this.ManifestSchema.Embedded + } + } + return $manifest | ConvertTo-Json -Depth 10 + } +} + +#endregion Classes + +#region Private functions + +function GetDscResourceTypeDefinition { + [CmdletBinding()] + [OutputType([System.Collections.Generic.List[hashtable]])] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + [System.Management.Automation.Language.Token[]] $tokens = $null + [System.Management.Automation.Language.ParseError[]] $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + + foreach ($e in $errors) { + Write-Error "Parse error in '$Path': $($e.Message)" + } + + $allTypeDefinitions = $ast.FindAll( + { + $typeAst = $args[0] -as [System.Management.Automation.Language.TypeDefinitionAst] + return $null -ne $typeAst + }, + $false + ) + + $results = [System.Collections.Generic.List[hashtable]]::new() + + foreach ($typeDefinition in $allTypeDefinitions) { + foreach ($attribute in $typeDefinition.Attributes) { + if ($attribute.TypeName.Name -eq 'DscResource') { + $results.Add(@{ + TypeDefinitionAst = $typeDefinition + AllTypeDefinitions = $allTypeDefinitions + }) + break + } + } + } + + return $results +} + +function GetDscResourceCapability { + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] + [System.Management.Automation.Language.MemberAst[]]$MemberAst + ) + + $capabilities = [System.Collections.Generic.List[string]]::new() + $availableMethods = @('get', 'set', 'setHandlesExist', 'whatIf', 'test', 'delete', 'export') + $methods = $MemberAst | Where-Object { + $_ -is [System.Management.Automation.Language.FunctionMemberAst] -and $_.Name -in $availableMethods + } + + foreach ($method in $methods.Name) { + switch ($method) { + 'Get' { $capabilities.Add('get') } + 'Set' { $capabilities.Add('set') } + 'Test' { $capabilities.Add('test') } + 'WhatIf' { $capabilities.Add('whatIf') } + 'SetHandlesExist' { $capabilities.Add('setHandlesExist') } + 'Delete' { $capabilities.Add('delete') } + 'Export' { $capabilities.Add('export') } + } + } + + return ($capabilities | Select-Object -Unique) +} + +function GetDscResourceProperty { + [CmdletBinding()] + [OutputType([System.Collections.Generic.List[hashtable]])] + param( + [Parameter(Mandatory)] + [System.Management.Automation.Language.TypeDefinitionAst[]]$AllTypeDefinitions, + + [Parameter(Mandatory)] + [System.Management.Automation.Language.TypeDefinitionAst]$TypeDefinitionAst + ) + + $properties = [System.Collections.Generic.List[hashtable]]::new() + CollectAstProperty -AllTypeDefinitions $AllTypeDefinitions -TypeAst $TypeDefinitionAst -Properties $properties + return , $properties +} + +function CollectAstProperty { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Management.Automation.Language.TypeDefinitionAst[]]$AllTypeDefinitions, + + [Parameter(Mandatory)] + [System.Management.Automation.Language.TypeDefinitionAst]$TypeAst, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[hashtable]]$Properties + ) + + foreach ($typeConstraint in $TypeAst.BaseTypes) { + $baseType = $AllTypeDefinitions | Where-Object { $_.Name -eq $typeConstraint.TypeName.Name } + if ($baseType) { + CollectAstProperty -AllTypeDefinitions $AllTypeDefinitions -TypeAst $baseType -Properties $Properties + } + } + + foreach ($member in $TypeAst.Members) { + $propertyAst = $member -as [System.Management.Automation.Language.PropertyMemberAst] + if (($null -eq $propertyAst) -or ($propertyAst.IsStatic)) { + continue + } + + $isDscProperty = $false + $isKey = $false + $isMandatory = $false + foreach ($attr in $propertyAst.Attributes) { + if ($attr.TypeName.Name -eq 'DscProperty') { + $isDscProperty = $true + foreach ($namedArg in $attr.NamedArguments) { + switch ($namedArg.ArgumentName) { + 'Key' { $isKey = $true } + 'Mandatory' { $isMandatory = $true } + } + } + } + } + + if (-not $isDscProperty) { + continue + } + + $typeName = if ($propertyAst.PropertyType) { + $propertyAst.PropertyType.TypeName.Name + } else { + 'string' + } + + # check if the type is an enum defined in the same file + $enumValues = $null + $enumAst = $AllTypeDefinitions | Where-Object { + $_.Name -eq $typeName -and $_.IsEnum + } + if ($enumAst) { + $enumValues = @($enumAst.Members | ForEach-Object { $_.Name }) + } + + $Properties.Add(@{ + Name = $propertyAst.Name + TypeName = $typeName + IsKey = $isKey + IsMandatory = $isMandatory -or $isKey + EnumValues = $enumValues + }) + } +} + +function ConvertToJsonSchemaType { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [string]$TypeName + ) + + switch ($TypeName) { + 'string' { return @{ type = 'string' } } + 'int' { return @{ type = 'integer' } } + 'int32' { return @{ type = 'integer' } } + 'int64' { return @{ type = 'integer' } } + 'long' { return @{ type = 'integer' } } + 'double' { return @{ type = 'number' } } + 'float' { return @{ type = 'number' } } + 'single' { return @{ type = 'number' } } + 'decimal' { return @{ type = 'number' } } + 'bool' { return @{ type = 'boolean' } } + 'boolean' { return @{ type = 'boolean' } } + 'switch' { return @{ type = 'boolean' } } + 'hashtable' { return @{ type = 'object' } } + 'datetime' { return @{ type = 'string'; format = 'date-time' } } + default { + # arrays like string[] or int[] + if ($TypeName -match '^(.+)\[\]$') { + $innerType = ConvertToJsonSchemaType -TypeName $Matches[1] + return @{ type = 'array'; items = $innerType } + } + # default to string for unknown types + return @{ type = 'string' } + } + } +} + +function BuildEmbeddedJsonSchema { + [CmdletBinding()] + [OutputType([ordered])] + param( + [Parameter(Mandatory)] + [string]$ResourceName, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[hashtable]]$Properties, + + [Parameter()] + [string]$Description + ) + + $schemaProperties = [ordered]@{} + $requiredList = [System.Collections.Generic.List[string]]::new() + + foreach ($prop in $Properties) { + $schemaProp = [ordered]@{} + + if ($prop.EnumValues) { + $schemaProp['type'] = 'string' + $schemaProp['enum'] = $prop.EnumValues + } else { + $jsonType = ConvertToJsonSchemaType -TypeName $prop.TypeName + foreach ($key in $jsonType.Keys) { + $schemaProp[$key] = $jsonType[$key] + } + } + + $schemaProp['title'] = $prop.Name + $schemaProp['description'] = "The $($prop.Name) property." + $schemaProperties[$prop.Name] = $schemaProp + + if ($prop.IsMandatory) { + $requiredList.Add($prop.Name) + } + } + + $schema = [ordered]@{ + '$schema' = $script:JsonSchemaUri + title = $ResourceName + type = 'object' + required = @($requiredList) + additionalProperties = $false + properties = $schemaProperties + } + + if (-not [string]::IsNullOrEmpty($Description)) { + $schema['description'] = $Description + } + + return $schema +} + +function ResolveModuleInfo { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $resolvedPath = Resolve-Path -Path $Path + $extension = [System.IO.Path]::GetExtension($resolvedPath) + $directory = [System.IO.Path]::GetDirectoryName($resolvedPath) + + if ($extension -eq '.psd1') { + $manifestData = Import-PowerShellDataFile -Path $resolvedPath + $moduleName = [System.IO.Path]::GetFileNameWithoutExtension($resolvedPath) + $version = if ($manifestData.ModuleVersion) { $manifestData.ModuleVersion } else { '0.0.1' } + $author = if ($manifestData.Author) { $manifestData.Author } else { '' } + $description = if ($manifestData.Description) { $manifestData.Description } else { '' } + + $rootModule = $manifestData.RootModule + if ([string]::IsNullOrEmpty($rootModule)) { + $rootModule = "$moduleName.psm1" + } + $scriptPath = Join-Path $directory $rootModule + $psd1RelativePath = "$moduleName/$([System.IO.Path]::GetFileName($resolvedPath))" + + return @{ + ModuleName = $moduleName + Version = $version + Author = $author + Description = $description + ScriptPath = $scriptPath + Psd1Path = $psd1RelativePath + Directory = $directory + } + } + + # derive fileName from .ps1 or .psm1 + $moduleName = [System.IO.Path]::GetFileNameWithoutExtension($resolvedPath) + + # validate if .psd1 is there and use that + $psd1Path = Join-Path $directory "$moduleName.psd1" + if (Test-Path -LiteralPath $psd1Path) { + return ResolveModuleInfo -Path $psd1Path + } + + return @{ + ModuleName = $moduleName + Version = '0.0.1' + Author = '' + Description = '' + ScriptPath = [string]$resolvedPath + Psd1Path = "$moduleName/$moduleName.psd1" + Directory = $directory + } +} + +#endregion Private functions + +#region Public functions + +<# + .SYNOPSIS + Creates adapted resource manifest objects from class-based PowerShell DSC resources. + + .DESCRIPTION + Parses the AST of a PowerShell file (.ps1, .psm1, or .psd1) to find class-based DSC + resources decorated with the [DscResource()] attribute. For each resource found, it + returns a DscAdaptedResourceManifest object that complies with the DSCv3 adapted + resource manifest JSON schema. + + The returned objects can be serialized to JSON using the .ToJson() method and written + to `.dsc.adaptedResource.json` files. These manifests enable DSCv3 to discover and + use PowerShell DSC resources without running Invoke-DscCacheRefresh. + + .PARAMETER Path + The path to a .ps1, .psm1, or .psd1 file containing class-based DSC resources. + When a .psd1 is provided, the RootModule is resolved and parsed automatically. + + .EXAMPLE + New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 + + Returns adapted resource manifest objects for all class-based DSC resources in the module. + + .EXAMPLE + New-DscAdaptedResourceManifest -Path ./MyResource.ps1 | ForEach-Object { + $_.ToJson() | Set-Content "$($_.Type -replace '/', '.').dsc.adaptedResource.json" + } + + Generates manifest objects and writes each to a JSON file. + + .EXAMPLE + Get-ChildItem -Path ./MyModules -Filter *.psd1 -Recurse | New-DscAdaptedResourceManifest + + Discovers all module manifests under `./MyModules` and pipes them into the function + to generate adapted resource manifests for every class-based DSC resource found. + + .OUTPUTS + Returns a DscAdaptedResourceManifest object for each class-based DSC resource found. + The object has a .ToJson() method for serialization to the adapted resource manifest + JSON format. +#> +function New-DscAdaptedResourceManifest { + [CmdletBinding()] + [OutputType([DscAdaptedResourceManifest])] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [ValidateScript({ + if (-not (Test-Path -LiteralPath $_)) { + throw "Path '$_' does not exist." + } + $ext = [System.IO.Path]::GetExtension($_) + if ($ext -notin '.ps1', '.psm1', '.psd1') { + throw "Path '$_' must be a .ps1, .psm1, or .psd1 file." + } + return $true + })] + [string]$Path + ) + + process { + $moduleInfo = ResolveModuleInfo -Path $Path + + if (-not (Test-Path -LiteralPath $moduleInfo.ScriptPath)) { + Write-Error "Cannot find script file '$($moduleInfo.ScriptPath)' to parse." + return + } + + $dscTypes = GetDscResourceTypeDefinition -Path $moduleInfo.ScriptPath + + if ($dscTypes.Count -eq 0) { + Write-Warning "No class-based DSC resources found in '$Path'." + return + } + + foreach ($entry in $dscTypes) { + $typeDefinitionAst = $entry.TypeDefinitionAst + $allTypeDefinitions = $entry.AllTypeDefinitions + $resourceName = $typeDefinitionAst.Name + $resourceType = "$($moduleInfo.ModuleName)/$resourceName" + + Write-Verbose "Processing DSC resource '$resourceType'" + + $capabilities = GetDscResourceCapability -MemberAst $typeDefinitionAst.Members + $properties = GetDscResourceProperty -AllTypeDefinitions $allTypeDefinitions -TypeDefinitionAst $typeDefinitionAst + $embeddedSchema = BuildEmbeddedJsonSchema -ResourceName $resourceType -Properties $properties -Description $moduleInfo.Description + + $manifest = [DscAdaptedResourceManifest]::new() + $manifest.Schema = $script:AdaptedResourceSchemaUri + $manifest.Type = $resourceType + $manifest.Kind = 'resource' + $manifest.Version = $moduleInfo.Version + $manifest.Capabilities = @($capabilities) + $manifest.Description = $moduleInfo.Description + $manifest.Author = $moduleInfo.Author + $manifest.RequireAdapter = $script:DefaultAdapter + $manifest.Path = $moduleInfo.Psd1Path + $manifest.ManifestSchema = [DscAdaptedResourceManifestSchema]@{ + Embedded = $embeddedSchema + } + + Write-Output $manifest + } + } +} + +#endregion Public functions diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psd1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psd1 new file mode 100644 index 000000000..f5502df1a --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psd1 @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +@{ + RootModule = 'MultiResource.psm1' + ModuleVersion = '2.5.0' + GUID = 'b2c3d4e5-f6a7-8901-bcde-f12345678901' + Author = 'Microsoft' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) Microsoft. All rights reserved.' + Description = 'Module with multiple DSC resources.' + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + DscResourcesToExport = @('ResourceA', 'ResourceB') +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psm1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psm1 new file mode 100644 index 000000000..5d3645579 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psm1 @@ -0,0 +1,69 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +enum Ensure { + Present + Absent +} + +class BaseResource { + [DscProperty()] + [string] $BaseProperty +} + +[DscResource()] +class ResourceA : BaseResource { + [DscProperty(Key)] + [string] $Name + + [DscProperty()] + [Ensure] $Ensure + + [DscProperty()] + [int] $Count + + [DscProperty()] + [string[]] $Tags + + [ResourceA] Get() { + return $this + } + + [bool] Test() { + return $true + } + + [void] Set() { + } + + [void] Delete() { + } + + static [ResourceA[]] Export() { + return @() + } +} + +[DscResource()] +class ResourceB { + [DscProperty(Key)] + [string] $Id + + [DscProperty()] + [hashtable] $Settings + + [ResourceB] Get() { + return $this + } + + [bool] Test() { + return $false + } + + [void] Set() { + } + + [bool] WhatIf() { + return $true + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/NoDscResource.psm1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/NoDscResource.psm1 new file mode 100644 index 000000000..4c74ec2b2 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/NoDscResource.psm1 @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# A helper module with no DSC resources +function Get-SomeValue { + return 'hello' +} + +class NotADscResource { + [string] $Name + + [string] GetName() { + return $this.Name + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psd1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psd1 new file mode 100644 index 000000000..2c6bc4824 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psd1 @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +@{ + RootModule = 'SimpleResource.psm1' + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + Author = 'Microsoft' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) Microsoft. All rights reserved.' + Description = 'A simple DSC resource for testing.' + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + DscResourcesToExport = @('SimpleResource') +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psm1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psm1 new file mode 100644 index 000000000..f4a71ae9b --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psm1 @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[DscResource()] +class SimpleResource { + [DscProperty(Key)] + [string] $Name + + [DscProperty(Mandatory)] + [string] $Value + + [DscProperty()] + [bool] $Enabled + + [SimpleResource] Get() { + return $this + } + + [bool] Test() { + return $true + } + + [void] Set() { + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/StandaloneResource.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/StandaloneResource.ps1 new file mode 100644 index 000000000..ae2f3420d --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/StandaloneResource.ps1 @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[DscResource()] +class StandaloneResource { + [DscProperty(Key)] + [string] $Name + + [DscProperty()] + [string] $Content + + [StandaloneResource] Get() { + return $this + } + + [bool] Test() { + return $true + } + + [void] Set() { + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/New-DscAdaptedResourceManifest.Tests.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/New-DscAdaptedResourceManifest.Tests.ps1 new file mode 100644 index 000000000..bcf295c7e --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/New-DscAdaptedResourceManifest.Tests.ps1 @@ -0,0 +1,338 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'New-DscAdaptedResourceManifest' { + + BeforeAll { + $modulePath = Join-Path (Join-Path $PSScriptRoot '..') 'Microsoft.PowerShell.DSC.psd1' + Import-Module $modulePath -Force + + $fixturesPath = Join-Path $PSScriptRoot 'Fixtures' + } + + Context 'Simple module with a single DSC resource' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $result = New-DscAdaptedResourceManifest -Path $psd1 + } + + It 'Returns exactly one manifest object' { + $result | Should -HaveCount 1 + } + + It 'Returns a DscAdaptedResourceManifest object' { + $result.GetType().Name | Should -BeExactly 'DscAdaptedResourceManifest' + } + + It 'Sets the correct resource type' { + $result.Type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Sets the kind to resource' { + $result.Kind | Should -BeExactly 'resource' + } + + It 'Sets the version from the module manifest' { + $result.Version | Should -BeExactly '1.0.0' + } + + It 'Sets the description from the module manifest' { + $result.Description | Should -BeExactly 'A simple DSC resource for testing.' + } + + It 'Sets the author from the module manifest' { + $result.Author | Should -BeExactly 'Microsoft' + } + + It 'Sets the schema URI' { + $result.Schema | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + } + + It 'Sets the require adapter to Microsoft.DSC/PowerShell' { + $result.RequireAdapter | Should -BeExactly 'Microsoft.DSC/PowerShell' + } + + It 'Sets the path to the psd1 relative path' { + $result.Path | Should -BeLike '*SimpleResource*' + } + + It 'Detects get, set, and test capabilities' { + $result.Capabilities | Should -Contain 'get' + $result.Capabilities | Should -Contain 'set' + $result.Capabilities | Should -Contain 'test' + } + + It 'Does not include capabilities for methods that do not exist' { + $result.Capabilities | Should -Not -Contain 'delete' + $result.Capabilities | Should -Not -Contain 'export' + $result.Capabilities | Should -Not -Contain 'whatIf' + } + + It 'Includes an embedded JSON schema' { + $result.ManifestSchema | Should -Not -BeNullOrEmpty + $result.ManifestSchema.Embedded | Should -Not -BeNullOrEmpty + } + + It 'Schema has correct $schema URI' { + $result.ManifestSchema.Embedded['$schema'] | Should -BeExactly 'https://json-schema.org/draft/2020-12/schema' + } + + It 'Schema has type set to object' { + $result.ManifestSchema.Embedded['type'] | Should -BeExactly 'object' + } + + It 'Schema includes Key property as required' { + $result.ManifestSchema.Embedded['required'] | Should -Contain 'Name' + } + + It 'Schema includes Mandatory property as required' { + $result.ManifestSchema.Embedded['required'] | Should -Contain 'Value' + } + + It 'Schema maps string properties correctly' { + $result.ManifestSchema.Embedded['properties']['Name']['type'] | Should -BeExactly 'string' + $result.ManifestSchema.Embedded['properties']['Value']['type'] | Should -BeExactly 'string' + } + + It 'Schema maps bool properties correctly' { + $result.ManifestSchema.Embedded['properties']['Enabled']['type'] | Should -BeExactly 'boolean' + } + } + + Context 'Module with multiple DSC resources, inheritance, and enums' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'MultiResource' 'MultiResource.psd1' + $results = @(New-DscAdaptedResourceManifest -Path $psd1) + } + + It 'Returns two manifest objects' { + $results | Should -HaveCount 2 + } + + It 'Returns manifests for ResourceA and ResourceB' { + $results.Type | Should -Contain 'MultiResource/ResourceA' + $results.Type | Should -Contain 'MultiResource/ResourceB' + } + + It 'All manifests share the same module version' { + $results | ForEach-Object { + $_.Version | Should -BeExactly '2.5.0' + } + } + + It 'All manifests share the same author' { + $results | ForEach-Object { + $_.Author | Should -BeExactly 'Microsoft' + } + } + + Context 'ResourceA - inheritance, enums, delete, export' { + + BeforeAll { + $resourceA = $results | Where-Object { $_.Type -eq 'MultiResource/ResourceA' } + } + + It 'Detects get, set, test, delete, and export capabilities' { + $resourceA.Capabilities | Should -Contain 'get' + $resourceA.Capabilities | Should -Contain 'set' + $resourceA.Capabilities | Should -Contain 'test' + $resourceA.Capabilities | Should -Contain 'delete' + $resourceA.Capabilities | Should -Contain 'export' + } + + It 'Includes inherited BaseProperty from base class' { + $resourceA.ManifestSchema.Embedded['properties'].Keys | Should -Contain 'BaseProperty' + } + + It 'Includes own properties' { + $props = $resourceA.ManifestSchema.Embedded['properties'] + $props.Keys | Should -Contain 'Name' + $props.Keys | Should -Contain 'Ensure' + $props.Keys | Should -Contain 'Count' + $props.Keys | Should -Contain 'Tags' + } + + It 'Maps the Ensure enum to string type with enum values' { + $ensureProp = $resourceA.ManifestSchema.Embedded['properties']['Ensure'] + $ensureProp['type'] | Should -BeExactly 'string' + $ensureProp['enum'] | Should -Contain 'Present' + $ensureProp['enum'] | Should -Contain 'Absent' + } + + It 'Maps int property to integer type' { + $resourceA.ManifestSchema.Embedded['properties']['Count']['type'] | Should -BeExactly 'integer' + } + + It 'Maps string[] property to array type with string items' { + $tagsProp = $resourceA.ManifestSchema.Embedded['properties']['Tags'] + $tagsProp['type'] | Should -BeExactly 'array' + $tagsProp['items']['type'] | Should -BeExactly 'string' + } + + It 'Has Key property Name as required' { + $resourceA.ManifestSchema.Embedded['required'] | Should -Contain 'Name' + } + } + + Context 'ResourceB - whatIf capability and hashtable property' { + + BeforeAll { + $resourceB = $results | Where-Object { $_.Type -eq 'MultiResource/ResourceB' } + } + + It 'Detects get, set, test, and whatIf capabilities' { + $resourceB.Capabilities | Should -Contain 'get' + $resourceB.Capabilities | Should -Contain 'set' + $resourceB.Capabilities | Should -Contain 'test' + $resourceB.Capabilities | Should -Contain 'whatIf' + } + + It 'Does not include delete or export capabilities' { + $resourceB.Capabilities | Should -Not -Contain 'delete' + $resourceB.Capabilities | Should -Not -Contain 'export' + } + + It 'Maps hashtable property to object type' { + $resourceB.ManifestSchema.Embedded['properties']['Settings']['type'] | Should -BeExactly 'object' + } + } + } + + Context 'Standalone .ps1 file with a DSC resource' { + + BeforeAll { + $ps1Path = Join-Path $fixturesPath 'StandaloneResource.ps1' + $result = New-DscAdaptedResourceManifest -Path $ps1Path + } + + It 'Returns a manifest object' { + $result | Should -HaveCount 1 + } + + It 'Uses the file name as the module name' { + $result.Type | Should -BeExactly 'StandaloneResource/StandaloneResource' + } + + It 'Defaults version to 0.0.1 when no psd1 exists' { + $result.Version | Should -BeExactly '0.0.1' + } + + It 'Defaults author to empty string when no psd1 exists' { + $result.Author | Should -BeExactly '' + } + } + + Context 'File with no DSC resources' { + + It 'Emits a warning and returns nothing' { + $psm1Path = Join-Path $fixturesPath 'NoDscResource.psm1' + $result = New-DscAdaptedResourceManifest -Path $psm1Path -WarningVariable warn -WarningAction SilentlyContinue + $result | Should -BeNullOrEmpty + $warn | Should -Not -BeNullOrEmpty + $warn[0] | Should -BeLike '*No class-based DSC resources found*' + } + } + + Context 'ToJson serialization' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $manifest = New-DscAdaptedResourceManifest -Path $psd1 + $json = $manifest.ToJson() + $parsed = $json | ConvertFrom-Json + } + + It 'Produces valid JSON' { + { $json | ConvertFrom-Json } | Should -Not -Throw + } + + It 'Contains the $schema key' { + $parsed.'$schema' | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + } + + It 'Contains the type key' { + $parsed.type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Contains the kind key' { + $parsed.kind | Should -BeExactly 'resource' + } + + It 'Contains the version key' { + $parsed.version | Should -BeExactly '1.0.0' + } + + It 'Contains the requireAdapter key' { + $parsed.requireAdapter | Should -BeExactly 'Microsoft.DSC/PowerShell' + } + + It 'Contains the schema.embedded object with properties' { + $parsed.schema.embedded | Should -Not -BeNullOrEmpty + $parsed.schema.embedded.properties | Should -Not -BeNullOrEmpty + } + } + + Context 'Pipeline input' { + + It 'Accepts Path from pipeline by value' { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $result = $psd1 | New-DscAdaptedResourceManifest + $result | Should -HaveCount 1 + $result.Type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Accepts multiple paths from pipeline' { + $paths = @( + (Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1') + (Join-Path $fixturesPath 'MultiResource' 'MultiResource.psd1') + ) + $results = $paths | New-DscAdaptedResourceManifest + $results | Should -HaveCount 3 # 1 from Simple + 2 from Multi + } + + It 'Accepts FileInfo objects from Get-ChildItem via pipeline' { + $results = Get-ChildItem -Path $fixturesPath -Filter '*.psd1' -Recurse | New-DscAdaptedResourceManifest + $results | Should -HaveCount 3 # 1 from Simple + 2 from Multi + } + } + + Context 'Input via .psm1 path resolves co-located .psd1' { + + It 'Uses psd1 metadata when psm1 is provided and psd1 exists' { + $psm1Path = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psm1' + $result = New-DscAdaptedResourceManifest -Path $psm1Path + $result.Version | Should -BeExactly '1.0.0' + $result.Author | Should -BeExactly 'Microsoft' + } + } + + Context 'Parameter validation' { + + It 'Throws when path does not exist' { + { New-DscAdaptedResourceManifest -Path 'C:\NonExistent\Fake.psd1' } | Should -Throw '*does not exist*' + } + + It 'Throws when path has an unsupported extension' { + $txtFile = Join-Path $TestDrive 'test.txt' + Set-Content -Path $txtFile -Value 'not a ps file' + { New-DscAdaptedResourceManifest -Path $txtFile } | Should -Throw '*must be a .ps1, .psm1, or .psd1 file*' + } + + It 'Is a mandatory parameter' { + (Get-Command New-DscAdaptedResourceManifest).Parameters['Path'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | + ForEach-Object { $_.Mandatory | Should -BeTrue } + } + } + + Context 'Schema additionalProperties' { + + It 'Sets additionalProperties to false' { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $result = New-DscAdaptedResourceManifest -Path $psd1 + $result.ManifestSchema.Embedded['additionalProperties'] | Should -BeFalse + } + } +} From be86365ef8a276f2c94f09caf31eaa95bff8feb7 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sun, 22 Mar 2026 14:04:07 +0100 Subject: [PATCH 2/4] Add New-DscResourceManifest function --- .../Microsoft.PowerShell.DSC.psd1 | 5 +- .../Microsoft.PowerShell.DSC.psm1 | 151 ++++++++- .../Tests/New-DscResourceManifest.Tests.ps1 | 319 ++++++++++++++++++ 3 files changed, 472 insertions(+), 3 deletions(-) create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/New-DscResourceManifest.Tests.ps1 diff --git a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 index 4b469fcb7..dfa536c87 100644 --- a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 +++ b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 @@ -22,14 +22,15 @@ CompanyName = 'Microsoft Corporation' Copyright = '(c) Microsoft Corporation. All rights reserved.' # Description of the functionality provided by this module -Description = 'Creates adapted resource manifests from class-based PowerShell DSC resources.' +Description = 'Provides functionality to assist in Microsoft Desired State Configuration (DSC) operations.' # Minimum version of the PowerShell engine required by this module -PowerShellVersion = '7.0' +PowerShellVersion = '5.1' # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. FunctionsToExport = @( 'New-DscAdaptedResourceManifest' + 'New-DscResourceManifest' ) # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. diff --git a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 index 4751f7da8..5b8a9ac44 100644 --- a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 +++ b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 @@ -4,8 +4,9 @@ $ErrorActionPreference = 'Stop' $script:AdaptedResourceSchemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' +$script:ResourceManifestSchemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' $script:JsonSchemaUri = 'https://json-schema.org/draft/2020-12/schema' -$script:DefaultAdapter = 'Microsoft.DSC/PowerShell' +$script:DefaultAdapter = 'Microsoft.Adapter/PowerShell' #region Classes @@ -42,6 +43,55 @@ class DscAdaptedResourceManifest { } return $manifest | ConvertTo-Json -Depth 10 } + + [hashtable] ToHashtable() { + return [ordered]@{ + '$schema' = $this.Schema + type = $this.Type + kind = $this.Kind + version = $this.Version + capabilities = $this.Capabilities + description = $this.Description + author = $this.Author + requireAdapter = $this.RequireAdapter + path = $this.Path + schema = [ordered]@{ + embedded = $this.ManifestSchema.Embedded + } + } + } +} + +class DscResourceManifestList { + [System.Collections.Generic.List[hashtable]] $AdaptedResources + [System.Collections.Generic.List[hashtable]] $Resources + + DscResourceManifestList() { + $this.AdaptedResources = [System.Collections.Generic.List[hashtable]]::new() + $this.Resources = [System.Collections.Generic.List[hashtable]]::new() + } + + [void] AddAdaptedResource([DscAdaptedResourceManifest]$Manifest) { + $this.AdaptedResources.Add($Manifest.ToHashtable()) + } + + [void] AddResource([hashtable]$Resource) { + $this.Resources.Add($Resource) + } + + [string] ToJson() { + $result = [ordered]@{} + + if ($this.AdaptedResources.Count -gt 0) { + $result['adaptedResources'] = @($this.AdaptedResources) + } + + if ($this.Resources.Count -gt 0) { + $result['resources'] = @($this.Resources) + } + + return $result | ConvertTo-Json -Depth 15 + } } #endregion Classes @@ -462,4 +512,103 @@ function New-DscAdaptedResourceManifest { } } +<# + .SYNOPSIS + Creates a DSC resource manifests list for bundling multiple resources in a single file. + + .DESCRIPTION + Builds a DscResourceManifestList object that can contain both adapted resources and + command-based resources. The resulting object can be serialized to JSON and written + to a `.dsc.manifests.json` file, which DSCv3 discovers and loads as a bundle. + + Adapted resources can be added by piping DscAdaptedResourceManifest objects from + New-DscAdaptedResourceManifest. Command-based resources can be added via the + -Resource parameter as hashtables matching the DSCv3 resource manifest schema. + + .PARAMETER AdaptedResource + One or more DscAdaptedResourceManifest objects to include in the manifests list. + These are typically produced by New-DscAdaptedResourceManifest. + + .PARAMETER Resource + One or more hashtables representing command-based DSC resource manifests. Each + hashtable should conform to the DSCv3 resource manifest schema with keys such as + `$schema`, `type`, `version`, `get`, `set`, `test`, `schema`, etc. + + .EXAMPLE + $adapted = New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 + New-DscResourceManifest -AdaptedResource $adapted + + Creates a manifests list from adapted resource manifests generated from a module. + + .EXAMPLE + $resource = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/MyTool' + version = '1.0.0' + get = @{ executable = 'mytool'; args = @('get') } + set = @{ executable = 'mytool'; args = @('set'); implementsPretest = $false; return = 'state' } + test = @{ executable = 'mytool'; args = @('test'); return = 'state' } + exitCodes = @{ '0' = 'Success'; '1' = 'Error' } + schema = @{ command = @{ executable = 'mytool'; args = @('schema') } } + } + New-DscResourceManifest -Resource $resource + + Creates a manifests list containing a single command-based resource. + + .EXAMPLE + $adapted = New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 + $resource = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/MyTool' + version = '1.0.0' + get = @{ executable = 'mytool'; args = @('get') } + } + New-DscResourceManifest -AdaptedResource $adapted -Resource $resource + + Creates a manifests list combining both adapted and command-based resources. + + .EXAMPLE + New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 | + New-DscResourceManifest + + Pipes adapted resource manifests directly into the function via the pipeline. + + .OUTPUTS + Returns a DscResourceManifestList object with a .ToJson() method for serialization + to the `.dsc.manifests.json` format. +#> +function New-DscResourceManifest { + [CmdletBinding()] + [OutputType([DscResourceManifestList])] + param( + [Parameter(ValueFromPipeline)] + [DscAdaptedResourceManifest[]]$AdaptedResource, + + [Parameter()] + [hashtable[]]$Resource + ) + + begin { + $manifestList = [DscResourceManifestList]::new() + + if ($Resource) { + foreach ($res in $Resource) { + $manifestList.AddResource($res) + } + } + } + + process { + if ($AdaptedResource) { + foreach ($adapted in $AdaptedResource) { + $manifestList.AddAdaptedResource($adapted) + } + } + } + + end { + Write-Output $manifestList + } +} + #endregion Public functions diff --git a/tools/Microsoft.PowerShell.DSC/Tests/New-DscResourceManifest.Tests.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/New-DscResourceManifest.Tests.ps1 new file mode 100644 index 000000000..26b3c5aa0 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/New-DscResourceManifest.Tests.ps1 @@ -0,0 +1,319 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'New-DscResourceManifest' { + + BeforeAll { + $modulePath = Join-Path (Join-Path $PSScriptRoot '..') 'Microsoft.PowerShell.DSC.psd1' + Import-Module $modulePath -Force + + $fixturesPath = Join-Path $PSScriptRoot 'Fixtures' + } + + Context 'With adapted resources from pipeline' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $adapted = New-DscAdaptedResourceManifest -Path $psd1 + $result = $adapted | New-DscResourceManifest + } + + It 'Returns a DscResourceManifestList object' { + $result.GetType().Name | Should -BeExactly 'DscResourceManifestList' + } + + It 'Contains one adapted resource' { + $result.AdaptedResources | Should -HaveCount 1 + } + + It 'Has no command-based resources' { + $result.Resources | Should -HaveCount 0 + } + + It 'Adapted resource has the correct type' { + $result.AdaptedResources[0]['type'] | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Adapted resource has the correct schema URI' { + $result.AdaptedResources[0]['$schema'] | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + } + } + + Context 'With multiple adapted resources from pipeline' { + + BeforeAll { + $paths = @( + (Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1') + (Join-Path $fixturesPath 'MultiResource' 'MultiResource.psd1') + ) + $adapted = $paths | New-DscAdaptedResourceManifest + $result = $adapted | New-DscResourceManifest + } + + It 'Contains three adapted resources' { + $result.AdaptedResources | Should -HaveCount 3 + } + + It 'Includes all resource types' { + $types = $result.AdaptedResources | ForEach-Object { $_['type'] } + $types | Should -Contain 'SimpleResource/SimpleResource' + $types | Should -Contain 'MultiResource/ResourceA' + $types | Should -Contain 'MultiResource/ResourceB' + } + } + + Context 'With AdaptedResource parameter' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $adapted = New-DscAdaptedResourceManifest -Path $psd1 + $result = New-DscResourceManifest -AdaptedResource $adapted + } + + It 'Returns a DscResourceManifestList object' { + $result.GetType().Name | Should -BeExactly 'DscResourceManifestList' + } + + It 'Contains one adapted resource' { + $result.AdaptedResources | Should -HaveCount 1 + } + } + + Context 'With command-based Resource parameter' { + + BeforeAll { + $resource = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/MyTool' + version = '1.0.0' + get = @{ executable = 'mytool'; args = @('get') } + set = @{ + executable = 'mytool' + args = @('set') + implementsPretest = $false + return = 'state' + } + test = @{ + executable = 'mytool' + args = @('test') + return = 'state' + } + exitCodes = @{ '0' = 'Success'; '1' = 'Error' } + schema = @{ + command = @{ + executable = 'mytool' + args = @('schema') + } + } + } + $result = New-DscResourceManifest -Resource $resource + } + + It 'Returns a DscResourceManifestList object' { + $result.GetType().Name | Should -BeExactly 'DscResourceManifestList' + } + + It 'Has no adapted resources' { + $result.AdaptedResources | Should -HaveCount 0 + } + + It 'Contains one command-based resource' { + $result.Resources | Should -HaveCount 1 + } + + It 'Resource has the correct type' { + $result.Resources[0]['type'] | Should -BeExactly 'MyCompany/MyTool' + } + + It 'Resource has the correct schema URI' { + $result.Resources[0]['$schema'] | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + } + + It 'Resource has get method defined' { + $result.Resources[0]['get'] | Should -Not -BeNullOrEmpty + $result.Resources[0]['get']['executable'] | Should -BeExactly 'mytool' + } + } + + Context 'Combining adapted and command-based resources' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $adapted = New-DscAdaptedResourceManifest -Path $psd1 + + $resource = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/MyTool' + version = '1.0.0' + get = @{ executable = 'mytool'; args = @('get') } + } + $result = $adapted | New-DscResourceManifest -Resource $resource + } + + It 'Contains one adapted resource' { + $result.AdaptedResources | Should -HaveCount 1 + } + + It 'Contains one command-based resource' { + $result.Resources | Should -HaveCount 1 + } + + It 'Adapted resource has the correct type' { + $result.AdaptedResources[0]['type'] | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Command-based resource has the correct type' { + $result.Resources[0]['type'] | Should -BeExactly 'MyCompany/MyTool' + } + } + + Context 'Multiple command-based resources' { + + BeforeAll { + $resources = @( + @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/ToolA' + version = '1.0.0' + get = @{ executable = 'toolA'; args = @('get') } + } + @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/ToolB' + version = '2.0.0' + get = @{ executable = 'toolB'; args = @('get') } + } + ) + $result = New-DscResourceManifest -Resource $resources + } + + It 'Contains two command-based resources' { + $result.Resources | Should -HaveCount 2 + } + + It 'Includes both resource types' { + $types = $result.Resources | ForEach-Object { $_['type'] } + $types | Should -Contain 'MyCompany/ToolA' + $types | Should -Contain 'MyCompany/ToolB' + } + } + + Context 'ToJson serialization' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $adapted = New-DscAdaptedResourceManifest -Path $psd1 + + $resource = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/MyTool' + version = '1.0.0' + get = @{ executable = 'mytool'; args = @('get') } + } + $manifestList = $adapted | New-DscResourceManifest -Resource $resource + $json = $manifestList.ToJson() + $parsed = $json | ConvertFrom-Json + } + + It 'Produces valid JSON' { + { $json | ConvertFrom-Json } | Should -Not -Throw + } + + It 'Contains adaptedResources array' { + $parsed.adaptedResources | Should -Not -BeNullOrEmpty + $parsed.adaptedResources | Should -HaveCount 1 + } + + It 'Contains resources array' { + $parsed.resources | Should -Not -BeNullOrEmpty + $parsed.resources | Should -HaveCount 1 + } + + It 'Adapted resource in JSON has correct type' { + $parsed.adaptedResources[0].type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Command resource in JSON has correct type' { + $parsed.resources[0].type | Should -BeExactly 'MyCompany/MyTool' + } + + It 'Adapted resource schema is embedded in JSON' { + $parsed.adaptedResources[0].schema.embedded | Should -Not -BeNullOrEmpty + } + } + + Context 'ToJson with only adapted resources' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $adapted = New-DscAdaptedResourceManifest -Path $psd1 + $manifestList = $adapted | New-DscResourceManifest + $json = $manifestList.ToJson() + $parsed = $json | ConvertFrom-Json + } + + It 'Contains adaptedResources array' { + $parsed.adaptedResources | Should -Not -BeNullOrEmpty + } + + It 'Does not contain resources key when empty' { + $parsed.PSObject.Properties.Name | Should -Not -Contain 'resources' + } + } + + Context 'ToJson with only command-based resources' { + + BeforeAll { + $resource = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/MyTool' + version = '1.0.0' + get = @{ executable = 'mytool'; args = @('get') } + } + $manifestList = New-DscResourceManifest -Resource $resource + $json = $manifestList.ToJson() + $parsed = $json | ConvertFrom-Json + } + + It 'Contains resources array' { + $parsed.resources | Should -Not -BeNullOrEmpty + } + + It 'Does not contain adaptedResources key when empty' { + $parsed.PSObject.Properties.Name | Should -Not -Contain 'adaptedResources' + } + } + + Context 'No inputs' { + + It 'Returns an empty manifest list when called without arguments' { + $result = New-DscResourceManifest + $result.AdaptedResources | Should -HaveCount 0 + $result.Resources | Should -HaveCount 0 + } + + It 'Empty manifest list produces empty JSON object' { + $result = New-DscResourceManifest + $json = $result.ToJson() + $parsed = $json | ConvertFrom-Json + $parsed.PSObject.Properties.Name | Should -Not -Contain 'adaptedResources' + $parsed.PSObject.Properties.Name | Should -Not -Contain 'resources' + } + } + + Context 'End-to-end pipeline from module to manifests file' { + + It 'Produces valid JSON matching the ManifestList schema structure' { + $psd1 = Join-Path $fixturesPath 'MultiResource' 'MultiResource.psd1' + $json = New-DscAdaptedResourceManifest -Path $psd1 | + New-DscResourceManifest | + ForEach-Object { $_.ToJson() } + + $parsed = $json | ConvertFrom-Json + $parsed.adaptedResources | Should -HaveCount 2 + $parsed.adaptedResources[0].type | Should -Not -BeNullOrEmpty + $parsed.adaptedResources[0].requireAdapter | Should -BeExactly 'Microsoft.DSC/PowerShell' + $parsed.adaptedResources[0].schema.embedded | Should -Not -BeNullOrEmpty + } + } +} From eb6352f7dc9078f307ed9380041c5e5acee3f289 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sun, 22 Mar 2026 15:15:08 +0100 Subject: [PATCH 3/4] Resolve Copilot remarks --- .../Microsoft.PowerShell.DSC.psm1 | 6 ++++-- .../Tests/New-DscAdaptedResourceManifest.Tests.ps1 | 8 ++++++-- .../Tests/New-DscResourceManifest.Tests.ps1 | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 index 5b8a9ac44..4c1dd8568 100644 --- a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 +++ b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 @@ -354,7 +354,7 @@ function ResolveModuleInfo { [string]$Path ) - $resolvedPath = Resolve-Path -Path $Path + $resolvedPath = Resolve-Path -LiteralPath $Path $extension = [System.IO.Path]::GetExtension($resolvedPath) $directory = [System.IO.Path]::GetDirectoryName($resolvedPath) @@ -392,13 +392,15 @@ function ResolveModuleInfo { return ResolveModuleInfo -Path $psd1Path } + $fileName = [System.IO.Path]::GetFileName($resolvedPath) + return @{ ModuleName = $moduleName Version = '0.0.1' Author = '' Description = '' ScriptPath = [string]$resolvedPath - Psd1Path = "$moduleName/$moduleName.psd1" + Psd1Path = "$moduleName/$fileName" Directory = $directory } } diff --git a/tools/Microsoft.PowerShell.DSC/Tests/New-DscAdaptedResourceManifest.Tests.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/New-DscAdaptedResourceManifest.Tests.ps1 index bcf295c7e..6bac80e10 100644 --- a/tools/Microsoft.PowerShell.DSC/Tests/New-DscAdaptedResourceManifest.Tests.ps1 +++ b/tools/Microsoft.PowerShell.DSC/Tests/New-DscAdaptedResourceManifest.Tests.ps1 @@ -50,7 +50,7 @@ Describe 'New-DscAdaptedResourceManifest' { } It 'Sets the require adapter to Microsoft.DSC/PowerShell' { - $result.RequireAdapter | Should -BeExactly 'Microsoft.DSC/PowerShell' + $result.RequireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' } It 'Sets the path to the psd1 relative path' { @@ -222,6 +222,10 @@ Describe 'New-DscAdaptedResourceManifest' { It 'Defaults author to empty string when no psd1 exists' { $result.Author | Should -BeExactly '' } + + It 'Sets path to the actual script file' { + $result.Path | Should -BeExactly 'StandaloneResource/StandaloneResource.ps1' + } } Context 'File with no DSC resources' { @@ -265,7 +269,7 @@ Describe 'New-DscAdaptedResourceManifest' { } It 'Contains the requireAdapter key' { - $parsed.requireAdapter | Should -BeExactly 'Microsoft.DSC/PowerShell' + $parsed.requireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' } It 'Contains the schema.embedded object with properties' { diff --git a/tools/Microsoft.PowerShell.DSC/Tests/New-DscResourceManifest.Tests.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/New-DscResourceManifest.Tests.ps1 index 26b3c5aa0..a94ce661e 100644 --- a/tools/Microsoft.PowerShell.DSC/Tests/New-DscResourceManifest.Tests.ps1 +++ b/tools/Microsoft.PowerShell.DSC/Tests/New-DscResourceManifest.Tests.ps1 @@ -312,7 +312,7 @@ Describe 'New-DscResourceManifest' { $parsed = $json | ConvertFrom-Json $parsed.adaptedResources | Should -HaveCount 2 $parsed.adaptedResources[0].type | Should -Not -BeNullOrEmpty - $parsed.adaptedResources[0].requireAdapter | Should -BeExactly 'Microsoft.DSC/PowerShell' + $parsed.adaptedResources[0].requireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' $parsed.adaptedResources[0].schema.embedded | Should -Not -BeNullOrEmpty } } From d900b0eb38c0cb1a3c6649b6e894d952fa0ed786 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sun, 22 Mar 2026 23:50:57 +0100 Subject: [PATCH 4/4] Add Import-* commands --- .../Microsoft.PowerShell.DSC.psd1 | 2 + .../Microsoft.PowerShell.DSC.psm1 | 219 +++++++++++++++++ .../MinimalResource.dsc.adaptedResource.json | 20 ++ .../SimpleResource.dsc.adaptedResource.json | 39 +++ .../Fixtures/TestModule.dsc.manifests.json | 102 ++++++++ ...mport-DscAdaptedResourceManifest.Tests.ps1 | 198 +++++++++++++++ .../Import-DscResourceManifest.Tests.ps1 | 227 ++++++++++++++++++ 7 files changed, 807 insertions(+) create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MinimalResource.dsc.adaptedResource.json create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource.dsc.adaptedResource.json create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/Fixtures/TestModule.dsc.manifests.json create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/Import-DscAdaptedResourceManifest.Tests.ps1 create mode 100644 tools/Microsoft.PowerShell.DSC/Tests/Import-DscResourceManifest.Tests.ps1 diff --git a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 index dfa536c87..1c17078fa 100644 --- a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 +++ b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 @@ -29,6 +29,8 @@ PowerShellVersion = '5.1' # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. FunctionsToExport = @( + 'Import-DscAdaptedResourceManifest' + 'Import-DscResourceManifest' 'New-DscAdaptedResourceManifest' 'New-DscResourceManifest' ) diff --git a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 index 4c1dd8568..9a29cdced 100644 --- a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 +++ b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 @@ -65,10 +65,12 @@ class DscAdaptedResourceManifest { class DscResourceManifestList { [System.Collections.Generic.List[hashtable]] $AdaptedResources [System.Collections.Generic.List[hashtable]] $Resources + [System.Collections.Generic.List[hashtable]] $Extensions DscResourceManifestList() { $this.AdaptedResources = [System.Collections.Generic.List[hashtable]]::new() $this.Resources = [System.Collections.Generic.List[hashtable]]::new() + $this.Extensions = [System.Collections.Generic.List[hashtable]]::new() } [void] AddAdaptedResource([DscAdaptedResourceManifest]$Manifest) { @@ -79,6 +81,10 @@ class DscResourceManifestList { $this.Resources.Add($Resource) } + [void] AddExtension([hashtable]$Extension) { + $this.Extensions.Add($Extension) + } + [string] ToJson() { $result = [ordered]@{} @@ -90,6 +96,10 @@ class DscResourceManifestList { $result['resources'] = @($this.Resources) } + if ($this.Extensions.Count -gt 0) { + $result['extensions'] = @($this.Extensions) + } + return $result | ConvertTo-Json -Depth 15 } } @@ -405,6 +415,71 @@ function ResolveModuleInfo { } } +function ConvertPSObjectToHashtable { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [object]$InputObject + ) + + if ($InputObject -is [System.Collections.IDictionary]) { + $result = [ordered]@{} + foreach ($key in $InputObject.Keys) { + $result[$key] = ConvertPSObjectToHashtable -InputObject $InputObject[$key] + } + return $result + } + + if ($InputObject -is [PSCustomObject]) { + $result = [ordered]@{} + foreach ($property in $InputObject.PSObject.Properties) { + $result[$property.Name] = ConvertPSObjectToHashtable -InputObject $property.Value + } + return $result + } + + if ($InputObject -is [System.Collections.IList]) { + $items = [System.Collections.Generic.List[object]]::new() + foreach ($item in $InputObject) { + $items.Add((ConvertPSObjectToHashtable -InputObject $item)) + } + return @($items) + } + + return $InputObject +} + +function ConvertToAdaptedResourceManifest { + [CmdletBinding()] + [OutputType([DscAdaptedResourceManifest])] + param( + [Parameter(Mandatory)] + [hashtable]$Hashtable + ) + + $manifest = [DscAdaptedResourceManifest]::new() + $manifest.Schema = $Hashtable['$schema'] + $manifest.Type = $Hashtable['type'] + $manifest.Kind = if ($Hashtable.Contains('kind')) { $Hashtable['kind'] } else { 'resource' } + $manifest.Version = $Hashtable['version'] + $manifest.Capabilities = if ($Hashtable.Contains('capabilities') -and $null -ne $Hashtable['capabilities']) { @($Hashtable['capabilities']) } else { [string[]]::new(0) } + $manifest.Description = if ($Hashtable.Contains('description')) { [string]$Hashtable['description'] } else { '' } + $manifest.Author = if ($Hashtable.Contains('author')) { [string]$Hashtable['author'] } else { '' } + $manifest.RequireAdapter = $Hashtable['requireAdapter'] + $manifest.Path = if ($Hashtable.Contains('path')) { [string]$Hashtable['path'] } else { '' } + + $schemaData = $Hashtable['schema'] + if ($schemaData) { + $embeddedSchema = if ($schemaData.Contains('embedded')) { $schemaData['embedded'] } else { $schemaData } + $manifest.ManifestSchema = [DscAdaptedResourceManifestSchema]@{ + Embedded = $embeddedSchema + } + } + + return $manifest +} + #endregion Private functions #region Public functions @@ -613,4 +688,148 @@ function New-DscResourceManifest { } } +<# + .SYNOPSIS + Imports adapted resource manifest objects from `.dsc.adaptedResource.json` files. + + .DESCRIPTION + Reads one or more `.dsc.adaptedResource.json` files and returns DscAdaptedResourceManifest + objects. This is the inverse of serializing a manifest with `.ToJson()` — it allows you + to load existing adapted resource manifests for inspection, modification, or inclusion + in a resource manifest list via New-DscResourceManifest. + + .PARAMETER Path + The path to a `.dsc.adaptedResource.json` file. Accepts pipeline input. + + .EXAMPLE + Import-DscAdaptedResourceManifest -Path ./MyResource.dsc.adaptedResource.json + + Imports a single adapted resource manifest and returns a DscAdaptedResourceManifest object. + + .EXAMPLE + Get-ChildItem -Filter *.dsc.adaptedResource.json | Import-DscAdaptedResourceManifest + + Imports all adapted resource manifest files in the current directory. + + .EXAMPLE + Import-DscAdaptedResourceManifest -Path ./MyResource.dsc.adaptedResource.json | + New-DscResourceManifest + + Imports an adapted resource manifest and bundles it into a resource manifest list. + + .OUTPUTS + Returns a DscAdaptedResourceManifest object for each file. The object has .ToJson() + and .ToHashtable() methods for serialization. +#> +function Import-DscAdaptedResourceManifest { + [CmdletBinding()] + [OutputType([DscAdaptedResourceManifest])] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [ValidateScript({ + if (-not (Test-Path -LiteralPath $_)) { + throw "Path '$_' does not exist." + } + return $true + })] + [Alias('FullName')] + [string]$Path + ) + + process { + $resolvedPath = Resolve-Path -LiteralPath $Path + Write-Verbose "Importing adapted resource manifest from '$resolvedPath'" + + $jsonContent = Get-Content -LiteralPath $resolvedPath -Raw + $parsed = ConvertFrom-Json -InputObject $jsonContent + $hashtable = ConvertPSObjectToHashtable -InputObject $parsed + + $manifest = ConvertToAdaptedResourceManifest -Hashtable $hashtable + Write-Output $manifest + } +} + +<# + .SYNOPSIS + Imports a DSC resource manifest list from a `.dsc.manifests.json` file. + + .DESCRIPTION + Reads a `.dsc.manifests.json` file and returns a DscResourceManifestList object + containing the adapted resources, command-based resources, and extensions defined + in the file. This is the inverse of serializing a manifest list with `.ToJson()`. + + The adapted resources in the returned list are hydrated into DscAdaptedResourceManifest + objects and stored via AddAdaptedResource. Resources and extensions are stored as + hashtables. + + .PARAMETER Path + The path to a `.dsc.manifests.json` file. Accepts pipeline input. + + .EXAMPLE + Import-DscResourceManifest -Path ./MyModule.dsc.manifests.json + + Imports a manifest list file and returns a DscResourceManifestList object. + + .EXAMPLE + Get-ChildItem -Filter *.dsc.manifests.json | Import-DscResourceManifest + + Imports all manifest list files in the current directory. + + .EXAMPLE + $list = Import-DscResourceManifest -Path ./existing.dsc.manifests.json + $list.AdaptedResources.Count + + Imports a manifest list and inspects the number of adapted resources. + + .OUTPUTS + Returns a DscResourceManifestList object with .ToJson() for serialization. +#> +function Import-DscResourceManifest { + [CmdletBinding()] + [OutputType([DscResourceManifestList])] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [ValidateScript({ + if (-not (Test-Path -LiteralPath $_)) { + throw "Path '$_' does not exist." + } + return $true + })] + [Alias('FullName')] + [string]$Path + ) + + process { + $resolvedPath = Resolve-Path -LiteralPath $Path + Write-Verbose "Importing resource manifest list from '$resolvedPath'" + + $jsonContent = Get-Content -LiteralPath $resolvedPath -Raw + $parsed = ConvertFrom-Json -InputObject $jsonContent + $hashtable = ConvertPSObjectToHashtable -InputObject $parsed + + $manifestList = [DscResourceManifestList]::new() + + if ($hashtable.Contains('adaptedResources')) { + foreach ($ar in $hashtable['adaptedResources']) { + $manifest = ConvertToAdaptedResourceManifest -Hashtable $ar + $manifestList.AddAdaptedResource($manifest) + } + } + + if ($hashtable.Contains('resources')) { + foreach ($res in $hashtable['resources']) { + $manifestList.AddResource($res) + } + } + + if ($hashtable.Contains('extensions')) { + foreach ($ext in $hashtable['extensions']) { + $manifestList.AddExtension($ext) + } + } + + Write-Output $manifestList + } +} + #endregion Public functions diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MinimalResource.dsc.adaptedResource.json b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MinimalResource.dsc.adaptedResource.json new file mode 100644 index 000000000..01a0d71c7 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MinimalResource.dsc.adaptedResource.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json", + "type": "TestModule/MinimalResource", + "version": "0.1.0", + "requireAdapter": "Microsoft.Adapter/PowerShell", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TestModule/MinimalResource", + "type": "object", + "required": [], + "additionalProperties": false, + "properties": { + "Id": { + "type": "integer", + "title": "Id", + "description": "The Id property." + } + } + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource.dsc.adaptedResource.json b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource.dsc.adaptedResource.json new file mode 100644 index 000000000..f58feb716 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource.dsc.adaptedResource.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json", + "type": "SimpleResource/SimpleResource", + "kind": "resource", + "version": "1.0.0", + "capabilities": [ + "get", + "set", + "test" + ], + "description": "A simple DSC resource for testing.", + "author": "Microsoft", + "requireAdapter": "Microsoft.Adapter/PowerShell", + "path": "SimpleResource/SimpleResource.psd1", + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SimpleResource/SimpleResource", + "type": "object", + "required": [ + "Name" + ], + "additionalProperties": false, + "properties": { + "Name": { + "type": "string", + "title": "Name", + "description": "The Name property." + }, + "Value": { + "type": "string", + "title": "Value", + "description": "The Value property." + } + }, + "description": "A simple DSC resource for testing." + } + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/TestModule.dsc.manifests.json b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/TestModule.dsc.manifests.json new file mode 100644 index 000000000..9c92eb795 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/TestModule.dsc.manifests.json @@ -0,0 +1,102 @@ +{ + "adaptedResources": [ + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json", + "type": "TestModule/ResourceOne", + "kind": "resource", + "version": "1.0.0", + "capabilities": [ + "get", + "set" + ], + "description": "First test resource.", + "author": "TestAuthor", + "requireAdapter": "Microsoft.Adapter/PowerShell", + "path": "TestModule/TestModule.psd1", + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TestModule/ResourceOne", + "type": "object", + "required": [ + "Name" + ], + "additionalProperties": false, + "properties": { + "Name": { + "type": "string", + "title": "Name", + "description": "The Name property." + } + }, + "description": "First test resource." + } + } + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json", + "type": "TestModule/ResourceTwo", + "kind": "resource", + "version": "1.0.0", + "capabilities": [ + "get" + ], + "description": "Second test resource.", + "author": "TestAuthor", + "requireAdapter": "Microsoft.Adapter/PowerShell", + "path": "TestModule/TestModule.psd1", + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TestModule/ResourceTwo", + "type": "object", + "required": [], + "additionalProperties": false, + "properties": { + "Value": { + "type": "integer", + "title": "Value", + "description": "The Value property." + } + }, + "description": "Second test resource." + } + } + } + ], + "resources": [ + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/CommandResource", + "version": "0.1.0", + "get": { + "executable": "testcmd", + "args": [ + "get" + ] + }, + "schema": { + "command": { + "executable": "testcmd", + "args": [ + "schema" + ] + } + } + } + ], + "extensions": [ + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/Extension", + "version": "0.1.0", + "description": "A test extension.", + "discover": { + "executable": "testcmd", + "args": [ + "discover" + ] + } + } + ] +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Import-DscAdaptedResourceManifest.Tests.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/Import-DscAdaptedResourceManifest.Tests.ps1 new file mode 100644 index 000000000..fad21789c --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Import-DscAdaptedResourceManifest.Tests.ps1 @@ -0,0 +1,198 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Import-DscAdaptedResourceManifest' { + + BeforeAll { + $modulePath = Join-Path (Join-Path $PSScriptRoot '..') 'Microsoft.PowerShell.DSC.psd1' + Import-Module $modulePath -Force + + $fixturesPath = Join-Path $PSScriptRoot 'Fixtures' + } + + Context 'Importing a full adapted resource manifest' { + + BeforeAll { + $jsonPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + $result = Import-DscAdaptedResourceManifest -Path $jsonPath + } + + It 'Returns a DscAdaptedResourceManifest object' { + $result.GetType().Name | Should -BeExactly 'DscAdaptedResourceManifest' + } + + It 'Imports the schema URI' { + $result.Schema | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + } + + It 'Imports the type' { + $result.Type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Imports the kind' { + $result.Kind | Should -BeExactly 'resource' + } + + It 'Imports the version' { + $result.Version | Should -BeExactly '1.0.0' + } + + It 'Imports capabilities as an array' { + $result.Capabilities | Should -HaveCount 3 + $result.Capabilities | Should -Contain 'get' + $result.Capabilities | Should -Contain 'set' + $result.Capabilities | Should -Contain 'test' + } + + It 'Imports the description' { + $result.Description | Should -BeExactly 'A simple DSC resource for testing.' + } + + It 'Imports the author' { + $result.Author | Should -BeExactly 'Microsoft' + } + + It 'Imports the requireAdapter' { + $result.RequireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' + } + + It 'Imports the path' { + $result.Path | Should -BeExactly 'SimpleResource/SimpleResource.psd1' + } + + It 'Imports the embedded schema' { + $result.ManifestSchema | Should -Not -BeNullOrEmpty + $result.ManifestSchema.Embedded | Should -Not -BeNullOrEmpty + } + + It 'Has correct schema properties' { + $result.ManifestSchema.Embedded['properties'] | Should -Not -BeNullOrEmpty + $result.ManifestSchema.Embedded['properties']['Name'] | Should -Not -BeNullOrEmpty + $result.ManifestSchema.Embedded['properties']['Value'] | Should -Not -BeNullOrEmpty + } + + It 'Has correct required fields in embedded schema' { + $result.ManifestSchema.Embedded['required'] | Should -Contain 'Name' + } + } + + Context 'Importing a minimal adapted resource manifest without optional fields' { + + BeforeAll { + $jsonPath = Join-Path $fixturesPath 'MinimalResource.dsc.adaptedResource.json' + $result = Import-DscAdaptedResourceManifest -Path $jsonPath + } + + It 'Returns a DscAdaptedResourceManifest object' { + $result.GetType().Name | Should -BeExactly 'DscAdaptedResourceManifest' + } + + It 'Imports the type' { + $result.Type | Should -BeExactly 'TestModule/MinimalResource' + } + + It 'Defaults kind to resource when missing' { + $result.Kind | Should -BeExactly 'resource' + } + + It 'Defaults capabilities to empty array when missing' { + $result.Capabilities.Count | Should -Be 0 + } + + It 'Defaults description to empty string when missing' { + $result.Description | Should -BeExactly '' + } + + It 'Defaults author to empty string when missing' { + $result.Author | Should -BeExactly '' + } + + It 'Defaults path to empty string when missing' { + $result.Path | Should -BeExactly '' + } + + It 'Handles schema without embedded wrapper' { + $result.ManifestSchema | Should -Not -BeNullOrEmpty + $result.ManifestSchema.Embedded | Should -Not -BeNullOrEmpty + $result.ManifestSchema.Embedded['properties']['Id'] | Should -Not -BeNullOrEmpty + } + } + + Context 'Pipeline input' { + + It 'Accepts paths from the pipeline' { + $jsonPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + $result = $jsonPath | Import-DscAdaptedResourceManifest + $result.Type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Accepts FileInfo objects from the pipeline' { + $jsonPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + $result = Get-Item $jsonPath | Import-DscAdaptedResourceManifest + $result.Type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Processes multiple files from the pipeline' { + $files = @( + (Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json') + (Join-Path $fixturesPath 'MinimalResource.dsc.adaptedResource.json') + ) + $results = $files | Import-DscAdaptedResourceManifest + $results | Should -HaveCount 2 + $results[0].Type | Should -BeExactly 'SimpleResource/SimpleResource' + $results[1].Type | Should -BeExactly 'TestModule/MinimalResource' + } + } + + Context 'Round-trip fidelity' { + + It 'Produces identical JSON after import and re-export' { + $jsonPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + $original = Get-Content -LiteralPath $jsonPath -Raw | ConvertFrom-Json + $imported = Import-DscAdaptedResourceManifest -Path $jsonPath + $reExported = $imported.ToJson() | ConvertFrom-Json + + $reExported.type | Should -BeExactly $original.type + $reExported.kind | Should -BeExactly $original.kind + $reExported.version | Should -BeExactly $original.version + $reExported.requireAdapter | Should -BeExactly $original.requireAdapter + $reExported.path | Should -BeExactly $original.path + $reExported.author | Should -BeExactly $original.author + $reExported.description | Should -BeExactly $original.description + } + } + + Context 'ToHashtable round-trip' { + + It 'Converts imported manifest to hashtable correctly' { + $jsonPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + $imported = Import-DscAdaptedResourceManifest -Path $jsonPath + $ht = $imported.ToHashtable() + + $ht['type'] | Should -BeExactly 'SimpleResource/SimpleResource' + $ht['version'] | Should -BeExactly '1.0.0' + $ht['requireAdapter'] | Should -BeExactly 'Microsoft.Adapter/PowerShell' + $ht['path'] | Should -BeExactly 'SimpleResource/SimpleResource.psd1' + $ht['schema']['embedded'] | Should -Not -BeNullOrEmpty + } + } + + Context 'Error handling' { + + It 'Throws when the path does not exist' { + { Import-DscAdaptedResourceManifest -Path 'nonexistent.json' } | Should -Throw '*does not exist*' + } + } + + Context 'Integration with New-DscResourceManifest' { + + It 'Imported manifests can be added to a resource manifest list' { + $jsonPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + $imported = Import-DscAdaptedResourceManifest -Path $jsonPath + + $list = $imported | New-DscResourceManifest + $list.AdaptedResources | Should -HaveCount 1 + $list.AdaptedResources[0]['type'] | Should -BeExactly 'SimpleResource/SimpleResource' + } + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Import-DscResourceManifest.Tests.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/Import-DscResourceManifest.Tests.ps1 new file mode 100644 index 000000000..c247ac77d --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Import-DscResourceManifest.Tests.ps1 @@ -0,0 +1,227 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Import-DscResourceManifest' { + + BeforeAll { + $modulePath = Join-Path (Join-Path $PSScriptRoot '..') 'Microsoft.PowerShell.DSC.psd1' + Import-Module $modulePath -Force + + $fixturesPath = Join-Path $PSScriptRoot 'Fixtures' + } + + Context 'Importing a manifest list with all sections' { + + BeforeAll { + $jsonPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $result = Import-DscResourceManifest -Path $jsonPath + } + + It 'Returns a DscResourceManifestList object' { + $result.GetType().Name | Should -BeExactly 'DscResourceManifestList' + } + + It 'Imports two adapted resources' { + $result.AdaptedResources | Should -HaveCount 2 + } + + It 'Imports the first adapted resource type' { + $result.AdaptedResources[0]['type'] | Should -BeExactly 'TestModule/ResourceOne' + } + + It 'Imports the second adapted resource type' { + $result.AdaptedResources[1]['type'] | Should -BeExactly 'TestModule/ResourceTwo' + } + + It 'Imports adapted resource capabilities' { + $result.AdaptedResources[0]['capabilities'] | Should -Contain 'get' + $result.AdaptedResources[0]['capabilities'] | Should -Contain 'set' + } + + It 'Imports adapted resource version' { + $result.AdaptedResources[0]['version'] | Should -BeExactly '1.0.0' + } + + It 'Imports adapted resource requireAdapter' { + $result.AdaptedResources[0]['requireAdapter'] | Should -BeExactly 'Microsoft.Adapter/PowerShell' + } + + It 'Imports adapted resource schema with embedded key' { + $result.AdaptedResources[0]['schema'] | Should -Not -BeNullOrEmpty + $result.AdaptedResources[0]['schema']['embedded'] | Should -Not -BeNullOrEmpty + } + + It 'Imports one command-based resource' { + $result.Resources | Should -HaveCount 1 + } + + It 'Imports the resource type' { + $result.Resources[0]['type'] | Should -BeExactly 'Test/CommandResource' + } + + It 'Imports the resource version' { + $result.Resources[0]['version'] | Should -BeExactly '0.1.0' + } + + It 'Imports the resource get command' { + $result.Resources[0]['get'] | Should -Not -BeNullOrEmpty + $result.Resources[0]['get']['executable'] | Should -BeExactly 'testcmd' + } + + It 'Imports one extension' { + $result.Extensions | Should -HaveCount 1 + } + + It 'Imports the extension type' { + $result.Extensions[0]['type'] | Should -BeExactly 'Test/Extension' + } + + It 'Imports the extension discover command' { + $result.Extensions[0]['discover'] | Should -Not -BeNullOrEmpty + $result.Extensions[0]['discover']['executable'] | Should -BeExactly 'testcmd' + } + } + + Context 'Importing a manifest list with only adapted resources' { + + BeforeAll { + $json = @{ + adaptedResources = @( + @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + type = 'OnlyAdapted/Resource' + version = '2.0.0' + requireAdapter = 'Microsoft.Adapter/PowerShell' + schema = @{ + embedded = @{ + type = 'object' + properties = @{} + } + } + } + ) + } | ConvertTo-Json -Depth 10 + + $tempFile = Join-Path $TestDrive 'adapted-only.dsc.manifests.json' + $json | Set-Content -LiteralPath $tempFile -Encoding utf8 + $result = Import-DscResourceManifest -Path $tempFile + } + + It 'Imports the adapted resource' { + $result.AdaptedResources | Should -HaveCount 1 + $result.AdaptedResources[0]['type'] | Should -BeExactly 'OnlyAdapted/Resource' + } + + It 'Has empty resources list' { + $result.Resources | Should -HaveCount 0 + } + + It 'Has empty extensions list' { + $result.Extensions | Should -HaveCount 0 + } + } + + Context 'Importing a manifest list with only resources' { + + BeforeAll { + $json = @{ + resources = @( + @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'OnlyCommand/Resource' + version = '0.5.0' + get = @{ + executable = 'mycmd' + args = @('get') + } + } + ) + } | ConvertTo-Json -Depth 10 + + $tempFile = Join-Path $TestDrive 'resources-only.dsc.manifests.json' + $json | Set-Content -LiteralPath $tempFile -Encoding utf8 + $result = Import-DscResourceManifest -Path $tempFile + } + + It 'Has empty adapted resources list' { + $result.AdaptedResources | Should -HaveCount 0 + } + + It 'Imports the resource' { + $result.Resources | Should -HaveCount 1 + $result.Resources[0]['type'] | Should -BeExactly 'OnlyCommand/Resource' + } + + It 'Has empty extensions list' { + $result.Extensions | Should -HaveCount 0 + } + } + + Context 'Pipeline input' { + + It 'Accepts paths from the pipeline' { + $jsonPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $result = $jsonPath | Import-DscResourceManifest + $result.AdaptedResources | Should -HaveCount 2 + } + + It 'Accepts FileInfo objects from the pipeline' { + $jsonPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $result = Get-Item $jsonPath | Import-DscResourceManifest + $result.AdaptedResources | Should -HaveCount 2 + } + } + + Context 'Round-trip fidelity' { + + It 'Re-exports JSON that preserves adapted resource types' { + $jsonPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $imported = Import-DscResourceManifest -Path $jsonPath + $reExported = $imported.ToJson() | ConvertFrom-Json + + $reExported.adaptedResources | Should -HaveCount 2 + $reExported.adaptedResources[0].type | Should -BeExactly 'TestModule/ResourceOne' + $reExported.adaptedResources[1].type | Should -BeExactly 'TestModule/ResourceTwo' + } + + It 'Re-exports JSON that preserves resource types' { + $jsonPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $imported = Import-DscResourceManifest -Path $jsonPath + $reExported = $imported.ToJson() | ConvertFrom-Json + + $reExported.resources | Should -HaveCount 1 + $reExported.resources[0].type | Should -BeExactly 'Test/CommandResource' + } + + It 'Re-exports JSON that preserves extension types' { + $jsonPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $imported = Import-DscResourceManifest -Path $jsonPath + $reExported = $imported.ToJson() | ConvertFrom-Json + + $reExported.extensions | Should -HaveCount 1 + $reExported.extensions[0].type | Should -BeExactly 'Test/Extension' + } + } + + Context 'Error handling' { + + It 'Throws when the path does not exist' { + { Import-DscResourceManifest -Path 'nonexistent.json' } | Should -Throw '*does not exist*' + } + } + + Context 'Integration with Import-DscAdaptedResourceManifest' { + + It 'Imported adapted manifests can be added to an imported manifest list' { + $manifestPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $adaptedPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + + $list = Import-DscResourceManifest -Path $manifestPath + $adapted = Import-DscAdaptedResourceManifest -Path $adaptedPath + $list.AddAdaptedResource($adapted) + + $list.AdaptedResources | Should -HaveCount 3 + $list.AdaptedResources[2]['type'] | Should -BeExactly 'SimpleResource/SimpleResource' + } + } +}