win_dsc - Add argument validation and other fixes (#53093)

* win_dsc - Add argument validation and other fixes

* Fix doc issues
This commit is contained in:
Jordan Borean 2019-03-06 06:49:37 +10:00 committed by GitHub
commit 6b294eab4d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1585 additions and 990 deletions

View file

@ -4,272 +4,400 @@
# Copyright: (c) 2017, Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
#Requires -Module Ansible.ModuleUtils.Legacy
#AnsibleRequires -CSharpUtil Ansible.Basic
#Requires -Version 5
$ErrorActionPreference = "Stop"
Function ConvertTo-ArgSpecType {
<#
.SYNOPSIS
Converts the DSC parameter type to the arg spec type required for Ansible.
#>
param(
[Parameter(Mandatory=$true)][String]$CimType
)
$params = Parse-Args $args -supports_check_mode $true
$result = @{
changed = $false
$arg_type = switch($CimType) {
Boolean { "bool" }
Char16 { [Func[[Object], [Char]]]{ [System.Char]::Parse($args[0].ToString()) } }
DateTime { [Func[[Object], [DateTime]]]{
# o == ISO 8601 format
[System.DateTime]::ParseExact($args[0].ToString(), "o", [CultureInfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::None)
}}
Instance { "dict" }
Real32 { "float" }
Real64 { [Func[[Object], [Double]]]{ [System.Double]::Parse($args[0].ToString()) } }
Reference { "dict" }
SInt16 { [Func[[Object], [Int16]]]{ [System.Int16]::Parse($args[0].ToString()) } }
SInt32 { "int" }
SInt64 { [Func[[Object], [Int64]]]{ [System.Int64]::Parse($args[0].ToString()) } }
SInt8 { [Func[[Object], [SByte]]]{ [System.SByte]::Parse($args[0].ToString()) } }
String { "str" }
UInt16 { [Func[[Object], [UInt16]]]{ [System.UInt16]::Parse($args[0].ToString()) } }
UInt32 { [Func[[Object], [UInt32]]]{ [System.UInt32]::Parse($args[0].ToString()) } }
UInt64 { [Func[[Object], [UInt64]]]{ [System.UInt64]::Parse($args[0].ToString()) } }
UInt8 { [Func[[Object], [Byte]]]{ [System.Byte]::Parse($args[0].ToString()) } }
Unknown { "raw" }
default { "raw" }
}
return $arg_type
}
Function Cast-ToCimInstance($name, $value, $className)
{
# this converts a hashtable to a CimInstance
Function Get-DscCimClassProperties {
<#
.SYNOPSIS
Get's a list of CimProperties of a CIM Class. It filters out any magic or
read only properties that we don't need to know about.
#>
param([Parameter(Mandatory=$true)][String]$ClassName)
$valueType = $value.GetType()
if ($valueType -ne [hashtable])
{
Fail-Json -obj $result -message "CimInstance value for property $name must be a hashtable, was $($valueType.FullName)"
$resource = Get-CimClass -ClassName $ClassName -Namespace root\Microsoft\Windows\DesiredStateConfiguration
# Filter out any magic properties that are used internally on an OMI_BaseResource
# https://github.com/PowerShell/PowerShell/blob/master/src/System.Management.Automation/DscSupport/CimDSCParser.cs#L1203
$magic_properties = @("ResourceId", "SourceInfo", "ModuleName", "ModuleVersion", "ConfigurationName")
$properties = $resource.CimClassProperties | Where-Object {
($resource.CimSuperClassName -ne "OMI_BaseResource" -or $_.Name -notin $magic_properties) -and
-not $_.Flags.HasFlag([Microsoft.Management.Infrastructure.CimFlags]::ReadOnly)
}
try
{
$cim = New-CimInstance -ClassName $className -Property $value -ClientOnly
}
catch
{
Fail-Json -obj $result -message "Failed to convert hashtable to CimInstance of $($className): $($_.Exception.Message)"
}
return ,$cim
return ,$properties
}
Function Cast-Value($value, $type, $typeString, $name)
{
if ($type -eq [CimInstance])
{
$newValue = Cast-ToCimInstance -name $name -value $value -className $typeString
Function Add-PropertyOption {
<#
.SYNOPSIS
Adds the spec for the property type to the existing module specification.
#>
param(
[Parameter(Mandatory=$true)][Hashtable]$Spec,
[Parameter(Mandatory=$true)]
[Microsoft.Management.Infrastructure.CimPropertyDeclaration]$Property
)
$option = @{
required = $false
}
ElseIf ($type -eq [CimInstance[]])
{
if ($value -isnot [array])
{
$value = @($value)
}
[CimInstance[]]$newValue = @()
$baseTypeString = $typeString.Substring(0, $typeString.Length - 2)
foreach ($cim in $value)
{
$newValue += Cast-ToCimInstance -name $name -value $cim -className $baseTypeString
}
$property_name = $Property.Name
$property_type = $Property.CimType.ToString()
if ($Property.Flags.HasFlag([Microsoft.Management.Infrastructure.CimFlags]::Key) -or
$Property.Flags.HasFlag([Microsoft.Management.Infrastructure.CimFlags]::Required)) {
$option.required = $true
}
Else
{
$originalType = $value.GetType()
if ($originalType -eq $type)
{
$newValue = $value
if ($null -ne $Property.Qualifiers['Values']) {
$option.choices = [System.Collections.Generic.List`1[Object]]$Property.Qualifiers['Values'].Value
}
if ($property_name -eq "Name") {
# For backwards compatibility we support specifying the Name DSC property as item_name
$option.aliases = @("item_name")
} elseif ($property_name -ceq "key") {
# There seems to be a bug in the CIM property parsing when the property name is 'Key'. The CIM instance will
# think the name is 'key' when the MOF actually defines it as 'Key'. We set the proper casing so the module arg
# validator won't fire a case sensitive warning
$property_name = "Key"
}
if ($Property.ReferenceClassName -eq "MSFT_Credential") {
# Special handling for the MSFT_Credential type (PSCredential), we handle this with having 2 options that
# have the suffix _username and _password.
$option_spec_pass = @{
type = "str"
required = $option.required
no_log = $true
}
Else
{
$newValue = $value -as $type
if ($newValue -eq $null)
{
Add-Warning -obj $result -message "failed to cast property $name from '$value' of type $($originalType.FullName) to type $($type.FullName), the DSC engine may ignore this property with an invalid cast"
$newValue = $value
$Spec.options."$($property_name)_password" = $option_spec_pass
$Spec.required_together.Add(@("$($property_name)_username", "$($property_name)_password")) > $null
$property_name = "$($property_name)_username"
$option.type = "str"
} elseif ($Property.ReferenceClassName -eq "MSFT_KeyValuePair") {
$option.type = "dict"
} elseif ($property_type.EndsWith("Array")) {
$option.type = "list"
$option.elements = ConvertTo-ArgSpecType -CimType $property_type.Substring(0, $property_type.Length - 5)
} else {
$option.type = ConvertTo-ArgSpecType -CimType $property_type
}
if (($option.type -eq "dict" -or ($option.type -eq "list" -and $option.elements -eq "dict")) -and
$Property.ReferenceClassName -ne "MSFT_KeyValuePair") {
# Get the sub spec if the type is a Instance (CimInstance/dict)
$sub_option_spec = Get-OptionSpec -ClassName $Property.ReferenceClassName
$option += $sub_option_spec
}
$Spec.options.$property_name = $option
}
Function Get-OptionSpec {
<#
.SYNOPSIS
Generates the specifiec used in AnsibleModule for a CIM MOF resource name.
.NOTES
This won't be able to retrieve the default values for an option as that is not defined in the MOF for a resource.
Default values are still preserved in the DSC engine if we don't pass in the property at all, we just can't report
on what they are automatically.
#>
param(
[Parameter(Mandatory=$true)][String]$ClassName
)
$spec = @{
options = @{}
required_together = [System.Collections.ArrayList]@()
}
$properties = Get-DscCimClassProperties -ClassName $ClassName
foreach ($property in $properties) {
Add-PropertyOption -Spec $spec -Property $property
}
return $spec
}
Function ConvertTo-CimInstance {
<#
.SYNOPSIS
Converts a dict to a CimInstance of the specified Class. Also provides a
better error message if this fails that contains the option name that failed.
#>
param(
[Parameter(Mandatory=$true)][String]$Name,
[Parameter(Mandatory=$true)][String]$ClassName,
[Parameter(Mandatory=$true)][System.Collections.IDictionary]$Value,
[Parameter(Mandatory=$true)][Ansible.Basic.AnsibleModule]$Module,
[Switch]$Recurse
)
$properties = @{}
foreach ($value_info in $Value.GetEnumerator()) {
# Need to remove all null values from existing dict so the conversion works
if ($null -eq $value_info.Value) {
continue
}
$properties.($value_info.Key) = $value_info.Value
}
if ($Recurse) {
# We want to validate and convert and values to what's required by DSC
$properties = ConvertTo-DscProperty -ClassName $ClassName -Params $properties -Module $Module
}
try {
return (New-CimInstance -ClassName $ClassName -Property $properties -ClientOnly)
} catch {
# New-CimInstance raises a poor error message, make sure we mention what option it is for
$Module.FailJson("Failed to cast dict value for option '$Name' to a CimInstance: $($_.Exception.Message)", $_)
}
}
Function ConvertTo-DscProperty {
<#
.SYNOPSIS
Converts the input module parameters that have been validated and casted
into the types expected by the DSC engine. This is mostly done to deal with
types like PSCredential and Dictionaries.
#>
param(
[Parameter(Mandatory=$true)][String]$ClassName,
[Parameter(Mandatory=$true)][System.Collections.IDictionary]$Params,
[Parameter(Mandatory=$true)][Ansible.Basic.AnsibleModule]$Module
)
$properties = Get-DscCimClassProperties -ClassName $ClassName
$dsc_properties = @{}
foreach ($property in $properties) {
$property_name = $property.Name
$property_type = $property.CimType.ToString()
if ($property.ReferenceClassName -eq "MSFT_Credential") {
$username = $Params."$($property_name)_username"
$password = $Params."$($property_name)_password"
# No user set == No option set in playbook, skip this property
if ($null -eq $username) {
continue
}
$sec_password = ConvertTo-SecureString -String $password -AsPlainText -Force
$value = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $username, $sec_password
} else {
$value = $Params.$property_name
# The actual value wasn't set, skip adding this property
if ($null -eq $value) {
continue
}
if ($property.ReferenceClassName -eq "MSFT_KeyValuePair") {
$key_value_pairs = [System.Collections.Generic.List`1[CimInstance]]@()
foreach ($value_info in $value.GetEnumerator()) {
$kvp = @{Key = $value_info.Key; Value = $value_info.Value.ToString()}
$cim_instance = ConvertTo-CimInstance -Name $property_name -ClassName MSFT_KeyValuePair `
-Value $kvp -Module $Module
$key_value_pairs.Add($cim_instance) > $null
}
$value = $key_value_pairs.ToArray()
} elseif ($null -ne $property.ReferenceClassName) {
# Convert the dict to a CimInstance (or list of CimInstances)
$convert_args = @{
ClassName = $property.ReferenceClassName
Module = $Module
Name = $property_name
Recurse = $true
}
if ($property_type.EndsWith("Array")) {
$value = [System.Collections.Generic.List`1[CimInstance]]@()
foreach ($raw in $Params.$property_name.GetEnumerator()) {
$cim_instance = ConvertTo-CimInstance -Value $raw @convert_args
$value.Add($cim_instance) > $null
}
$value = $value.ToArray() # Need to make sure we are dealing with an Array not a List
} else {
$value = ConvertTo-CimInstance -Value $value @convert_args
}
}
}
$dsc_properties.$property_name = $value
}
return ,$newValue
return $dsc_properties
}
Function Parse-DscProperty($name, $value, $resourceProp)
{
$propertyTypeString = $resourceProp.PropertyType
if ($propertyTypeString.StartsWith("["))
{
$propertyTypeString = $propertyTypeString.Substring(1, $propertyTypeString.Length - 2)
}
$propertyType = $propertyTypeString -as [type]
Function Invoke-DscMethod {
<#
.SYNOPSIS
Invokes the DSC Resource Method specified in another PS pipeline. This is
done so we can retrieve the Verbose stream and return it back to the user
for futher debugging.
#>
param(
[Parameter(Mandatory=$true)][Ansible.Basic.AnsibleModule]$Module,
[Parameter(Mandatory=$true)][String]$Method,
[Parameter(Mandatory=$true)][Hashtable]$Arguments
)
# CimInstance and CimInstance[] are reperesented as the actual Cim
# ClassName and the above returns a $null. We need to manually set the
# type in these cases
if ($propertyType -eq $null)
{
if ($propertyTypeString.EndsWith("[]"))
{
$propertyType = [CimInstance[]]
}
Else
{
$propertyType = [CimInstance]
# Invoke the DSC resource in a separate runspace so we can capture the Verbose output
$ps = [PowerShell]::Create()
$ps.AddCommand("Invoke-DscResource").AddParameter("Method", $Method) > $null
$ps.AddParameters($Arguments) > $null
$result = $ps.Invoke()
# Pass the warnings through to the AnsibleModule return result
foreach ($warning in $ps.Streams.Warning) {
$Module.Warn($warning.Message)
}
# If running at a high enough verbosity, add the verbose output to the AnsibleModule return result
if ($Module.Verbosity -ge 3) {
$verbose_logs = [System.Collections.Generic.List`1[String]]@()
foreach ($verbosity in $ps.Streams.Verbose) {
$verbose_logs.Add($verbosity.Message) > $null
}
$Module.Result."verbose_$($Method.ToLower())" = $verbose_logs
}
if ($propertyType.IsArray)
{
# convert the value to a list for later conversion
if ($value -is [string])
{
$value = $value.Split(",").Trim()
}
ElseIf ($value -isnot [array])
{
$value = @($value)
}
if ($ps.HadErrors) {
# Cannot pass in the ErrorRecord as it's a RemotingErrorRecord and doesn't contain the ScriptStackTrace
# or other info that would be useful
$Module.FailJson("Failed to invoke DSC $Method method: $($ps.Streams.Error[0].Exception.Message)")
}
$newValue = Cast-Value -value $value -type $propertyType -typeString $propertyTypeString -name $name
return ,$newValue
return $result
}
$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false
$resourcename = Get-AnsibleParam -obj $params -name "resource_name" -type "str" -failifempty $true
$module_version = Get-AnsibleParam -obj $params -name "module_version" -type "str" -default "latest"
#From Ansible 2.3 onwards, params is now a Hash Array
$Attributes = @{}
foreach ($param in $params.GetEnumerator())
{
if ($param.Name -notin @("resource_name", "module_version") -and $param.Name -notlike "_ansible_*")
{
$Attributes[$param.Name] = $param.Value
# win_dsc is unique in that is builds the arg spec based on DSC Resource input. To get this info
# we need to read the resource_name and module_version value which is done outside of Ansible.Basic
if ($args.Length -gt 0) {
$params = Get-Content -Path $args[0] | ConvertFrom-Json
} else {
$params = $complex_args
}
if (-not $params.ContainsKey("resource_name")) {
$res = @{
msg = "missing required argument: resource_name"
failed = $true
}
Write-Output -InputObject (ConvertTo-Json -Compress -InputObject $res)
exit 1
}
$resource_name = $params.resource_name
if ($params.ContainsKey("module_version")) {
$module_version = $params.module_version
} else {
$module_version = "latest"
}
if ($Attributes.Count -eq 0)
{
Fail-Json -obj $result -message "No attributes specified"
$module_versions = (Get-DscResource -Name $resource_name -ErrorAction SilentlyContinue | Sort-Object -Property Version)
$resource = $null
if ($module_version -eq "latest" -and $null -ne $module_versions) {
$resource = $module_versions[-1]
} elseif ($module_version -ne "latest") {
$resource = $module_versions | Where-Object { $_.Version -eq $module_version }
}
#Always return some basic info
$result["reboot_required"] = $false
$Config = @{
Name = ($resourcename)
Property = @{}
}
#Get the latest version of the module
if ($module_version -eq "latest")
{
$Resource = Get-DscResource -Name $resourcename -ErrorAction SilentlyContinue | sort Version | select -Last 1
}
else
{
$Resource = Get-DscResource -Name $resourcename -ErrorAction SilentlyContinue | where {$_.Version -eq $module_version}
}
if (!$Resource)
{
if ($module_version -eq "latest")
{
Fail-Json -obj $result -message "Resource $resourcename not found"
if (-not $resource) {
if ($module_version -eq "latest") {
$msg = "Resource '$resource_name' not found."
} else {
$msg = "Resource '$resource_name' with version '$module_version' not found."
$msg += " Versions installed: '$($module_versions.Version -join "', '")'."
}
else
{
Fail-Json -obj $result -message "Resource $resourcename with version $module_version not found"
}
Write-Output -InputObject (ConvertTo-Json -Compress -InputObject @{ failed = $true; msg = $msg })
exit 1
}
#Get the Module that provides the resource. Will be used as
#mandatory argument for Invoke-DscResource
$Module = @{
ModuleName = $Resource.ModuleName
ModuleVersion = $Resource.Version
# Build the base args for the DSC Invocation based on the resource selected
$dsc_args = @{
Name = $resource.Name
}
# Binary resources are not working very well with that approach - need to guesstimate module name/version
if ( -not ($Module.ModuleName -or $Module.ModuleVersion)) {
$Module = 'PSDesiredStateConfiguration'
$module_version = $null
if ($resource.Module) {
$dsc_args.ModuleName = @{
ModuleName = $resource.Module.Name
ModuleVersion = $resource.Module.Version
}
$module_version = $resource.Module.Version.ToString()
} else {
$dsc_args.ModuleName = "PSDesiredStateConfiguration"
}
#grab the module version if we can
# To ensure the class registered with CIM is the one based on our version, we want to run the Get method so the DSC
# engine updates the metadata propery. We don't care about any errors here
try {
if ($Resource.Module.Version)
{
$result["module_version"] = $Resource.Module.Version.ToString()
}
}
catch {}
Invoke-DscResource -Method Get -Property @{Fake="Fake"} @dsc_args > $null
} catch {}
#Convert params to correct datatype and inject
foreach ($attribute in $Attributes.GetEnumerator())
{
$key = $attribute.Name.Replace("item_name", "name")
$value = $attribute.Value
$prop = $resource.Properties | Where-Object {$_.Name -eq $key}
if (!$prop)
{
#If its a credential specified as "credential", Ansible will support credential_username and credential_password. Need to check for that
$prop = $resource.Properties | Where-Object {$_.Name -eq $key.Replace("_username","")}
if ($prop)
{
#We need to construct a cred object. At this point keyvalue is the username, so grab the password
$PropUserNameValue = $value
$PropPassword = $key.Replace("_username","_password")
$PropPasswordValue = $Attributes.$PropPassword
# Dynamically build the option spec based on the resource_name specified and create the module object
$spec = Get-OptionSpec -ClassName $resource.ResourceType
$spec.supports_check_mode = $true
$spec.options.module_version = @{ type = "str"; default = "latest" }
$spec.options.resource_name = @{ type = "str"; required = $true }
$KeyValue = New-Object System.Management.Automation.PSCredential ($PropUserNameValue, ($PropPasswordValue | ConvertTo-SecureString -AsPlainText -Force))
$config.Property.Add($key.Replace("_username",""),$KeyValue)
}
ElseIf ($key.Contains("_password"))
{
#Do nothing. We suck in the password in the handler for _username, so we can just skip it.
}
Else
{
Fail-Json -obj $result -message "Property $key in resource $resourcename is not a valid property"
}
}
Else
{
if ($value -eq $null)
{
$keyValue = $null
}
Else
{
$keyValue = Parse-DscProperty -name $key -value $value -resourceProp $prop
}
$config.Property.Add($key, $keyValue)
$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec)
$module.Result.reboot_required = $false
$module.Result.module_version = $module_version
# Build the DSC invocation arguments and invoke the resource
$dsc_args.Property = ConvertTo-DscProperty -ClassName $resource.ResourceType -Module $module -Params $Module.Params
$dsc_args.Verbose = $true
$test_result = Invoke-DscMethod -Module $module -Method Test -Arguments $dsc_args
if ($test_result.InDesiredState -ne $true) {
if (-not $module.CheckMode) {
$result = Invoke-DscMethod -Module $module -Method Set -Arguments $dsc_args
$module.Result.reboot_required = $result.RebootRequired
}
$module.Result.changed = $true
}
try
{
#Defined variables in strictmode
$TestError, $TestError = $null
$TestResult = Invoke-DscResource @Config -Method Test -ModuleName $Module -ErrorVariable TestError -ErrorAction SilentlyContinue -WarningVariable TestWarn
foreach ($warning in $TestWarn) {
Add-Warning -obj $result -message $warning.Message
}
$module.ExitJson()
if ($TestError)
{
throw ($TestError[0].Exception.Message)
}
ElseIf (($TestResult.InDesiredState) -ne $true)
{
if ($check_mode -eq $False)
{
$SetResult = Invoke-DscResource -Method Set @Config -ModuleName $Module -ErrorVariable SetError -ErrorAction SilentlyContinue -WarningVariable SetWarn
foreach ($warning in $SetWarn) {
Add-Warning -obj $result -message $warning.Message
}
if ($SetError -and ($SetResult -eq $null))
{
#If SetError was filled, throw to exit out of the try/catch loop
throw $SetError
}
$result["reboot_required"] = $SetResult.RebootRequired
}
$result["changed"] = $true
if ($SetError)
{
throw ($SetError[0].Exception.Message)
}
}
}
Catch
{
Fail-Json -obj $result -message $_[0].Exception.Message
}
Exit-Json -obj $result

View file

@ -54,6 +54,11 @@ options:
provided but a comma separated string also work. Use a list where
possible as no escaping is required and it works with more complex types
list C(CimInstance[]).
- If the type of the DSC resource option is a C(DateTime), use a string in
the form of an ISO 8901 string.
- Since Ansible 2.8, Ansible will now validate the input fields against the
DSC resource definition automatically. Older versions will silently
ignore invalid fields.
type: str
required: true
notes:
@ -65,6 +70,9 @@ notes:
- The DSC engine run's each task as the SYSTEM account, any resources that need
to be accessed with a different account need to have C(PsDscRunAsCredential)
set.
- To see the valid options for a DSC resource, run the module with C(-vvv) to
show the possible module invocation. Default values are not shown in this
output but are applied within the DSC engine.
author:
- Trond Hindenes (@trondhindenes)
'''
@ -103,6 +111,11 @@ EXAMPLES = r'''
Ensure: Present
Type: Directory
- name: Call DSC resource with DateTime option
win_dsc:
resource_name: DateTimeResource
DateTimeOption: '2019-02-22T13:57:31.2311892+00:00'
# more complex example using custom DSC resource and dict values
- name: Setup the xWebAdministration module
win_psmodule:
@ -137,7 +150,7 @@ EXAMPLES = r'''
RETURN = r'''
module_version:
description: The version of the dsc resource/module used.
returned: success
returned: always
type: str
sample: "1.0.1"
reboot_required:
@ -146,9 +159,24 @@ reboot_required:
returned: always
type: bool
sample: true
message:
description: Any error message from invoking the DSC resource.
returned: error
type: str
sample: Multiple DSC modules found with resource name xyz
verbose_test:
description: The verbose output as a list from executing the DSC test
method.
returned: Ansible verbosity is -vvv or greater
type: list
sample: [
"Perform operation 'Invoke CimMethod' with the following parameters, ",
"[SERVER]: LCM: [Start Test ] [[File]DirectResourceAccess]",
"Operation 'Invoke CimMethod' complete."
]
verbose_set:
description: The verbose output as a list from executing the DSC Set
method.
returned: Ansible verbosity is -vvv or greater and a change occurred
type: list
sample: [
"Perform operation 'Invoke CimMethod' with the following parameters, ",
"[SERVER]: LCM: [Start Set ] [[File]DirectResourceAccess]",
"Operation 'Invoke CimMethod' complete."
]
'''