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..1c17078fa --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 @@ -0,0 +1,52 @@ +# 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 = 'Provides functionality to assist in Microsoft Desired State Configuration (DSC) operations.' + +# Minimum version of the PowerShell engine required by this module +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' +) + +# 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..9a29cdced --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 @@ -0,0 +1,835 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$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.Adapter/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 + } + + [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 + [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) { + $this.AdaptedResources.Add($Manifest.ToHashtable()) + } + + [void] AddResource([hashtable]$Resource) { + $this.Resources.Add($Resource) + } + + [void] AddExtension([hashtable]$Extension) { + $this.Extensions.Add($Extension) + } + + [string] ToJson() { + $result = [ordered]@{} + + if ($this.AdaptedResources.Count -gt 0) { + $result['adaptedResources'] = @($this.AdaptedResources) + } + + if ($this.Resources.Count -gt 0) { + $result['resources'] = @($this.Resources) + } + + if ($this.Extensions.Count -gt 0) { + $result['extensions'] = @($this.Extensions) + } + + return $result | ConvertTo-Json -Depth 15 + } +} + +#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 -LiteralPath $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 + } + + $fileName = [System.IO.Path]::GetFileName($resolvedPath) + + return @{ + ModuleName = $moduleName + Version = '0.0.1' + Author = '' + Description = '' + ScriptPath = [string]$resolvedPath + Psd1Path = "$moduleName/$fileName" + Directory = $directory + } +} + +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 + +<# + .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 + } + } +} + +<# + .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 + } +} + +<# + .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/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.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/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/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' + } + } +} 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..6bac80e10 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/New-DscAdaptedResourceManifest.Tests.ps1 @@ -0,0 +1,342 @@ +# 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.Adapter/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 '' + } + + It 'Sets path to the actual script file' { + $result.Path | Should -BeExactly 'StandaloneResource/StandaloneResource.ps1' + } + } + + 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.Adapter/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 + } + } +} 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..a94ce661e --- /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.Adapter/PowerShell' + $parsed.adaptedResources[0].schema.embedded | Should -Not -BeNullOrEmpty + } + } +}