503 lines
20 KiB
PowerShell
503 lines
20 KiB
PowerShell
#!powershell
|
|
|
|
# Copyright: (c) 2015, Trond Hindenes <trond@hindenes.com>, and others
|
|
# Copyright: (c) 2017, Ansible Project
|
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
|
|
#AnsibleRequires -CSharpUtil Ansible.Basic
|
|
#Requires -Version 5
|
|
|
|
Function ConvertTo-ArgSpecType {
|
|
<#
|
|
.SYNOPSIS
|
|
Converts the DSC parameter type to the arg spec type required for Ansible.
|
|
#>
|
|
param(
|
|
[Parameter(Mandatory = $true)][String]$CimType
|
|
)
|
|
|
|
$arg_type = switch ($CimType) {
|
|
Boolean { "bool" }
|
|
Char16 { [Func[[Object], [Char]]] { [System.Char]::Parse($args[0].ToString()) } }
|
|
DateTime { [Func[[Object], [DateTime]]] { [System.DateTime]($args[0].ToString()) } }
|
|
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 Get-DscCimClassProperty {
|
|
<#
|
|
.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)
|
|
|
|
$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)
|
|
}
|
|
|
|
return , $properties
|
|
}
|
|
|
|
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
|
|
}
|
|
$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
|
|
}
|
|
|
|
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
|
|
}
|
|
$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-DscCimClassProperty -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-DscCimClassProperty -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 $dsc_properties
|
|
}
|
|
|
|
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
|
|
)
|
|
|
|
# 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 ($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)")
|
|
}
|
|
|
|
return $result
|
|
}
|
|
|
|
Function Invoke-SafeDscResource {
|
|
<#
|
|
.SYNOPSIS
|
|
Calls Invoke-DscResource but respects the value of -ErrorAction from the caller
|
|
|
|
.PARAMETER Parameters
|
|
The parameters to pass onto Invoke-DscResource
|
|
#>
|
|
[CmdletBinding()]
|
|
param (
|
|
[Parameter(Mandatory = $true)]
|
|
[System.Collections.IDictionary]
|
|
$Parameters
|
|
)
|
|
|
|
try {
|
|
Invoke-DscResource @Parameters
|
|
}
|
|
catch {
|
|
Write-Error -Message $_.Exception.Message -Exception $_.Exception
|
|
}
|
|
}
|
|
|
|
Function Write-AnsibleError {
|
|
<#
|
|
.SYNOPSIS
|
|
Raises a standard terminating error.
|
|
|
|
.PARAMETER Msg
|
|
Error message text
|
|
#>
|
|
[CmdletBinding()]
|
|
param (
|
|
[Parameter(Mandatory = $true)]
|
|
[String]$Msg
|
|
)
|
|
|
|
$res = @{
|
|
msg = $msg
|
|
failed = $true
|
|
}
|
|
Write-Output -InputObject (ConvertTo-Json -Compress -InputObject $res)
|
|
exit 1
|
|
}
|
|
|
|
# DSC requires the default HTTP WSMan listener to be active and cannot be configured by Invoke-DscResource so we check
|
|
# if it is active and present a better error message to the caller if it isn't. The only alternative is to invoke the
|
|
# CIM method manually in our own CimSession but that would mean we need to compile the MOF ourselves which is not
|
|
# something we can realistically tackle.
|
|
$wsman_http_port_path = 'WSMan:\localhost\Client\DefaultPorts\HTTP'
|
|
$wsman_http_port = (Get-Item -LiteralPath $wsman_http_port_path).Value
|
|
if (-not (Test-WSMan -Port $wsman_http_port -ErrorAction SilentlyContinue)) {
|
|
$msg = "The win_dsc module requires the WSMan HTTP listener to be configured and online. The port win_dsc is set "
|
|
$msg += "to use is $wsman_http_port as configured by 'Get-Item -LiteralPath $wsman_http_port_path'."
|
|
Write-AnsibleError -Msg $msg
|
|
}
|
|
|
|
# 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 -LiteralPath $args[0] | ConvertFrom-Json
|
|
}
|
|
else {
|
|
$params = $complex_args
|
|
}
|
|
if (-not $params.ContainsKey("resource_name")) {
|
|
Write-AnsibleError -Msg 'missing required argument: resource_name'
|
|
}
|
|
$resource_name = $params.resource_name
|
|
|
|
$module_params = @{ Name = $resource_name }
|
|
if ($params.ContainsKey("module_version")) {
|
|
$module_version = $params.module_version
|
|
$module_params.RequiredVersion = $params.module_version
|
|
}
|
|
else {
|
|
$module_version = "latest"
|
|
}
|
|
|
|
# For DSC class based resources we may need to ensure the module was loaded normally so the 'RequiredAssemblies' part
|
|
# of the manifest is run. This is important as classes have their types validated during parse time before the
|
|
# assemblies are loaded and this will fail if the assembly isn't already there.
|
|
# https://github.com/ansible-collections/ansible.windows/issues/66
|
|
Import-Module @module_params -ErrorAction SilentlyContinue
|
|
|
|
# A nested module of PSDesiredStateConfiguration calls Import-LocalizedData to load some localised resources which is
|
|
# fine in most cases but when running with Ansible the current UI culture may not match the UI culture that Windows was
|
|
# installed with so Import-LocalizedData fails. Because our modules run with $ErrorActionPreference = 'Stop' this
|
|
# failure cascades back and causes a fatal failure when loading PSDesiredStateConfiguration. This code does 2 things:
|
|
# * Resets the culture for this step only back to the install UI culture so Import-LocalizedData should not fail
|
|
# * Temporarily changes EAP to Continue to ensure that errors on that step don't bring down the whole import process
|
|
# https://github.com/ansible-collections/ansible.windows/issues/83
|
|
$existingCulture = [Threading.Thread]::CurrentThread.CurrentUICulture
|
|
$existingAction = $ErrorActionPreference
|
|
try {
|
|
[Threading.Thread]::CurrentThread.CurrentUICulture = [cultureinfo]::InstalledUICulture
|
|
$ErrorActionPreference = 'Continue' # Cannot use -ErrorAction Continue for some reason - probably due to how ipmo works
|
|
Import-Module -Name PSDesiredStateConfiguration
|
|
}
|
|
finally {
|
|
[Threading.Thread]::CurrentThread.CurrentUICulture = $existingCulture
|
|
$ErrorActionPreference = $existingAction
|
|
}
|
|
|
|
$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 }
|
|
}
|
|
|
|
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 "', '")'."
|
|
}
|
|
Write-AnsibleError -Msg $msg
|
|
}
|
|
|
|
# DSC Composite resources are currently not supported without a MOF file
|
|
# this check prevents the user from getting a "Failed to serialize properties into CimInstance." error and
|
|
# instead gets something a bit more informative while support is being worked on.
|
|
# https://github.com/ansible-collections/ansible.windows/issues/15
|
|
if ($resource.ImplementedAs -eq 'Composite') {
|
|
Write-AnsibleError -Msg "unsupported resource type: '$resource_name' is a composite resource"
|
|
}
|
|
|
|
# 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
|
|
$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"
|
|
}
|
|
|
|
# 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
|
|
$get_args = $dsc_args.Clone()
|
|
$get_args.Method = 'Get'
|
|
$get_args.Property = @{ Fake = 'Fake' }
|
|
$null = Invoke-SafeDscResource -Parameters $get_args -ErrorAction SilentlyContinue
|
|
|
|
# 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 }
|
|
|
|
$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
|
|
}
|
|
|
|
$module.ExitJson()
|