From 0eb8cb855db9c19b5ca52cea91699cec39b73cb1 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Mon, 25 May 2026 16:13:59 +0200 Subject: [PATCH 1/7] Initial prototype commands --- .../005.MicrosoftDscAuthoringTypes.ps1 | 277 ++++++++++++++++ source/Private/Add-AstProperty.ps1 | 9 +- .../Private/ConvertFrom-CommentBasedHelp.ps1 | 6 +- .../ConvertTo-AdaptedResourceManifest.ps1 | 8 +- .../ConvertTo-DscAuthoringHashtable.ps1 | 93 ++++++ .../Private/ConvertTo-DscExpressionText.ps1 | 182 +++++++++++ ...onvertTo-DscPropertyOverrideFromConfig.ps1 | 9 +- ...ConvertTo-DscResourceAuthoringMetadata.ps1 | 187 +++++++++++ source/Private/ConvertTo-Hashtable.ps1 | 18 +- source/Private/ConvertTo-JsonSchemaType.ps1 | 4 +- source/Private/Get-ClassCommentBasedHelp.ps1 | 9 +- source/Private/Get-DscAdapterCachePath.ps1 | 58 ++++ .../Get-DscResourceAuthoringTypeName.ps1 | 54 ++++ source/Private/Get-DscResourceCapability.ps1 | 8 +- source/Private/Get-DscResourceProperty.ps1 | 6 +- .../Private/Get-DscResourceTypeDefinition.ps1 | 6 +- .../New-DscResourceAuthoringTypeSource.ps1 | 80 +++++ source/Private/New-EmbeddedJsonSchema.ps1 | 8 +- .../Private/Register-DscResourceFunction.ps1 | 137 ++++++++ ...olve-DscResourceAuthoringSchemaCommand.ps1 | 134 ++++++++ source/Private/Resolve-ModuleInfo.ps1 | 11 +- .../Private/Test-IsEcmaCompatiblePattern.ps1 | 8 +- .../Export-DscConfigurationDocument.ps1 | 90 ++++++ .../Import-DscAdaptedResourceManifest.ps1 | 8 +- source/Public/Import-DscAuthoringResource.ps1 | 132 ++++++++ .../Import-DscResourceAuthoringMetadata.ps1 | 193 +++++++++++ source/Public/Import-DscResourceManifest.ps1 | 7 +- .../Public/New-DscAdaptedResourceManifest.ps1 | 20 +- .../Public/New-DscConfigurationDocument.ps1 | 80 +++++ source/Public/New-DscExpression.ps1 | 34 ++ source/Public/New-DscPropertyOverride.ps1 | 4 +- source/Public/New-DscResourceManifest.ps1 | 7 +- .../Register-DscResourceAuthoringType.ps1 | 104 ++++++ .../Update-DscAdaptedResourceManifest.ps1 | 9 +- source/WikiSource/Command-Reference.md | 47 ++- source/WikiSource/Configuration-Authoring.md | 301 ++++++++++++++++++ source/WikiSource/Getting-Started.md | 35 ++ source/WikiSource/Home.md | 32 +- .../Fixtures/AdapterCache/PSAdapterCache.json | 38 +++ .../simple-tool.dsc.resource.json | 49 +++ .../ConvertTo-DscAuthoringHashtable.Tests.ps1 | 63 ++++ .../ConvertTo-DscExpressionText.Tests.ps1 | 41 +++ ...tTo-DscResourceAuthoringMetadata.Tests.ps1 | 78 +++++ .../Private/ConvertTo-Hashtable.Tests.ps1 | 7 + .../Private/Get-DscAdapterCachePath.Tests.ps1 | 41 +++ ...Get-DscResourceAuthoringTypeName.Tests.ps1 | 31 ++ ...w-DscResourceAuthoringTypeSource.Tests.ps1 | 45 +++ .../Register-DscResourceFunction.Tests.ps1 | 58 ++++ ...scResourceAuthoringSchemaCommand.Tests.ps1 | 87 +++++ .../Export-DscConfigurationDocument.Tests.ps1 | 36 +++ .../Import-DscAuthoringResource.Tests.ps1 | 57 ++++ ...ort-DscResourceAuthoringMetadata.Tests.ps1 | 120 +++++++ .../New-DscConfigurationDocument.Tests.ps1 | 86 +++++ tests/Unit/Public/New-DscExpression.Tests.ps1 | 22 ++ ...egister-DscResourceAuthoringType.Tests.ps1 | 70 ++++ 55 files changed, 3249 insertions(+), 95 deletions(-) create mode 100644 source/Classes/005.MicrosoftDscAuthoringTypes.ps1 create mode 100644 source/Private/ConvertTo-DscAuthoringHashtable.ps1 create mode 100644 source/Private/ConvertTo-DscExpressionText.ps1 create mode 100644 source/Private/ConvertTo-DscResourceAuthoringMetadata.ps1 create mode 100644 source/Private/Get-DscAdapterCachePath.ps1 create mode 100644 source/Private/Get-DscResourceAuthoringTypeName.ps1 create mode 100644 source/Private/New-DscResourceAuthoringTypeSource.ps1 create mode 100644 source/Private/Register-DscResourceFunction.ps1 create mode 100644 source/Private/Resolve-DscResourceAuthoringSchemaCommand.ps1 create mode 100644 source/Public/Export-DscConfigurationDocument.ps1 create mode 100644 source/Public/Import-DscAuthoringResource.ps1 create mode 100644 source/Public/Import-DscResourceAuthoringMetadata.ps1 create mode 100644 source/Public/New-DscConfigurationDocument.ps1 create mode 100644 source/Public/New-DscExpression.ps1 create mode 100644 source/Public/Register-DscResourceAuthoringType.ps1 create mode 100644 source/WikiSource/Configuration-Authoring.md create mode 100644 tests/Unit/Fixtures/AdapterCache/PSAdapterCache.json create mode 100644 tests/Unit/Fixtures/CommandResource/simple-tool.dsc.resource.json create mode 100644 tests/Unit/Private/ConvertTo-DscAuthoringHashtable.Tests.ps1 create mode 100644 tests/Unit/Private/ConvertTo-DscExpressionText.Tests.ps1 create mode 100644 tests/Unit/Private/ConvertTo-DscResourceAuthoringMetadata.Tests.ps1 create mode 100644 tests/Unit/Private/Get-DscAdapterCachePath.Tests.ps1 create mode 100644 tests/Unit/Private/Get-DscResourceAuthoringTypeName.Tests.ps1 create mode 100644 tests/Unit/Private/New-DscResourceAuthoringTypeSource.Tests.ps1 create mode 100644 tests/Unit/Private/Register-DscResourceFunction.Tests.ps1 create mode 100644 tests/Unit/Private/Resolve-DscResourceAuthoringSchemaCommand.Tests.ps1 create mode 100644 tests/Unit/Public/Export-DscConfigurationDocument.Tests.ps1 create mode 100644 tests/Unit/Public/Import-DscAuthoringResource.Tests.ps1 create mode 100644 tests/Unit/Public/Import-DscResourceAuthoringMetadata.Tests.ps1 create mode 100644 tests/Unit/Public/New-DscConfigurationDocument.Tests.ps1 create mode 100644 tests/Unit/Public/New-DscExpression.Tests.ps1 create mode 100644 tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 diff --git a/source/Classes/005.MicrosoftDscAuthoringTypes.ps1 b/source/Classes/005.MicrosoftDscAuthoringTypes.ps1 new file mode 100644 index 0000000..a5417c3 --- /dev/null +++ b/source/Classes/005.MicrosoftDscAuthoringTypes.ps1 @@ -0,0 +1,277 @@ +<# + .SYNOPSIS + Defines the core Microsoft.DSC authoring types. +#> +if (-not ('Microsoft.DSC.Configuration' -as [System.Type])) +{ + $microsoftDscAuthoringTypes = @' +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; + +namespace Microsoft.DSC +{ + public sealed class Expression + { + public string Value { get; set; } + + public Expression() + { + } + + public Expression(string value) + { + this.Value = value; + } + + public override string ToString() + { + return this.Value; + } + + public static implicit operator string(Expression expression) + { + return expression == null ? null : expression.Value; + } + } + + public sealed class Directives + { + public string SecurityContext { get; set; } + + public Hashtable ToHashtable() + { + Hashtable result = new Hashtable(StringComparer.OrdinalIgnoreCase); + + if (!String.IsNullOrEmpty(this.SecurityContext)) + { + result["securityContext"] = this.SecurityContext; + } + + return result; + } + } + + public sealed class ResourceDirectives + { + public string RequireAdapter { get; set; } + + public Hashtable ToHashtable() + { + Hashtable result = new Hashtable(StringComparer.OrdinalIgnoreCase); + + if (!String.IsNullOrEmpty(this.RequireAdapter)) + { + result["requireAdapter"] = this.RequireAdapter; + } + + return result; + } + } + + public sealed class Resource + { + public string Name { get; set; } + public string Type { get; set; } + public object Properties { get; set; } + public string[] DependsOn { get; set; } + public ResourceDirectives Directives { get; set; } + public Hashtable Metadata { get; set; } + + public Resource() + { + this.Metadata = new Hashtable(StringComparer.OrdinalIgnoreCase); + } + + public Hashtable ToHashtable() + { + Hashtable result = new Hashtable(StringComparer.OrdinalIgnoreCase); + + if (!String.IsNullOrEmpty(this.Name)) + { + result["name"] = this.Name; + } + + if (!String.IsNullOrEmpty(this.Type)) + { + result["type"] = this.Type; + } + + if (this.Properties != null) + { + result["properties"] = AuthoringData.Normalize(this.Properties); + } + + if (this.DependsOn != null && this.DependsOn.Length > 0) + { + result["dependsOn"] = this.DependsOn; + } + + if (this.Directives != null) + { + Hashtable directives = this.Directives.ToHashtable(); + + if (directives.Count > 0) + { + result["directives"] = directives; + } + } + + if (this.Metadata != null && this.Metadata.Count > 0) + { + result["metadata"] = AuthoringData.Normalize(this.Metadata); + } + + return result; + } + } + + public sealed class Configuration + { + public string Schema { get; set; } + public object Parameters { get; set; } + public Directives Directives { get; set; } + public List Resources { get; set; } + public Hashtable Metadata { get; set; } + + public Configuration() + { + this.Schema = "https://aka.ms/dsc/schemas/v3/bundled/config/document.json"; + this.Resources = new List(); + this.Metadata = new Hashtable(StringComparer.OrdinalIgnoreCase); + } + + public Hashtable ToHashtable() + { + Hashtable result = new Hashtable(StringComparer.OrdinalIgnoreCase); + + if (!String.IsNullOrEmpty(this.Schema)) + { + result["$schema"] = this.Schema; + } + + if (this.Parameters != null) + { + result["parameters"] = AuthoringData.Normalize(this.Parameters); + } + + if (this.Directives != null) + { + Hashtable directives = this.Directives.ToHashtable(); + + if (directives.Count > 0) + { + result["directives"] = directives; + } + } + + if (this.Resources != null && this.Resources.Count > 0) + { + ArrayList resources = new ArrayList(); + + foreach (Resource resource in this.Resources) + { + resources.Add(resource.ToHashtable()); + } + + result["resources"] = resources; + } + + if (this.Metadata != null && this.Metadata.Count > 0) + { + result["metadata"] = AuthoringData.Normalize(this.Metadata); + } + + return result; + } + } + + internal static class AuthoringData + { + internal static object Normalize(object value) + { + if (value == null) + { + return null; + } + + if (value is string) + { + return value; + } + + Expression expression = value as Expression; + + if (expression != null) + { + return expression.Value; + } + + MethodInfo toHashtable = value.GetType().GetMethod("ToHashtable", Type.EmptyTypes); + + if (toHashtable != null && toHashtable.DeclaringType != typeof(Hashtable)) + { + return toHashtable.Invoke(value, null); + } + + IDictionary dictionary = value as IDictionary; + + if (dictionary != null) + { + Hashtable result = new Hashtable(StringComparer.OrdinalIgnoreCase); + + foreach (DictionaryEntry entry in dictionary) + { + result[entry.Key] = Normalize(entry.Value); + } + + return result; + } + + IEnumerable enumerable = value as IEnumerable; + + if (enumerable != null) + { + ArrayList result = new ArrayList(); + + foreach (object item in enumerable) + { + result.Add(Normalize(item)); + } + + return result; + } + + Type valueType = value.GetType(); + + if (valueType.IsPrimitive || value is decimal || value is DateTime || value is Guid) + { + return value; + } + + Hashtable properties = new Hashtable(StringComparer.OrdinalIgnoreCase); + + foreach (PropertyInfo property in valueType.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (!property.CanRead || property.GetIndexParameters().Length > 0) + { + continue; + } + + object propertyValue = property.GetValue(value, null); + + if (propertyValue != null) + { + properties[property.Name] = Normalize(propertyValue); + } + } + + return properties; + } + } +} +'@ + + Add-Type -TypeDefinition $microsoftDscAuthoringTypes -Language CSharp +} \ No newline at end of file diff --git a/source/Private/Add-AstProperty.ps1 b/source/Private/Add-AstProperty.ps1 index ed01f55..68ad300 100644 --- a/source/Private/Add-AstProperty.ps1 +++ b/source/Private/Add-AstProperty.ps1 @@ -3,11 +3,10 @@ Recursively collects DSC properties from a class type definition AST. .DESCRIPTION - Walks the base type chain of the supplied type definition AST and adds a - hashtable describing each property decorated with the [DscProperty()] - attribute to the supplied list. Properties from base classes are added - first so that derived class properties override them when the list is - consumed. + The function Add-AstProperty walks the base type chain of the supplied type definition AST + and adds a hashtable describing each property decorated with the [DscProperty()] attribute + to the supplied list. Properties from base classes are added first so that derived class + properties override them when the list is consumed. .PARAMETER AllTypeDefinitions All type definition AST nodes discovered in the script. Used to resolve diff --git a/source/Private/ConvertFrom-CommentBasedHelp.ps1 b/source/Private/ConvertFrom-CommentBasedHelp.ps1 index 647dd73..f68d27d 100644 --- a/source/Private/ConvertFrom-CommentBasedHelp.ps1 +++ b/source/Private/ConvertFrom-CommentBasedHelp.ps1 @@ -3,9 +3,9 @@ Parses a comment-based help block into a structured hashtable. .DESCRIPTION - Extracts the .SYNOPSIS, .DESCRIPTION and .PARAMETER content from a - PowerShell block comment and returns the values as a hashtable with - Synopsis, Description and Parameters keys. + The function ConvertFrom-CommentBasedHelp extracts the .SYNOPSIS, .DESCRIPTION and + .PARAMETER content from a PowerShell block comment and returns the values as a hashtable + with Synopsis, Description and Parameters keys. .PARAMETER CommentText The raw text of a PowerShell block comment, including the surrounding diff --git a/source/Private/ConvertTo-AdaptedResourceManifest.ps1 b/source/Private/ConvertTo-AdaptedResourceManifest.ps1 index 57e501e..a15d1a4 100644 --- a/source/Private/ConvertTo-AdaptedResourceManifest.ps1 +++ b/source/Private/ConvertTo-AdaptedResourceManifest.ps1 @@ -3,10 +3,10 @@ Hydrates a hashtable into a DscAdaptedResourceManifest object. .DESCRIPTION - Maps the keys of an adapted resource manifest hashtable (such as the - output of ConvertFrom-Json followed by ConvertTo-Hashtable) onto the - properties of a new DscAdaptedResourceManifest instance, including the - nested embedded JSON schema. + The function ConvertTo-AdaptedResourceManifest maps the keys of an adapted resource + manifest hashtable (such as the output of ConvertFrom-Json followed by ConvertTo-Hashtable) + onto the properties of a new DscAdaptedResourceManifest instance, including the nested + embedded JSON schema. .PARAMETER Hashtable The hashtable representation of an adapted resource manifest document. diff --git a/source/Private/ConvertTo-DscAuthoringHashtable.ps1 b/source/Private/ConvertTo-DscAuthoringHashtable.ps1 new file mode 100644 index 0000000..de62644 --- /dev/null +++ b/source/Private/ConvertTo-DscAuthoringHashtable.ps1 @@ -0,0 +1,93 @@ +<# + .SYNOPSIS + Converts authoring objects to hashtables for serialization. + + .DESCRIPTION + The function ConvertTo-DscAuthoringHashtable recursively converts Microsoft DSC authoring + objects, expressions, dictionaries, custom objects, and enumerable values into plain + PowerShell objects that can be serialized into a DSC configuration document. + + Objects with a parameterless ToHashtable() method are converted by calling that + method. Microsoft.DSC.Expression objects are serialized as their expression text. + + .PARAMETER InputObject + The object or object graph to normalize for serialization. Null input is allowed + and returns null. + + .EXAMPLE + ConvertTo-DscAuthoringHashtable -InputObject $configuration + + Converts a Microsoft.DSC.Configuration instance into a hashtable graph suitable + for ConvertTo-Json. + + .OUTPUTS + Returns a hashtable, array, scalar value, or null, depending on the input object. +#> +function ConvertTo-DscAuthoringHashtable +{ + [CmdletBinding()] + [OutputType([System.Collections.Hashtable])] + [OutputType([System.Object[]])] + [OutputType([System.Object])] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [AllowNull()] + [System.Object] + $InputObject + ) + + process + { + if ($null -eq $InputObject) + { + return $null + } + + if ($InputObject -is [Microsoft.DSC.Expression]) + { + return $InputObject.Value + } + + $toHashtable = $InputObject.GetType().GetMethod('ToHashtable', [System.Type]::EmptyTypes) + if ($null -ne $toHashtable) + { + return $toHashtable.Invoke($InputObject, $null) + } + + if ($InputObject -is [System.Collections.IDictionary]) + { + $result = [ordered]@{} + foreach ($key in $InputObject.Keys) + { + $result[$key] = ConvertTo-DscAuthoringHashtable -InputObject $InputObject[$key] + } + + return $result + } + + if ($InputObject -is [PSCustomObject]) + { + $result = [ordered]@{} + foreach ($property in $InputObject.PSObject.Properties) + { + $result[$property.Name] = ConvertTo-DscAuthoringHashtable -InputObject $property.Value + } + + return $result + } + + if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [System.String]) + { + $items = [System.Collections.Generic.List[System.Object]]::new() + foreach ($item in $InputObject) + { + $items.Add((ConvertTo-DscAuthoringHashtable -InputObject $item)) + } + + return @($items) + } + + return $InputObject + } +} \ No newline at end of file diff --git a/source/Private/ConvertTo-DscExpressionText.ps1 b/source/Private/ConvertTo-DscExpressionText.ps1 new file mode 100644 index 0000000..d8ae4ad --- /dev/null +++ b/source/Private/ConvertTo-DscExpressionText.ps1 @@ -0,0 +1,182 @@ +<# + .SYNOPSIS + Converts a constrained PowerShell AST into a DSC expression body. + + .DESCRIPTION + The function ConvertTo-DscExpressionText walks the PowerShell AST from a script block + supplied to New-DscExpression and transpiles supported syntax into the inner DSC expression + text. The helper supports string constants, numeric and Boolean constants, variables as + parameter references, Invoke-DscFunction command calls, parentheses, and the plus operator + as concat(). + + Unsupported syntax raises a terminating error with the original AST text as the + target object so callers can report actionable authoring errors. + + .PARAMETER Ast + The PowerShell AST node to convert into DSC expression text. + + .EXAMPLE + ConvertTo-DscExpressionText -Ast $ScriptBlock.Ast + + Converts a supported script block AST into text such as + concat(systemRoot(), '\Windows\System32'). + + .OUTPUTS + Returns the DSC expression body without the surrounding square brackets. +#> +function ConvertTo-DscExpressionText +{ + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.Management.Automation.Language.Ast] + $Ast + ) + + if ($Ast -is [System.Management.Automation.Language.ScriptBlockAst]) + { + if ($Ast.EndBlock.Statements.Count -ne 1) + { + $exception = [System.NotSupportedException]::new('DSC expressions must contain exactly one statement.') + $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new($exception, 'DscExpressionStatementCount', [System.Management.Automation.ErrorCategory]::InvalidArgument, $Ast.Extent.Text)) + } + + return ConvertTo-DscExpressionText -Ast $Ast.EndBlock.Statements[0] + } + + if ($Ast -is [System.Management.Automation.Language.PipelineAst]) + { + if ($Ast.PipelineElements.Count -ne 1) + { + $exception = [System.NotSupportedException]::new('Pipeline expressions are not supported in DSC expressions.') + $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new($exception, 'DscExpressionPipeline', [System.Management.Automation.ErrorCategory]::InvalidArgument, $Ast.Extent.Text)) + } + + return ConvertTo-DscExpressionText -Ast $Ast.PipelineElements[0] + } + + if ($Ast -is [System.Management.Automation.Language.CommandExpressionAst]) + { + return ConvertTo-DscExpressionText -Ast $Ast.Expression + } + + if ($Ast -is [System.Management.Automation.Language.ParenExpressionAst]) + { + return ConvertTo-DscExpressionText -Ast $Ast.Pipeline + } + + if ($Ast -is [System.Management.Automation.Language.BinaryExpressionAst]) + { + if ($Ast.Operator -ne [System.Management.Automation.Language.TokenKind]::Plus) + { + $exception = [System.NotSupportedException]::new("The '$($Ast.Operator)' operator is not supported in DSC expressions.") + $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new($exception, 'DscExpressionOperator', [System.Management.Automation.ErrorCategory]::InvalidArgument, $Ast.Extent.Text)) + } + + $items = [System.Collections.Generic.List[System.String]]::new() + + foreach ($side in @($Ast.Left, $Ast.Right)) + { + $text = ConvertTo-DscExpressionText -Ast $side + if ($text -match '^concat\((?.*)\)$') + { + foreach ($item in $Matches['inner'] -split ',\s*') + { + $items.Add($item) + } + } + else + { + $items.Add($text) + } + } + + return "concat($($items -join ', '))" + } + + if ($Ast -is [System.Management.Automation.Language.StringConstantExpressionAst]) + { + return "'$($Ast.Value -replace '''', '''''')'" + } + + if ($Ast -is [System.Management.Automation.Language.ConstantExpressionAst]) + { + if ($Ast.Value -is [System.Boolean]) + { + return $Ast.Value.ToString().ToLowerInvariant() + } + + return [System.Convert]::ToString($Ast.Value, [System.Globalization.CultureInfo]::InvariantCulture) + } + + if ($Ast -is [System.Management.Automation.Language.VariableExpressionAst]) + { + return "parameters('$($Ast.VariablePath.UserPath -replace '''', '''''')')" + } + + if ($Ast -is [System.Management.Automation.Language.ConvertExpressionAst]) + { + return ConvertTo-DscExpressionText -Ast $Ast.Child + } + + if ($Ast -is [System.Management.Automation.Language.CommandAst]) + { + $commandName = $Ast.GetCommandName() + + if ($commandName -ne 'Invoke-DscFunction') + { + $exception = [System.NotSupportedException]::new("Only Invoke-DscFunction command expressions are supported. Found '$commandName'.") + $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new($exception, 'DscExpressionCommand', [System.Management.Automation.ErrorCategory]::InvalidArgument, $Ast.Extent.Text)) + } + + $functionName = $null + $arguments = [System.Collections.Generic.List[System.String]]::new() + + for ($index = 1; $index -lt $Ast.CommandElements.Count; $index++) + { + $element = $Ast.CommandElements[$index] + + if ($element -is [System.Management.Automation.Language.CommandParameterAst]) + { + if ($element.ParameterName -eq 'Name') + { + $index++ + $functionName = $Ast.CommandElements[$index].Extent.Text.Trim('''', '"') + continue + } + + if ($element.ParameterName -eq 'Argument') + { + $index++ + $argumentAst = $Ast.CommandElements[$index] + + if ($argumentAst -is [System.Management.Automation.Language.ArrayLiteralAst]) + { + foreach ($nestedArgument in $argumentAst.Elements) + { + $arguments.Add((ConvertTo-DscExpressionText -Ast $nestedArgument)) + } + } + else + { + $arguments.Add((ConvertTo-DscExpressionText -Ast $argumentAst)) + } + continue + } + } + } + + if ([System.String]::IsNullOrWhiteSpace($functionName)) + { + $exception = [System.ArgumentException]::new('Invoke-DscFunction requires the -Name parameter in DSC expressions.') + $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new($exception, 'DscExpressionFunctionName', [System.Management.Automation.ErrorCategory]::InvalidArgument, $Ast.Extent.Text)) + } + + return "$functionName($($arguments -join ', '))" + } + + $notSupported = [System.NotSupportedException]::new("The expression syntax '$($Ast.GetType().Name)' is not supported.") + $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new($notSupported, 'DscExpressionSyntax', [System.Management.Automation.ErrorCategory]::InvalidArgument, $Ast.Extent.Text)) +} \ No newline at end of file diff --git a/source/Private/ConvertTo-DscPropertyOverrideFromConfig.ps1 b/source/Private/ConvertTo-DscPropertyOverrideFromConfig.ps1 index a613478..1261e31 100644 --- a/source/Private/ConvertTo-DscPropertyOverrideFromConfig.ps1 +++ b/source/Private/ConvertTo-DscPropertyOverrideFromConfig.ps1 @@ -3,10 +3,11 @@ Converts property override configuration entries into DscPropertyOverride objects. .DESCRIPTION - Maps each hashtable entry from the build configuration PropertyOverrides section - into a DscPropertyOverride object understood by Update-DscAdaptedResourceManifest. - Each entry must contain at least a 'Name' key. Supported optional keys are - 'Description', 'Title', 'JsonSchema', 'RemoveKeys', and 'Required'. + The function ConvertTo-DscPropertyOverrideFromConfig maps each hashtable entry from the + build configuration PropertyOverrides section into a DscPropertyOverride object understood + by Update-DscAdaptedResourceManifest. Each entry must contain at least a 'Name' key. + Supported optional keys are 'Description', 'Title', 'JsonSchema', 'RemoveKeys', and + 'Required'. This function must only be called after DscResource.Authoring has been imported into the session. diff --git a/source/Private/ConvertTo-DscResourceAuthoringMetadata.ps1 b/source/Private/ConvertTo-DscResourceAuthoringMetadata.ps1 new file mode 100644 index 0000000..db922bb --- /dev/null +++ b/source/Private/ConvertTo-DscResourceAuthoringMetadata.ps1 @@ -0,0 +1,187 @@ +<# + .SYNOPSIS + Normalizes DSC resource manifests and adapter cache entries into authoring metadata. + + .DESCRIPTION + The function ConvertTo-DscResourceAuthoringMetadata converts command-based DSC resource + manifests and PowerShell adapter cache entries into a common metadata shape consumed by + Register-DscResourceAuthoringType. Command resource manifests contribute their embedded + JSON schema directly. Adapter cache entries are converted into a schema from cached DSC + resource property information. + + .PARAMETER Manifest + A hashtable representation of a DSC command resource manifest. + + .PARAMETER AdapterCacheEntry + A resource entry from a PowerShell DSC adapter cache file. + + .PARAMETER SourcePath + The path to the manifest or adapter cache file that supplied the metadata. + + .PARAMETER RequireAdapter + The adapter type name to assign to metadata converted from an adapter cache entry. + + .PARAMETER ResolveSchemaCommand + Runs schema commands from resource manifests when an embedded schema is not available. + + .EXAMPLE + ConvertTo-DscResourceAuthoringMetadata -Manifest $manifest -SourcePath $path + + Converts a command resource manifest into normalized authoring metadata. + + .EXAMPLE + ConvertTo-DscResourceAuthoringMetadata -AdapterCacheEntry $entry -RequireAdapter 'Microsoft.Adapter/PowerShell' + + Converts one adapter cache entry into normalized authoring metadata. + + .OUTPUTS + Returns a PSCustomObject with Type, Kind, Version, Description, RequireAdapter, + Capabilities, Schema, SourcePath, and Manifest properties. +#> +function ConvertTo-DscResourceAuthoringMetadata +{ + [CmdletBinding(DefaultParameterSetName = 'Manifest')] + [OutputType([System.Management.Automation.PSCustomObject])] + param + ( + [Parameter(Mandatory = $true, ParameterSetName = 'Manifest')] + [System.Collections.IDictionary] + $Manifest, + + [Parameter(Mandatory = $true, ParameterSetName = 'AdapterCacheEntry')] + [System.Object] + $AdapterCacheEntry, + + [Parameter(ParameterSetName = 'Manifest')] + [Parameter(ParameterSetName = 'AdapterCacheEntry')] + [System.String] + $SourcePath, + + [Parameter(ParameterSetName = 'AdapterCacheEntry')] + [System.String] + $RequireAdapter, + + [Parameter(ParameterSetName = 'Manifest')] + [System.Management.Automation.SwitchParameter] + $ResolveSchemaCommand + ) + + if ($PSCmdlet.ParameterSetName -eq 'Manifest') + { + Write-Debug "Normalizing DSC resource manifest '$($Manifest['type'])' from '$SourcePath'." + $schema = $null + if ($Manifest.Contains('schema') -and $Manifest['schema'] -is [System.Collections.IDictionary] -and $Manifest['schema'].Contains('embedded')) + { + Write-Debug "Using embedded schema for DSC resource '$($Manifest['type'])'." + $schema = $Manifest['schema']['embedded'] + } + elseif ($ResolveSchemaCommand.IsPresent) + { + Write-Debug "Attempting to resolve schema command for DSC resource '$($Manifest['type'])'." + $schema = Resolve-DscResourceAuthoringSchemaCommand -Manifest $Manifest -SourcePath $SourcePath + } + else + { + Write-Debug "No embedded schema found for DSC resource '$($Manifest['type'])'. Schema command resolution is disabled." + } + + $capabilities = [System.Collections.Generic.List[System.String]]::new() + foreach ($capabilityName in @('get', 'set', 'test', 'delete', 'export', 'whatIf', 'validate')) + { + if ($Manifest.Contains($capabilityName)) + { + $capabilities.Add($capabilityName) + } + } + + if ($Manifest.Contains('capabilities')) + { + foreach ($capability in @($Manifest['capabilities'])) + { + if ($capability -notin $capabilities) + { + $capabilities.Add([System.String]$capability) + } + } + } + + Write-Debug "Normalized DSC resource '$($Manifest['type'])' with $($capabilities.Count) discovered capability value(s). Schema available: $($null -ne $schema)." + + return [PSCustomObject]@{ + Type = [System.String]$Manifest['type'] + Kind = if ($Manifest.Contains('kind')) { [System.String]$Manifest['kind'] } else { 'resource' } + Version = if ($Manifest.Contains('version')) { [System.String]$Manifest['version'] } else { $null } + Description = if ($Manifest.Contains('description')) { [System.String]$Manifest['description'] } else { $null } + RequireAdapter = if ($Manifest.Contains('requireAdapter')) { [System.String]$Manifest['requireAdapter'] } else { $null } + Capabilities = [System.String[]]($capabilities | Select-Object -Unique) + Schema = $schema + SourcePath = $SourcePath + Manifest = $Manifest + } + } + + $resourceInfo = $AdapterCacheEntry.DscResourceInfo + if ($null -eq $resourceInfo) + { + Write-Debug "Skipping adapter cache entry '$($AdapterCacheEntry.Type)' because DscResourceInfo is null." + return + } + + Write-Debug "Normalizing DSC adapter cache entry '$($AdapterCacheEntry.Type)' from '$SourcePath'." + + $properties = [ordered]@{} + $required = [System.Collections.Generic.List[System.String]]::new() + + foreach ($property in @($resourceInfo.Properties)) + { + $jsonType = switch -Regex ([System.String]$property.PropertyType) + { + 'Boolean|bool' { 'boolean'; break } + 'Byte|Int16|Int32|Int64|UInt16|UInt32|UInt64|SInt|UInt' { 'integer'; break } + 'Single|Double|Decimal' { 'number'; break } + 'String' { 'string'; break } + 'Hashtable|Dictionary|Object' { 'object'; break } + '\[\]|Array' { 'array'; break } + default { 'string' } + } + + $propertySchema = [ordered]@{ type = $jsonType } + if ($property.Values -and $property.Values.Count -gt 0) + { + $propertySchema['enum'] = [System.String[]]$property.Values + } + + $properties[[System.String]$property.Name] = $propertySchema + + if ($property.IsMandatory) + { + $required.Add([System.String]$property.Name) + } + } + + Write-Debug "Built adapter cache schema for '$($AdapterCacheEntry.Type)' with $($properties.Count) propertie(s)." + + $schema = [ordered]@{ + '$schema' = $script:JsonSchemaUri + type = 'object' + additionalProperties = $false + properties = $properties + } + + if ($required.Count -gt 0) + { + $schema['required'] = [System.String[]]$required + } + + return [PSCustomObject]@{ + Type = [System.String]$AdapterCacheEntry.Type + Kind = 'resource' + Version = if ($resourceInfo.Version) { [System.String]$resourceInfo.Version } else { $null } + Description = if ($resourceInfo.FriendlyName) { [System.String]$resourceInfo.FriendlyName } else { [System.String]$resourceInfo.Name } + RequireAdapter = $RequireAdapter + Capabilities = [System.String[]]$resourceInfo.Capabilities + Schema = $schema + SourcePath = $SourcePath + Manifest = $null + } +} \ No newline at end of file diff --git a/source/Private/ConvertTo-Hashtable.ps1 b/source/Private/ConvertTo-Hashtable.ps1 index 48ce530..e41a7fe 100644 --- a/source/Private/ConvertTo-Hashtable.ps1 +++ b/source/Private/ConvertTo-Hashtable.ps1 @@ -3,14 +3,14 @@ Recursively converts PSCustomObject and array structures to hashtables. .DESCRIPTION - Walks the supplied object and converts every PSCustomObject into an - ordered hashtable, every IDictionary into an ordered hashtable, and - every IList into an array, recursing into the values. Scalar values - are returned unchanged. Useful for normalizing the output of - ConvertFrom-Json before consuming it as hashtables. + The function ConvertTo-Hashtable walks the supplied object and converts every PSCustomObject + into an ordered hashtable, every IDictionary into an ordered hashtable, and every IList into + an array, recursing into the values. Scalar values are returned unchanged. Useful for + normalizing the output of ConvertFrom-Json before consuming it as hashtables. .PARAMETER InputObject - The object or structure to recursively convert to a hashtable. + The object or structure to recursively convert to a hashtable. Null input is allowed + and returns null. .EXAMPLE $parsed = ConvertFrom-Json -InputObject $jsonContent @@ -28,10 +28,16 @@ function ConvertTo-Hashtable param ( [Parameter(Mandatory = $true)] + [AllowNull()] [object] $InputObject ) + if ($null -eq $InputObject) + { + return $null + } + if ($InputObject -is [System.Collections.IDictionary]) { $result = [ordered]@{} diff --git a/source/Private/ConvertTo-JsonSchemaType.ps1 b/source/Private/ConvertTo-JsonSchemaType.ps1 index ab5bc3c..02e5105 100644 --- a/source/Private/ConvertTo-JsonSchemaType.ps1 +++ b/source/Private/ConvertTo-JsonSchemaType.ps1 @@ -3,8 +3,8 @@ Converts a PowerShell type name to its JSON Schema type definition. .DESCRIPTION - Maps a PowerShell type name (such as 'string', 'int', 'bool', 'datetime' - or an array form like 'string[]') to a hashtable describing the + The function ConvertTo-JsonSchemaType maps a PowerShell type name (such as 'string', 'int', + 'bool', 'datetime' or an array form like 'string[]') to a hashtable describing the equivalent JSON Schema type. Unknown types fall back to 'string'. .PARAMETER TypeName diff --git a/source/Private/Get-ClassCommentBasedHelp.ps1 b/source/Private/Get-ClassCommentBasedHelp.ps1 index bda696d..352a948 100644 --- a/source/Private/Get-ClassCommentBasedHelp.ps1 +++ b/source/Private/Get-ClassCommentBasedHelp.ps1 @@ -3,11 +3,10 @@ Returns the comment-based help associated with each class in a script. .DESCRIPTION - Tokenizes a PowerShell script and locates block comments that immediately - precede class declarations (allowing for attributes and blank lines in - between). Each matched class name is returned as a key in a hashtable - whose value is the parsed comment-based help (Synopsis, Description and - Parameters). + The function Get-ClassCommentBasedHelp tokenizes a PowerShell script and locates block + comments that immediately precede class declarations (allowing for attributes and blank + lines in between). Each matched class name is returned as a key in a hashtable whose value + is the parsed comment-based help (Synopsis, Description and Parameters). .PARAMETER Path The full path to a .ps1 or .psm1 file to inspect. diff --git a/source/Private/Get-DscAdapterCachePath.ps1 b/source/Private/Get-DscAdapterCachePath.ps1 new file mode 100644 index 0000000..493e979 --- /dev/null +++ b/source/Private/Get-DscAdapterCachePath.ps1 @@ -0,0 +1,58 @@ +<# + .SYNOPSIS + Gets known DSC PowerShell adapter cache paths. + + .DESCRIPTION + The function Get-DscAdapterCachePath returns the platform-specific cache paths used by the + Microsoft.Adapter/PowerShell and Microsoft.Adapter/WindowsPowerShell DSC adapters. The + function only calculates the well-known locations; it does not create, refresh, or validate + the cache files. + + .PARAMETER Adapter + The adapter cache path set to return. Specify PowerShell for PSAdapterCache.json, + WindowsPowerShell for WindowsPSAdapterCache.json, or All for both known paths. + + .EXAMPLE + Get-DscAdapterCachePath -Adapter All + + Returns every known PowerShell DSC adapter cache path for the current platform. + + .OUTPUTS + Returns one or more file paths as strings. +#> +function Get-DscAdapterCachePath +{ + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter()] + [ValidateSet('PowerShell', 'WindowsPowerShell', 'All')] + [System.String] + $Adapter = 'All' + ) + + $paths = [System.Collections.Generic.List[System.String]]::new() + + if ($Adapter -in @('PowerShell', 'All')) + { + if ($IsWindows -or $env:LOCALAPPDATA) + { + $paths.Add((Join-Path -Path $env:LOCALAPPDATA -ChildPath 'dsc\PSAdapterCache.json')) + } + elseif ($env:HOME) + { + $paths.Add((Join-Path -Path $env:HOME -ChildPath '.dsc/PSAdapterCache.json')) + } + } + + if ($Adapter -in @('WindowsPowerShell', 'All') -and $env:LOCALAPPDATA) + { + $paths.Add((Join-Path -Path $env:LOCALAPPDATA -ChildPath 'dsc\WindowsPSAdapterCache.json')) + } + + foreach ($path in $paths | Select-Object -Unique) + { + Write-Output $path + } +} \ No newline at end of file diff --git a/source/Private/Get-DscResourceAuthoringTypeName.ps1 b/source/Private/Get-DscResourceAuthoringTypeName.ps1 new file mode 100644 index 0000000..81712aa --- /dev/null +++ b/source/Private/Get-DscResourceAuthoringTypeName.ps1 @@ -0,0 +1,54 @@ +<# + .SYNOPSIS + Converts a DSC resource type name into a generated .NET type name. + + .DESCRIPTION + The function Get-DscResourceAuthoringTypeName builds the generated .NET type name used for + DSC resource property authoring. Resource type segments are split on slash and dot + separators, sanitized into valid C# identifiers, and prefixed with the DSC namespace root. + + .PARAMETER ResourceType + The fully qualified DSC resource type name to convert, such as + Microsoft.Windows/Service. + + .EXAMPLE + Get-DscResourceAuthoringTypeName -ResourceType 'Microsoft.Windows/Service' + + Returns DSC.Microsoft.Windows.Service. + + .OUTPUTS + Returns the generated .NET type name as a string. +#> +function Get-DscResourceAuthoringTypeName +{ + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $ResourceType + ) + + $segments = [System.Collections.Generic.List[System.String]]::new() + $segments.Add('DSC') + + foreach ($segment in ($ResourceType -split '[/\.]')) + { + if ([System.String]::IsNullOrWhiteSpace($segment)) + { + continue + } + + $identifier = $segment -replace '[^A-Za-z0-9_]', '_' + + if ($identifier -notmatch '^[A-Za-z_]') + { + $identifier = "_$identifier" + } + + $segments.Add($identifier) + } + + return ($segments -join '.') +} \ No newline at end of file diff --git a/source/Private/Get-DscResourceCapability.ps1 b/source/Private/Get-DscResourceCapability.ps1 index f960c62..05ba744 100644 --- a/source/Private/Get-DscResourceCapability.ps1 +++ b/source/Private/Get-DscResourceCapability.ps1 @@ -3,10 +3,10 @@ Returns the DSCv3 capabilities for a class-based DSC resource. .DESCRIPTION - Inspects the member AST of a class-based DSC resource type definition and - returns the DSCv3 capability strings (such as 'get', 'set', 'test', - 'whatIf', 'setHandlesExist', 'delete', 'export') corresponding to the - methods implemented on the class. + The function Get-DscResourceCapability inspects the member AST of a class-based DSC resource + type definition and returns the DSCv3 capability strings (such as 'get', 'set', 'test', + 'whatIf', 'setHandlesExist', 'delete', 'export') corresponding to the methods implemented + on the class. .PARAMETER MemberAst The collection of member AST nodes from the class type definition. diff --git a/source/Private/Get-DscResourceProperty.ps1 b/source/Private/Get-DscResourceProperty.ps1 index 0d278fe..1c6409f 100644 --- a/source/Private/Get-DscResourceProperty.ps1 +++ b/source/Private/Get-DscResourceProperty.ps1 @@ -3,9 +3,9 @@ Returns the DSC properties for a class-based DSC resource. .DESCRIPTION - Returns a list of hashtables describing each [DscProperty()] decorated - property on the supplied class type definition AST, including properties - inherited from base classes defined in the same file. + The function Get-DscResourceProperty returns a list of hashtables describing each + [DscProperty()] decorated property on the supplied class type definition AST, including + properties inherited from base classes defined in the same file. .PARAMETER AllTypeDefinitions All type definition AST nodes discovered in the script. Used to resolve diff --git a/source/Private/Get-DscResourceTypeDefinition.ps1 b/source/Private/Get-DscResourceTypeDefinition.ps1 index 56617a4..31d0325 100644 --- a/source/Private/Get-DscResourceTypeDefinition.ps1 +++ b/source/Private/Get-DscResourceTypeDefinition.ps1 @@ -3,9 +3,9 @@ Finds class-based DSC resource type definitions in a PowerShell file. .DESCRIPTION - Parses the AST of a PowerShell file and returns the type definitions that - are decorated with the [DscResource()] attribute, along with all type - definitions discovered in the file (used for resolving base types and enums). + The function Get-DscResourceTypeDefinition parses the AST of a PowerShell file and returns + the type definitions that are decorated with the [DscResource()] attribute, along with all + type definitions discovered in the file (used for resolving base types and enums). .PARAMETER Path The full path to a .ps1 or .psm1 file to parse. diff --git a/source/Private/New-DscResourceAuthoringTypeSource.ps1 b/source/Private/New-DscResourceAuthoringTypeSource.ps1 new file mode 100644 index 0000000..29cb28f --- /dev/null +++ b/source/Private/New-DscResourceAuthoringTypeSource.ps1 @@ -0,0 +1,80 @@ +<# + .SYNOPSIS + Builds C# source for a generated DSC resource property type. + + .DESCRIPTION + The function New-DscResourceAuthoringTypeSource creates the C# source text used by + Register-DscResourceAuthoringType to compile a resource-specific property object type. The + generated class exposes one public object-typed property for each JSON schema property so + PowerShell can provide type conversion and tab completion against the generated .NET type. + + .PARAMETER TypeName + The fully qualified .NET type name to generate, including namespace and class name. + + .PARAMETER Schema + The normalized JSON schema for the DSC resource instance properties. + + .EXAMPLE + New-DscResourceAuthoringTypeSource -TypeName 'DSC.Test.Company.Tool' -Schema $metadata.Schema + + Builds C# source for a generated authoring type with properties from the schema. + + .OUTPUTS + Returns C# source code as a string. +#> +function New-DscResourceAuthoringTypeSource +{ + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $TypeName, + + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary] + $Schema + ) + + $lastDot = $TypeName.LastIndexOf('.') + $namespace = $TypeName.Substring(0, $lastDot) + $className = $TypeName.Substring($lastDot + 1) + $properties = [System.Collections.Generic.List[System.String]]::new() + + if ($Schema.Contains('properties') -and $Schema['properties'] -is [System.Collections.IDictionary]) + { + foreach ($propertyName in $Schema['properties'].Keys) + { + $identifier = [System.String]$propertyName + $identifier = $identifier -replace '[^A-Za-z0-9_]', '_' + if ($identifier -notmatch '^[A-Za-z_]') + { + $identifier = "_$identifier" + } + + $properties.Add(" public object $identifier { get; set; }") + } + } + + $propertyText = if ($properties.Count -gt 0) + { + $properties -join [System.Environment]::NewLine + } + else + { + ' public object Value { get; set; }' + } + + return @" +using System; + +namespace $namespace +{ + public sealed class $className + { +$propertyText + } +} +"@ +} \ No newline at end of file diff --git a/source/Private/New-EmbeddedJsonSchema.ps1 b/source/Private/New-EmbeddedJsonSchema.ps1 index bbc7b40..fd4683d 100644 --- a/source/Private/New-EmbeddedJsonSchema.ps1 +++ b/source/Private/New-EmbeddedJsonSchema.ps1 @@ -3,10 +3,10 @@ Builds the embedded JSON schema for a class-based DSC resource. .DESCRIPTION - Produces an ordered hashtable representing the embedded JSON Schema - document for an adapted resource manifest. The schema describes the - DSC resource properties and their required-ness, and uses descriptions - from the supplied class comment-based help when available. + The function New-EmbeddedJsonSchema produces an ordered hashtable representing the embedded + JSON Schema document for an adapted resource manifest. The schema describes the DSC resource + properties and their required-ness, and uses descriptions from the supplied class + comment-based help when available. .PARAMETER ResourceName The fully-qualified resource type name (for example 'MyModule/MyResource') diff --git a/source/Private/Register-DscResourceFunction.ps1 b/source/Private/Register-DscResourceFunction.ps1 new file mode 100644 index 0000000..bf4efe5 --- /dev/null +++ b/source/Private/Register-DscResourceFunction.ps1 @@ -0,0 +1,137 @@ +<# + .SYNOPSIS + Registers a PowerShell function that emits a DSC resource instance. + + .DESCRIPTION + The function Register-DscResourceFunction creates a global PowerShell function for a + generated DSC authoring resource. The function name is derived from the DSC resource type by + replacing the slash with a dot. The generated function accepts resource schema properties as + parameters and emits a Microsoft.DSC.Resource object with typed property data. + + .PARAMETER Metadata + Normalized resource metadata returned by Import-DscResourceAuthoringMetadata. + + .PARAMETER PropertyTypeName + The generated .NET type name used for the resource instance properties. + + .EXAMPLE + Register-DscResourceFunction -Metadata $metadata -PropertyTypeName 'DSC.Test.Company.Tool' + + Creates a Test.Company.Tool function that emits Microsoft.DSC.Resource instances. + + .OUTPUTS + Returns the generated PowerShell function name. +#> +function Register-DscResourceFunction +{ + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary] + $Metadata, + + [Parameter(Mandatory = $true)] + [System.String] + $PropertyTypeName + ) + + $resourceType = [System.String]$Metadata['Type'] + $functionName = $resourceType -replace '/', '.' + $propertyNames = [System.Collections.Generic.List[System.String]]::new() + Write-Debug "Preparing generated resource function '$functionName' for DSC resource '$resourceType'." + + if ($Metadata.Contains('Schema') -and $Metadata['Schema'] -is [System.Collections.IDictionary] -and $Metadata['Schema'].Contains('properties')) + { + foreach ($propertyName in $Metadata['Schema']['properties'].Keys) + { + $propertyNames.Add([System.String]$propertyName) + } + } + Write-Debug "Generated resource function '$functionName' will expose $($propertyNames.Count) schema parameter(s)." + + $parameterBlocks = [System.Collections.Generic.List[System.String]]::new() + foreach ($propertyName in $propertyNames) + { + $safePropertyName = $propertyName -replace '[^A-Za-z0-9_]', '_' + if ($safePropertyName -notmatch '^[A-Za-z_]') + { + $safePropertyName = "_$safePropertyName" + } + + $parameterBlocks.Add(@" + [Parameter(ValueFromPipelineByPropertyName = `$true)] + [System.Object]`$$safePropertyName +"@) + } + + $parameterText = if ($parameterBlocks.Count -gt 0) + { + ($parameterBlocks -join ",$([System.Environment]::NewLine)$([System.Environment]::NewLine)") + ',' + } + else + { + '' + } + + $quotedResourceType = "'$($resourceType -replace '''', '''''')'" + $quotedPropertyTypeName = "'$($PropertyTypeName -replace '''', '''''')'" + $quotedRequireAdapter = if ($Metadata.Contains('RequireAdapter') -and -not [System.String]::IsNullOrWhiteSpace([System.String]$Metadata['RequireAdapter'])) + { + "'$([System.String]$Metadata['RequireAdapter'] -replace '''', '''''')'" + } + else + { + '$null' + } + $quotedProperties = ($propertyNames | ForEach-Object { "'$($_ -replace '''', '''''')'" }) -join ', ' + + $functionSource = @" +[CmdletBinding()] +[OutputType([Microsoft.DSC.Resource])] +param( +$parameterText + [Parameter()] + [System.String]`$InstanceName, + + [Parameter()] + [System.String[]]`$DependsOn +) + +process { + `$propertyTypeName = $quotedPropertyTypeName + `$resourceType = $quotedResourceType + `$propertyObject = New-Object -TypeName `$propertyTypeName + + foreach (`$propertyName in @($quotedProperties)) { + `$safePropertyName = `$propertyName -replace '[^A-Za-z0-9_]', '_' + if (`$safePropertyName -notmatch '^[A-Za-z_]') { + `$safePropertyName = "_`$safePropertyName" + } + + if (`$PSBoundParameters.ContainsKey(`$safePropertyName)) { + `$propertyObject.`$safePropertyName = `$PSBoundParameters[`$safePropertyName] + } + } + + `$resource = [Microsoft.DSC.Resource]::new() + `$resource.Name = if (`$PSBoundParameters.ContainsKey('InstanceName')) { `$InstanceName } else { `$resourceType } + `$resource.Type = `$resourceType + `$resource.Properties = `$propertyObject + `$resource.DependsOn = `$DependsOn + + `$requireAdapter = $quotedRequireAdapter + if (-not [System.String]::IsNullOrWhiteSpace(`$requireAdapter)) { + `$resource.Directives = [Microsoft.DSC.ResourceDirectives]::new() + `$resource.Directives.RequireAdapter = `$requireAdapter + } + + Write-Output `$resource +} +"@ + + Set-Item -Path "Function:\global:$functionName" -Value ([scriptblock]::Create($functionSource)) + Write-Debug "Registered generated resource function '$functionName'." + return $functionName +} \ No newline at end of file diff --git a/source/Private/Resolve-DscResourceAuthoringSchemaCommand.ps1 b/source/Private/Resolve-DscResourceAuthoringSchemaCommand.ps1 new file mode 100644 index 0000000..7ba4404 --- /dev/null +++ b/source/Private/Resolve-DscResourceAuthoringSchemaCommand.ps1 @@ -0,0 +1,134 @@ +<# + .SYNOPSIS + Resolves a DSC resource schema command. + + .DESCRIPTION + The function Resolve-DscResourceAuthoringSchemaCommand invokes the schema command defined + in a DSC resource manifest and converts the JSON schema output to a hashtable. If the + manifest does not define a schema command, the function returns null. + + .PARAMETER Manifest + A hashtable representation of a DSC command resource manifest. + + .PARAMETER SourcePath + The path to the manifest that supplied the schema command. When specified, the schema + command runs from the manifest directory so relative paths behave like they do for the + resource manifest. + + .EXAMPLE + Resolve-DscResourceAuthoringSchemaCommand -Manifest $manifest -SourcePath $path + + Runs the schema command from the manifest and returns the parsed JSON schema. + + .OUTPUTS + Returns a hashtable representation of the JSON schema, or null. +#> +function Resolve-DscResourceAuthoringSchemaCommand +{ + [CmdletBinding()] + [OutputType([System.Collections.IDictionary])] + param + ( + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary] + $Manifest, + + [Parameter()] + [System.String] + $SourcePath + ) + + if (-not $Manifest.Contains('schema') -or $Manifest['schema'] -isnot [System.Collections.IDictionary]) + { + Write-Debug "Resource '$($Manifest['type'])' does not define a schema object." + return $null + } + + $schema = $Manifest['schema'] + if (-not $schema.Contains('command') -or $schema['command'] -isnot [System.Collections.IDictionary]) + { + Write-Debug "Resource '$($Manifest['type'])' does not define a schema command." + return $null + } + + $schemaCommand = $schema['command'] + if (-not $schemaCommand.Contains('executable') -or [System.String]::IsNullOrWhiteSpace([System.String]$schemaCommand['executable'])) + { + Write-Debug "Resource '$($Manifest['type'])' defines a schema command without an executable." + return $null + } + + $executable = [System.String]$schemaCommand['executable'] + $arguments = [System.Collections.Generic.List[System.String]]::new() + if ($schemaCommand.Contains('args')) + { + foreach ($argument in @($schemaCommand['args'])) + { + if ($null -ne $argument) + { + $arguments.Add([System.String]$argument) + } + } + } + + Write-Debug "Resolving schema command for resource '$($Manifest['type'])' with executable '$executable' and $($arguments.Count) argument(s)." + + $locationChanged = $false + $errorPath = [System.IO.Path]::GetTempFileName() + try + { + if (-not [System.String]::IsNullOrWhiteSpace($SourcePath)) + { + $sourceDirectory = Split-Path -Path $SourcePath -Parent + if (-not [System.String]::IsNullOrWhiteSpace($sourceDirectory) -and (Test-Path -LiteralPath $sourceDirectory)) + { + Write-Debug "Running schema command from manifest directory '$sourceDirectory'." + Push-Location -LiteralPath $sourceDirectory + $locationChanged = $true + } + } + + $output = & $executable @arguments 2> $errorPath + $exitCode = $LASTEXITCODE + $errorText = if (Test-Path -LiteralPath $errorPath) + { + Get-Content -LiteralPath $errorPath -Raw + } + else + { + $null + } + + if ($exitCode -ne 0) + { + $commandText = @($executable) + @($arguments) -join ' ' + Write-Warning "Schema command '$commandText' failed with exit code $exitCode. $([System.String]$errorText)" + return $null + } + + $schemaJson = ($output | ForEach-Object -Process { [System.String]$_ }) -join [System.Environment]::NewLine + Write-Debug "Schema command for resource '$($Manifest['type'])' returned $($schemaJson.Length) character(s)." + if ([System.String]::IsNullOrWhiteSpace($schemaJson)) + { + Write-Warning "Schema command '$executable' returned no output." + return $null + } + + Write-Debug "Parsing schema command output for resource '$($Manifest['type'])'." + return ConvertTo-Hashtable -InputObject (ConvertFrom-Json -InputObject $schemaJson) + } + catch + { + Write-Warning "Failed to resolve schema command '$executable': $($_.Exception.Message)" + return $null + } + finally + { + if ($locationChanged) + { + Pop-Location + } + + Remove-Item -LiteralPath $errorPath -ErrorAction SilentlyContinue + } +} \ No newline at end of file diff --git a/source/Private/Resolve-ModuleInfo.ps1 b/source/Private/Resolve-ModuleInfo.ps1 index dc16e6b..4659960 100644 --- a/source/Private/Resolve-ModuleInfo.ps1 +++ b/source/Private/Resolve-ModuleInfo.ps1 @@ -3,12 +3,11 @@ Resolves module metadata from a .psd1, .psm1 or .ps1 file. .DESCRIPTION - Returns a hashtable containing the module name, version, author, - description and the path to the script file that should be parsed for - DSC resources. When a .psd1 path is provided the module manifest is - imported and the RootModule is resolved relative to the manifest's - directory. When a .ps1 or .psm1 is provided, a sibling .psd1 is used - when present; otherwise default values are returned. + The function Resolve-ModuleInfo returns a hashtable containing the module name, version, + author, description and the path to the script file that should be parsed for DSC resources. + When a .psd1 path is provided the module manifest is imported and the RootModule is resolved + relative to the manifest's directory. When a .ps1 or .psm1 is provided, a sibling .psd1 is + used when present; otherwise default values are returned. .PARAMETER Path The path to a .ps1, .psm1 or .psd1 file. diff --git a/source/Private/Test-IsEcmaCompatiblePattern.ps1 b/source/Private/Test-IsEcmaCompatiblePattern.ps1 index 24423e0..ed1b464 100644 --- a/source/Private/Test-IsEcmaCompatiblePattern.ps1 +++ b/source/Private/Test-IsEcmaCompatiblePattern.ps1 @@ -4,10 +4,10 @@ used by JSON Schema validators. .DESCRIPTION - Inspects a regex string for .NET-specific constructs that have no - equivalent in ECMA 262. When any such construct is found the function - returns $false so callers can decide whether to emit the pattern as a - JSON Schema `pattern` keyword. + The function Test-IsEcmaCompatiblePattern inspects a regex string for .NET-specific + constructs that have no equivalent in ECMA 262. When any such construct is found the + function returns $false so callers can decide whether to emit the pattern as a JSON Schema + `pattern` keyword. The following .NET-only constructs are detected: diff --git a/source/Public/Export-DscConfigurationDocument.ps1 b/source/Public/Export-DscConfigurationDocument.ps1 new file mode 100644 index 0000000..8e882ff --- /dev/null +++ b/source/Public/Export-DscConfigurationDocument.ps1 @@ -0,0 +1,90 @@ +<# + .SYNOPSIS + Exports a Microsoft DSC configuration document. + + .DESCRIPTION + The function Export-DscConfigurationDocument serializes Microsoft.DSC.Configuration + objects, or resource instances that can be wrapped in a configuration document, to JSON. + JSON is valid YAML, so the output can be saved with a .json, .yaml, or .yml extension for + DSC configuration document authoring workflows. + + .PARAMETER InputObject + The configuration document or resource instances to export. + + .PARAMETER Path + The output path for the serialized document. When omitted, the JSON text is written to the pipeline. + + .PARAMETER Depth + The JSON serialization depth. The default is 20. + + .EXAMPLE + $configuration | Export-DscConfigurationDocument -Path ./example.dsc.config.yaml + + Writes the configuration document as YAML-compatible JSON. + + .OUTPUTS + Returns a string of the serialized configuration document when Path is not specified. Otherwise, returns null. +#> +function Export-DscConfigurationDocument +{ + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [System.Object[]] + $InputObject, + + [Parameter()] + [System.String] + $Path, + + [Parameter()] + [System.Int32] + $Depth = 20 + ) + + begin + { + $items = [System.Collections.Generic.List[System.Object]]::new() + } + + process + { + foreach ($item in $InputObject) + { + $items.Add($item) + } + } + + end + { + if ($items.Count -eq 1 -and $items[0] -is [Microsoft.DSC.Configuration]) + { + $configuration = $items[0] + } + else + { + $configuration = New-DscConfigurationDocument -Resource ([Microsoft.DSC.Resource[]]$items.ToArray()) + } + + $json = ConvertTo-DscAuthoringHashtable -InputObject $configuration | ConvertTo-Json -Depth $Depth + + if ([System.String]::IsNullOrWhiteSpace($Path)) + { + Write-Output $json + return + } + + if ($PSCmdlet.ShouldProcess($Path, 'Export DSC configuration document')) + { + $parentPath = Split-Path -Path $Path -Parent + if (-not [System.String]::IsNullOrWhiteSpace($parentPath) -and -not (Test-Path -LiteralPath $parentPath)) + { + New-Item -Path $parentPath -ItemType Directory -Force | Out-Null + } + + Set-Content -LiteralPath $Path -Value $json -Encoding utf8 + } + } +} \ No newline at end of file diff --git a/source/Public/Import-DscAdaptedResourceManifest.ps1 b/source/Public/Import-DscAdaptedResourceManifest.ps1 index 2b545ad..5fdd032 100644 --- a/source/Public/Import-DscAdaptedResourceManifest.ps1 +++ b/source/Public/Import-DscAdaptedResourceManifest.ps1 @@ -3,10 +3,10 @@ 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. + The function Import-DscAdaptedResourceManifest 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. diff --git a/source/Public/Import-DscAuthoringResource.ps1 b/source/Public/Import-DscAuthoringResource.ps1 new file mode 100644 index 0000000..a2d447c --- /dev/null +++ b/source/Public/Import-DscAuthoringResource.ps1 @@ -0,0 +1,132 @@ +<# + .SYNOPSIS + Imports DSC resources for configuration document authoring. + + .DESCRIPTION + The function Import-DscAuthoringResource reads DSC resource metadata without invoking + dsc.exe, generates .NET property types, and creates resource functions that emit + Microsoft.DSC.Resource instances. + + This command intentionally avoids the resource import command name used by + PSDesiredStateConfiguration. + + .PARAMETER Path + A manifest file or directory containing DSC resource manifests. + + .PARAMETER Metadata + Preloaded resource metadata to register. + + .PARAMETER IncludeAdapterCache + Includes existing PowerShell adapter cache files when importing metadata. + + .PARAMETER AdapterCachePath + Explicit adapter cache files to read. + + .PARAMETER Recurse + Searches directories recursively for manifest files. + + .PARAMETER ResolveSchemaCommand + Runs schema commands from resource manifests when an embedded schema is not available. + This can call external resource executables and is disabled by default. + + .PARAMETER PassThru + Returns registration information for generated types and functions. + + .EXAMPLE + Import-DscAuthoringResource -Path ./resources -Recurse + + Imports command-based DSC resources from manifests below the resources folder. + + .OUTPUTS + Returns registration information when PassThru is specified. +#> +function Import-DscAuthoringResource +{ + [CmdletBinding(DefaultParameterSetName = 'Path')] + [OutputType([System.Management.Automation.PSCustomObject])] + param + ( + [Parameter(ParameterSetName = 'Path', ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [Alias('FullName')] + [System.String[]] + $Path, + + [Parameter(Mandatory = $true, ParameterSetName = 'Metadata', ValueFromPipeline = $true)] + [System.Object[]] + $Metadata, + + [Parameter(ParameterSetName = 'Path')] + [System.Management.Automation.SwitchParameter] + $IncludeAdapterCache, + + [Parameter(ParameterSetName = 'Path')] + [System.String[]] + $AdapterCachePath, + + [Parameter(ParameterSetName = 'Path')] + [System.Management.Automation.SwitchParameter] + $Recurse, + + [Parameter(ParameterSetName = 'Path')] + [System.Management.Automation.SwitchParameter] + $ResolveSchemaCommand, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $PassThru + ) + + begin + { + $metadataItems = [System.Collections.Generic.List[System.Object]]::new() + Write-Debug 'Starting DSC authoring resource import.' + } + + process + { + if ($PSCmdlet.ParameterSetName -eq 'Metadata') + { + Write-Debug "Import-DscAuthoringResource received metadata from the pipeline. Count: $(@($Metadata).Count)." + foreach ($item in $Metadata) + { + $metadataItems.Add($item) + } + } + else + { + Write-Debug "Import-DscAuthoringResource importing metadata from paths. Path count: $(@($Path).Count). Recurse: $($Recurse.IsPresent). IncludeAdapterCache: $($IncludeAdapterCache.IsPresent). ResolveSchemaCommand: $($ResolveSchemaCommand.IsPresent)." + $metadataParameters = @{ + IncludeAdapterCache = $IncludeAdapterCache + ResolveSchemaCommand = $ResolveSchemaCommand + Recurse = $Recurse + } + + if ($Path) + { + $metadataParameters['Path'] = $Path + } + + if ($AdapterCachePath) + { + $metadataParameters['AdapterCachePath'] = $AdapterCachePath + } + + foreach ($item in Import-DscResourceAuthoringMetadata @metadataParameters) + { + Write-Debug "Import-DscAuthoringResource collected metadata for resource type '$($item.Type)'." + $metadataItems.Add($item) + } + } + } + + end + { + Write-Debug "Registering $($metadataItems.Count) DSC authoring metadata item(s)." + $registrations = $metadataItems | Register-DscResourceAuthoringType + Write-Debug "Finished registering $(@($registrations).Count) DSC authoring resource type(s)." + if ($PassThru.IsPresent) + { + Write-Output $registrations + } + } +} \ No newline at end of file diff --git a/source/Public/Import-DscResourceAuthoringMetadata.ps1 b/source/Public/Import-DscResourceAuthoringMetadata.ps1 new file mode 100644 index 0000000..a1163d0 --- /dev/null +++ b/source/Public/Import-DscResourceAuthoringMetadata.ps1 @@ -0,0 +1,193 @@ +<# + .SYNOPSIS + Imports DSC resource metadata for configuration document authoring. + + .DESCRIPTION + The function Import-DscResourceAuthoringMetadata reads command-based DSC resource manifests + and optional PowerShell adapter cache files, returning normalized metadata that can be used + to generate authoring types and functions. This command does not invoke dsc.exe. + + .PARAMETER Path + A manifest file or directory containing .dsc.resource.json or .dsc.manifests.json files. + When omitted and IncludeAdapterCache is not specified, the current directory is searched recursively. + + .PARAMETER IncludeAdapterCache + Includes metadata from known PowerShell adapter cache files when those files exist. + + .PARAMETER AdapterCachePath + One or more explicit adapter cache files to read. + + .PARAMETER Recurse + Searches directories recursively for manifest files. + + .PARAMETER ResolveSchemaCommand + Runs schema commands from resource manifests when an embedded schema is not available. + This can call external resource executables and is disabled by default. + + .EXAMPLE + Import-DscResourceAuthoringMetadata -Path ./resources -Recurse + + Imports command resource metadata from manifests below the resources folder. + + .OUTPUTS + Returns normalized resource metadata objects. +#> +function Import-DscResourceAuthoringMetadata +{ + [CmdletBinding()] + [OutputType([System.Management.Automation.PSCustomObject])] + param + ( + [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [Alias('FullName')] + [System.String[]] + $Path, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $IncludeAdapterCache, + + [Parameter()] + [System.String[]] + $AdapterCachePath, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $Recurse, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $ResolveSchemaCommand + ) + + begin + { + $paths = [System.Collections.Generic.List[System.String]]::new() + } + + process + { + foreach ($item in $Path) + { + $paths.Add($item) + } + } + + end + { + if ($paths.Count -eq 0 -and -not $IncludeAdapterCache.IsPresent -and -not $AdapterCachePath) + { + $paths.Add((Get-Location).Path) + Write-Debug "No path was specified. Searching current location '$((Get-Location).Path)'." + } + + foreach ($candidatePath in $paths) + { + Write-Debug "Resolving DSC resource metadata path '$candidatePath'." + $resolvedPaths = Resolve-Path -LiteralPath $candidatePath -ErrorAction Stop + + foreach ($resolvedPath in $resolvedPaths) + { + $item = Get-Item -LiteralPath $resolvedPath.ProviderPath + Write-Debug "Inspecting '$($item.FullName)'. Is directory: $($item.PSIsContainer)." + $manifestFiles = if ($item.PSIsContainer) + { + $searchParameters = @{ + LiteralPath = $item.FullName + File = $true + } + + if ($Recurse.IsPresent) + { + $searchParameters['Recurse'] = $true + } + + @( + Get-ChildItem @searchParameters -Filter '*.dsc.resource.json' + Get-ChildItem @searchParameters -Filter '*.dsc.manifests.json' + ) + } + else + { + $item + } + + Write-Debug "Found $(@($manifestFiles).Count) DSC resource manifest file(s) under '$($item.FullName)'." + + foreach ($manifestFile in $manifestFiles) + { + Write-Debug "Reading DSC resource manifest '$($manifestFile.FullName)'." + $json = Get-Content -LiteralPath $manifestFile.FullName -Raw + $parsed = ConvertTo-Hashtable -InputObject (ConvertFrom-Json -InputObject $json) + + if ($manifestFile.Name -like '*.dsc.manifests.json') + { + if (-not $parsed.Contains('resources')) + { + Write-Debug "Skipping manifest list '$($manifestFile.FullName)' because it does not contain a resources property." + continue + } + + foreach ($resourceManifest in @($parsed['resources'])) + { + if ($null -eq $resourceManifest) + { + Write-Debug "Skipping null resource entry in manifest list '$($manifestFile.FullName)'." + continue + } + + Write-Debug "Converting resource '$($resourceManifest['type'])' from manifest list '$($manifestFile.FullName)'." + ConvertTo-DscResourceAuthoringMetadata -Manifest $resourceManifest -SourcePath $manifestFile.FullName -ResolveSchemaCommand:$ResolveSchemaCommand + } + continue + } + + Write-Debug "Converting resource '$($parsed['type'])' from manifest '$($manifestFile.FullName)'." + ConvertTo-DscResourceAuthoringMetadata -Manifest $parsed -SourcePath $manifestFile.FullName -ResolveSchemaCommand:$ResolveSchemaCommand + } + } + } + + $cachePaths = [System.Collections.Generic.List[System.String]]::new() + if ($IncludeAdapterCache.IsPresent) + { + Write-Debug 'Including known DSC adapter cache paths.' + foreach ($cachePath in Get-DscAdapterCachePath) + { + $cachePaths.Add($cachePath) + } + } + + foreach ($cachePath in $AdapterCachePath) + { + Write-Debug "Including explicit DSC adapter cache path '$cachePath'." + $cachePaths.Add($cachePath) + } + + foreach ($cachePath in $cachePaths | Select-Object -Unique) + { + if (-not (Test-Path -LiteralPath $cachePath)) + { + Write-Verbose "Skipping missing DSC adapter cache '$cachePath'." + continue + } + + Write-Debug "Reading DSC adapter cache '$cachePath'." + $cache = ConvertFrom-Json -InputObject (Get-Content -LiteralPath $cachePath -Raw) + $requireAdapter = if ((Split-Path -Path $cachePath -Leaf) -eq 'WindowsPSAdapterCache.json') + { + 'Microsoft.Adapter/WindowsPowerShell' + } + else + { + 'Microsoft.Adapter/PowerShell' + } + + foreach ($entry in @($cache.ResourceCache)) + { + Write-Debug "Converting adapter cache entry '$($entry.Type)' from '$cachePath'." + ConvertTo-DscResourceAuthoringMetadata -AdapterCacheEntry $entry -SourcePath $cachePath -RequireAdapter $requireAdapter + } + } + } +} \ No newline at end of file diff --git a/source/Public/Import-DscResourceManifest.ps1 b/source/Public/Import-DscResourceManifest.ps1 index 85ad481..dfd89c8 100644 --- a/source/Public/Import-DscResourceManifest.ps1 +++ b/source/Public/Import-DscResourceManifest.ps1 @@ -3,9 +3,10 @@ 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 function Import-DscResourceManifest 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 diff --git a/source/Public/New-DscAdaptedResourceManifest.ps1 b/source/Public/New-DscAdaptedResourceManifest.ps1 index 74f7166..1408594 100644 --- a/source/Public/New-DscAdaptedResourceManifest.ps1 +++ b/source/Public/New-DscAdaptedResourceManifest.ps1 @@ -3,10 +3,10 @@ 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 function New-DscAdaptedResourceManifest 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 @@ -24,6 +24,12 @@ string (e.g. '1.2.3' or '1.2.3-preview'). When omitted, the version from the .psd1 ModuleVersion field is used, or '0.0.1' for files without a co-located manifest. + .PARAMETER AllowNonEcmaPattern + When specified, `[ValidatePattern()]` regex values that contain .NET-specific constructs + incompatible with ECMA 262 (e.g. `\A`, `\Z`, atomic groups, inline flags) are still + written into the JSON Schema `pattern` keyword. By default such patterns are skipped + and a warning is written instead. + .EXAMPLE New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 @@ -46,12 +52,6 @@ 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. - - .PARAMETER AllowNonEcmaPattern - When specified, `[ValidatePattern()]` regex values that contain .NET-specific constructs - incompatible with ECMA 262 (e.g. `\A`, `\Z`, atomic groups, inline flags) are still - written into the JSON Schema `pattern` keyword. By default such patterns are skipped - and a warning is written instead. #> function New-DscAdaptedResourceManifest { diff --git a/source/Public/New-DscConfigurationDocument.ps1 b/source/Public/New-DscConfigurationDocument.ps1 new file mode 100644 index 0000000..e59f021 --- /dev/null +++ b/source/Public/New-DscConfigurationDocument.ps1 @@ -0,0 +1,80 @@ +<# + .SYNOPSIS + Creates a Microsoft DSC configuration document object. + + .DESCRIPTION + The function New-DscConfigurationDocument creates a Microsoft.DSC.Configuration object + from resource instances and optional configuration-level metadata, parameters, and + directives. + + .PARAMETER Resource + One or more Microsoft.DSC.Resource instances to include in the configuration document. + + .PARAMETER Parameter + Configuration parameters to include in the document. + + .PARAMETER Directive + Configuration-level directives to include in the document. + + .PARAMETER Metadata + Metadata to include in the document. + + .EXAMPLE + New-DscConfigurationDocument -Resource $resources + + Creates a configuration document containing the supplied resources. + + .OUTPUTS + Returns a Microsoft.DSC.Configuration object. +#> +function New-DscConfigurationDocument +{ + [CmdletBinding()] + [OutputType([Microsoft.DSC.Configuration])] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [Microsoft.DSC.Resource[]] + $Resource, + + [Parameter()] + [System.Collections.IDictionary] + $Parameter, + + [Parameter()] + [Microsoft.DSC.Directives] + $Directive, + + [Parameter()] + [System.Collections.IDictionary] + $Metadata + ) + + begin + { + $configuration = [Microsoft.DSC.Configuration]::new() + $configuration.Parameters = $Parameter + $configuration.Directives = $Directive + + if ($Metadata) + { + foreach ($key in $Metadata.Keys) + { + $configuration.Metadata[$key] = $Metadata[$key] + } + } + } + + process + { + foreach ($resourceInstance in $Resource) + { + $configuration.Resources.Add($resourceInstance) + } + } + + end + { + Write-Output $configuration + } +} \ No newline at end of file diff --git a/source/Public/New-DscExpression.ps1 b/source/Public/New-DscExpression.ps1 new file mode 100644 index 0000000..ff563e5 --- /dev/null +++ b/source/Public/New-DscExpression.ps1 @@ -0,0 +1,34 @@ +<# + .SYNOPSIS + Creates a DSC expression from constrained PowerShell syntax. + + .DESCRIPTION + The function New-DscExpression transpiles a small, safe subset of PowerShell expression + syntax into a DSC expression string. Use Invoke-DscFunction inside the script block to + call DSC or ARM-compatible functions. The plus operator is translated to concat(). + + .PARAMETER ScriptBlock + The PowerShell script block to transpile into a DSC expression. + + .EXAMPLE + New-DscExpression -ScriptBlock { (Invoke-DscFunction -Name systemRoot) + '\Windows\System32' } + + Returns a Microsoft.DSC.Expression with the value [concat(systemRoot(), '\Windows\System32')]. + + .OUTPUTS + Returns a Microsoft.DSC.Expression object. +#> +function New-DscExpression +{ + [CmdletBinding()] + [OutputType([Microsoft.DSC.Expression])] + param + ( + [Parameter(Mandatory = $true, Position = 0)] + [System.Management.Automation.ScriptBlock] + $ScriptBlock + ) + + $body = ConvertTo-DscExpressionText -Ast $ScriptBlock.Ast + return [Microsoft.DSC.Expression]::new("[$body]") +} \ No newline at end of file diff --git a/source/Public/New-DscPropertyOverride.ps1 b/source/Public/New-DscPropertyOverride.ps1 index b42538c..ecc0ec6 100644 --- a/source/Public/New-DscPropertyOverride.ps1 +++ b/source/Public/New-DscPropertyOverride.ps1 @@ -3,8 +3,8 @@ Creates a DscPropertyOverride object for use with Update-DscAdaptedResourceManifest. .DESCRIPTION - Constructs a DscPropertyOverride object that specifies how to modify a single property - in the embedded JSON schema of an adapted resource manifest. + The function New-DscPropertyOverride constructs a DscPropertyOverride object that specifies + how to modify a single property in the embedded JSON schema of an adapted resource manifest. .PARAMETER Name The name of the property in the embedded JSON schema to override. diff --git a/source/Public/New-DscResourceManifest.ps1 b/source/Public/New-DscResourceManifest.ps1 index 0956fc4..921a904 100644 --- a/source/Public/New-DscResourceManifest.ps1 +++ b/source/Public/New-DscResourceManifest.ps1 @@ -3,9 +3,10 @@ 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. + The function New-DscResourceManifest 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 diff --git a/source/Public/Register-DscResourceAuthoringType.ps1 b/source/Public/Register-DscResourceAuthoringType.ps1 new file mode 100644 index 0000000..6e097ba --- /dev/null +++ b/source/Public/Register-DscResourceAuthoringType.ps1 @@ -0,0 +1,104 @@ +<# + .SYNOPSIS + Registers generated .NET types and resource functions for DSC authoring. + + .DESCRIPTION + The function Register-DscResourceAuthoringType generates a schema-backed .NET property type + under the DSC namespace for each supplied resource metadata object. By default, the command + also creates a PowerShell function named after the resource type, replacing the slash with a + dot, that emits Microsoft.DSC.Resource instances. + + .PARAMETER Metadata + Metadata objects returned by Import-DscResourceAuthoringMetadata. + + .PARAMETER NoFunction + Skips generating resource functions and registers only .NET property types. + + .EXAMPLE + Import-DscResourceAuthoringMetadata -Path ./registry.dsc.resource.json | + Register-DscResourceAuthoringType + + Registers the generated DSC property type and resource function. + + .OUTPUTS + For each metadata object, outputs a custom object with the following properties: + - Type: The original resource type from the metadata (e.g. 'MyCompany/MyResource'). + - TypeName: The full name of the generated .NET type for the resource properties (e.g. 'Dsc.MyCompany.MyResourceProperties'). + - FunctionName: The name of the generated PowerShell function for creating resource instances (e.g. 'MyCompany.MyResource'), or null if NoFunction was specified. +#> +function Register-DscResourceAuthoringType +{ + [CmdletBinding()] + [OutputType([System.Management.Automation.PSCustomObject])] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [System.Object[]] + $Metadata, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $NoFunction + ) + + process + { + foreach ($metadataObject in $Metadata) + { + $metadataHashtable = ConvertTo-DscAuthoringHashtable -InputObject $metadataObject + $resourceType = [System.String]$metadataHashtable['Type'] + Write-Debug "Processing DSC authoring metadata for resource type '$resourceType'." + if ([System.String]::IsNullOrWhiteSpace($resourceType)) + { + Write-Debug 'Skipping metadata item because the resource type is empty.' + continue + } + + $typeName = Get-DscResourceAuthoringTypeName -ResourceType $resourceType + $type = $typeName -as [System.Type] + Write-Debug "Resolved DSC resource '$resourceType' to generated type '$typeName'. Type already loaded: $($null -ne $type)." + + if ($null -eq $type) + { + $schema = if ($metadataHashtable.Contains('Schema') -and $metadataHashtable['Schema'] -is [System.Collections.IDictionary]) + { + $metadataHashtable['Schema'] + } + else + { + [ordered]@{ type = 'object'; properties = [ordered]@{} } + } + + $schemaPropertyCount = if ($schema.Contains('properties') -and $schema['properties'] -is [System.Collections.IDictionary]) + { + $schema['properties'].Count + } + else + { + 0 + } + Write-Debug "Compiling generated type '$typeName' for resource '$resourceType' with $schemaPropertyCount schema propertie(s)." + + $source = New-DscResourceAuthoringTypeSource -TypeName $typeName -Schema $schema + Add-Type -TypeDefinition $source -Language CSharp + } + + $functionName = $null + if (-not $NoFunction.IsPresent) + { + Write-Debug "Registering generated resource function for '$resourceType'." + $functionName = Register-DscResourceFunction -Metadata $metadataHashtable -PropertyTypeName $typeName + } + else + { + Write-Debug "Skipping generated resource function for '$resourceType' because NoFunction was specified." + } + + [PSCustomObject]@{ + Type = $resourceType + TypeName = $typeName + FunctionName = $functionName + } + } + } +} \ No newline at end of file diff --git a/source/Public/Update-DscAdaptedResourceManifest.ps1 b/source/Public/Update-DscAdaptedResourceManifest.ps1 index 12b913a..37f7ebc 100644 --- a/source/Public/Update-DscAdaptedResourceManifest.ps1 +++ b/source/Public/Update-DscAdaptedResourceManifest.ps1 @@ -3,10 +3,11 @@ Applies post-processing overrides to adapted resource manifest objects. .DESCRIPTION - Modifies the embedded JSON schema of a DscAdaptedResourceManifest object by applying - property-level overrides. This enables customization that AST extraction alone cannot - provide, such as meaningful property descriptions, JSON schema keywords like anyOf or - oneOf for complex type unions, default values, numeric ranges, and string patterns. + The function Update-DscAdaptedResourceManifest modifies the embedded JSON schema of a + DscAdaptedResourceManifest object by applying property-level overrides. This enables + customization that AST extraction alone cannot provide, such as meaningful property + descriptions, JSON schema keywords like anyOf or oneOf for complex type unions, default + values, numeric ranges, and string patterns. Property overrides are specified via DscPropertyOverride objects that target individual properties by name. Each override can change the description, title, required status, diff --git a/source/WikiSource/Command-Reference.md b/source/WikiSource/Command-Reference.md index 715a5ed..4236164 100644 --- a/source/WikiSource/Command-Reference.md +++ b/source/WikiSource/Command-Reference.md @@ -1,6 +1,7 @@ ## Contents 1. Overview +1. Configuration authoring commands 1. New-DscAdaptedResourceManifest 1. Import-DscAdaptedResourceManifest 1. Import-DscResourceManifest @@ -15,14 +16,44 @@ This page lists the public commands in `DscResource.Authoring`. The SYNOPSIS text matches the command help used by the module. -| Command | Synopsis | -|-------------------------------------|------------------------------------------------------------------------------------------| -| `New-DscAdaptedResourceManifest` | Creates adapted resource manifest objects from class-based PowerShell DSC resources. | -| `Import-DscAdaptedResourceManifest` | Imports adapted resource manifest objects from `.dsc.adaptedResource.json` files. | -| `Import-DscResourceManifest` | Imports a DSC resource manifest list from a `.dsc.manifests.json` file. | -| `New-DscPropertyOverride` | Creates a `DscPropertyOverride` object for use with `Update-DscAdaptedResourceManifest`. | -| `New-DscResourceManifest` | Creates a DSC resource manifests list for bundling multiple resources in a single file. | -| `Update-DscAdaptedResourceManifest` | Applies post-processing overrides to adapted resource manifest objects. | +| Command | Synopsis | +| ------- | -------- | +| `Export-DscConfigurationDocument` | Exports a Microsoft DSC configuration document. | +| `Import-DscAuthoringResource` | Imports DSC resources for configuration document authoring. | +| `New-DscAdaptedResourceManifest` | Creates adapted resource manifest objects from class-based PowerShell DSC resources. | +| `Import-DscResourceAuthoringMetadata` | Imports DSC resource metadata for configuration document authoring. | +| `Import-DscAdaptedResourceManifest` | Imports adapted resource manifest objects from `.dsc.adaptedResource.json` files. | +| `Import-DscResourceManifest` | Imports a DSC resource manifest list from a `.dsc.manifests.json` file. | +| `New-DscConfigurationDocument` | Creates a Microsoft DSC configuration document object. | +| `New-DscExpression` | Creates a DSC expression from constrained PowerShell syntax. | +| `New-DscPropertyOverride` | Creates a `DscPropertyOverride` object for use with `Update-DscAdaptedResourceManifest`. | +| `New-DscResourceManifest` | Creates a DSC resource manifests list for bundling multiple resources in a single file. | +| `Register-DscResourceAuthoringType` | Registers generated .NET types and resource functions for DSC authoring. | +| `Update-DscAdaptedResourceManifest` | Applies post-processing overrides to adapted resource manifest objects. | + + + +## Configuration Authoring Commands + +The configuration authoring commands are introduced in [[Configuration Authoring]]. +Use them to load resource metadata, register generated authoring types and functions, +create `Microsoft.DSC.Configuration` objects, build DSC expressions, and export +configuration documents. + +`Import-DscResourceAuthoringMetadata` and `Import-DscAuthoringResource` support +`-ResolveSchemaCommand` for command-based resources that define `schema.command` +instead of an embedded schema. The switch runs the resource executable's schema +command so generated functions can expose resource-specific property parameters. +The switch does not call `dsc.exe`. + +| Command | Purpose | +| ------- | ------- | +| `Import-DscResourceAuthoringMetadata` | Reads command resource manifests and existing adapter caches into normalized metadata. | +| `Register-DscResourceAuthoringType` | Generates .NET property types and optional resource functions from metadata. | +| `Import-DscAuthoringResource` | Imports metadata and registers generated authoring types and functions in one step. | +| `New-DscConfigurationDocument` | Creates a `Microsoft.DSC.Configuration` object from resource instances. | +| `New-DscExpression` | Converts constrained PowerShell expression syntax into DSC expression strings. | +| `Export-DscConfigurationDocument` | Serializes resources or configuration objects to a DSC configuration document. | --- diff --git a/source/WikiSource/Configuration-Authoring.md b/source/WikiSource/Configuration-Authoring.md new file mode 100644 index 0000000..75c57a2 --- /dev/null +++ b/source/WikiSource/Configuration-Authoring.md @@ -0,0 +1,301 @@ +## Contents + +1. Overview +1. Prerequisites +1. Load resource metadata +1. Resolve command-backed schemas +1. Author with generated resource functions +1. Author with generated .NET types +1. Use DSC expressions +1. Use existing PowerShell adapter cache metadata +1. Export a configuration document +1. Command quick reference + + + +## Overview + +`DscResource.Authoring` can help authors create Microsoft DSC configuration +documents from PowerShell. This authoring experience focuses on command-based +DSC resources first. It reads resource manifests and uses their JSON schemas to +generate: + +- .NET property types under the `DSC.*` namespace. +- PowerShell resource functions named from the DSC resource type. +- `Microsoft.DSC.Resource` objects for configuration documents. +- `Microsoft.DSC.Configuration` objects that can be exported as DSC documents. + +The resource metadata import path does not call `dsc.exe`. By default, it only +reads `.dsc.resource.json`, `.dsc.manifests.json`, and existing adapter cache +files. If a command-based resource defines its schema with `schema.command`, you +can opt in to running that resource's schema command with `-ResolveSchemaCommand`. + + + +## Prerequisites + +- PowerShell 7 is recommended for authoring configuration documents. +- A built or imported `DscResource.Authoring` module. +- One or more command-based DSC resource manifests with embedded schemas or + schema commands that can be resolved locally. + +Import the module: + +```powershell +Import-Module DscResource.Authoring +``` + +For local repository development, import the built module from the output folder: + +```powershell +Import-Module ./output/module/DscResource.Authoring/DscResource.Authoring.psd1 +``` + + + +## Load resource metadata + +Use `Import-DscResourceAuthoringMetadata` when you want to inspect resource +metadata before registering authoring types: + +```powershell +$metadata = Import-DscResourceAuthoringMetadata -Path ./resources -Recurse +$metadata | Format-Table Type, Kind, Version, RequireAdapter +``` + +Use `Import-DscAuthoringResource` when you want to load metadata and immediately +register generated .NET types and resource functions: + +```powershell +$registration = Import-DscAuthoringResource -Path ./resources -Recurse -PassThru +$registration | Format-Table Type, TypeName, FunctionName +``` + +For a resource type named `Test.Company/Tool`, the command registers: + +- A .NET type named `DSC.Test.Company.Tool`. +- A PowerShell function named `Test.Company.Tool`. + +The command name is intentionally `Import-DscAuthoringResource` to avoid +conflicting with the `Import-DscResource` keyword from PSDesiredStateConfiguration. + + + +## Resolve command-backed schemas + +Some command-based resources do not embed their JSON schema in the manifest. +Instead, the manifest contains a `schema.command` entry that tells DSC how to ask +the resource executable for its schema. The Registry resources in the DSC +repository are an example of this pattern. + +By default, `DscResource.Authoring` does not run those commands. This keeps the +normal metadata import path file-based and avoids unexpected external calls: + +```powershell +Import-DscAuthoringResource -Path ./resources -Recurse +``` + +When you want generated functions and generated .NET types to include properties +from command-backed schemas, opt in with `-ResolveSchemaCommand`: + +```powershell +Import-DscAuthoringResource -Path ./resources -Recurse -ResolveSchemaCommand + +$resource = Microsoft.Windows.Registry ` + -InstanceName 'Configure registry value' ` + -keyPath 'HKCU\Software\Example' ` + -valueName 'Enabled' ` + -_exist $true +``` + +`-ResolveSchemaCommand` invokes the resource executable named in the manifest's +schema command. It still does not call `dsc.exe`. The executable must be +available from the current environment, such as from the repository build output +or from `$env:PATH`. + +Tab completion depends on the schema information that was loaded. If a resource +only has `schema.command` and you do not specify `-ResolveSchemaCommand`, the +generated resource function can be registered, but PowerShell cannot complete the +resource-specific property parameters because the module has not loaded those +property names. After resolving the schema command, parameters such as `-keyPath`, +`-valueName`, and `-_exist` can be exposed by the generated function. + + + +## Author with generated resource functions + +Generated resource functions are the quickest way to create resource instances. +Each schema property becomes a function parameter. The function emits a +`Microsoft.DSC.Resource` object. + +```powershell +Import-DscAuthoringResource -Path ./resources -Recurse + +$resource = Test.Company.Tool ` + -InstanceName 'Configure example tool' ` + -name 'example' ` + -enabled $true +``` + +The generated resource object can be placed directly in a configuration document: + +```powershell +$configuration = New-DscConfigurationDocument -Resource $resource +``` + +You can also create many resources with normal PowerShell pipeline patterns: + +```powershell +$tools = @( + @{ name = 'alpha'; enabled = $true } + @{ name = 'beta'; enabled = $false } +) + +$resources = $tools | Test.Company.Tool +$configuration = $resources | New-DscConfigurationDocument +``` + + + +## Author with generated .NET types + +Generated .NET types support a type-first authoring style. Use the generated +type for resource properties and create `Microsoft.DSC.Resource` objects +directly: + +```powershell +Import-DscAuthoringResource -Path ./resources -Recurse + +$properties = [DSC.Test.Company.Tool]@{ + name = 'example' + enabled = $true +} + +$resource = [Microsoft.DSC.Resource]@{ + Name = 'Configure example tool' + Type = 'Test.Company/Tool' + Properties = $properties +} + +$configuration = [Microsoft.DSC.Configuration]@{ + Resources = @($resource) +} +``` + +The function style and the type-first style produce the same resource object +shape, so authors can mix both approaches in the same script. + + + +## Use DSC expressions + +Use `New-DscExpression` to build DSC expression strings from a constrained +PowerShell script block. Call DSC or ARM-compatible functions with +`Invoke-DscFunction`. The `+` operator is translated to `concat()`. + +```powershell +$system32 = New-DscExpression -ScriptBlock { + (Invoke-DscFunction -Name systemRoot) + '\System32' +} +``` + +The resulting expression value is: + +```text +[concat(systemRoot(), '\System32')] +``` + +Use the expression as a resource property value: + +```powershell +$resource = Test.Company.Tool ` + -InstanceName 'Configure example tool' ` + -name 'example' ` + -path $system32 +``` + +Variables in expression script blocks are converted to DSC parameter references: + +```powershell +New-DscExpression -ScriptBlock { $installRoot } +``` + +The resulting expression value is: + +```text +[parameters('installRoot')] +``` + + + +## Use existing PowerShell adapter cache metadata + +PowerShell adapted resources can be loaded from existing adapter cache files. +The module only reads cache files that already exist; it does not invoke +`dsc.exe` or refresh missing caches. + +Known cache locations are: + +| Adapter | Platform | Path | +| ------- | -------- | ---- | +| `Microsoft.Adapter/PowerShell` | Windows | `%LOCALAPPDATA%\dsc\PSAdapterCache.json` | +| `Microsoft.Adapter/PowerShell` | Linux or macOS | `$HOME/.dsc/PSAdapterCache.json` | +| `Microsoft.Adapter/WindowsPowerShell` | Windows | `%LOCALAPPDATA%\dsc\WindowsPSAdapterCache.json` | + +Load existing adapter cache metadata: + +```powershell +Import-DscAuthoringResource -IncludeAdapterCache +``` + +Or specify a cache file explicitly: + +```powershell +Import-DscAuthoringResource -AdapterCachePath "$env:LOCALAPPDATA\dsc\PSAdapterCache.json" +``` + +Adapter-backed resources include a `requireAdapter` directive automatically when +the generated function creates a resource instance. + + + +## Export a configuration document + +Use `Export-DscConfigurationDocument` to serialize a configuration document or a +stream of resources. The command writes JSON, which is valid YAML and can be +saved with a `.dsc.config.yaml` file name. + +```powershell +$configuration = Test.Company.Tool ` + -InstanceName 'Configure example tool' ` + -name 'example' ` + -enabled $true | + New-DscConfigurationDocument + +$configuration | Export-DscConfigurationDocument -Path ./example.dsc.config.yaml +``` + +You can also pipe resources directly to the export command. The command wraps +them in a configuration document automatically: + +```powershell +Test.Company.Tool -name 'example' -enabled $true | + Export-DscConfigurationDocument -Path ./example.dsc.config.yaml +``` + + + +## Command quick reference + +| Command | Purpose | +| ------- | ------- | +| `Import-DscResourceAuthoringMetadata` | Reads command resource manifests and existing adapter caches into normalized metadata. | +| `Register-DscResourceAuthoringType` | Generates .NET property types and optional resource functions from metadata. | +| `Import-DscAuthoringResource` | Imports metadata and registers generated authoring types and functions in one step. | +| `New-DscConfigurationDocument` | Creates a `Microsoft.DSC.Configuration` object from resource instances. | +| `New-DscExpression` | Converts constrained PowerShell expression syntax into DSC expression strings. | +| `Export-DscConfigurationDocument` | Serializes resources or configuration objects to a DSC configuration document. | + +Start with `Import-DscAuthoringResource` for the simplest workflow. Use +`Import-DscResourceAuthoringMetadata` and `Register-DscResourceAuthoringType` +separately when you need to inspect or filter metadata before registering types. \ No newline at end of file diff --git a/source/WikiSource/Getting-Started.md b/source/WikiSource/Getting-Started.md index 69683d7..5bf4b96 100644 --- a/source/WikiSource/Getting-Started.md +++ b/source/WikiSource/Getting-Started.md @@ -6,6 +6,7 @@ 1. Generate an adapted resource manifest 1. How DSC discovers adapted resource manifests 1. Create a resource manifest list +1. Author configuration documents 1. Next steps @@ -154,11 +155,45 @@ Use `Import-DscAdaptedResourceManifest` and `Import-DscResourceManifest` when you need to load existing JSON files back into objects for inspection or post-processing. + + +## Author configuration documents + +After you have command-based DSC resource manifests, you can use the new +configuration document authoring commands to generate PowerShell resource +functions and create configuration documents: + +```powershell +Import-DscAuthoringResource -Path ./resources -Recurse + +$configuration = Test.Company.Tool ` + -InstanceName 'Configure example tool' ` + -name 'example' ` + -enabled $true | + New-DscConfigurationDocument + +$configuration | Export-DscConfigurationDocument -Path ./example.dsc.config.yaml +``` + +If a resource manifest defines its schema with `schema.command` instead of an +embedded schema, add `-ResolveSchemaCommand` so generated functions and .NET +types include the resource-specific properties: + +```powershell +Import-DscAuthoringResource -Path ./resources -Recurse -ResolveSchemaCommand +``` + +This option invokes the resource executable's schema command, but it does not +call `dsc.exe`. + +For a complete walkthrough, see [[Configuration Authoring]]. + ## Next steps - Review [[Examples]] for common authoring tasks. +- Review [[Configuration Authoring]] for PowerShell-based configuration document authoring. - Review the [[Command Reference]] for command synopsis and usage details. - Read the [Microsoft DSC documentation][02] for DSC configuration and resource provider concepts. diff --git a/source/WikiSource/Home.md b/source/WikiSource/Home.md index 0644d1b..b8682ee 100644 --- a/source/WikiSource/Home.md +++ b/source/WikiSource/Home.md @@ -7,6 +7,7 @@ manifest files in automation. 1. Why use it? 1. Available commands 1. Authoring workflow +1. Configuration document authoring @@ -73,14 +74,20 @@ embedded schema. ## Available commands -| Command | Description | -|-------------------------------------|------------------------------------------------------------------------------------------| -| `New-DscAdaptedResourceManifest` | Creates adapted resource manifest objects from class-based PowerShell DSC resources. | -| `Import-DscAdaptedResourceManifest` | Imports adapted resource manifest objects from `.dsc.adaptedResource.json` files. | -| `Import-DscResourceManifest` | Imports a DSC resource manifest list from a `.dsc.manifests.json` file. | -| `New-DscPropertyOverride` | Creates a `DscPropertyOverride` object for use with `Update-DscAdaptedResourceManifest`. | -| `New-DscResourceManifest` | Creates a DSC resource manifests list for bundling multiple resources in a single file. | -| `Update-DscAdaptedResourceManifest` | Applies post-processing overrides to adapted resource manifest objects. | +| Command | Description | +| ------- | ----------- | +| `New-DscAdaptedResourceManifest` | Creates adapted resource manifest objects from class-based PowerShell DSC resources. | +| `Import-DscAdaptedResourceManifest` | Imports adapted resource manifest objects from `.dsc.adaptedResource.json` files. | +| `Import-DscAuthoringResource` | Imports DSC resources for configuration document authoring. | +| `Import-DscResourceAuthoringMetadata` | Imports DSC resource metadata for configuration document authoring. | +| `Import-DscResourceManifest` | Imports a DSC resource manifest list from a `.dsc.manifests.json` file. | +| `Export-DscConfigurationDocument` | Exports a Microsoft DSC configuration document. | +| `New-DscConfigurationDocument` | Creates a Microsoft DSC configuration document object. | +| `New-DscExpression` | Creates a DSC expression from constrained PowerShell syntax. | +| `New-DscPropertyOverride` | Creates a `DscPropertyOverride` object for use with `Update-DscAdaptedResourceManifest`. | +| `New-DscResourceManifest` | Creates a DSC resource manifests list for bundling multiple resources in a single file. | +| `Register-DscResourceAuthoringType` | Registers generated .NET types and resource functions for DSC authoring. | +| `Update-DscAdaptedResourceManifest` | Applies post-processing overrides to adapted resource manifest objects. | See [[Command Reference]] for syntax and usage details. @@ -100,6 +107,15 @@ The typical workflow is: Start with [[Getting Started]], then review [[Examples]] and the [[Command Reference]]. + + +## Configuration document authoring + +Use [[Configuration Authoring]] when you want to author Microsoft DSC +configuration documents in PowerShell. That workflow reads command resource +manifests, generates resource property types and functions, creates +`Microsoft.DSC.Configuration` objects, and exports `.dsc.config.yaml` files. + [01]: https://learn.microsoft.com/en-us/powershell/dsc/overview?view=dsc-3.0 [02]: https://github.com/PowerShell/DSC/blob/main/extensions/powershell/powershell.discover.ps1 diff --git a/tests/Unit/Fixtures/AdapterCache/PSAdapterCache.json b/tests/Unit/Fixtures/AdapterCache/PSAdapterCache.json new file mode 100644 index 0000000..cf7cd2b --- /dev/null +++ b/tests/Unit/Fixtures/AdapterCache/PSAdapterCache.json @@ -0,0 +1,38 @@ +{ + "CacheSchemaVersion": 3, + "PSModulePaths": [], + "ResourceCache": [ + { + "Type": "Cache.Module/Resource", + "DscResourceInfo": { + "Name": "Resource", + "FriendlyName": "Cached Resource", + "ModuleName": "Cache.Module", + "Version": "2.0.0", + "Properties": [ + { + "Name": "Name", + "PropertyType": "System.String", + "IsMandatory": true, + "Values": [] + }, + { + "Name": "Ensure", + "PropertyType": "System.String", + "IsMandatory": false, + "Values": [ + "Present", + "Absent" + ] + } + ], + "Capabilities": [ + "get", + "set", + "test" + ] + }, + "LastWriteTimes": {} + } + ] +} \ No newline at end of file diff --git a/tests/Unit/Fixtures/CommandResource/simple-tool.dsc.resource.json b/tests/Unit/Fixtures/CommandResource/simple-tool.dsc.resource.json new file mode 100644 index 0000000..6af9198 --- /dev/null +++ b/tests/Unit/Fixtures/CommandResource/simple-tool.dsc.resource.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test.Company/Tool", + "kind": "resource", + "version": "1.0.0", + "description": "Fixture command resource for authoring tests.", + "get": { + "executable": "tool", + "args": [ + "get" + ] + }, + "set": { + "executable": "tool", + "args": [ + "set" + ], + "return": "state", + "implementsPretest": false + }, + "test": { + "executable": "tool", + "args": [ + "test" + ], + "return": "state" + }, + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/tests/Unit/Private/ConvertTo-DscAuthoringHashtable.Tests.ps1 b/tests/Unit/Private/ConvertTo-DscAuthoringHashtable.Tests.ps1 new file mode 100644 index 0000000..e497462 --- /dev/null +++ b/tests/Unit/Private/ConvertTo-DscAuthoringHashtable.Tests.ps1 @@ -0,0 +1,63 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'ConvertTo-DscAuthoringHashtable' { + + Context 'Microsoft DSC authoring objects' { + + It 'Converts DSC expressions to their expression text' { + InModuleScope 'DscResource.Authoring' { + $expression = [Microsoft.DSC.Expression]::new('[parameters(''name'')]') + $result = ConvertTo-DscAuthoringHashtable -InputObject $expression + + $result | Should -BeExactly '[parameters(''name'')]' + } + } + + It 'Uses ToHashtable when an object exposes it' { + InModuleScope 'DscResource.Authoring' { + $resource = [Microsoft.DSC.Resource]::new() + $resource.Name = 'Example' + $resource.Type = 'Test/Resource' + $resource.Properties = @{ name = 'example' } + + $result = ConvertTo-DscAuthoringHashtable -InputObject $resource + + $result['name'] | Should -BeExactly 'Example' + $result['type'] | Should -BeExactly 'Test/Resource' + $result['properties']['name'] | Should -BeExactly 'example' + } + } + } + + Context 'Nested object graphs' { + + It 'Recursively converts PSCustomObjects and arrays' { + InModuleScope 'DscResource.Authoring' { + $inputObject = [PSCustomObject]@{ + Name = 'Example' + Items = @( + [PSCustomObject]@{ Value = 'one' } + [PSCustomObject]@{ Value = 'two' } + ) + } + + $result = ConvertTo-DscAuthoringHashtable -InputObject $inputObject + + $result['Name'] | Should -BeExactly 'Example' + $result['Items'][0]['Value'] | Should -BeExactly 'one' + $result['Items'][1]['Value'] | Should -BeExactly 'two' + } + } + } +} \ No newline at end of file diff --git a/tests/Unit/Private/ConvertTo-DscExpressionText.Tests.ps1 b/tests/Unit/Private/ConvertTo-DscExpressionText.Tests.ps1 new file mode 100644 index 0000000..37e8cf2 --- /dev/null +++ b/tests/Unit/Private/ConvertTo-DscExpressionText.Tests.ps1 @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'ConvertTo-DscExpressionText' { + + It 'Converts Invoke-DscFunction calls and string concatenation to concat syntax' { + InModuleScope 'DscResource.Authoring' { + $scriptBlock = { (Invoke-DscFunction -Name systemRoot) + '\Windows\System32' } + $result = ConvertTo-DscExpressionText -Ast $scriptBlock.Ast + + $result | Should -BeExactly "concat(systemRoot(), '\Windows\System32')" + } + } + + It 'Converts variables to parameter references' { + InModuleScope 'DscResource.Authoring' { + $scriptBlock = { $installRoot } + $result = ConvertTo-DscExpressionText -Ast $scriptBlock.Ast + + $result | Should -BeExactly "parameters('installRoot')" + } + } + + It 'Throws for unsupported commands' { + InModuleScope 'DscResource.Authoring' { + $scriptBlock = { Get-ChildItem } + + { ConvertTo-DscExpressionText -Ast $scriptBlock.Ast } | Should -Throw + } + } +} \ No newline at end of file diff --git a/tests/Unit/Private/ConvertTo-DscResourceAuthoringMetadata.Tests.ps1 b/tests/Unit/Private/ConvertTo-DscResourceAuthoringMetadata.Tests.ps1 new file mode 100644 index 0000000..7628713 --- /dev/null +++ b/tests/Unit/Private/ConvertTo-DscResourceAuthoringMetadata.Tests.ps1 @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'ConvertTo-DscResourceAuthoringMetadata' { + + It 'Normalizes command resource manifests with embedded schemas' { + InModuleScope 'DscResource.Authoring' { + $manifest = [ordered]@{ + type = 'Test.Company/Tool' + kind = 'resource' + version = '1.0.0' + description = 'A test resource.' + get = @{ executable = 'tool'; args = @('get') } + set = @{ executable = 'tool'; args = @('set') } + schema = @{ + embedded = [ordered]@{ + type = 'object' + properties = [ordered]@{ name = @{ type = 'string' } } + } + } + } + + $result = ConvertTo-DscResourceAuthoringMetadata -Manifest $manifest -SourcePath 'resource.json' + + $result.Type | Should -BeExactly 'Test.Company/Tool' + $result.Capabilities | Should -Contain 'get' + $result.Capabilities | Should -Contain 'set' + $result.Schema.properties.Keys | Should -Contain 'name' + $result.SourcePath | Should -BeExactly 'resource.json' + } + } + + It 'Normalizes adapter cache entries into schema-backed metadata' { + InModuleScope 'DscResource.Authoring' { + $entry = [PSCustomObject]@{ + Type = 'Cache.Module/Resource' + DscResourceInfo = [PSCustomObject]@{ + Name = 'Resource' + FriendlyName = 'Cached Resource' + ModuleName = 'Cache.Module' + Version = '2.0.0' + Properties = @( + [PSCustomObject]@{ + Name = 'Name' + PropertyType = 'System.String' + IsMandatory = $true + Values = @() + } + [PSCustomObject]@{ + Name = 'Ensure' + PropertyType = 'System.String' + IsMandatory = $false + Values = @('Present', 'Absent') + } + ) + Capabilities = @('get', 'set', 'test') + } + } + + $result = ConvertTo-DscResourceAuthoringMetadata -AdapterCacheEntry $entry -SourcePath 'cache.json' -RequireAdapter 'Microsoft.Adapter/PowerShell' + + $result.Type | Should -BeExactly 'Cache.Module/Resource' + $result.RequireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' + @($result.Schema.required) | Should -Contain 'Name' + @($result.Schema.properties['Ensure'].enum) | Should -Contain 'Present' + } + } +} \ No newline at end of file diff --git a/tests/Unit/Private/ConvertTo-Hashtable.Tests.ps1 b/tests/Unit/Private/ConvertTo-Hashtable.Tests.ps1 index 7a7d82c..3955583 100644 --- a/tests/Unit/Private/ConvertTo-Hashtable.Tests.ps1 +++ b/tests/Unit/Private/ConvertTo-Hashtable.Tests.ps1 @@ -51,6 +51,13 @@ Describe 'ConvertTo-Hashtable' { Context 'Scalar input' { + It 'Returns null unchanged' { + InModuleScope 'DscResource.Authoring' { + $result = ConvertTo-Hashtable -InputObject $null + $result | Should -BeNullOrEmpty + } + } + It 'Returns a string unchanged' { InModuleScope 'DscResource.Authoring' { $result = ConvertTo-Hashtable -InputObject 'hello' diff --git a/tests/Unit/Private/Get-DscAdapterCachePath.Tests.ps1 b/tests/Unit/Private/Get-DscAdapterCachePath.Tests.ps1 new file mode 100644 index 0000000..242a372 --- /dev/null +++ b/tests/Unit/Private/Get-DscAdapterCachePath.Tests.ps1 @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'Get-DscAdapterCachePath' { + + It 'Returns the PowerShell adapter cache path' { + InModuleScope 'DscResource.Authoring' { + $result = Get-DscAdapterCachePath -Adapter PowerShell + + $result | Should -Not -BeNullOrEmpty + Split-Path -Path $result -Leaf | Should -BeExactly 'PSAdapterCache.json' + } + } + + It 'Returns the Windows PowerShell adapter cache path when LOCALAPPDATA is available' -Skip:([System.String]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + InModuleScope 'DscResource.Authoring' { + $result = Get-DscAdapterCachePath -Adapter WindowsPowerShell + + $result | Should -Not -BeNullOrEmpty + Split-Path -Path $result -Leaf | Should -BeExactly 'WindowsPSAdapterCache.json' + } + } + + It 'Returns unique paths for all known adapter caches' { + InModuleScope 'DscResource.Authoring' { + $result = @(Get-DscAdapterCachePath -Adapter All) + + $result.Count | Should -Be ($result | Select-Object -Unique).Count + } + } +} \ No newline at end of file diff --git a/tests/Unit/Private/Get-DscResourceAuthoringTypeName.Tests.ps1 b/tests/Unit/Private/Get-DscResourceAuthoringTypeName.Tests.ps1 new file mode 100644 index 0000000..fe5b669 --- /dev/null +++ b/tests/Unit/Private/Get-DscResourceAuthoringTypeName.Tests.ps1 @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'Get-DscResourceAuthoringTypeName' { + + It 'Converts slashes and dots to DSC namespace segments' { + InModuleScope 'DscResource.Authoring' { + $result = Get-DscResourceAuthoringTypeName -ResourceType 'Microsoft.Windows/Service' + + $result | Should -BeExactly 'DSC.Microsoft.Windows.Service' + } + } + + It 'Sanitizes invalid C# identifier characters' { + InModuleScope 'DscResource.Authoring' { + $result = Get-DscResourceAuthoringTypeName -ResourceType 'Vendor/123-Resource' + + $result | Should -BeExactly 'DSC.Vendor._123_Resource' + } + } +} \ No newline at end of file diff --git a/tests/Unit/Private/New-DscResourceAuthoringTypeSource.Tests.ps1 b/tests/Unit/Private/New-DscResourceAuthoringTypeSource.Tests.ps1 new file mode 100644 index 0000000..5985fb8 --- /dev/null +++ b/tests/Unit/Private/New-DscResourceAuthoringTypeSource.Tests.ps1 @@ -0,0 +1,45 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'New-DscResourceAuthoringTypeSource' { + + It 'Builds C# source with schema property names as public object properties' { + InModuleScope 'DscResource.Authoring' { + $schema = [ordered]@{ + type = 'object' + properties = [ordered]@{ + Name = @{ type = 'string' } + '1Value' = @{ type = 'string' } + 'display-name' = @{ type = 'string' } + } + } + + $result = New-DscResourceAuthoringTypeSource -TypeName 'DSC.Test.Company.Tool' -Schema $schema + + $result | Should -Match 'namespace DSC.Test.Company' + $result | Should -Match 'public sealed class Tool' + $result | Should -Match 'public object Name \{ get; set; \}' + $result | Should -Match 'public object _1Value \{ get; set; \}' + $result | Should -Match 'public object display_name \{ get; set; \}' + } + } + + It 'Adds a Value property when no schema properties are available' { + InModuleScope 'DscResource.Authoring' { + $schema = [ordered]@{ type = 'object'; properties = [ordered]@{} } + $result = New-DscResourceAuthoringTypeSource -TypeName 'DSC.Test.Empty' -Schema $schema + + $result | Should -Match 'public object Value \{ get; set; \}' + } + } +} \ No newline at end of file diff --git a/tests/Unit/Private/Register-DscResourceFunction.Tests.ps1 b/tests/Unit/Private/Register-DscResourceFunction.Tests.ps1 new file mode 100644 index 0000000..284157a --- /dev/null +++ b/tests/Unit/Private/Register-DscResourceFunction.Tests.ps1 @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Remove-Item -Path 'Function:\global:Test.Private.RegisteredResource' -ErrorAction SilentlyContinue + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'Register-DscResourceFunction' { + + It 'Registers a function that emits Microsoft.DSC.Resource instances' { + InModuleScope 'DscResource.Authoring' { + $propertyTypeName = 'DSC.Private.Tests.RegisteredResource' + if (-not ($propertyTypeName -as [System.Type])) + { + Add-Type -TypeDefinition @' +namespace DSC.Private.Tests +{ + public sealed class RegisteredResource + { + public object name { get; set; } + public object enabled { get; set; } + } +} +'@ -Language CSharp + } + + $metadata = [ordered]@{ + Type = 'Test.Private/RegisteredResource' + RequireAdapter = 'Microsoft.Adapter/PowerShell' + Schema = [ordered]@{ + type = 'object' + properties = [ordered]@{ + name = @{ type = 'string' } + enabled = @{ type = 'boolean' } + } + } + } + + $functionName = Register-DscResourceFunction -Metadata $metadata -PropertyTypeName $propertyTypeName + $resource = Test.Private.RegisteredResource -InstanceName 'Example' -name 'example' -enabled $true + + $functionName | Should -BeExactly 'Test.Private.RegisteredResource' + $resource | Should -BeOfType ([Microsoft.DSC.Resource]) + $resource.Name | Should -BeExactly 'Example' + $resource.Type | Should -BeExactly 'Test.Private/RegisteredResource' + $resource.Directives.RequireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' + $resource.ToHashtable()['properties']['name'] | Should -BeExactly 'example' + $resource.ToHashtable()['properties']['enabled'] | Should -BeTrue + } + } +} \ No newline at end of file diff --git a/tests/Unit/Private/Resolve-DscResourceAuthoringSchemaCommand.Tests.ps1 b/tests/Unit/Private/Resolve-DscResourceAuthoringSchemaCommand.Tests.ps1 new file mode 100644 index 0000000..bd54b00 --- /dev/null +++ b/tests/Unit/Private/Resolve-DscResourceAuthoringSchemaCommand.Tests.ps1 @@ -0,0 +1,87 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'Resolve-DscResourceAuthoringSchemaCommand' { + + It 'Returns null when a manifest does not define a schema command' { + InModuleScope 'DscResource.Authoring' { + $manifest = [ordered]@{ + type = 'Test.Schema/Embedded' + schema = [ordered]@{ + embedded = [ordered]@{ + type = 'object' + properties = [ordered]@{} + } + } + } + + Resolve-DscResourceAuthoringSchemaCommand -Manifest $manifest | + Should -BeNullOrEmpty + } + } + + It 'Runs a schema command and returns the parsed JSON schema' { + InModuleScope 'DscResource.Authoring' { + $powerShellExecutable = (Get-Process -Id $PID).Path + $schemaCommand = @' +$schema = [ordered]@{ + type = 'object' + properties = [ordered]@{ + keyPath = [ordered]@{ type = 'string' } + _exist = [ordered]@{ type = 'boolean' } + } +} +$schema | ConvertTo-Json -Depth 10 -Compress +'@ + + $manifest = [ordered]@{ + type = 'Test.Schema/Command' + schema = [ordered]@{ + command = [ordered]@{ + executable = $powerShellExecutable + args = @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command', $schemaCommand) + } + } + } + + $result = Resolve-DscResourceAuthoringSchemaCommand -Manifest $manifest + + $result['type'] | Should -BeExactly 'object' + $result['properties'].Keys | Should -Contain 'keyPath' + $result['properties'].Keys | Should -Contain '_exist' + } + } + + It 'Returns null when a schema command fails' { + InModuleScope 'DscResource.Authoring' { + $powerShellExecutable = (Get-Process -Id $PID).Path + $schemaCommand = @' +[Console]::Error.WriteLine('expected schema failure') +exit 12 +'@ + + $manifest = [ordered]@{ + type = 'Test.Schema/Failure' + schema = [ordered]@{ + command = [ordered]@{ + executable = $powerShellExecutable + args = @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command', $schemaCommand) + } + } + } + + Resolve-DscResourceAuthoringSchemaCommand -Manifest $manifest | + Should -BeNullOrEmpty + } + } +} \ No newline at end of file diff --git a/tests/Unit/Public/Export-DscConfigurationDocument.Tests.ps1 b/tests/Unit/Public/Export-DscConfigurationDocument.Tests.ps1 new file mode 100644 index 0000000..531ed83 --- /dev/null +++ b/tests/Unit/Public/Export-DscConfigurationDocument.Tests.ps1 @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Export-DscConfigurationDocument' { + + BeforeAll { + Import-Module -Name 'DscResource.Authoring' -Force + + $fixturesPath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'Fixtures' + $commandResourcePath = Join-Path -Path (Join-Path -Path $fixturesPath -ChildPath 'CommandResource') -ChildPath 'simple-tool.dsc.resource.json' + Import-DscAuthoringResource -Path $commandResourcePath + } + + AfterAll { + Remove-Item -Path 'Function:\global:Test.Company.Tool' -ErrorAction SilentlyContinue + } + + It 'Exports a configuration document to JSON text' { + $resource = Test.Company.Tool -InstanceName 'Example tool' -name 'example' + $configuration = New-DscConfigurationDocument -Resource $resource + $json = $configuration | Export-DscConfigurationDocument + $parsed = $json | ConvertFrom-Json + + $parsed.'$schema' | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + $parsed.resources[0].type | Should -BeExactly 'Test.Company/Tool' + $parsed.resources[0].properties.name | Should -BeExactly 'example' + } + + It 'Exports resource instances directly by wrapping them in a configuration document' { + $resource = Test.Company.Tool -name 'example' + $json = $resource | Export-DscConfigurationDocument + $parsed = $json | ConvertFrom-Json + + $parsed.resources[0].type | Should -BeExactly 'Test.Company/Tool' + } +} \ No newline at end of file diff --git a/tests/Unit/Public/Import-DscAuthoringResource.Tests.ps1 b/tests/Unit/Public/Import-DscAuthoringResource.Tests.ps1 new file mode 100644 index 0000000..f0931d6 --- /dev/null +++ b/tests/Unit/Public/Import-DscAuthoringResource.Tests.ps1 @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Import-DscAuthoringResource' { + + BeforeAll { + Import-Module -Name 'DscResource.Authoring' -Force + + $fixturesPath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'Fixtures' + $commandResourcePath = Join-Path -Path (Join-Path -Path $fixturesPath -ChildPath 'CommandResource') -ChildPath 'simple-tool.dsc.resource.json' + } + + AfterAll { + Remove-Item -Path 'Function:\global:Test.Company.Tool' -ErrorAction SilentlyContinue + } + + Context 'When importing command resource manifests' { + + BeforeAll { + $registration = Import-DscAuthoringResource -Path $commandResourcePath -PassThru + } + + It 'Returns registration information when PassThru is specified' { + $registration.Type | Should -BeExactly 'Test.Company/Tool' + $registration.TypeName | Should -BeExactly 'DSC.Test.Company.Tool' + $registration.FunctionName | Should -BeExactly 'Test.Company.Tool' + } + + It 'Registers a generated resource function' { + Get-Command -Name 'Test.Company.Tool' -CommandType Function | Should -Not -BeNullOrEmpty + } + + It 'Creates resource instances from the generated function' { + $resource = Test.Company.Tool -InstanceName 'Example tool' -name 'example' -enabled $true + + $resource | Should -BeOfType ([Microsoft.DSC.Resource]) + $resource.Type | Should -BeExactly 'Test.Company/Tool' + $resource.Name | Should -BeExactly 'Example tool' + $resource.ToHashtable()['properties']['name'] | Should -BeExactly 'example' + $resource.ToHashtable()['properties']['enabled'] | Should -BeTrue + } + } + + Context 'When importing preloaded metadata' { + + BeforeAll { + Remove-Item -Path 'Function:\global:Test.Company.Tool' -ErrorAction SilentlyContinue + $metadata = Import-DscResourceAuthoringMetadata -Path $commandResourcePath + $registration = $metadata | Import-DscAuthoringResource -PassThru + } + + It 'Accepts metadata from the pipeline' { + $registration.Type | Should -BeExactly 'Test.Company/Tool' + Get-Command -Name 'Test.Company.Tool' -CommandType Function | Should -Not -BeNullOrEmpty + } + } +} \ No newline at end of file diff --git a/tests/Unit/Public/Import-DscResourceAuthoringMetadata.Tests.ps1 b/tests/Unit/Public/Import-DscResourceAuthoringMetadata.Tests.ps1 new file mode 100644 index 0000000..c74c77a --- /dev/null +++ b/tests/Unit/Public/Import-DscResourceAuthoringMetadata.Tests.ps1 @@ -0,0 +1,120 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Import-DscResourceAuthoringMetadata' { + + BeforeAll { + Import-Module -Name 'DscResource.Authoring' -Force + + $fixturesPath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'Fixtures' + $commandResourcePath = Join-Path -Path (Join-Path -Path $fixturesPath -ChildPath 'CommandResource') -ChildPath 'simple-tool.dsc.resource.json' + $adapterCachePath = Join-Path -Path (Join-Path -Path $fixturesPath -ChildPath 'AdapterCache') -ChildPath 'PSAdapterCache.json' + } + + Context 'When importing a command resource manifest' { + BeforeAll { + $metadata = Import-DscResourceAuthoringMetadata -Path $commandResourcePath + } + + It 'Returns normalized resource metadata' { + $metadata.Type | Should -BeExactly 'Test.Company/Tool' + $metadata.Kind | Should -BeExactly 'resource' + $metadata.Version | Should -BeExactly '1.0.0' + } + + It 'Reads the embedded schema without invoking dsc.exe' { + $metadata.Schema.properties.Keys | Should -Contain 'name' + $metadata.Schema.properties.Keys | Should -Contain 'enabled' + } + + It 'Infers capabilities from manifest operations' { + $metadata.Capabilities | Should -Contain 'get' + $metadata.Capabilities | Should -Contain 'set' + $metadata.Capabilities | Should -Contain 'test' + } + } + + Context 'When importing manifests with null JSON values' { + It 'Does not fail while normalizing the manifest' { + $manifestPath = Join-Path -Path $TestDrive -ChildPath 'null-value-tool.dsc.resource.json' + @' +{ + "type": "Test.Company/NullValueTool", + "set": { + "executable": "tool", + "implementsPretest": null + }, + "schema": { + "embedded": { + "type": "object", + "properties": { + "name": { + "type": "string", + "default": null + } + } + } + } +} +'@ | Set-Content -Path $manifestPath + + { Import-DscResourceAuthoringMetadata -Path $manifestPath } | + Should -Not -Throw + } + } + + Context 'When resolving a schema command' { + BeforeAll { + $powerShellExecutable = (Get-Process -Id $PID).Path + $schemaCommand = @' +$schema = [ordered]@{ + type = 'object' + properties = [ordered]@{ + keyPath = [ordered]@{ type = 'string' } + valueName = [ordered]@{ type = 'string' } + } +} +$schema | ConvertTo-Json -Depth 10 -Compress +'@ + $manifestPath = Join-Path -Path $TestDrive -ChildPath 'schema-command-tool.dsc.resource.json' + [ordered]@{ + type = 'Test.Company/SchemaCommandTool' + schema = [ordered]@{ + command = [ordered]@{ + executable = $powerShellExecutable + args = @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command', $schemaCommand) + } + } + } | ConvertTo-Json -Depth 20 | Set-Content -Path $manifestPath + } + + It 'Does not run schema commands by default' { + $metadata = Import-DscResourceAuthoringMetadata -Path $manifestPath + + $metadata.Schema | Should -BeNullOrEmpty + } + + It 'Uses schema command output when requested' { + $metadata = Import-DscResourceAuthoringMetadata -Path $manifestPath -ResolveSchemaCommand + + $metadata.Schema.properties.Keys | Should -Contain 'keyPath' + $metadata.Schema.properties.Keys | Should -Contain 'valueName' + } + } + + Context 'When importing a PowerShell adapter cache' { + BeforeAll { + $metadata = Import-DscResourceAuthoringMetadata -AdapterCachePath $adapterCachePath + } + + It 'Returns metadata for cached adapted resources' { + $metadata.Type | Should -BeExactly 'Cache.Module/Resource' + $metadata.RequireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' + } + + It 'Builds a schema from cached DSC resource properties' { + @($metadata.Schema.required) | Should -Contain 'Name' + @($metadata.Schema.properties['Ensure'].enum) | Should -Contain 'Present' + } + } +} \ No newline at end of file diff --git a/tests/Unit/Public/New-DscConfigurationDocument.Tests.ps1 b/tests/Unit/Public/New-DscConfigurationDocument.Tests.ps1 new file mode 100644 index 0000000..c33277c --- /dev/null +++ b/tests/Unit/Public/New-DscConfigurationDocument.Tests.ps1 @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'New-DscConfigurationDocument' { + + BeforeAll { + Import-Module -Name 'DscResource.Authoring' -Force + } + + Context 'When creating a document from resources' { + + BeforeAll { + $resource = [Microsoft.DSC.Resource]::new() + $resource.Name = 'Example resource' + $resource.Type = 'Test.Company/Tool' + $resource.Properties = @{ name = 'example' } + + $configuration = New-DscConfigurationDocument -Resource $resource + } + + It 'Returns a Microsoft.DSC.Configuration object' { + $configuration | Should -BeOfType ([Microsoft.DSC.Configuration]) + } + + It 'Adds supplied resources to the configuration document' { + $configuration.Resources | Should -HaveCount 1 + $configuration.Resources[0].Name | Should -BeExactly 'Example resource' + $configuration.Resources[0].Type | Should -BeExactly 'Test.Company/Tool' + } + + It 'Sets the default configuration document schema' { + $configuration.Schema | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + } + } + + Context 'When resources are provided from the pipeline' { + + BeforeAll { + $firstResource = [Microsoft.DSC.Resource]::new() + $firstResource.Name = 'First' + $firstResource.Type = 'Test.Company/Tool' + + $secondResource = [Microsoft.DSC.Resource]::new() + $secondResource.Name = 'Second' + $secondResource.Type = 'Test.Company/Tool' + + $configuration = $firstResource, $secondResource | New-DscConfigurationDocument + } + + It 'Collects every resource from the pipeline' { + $configuration.Resources | Should -HaveCount 2 + $configuration.Resources[0].Name | Should -BeExactly 'First' + $configuration.Resources[1].Name | Should -BeExactly 'Second' + } + } + + Context 'When optional document data is supplied' { + + BeforeAll { + $resource = [Microsoft.DSC.Resource]::new() + $resource.Name = 'Example resource' + $resource.Type = 'Test.Company/Tool' + + $directive = [Microsoft.DSC.Directives]::new() + $directive.SecurityContext = 'Elevated' + + $configuration = New-DscConfigurationDocument ` + -Resource $resource ` + -Parameter @{ serviceName = @{ type = 'string'; defaultValue = 'sshd' } } ` + -Directive $directive ` + -Metadata @{ owner = 'DSC' } + } + + It 'Stores configuration parameters' { + $configuration.Parameters['serviceName']['type'] | Should -BeExactly 'string' + } + + It 'Stores configuration directives' { + $configuration.Directives.SecurityContext | Should -BeExactly 'Elevated' + } + + It 'Stores metadata' { + $configuration.Metadata['owner'] | Should -BeExactly 'DSC' + } + } +} \ No newline at end of file diff --git a/tests/Unit/Public/New-DscExpression.Tests.ps1 b/tests/Unit/Public/New-DscExpression.Tests.ps1 new file mode 100644 index 0000000..9cf321c --- /dev/null +++ b/tests/Unit/Public/New-DscExpression.Tests.ps1 @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'New-DscExpression' { + + BeforeAll { + Import-Module -Name 'DscResource.Authoring' -Force + } + + It 'Converts Invoke-DscFunction calls and string concatenation' { + $expression = New-DscExpression -ScriptBlock { (Invoke-DscFunction -Name systemRoot) + '\Windows\System32' } + + $expression | Should -BeOfType ([Microsoft.DSC.Expression]) + $expression.Value | Should -BeExactly "[concat(systemRoot(), '\Windows\System32')]" + } + + It 'Converts variables to parameter references' { + $expression = New-DscExpression -ScriptBlock { $installRoot } + + $expression.Value | Should -BeExactly "[parameters('installRoot')]" + } +} \ No newline at end of file diff --git a/tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 b/tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 new file mode 100644 index 0000000..94ed965 --- /dev/null +++ b/tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Register-DscResourceAuthoringType' { + + BeforeAll { + Import-Module -Name 'DscResource.Authoring' -Force + + $fixturesPath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'Fixtures' + $commandResourcePath = Join-Path -Path (Join-Path -Path $fixturesPath -ChildPath 'CommandResource') -ChildPath 'simple-tool.dsc.resource.json' + $metadata = Import-DscResourceAuthoringMetadata -Path $commandResourcePath + $registration = $metadata | Register-DscResourceAuthoringType + } + + AfterAll { + Remove-Item -Path 'Function:\global:Test.Company.Tool' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\global:Test.Company.CompletionTool' -ErrorAction SilentlyContinue + } + + It 'Registers a .NET authoring type in the DSC namespace' { + $registration.TypeName | Should -BeExactly 'DSC.Test.Company.Tool' + $registration.TypeName -as [System.Type] | Should -Not -BeNullOrEmpty + } + + It 'Registers a resource function named from the DSC resource type' { + $registration.FunctionName | Should -BeExactly 'Test.Company.Tool' + Get-Command -Name 'Test.Company.Tool' -CommandType Function | Should -Not -BeNullOrEmpty + } + + It 'Emits Microsoft.DSC.Resource instances from generated functions' { + $resource = Test.Company.Tool -InstanceName 'Example tool' -name 'example' -enabled $true + $resource | Should -BeOfType ([Microsoft.DSC.Resource]) + $resource.Type | Should -BeExactly 'Test.Company/Tool' + $resource.Name | Should -BeExactly 'Example tool' + } + + It 'Serializes generated property objects into DSC resource properties' { + $resource = Test.Company.Tool -name 'example' -enabled $true + $hashtable = $resource.ToHashtable() + + $hashtable['properties']['name'] | Should -BeExactly 'example' + $hashtable['properties']['enabled'] | Should -BeTrue + } + + It 'Exposes schema properties as generated function parameters' { + $metadata = [PSCustomObject]@{ + Type = 'Test.Company/CompletionTool' + Kind = 'resource' + Version = '1.0.0' + Description = 'Completion fixture.' + RequireAdapter = $null + Capabilities = @('get') + Schema = [ordered]@{ + type = 'object' + properties = [ordered]@{ + keyPath = [ordered]@{ type = 'string' } + valueName = [ordered]@{ type = 'string' } + } + } + SourcePath = $null + Manifest = $null + } + + $metadata | Register-DscResourceAuthoringType | Out-Null + $parameters = (Get-Command -Name 'Test.Company.CompletionTool' -CommandType Function).Parameters + + $parameters.Keys | Should -Contain 'keyPath' + $parameters.Keys | Should -Contain 'valueName' + } +} \ No newline at end of file From 445154b08a6af5367d63a45b9c6d54f598687157 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 29 May 2026 06:34:33 +0200 Subject: [PATCH 2/7] Fix scriptanalyzer --- source/Private/ConvertTo-DscAuthoringHashtable.ps1 | 3 ++- source/Private/ConvertTo-DscResourceAuthoringMetadata.ps1 | 1 + source/Private/New-DscResourceAuthoringTypeSource.ps1 | 7 +++++-- source/Public/Import-DscResourceAuthoringMetadata.ps1 | 1 + source/Public/New-DscConfigurationDocument.ps1 | 7 +++++-- source/Public/New-DscExpression.ps1 | 7 +++++-- 6 files changed, 19 insertions(+), 7 deletions(-) diff --git a/source/Private/ConvertTo-DscAuthoringHashtable.ps1 b/source/Private/ConvertTo-DscAuthoringHashtable.ps1 index de62644..ea5fb97 100644 --- a/source/Private/ConvertTo-DscAuthoringHashtable.ps1 +++ b/source/Private/ConvertTo-DscAuthoringHashtable.ps1 @@ -21,12 +21,13 @@ for ConvertTo-Json. .OUTPUTS - Returns a hashtable, array, scalar value, or null, depending on the input object. + Returns an ordered dictionary, hashtable, array, scalar value, or null, depending on the input object. #> function ConvertTo-DscAuthoringHashtable { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] + [OutputType([System.Collections.Specialized.OrderedDictionary])] [OutputType([System.Object[]])] [OutputType([System.Object])] param diff --git a/source/Private/ConvertTo-DscResourceAuthoringMetadata.ps1 b/source/Private/ConvertTo-DscResourceAuthoringMetadata.ps1 index db922bb..f67f3b2 100644 --- a/source/Private/ConvertTo-DscResourceAuthoringMetadata.ps1 +++ b/source/Private/ConvertTo-DscResourceAuthoringMetadata.ps1 @@ -40,6 +40,7 @@ #> function ConvertTo-DscResourceAuthoringMetadata { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Metadata is a domain term used by the DSC resource manifest schema.')] [CmdletBinding(DefaultParameterSetName = 'Manifest')] [OutputType([System.Management.Automation.PSCustomObject])] param diff --git a/source/Private/New-DscResourceAuthoringTypeSource.ps1 b/source/Private/New-DscResourceAuthoringTypeSource.ps1 index 29cb28f..2567400 100644 --- a/source/Private/New-DscResourceAuthoringTypeSource.ps1 +++ b/source/Private/New-DscResourceAuthoringTypeSource.ps1 @@ -24,7 +24,7 @@ #> function New-DscResourceAuthoringTypeSource { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([System.String])] param ( @@ -66,7 +66,9 @@ function New-DscResourceAuthoringTypeSource ' public object Value { get; set; }' } - return @" + if ($PSCmdlet.ShouldProcess($TypeName, 'Create DSC resource authoring type source')) + { + return @" using System; namespace $namespace @@ -77,4 +79,5 @@ $propertyText } } "@ + } } \ No newline at end of file diff --git a/source/Public/Import-DscResourceAuthoringMetadata.ps1 b/source/Public/Import-DscResourceAuthoringMetadata.ps1 index a1163d0..7cc446c 100644 --- a/source/Public/Import-DscResourceAuthoringMetadata.ps1 +++ b/source/Public/Import-DscResourceAuthoringMetadata.ps1 @@ -34,6 +34,7 @@ #> function Import-DscResourceAuthoringMetadata { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Metadata is a domain term used by the DSC resource manifest schema.')] [CmdletBinding()] [OutputType([System.Management.Automation.PSCustomObject])] param diff --git a/source/Public/New-DscConfigurationDocument.ps1 b/source/Public/New-DscConfigurationDocument.ps1 index e59f021..a23cf6d 100644 --- a/source/Public/New-DscConfigurationDocument.ps1 +++ b/source/Public/New-DscConfigurationDocument.ps1 @@ -29,7 +29,7 @@ #> function New-DscConfigurationDocument { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] [OutputType([Microsoft.DSC.Configuration])] param ( @@ -75,6 +75,9 @@ function New-DscConfigurationDocument end { - Write-Output $configuration + if ($PSCmdlet.ShouldProcess('DSC configuration document', 'Create')) + { + Write-Output $configuration + } } } \ No newline at end of file diff --git a/source/Public/New-DscExpression.ps1 b/source/Public/New-DscExpression.ps1 index ff563e5..bb09c17 100644 --- a/source/Public/New-DscExpression.ps1 +++ b/source/Public/New-DscExpression.ps1 @@ -20,7 +20,7 @@ #> function New-DscExpression { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] [OutputType([Microsoft.DSC.Expression])] param ( @@ -30,5 +30,8 @@ function New-DscExpression ) $body = ConvertTo-DscExpressionText -Ast $ScriptBlock.Ast - return [Microsoft.DSC.Expression]::new("[$body]") + if ($PSCmdlet.ShouldProcess('DSC expression', 'Create')) + { + return [Microsoft.DSC.Expression]::new("[$body]") + } } \ No newline at end of file From cb5870b67c8590d708f67dbfccc14e18010df706 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 29 May 2026 13:20:51 +0200 Subject: [PATCH 3/7] Fix enum types --- CHANGELOG.md | 4 + .../005.MicrosoftDscAuthoringTypes.ps1 | 62 ++++- .../006.DscExecutableResourceDiscovery.ps1 | 222 +++++++++++++++++ .../Private/ConvertFrom-CommentBasedHelp.ps1 | 2 +- .../ConvertTo-AdaptedResourceManifest.ps1 | 2 +- source/Private/ConvertTo-Hashtable.ps1 | 2 +- source/Private/ConvertTo-JsonSchemaType.ps1 | 2 +- source/Private/Get-ClassCommentBasedHelp.ps1 | 2 +- .../Private/Get-DscResourceTypeDefinition.ps1 | 2 +- .../Private/Invoke-DscResourceDiscovery.ps1 | 52 ++++ .../New-DscResourceAuthoringTypeSource.ps1 | 229 +++++++++++++++++- source/Private/New-EmbeddedJsonSchema.ps1 | 8 +- .../Private/Register-DscResourceFunction.ps1 | 22 +- source/Private/Resolve-ModuleInfo.ps1 | 2 +- .../Private/Test-IsEcmaCompatiblePattern.ps1 | 2 +- .../Import-DscAdaptedResourceManifest.ps1 | 2 +- source/Public/Import-DscAuthoringResource.ps1 | 43 +++- .../Import-DscResourceAuthoringMetadata.ps1 | 72 +++++- source/Public/Import-DscResourceManifest.ps1 | 2 +- .../Public/New-DscAdaptedResourceManifest.ps1 | 4 +- .../Public/New-DscConfigurationDocument.ps1 | 10 +- source/Public/New-DscPropertyOverride.ps1 | 12 +- .../Update-DscAdaptedResourceManifest.ps1 | 2 +- source/WikiSource/Configuration-Authoring.md | 153 +++++++++--- .../ConvertTo-DscExpressionText.Tests.ps1 | 82 +++++++ ...To-DscPropertyOverrideFromConfig.Tests.ps1 | 56 +++++ ...tTo-DscResourceAuthoringMetadata.Tests.ps1 | 43 ++++ .../ConvertTo-JsonSchemaType.Tests.ps1 | 14 ++ .../Get-ClassCommentBasedHelp.Tests.ps1 | 25 ++ .../Get-DscResourceCapability.Tests.ps1 | 37 +++ .../Get-DscResourceTypeDefinition.Tests.ps1 | 20 ++ .../Invoke-DscResourceDiscovery.Tests.ps1 | 171 +++++++++++++ ...w-DscResourceAuthoringTypeSource.Tests.ps1 | 37 +++ ...scResourceAuthoringSchemaCommand.Tests.ps1 | 62 +++++ .../Unit/Private/Resolve-ModuleInfo.Tests.ps1 | 22 ++ .../Export-DscConfigurationDocument.Tests.ps1 | 36 ++- .../Import-DscAuthoringResource.Tests.ps1 | 75 ++++++ ...ort-DscResourceAuthoringMetadata.Tests.ps1 | 130 ++++++++++ .../New-DscConfigurationDocument.Tests.ps1 | 5 + ...egister-DscResourceAuthoringType.Tests.ps1 | 119 +++++++++ 40 files changed, 1756 insertions(+), 93 deletions(-) create mode 100644 source/Classes/006.DscExecutableResourceDiscovery.ps1 create mode 100644 source/Private/Invoke-DscResourceDiscovery.ps1 create mode 100644 tests/Unit/Private/Invoke-DscResourceDiscovery.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0406fa0..58dceee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added support for configuration document variables through `New-DscConfigurationDocument`. +- Added dsc.exe-based discovery for `Import-DscAuthoringResource` and `Import-DscResourceAuthoringMetadata`, including adapted resource metadata. - Added support for `[ValidatePattern()]` attributes on DSC properties, emitting the regex as a `pattern` keyword in the generated JSON schema. - Added `-AllowNonEcmaPattern` switch to `New-DscAdaptedResourceManifest` to force-emit patterns containing .NET-specific regex constructs that are not ECMA 262 compatible. @@ -14,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed build task import so module aliases are correctly exported when the module is loaded. - Fixed `[ValidateSet()]` attributes on `[string]` DSC properties now being correctly emitted as `enum` in the generated JSON schema. +- Fixed generated authoring types to initialize JSON Schema `default` values and expose JSON Schema `enum` values as validated .NET enum properties. +- Fixed exported configuration documents to preserve `$schema` as the first top-level property. - Fixed `UTF8BOM` issue on new script. - Fixed taskss not being updated in the manifest. diff --git a/source/Classes/005.MicrosoftDscAuthoringTypes.ps1 b/source/Classes/005.MicrosoftDscAuthoringTypes.ps1 index a5417c3..af7f3a5 100644 --- a/source/Classes/005.MicrosoftDscAuthoringTypes.ps1 +++ b/source/Classes/005.MicrosoftDscAuthoringTypes.ps1 @@ -8,6 +8,7 @@ if (-not ('Microsoft.DSC.Configuration' -as [System.Type])) using System; using System.Collections; using System.Collections.Generic; +using System.Collections.Specialized; using System.Reflection; namespace Microsoft.DSC @@ -40,9 +41,9 @@ namespace Microsoft.DSC { public string SecurityContext { get; set; } - public Hashtable ToHashtable() + public OrderedDictionary ToHashtable() { - Hashtable result = new Hashtable(StringComparer.OrdinalIgnoreCase); + OrderedDictionary result = new OrderedDictionary(StringComparer.OrdinalIgnoreCase); if (!String.IsNullOrEmpty(this.SecurityContext)) { @@ -57,9 +58,9 @@ namespace Microsoft.DSC { public string RequireAdapter { get; set; } - public Hashtable ToHashtable() + public OrderedDictionary ToHashtable() { - Hashtable result = new Hashtable(StringComparer.OrdinalIgnoreCase); + OrderedDictionary result = new OrderedDictionary(StringComparer.OrdinalIgnoreCase); if (!String.IsNullOrEmpty(this.RequireAdapter)) { @@ -84,9 +85,9 @@ namespace Microsoft.DSC this.Metadata = new Hashtable(StringComparer.OrdinalIgnoreCase); } - public Hashtable ToHashtable() + public OrderedDictionary ToHashtable() { - Hashtable result = new Hashtable(StringComparer.OrdinalIgnoreCase); + OrderedDictionary result = new OrderedDictionary(StringComparer.OrdinalIgnoreCase); if (!String.IsNullOrEmpty(this.Name)) { @@ -110,7 +111,7 @@ namespace Microsoft.DSC if (this.Directives != null) { - Hashtable directives = this.Directives.ToHashtable(); + OrderedDictionary directives = this.Directives.ToHashtable(); if (directives.Count > 0) { @@ -131,6 +132,7 @@ namespace Microsoft.DSC { public string Schema { get; set; } public object Parameters { get; set; } + public object Variables { get; set; } public Directives Directives { get; set; } public List Resources { get; set; } public Hashtable Metadata { get; set; } @@ -142,9 +144,9 @@ namespace Microsoft.DSC this.Metadata = new Hashtable(StringComparer.OrdinalIgnoreCase); } - public Hashtable ToHashtable() + public OrderedDictionary ToHashtable() { - Hashtable result = new Hashtable(StringComparer.OrdinalIgnoreCase); + OrderedDictionary result = new OrderedDictionary(StringComparer.OrdinalIgnoreCase); if (!String.IsNullOrEmpty(this.Schema)) { @@ -156,9 +158,14 @@ namespace Microsoft.DSC result["parameters"] = AuthoringData.Normalize(this.Parameters); } + if (this.Variables != null) + { + result["variables"] = AuthoringData.Normalize(this.Variables); + } + if (this.Directives != null) { - Hashtable directives = this.Directives.ToHashtable(); + OrderedDictionary directives = this.Directives.ToHashtable(); if (directives.Count > 0) { @@ -219,7 +226,7 @@ namespace Microsoft.DSC if (dictionary != null) { - Hashtable result = new Hashtable(StringComparer.OrdinalIgnoreCase); + OrderedDictionary result = new OrderedDictionary(StringComparer.OrdinalIgnoreCase); foreach (DictionaryEntry entry in dictionary) { @@ -245,12 +252,27 @@ namespace Microsoft.DSC Type valueType = value.GetType(); + Type nullableType = Nullable.GetUnderlyingType(valueType); + if (nullableType != null) + { + PropertyInfo nullableValue = valueType.GetProperty("Value"); + if (nullableValue != null) + { + return Normalize(nullableValue.GetValue(value, null)); + } + } + + if (valueType.IsEnum) + { + return value.ToString(); + } + if (valueType.IsPrimitive || value is decimal || value is DateTime || value is Guid) { return value; } - Hashtable properties = new Hashtable(StringComparer.OrdinalIgnoreCase); + OrderedDictionary properties = new OrderedDictionary(StringComparer.OrdinalIgnoreCase); foreach (PropertyInfo property in valueType.GetProperties(BindingFlags.Instance | BindingFlags.Public)) { @@ -259,6 +281,22 @@ namespace Microsoft.DSC continue; } + MethodInfo shouldSerialize = valueType.GetMethod( + "ShouldSerialize" + property.Name, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + Type.EmptyTypes, + null); + + if (shouldSerialize != null && shouldSerialize.ReturnType == typeof(bool)) + { + bool serializeProperty = (bool) shouldSerialize.Invoke(value, null); + if (!serializeProperty) + { + continue; + } + } + object propertyValue = property.GetValue(value, null); if (propertyValue != null) diff --git a/source/Classes/006.DscExecutableResourceDiscovery.ps1 b/source/Classes/006.DscExecutableResourceDiscovery.ps1 new file mode 100644 index 0000000..0104500 --- /dev/null +++ b/source/Classes/006.DscExecutableResourceDiscovery.ps1 @@ -0,0 +1,222 @@ +class DscExecutableResourceDiscovery +{ + [System.String] $ExecutablePath + + DscExecutableResourceDiscovery([System.String] $ExecutablePath) + { + if ([System.String]::IsNullOrWhiteSpace($ExecutablePath)) + { + $this.ExecutablePath = 'dsc' + } + else + { + $this.ExecutablePath = $ExecutablePath + } + } + + [System.Object[]] GetResourceMetadata([System.String] $ResourceType, [System.String] $Adapter) + { + $arguments = [System.Collections.Generic.List[System.String]]::new() + $arguments.Add('resource') + $arguments.Add('list') + + if (-not [System.String]::IsNullOrWhiteSpace($ResourceType)) + { + $arguments.Add($ResourceType) + } + + if (-not [System.String]::IsNullOrWhiteSpace($Adapter)) + { + $arguments.Add('--adapter') + $arguments.Add($Adapter) + } + + $metadataItems = [System.Collections.Generic.List[System.Object]]::new() + foreach ($resourceInfo in $this.InvokeJson($arguments.ToArray())) + { + $metadata = $this.ConvertResourceInfo($resourceInfo) + if ($null -ne $metadata) + { + $metadataItems.Add($metadata) + } + } + + return $metadataItems.ToArray() + } + + hidden [System.Object[]] InvokeJson([System.String[]] $Arguments) + { + $command = Get-Command -Name $this.ExecutablePath -ErrorAction Stop + $commandName = if ($command.CommandType -eq 'Application' -or $command.CommandType -eq 'ExternalScript') + { + $command.Source + } + else + { + $this.ExecutablePath + } + + $errorPath = [System.IO.Path]::GetTempFileName() + try + { + $output = & $commandName @Arguments 2> $errorPath + $exitCode = $LASTEXITCODE + $errorText = if (Test-Path -LiteralPath $errorPath) + { + Get-Content -LiteralPath $errorPath -Raw + } + else + { + $null + } + + if ($exitCode -ne 0) + { + $commandText = @($this.ExecutablePath) + @($Arguments) -join ' ' + throw "Command '$commandText' failed with exit code $exitCode. $([System.String] $errorText)" + } + + $items = [System.Collections.Generic.List[System.Object]]::new() + $jsonText = (@($output) | ForEach-Object -Process { [System.String] $_ }) -join [System.Environment]::NewLine + + if ([System.String]::IsNullOrWhiteSpace($jsonText)) + { + return $items.ToArray() + } + + try + { + $parsed = ConvertTo-Hashtable -InputObject (ConvertFrom-Json -InputObject $jsonText -Depth 100) + $this.AddJsonItem($items, $parsed) + } + catch + { + foreach ($line in @($output)) + { + $json = [System.String] $line + if ([System.String]::IsNullOrWhiteSpace($json)) + { + continue + } + + $parsed = ConvertTo-Hashtable -InputObject (ConvertFrom-Json -InputObject $json -Depth 100) + $this.AddJsonItem($items, $parsed) + } + } + + return $items.ToArray() + } + finally + { + Remove-Item -LiteralPath $errorPath -ErrorAction SilentlyContinue + } + } + + hidden [void] AddJsonItem([System.Collections.Generic.List[System.Object]] $Items, [System.Object] $Item) + { + if ($null -eq $Item) + { + return + } + + if ($Item -is [System.Collections.IDictionary]) + { + $Items.Add($Item) + return + } + + if ($Item -is [System.Collections.IEnumerable] -and $Item -isnot [System.String]) + { + foreach ($entry in $Item) + { + if ($null -ne $entry) + { + $Items.Add($entry) + } + } + + return + } + + $Items.Add($Item) + } + + hidden [System.Object] ConvertResourceInfo([System.Object] $ResourceInfo) + { + $resource = ConvertTo-Hashtable -InputObject $ResourceInfo + if ($resource -isnot [System.Collections.IDictionary] -or -not $resource.Contains('type')) + { + return $null + } + + $capabilities = [System.Collections.Generic.List[System.String]]::new() + if ($resource.Contains('capabilities')) + { + foreach ($capability in @($resource['capabilities'])) + { + if ($null -ne $capability) + { + $capabilities.Add([System.String] $capability) + } + } + } + + $resourceType = [System.String] $resource['type'] + $schema = $this.ResolveSchema($resource) + + return [PSCustomObject] @{ + Type = $resourceType + Kind = if ($resource.Contains('kind')) { [System.String] $resource['kind'] } else { 'resource' } + Version = if ($resource.Contains('version')) { [System.String] $resource['version'] } else { $null } + Description = if ($resource.Contains('description')) { [System.String] $resource['description'] } else { $null } + RequireAdapter = if ($resource.Contains('requireAdapter')) { [System.String] $resource['requireAdapter'] } else { $null } + Capabilities = [System.String[]] $capabilities + Schema = $schema + SourcePath = if ($resource.Contains('path')) { [System.String] $resource['path'] } else { $null } + Manifest = if ($resource.Contains('manifest')) { $resource['manifest'] } else { $null } + } + } + + hidden [System.Collections.IDictionary] ResolveSchema([System.Collections.IDictionary] $ResourceInfo) + { + if ($ResourceInfo.Contains('schema')) + { + $schema = $this.UnwrapSchema($ResourceInfo['schema']) + if ($null -ne $schema) + { + return $schema + } + } + + if ($ResourceInfo.Contains('manifest') -and $ResourceInfo['manifest'] -is [System.Collections.IDictionary]) + { + $manifest = $ResourceInfo['manifest'] + if ($manifest.Contains('schema')) + { + return $this.UnwrapSchema($manifest['schema']) + } + } + + return $null + } + + hidden [System.Collections.IDictionary] UnwrapSchema([System.Object] $Schema) + { + if ($Schema -isnot [System.Collections.IDictionary]) + { + return $null + } + + if ($Schema.Contains('embedded') -and $Schema['embedded'] -is [System.Collections.IDictionary]) + { + return $Schema['embedded'] + } + + if ($Schema.Contains('properties') -or $Schema.Contains('type')) + { + return $Schema + } + + return $null + } +} diff --git a/source/Private/ConvertFrom-CommentBasedHelp.ps1 b/source/Private/ConvertFrom-CommentBasedHelp.ps1 index f68d27d..6f1ff6d 100644 --- a/source/Private/ConvertFrom-CommentBasedHelp.ps1 +++ b/source/Private/ConvertFrom-CommentBasedHelp.ps1 @@ -24,7 +24,7 @@ function ConvertFrom-CommentBasedHelp param ( [Parameter(Mandatory = $true)] - [string] + [System.String] $CommentText ) diff --git a/source/Private/ConvertTo-AdaptedResourceManifest.ps1 b/source/Private/ConvertTo-AdaptedResourceManifest.ps1 index a15d1a4..c0c1f8a 100644 --- a/source/Private/ConvertTo-AdaptedResourceManifest.ps1 +++ b/source/Private/ConvertTo-AdaptedResourceManifest.ps1 @@ -24,7 +24,7 @@ function ConvertTo-AdaptedResourceManifest param ( [Parameter(Mandatory = $true)] - [hashtable] + [System.Collections.Hashtable] $Hashtable ) diff --git a/source/Private/ConvertTo-Hashtable.ps1 b/source/Private/ConvertTo-Hashtable.ps1 index e41a7fe..a70fe9f 100644 --- a/source/Private/ConvertTo-Hashtable.ps1 +++ b/source/Private/ConvertTo-Hashtable.ps1 @@ -29,7 +29,7 @@ function ConvertTo-Hashtable ( [Parameter(Mandatory = $true)] [AllowNull()] - [object] + [System.Object] $InputObject ) diff --git a/source/Private/ConvertTo-JsonSchemaType.ps1 b/source/Private/ConvertTo-JsonSchemaType.ps1 index 02e5105..25fd670 100644 --- a/source/Private/ConvertTo-JsonSchemaType.ps1 +++ b/source/Private/ConvertTo-JsonSchemaType.ps1 @@ -27,7 +27,7 @@ function ConvertTo-JsonSchemaType param ( [Parameter(Mandatory = $true)] - [string] + [System.String] $TypeName ) diff --git a/source/Private/Get-ClassCommentBasedHelp.ps1 b/source/Private/Get-ClassCommentBasedHelp.ps1 index 352a948..e242b84 100644 --- a/source/Private/Get-ClassCommentBasedHelp.ps1 +++ b/source/Private/Get-ClassCommentBasedHelp.ps1 @@ -25,7 +25,7 @@ function Get-ClassCommentBasedHelp param ( [Parameter(Mandatory = $true)] - [string] + [System.String] $Path ) diff --git a/source/Private/Get-DscResourceTypeDefinition.ps1 b/source/Private/Get-DscResourceTypeDefinition.ps1 index 31d0325..75e33bd 100644 --- a/source/Private/Get-DscResourceTypeDefinition.ps1 +++ b/source/Private/Get-DscResourceTypeDefinition.ps1 @@ -23,7 +23,7 @@ function Get-DscResourceTypeDefinition param ( [Parameter(Mandatory = $true)] - [string] + [System.String] $Path ) diff --git a/source/Private/Invoke-DscResourceDiscovery.ps1 b/source/Private/Invoke-DscResourceDiscovery.ps1 new file mode 100644 index 0000000..a6a62d7 --- /dev/null +++ b/source/Private/Invoke-DscResourceDiscovery.ps1 @@ -0,0 +1,52 @@ +<# + .SYNOPSIS + Discovers DSC resource authoring metadata through dsc.exe. + + .DESCRIPTION + The function Invoke-DscResourceDiscovery calls dsc resource list and converts each returned + resource into the normalized metadata shape consumed by Register-DscResourceAuthoringType. + Discovery uses the schema data returned by dsc resource list when available and does not + make additional per-resource schema calls. + + .PARAMETER DscExecutablePath + The dsc executable or command name to invoke. Defaults to dsc. + + .PARAMETER ResourceType + Optional resource type or wildcard pattern to pass to dsc resource list. + + .PARAMETER Adapter + Optional adapter type to pass to dsc resource list. + + .EXAMPLE + Invoke-DscResourceDiscovery -ResourceType 'Microsoft/OSInfo' + + Discovers authoring metadata for Microsoft/OSInfo by invoking dsc.exe. + + .OUTPUTS + Returns normalized resource metadata objects. +#> +function Invoke-DscResourceDiscovery +{ + [CmdletBinding()] + [OutputType([System.Management.Automation.PSCustomObject])] + param + ( + [Parameter()] + [System.String] + $DscExecutablePath = 'dsc', + + [Parameter()] + [System.String] + $ResourceType, + + [Parameter()] + [System.String] + $Adapter + ) + + $discovery = [DscExecutableResourceDiscovery]::new($DscExecutablePath) + foreach ($metadata in $discovery.GetResourceMetadata($ResourceType, $Adapter)) + { + Write-Output $metadata + } +} diff --git a/source/Private/New-DscResourceAuthoringTypeSource.ps1 b/source/Private/New-DscResourceAuthoringTypeSource.ps1 index 2567400..82f6f1d 100644 --- a/source/Private/New-DscResourceAuthoringTypeSource.ps1 +++ b/source/Private/New-DscResourceAuthoringTypeSource.ps1 @@ -5,8 +5,8 @@ .DESCRIPTION The function New-DscResourceAuthoringTypeSource creates the C# source text used by Register-DscResourceAuthoringType to compile a resource-specific property object type. The - generated class exposes one public object-typed property for each JSON schema property so - PowerShell can provide type conversion and tab completion against the generated .NET type. + generated class exposes one public property for each JSON schema property so PowerShell can + provide type conversion and tab completion against the generated .NET type. .PARAMETER TypeName The fully qualified .NET type name to generate, including namespace and class name. @@ -37,47 +37,252 @@ function New-DscResourceAuthoringTypeSource $Schema ) + function ConvertTo-CSharpIdentifier + { + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Name, + + [Parameter()] + [System.String] + $Fallback = 'Value' + ) + + $identifier = $Name -replace '[^A-Za-z0-9_]', '_' + if ([System.String]::IsNullOrWhiteSpace($identifier)) + { + $identifier = $Fallback + } + + if ($identifier -notmatch '^[A-Za-z_]') + { + $identifier = "_$identifier" + } + + return $identifier + } + + function ConvertTo-CSharpStringLiteral + { + param + ( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [System.String] + $Value + ) + + $escaped = $Value.Replace('\', '\\').Replace('"', '\"').Replace("`r", '\r').Replace("`n", '\n').Replace("`t", '\t') + return '"{0}"' -f $escaped + } + + function ConvertTo-CSharpLiteral + { + param + ( + [Parameter()] + [AllowNull()] + [System.Object] + $Value + ) + + if ($null -eq $Value) + { + return 'null' + } + + if ($Value -is [System.Boolean]) + { + return $Value.ToString().ToLowerInvariant() + } + + if ($Value -is [System.Byte] -or + $Value -is [System.Int16] -or + $Value -is [System.Int32] -or + $Value -is [System.Int64] -or + $Value -is [System.UInt16] -or + $Value -is [System.UInt32] -or + $Value -is [System.UInt64] -or + $Value -is [System.Single] -or + $Value -is [System.Double] -or + $Value -is [System.Decimal]) + { + return [System.Convert]::ToString($Value, [System.Globalization.CultureInfo]::InvariantCulture) + } + + if ($Value -is [System.Collections.IDictionary]) + { + $entries = [System.Collections.Generic.List[System.String]]::new() + foreach ($key in $Value.Keys) + { + $entries.Add('{{ {0}, {1} }}' -f (ConvertTo-CSharpStringLiteral -Value ([System.String]$key)), (ConvertTo-CSharpLiteral -Value $Value[$key])) + } + + return 'new Hashtable(StringComparer.OrdinalIgnoreCase) { {0} }' -f ($entries -join ', ') + } + + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [System.String]) + { + $items = @($Value | ForEach-Object -Process { ConvertTo-CSharpLiteral -Value $_ }) + return 'new object[] { {0} }' -f ($items -join ', ') + } + + return ConvertTo-CSharpStringLiteral -Value ([System.String]$Value) + } + $lastDot = $TypeName.LastIndexOf('.') $namespace = $TypeName.Substring(0, $lastDot) $className = $TypeName.Substring($lastDot + 1) $properties = [System.Collections.Generic.List[System.String]]::new() + $enumDefinitions = [System.Collections.Generic.List[System.String]]::new() + $defaultAssignments = [System.Collections.Generic.List[System.String]]::new() + $members = [System.Collections.Generic.List[System.String]]::new() if ($Schema.Contains('properties') -and $Schema['properties'] -is [System.Collections.IDictionary]) { foreach ($propertyName in $Schema['properties'].Keys) { - $identifier = [System.String]$propertyName - $identifier = $identifier -replace '[^A-Za-z0-9_]', '_' - if ($identifier -notmatch '^[A-Za-z_]') + $identifier = ConvertTo-CSharpIdentifier -Name ([System.String]$propertyName) + $propertySchema = $Schema['properties'][$propertyName] + $propertyType = 'object' + $enumMembers = [ordered]@{} + + if ($propertySchema -is [System.Collections.IDictionary] -and $propertySchema.Contains('enum')) { - $identifier = "_$identifier" + $enumTypeName = '{0}{1}' -f $className, (Get-Culture).TextInfo.ToTitleCase($identifier).Replace('_', '') + $memberLines = [System.Collections.Generic.List[System.String]]::new() + + foreach ($enumValue in @($propertySchema['enum'])) + { + if ($null -eq $enumValue) + { + continue + } + + $memberIdentifier = ConvertTo-CSharpIdentifier -Name ([System.String]$enumValue) -Fallback 'Value' + if ($enumMembers.Contains($memberIdentifier)) + { + $memberIdentifier = '{0}_{1}' -f $memberIdentifier, $enumMembers.Count + } + + $enumMembers[$memberIdentifier] = [System.String]$enumValue + $memberLines.Add(" $memberIdentifier") + } + + if ($memberLines.Count -gt 0) + { + $propertyType = $enumTypeName + $enumDefinitions.Add(@" + public enum $enumTypeName + { +$($memberLines -join ",$([System.Environment]::NewLine)") + } +"@) + } + } + + if ($propertySchema -is [System.Collections.IDictionary] -and $propertySchema.Contains('default')) + { + $defaultValue = $propertySchema['default'] + if ($enumMembers.Count -gt 0 -and $null -ne $defaultValue) + { + $enumMemberName = ($enumMembers.Keys | Where-Object -FilterScript { $enumMembers[$_] -eq [System.String]$defaultValue } | Select-Object -First 1) + if ($enumMemberName) + { + $defaultAssignments.Add(" this.$identifier = $($propertyType.TrimEnd('?')).$enumMemberName;") + } + } + else + { + $defaultAssignments.Add(" this.$identifier = $(ConvertTo-CSharpLiteral -Value $defaultValue);") + } + } + + if ($enumMembers.Count -gt 0) + { + $members.Add(@" + private ${propertyType}? ${identifier}Value; + + public $propertyType $identifier + { + get + { + return this.${identifier}Value.GetValueOrDefault(); + } + + set + { + this.${identifier}Value = value; } + } - $properties.Add(" public object $identifier { get; set; }") + public bool ShouldSerialize$identifier() + { + return this.${identifier}Value.HasValue; + } +"@) + } + else + { + $properties.Add(" public $propertyType $identifier { get; set; }") + } } } - $propertyText = if ($properties.Count -gt 0) + foreach ($property in $properties) + { + $members.Add($property) + } + + $propertyText = if ($members.Count -gt 0) + { + ($members -join [System.Environment]::NewLine) + [System.Environment]::NewLine + } + else + { + ' public object Value { get; set; }' + [System.Environment]::NewLine + } + + $constructorAssignments = if ($defaultAssignments.Count -gt 0) { - $properties -join [System.Environment]::NewLine + [System.Environment]::NewLine + ($defaultAssignments -join [System.Environment]::NewLine) + [System.Environment]::NewLine } else { - ' public object Value { get; set; }' + [System.Environment]::NewLine + } + + $constructorText = @" + public $className() + {$constructorAssignments } +"@ + + $enumText = if ($enumDefinitions.Count -gt 0) + { + ($enumDefinitions -join [System.Environment]::NewLine) + [System.Environment]::NewLine + } + else + { + '' } if ($PSCmdlet.ShouldProcess($TypeName, 'Create DSC resource authoring type source')) { return @" using System; +using System.Collections; namespace $namespace { - public sealed class $className +$enumText public sealed class $className { +$constructorText + $propertyText } } "@ } -} \ No newline at end of file +} diff --git a/source/Private/New-EmbeddedJsonSchema.ps1 b/source/Private/New-EmbeddedJsonSchema.ps1 index fd4683d..adfd895 100644 --- a/source/Private/New-EmbeddedJsonSchema.ps1 +++ b/source/Private/New-EmbeddedJsonSchema.ps1 @@ -48,20 +48,20 @@ function New-EmbeddedJsonSchema param ( [Parameter(Mandatory = $true)] - [string] + [System.String] $ResourceName, [Parameter(Mandatory = $true)] [AllowEmptyCollection()] - [System.Collections.Generic.List[hashtable]] + [System.Collections.Generic.List[System.Collections.Hashtable]] $Properties, [Parameter()] - [string] + [System.String] $Description, [Parameter()] - [hashtable] + [System.Collections.Hashtable] $ClassHelp, [Parameter()] diff --git a/source/Private/Register-DscResourceFunction.ps1 b/source/Private/Register-DscResourceFunction.ps1 index bf4efe5..518977c 100644 --- a/source/Private/Register-DscResourceFunction.ps1 +++ b/source/Private/Register-DscResourceFunction.ps1 @@ -60,9 +60,26 @@ function Register-DscResourceFunction $safePropertyName = "_$safePropertyName" } + $validateSet = $null + if ($Metadata.Contains('Schema') -and + $Metadata['Schema'] -is [System.Collections.IDictionary] -and + $Metadata['Schema'].Contains('properties') -and + $Metadata['Schema']['properties'] -is [System.Collections.IDictionary] -and + $Metadata['Schema']['properties'].Contains($propertyName) -and + $Metadata['Schema']['properties'][$propertyName] -is [System.Collections.IDictionary] -and + $Metadata['Schema']['properties'][$propertyName].Contains('enum')) + { + $enumValues = @($Metadata['Schema']['properties'][$propertyName]['enum'] | Where-Object -FilterScript { $null -ne $_ }) + if ($enumValues.Count -gt 0) + { + $quotedEnumValues = @($enumValues | ForEach-Object -Process { "'$([System.String]$_ -replace '''', '''''')'" }) + $validateSet = " [ValidateSet($($quotedEnumValues -join ', '))]$([System.Environment]::NewLine)" + } + } + $parameterBlocks.Add(@" [Parameter(ValueFromPipelineByPropertyName = `$true)] - [System.Object]`$$safePropertyName +$validateSet [System.Object]`$$safePropertyName "@) } @@ -102,6 +119,7 @@ $parameterText process { `$propertyTypeName = $quotedPropertyTypeName `$resourceType = $quotedResourceType + `$resourceName = if (`$PSBoundParameters.ContainsKey('InstanceName')) { `$InstanceName } else { `$resourceType } `$propertyObject = New-Object -TypeName `$propertyTypeName foreach (`$propertyName in @($quotedProperties)) { @@ -116,7 +134,7 @@ process { } `$resource = [Microsoft.DSC.Resource]::new() - `$resource.Name = if (`$PSBoundParameters.ContainsKey('InstanceName')) { `$InstanceName } else { `$resourceType } + `$resource.Name = `$resourceName `$resource.Type = `$resourceType `$resource.Properties = `$propertyObject `$resource.DependsOn = `$DependsOn diff --git a/source/Private/Resolve-ModuleInfo.ps1 b/source/Private/Resolve-ModuleInfo.ps1 index 4659960..4b95b30 100644 --- a/source/Private/Resolve-ModuleInfo.ps1 +++ b/source/Private/Resolve-ModuleInfo.ps1 @@ -30,7 +30,7 @@ function Resolve-ModuleInfo param ( [Parameter(Mandatory = $true)] - [string] + [System.String] $Path ) diff --git a/source/Private/Test-IsEcmaCompatiblePattern.ps1 b/source/Private/Test-IsEcmaCompatiblePattern.ps1 index ed1b464..edb6a34 100644 --- a/source/Private/Test-IsEcmaCompatiblePattern.ps1 +++ b/source/Private/Test-IsEcmaCompatiblePattern.ps1 @@ -41,7 +41,7 @@ function Test-IsEcmaCompatiblePattern param ( [Parameter(Mandatory = $true)] - [string] + [System.String] $Pattern ) diff --git a/source/Public/Import-DscAdaptedResourceManifest.ps1 b/source/Public/Import-DscAdaptedResourceManifest.ps1 index 5fdd032..fb21c48 100644 --- a/source/Public/Import-DscAdaptedResourceManifest.ps1 +++ b/source/Public/Import-DscAdaptedResourceManifest.ps1 @@ -46,7 +46,7 @@ function Import-DscAdaptedResourceManifest return $true })] [Alias('FullName')] - [string] + [System.String] $Path ) diff --git a/source/Public/Import-DscAuthoringResource.ps1 b/source/Public/Import-DscAuthoringResource.ps1 index a2d447c..512118f 100644 --- a/source/Public/Import-DscAuthoringResource.ps1 +++ b/source/Public/Import-DscAuthoringResource.ps1 @@ -3,9 +3,9 @@ Imports DSC resources for configuration document authoring. .DESCRIPTION - The function Import-DscAuthoringResource reads DSC resource metadata without invoking - dsc.exe, generates .NET property types, and creates resource functions that emit - Microsoft.DSC.Resource instances. + The function Import-DscAuthoringResource reads DSC resource metadata from manifests, + preloaded metadata, adapter cache files, or dsc.exe discovery, generates .NET property + types, and creates resource functions that emit Microsoft.DSC.Resource instances. This command intentionally avoids the resource import command name used by PSDesiredStateConfiguration. @@ -29,6 +29,18 @@ Runs schema commands from resource manifests when an embedded schema is not available. This can call external resource executables and is disabled by default. + .PARAMETER UseDscExecutable + Uses dsc.exe resource discovery instead of reading manifests directly. + + .PARAMETER DscExecutablePath + The dsc executable or command name to invoke when UseDscExecutable is specified. + + .PARAMETER ResourceType + Optional resource type or wildcard pattern to pass to dsc resource list. + + .PARAMETER Adapter + Optional adapter type to pass to dsc resource list. + .PARAMETER PassThru Returns registration information for generated types and functions. @@ -71,6 +83,22 @@ function Import-DscAuthoringResource [System.Management.Automation.SwitchParameter] $ResolveSchemaCommand, + [Parameter(Mandatory = $true, ParameterSetName = 'DscExecutable')] + [System.Management.Automation.SwitchParameter] + $UseDscExecutable, + + [Parameter(ParameterSetName = 'DscExecutable')] + [System.String] + $DscExecutablePath = 'dsc', + + [Parameter(ParameterSetName = 'DscExecutable')] + [System.String] + $ResourceType, + + [Parameter(ParameterSetName = 'DscExecutable')] + [System.String] + $Adapter, + [Parameter()] [System.Management.Automation.SwitchParameter] $PassThru @@ -92,6 +120,15 @@ function Import-DscAuthoringResource $metadataItems.Add($item) } } + elseif ($PSCmdlet.ParameterSetName -eq 'DscExecutable') + { + Write-Debug "Import-DscAuthoringResource importing metadata from dsc.exe. UseDscExecutable: $($UseDscExecutable.IsPresent). ResourceType: '$ResourceType'. Adapter: '$Adapter'." + foreach ($item in Import-DscResourceAuthoringMetadata -UseDscExecutable -DscExecutablePath $DscExecutablePath -ResourceType $ResourceType -Adapter $Adapter) + { + Write-Debug "Import-DscAuthoringResource collected metadata for resource type '$($item.Type)' from dsc.exe." + $metadataItems.Add($item) + } + } else { Write-Debug "Import-DscAuthoringResource importing metadata from paths. Path count: $(@($Path).Count). Recurse: $($Recurse.IsPresent). IncludeAdapterCache: $($IncludeAdapterCache.IsPresent). ResolveSchemaCommand: $($ResolveSchemaCommand.IsPresent)." diff --git a/source/Public/Import-DscResourceAuthoringMetadata.ps1 b/source/Public/Import-DscResourceAuthoringMetadata.ps1 index 7cc446c..6d21880 100644 --- a/source/Public/Import-DscResourceAuthoringMetadata.ps1 +++ b/source/Public/Import-DscResourceAuthoringMetadata.ps1 @@ -3,9 +3,9 @@ Imports DSC resource metadata for configuration document authoring. .DESCRIPTION - The function Import-DscResourceAuthoringMetadata reads command-based DSC resource manifests - and optional PowerShell adapter cache files, returning normalized metadata that can be used - to generate authoring types and functions. This command does not invoke dsc.exe. + The function Import-DscResourceAuthoringMetadata reads command-based and adapted DSC + resource manifests, optional PowerShell adapter cache files, or dsc.exe discovery output, + returning normalized metadata that can be used to generate authoring types and functions. .PARAMETER Path A manifest file or directory containing .dsc.resource.json or .dsc.manifests.json files. @@ -24,6 +24,18 @@ Runs schema commands from resource manifests when an embedded schema is not available. This can call external resource executables and is disabled by default. + .PARAMETER UseDscExecutable + Uses dsc.exe resource discovery instead of reading manifests directly. + + .PARAMETER DscExecutablePath + The dsc executable or command name to invoke when UseDscExecutable is specified. + + .PARAMETER ResourceType + Optional resource type or wildcard pattern to pass to dsc resource list. + + .PARAMETER Adapter + Optional adapter type to pass to dsc resource list. + .EXAMPLE Import-DscResourceAuthoringMetadata -Path ./resources -Recurse @@ -35,30 +47,46 @@ function Import-DscResourceAuthoringMetadata { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Metadata is a domain term used by the DSC resource manifest schema.')] - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName = 'Path')] [OutputType([System.Management.Automation.PSCustomObject])] param ( - [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [Parameter(ParameterSetName = 'Path', ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('FullName')] [System.String[]] $Path, - [Parameter()] + [Parameter(ParameterSetName = 'Path')] [System.Management.Automation.SwitchParameter] $IncludeAdapterCache, - [Parameter()] + [Parameter(ParameterSetName = 'Path')] [System.String[]] $AdapterCachePath, - [Parameter()] + [Parameter(ParameterSetName = 'Path')] [System.Management.Automation.SwitchParameter] $Recurse, - [Parameter()] + [Parameter(ParameterSetName = 'Path')] + [System.Management.Automation.SwitchParameter] + $ResolveSchemaCommand, + + [Parameter(Mandatory = $true, ParameterSetName = 'DscExecutable')] [System.Management.Automation.SwitchParameter] - $ResolveSchemaCommand + $UseDscExecutable, + + [Parameter(ParameterSetName = 'DscExecutable')] + [System.String] + $DscExecutablePath = 'dsc', + + [Parameter(ParameterSetName = 'DscExecutable')] + [System.String] + $ResourceType, + + [Parameter(ParameterSetName = 'DscExecutable')] + [System.String] + $Adapter ) begin @@ -76,6 +104,13 @@ function Import-DscResourceAuthoringMetadata end { + if ($PSCmdlet.ParameterSetName -eq 'DscExecutable') + { + Write-Debug "Importing DSC resource metadata from dsc.exe. UseDscExecutable: $($UseDscExecutable.IsPresent). ResourceType: '$ResourceType'. Adapter: '$Adapter'." + Invoke-DscResourceDiscovery -DscExecutablePath $DscExecutablePath -ResourceType $ResourceType -Adapter $Adapter + return + } + if ($paths.Count -eq 0 -and -not $IncludeAdapterCache.IsPresent -and -not $AdapterCachePath) { $paths.Add((Get-Location).Path) @@ -105,6 +140,7 @@ function Import-DscResourceAuthoringMetadata @( Get-ChildItem @searchParameters -Filter '*.dsc.resource.json' + Get-ChildItem @searchParameters -Filter '*.dsc.adaptedResource.json' Get-ChildItem @searchParameters -Filter '*.dsc.manifests.json' ) } @@ -123,9 +159,9 @@ function Import-DscResourceAuthoringMetadata if ($manifestFile.Name -like '*.dsc.manifests.json') { - if (-not $parsed.Contains('resources')) + if (-not $parsed.Contains('resources') -and -not $parsed.Contains('adaptedResources')) { - Write-Debug "Skipping manifest list '$($manifestFile.FullName)' because it does not contain a resources property." + Write-Debug "Skipping manifest list '$($manifestFile.FullName)' because it does not contain resources or adaptedResources." continue } @@ -140,6 +176,18 @@ function Import-DscResourceAuthoringMetadata Write-Debug "Converting resource '$($resourceManifest['type'])' from manifest list '$($manifestFile.FullName)'." ConvertTo-DscResourceAuthoringMetadata -Manifest $resourceManifest -SourcePath $manifestFile.FullName -ResolveSchemaCommand:$ResolveSchemaCommand } + + foreach ($resourceManifest in @($parsed['adaptedResources'])) + { + if ($null -eq $resourceManifest) + { + Write-Debug "Skipping null adapted resource entry in manifest list '$($manifestFile.FullName)'." + continue + } + + Write-Debug "Converting adapted resource '$($resourceManifest['type'])' from manifest list '$($manifestFile.FullName)'." + ConvertTo-DscResourceAuthoringMetadata -Manifest $resourceManifest -SourcePath $manifestFile.FullName -ResolveSchemaCommand:$ResolveSchemaCommand + } continue } diff --git a/source/Public/Import-DscResourceManifest.ps1 b/source/Public/Import-DscResourceManifest.ps1 index dfd89c8..5634f71 100644 --- a/source/Public/Import-DscResourceManifest.ps1 +++ b/source/Public/Import-DscResourceManifest.ps1 @@ -49,7 +49,7 @@ function Import-DscResourceManifest return $true })] [Alias('FullName')] - [string] + [System.String] $Path ) diff --git a/source/Public/New-DscAdaptedResourceManifest.ps1 b/source/Public/New-DscAdaptedResourceManifest.ps1 index 1408594..79f0593 100644 --- a/source/Public/New-DscAdaptedResourceManifest.ps1 +++ b/source/Public/New-DscAdaptedResourceManifest.ps1 @@ -73,7 +73,7 @@ function New-DscAdaptedResourceManifest } return $true })] - [string] + [System.String] $Path, # Semantic version string for PS7: SemanticVersion Class @@ -85,7 +85,7 @@ function New-DscAdaptedResourceManifest } return $true })] - [string] + [System.String] $Version, [Parameter()] diff --git a/source/Public/New-DscConfigurationDocument.ps1 b/source/Public/New-DscConfigurationDocument.ps1 index a23cf6d..a28af8b 100644 --- a/source/Public/New-DscConfigurationDocument.ps1 +++ b/source/Public/New-DscConfigurationDocument.ps1 @@ -4,7 +4,7 @@ .DESCRIPTION The function New-DscConfigurationDocument creates a Microsoft.DSC.Configuration object - from resource instances and optional configuration-level metadata, parameters, and + from resource instances and optional configuration-level metadata, parameters, variables, and directives. .PARAMETER Resource @@ -13,6 +13,9 @@ .PARAMETER Parameter Configuration parameters to include in the document. + .PARAMETER Variable + Configuration variables to include in the document. + .PARAMETER Directive Configuration-level directives to include in the document. @@ -41,6 +44,10 @@ function New-DscConfigurationDocument [System.Collections.IDictionary] $Parameter, + [Parameter()] + [System.Collections.IDictionary] + $Variable, + [Parameter()] [Microsoft.DSC.Directives] $Directive, @@ -54,6 +61,7 @@ function New-DscConfigurationDocument { $configuration = [Microsoft.DSC.Configuration]::new() $configuration.Parameters = $Parameter + $configuration.Variables = $Variable $configuration.Directives = $Directive if ($Metadata) diff --git a/source/Public/New-DscPropertyOverride.ps1 b/source/Public/New-DscPropertyOverride.ps1 index ecc0ec6..d25f477 100644 --- a/source/Public/New-DscPropertyOverride.ps1 +++ b/source/Public/New-DscPropertyOverride.ps1 @@ -61,27 +61,27 @@ function New-DscPropertyOverride param ( [Parameter(Mandatory = $true)] - [string] + [System.String] $Name, [Parameter()] - [string] + [System.String] $Description, [Parameter()] - [string] + [System.String] $Title, [Parameter()] - [hashtable] + [System.Collections.Hashtable] $JsonSchema, [Parameter()] - [string[]] + [System.String[]] $RemoveKeys, [Parameter()] - [nullable[bool]] + [System.Nullable[System.Boolean]] $Required ) diff --git a/source/Public/Update-DscAdaptedResourceManifest.ps1 b/source/Public/Update-DscAdaptedResourceManifest.ps1 index 37f7ebc..7eb936e 100644 --- a/source/Public/Update-DscAdaptedResourceManifest.ps1 +++ b/source/Public/Update-DscAdaptedResourceManifest.ps1 @@ -103,7 +103,7 @@ function Update-DscAdaptedResourceManifest $PropertyOverride, [Parameter()] - [string] + [System.String] $Description ) diff --git a/source/WikiSource/Configuration-Authoring.md b/source/WikiSource/Configuration-Authoring.md index 75c57a2..e411694 100644 --- a/source/WikiSource/Configuration-Authoring.md +++ b/source/WikiSource/Configuration-Authoring.md @@ -3,10 +3,12 @@ 1. Overview 1. Prerequisites 1. Load resource metadata +1. Discover resources with dsc.exe 1. Resolve command-backed schemas 1. Author with generated resource functions 1. Author with generated .NET types 1. Use DSC expressions +1. Use configuration variables 1. Use existing PowerShell adapter cache metadata 1. Export a configuration document 1. Command quick reference @@ -25,10 +27,12 @@ generate: - `Microsoft.DSC.Resource` objects for configuration documents. - `Microsoft.DSC.Configuration` objects that can be exported as DSC documents. -The resource metadata import path does not call `dsc.exe`. By default, it only -reads `.dsc.resource.json`, `.dsc.manifests.json`, and existing adapter cache -files. If a command-based resource defines its schema with `schema.command`, you -can opt in to running that resource's schema command with `-ResolveSchemaCommand`. +The default resource metadata import path is file-based. It reads +`.dsc.resource.json`, `.dsc.adaptedResource.json`, `.dsc.manifests.json`, and +existing adapter cache files. If a command-based resource defines its schema with +`schema.command`, you can opt in to running that resource's schema command with +`-ResolveSchemaCommand`. You can also opt in to asking `dsc.exe` for discovered +resource metadata with `-UseDscExecutable`. @@ -36,8 +40,9 @@ can opt in to running that resource's schema command with `-ResolveSchemaCommand - PowerShell 7 is recommended for authoring configuration documents. - A built or imported `DscResource.Authoring` module. -- One or more command-based DSC resource manifests with embedded schemas or - schema commands that can be resolved locally. +- One or more command-based or adapted DSC resource manifests with embedded + schemas, schema commands that can be resolved locally, or `dsc.exe` + available on `$env:PATH` for executable-backed discovery. Import the module: @@ -63,6 +68,10 @@ $metadata = Import-DscResourceAuthoringMetadata -Path ./resources -Recurse $metadata | Format-Table Type, Kind, Version, RequireAdapter ``` +The path-based import reads standalone command resource manifests, standalone +adapted resource manifests, and manifest-list files containing `resources` or +`adaptedResources` arrays. + Use `Import-DscAuthoringResource` when you want to load metadata and immediately register generated .NET types and resource functions: @@ -79,6 +88,51 @@ For a resource type named `Test.Company/Tool`, the command registers: The command name is intentionally `Import-DscAuthoringResource` to avoid conflicting with the `Import-DscResource` keyword from PSDesiredStateConfiguration. + + +## Discover resources with dsc.exe + +Use `-UseDscExecutable` when you want DSC itself to discover resources. This path +calls `dsc resource list` and normalizes the returned metadata into the same +shape used by manifest and adapter-cache imports. The module uses schema data +returned by `dsc resource list` when available and does not make additional +per-resource `dsc resource schema` calls during discovery. + +```powershell +$metadata = Import-DscResourceAuthoringMetadata ` + -UseDscExecutable ` + -ResourceType 'Microsoft/OSInfo' +``` + +Register generated functions directly from `dsc.exe` discovery: + +```powershell +Import-DscAuthoringResource -UseDscExecutable -ResourceType 'Microsoft/OSInfo' +``` + +You can pass an explicit executable path or command name when `dsc` is not on +`$env:PATH`: + +```powershell +Import-DscAuthoringResource ` + -UseDscExecutable ` + -DscExecutablePath 'C:\Tools\dsc\dsc.exe' +``` + +Adapter-backed resources can be filtered through DSC discovery. The returned +metadata preserves the resource's `requireAdapter` value, so generated resource +functions emit resource instances with the adapter directive already set. + +```powershell +Import-DscAuthoringResource ` + -UseDscExecutable ` + -Adapter 'Microsoft.Adapter/PowerShell' +``` + +This mode invokes `dsc.exe` during discovery. Use the file-based `-Path`, +`-IncludeAdapterCache`, or `-AdapterCachePath` modes when you want metadata +loading to avoid external process execution. + ## Resolve command-backed schemas @@ -89,7 +143,8 @@ the resource executable for its schema. The Registry resources in the DSC repository are an example of this pattern. By default, `DscResource.Authoring` does not run those commands. This keeps the -normal metadata import path file-based and avoids unexpected external calls: +normal path-based metadata import file-based and avoids unexpected external +calls: ```powershell Import-DscAuthoringResource -Path ./resources -Recurse @@ -109,9 +164,9 @@ $resource = Microsoft.Windows.Registry ` ``` `-ResolveSchemaCommand` invokes the resource executable named in the manifest's -schema command. It still does not call `dsc.exe`. The executable must be -available from the current environment, such as from the repository build output -or from `$env:PATH`. +schema command. The executable must be available from the current environment, +such as from the repository build output or from `$env:PATH`. To have DSC resolve +discovered schemas instead, use `-UseDscExecutable`. Tab completion depends on the schema information that was loaded. If a resource only has `schema.command` and you do not specify `-ResolveSchemaCommand`, the @@ -226,21 +281,65 @@ The resulting expression value is: [parameters('installRoot')] ``` + + +## Use configuration variables + +Use the `-Variable` parameter on `New-DscConfigurationDocument` to include +document-level DSC variables. Variables can be scalar values, nested hashtables, +arrays, or DSC expression strings. + +```powershell +$parameter = @{ + myParameter = @{ + type = 'string' + defaultValue = "[concat('world','!')]" + } +} + +$variable = @{ + myOutput = "[concat('Hello ', parameters('myParameter'))]" + myObject = @{ + test = 'baz' + } +} + +$resource = Microsoft.DSC.Debug.Echo ` + -InstanceName 'test' ` + -output "[concat('myOutput is: ', variables('myOutput'), ', myObject is: ', variables('myObject').test)]" + +$configuration = New-DscConfigurationDocument ` + -Resource $resource ` + -Parameter $parameter ` + -Variable $variable +``` + +When exported, the configuration document contains a top-level `variables` block: + +```yaml +variables: + myOutput: "[concat('Hello ', parameters('myParameter'))]" + myObject: + test: baz +``` + ## Use existing PowerShell adapter cache metadata -PowerShell adapted resources can be loaded from existing adapter cache files. -The module only reads cache files that already exist; it does not invoke -`dsc.exe` or refresh missing caches. +PowerShell adapted resources can be loaded from `.dsc.adaptedResource.json` +files, from manifest lists containing `adaptedResources`, from existing adapter +cache files, or through `dsc.exe` discovery. The adapter cache mode only reads +cache files that already exist; it does not invoke `dsc.exe` or refresh missing +caches. Known cache locations are: -| Adapter | Platform | Path | -| ------- | -------- | ---- | -| `Microsoft.Adapter/PowerShell` | Windows | `%LOCALAPPDATA%\dsc\PSAdapterCache.json` | -| `Microsoft.Adapter/PowerShell` | Linux or macOS | `$HOME/.dsc/PSAdapterCache.json` | -| `Microsoft.Adapter/WindowsPowerShell` | Windows | `%LOCALAPPDATA%\dsc\WindowsPSAdapterCache.json` | +| Adapter | Platform | Path | +|---------------------------------------|----------------|-------------------------------------------------| +| `Microsoft.Adapter/PowerShell` | Windows | `%LOCALAPPDATA%\dsc\PSAdapterCache.json` | +| `Microsoft.Adapter/PowerShell` | Linux or macOS | `$HOME/.dsc/PSAdapterCache.json` | +| `Microsoft.Adapter/WindowsPowerShell` | Windows | `%LOCALAPPDATA%\dsc\WindowsPSAdapterCache.json` | Load existing adapter cache metadata: @@ -287,15 +386,15 @@ Test.Company.Tool -name 'example' -enabled $true | ## Command quick reference -| Command | Purpose | -| ------- | ------- | -| `Import-DscResourceAuthoringMetadata` | Reads command resource manifests and existing adapter caches into normalized metadata. | -| `Register-DscResourceAuthoringType` | Generates .NET property types and optional resource functions from metadata. | -| `Import-DscAuthoringResource` | Imports metadata and registers generated authoring types and functions in one step. | -| `New-DscConfigurationDocument` | Creates a `Microsoft.DSC.Configuration` object from resource instances. | -| `New-DscExpression` | Converts constrained PowerShell expression syntax into DSC expression strings. | -| `Export-DscConfigurationDocument` | Serializes resources or configuration objects to a DSC configuration document. | +| Command | Purpose | +|---------------------------------------|---------------------------------------------------------------------------------------------------------------------| +| `Import-DscResourceAuthoringMetadata` | Reads command/adapted resource manifests, existing adapter caches, or `dsc.exe` discovery into normalized metadata. | +| `Register-DscResourceAuthoringType` | Generates .NET property types and optional resource functions from metadata. | +| `Import-DscAuthoringResource` | Imports metadata and registers generated authoring types and functions in one step. | +| `New-DscConfigurationDocument` | Creates a `Microsoft.DSC.Configuration` object from resources and document data. | +| `New-DscExpression` | Converts constrained PowerShell expression syntax into DSC expression strings. | +| `Export-DscConfigurationDocument` | Serializes resources or configuration objects to a DSC configuration document. | Start with `Import-DscAuthoringResource` for the simplest workflow. Use `Import-DscResourceAuthoringMetadata` and `Register-DscResourceAuthoringType` -separately when you need to inspect or filter metadata before registering types. \ No newline at end of file +separately when you need to inspect or filter metadata before registering types. diff --git a/tests/Unit/Private/ConvertTo-DscExpressionText.Tests.ps1 b/tests/Unit/Private/ConvertTo-DscExpressionText.Tests.ps1 index 37e8cf2..6f78e0d 100644 --- a/tests/Unit/Private/ConvertTo-DscExpressionText.Tests.ps1 +++ b/tests/Unit/Private/ConvertTo-DscExpressionText.Tests.ps1 @@ -22,6 +22,21 @@ Describe 'ConvertTo-DscExpressionText' { } } + It 'Flattens nested concat expressions' { + InModuleScope 'DscResource.Authoring' { + $scriptBlock = { 'a' + ('b' + 'c') } + $result = ConvertTo-DscExpressionText -Ast $scriptBlock.Ast + + $result | Should -BeExactly "concat('a', 'b', 'c')" + } + } + + It 'Converts constants using DSC expression formatting' { + InModuleScope 'DscResource.Authoring' { + ConvertTo-DscExpressionText -Ast ({ 12.5 }).Ast | Should -BeExactly '12.5' + } + } + It 'Converts variables to parameter references' { InModuleScope 'DscResource.Authoring' { $scriptBlock = { $installRoot } @@ -31,6 +46,55 @@ Describe 'ConvertTo-DscExpressionText' { } } + It 'Converts type-constrained variables to parameter references' { + InModuleScope 'DscResource.Authoring' { + $scriptBlock = { [string] $installRoot } + $result = ConvertTo-DscExpressionText -Ast $scriptBlock.Ast + + $result | Should -BeExactly "parameters('installRoot')" + } + } + + It 'Converts Invoke-DscFunction arguments' { + InModuleScope 'DscResource.Authoring' { + $singleArgument = { Invoke-DscFunction -Name base64 -Argument $value } + $arrayArgument = { Invoke-DscFunction -Name concat -Argument 'prefix', $name, 3 } + + ConvertTo-DscExpressionText -Ast $singleArgument.Ast | + Should -BeExactly "base64(parameters('value'))" + + ConvertTo-DscExpressionText -Ast $arrayArgument.Ast | + Should -BeExactly "concat('prefix', parameters('name'), 3)" + } + } + + It 'Throws when script blocks contain multiple statements' { + InModuleScope 'DscResource.Authoring' { + $scriptBlock = { $first; $second } + + { ConvertTo-DscExpressionText -Ast $scriptBlock.Ast } | + Should -Throw '*exactly one statement*' + } + } + + It 'Throws for pipeline expressions' { + InModuleScope 'DscResource.Authoring' { + $scriptBlock = { 'value' | Write-Output } + + { ConvertTo-DscExpressionText -Ast $scriptBlock.Ast } | + Should -Throw '*Pipeline expressions are not supported*' + } + } + + It 'Throws for unsupported binary operators' { + InModuleScope 'DscResource.Authoring' { + $scriptBlock = { 10 - 1 } + + { ConvertTo-DscExpressionText -Ast $scriptBlock.Ast } | + Should -Throw '*operator is not supported*' + } + } + It 'Throws for unsupported commands' { InModuleScope 'DscResource.Authoring' { $scriptBlock = { Get-ChildItem } @@ -38,4 +102,22 @@ Describe 'ConvertTo-DscExpressionText' { { ConvertTo-DscExpressionText -Ast $scriptBlock.Ast } | Should -Throw } } + + It 'Throws when Invoke-DscFunction is missing the Name parameter' { + InModuleScope 'DscResource.Authoring' { + $scriptBlock = { Invoke-DscFunction -Argument 'value' } + + { ConvertTo-DscExpressionText -Ast $scriptBlock.Ast } | + Should -Throw '*requires the -Name parameter*' + } + } + + It 'Throws for unsupported expression syntax' { + InModuleScope 'DscResource.Authoring' { + $scriptBlock = { @('value') } + + { ConvertTo-DscExpressionText -Ast $scriptBlock.Ast } | + Should -Throw '*is not supported*' + } + } } \ No newline at end of file diff --git a/tests/Unit/Private/ConvertTo-DscPropertyOverrideFromConfig.Tests.ps1 b/tests/Unit/Private/ConvertTo-DscPropertyOverrideFromConfig.Tests.ps1 index e69de29..7d689e1 100644 --- a/tests/Unit/Private/ConvertTo-DscPropertyOverrideFromConfig.Tests.ps1 +++ b/tests/Unit/Private/ConvertTo-DscPropertyOverrideFromConfig.Tests.ps1 @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'ConvertTo-DscPropertyOverrideFromConfig' { + + It 'Converts configuration hashtables to property override objects' { + InModuleScope 'DscResource.Authoring' { + $overrideConfig = @( + @{ + Name = 'Status' + Description = 'The status description.' + Title = 'Status title' + JsonSchema = @{ enum = @('Enabled', 'Disabled') } + RemoveKeys = @('type') + Required = $true + } + ) + + $result = ConvertTo-DscPropertyOverrideFromConfig -OverrideConfig $overrideConfig + + $result | Should -HaveCount 1 + $result[0].Name | Should -BeExactly 'Status' + $result[0].Description | Should -BeExactly 'The status description.' + $result[0].Title | Should -BeExactly 'Status title' + $result[0].JsonSchema['enum'] | Should -Contain 'Enabled' + $result[0].RemoveKeys | Should -Contain 'type' + $result[0].Required | Should -BeTrue + } + } + + It 'Skips entries with missing names' { + InModuleScope 'DscResource.Authoring' { + $overrideConfig = @( + @{ Description = 'No name.' } + @{ Name = '' } + @{ Name = 'Valid' } + ) + + $result = ConvertTo-DscPropertyOverrideFromConfig -OverrideConfig $overrideConfig -WarningVariable warnings + + $result | Should -HaveCount 1 + $result[0].Name | Should -BeExactly 'Valid' + $warnings | Should -HaveCount 2 + } + } +} diff --git a/tests/Unit/Private/ConvertTo-DscResourceAuthoringMetadata.Tests.ps1 b/tests/Unit/Private/ConvertTo-DscResourceAuthoringMetadata.Tests.ps1 index 7628713..ec2dd12 100644 --- a/tests/Unit/Private/ConvertTo-DscResourceAuthoringMetadata.Tests.ps1 +++ b/tests/Unit/Private/ConvertTo-DscResourceAuthoringMetadata.Tests.ps1 @@ -75,4 +75,47 @@ Describe 'ConvertTo-DscResourceAuthoringMetadata' { @($result.Schema.properties['Ensure'].enum) | Should -Contain 'Present' } } + + It 'Skips adapter cache entries without resource info' { + InModuleScope 'DscResource.Authoring' { + $entry = [PSCustomObject]@{ + Type = 'Cache.Module/Missing' + DscResourceInfo = $null + } + + ConvertTo-DscResourceAuthoringMetadata -AdapterCacheEntry $entry | + Should -BeNullOrEmpty + } + } + + It 'Maps adapter cache property types to JSON schema types' { + InModuleScope 'DscResource.Authoring' { + $entry = [PSCustomObject]@{ + Type = 'Cache.Module/TypedResource' + DscResourceInfo = [PSCustomObject]@{ + Name = 'TypedResource' + FriendlyName = $null + Version = $null + Properties = @( + [PSCustomObject]@{ Name = 'Enabled'; PropertyType = 'Boolean'; IsMandatory = $false; Values = @() } + [PSCustomObject]@{ Name = 'Count'; PropertyType = 'UInt32'; IsMandatory = $false; Values = @() } + [PSCustomObject]@{ Name = 'Ratio'; PropertyType = 'Decimal'; IsMandatory = $false; Values = @() } + [PSCustomObject]@{ Name = 'Options'; PropertyType = 'Hashtable'; IsMandatory = $false; Values = @() } + [PSCustomObject]@{ Name = 'Items'; PropertyType = 'Array'; IsMandatory = $false; Values = @() } + [PSCustomObject]@{ Name = 'Identifier'; PropertyType = 'Guid'; IsMandatory = $false; Values = @() } + ) + Capabilities = @('get') + } + } + + $result = ConvertTo-DscResourceAuthoringMetadata -AdapterCacheEntry $entry + + $result.Schema.properties['Enabled'].type | Should -BeExactly 'boolean' + $result.Schema.properties['Count'].type | Should -BeExactly 'integer' + $result.Schema.properties['Ratio'].type | Should -BeExactly 'number' + $result.Schema.properties['Options'].type | Should -BeExactly 'object' + $result.Schema.properties['Items'].type | Should -BeExactly 'array' + $result.Schema.properties['Identifier'].type | Should -BeExactly 'string' + } + } } \ No newline at end of file diff --git a/tests/Unit/Private/ConvertTo-JsonSchemaType.Tests.ps1 b/tests/Unit/Private/ConvertTo-JsonSchemaType.Tests.ps1 index bd5f876..0baf070 100644 --- a/tests/Unit/Private/ConvertTo-JsonSchemaType.Tests.ps1 +++ b/tests/Unit/Private/ConvertTo-JsonSchemaType.Tests.ps1 @@ -74,6 +74,20 @@ Describe 'ConvertTo-JsonSchemaType' { $result['type'] | Should -BeExactly 'number' } } + + It 'Maps single to { type = number }' { + InModuleScope 'DscResource.Authoring' { + $result = ConvertTo-JsonSchemaType -TypeName 'single' + $result['type'] | Should -BeExactly 'number' + } + } + + It 'Maps decimal to { type = number }' { + InModuleScope 'DscResource.Authoring' { + $result = ConvertTo-JsonSchemaType -TypeName 'decimal' + $result['type'] | Should -BeExactly 'number' + } + } } Context 'Boolean types' { diff --git a/tests/Unit/Private/Get-ClassCommentBasedHelp.Tests.ps1 b/tests/Unit/Private/Get-ClassCommentBasedHelp.Tests.ps1 index 0e1528e..8b38484 100644 --- a/tests/Unit/Private/Get-ClassCommentBasedHelp.Tests.ps1 +++ b/tests/Unit/Private/Get-ClassCommentBasedHelp.Tests.ps1 @@ -259,4 +259,29 @@ Describe 'GetClassCommentBasedHelp integration' { $result.ManifestSchema.Embedded['properties']['Enabled']['type'] | Should -BeExactly 'boolean' } } + + Context 'Direct class comment lookup' { + + It 'Does not reuse help across intervening class declarations' { + InModuleScope 'DscResource.Authoring' { + $path = Join-Path -Path $TestDrive -ChildPath 'ClassHelp.psm1' + @' +<# +.SYNOPSIS +First resource help. +#> +class FirstResource { +} + +class SecondResource { +} +'@ | Set-Content -Path $path + + $result = Get-ClassCommentBasedHelp -Path $path + + $result.Keys | Should -Contain 'FirstResource' + $result.Keys | Should -Not -Contain 'SecondResource' + } + } + } } diff --git a/tests/Unit/Private/Get-DscResourceCapability.Tests.ps1 b/tests/Unit/Private/Get-DscResourceCapability.Tests.ps1 index c1cbecc..e9ec445 100644 --- a/tests/Unit/Private/Get-DscResourceCapability.Tests.ps1 +++ b/tests/Unit/Private/Get-DscResourceCapability.Tests.ps1 @@ -60,4 +60,41 @@ Describe 'Get-DscResourceCapability' { } } } + + Context 'Class with all supported DSC methods' { + + It 'Returns capability values for every supported method' { + InModuleScope 'DscResource.Authoring' { + $path = Join-Path -Path $TestDrive -ChildPath 'AllCapabilities.psm1' + @' +[DscResource()] +class AllCapabilities { + [AllCapabilities] Get() { return $this } + [void] Set() { } + [bool] Test() { return $true } + [void] WhatIf() { } + [bool] SetHandlesExist() { return $true } + [void] Delete() { } + [AllCapabilities[]] Export() { return @($this) } +} +'@ | Set-Content -Path $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) + $typeAst = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.TypeDefinitionAst] }, $false) | + Where-Object -FilterScript { $_.Name -eq 'AllCapabilities' } + + $capabilities = Get-DscResourceCapability -MemberAst $typeAst.Members + + $capabilities | Should -Contain 'get' + $capabilities | Should -Contain 'set' + $capabilities | Should -Contain 'test' + $capabilities | Should -Contain 'whatIf' + $capabilities | Should -Contain 'setHandlesExist' + $capabilities | Should -Contain 'delete' + $capabilities | Should -Contain 'export' + } + } + } } diff --git a/tests/Unit/Private/Get-DscResourceTypeDefinition.Tests.ps1 b/tests/Unit/Private/Get-DscResourceTypeDefinition.Tests.ps1 index da433dd..3bc412c 100644 --- a/tests/Unit/Private/Get-DscResourceTypeDefinition.Tests.ps1 +++ b/tests/Unit/Private/Get-DscResourceTypeDefinition.Tests.ps1 @@ -82,4 +82,24 @@ Describe 'Get-DscResourceTypeDefinition' { } } } + + Context 'File with parse errors' { + + It 'Writes parse errors before returning discovered type information' { + InModuleScope 'DscResource.Authoring' { + $path = Join-Path -Path $TestDrive -ChildPath 'ParseError.psm1' + @' +[DscResource()] +class BrokenResource { + [string] $Name = +} +'@ | Set-Content -Path $path + + $result = @(Get-DscResourceTypeDefinition -Path $path -ErrorAction SilentlyContinue -ErrorVariable errors) + + $errors | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 1 + } + } + } } diff --git a/tests/Unit/Private/Invoke-DscResourceDiscovery.Tests.ps1 b/tests/Unit/Private/Invoke-DscResourceDiscovery.Tests.ps1 new file mode 100644 index 0000000..484e650 --- /dev/null +++ b/tests/Unit/Private/Invoke-DscResourceDiscovery.Tests.ps1 @@ -0,0 +1,171 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'Invoke-DscResourceDiscovery' { + BeforeAll { + $fakeDscPath = Join-Path -Path $TestDrive -ChildPath 'fake-dsc.ps1' + @' +$commandArguments = $args + +if ($commandArguments[0] -eq 'resource' -and $commandArguments[1] -eq 'list') +{ + if ($commandArguments -contains '--adapter') + { + @{ + type = 'Adapted/Tool' + kind = 'resource' + version = '2.0.0' + capabilities = @('get', 'set') + description = 'Adapted resource from dsc.exe.' + requireAdapter = 'Test/Adapter' + path = 'C:\dsc\adapted.dsc.adaptedResource.json' + schema = @{ + embedded = @{ + type = 'object' + properties = @{ + value = @{ type = 'string' } + } + } + } + } | ConvertTo-Json -Depth 20 -Compress + exit 0 + } + + if ($commandArguments[2] -eq 'Array/Output') + { + @( + @{ + type = 'Array/One' + kind = 'resource' + capabilities = @('get') + schema = @{ + embedded = @{ + type = 'object' + properties = @{ + first = @{ type = 'string' } + } + } + } + } + @{ + type = 'Array/Two' + kind = 'resource' + capabilities = @('get') + schema = @{ + embedded = @{ + type = 'object' + properties = @{ + second = @{ type = 'string' } + } + } + } + } + ) | ConvertTo-Json -Depth 20 -Compress + exit 0 + } + + if ($commandArguments[2] -eq 'NoSchema/Tool') + { + @{ + type = 'NoSchema/Tool' + kind = 'resource' + version = '1.0.0' + capabilities = @('get') + description = 'Resource without schema in list output.' + path = 'C:\dsc\noschema.dsc.resource.json' + } | ConvertTo-Json -Depth 20 -Compress + exit 0 + } + + @{ + type = 'Dsc.Executable/Tool' + kind = 'resource' + version = '1.0.0' + capabilities = @('get') + description = 'Resource from dsc.exe.' + requireAdapter = $null + path = 'C:\dsc\tool.dsc.resource.json' + manifest = @{ + schema = @{ + embedded = @{ + type = 'object' + properties = @{ + name = @{ type = 'string' } + } + } + } + } + } | ConvertTo-Json -Depth 20 -Compress + exit 0 +} + +if ($commandArguments[0] -eq 'resource' -and $commandArguments[1] -eq 'schema') +{ + Write-Error 'The discovery helper should not call dsc resource schema.' + exit 9 +} + +Write-Error "Unexpected fake dsc arguments: $($commandArguments -join ' ')" +exit 1 +'@ | Set-Content -Path $fakeDscPath + } + + It 'Converts dsc resource list output into authoring metadata' { + InModuleScope 'DscResource.Authoring' -Parameters @{ FakeDscPath = $fakeDscPath } { + param($FakeDscPath) + + $metadata = Invoke-DscResourceDiscovery -DscExecutablePath $FakeDscPath + + $metadata.Type | Should -BeExactly 'Dsc.Executable/Tool' + $metadata.Kind | Should -BeExactly 'resource' + $metadata.Capabilities | Should -Contain 'get' + $metadata.Schema.properties.Keys | Should -Contain 'name' + $metadata.SourcePath | Should -BeExactly 'C:\dsc\tool.dsc.resource.json' + } + } + + It 'Discovers adapted resources through dsc resource list adapter filtering' { + InModuleScope 'DscResource.Authoring' -Parameters @{ FakeDscPath = $fakeDscPath } { + param($FakeDscPath) + + $metadata = Invoke-DscResourceDiscovery -DscExecutablePath $FakeDscPath -Adapter 'Test/Adapter' + + $metadata.Type | Should -BeExactly 'Adapted/Tool' + $metadata.RequireAdapter | Should -BeExactly 'Test/Adapter' + $metadata.Schema.properties.Keys | Should -Contain 'value' + } + } + + It 'Flattens top-level JSON arrays returned by dsc resource list' { + InModuleScope 'DscResource.Authoring' -Parameters @{ FakeDscPath = $fakeDscPath } { + param($FakeDscPath) + + $metadata = @(Invoke-DscResourceDiscovery -DscExecutablePath $FakeDscPath -ResourceType 'Array/Output') + + $metadata | Should -HaveCount 2 + $metadata.Type | Should -Contain 'Array/One' + $metadata.Type | Should -Contain 'Array/Two' + } + } + + It 'Does not call dsc resource schema when list output does not include a schema' { + InModuleScope 'DscResource.Authoring' -Parameters @{ FakeDscPath = $fakeDscPath } { + param($FakeDscPath) + + $metadata = Invoke-DscResourceDiscovery -DscExecutablePath $FakeDscPath -ResourceType 'NoSchema/Tool' + + $metadata.Type | Should -BeExactly 'NoSchema/Tool' + $metadata.Schema | Should -BeNullOrEmpty + } + } +} diff --git a/tests/Unit/Private/New-DscResourceAuthoringTypeSource.Tests.ps1 b/tests/Unit/Private/New-DscResourceAuthoringTypeSource.Tests.ps1 index 5985fb8..c99527e 100644 --- a/tests/Unit/Private/New-DscResourceAuthoringTypeSource.Tests.ps1 +++ b/tests/Unit/Private/New-DscResourceAuthoringTypeSource.Tests.ps1 @@ -34,6 +34,43 @@ Describe 'New-DscResourceAuthoringTypeSource' { } } + It 'Initializes properties from JSON schema defaults' { + InModuleScope 'DscResource.Authoring' { + $schema = [ordered]@{ + type = 'object' + properties = [ordered]@{ + name = @{ type = 'string'; default = 'example' } + exists = @{ type = 'boolean'; default = $false } + } + } + + $result = New-DscResourceAuthoringTypeSource -TypeName 'DSC.Test.Company.Defaults' -Schema $schema + + $result | Should -Match 'this\.name = "example";' + $result | Should -Match 'this\.exists = false;' + } + } + + It 'Generates enum properties from JSON schema enum values' { + InModuleScope 'DscResource.Authoring' { + $schema = [ordered]@{ + type = 'object' + properties = [ordered]@{ + status = @{ type = 'string'; enum = @('Running', 'Stopped', 'StartPending'); default = 'Stopped' } + } + } + + $result = New-DscResourceAuthoringTypeSource -TypeName 'DSC.Test.Company.Service' -Schema $schema + + $result | Should -Match 'public enum ServiceStatus' + $result | Should -Match 'StartPending' + $result | Should -Match 'private ServiceStatus\? statusValue;' + $result | Should -Match 'public ServiceStatus status' + $result | Should -Match 'public bool ShouldSerializestatus\(\)' + $result | Should -Match 'this\.status = ServiceStatus\.Stopped;' + } + } + It 'Adds a Value property when no schema properties are available' { InModuleScope 'DscResource.Authoring' { $schema = [ordered]@{ type = 'object'; properties = [ordered]@{} } diff --git a/tests/Unit/Private/Resolve-DscResourceAuthoringSchemaCommand.Tests.ps1 b/tests/Unit/Private/Resolve-DscResourceAuthoringSchemaCommand.Tests.ps1 index bd54b00..832c4f1 100644 --- a/tests/Unit/Private/Resolve-DscResourceAuthoringSchemaCommand.Tests.ps1 +++ b/tests/Unit/Private/Resolve-DscResourceAuthoringSchemaCommand.Tests.ps1 @@ -30,6 +30,33 @@ Describe 'Resolve-DscResourceAuthoringSchemaCommand' { } } + It 'Returns null when a manifest does not define a schema object' { + InModuleScope 'DscResource.Authoring' { + $manifest = [ordered]@{ + type = 'Test.Schema/Missing' + } + + Resolve-DscResourceAuthoringSchemaCommand -Manifest $manifest | + Should -BeNullOrEmpty + } + } + + It 'Returns null when a schema command has no executable' { + InModuleScope 'DscResource.Authoring' { + $manifest = [ordered]@{ + type = 'Test.Schema/NoExecutable' + schema = [ordered]@{ + command = [ordered]@{ + args = @('schema') + } + } + } + + Resolve-DscResourceAuthoringSchemaCommand -Manifest $manifest | + Should -BeNullOrEmpty + } + } + It 'Runs a schema command and returns the parsed JSON schema' { InModuleScope 'DscResource.Authoring' { $powerShellExecutable = (Get-Process -Id $PID).Path @@ -84,4 +111,39 @@ exit 12 Should -BeNullOrEmpty } } + + It 'Returns null when a schema command writes no output' { + InModuleScope 'DscResource.Authoring' { + $powerShellExecutable = (Get-Process -Id $PID).Path + + $manifest = [ordered]@{ + type = 'Test.Schema/Empty' + schema = [ordered]@{ + command = [ordered]@{ + executable = $powerShellExecutable + args = @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '') + } + } + } + + Resolve-DscResourceAuthoringSchemaCommand -Manifest $manifest | + Should -BeNullOrEmpty + } + } + + It 'Returns null when a schema command cannot be invoked' { + InModuleScope 'DscResource.Authoring' { + $manifest = [ordered]@{ + type = 'Test.Schema/Throws' + schema = [ordered]@{ + command = [ordered]@{ + executable = 'Missing-DscSchemaExecutable-For-Test' + } + } + } + + Resolve-DscResourceAuthoringSchemaCommand -Manifest $manifest | + Should -BeNullOrEmpty + } + } } \ No newline at end of file diff --git a/tests/Unit/Private/Resolve-ModuleInfo.Tests.ps1 b/tests/Unit/Private/Resolve-ModuleInfo.Tests.ps1 index 27a3a9a..d927917 100644 --- a/tests/Unit/Private/Resolve-ModuleInfo.Tests.ps1 +++ b/tests/Unit/Private/Resolve-ModuleInfo.Tests.ps1 @@ -58,6 +58,28 @@ Describe 'Resolve-ModuleInfo' { } } + Context 'With a minimal .psd1 path' { + + It 'Uses default manifest metadata when optional keys are omitted' { + InModuleScope 'DscResource.Authoring' { + $moduleDirectory = Join-Path -Path $TestDrive -ChildPath 'MinimalModule' + New-Item -Path $moduleDirectory -ItemType Directory -Force | Out-Null + $psd1 = Join-Path -Path $moduleDirectory -ChildPath 'MinimalModule.psd1' + $psm1 = Join-Path -Path $moduleDirectory -ChildPath 'MinimalModule.psm1' + + '@{}' | Set-Content -Path $psd1 + '' | Set-Content -Path $psm1 + + $result = Resolve-ModuleInfo -Path $psd1 + + $result.Version | Should -BeExactly '0.0.1' + $result.Author | Should -BeExactly '' + $result.Description | Should -BeExactly '' + Split-Path -Path $result.ScriptPath -Leaf | Should -BeExactly 'MinimalModule.psm1' + } + } + } + Context 'With a .psm1 path that has a sibling .psd1' { It 'Reads the sibling manifest and returns correct ModuleName' { diff --git a/tests/Unit/Public/Export-DscConfigurationDocument.Tests.ps1 b/tests/Unit/Public/Export-DscConfigurationDocument.Tests.ps1 index 531ed83..6ebb929 100644 --- a/tests/Unit/Public/Export-DscConfigurationDocument.Tests.ps1 +++ b/tests/Unit/Public/Export-DscConfigurationDocument.Tests.ps1 @@ -17,15 +17,29 @@ Describe 'Export-DscConfigurationDocument' { It 'Exports a configuration document to JSON text' { $resource = Test.Company.Tool -InstanceName 'Example tool' -name 'example' - $configuration = New-DscConfigurationDocument -Resource $resource + $configuration = New-DscConfigurationDocument ` + -Resource $resource ` + -Variable @{ + myOutput = "[concat('Hello ', parameters('myParameter'))]" + myObject = @{ test = 'baz' } + } $json = $configuration | Export-DscConfigurationDocument $parsed = $json | ConvertFrom-Json $parsed.'$schema' | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + $parsed.variables.myOutput | Should -BeExactly "[concat('Hello ', parameters('myParameter'))]" + $parsed.variables.myObject.test | Should -BeExactly 'baz' $parsed.resources[0].type | Should -BeExactly 'Test.Company/Tool' $parsed.resources[0].properties.name | Should -BeExactly 'example' } + It 'Exports the document schema as the first property' { + $resource = Test.Company.Tool -name 'example' + $json = $resource | New-DscConfigurationDocument | Export-DscConfigurationDocument + + ($json -split [System.Environment]::NewLine)[1] | Should -BeLike ' "$schema":*' + } + It 'Exports resource instances directly by wrapping them in a configuration document' { $resource = Test.Company.Tool -name 'example' $json = $resource | Export-DscConfigurationDocument @@ -33,4 +47,24 @@ Describe 'Export-DscConfigurationDocument' { $parsed.resources[0].type | Should -BeExactly 'Test.Company/Tool' } + + It 'Creates parent directories when exporting to a file' { + $resource = Test.Company.Tool -name 'example' + $path = Join-Path -Path $TestDrive -ChildPath 'nested\example.dsc.config.json' + + $resource | Export-DscConfigurationDocument -Path $path + + Test-Path -LiteralPath $path | Should -BeTrue + $parsed = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $parsed.resources[0].properties.name | Should -BeExactly 'example' + } + + It 'Does not write the export file when WhatIf is specified' { + $resource = Test.Company.Tool -name 'example' + $path = Join-Path -Path $TestDrive -ChildPath 'whatif\example.dsc.config.json' + + $resource | Export-DscConfigurationDocument -Path $path -WhatIf + + Test-Path -LiteralPath $path | Should -BeFalse + } } \ No newline at end of file diff --git a/tests/Unit/Public/Import-DscAuthoringResource.Tests.ps1 b/tests/Unit/Public/Import-DscAuthoringResource.Tests.ps1 index f0931d6..60ae6a7 100644 --- a/tests/Unit/Public/Import-DscAuthoringResource.Tests.ps1 +++ b/tests/Unit/Public/Import-DscAuthoringResource.Tests.ps1 @@ -8,10 +8,60 @@ Describe 'Import-DscAuthoringResource' { $fixturesPath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'Fixtures' $commandResourcePath = Join-Path -Path (Join-Path -Path $fixturesPath -ChildPath 'CommandResource') -ChildPath 'simple-tool.dsc.resource.json' + $fakeDscPath = Join-Path -Path $TestDrive -ChildPath 'fake-dsc.ps1' + @' +$commandArguments = $args + +if ($commandArguments[0] -eq 'resource' -and $commandArguments[1] -eq 'list') +{ + @{ + type = 'Dsc.Executable/Tool' + kind = 'resource' + version = '1.0.0' + capabilities = @('get') + description = 'Resource from dsc.exe.' + path = 'C:\dsc\tool.dsc.resource.json' + manifest = @{ + schema = @{ + embedded = @{ + type = 'object' + properties = @{ + name = @{ type = 'string' } + } + } + } + } + } | ConvertTo-Json -Depth 20 -Compress + + @{ + type = 'Adapted/Tool' + kind = 'resource' + version = '2.0.0' + capabilities = @('get', 'set') + description = 'Adapted resource from dsc.exe.' + requireAdapter = 'Test/Adapter' + path = 'C:\dsc\adapted.dsc.adaptedResource.json' + schema = @{ + embedded = @{ + type = 'object' + properties = @{ + value = @{ type = 'string' } + } + } + } + } | ConvertTo-Json -Depth 20 -Compress + exit 0 +} + +Write-Error "Unexpected fake dsc arguments: $($commandArguments -join ' ')" +exit 1 +'@ | Set-Content -Path $fakeDscPath } AfterAll { Remove-Item -Path 'Function:\global:Test.Company.Tool' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\global:Dsc.Executable.Tool' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\global:Adapted.Tool' -ErrorAction SilentlyContinue } Context 'When importing command resource manifests' { @@ -54,4 +104,29 @@ Describe 'Import-DscAuthoringResource' { Get-Command -Name 'Test.Company.Tool' -CommandType Function | Should -Not -BeNullOrEmpty } } + + Context 'When importing from dsc.exe discovery' { + + BeforeAll { + $registration = @(Import-DscAuthoringResource -UseDscExecutable -DscExecutablePath $fakeDscPath -PassThru) + } + + It 'Returns registration information for discovered resources' { + $registration.Type | Should -Contain 'Dsc.Executable/Tool' + $registration.Type | Should -Contain 'Adapted/Tool' + } + + It 'Registers generated functions for discovered resources' { + Get-Command -Name 'Dsc.Executable.Tool' -CommandType Function | Should -Not -BeNullOrEmpty + Get-Command -Name 'Adapted.Tool' -CommandType Function | Should -Not -BeNullOrEmpty + } + + It 'Creates resource instances from dsc.exe metadata' { + $resource = Dsc.Executable.Tool -InstanceName 'Executable tool' -name 'example' + + $resource | Should -BeOfType ([Microsoft.DSC.Resource]) + $resource.Type | Should -BeExactly 'Dsc.Executable/Tool' + $resource.ToHashtable()['properties']['name'] | Should -BeExactly 'example' + } + } } \ No newline at end of file diff --git a/tests/Unit/Public/Import-DscResourceAuthoringMetadata.Tests.ps1 b/tests/Unit/Public/Import-DscResourceAuthoringMetadata.Tests.ps1 index c74c77a..c803326 100644 --- a/tests/Unit/Public/Import-DscResourceAuthoringMetadata.Tests.ps1 +++ b/tests/Unit/Public/Import-DscResourceAuthoringMetadata.Tests.ps1 @@ -1,6 +1,26 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +BeforeDiscovery { + if (-not (Get-Command -Name dsc -ErrorAction SilentlyContinue) -and $env:TF_BUILD) + { + if (-not (Get-Module -Name PSDSC -ListAvailable)) + { + if (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue) + { + Install-PSResource -Name PSDSC -Scope CurrentUser -TrustRepository -ErrorAction Stop + } + else + { + Install-Module -Name PSDSC -Scope CurrentUser -Force -ErrorAction Stop + } + } + + Import-Module -Name PSDSC -Force -ErrorAction Stop + Install-DscExe -ErrorAction Stop + } +} + Describe 'Import-DscResourceAuthoringMetadata' { BeforeAll { @@ -9,6 +29,57 @@ Describe 'Import-DscResourceAuthoringMetadata' { $fixturesPath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'Fixtures' $commandResourcePath = Join-Path -Path (Join-Path -Path $fixturesPath -ChildPath 'CommandResource') -ChildPath 'simple-tool.dsc.resource.json' $adapterCachePath = Join-Path -Path (Join-Path -Path $fixturesPath -ChildPath 'AdapterCache') -ChildPath 'PSAdapterCache.json' + $adaptedResourcePath = Join-Path -Path $fixturesPath -ChildPath 'SimpleResource.dsc.adaptedResource.json' + $manifestListPath = Join-Path -Path $fixturesPath -ChildPath 'TestModule.dsc.manifests.json' + + $fakeDscPath = Join-Path -Path $TestDrive -ChildPath 'fake-dsc.ps1' + @' +$commandArguments = $args + +if ($commandArguments[0] -eq 'resource' -and $commandArguments[1] -eq 'list') +{ + @{ + type = 'Dsc.Executable/Tool' + kind = 'resource' + version = '1.0.0' + capabilities = @('get') + description = 'Resource from dsc.exe.' + path = 'C:\dsc\tool.dsc.resource.json' + manifest = @{ + schema = @{ + embedded = @{ + type = 'object' + properties = @{ + name = @{ type = 'string' } + } + } + } + } + } | ConvertTo-Json -Depth 20 -Compress + + @{ + type = 'Adapted/Tool' + kind = 'resource' + version = '2.0.0' + capabilities = @('get', 'set') + description = 'Adapted resource from dsc.exe.' + requireAdapter = 'Test/Adapter' + path = 'C:\dsc\adapted.dsc.adaptedResource.json' + schema = @{ + embedded = @{ + type = 'object' + properties = @{ + value = @{ type = 'string' } + } + } + } + } | ConvertTo-Json -Depth 20 -Compress + exit 0 +} + +Write-Error "Unexpected fake dsc arguments: $($commandArguments -join ' ')" +exit 1 +'@ | Set-Content -Path $fakeDscPath } Context 'When importing a command resource manifest' { @@ -117,4 +188,63 @@ $schema | ConvertTo-Json -Depth 10 -Compress @($metadata.Schema.properties['Ensure'].enum) | Should -Contain 'Present' } } + + Context 'When importing an adapted resource manifest' { + BeforeAll { + $metadata = Import-DscResourceAuthoringMetadata -Path $adaptedResourcePath + } + + It 'Returns metadata for adapted resources' { + $metadata.Type | Should -BeExactly 'SimpleResource/SimpleResource' + $metadata.RequireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' + $metadata.Capabilities | Should -Contain 'test' + } + + It 'Reads the embedded adapted resource schema' { + $metadata.Schema.properties.Keys | Should -Contain 'Name' + $metadata.Schema.properties.Keys | Should -Contain 'Value' + } + } + + Context 'When importing a manifest list with adapted resources' { + BeforeAll { + $metadata = @(Import-DscResourceAuthoringMetadata -Path $manifestListPath) + } + + It 'Includes adapted resources from the manifest list' { + $metadata.Type | Should -Contain 'TestModule/ResourceOne' + $metadata.Type | Should -Contain 'TestModule/ResourceTwo' + } + + It 'Includes command resources from the manifest list' { + $metadata.Type | Should -Contain 'Test/CommandResource' + } + } + + Context 'When importing from dsc.exe discovery' { + BeforeAll { + $metadata = @(Import-DscResourceAuthoringMetadata -UseDscExecutable -DscExecutablePath $fakeDscPath) + } + + It 'Returns metadata discovered from dsc.exe' { + $metadata.Type | Should -Contain 'Dsc.Executable/Tool' + ($metadata | Where-Object Type -EQ 'Dsc.Executable/Tool').Schema.properties.Keys | Should -Contain 'name' + } + + It 'Includes adapted resources discovered by dsc.exe' { + $adapted = $metadata | Where-Object Type -EQ 'Adapted/Tool' + + $adapted.RequireAdapter | Should -BeExactly 'Test/Adapter' + $adapted.Schema.properties.Keys | Should -Contain 'value' + } + } + + Context 'When importing from an installed dsc.exe' -Skip:(-not (Get-Command -Name dsc -ErrorAction SilentlyContinue)) { + It 'Discovers a built-in resource' { + $metadata = Import-DscResourceAuthoringMetadata -UseDscExecutable -ResourceType 'Microsoft/OSInfo' + + $metadata.Type | Should -BeExactly 'Microsoft/OSInfo' + $metadata.Schema.properties.Keys | Should -Contain 'family' + } + } } \ No newline at end of file diff --git a/tests/Unit/Public/New-DscConfigurationDocument.Tests.ps1 b/tests/Unit/Public/New-DscConfigurationDocument.Tests.ps1 index c33277c..64d250a 100644 --- a/tests/Unit/Public/New-DscConfigurationDocument.Tests.ps1 +++ b/tests/Unit/Public/New-DscConfigurationDocument.Tests.ps1 @@ -67,6 +67,7 @@ Describe 'New-DscConfigurationDocument' { $configuration = New-DscConfigurationDocument ` -Resource $resource ` -Parameter @{ serviceName = @{ type = 'string'; defaultValue = 'sshd' } } ` + -Variable @{ serviceDisplayName = "[concat(parameters('serviceName'), ' service')]" } ` -Directive $directive ` -Metadata @{ owner = 'DSC' } } @@ -75,6 +76,10 @@ Describe 'New-DscConfigurationDocument' { $configuration.Parameters['serviceName']['type'] | Should -BeExactly 'string' } + It 'Stores configuration variables' { + $configuration.Variables['serviceDisplayName'] | Should -BeExactly "[concat(parameters('serviceName'), ' service')]" + } + It 'Stores configuration directives' { $configuration.Directives.SecurityContext | Should -BeExactly 'Elevated' } diff --git a/tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 b/tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 index 94ed965..0e3a087 100644 --- a/tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 +++ b/tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 @@ -15,6 +15,10 @@ Describe 'Register-DscResourceAuthoringType' { AfterAll { Remove-Item -Path 'Function:\global:Test.Company.Tool' -ErrorAction SilentlyContinue Remove-Item -Path 'Function:\global:Test.Company.CompletionTool' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\global:Test.Company.SanitizedTool' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\global:Test.Company.EmptyTool' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\global:Test.Company.DefaultTool' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\global:Test.Company.UnsetEnumTool' -ErrorAction SilentlyContinue } It 'Registers a .NET authoring type in the DSC namespace' { @@ -67,4 +71,119 @@ Describe 'Register-DscResourceAuthoringType' { $parameters.Keys | Should -Contain 'keyPath' $parameters.Keys | Should -Contain 'valueName' } + + It 'Prefixes generated parameters that start with a number' { + $metadata = [PSCustomObject]@{ + Type = 'Test.Company/SanitizedTool' + Kind = 'resource' + Version = '1.0.0' + Description = 'Sanitized fixture.' + RequireAdapter = $null + Capabilities = @('get') + Schema = [ordered]@{ + type = 'object' + properties = [ordered]@{ + '9lives' = [ordered]@{ type = 'string' } + } + } + SourcePath = $null + Manifest = $null + } + + $metadata | Register-DscResourceAuthoringType | Out-Null + $parameters = (Get-Command -Name 'Test.Company.SanitizedTool' -CommandType Function).Parameters + + $parameters.Keys | Should -Contain '_9lives' + } + + It 'Registers functions when metadata has no schema properties' { + $metadata = [PSCustomObject]@{ + Type = 'Test.Company/EmptyTool' + Kind = 'resource' + Version = '1.0.0' + Description = 'Empty fixture.' + RequireAdapter = $null + Capabilities = @('get') + Schema = [ordered]@{ + type = 'object' + } + SourcePath = $null + Manifest = $null + } + + $metadata | Register-DscResourceAuthoringType | Out-Null + $parameters = (Get-Command -Name 'Test.Company.EmptyTool' -CommandType Function).Parameters + + $parameters.Keys | Should -Contain 'InstanceName' + } + + It 'Initializes generated properties from JSON schema defaults' { + $metadata = [PSCustomObject]@{ + Type = 'Test.Company/DefaultTool' + Kind = 'resource' + Version = '1.0.0' + Description = 'Default fixture.' + RequireAdapter = $null + Capabilities = @('get') + Schema = [ordered]@{ + type = 'object' + properties = [ordered]@{ + _exist = [ordered]@{ type = 'boolean'; default = $false } + status = [ordered]@{ type = 'string'; enum = @('Running', 'Stopped', 'Paused'); default = 'Stopped' } + } + } + SourcePath = $null + Manifest = $null + } + + $metadata | Register-DscResourceAuthoringType | Out-Null + $resource = Test.Company.DefaultTool + $hashtable = $resource.ToHashtable() + + $hashtable['properties']['_exist'] | Should -BeFalse + $hashtable['properties']['status'] | Should -BeExactly 'Stopped' + } + + It 'Validates generated function enum parameters' { + $parameters = (Get-Command -Name 'Test.Company.DefaultTool' -CommandType Function).Parameters + $validateSet = $parameters['status'].Attributes | Where-Object -FilterScript { $_ -is [System.Management.Automation.ValidateSetAttribute] } + + $validateSet.ValidValues | Should -Contain 'Running' + $validateSet.ValidValues | Should -Contain 'Stopped' + { Test.Company.DefaultTool -status 'Invalid' } | Should -Throw + } + + It 'Validates generated class enum properties' { + $type = 'DSC.Test.Company.DefaultTool' -as [System.Type] + $properties = [System.Activator]::CreateInstance($type) + + $properties._exist | Should -BeFalse + $properties.status.ToString() | Should -BeExactly 'Stopped' + { $properties.status = 'Invalid' } | Should -Throw + } + + It 'Does not serialize unset enum properties' { + $metadata = [PSCustomObject]@{ + Type = 'Test.Company/UnsetEnumTool' + Kind = 'resource' + Version = '1.0.0' + Description = 'Unset enum fixture.' + RequireAdapter = $null + Capabilities = @('get') + Schema = [ordered]@{ + type = 'object' + properties = [ordered]@{ + startType = [ordered]@{ type = 'string'; enum = @('Automatic', 'Manual') } + } + } + SourcePath = $null + Manifest = $null + } + + $metadata | Register-DscResourceAuthoringType | Out-Null + $resource = Test.Company.UnsetEnumTool + $hashtable = $resource.ToHashtable() + + $hashtable['properties'].ContainsKey('startType') | Should -BeFalse + } } \ No newline at end of file From a8006265248a4d2c6800c9a4c24addfd8592d839 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 29 May 2026 14:09:16 +0200 Subject: [PATCH 4/7] Fix test --- tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 b/tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 index 0e3a087..bc8cf64 100644 --- a/tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 +++ b/tests/Unit/Public/Register-DscResourceAuthoringType.Tests.ps1 @@ -184,6 +184,6 @@ Describe 'Register-DscResourceAuthoringType' { $resource = Test.Company.UnsetEnumTool $hashtable = $resource.ToHashtable() - $hashtable['properties'].ContainsKey('startType') | Should -BeFalse + $hashtable['properties'].Contains('startType') | Should -BeFalse } } \ No newline at end of file From 9d8214028cb7bb98c3643928c55971f3b3101fbc Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 29 May 2026 14:19:02 +0200 Subject: [PATCH 5/7] Fix test on Windows PowerShell 5.1 --- .../006.DscExecutableResourceDiscovery.ps1 | 18 ++++++++++++++++-- .../Export-DscConfigurationDocument.Tests.ps1 | 5 ++++- ...port-DscResourceAuthoringMetadata.Tests.ps1 | 4 ++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/source/Classes/006.DscExecutableResourceDiscovery.ps1 b/source/Classes/006.DscExecutableResourceDiscovery.ps1 index 0104500..6f65494 100644 --- a/source/Classes/006.DscExecutableResourceDiscovery.ps1 +++ b/source/Classes/006.DscExecutableResourceDiscovery.ps1 @@ -86,7 +86,7 @@ class DscExecutableResourceDiscovery try { - $parsed = ConvertTo-Hashtable -InputObject (ConvertFrom-Json -InputObject $jsonText -Depth 100) + $parsed = ConvertTo-Hashtable -InputObject $this.ConvertFromJson($jsonText) $this.AddJsonItem($items, $parsed) } catch @@ -99,7 +99,7 @@ class DscExecutableResourceDiscovery continue } - $parsed = ConvertTo-Hashtable -InputObject (ConvertFrom-Json -InputObject $json -Depth 100) + $parsed = ConvertTo-Hashtable -InputObject $this.ConvertFromJson($json) $this.AddJsonItem($items, $parsed) } } @@ -112,6 +112,20 @@ class DscExecutableResourceDiscovery } } + hidden [System.Object] ConvertFromJson([System.String] $Json) + { + $parameters = @{ + InputObject = $Json + } + + if ((Get-Command -Name ConvertFrom-Json).Parameters.ContainsKey('Depth')) + { + $parameters['Depth'] = 100 + } + + return ConvertFrom-Json @parameters + } + hidden [void] AddJsonItem([System.Collections.Generic.List[System.Object]] $Items, [System.Object] $Item) { if ($null -eq $Item) diff --git a/tests/Unit/Public/Export-DscConfigurationDocument.Tests.ps1 b/tests/Unit/Public/Export-DscConfigurationDocument.Tests.ps1 index 6ebb929..2c73be0 100644 --- a/tests/Unit/Public/Export-DscConfigurationDocument.Tests.ps1 +++ b/tests/Unit/Public/Export-DscConfigurationDocument.Tests.ps1 @@ -36,8 +36,11 @@ Describe 'Export-DscConfigurationDocument' { It 'Exports the document schema as the first property' { $resource = Test.Company.Tool -name 'example' $json = $resource | New-DscConfigurationDocument | Export-DscConfigurationDocument + $firstPropertyLine = $json -split "\r?\n" | + Where-Object -FilterScript { -not [System.String]::IsNullOrWhiteSpace($_) -and $_.Trim() -ne '{' } | + Select-Object -First 1 - ($json -split [System.Environment]::NewLine)[1] | Should -BeLike ' "$schema":*' + $firstPropertyLine.TrimStart() | Should -BeLike '"$schema":*' } It 'Exports resource instances directly by wrapping them in a configuration document' { diff --git a/tests/Unit/Public/Import-DscResourceAuthoringMetadata.Tests.ps1 b/tests/Unit/Public/Import-DscResourceAuthoringMetadata.Tests.ps1 index c803326..395086f 100644 --- a/tests/Unit/Public/Import-DscResourceAuthoringMetadata.Tests.ps1 +++ b/tests/Unit/Public/Import-DscResourceAuthoringMetadata.Tests.ps1 @@ -2,7 +2,7 @@ # Licensed under the MIT License. BeforeDiscovery { - if (-not (Get-Command -Name dsc -ErrorAction SilentlyContinue) -and $env:TF_BUILD) + if (-not (Get-Command -Name dsc -ErrorAction SilentlyContinue) -and $env:TF_BUILD -and $PSVersionTable.PSVersion -ge [System.Version]'7.2') { if (-not (Get-Module -Name PSDSC -ListAvailable)) { @@ -239,7 +239,7 @@ $schema | ConvertTo-Json -Depth 10 -Compress } } - Context 'When importing from an installed dsc.exe' -Skip:(-not (Get-Command -Name dsc -ErrorAction SilentlyContinue)) { + Context 'When importing from an installed dsc.exe' -Skip:(-not (Get-Command -Name dsc -ErrorAction SilentlyContinue) -or $PSVersionTable.PSVersion -lt [System.Version]'7.2') { It 'Discovers a built-in resource' { $metadata = Import-DscResourceAuthoringMetadata -UseDscExecutable -ResourceType 'Microsoft/OSInfo' From 24ceeb7aa55f92467be1f0992d6a3f6306294931 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 29 May 2026 14:27:25 +0200 Subject: [PATCH 6/7] Test for Win PowerShell --- source/Private/ConvertTo-Hashtable.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Private/ConvertTo-Hashtable.ps1 b/source/Private/ConvertTo-Hashtable.ps1 index a70fe9f..fda7e41 100644 --- a/source/Private/ConvertTo-Hashtable.ps1 +++ b/source/Private/ConvertTo-Hashtable.ps1 @@ -65,7 +65,7 @@ function ConvertTo-Hashtable { $items.Add((ConvertTo-Hashtable -InputObject $item)) } - return @($items) + return ,$items.ToArray() } return $InputObject From 0a96ce1438ff2f2e246162390d8bea3118a5383f Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 29 May 2026 14:37:25 +0200 Subject: [PATCH 7/7] Fix up function --- source/Private/ConvertTo-Hashtable.ps1 | 20 +++++++++---------- .../Private/ConvertTo-Hashtable.Tests.ps1 | 12 +++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/source/Private/ConvertTo-Hashtable.ps1 b/source/Private/ConvertTo-Hashtable.ps1 index fda7e41..8649581 100644 --- a/source/Private/ConvertTo-Hashtable.ps1 +++ b/source/Private/ConvertTo-Hashtable.ps1 @@ -48,16 +48,6 @@ function ConvertTo-Hashtable return $result } - if ($InputObject -is [PSCustomObject]) - { - $result = [ordered]@{} - foreach ($property in $InputObject.PSObject.Properties) - { - $result[$property.Name] = ConvertTo-Hashtable -InputObject $property.Value - } - return $result - } - if ($InputObject -is [System.Collections.IList]) { $items = [System.Collections.Generic.List[object]]::new() @@ -68,5 +58,15 @@ function ConvertTo-Hashtable return ,$items.ToArray() } + if ($InputObject -is [PSCustomObject]) + { + $result = [ordered]@{} + foreach ($property in $InputObject.PSObject.Properties) + { + $result[$property.Name] = ConvertTo-Hashtable -InputObject $property.Value + } + return $result + } + return $InputObject } diff --git a/tests/Unit/Private/ConvertTo-Hashtable.Tests.ps1 b/tests/Unit/Private/ConvertTo-Hashtable.Tests.ps1 index 3955583..e583fab 100644 --- a/tests/Unit/Private/ConvertTo-Hashtable.Tests.ps1 +++ b/tests/Unit/Private/ConvertTo-Hashtable.Tests.ps1 @@ -47,6 +47,18 @@ Describe 'ConvertTo-Hashtable' { $result[1]['Id'] | Should -Be 2 } } + + It 'Converts a top-level JSON array to an array of hashtables' { + InModuleScope 'DscResource.Authoring' { + $json = '[{"type":"Array/One"},{"type":"Array/Two"}]' + $parsed = ConvertFrom-Json -InputObject $json + $result = ConvertTo-Hashtable -InputObject $parsed + + $result | Should -HaveCount 2 + $result[0]['type'] | Should -BeExactly 'Array/One' + $result[1]['type'] | Should -BeExactly 'Array/Two' + } + } } Context 'Scalar input' {