diff --git a/source/DSCResources/DSC_xServiceResource/DSC_xServiceResource.psm1 b/source/DSCResources/DSC_xServiceResource/DSC_xServiceResource.psm1 index 94962d49d..145c1ebb6 100644 --- a/source/DSCResources/DSC_xServiceResource/DSC_xServiceResource.psm1 +++ b/source/DSCResources/DSC_xServiceResource/DSC_xServiceResource.psm1 @@ -18,6 +18,14 @@ Import-Module -Name (Join-Path -Path $modulePath -ChildPath 'DscResource.Common' # Import Localization Strings $script:localizedData = Get-LocalizedData -DefaultUICulture 'en-US' +# This type is documented here: https://docs.microsoft.com/en-us/windows/win32/api/winsvc/ns-winsvc-sc_action +enum ACTION_TYPE { + NONE = 0 + RESTART = 1 + REBOOT = 2 + RUN_COMMAND = 3 +} + <# .SYNOPSIS Retrieves the current status of the service resource with the given name. @@ -80,17 +88,24 @@ function Get-TargetResource default { $serviceCimInstance.StartName } } + $serviceFailureActions = Get-ServiceFailureActions -Service $service.Name + $serviceResource = @{ - Name = $Name - Ensure = 'Present' - Path = $serviceCimInstance.PathName - StartupType = $startupType - BuiltInAccount = $builtInAccount - State = $service.Status.ToString() - DisplayName = $service.DisplayName - Description = $serviceCimInstance.Description - DesktopInteract = $serviceCimInstance.DesktopInteract - Dependencies = $dependencies + Name = $Name + Ensure = 'Present' + Path = $serviceCimInstance.PathName + StartupType = $startupType + BuiltInAccount = $builtInAccount + State = $service.Status.ToString() + DisplayName = $service.DisplayName + Description = $serviceCimInstance.Description + DesktopInteract = $serviceCimInstance.DesktopInteract + Dependencies = $dependencies + ResetPeriodSeconds = $serviceFailureActions.resetPeriodSeconds + FailureCommand = $serviceFailureActions.failureCommand + RebootMessage = $serviceFailureActions.rebootMessage + FailureActionsCollection = $serviceFailureActions.ActionsCollection + FailureActionsOnNonCrashFailures = $serviceFailureActions.FailureActionsOnNonCrashFailures } } else @@ -178,6 +193,32 @@ function Get-TargetResource The time to wait for the service to stop in milliseconds. The default value is 30000 (30 seconds). + .PARAMETER ResetPeriodSeconds + The time after which to reset the failure count to zero if there are no failures, in seconds. + Specify INFINITE to indicate that this value should never be reset. + + .PARAMETER RebootMessage + The message to be broadcast to server users before rebooting in response to the SC_ACTION_REBOOT service controller action. + If this value is NULL, the reboot message is unchanged. If the value is an empty string (""), the reboot message is deleted + and no message is broadcast. + + .PARAMETER FailureCommand + The command line of the process for the CreateProcess function to execute in response to the SC_ACTION_RUN_COMMAND service controller action. + This process runs under the same account as the service. If this value is NULL, the command is unchanged. + If the value is an empty string (""), the command is deleted and no program is run when the service fails. + + .PARAMETER FailureActionsCollection + An array of hash tables representing the failure actions to take. Each hash table should have + two keys: type and delayMilliSeconds. The value for the type key should be one of RESTART, RUN_COMMAND, REBOOT, or NONE. + The value for the delayMilliSeconds key is an integer value of seconds to wait before applying the requested action. + + .PARAMETER FailureActionsOnNonCrashFailures + By default, failure actions are only queued if the service terminates without reporting + a status of SERVICE_STOPPED. Setting this to true will queue actions in that case, as well + as if the SERVICE_STOPPED state is reported but the exit code is non-zero. This property + correspones to the 'Enable Actions For Stops With Errors' check box in the 'Recovery' tab + of the service properties GUI. + .PARAMETER Credential The credential of the user account the service should start under. @@ -263,6 +304,26 @@ function Set-TargetResource [System.UInt32] $TerminateTimeout = 30000, + [Parameter()] + [System.UInt32] + $ResetPeriodSeconds, + + [Parameter()] + [System.String] + $RebootMessage, + + [Parameter()] + [System.String] + $FailureCommand, + + [Parameter()] + [Microsoft.Management.Infrastructure.CimInstance[]] + $FailureActionsCollection, + + [Parameter()] + [System.Boolean] + $FailureActionsOnNonCrashFailures, + [Parameter()] [ValidateNotNull()] [System.Management.Automation.PSCredential] @@ -329,7 +390,10 @@ function Set-TargetResource # Update the properties of the service if needed $setServicePropertyParameters = @{} - $servicePropertyParameterNames = @( 'StartupType', 'BuiltInAccount', 'Credential', 'GroupManagedServiceAccount', 'DesktopInteract', 'DisplayName', 'Description', 'Dependencies' ) + $servicePropertyParameterNames = @( 'StartupType', 'BuiltInAccount', 'Credential', 'GroupManagedServiceAccount', + 'DesktopInteract', 'DisplayName', 'Description', 'Dependencies', + 'ResetPeriodSeconds', 'RebootMessage', 'FailureCommand', 'FailureActionsCollection', + 'FailureActionsOnNonCrashFailures') foreach ($servicePropertyParameterName in $servicePropertyParameterNames) { @@ -427,6 +491,32 @@ function Set-TargetResource .PARAMETER TerminateTimeout Not used in Test-TargetResource. + .PARAMETER ResetPeriodSeconds + The time after which to reset the failure count to zero if there are no failures, in seconds. + Specify INFINITE to indicate that this value should never be reset. + + .PARAMETER RebootMessage + The message to be broadcast to server users before rebooting in response to the SC_ACTION_REBOOT service controller action. + If this value is NULL, the reboot message is unchanged. If the value is an empty string (""), the reboot message is deleted + and no message is broadcast. + + .PARAMETER FailureCommand + The command line of the process for the CreateProcess function to execute in response to the SC_ACTION_RUN_COMMAND service controller action. + This process runs under the same account as the service. If this value is NULL, the command is unchanged. + If the value is an empty string (""), the command is deleted and no program is run when the service fails. + + .PARAMETER FailureActionsCollection + An array of hash tables representing the failure actions to take. Each hash table should have + two keys: type and delayMilliSeconds. The value for the type key should be one of RESTART, RUN_COMMAND, REBOOT, or NONE. + The value for the delayMilliSeconds key is an integer value of seconds to wait before applying the requested action. + + .PARAMETER FailureActionsOnNonCrashFailures + By default, failure actions are only queued if the service terminates without reporting + a status of SERVICE_STOPPED. Setting this to true will queue actions in that case, as well + as if the SERVICE_STOPPED state is reported but the exit code is non-zero. This property + correspones to the 'Enable Actions For Stops With Errors' check box in the 'Recovery' tab + of the service properties GUI. + .PARAMETER Credential The credential the service should be running under. @@ -499,6 +589,26 @@ function Test-TargetResource [System.UInt32] $TerminateTimeout = 30000, + [Parameter()] + [System.UInt32] + $ResetPeriodSeconds, + + [Parameter()] + [System.String] + $RebootMessage, + + [Parameter()] + [System.String] + $FailureCommand, + + [Parameter()] + [Microsoft.Management.Infrastructure.CimInstance[]] + $FailureActionsCollection, + + [Parameter()] + [System.Boolean] + $FailureActionsOnNonCrashFailures, + [Parameter()] [ValidateNotNull()] [System.Management.Automation.PSCredential] @@ -520,6 +630,12 @@ function Test-TargetResource New-InvalidArgumentException -ArgumentName 'BuiltInAccount / Credential / GroupManagedServiceAccount' -Message $errorMessage } + if ($PSBoundParameters.ContainsKey('FailureCommand') -and (-not (Test-HasRestartFailureAction -Collection $FailureActionsCollection))) + { + $errorMessage = $script:localizedData.MustSpecifyRestartFailureAction + New-InvalidArgumentException -ArgumentName 'FailureCommand' -Message $errorMessage + } + $serviceResource = Get-TargetResource -Name $Name if ($serviceResource.Ensure -eq 'Absent') @@ -640,6 +756,62 @@ function Test-TargetResource Write-Verbose -Message ($script:localizedData.ServicePropertyDoesNotMatch -f 'State', $Name, $State, $serviceResource.State) return $false } + + # Check the reset period + if($PSBoundParameters.ContainsKey('ResetPeriodSeconds') -and $ResetPeriodSeconds -ine $serviceResource.ResetPeriodSeconds) + { + Write-Verbose -Message ($script:localizedData.ServicePropertyDoesNotMatch -f 'ResetPeriodSeconds', $Name, $ResetPeriodSeconds, $serviceResource.ResetPeriodSeconds) + return $false + } + + # Check the failure command + if($PSBoundParameters.ContainsKey('FailureCommand') -and $FailureCommand -ine $serviceResource.failureCommand) + { + Write-Verbose -Message ($script:localizedData.ServicePropertyDoesNotMatch -f 'FailureCommand', $Name, $FailureCommand, $serviceResource.failureCommand) + return $false + } + + # Check the reboot message + if($PSBoundParameters.ContainsKey('RebootMessage') -and $RebootMessage -ine $serviceResource.rebootMessage) + { + Write-Verbose -Message ($script:localizedData.ServicePropertyDoesNotMatch -f 'RebootMessage', $Name, $RebootMessage, $serviceResource.rebootMessage) + return $false + } + + # Check the failure actions collection + if($PSBoundParameters.ContainsKey('FailureActionsCollection')) { + $inSync = $true + if($FailureActionsCollection.count -ne @($serviceResource.FailureActionsCollection).count) { + Write-Verbose -Message ($script:localizedData.ServicePropertyDoesNotMatch -f 'FailureActionsCollection.count', $Name, @($FailureActionsCollection).count, @($serviceResource.FailureActionsCollection).count) + return $false + } + + foreach ($actionIndex in (0..(@($FailureActionsCollection).count - 1))) { + $parameterAction = @($FailureActionsCollection)[$actionIndex] + $serviceResourceAction = @($serviceResource.FailureActionsCollection)[$actionIndex] + + if($parameterAction.type -ne $serviceResourceAction.type) { + Write-Verbose -Message ($script:localizedData.ServicePropertyDoesNotMatch -f "FailureActionsCollection Action $actionIndex type", $Name, $parameterAction.type, $serviceResourceAction.type) + $inSync = $false + } + + if($parameterAction.delayMilliSeconds -ne $serviceResourceAction.delayMilliSeconds) { + Write-Verbose -Message ($script:localizedData.ServicePropertyDoesNotMatch -f "FailureActionsCollection Action $actionIndex delayMilliSeconds", $Name, $parameterAction.delayMilliSeconds, $serviceResourceAction.delayMilliSeconds) + $inSync = $false + } + } + + if(-not $inSync) { + return $inSync + } + } + + # Check for service actions on non crash failures + if($PSBoundParameters.ContainsKey('FailureActionsOnNonCrashFailures') -and $FailureActionsOnNonCrashFailures -ne $serviceResource.failureActionsOnNonCrashFailures) + { + Write-Verbose -Message ($script:localizedData.ServicePropertyDoesNotMatch -f 'FailureActionsOnNonCrashFailures', $Name, $FailureActionsOnNonCrashFailures, $serviceResource.failureActionsOnNonCrashFailures) + return $false + } } return $true @@ -1575,6 +1747,32 @@ function Set-ServiceStartupType .PARAMETER StartupType The startup type the service should have. + .PARAMETER ResetPeriodSeconds + The time after which to reset the failure count to zero if there are no failures, in seconds. + Specify INFINITE to indicate that this value should never be reset. + + .PARAMETER RebootMessage + The message to be broadcast to server users before rebooting in response to the SC_ACTION_REBOOT service controller action. + If this value is NULL, the reboot message is unchanged. If the value is an empty string (""), the reboot message is deleted + and no message is broadcast. + + .PARAMETER FailureCommand + The command line of the process for the CreateProcess function to execute in response to the SC_ACTION_RUN_COMMAND service controller action. + This process runs under the same account as the service. If this value is NULL, the command is unchanged. + If the value is an empty string (""), the command is deleted and no program is run when the service fails. + + .PARAMETER FailureActionsCollection + An array of hash tables representing the failure actions to take. Each hash table should have + two keys: type and delayMilliSeconds. The value for the type key should be one of RESTART, RUN_COMMAND, REBOOT, or NONE. + The value for the delayMilliSeconds key is an integer value of seconds to wait before applying the requested action. + + .PARAMETER FailureActionsOnNonCrashFailures + By default, failure actions are only queued if the service terminates without reporting + a status of SERVICE_STOPPED. Setting this to true will queue actions in that case, as well + as if the SERVICE_STOPPED state is reported but the exit code is non-zero. This property + correspones to the 'Enable Actions For Stops With Errors' check box in the 'Recovery' tab + of the service properties GUI. + .NOTES SupportsShouldProcess is enabled because Invoke-CimMethod calls ShouldProcess. Here are the paths through which Set-ServiceProperty calls Invoke-CimMethod: @@ -1626,6 +1824,26 @@ function Set-ServiceProperty [AllowEmptyCollection()] $Dependencies, + [Parameter()] + [System.UInt32] + $ResetPeriodSeconds, + + [Parameter()] + [System.String] + $RebootMessage, + + [Parameter()] + [System.String] + $FailureCommand, + + [Parameter()] + [Microsoft.Management.Infrastructure.CimInstance[]] + $FailureActionsCollection, + + [Parameter()] + [System.Boolean] + $FailureActionsOnNonCrashFailures, + [Parameter()] [ValidateNotNull()] [System.Management.Automation.PSCredential] @@ -1685,6 +1903,39 @@ function Set-ServiceProperty Set-ServiceAccountProperty -ServiceName $ServiceName @setServiceAccountPropertyParameters } + # Update service failure actions properties if needed + $setServiceFailureActionsPropertyParameters = @{} + + if ($PSBoundParameters.ContainsKey('ResetPeriodSeconds')) + { + $setServiceFailureActionsPropertyParameters['ResetPeriodSeconds'] = $ResetPeriodSeconds + } + + if ($PSBoundParameters.ContainsKey('RebootMessage')) + { + $setServiceFailureActionsPropertyParameters['RebootMessage'] = $RebootMessage + } + + if ($PSBoundParameters.ContainsKey('FailureCommand')) + { + $setServiceFailureActionsPropertyParameters['FailureCommand'] = $FailureCommand + } + + if ($PSBoundParameters.ContainsKey('FailureActionsCollection')) + { + $setServiceFailureActionsPropertyParameters['FailureActionsCollection'] = $FailureActionsCollection + } + + if ($PSBoundParameters.ContainsKey('FailureActionsOnNonCrashFailures')) + { + $setServiceFailureActionsPropertyParameters['FailureActionsOnNonCrashFailures'] = $FailureActionsOnNonCrashFailures + } + + if($setServiceFailureActionsPropertyParameters.count -gt 0) + { + Set-ServiceFailureActionProperty -ServiceName $ServiceName @setServiceFailureActionsPropertyParameters + } + # Update startup type if ($PSBoundParameters.ContainsKey('StartupType')) { @@ -1870,3 +2121,395 @@ function Stop-ServiceWithTimeout $waitTimeSpan = New-Object -TypeName 'TimeSpan' -ArgumentList (0, 0, 0, 0, $TerminateTimeout) Wait-ServiceStateWithTimeout -ServiceName $ServiceName -State 'Stopped' -WaitTimeSpan $waitTimeSpan } + +<# + .SYNOPSIS + Get the Failure Actions properties for a service from the registry + .DESCRIPTION + For the named service, read the registry and find its Failure Actions settings. + + Most of the data this function needs to retrieve is from the FailureActions registry key. + This key is a binary field that encodes the contents of a SERVICE_FAILURE_ACTIONSA C++ struct. + The struct and how to read its contents are documented at the link below. + https://docs.microsoft.com/en-us/windows/win32/api/winsvc/ns-winsvc-service_failure_actionsa + + It will also read the 'FailureCommand' and 'RebootMessage' property to get the string values for + those settings, and the 'FailureActionsOnNonCrashFailures' property to get the + flag value that stores the current state of the 'Enable Actions For Stops With Errors' checkbox. + .EXAMPLE + PS C:\> $serviceFailureActions = Get-ServiceFailureActions -Service $service + Reads the failure actions from the binary data in the registry + .PARAMETER Service + The name of the service to retrieve properties for. +#> +function Get-ServiceFailureActions { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [String] + $Service + ) + process { + if($registryData = Get-Item -Path HKLM:\SYSTEM\CurrentControlSet\Services\$service -ErrorAction SilentlyContinue) + { + $failureActions = [PSCustomObject]@{ + resetPeriodSeconds = $null + hasRebootMessage = $null + hasFailureCommand = $null + failureActionCount = @() + failureCommand = $null + rebootMessage = $null + actionsCollection = $null + FailureActionsOnNonCrashFailures = $false + } + + if($registryData.GetvalueNames() -match 'FailureCommand') { + $failureActions.failureCommand = $registryData.GetValue('FailureCommand') + } + + if($registryData.GetValueNames() -match 'RebootMessage') { + $failureActions.rebootMessage = $registryData.GetValue('RebootMessage') + } + + if($registryData.GetvalueNames() -match 'FailureActionsOnNonCrashFailures') { + $failureActions.FailureActionsOnNonCrashFailures = [System.Boolean]$registryData.GetValue('FailureActionsOnNonCrashFailures') + } + + if($registryData.GetValueNames() -match 'FailureActions') + { + $failureActionsBinaryData = $registryData.GetValue('FailureActions') + + # The first four bytes represent the Reset Period. + $failureActions.resetPeriodSeconds = Get-FailureActionsProperty -PropertyName ResetPeriodSeconds -Bytes $failureActionsBinaryData + + # Next four bytes indicate the presence of a reboot message in case one of the chosen failure actions is + # SC_ACTION_REBOOT. The actual value of the message is stored in the 'RebootMessage' property + $failureActions.hasRebootMessage = Get-FailureActionsProperty -PropertyName HasRebootMsg -Bytes $failureActionsBinaryData + + # The next four bytes indicate whether a failure action run command exists. This command + # would be run in the case one of the failure actions chosen is SC_ACTION_RUN_COMMAND + # If this value is true then the actual command string is stored in the 'FailureCommand' property. + $failureActions.hasFailureCommand = Get-FailureActionsProperty -PropertyName HasFailureCommand -Bytes $failureActionsBinaryData + + # These four bytes give the count of how many reboot failure actions have been defined. + $failureActions.failureActionCount = Get-FailureActionsProperty -PropertyName FailureActionCount -Bytes $failureActionsBinaryData + + if($failureActions.failureActionCount -gt 0) + { + $failureActions.ActionsCollection = Get-FailureActionCollection -Bytes $failureActionsBinaryData -ActionsCount $failureActions.failureActionCount + } + } + + $failureActions + } + } +} + +<# + .SYNOPSIS + Translate the binary data from the registry into a property value + + .DESCRIPTION + The binary data in the 'FailureActions' registry property encodes a _SERVICE_FAILURE_ACTIONSA struct. + This function translates human readable property names into the byte range encoded by the binary data. + Those bytes are then read and cast to the integer values they store. + + .PARAMETER PropertyName + The name of the property to retrieve from the struct. + + .PARAMETER Bytes + The raw binary data read out from the registry. +#> +function Get-FailureActionsProperty +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateSet('ResetPeriodSeconds','HasRebootMsg','HasFailureCommand','FailureActionCount')] + [System.String] + $PropertyName, + [Parameter(Mandatory)] + [System.Byte[]] + $Bytes + ) + + process { + $byteRange = switch ($PropertyName) { + ResetPeriodSeconds { 0..3 } + HasRebootMsg { 4..7 } + HasFailureCommand { 8..11 } + FailureActionCount { 12..15 } + Default {} + } + + [System.BitConverter]::ToInt32($Bytes[$byteRange],0) + } +} + +<# + .SYNOPSIS + Retrieve and decode the array of service failure actions + + .DESCRIPTION + The array of SC_ACTION structures that define each failure action is stored + in the same binary blob property as the struct that defines the rest of the + service failure actions settings. This function takes the binary data and a + count of how many actions to expect, and does the offset math to read each + action and decode it, and return the actions as an array of custom objects. + + .PARAMETER Bytes + The binary data that stores the actions array. + + .PARAMETER ActionsCount + The number of actions encoded in the data. +#> +function Get-FailureActionCollection +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [System.Byte[]] + $Bytes, + [Parameter(Mandatory)] + [System.UInt32] + $ActionsCount + ) + + process { + $actionsCollection = New-Object System.Collections.Generic.List[PSObject] + + foreach($action in 0..($ActionsCount - 1)) { + + # The structure of the _SERVICE_FAILURE_ACTIONS struct encoded in this registry key + # dictates that the array of _SC_ACTION structs will always start at byte 20. + # The 8 is because each _SC_ACTION is a 8 byte struct. + $actionTypeByteRange = (20 + (8 * $action))..(20 + (8 * $action) + 3) + $actionDelayByteRange = (20 + (8 * $action) + 4)..(20 + (8 * $action) + 7) + + $currentAction = [PSCustomObject]@{ + type = [ACTION_TYPE]([System.BitConverter]::ToInt32($Bytes[$actionTypeByteRange],0)) + delayMilliSeconds = [System.BitConverter]::ToInt32($Bytes[$actionDelayByteRange],0) + } + + $actionsCollection.Add($currentAction) | Out-Null + } + + $actionsCollection + } +} + +<# + .SYNOPSIS + Set a failure actions settings registry value. + + .DESCRIPTION + Set all of the settings that are stored in the 'FailureActions' binary registry property + as well as some of the settings stored in other properties like 'RebootMessage', + 'FailureCommand', and 'FailureActionsOnNonCrashFailures'. + + .PARAMETER ServiceName + The name of the service to set + + .PARAMETER ResetPeriodSeconds + The time after which to reset the failure count to zero if there are no failures, in seconds. + Specify INFINITE to indicate that this value should never be reset. + + .PARAMETER RebootMessage + The message to be broadcast to server users before rebooting in response to the SC_ACTION_REBOOT service controller action. + If this value is NULL, the reboot message is unchanged. If the value is an empty string (""), the reboot message is deleted + and no message is broadcast. + + .PARAMETER FailureCommand + The command line of the process for the CreateProcess function to execute in response to the SC_ACTION_RUN_COMMAND service controller action. + This process runs under the same account as the service. If this value is NULL, the command is unchanged. + If the value is an empty string (""), the command is deleted and no program is run when the service fails. + + .PARAMETER FailureActionsCollection + An array of hash tables representing the failure actions to take. Each hash table should have + two keys: type and delayMilliSeconds. The value for the type key should be one of RESTART, RUN_COMMAND, REBOOT, or NONE. + The value for the delayMilliSeconds key is an integer value of seconds to wait before applying the requested action. + + .PARAMETER FailureActionsOnNonCrashFailures + By default, failure actions are only queued if the service terminates without reporting + a status of SERVICE_STOPPED. Setting this to true will queue actions in that case, as well + as if the SERVICE_STOPPED state is reported but the exit code is non-zero. This property + correspones to the 'Enable Actions For Stops With Errors' check box in the 'Recovery' tab + of the service properties GUI. +#> +function Set-ServiceFailureActionProperty { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [System.String] + $ServiceName, + + [Parameter()] + [System.UInt32] + $ResetPeriodSeconds, + + [Parameter()] + [System.String] + $RebootMessage, + + [Parameter()] + [System.String] + $FailureCommand, + + [Parameter()] + [Microsoft.Management.Infrastructure.CimInstance[]] + $FailureActionsCollection, + + [Parameter()] + [System.Boolean] + $FailureActionsOnNonCrashFailures + ) + + process { + $failureActions = Get-ServiceFailureActions -Service $ServiceName + + <# + This integer array will store the cumulative value that should be stored + in the 'FailureActions' registry property. This property must always be set as + one large byte array, so the way this works is as follows: + 1. Get the settings as they exist now. + 2. Check each of the available values to see if something has changed. + If it has, modify the value in the $failureActions object. + 3. Once that check as been run add the value from the $failureActions object to the + $integerData array. + 4. Once all values have been checked, the integer array can be turned into a + byte array of hex values and then that is written to the registry. + #> + $integerData = New-Object System.Collections.Generic.List[int32] + + # Check to see if we need to modify the existing value. + if ($PSBoundParameters.ContainsKey('ResetPeriodSeconds')) + { + $failureActions.resetPeriodSeconds = $ResetPeriodSeconds + } + + # Add the value to the integer array. + $integerData.add($failureActions.resetPeriodSeconds) | Out-Null + + if ($PSBoundParameters.ContainsKey('RebootMessage')) + { + # This setting is a combination a flag in the _SERVICE_FAILURE_ACTIONSA struct, + # and the actual string value for the message stored in the 'RebootMessage' registry property. + # If hasRebootMessage is already true, then we know the key exists and we can just set it. + if (Get-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName -Name 'RebootMessage' -ErrorAction SilentlyContinue) + { + Set-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName -Name 'RebootMessage' -Value $RebootMessage | Out-Null + } + else + { + # If hasRebootMessage was false, we both have to create the key with the string value, and set the flag to true in the struct. + New-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName -Name 'RebootMessage' -Value $RebootMessage | Out-Null + $failureActions.hasRebootMessage = 1 + } + } + + # Add the current value of the flag to the array. + $integerData.add($failureActions.hasRebootMessage) | Out-Null + + # This is the same as the RebootMessage property above. It's a combination of a flag in the struct, and an external property that stores the value. + if ($PSBoundParameters.ContainsKey('FailureCommand')) + { + if (Get-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName -Name 'FailureCommand' -ErrorAction SilentlyContinue) + { + Set-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName -Name 'FailureCommand' -Value $FailureCommand | Out-Null + } + else + { + New-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName -Name 'FailureCommand' -Value $FailureCommand | Out-Null + $failureActions.hasFailureCommand = 1 + } + } + + # Add the current value of the flag to the array. + $integerData.Add($failureActions.hasFailureCommand) | Out-Null + + if ($PSBoundParameters.ContainsKey('FailureActionsCollection')) + { + $failureActions.ActionsCollection = $FailureActionsCollection + } + + # Even if we didn't change anything about the failure actions, we still have to build the array + $integerData.add($failureActions.ActionsCollection.count) + + # This element of the array is a pointer to the array of actions. It's always going to be 0x14 or 20 in decimal. + $integerData.Add(20) + + # Iterate over the actions and their properties to add the integers that they encode to the array. + foreach ($action in $failureActions.ActionsCollection) { + $integerData.add([ACTION_TYPE]$action.type) | Out-Null + $integerData.add($action.delayMilliSeconds) | Out-Null + } + + # Now that we finally have all of the data we need, we can convert it to a byte array. + $bytes = $integerData | Format-Hex -raw | Select-Object -ExpandProperty bytes + + Set-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName -Name 'FailureActions' -Value $bytes | Out-Null + + # This flag is stored in it's own registry property, so we don't have to add a flag value to the struct, but we do have to create the key + # if it doesn't already exist. + if ($PSBoundParameters.ContainsKey('FailureActionsOnNonCrashFailures')) + { + Invoke-SCFailureFlag -ServiceName $ServiceName -Flag $FailureActionsOnNonCrashFailures + } + } +} + +function Test-HasRestartFailureAction +{ + [CmdletBinding()] + param ( + [Parameter()] + [System.Object[]] + $Collection + ) + + process { + $hasRestartAction = $false + + foreach ($action in $collection) { + if ($action.type -eq 'RUN_COMMAND') { + $hasRestartAction = $true + } + } + + $hasRestartAction + } +} + +<# +.SYNOPSIS + Use sc.exe to set the failure flag +.DESCRIPTION + The FailureActionsOnNonCrashFailures feature of the service controller + cannnot be reliably managed in code via anything but sc.exe. Managing the + registry key value on its own has proved inadequate. This cmdlet gives us + an easily mockable and testable way to invoke sc to manage this flag. +.EXAMPLE + PS C:\> Invoke-SCFailureFlag -ServiceName WSearch -Flag 1 + This will invoke the equivelent of '& sc.exe failureFlag WSearch 1' +#> +function Invoke-SCFailureFlag { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [System.String] + $ServiceName, + [Parameter(Mandatory = $true)] + [ValidateSet(0,1)] + [System.Int32] + $Flag + ) + + process { + & sc.exe @('failureFlag', $ServiceName, $Flag) | Out-Null + # A quick sanity check to make sure the value was set as expected without having to parse the string output of sc. + $failureFlag = (Get-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName -Name 'FailureActionsOnNonCrashFailures').failureActionsOnNonCrashFailures + if ($failureFlag -ne $Flag) { + throw "sc.exe did not succesfully set the failure flag for $servicename" + } + } +} diff --git a/source/DSCResources/DSC_xServiceResource/DSC_xServiceResource.schema.mof b/source/DSCResources/DSC_xServiceResource/DSC_xServiceResource.schema.mof index 2876dbea2..af7cd26ee 100644 --- a/source/DSCResources/DSC_xServiceResource/DSC_xServiceResource.schema.mof +++ b/source/DSCResources/DSC_xServiceResource/DSC_xServiceResource.schema.mof @@ -16,4 +16,16 @@ class DSC_xServiceResource : OMI_BaseResource [Write,Description("An array of strings indicating the names of the dependencies of the service.")] String Dependencies[]; [Write,Description("The time to wait for the service to start in milliseconds. Defaults to 30000.")] uint32 StartupTimeout; [Write,Description("The time to wait for the service to stop in milliseconds. Defaults to 30000.")] uint32 TerminateTimeout; + [Write,Description("The time to wait for the Failure count to reset in seconds.")] UInt32 ResetPeriodSeconds; + [Write,Description("The command line to run if a service fails.")] String FailureCommand; + [Write,EmbeddedInstance("DSC_xFailureAction"),Description("The actions to take when a service fails.")] String FailureActionsCollection[]; + [Write,Description("A flag indicating whether failure actions should be invoked on non-crash failures.")] Boolean FailureActionsOnNonCrashFailures; + [Write,Description("An optional broadcast message to send to logged in users if the machine reboots as a result of a failure action.")] String RebootMessage; +}; + +[ClassVersion("1.0.0")] +class DSC_xFailureAction +{ + [Write, Description("The action to take on failure"), ValueMap{"NONE", "RESTART", "REBOOT", "RUN_COMMAND"}, Values{"NONE", "RESTART", "REBOOT", "RUN_COMMAND"}] String Type; + [Write, Description("Delay in milliseconds to wait before taking the specified action")] UInt32 DelayMilliSeconds; }; diff --git a/source/DSCResources/DSC_xServiceResource/en-US/DSC_xServiceResource.strings.psd1 b/source/DSCResources/DSC_xServiceResource/en-US/DSC_xServiceResource.strings.psd1 index 6bfb21e89..377ff19da 100644 --- a/source/DSCResources/DSC_xServiceResource/en-US/DSC_xServiceResource.strings.psd1 +++ b/source/DSCResources/DSC_xServiceResource/en-US/DSC_xServiceResource.strings.psd1 @@ -37,4 +37,5 @@ ConvertFrom-StringData @' CannotGetAccountAccessErrorMessage = Failed to get user policy rights. CannotSetAccountAccessErrorMessage = Failed to set user policy rights. CorruptDependency = Service '{0}' has a corrupt dependency. For more information, inspect the registry value at HKLM:\\SYSTEM\\CurrentControlSet\\Services\\{0}\\DependOnService. + MustSpecifyRestartFailureAction = A failure command can only be specified if one of the failure actions is 'RUN_COMMAND' '@ diff --git a/source/Examples/xService_ChangeServiceRecoveryOptions.ps1 b/source/Examples/xService_ChangeServiceRecoveryOptions.ps1 new file mode 100644 index 000000000..a158becdc --- /dev/null +++ b/source/Examples/xService_ChangeServiceRecoveryOptions.ps1 @@ -0,0 +1,100 @@ +#Requires -module 'xPSDesiredStateConfiguration' + +<# + .SYNOPSIS + Configuration that changes the recovery options for an existing service. + + .DESCRIPTION + Configuration that changes the recovery options for an existing service. + + .PARAMETER Name + The name of the Windows service. + + .PARAMETER State + The state that the Windows service should have. + + .PARAMETER ResetPeriodSeconds + The time to wait for the Failure count to reset in seconds. + + .PARAMETER FailureCommand + The command line to run if a service fails. + + .PARAMETER RebootMessage + An optional broadcast message to send to logged in users if the machine reboots as a result of a failure action. + + .EXAMPLE + xService_ChangeServiceStateConfig -Name 'spooler' -State 'Stopped' + + Compiles a configuration that make sure the state for the Windows + service 'spooler' is 'Stopped'. If the service is running the + Windows service will be stopped. +#> +Configuration xService_ChangeServiceFailureActionsConfig +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Name, + + [Parameter()] + [ValidateSet('Running', 'Stopped')] + [System.String] + $State = 'Running', + + [Parameter()] + [System.Int32] + $ResetPeriodSeconds = 86400, + + [Parameter()] + [System.String] + $FailureCommand, + + [Parameter()] + [System.String] + $RebootMessage, + + [Parameter()] + [Switch] + $FailureActionsOnNonCrashFailures + ) + + Import-DscResource -ModuleName 'xPSDesiredStateConfiguration' + + Node localhost + { + xService 'ChangeServiceState' + { + Name = $Name + State = $State + Ensure = 'Present' + ResetPeriodSeconds = $ResetPeriodSeconds + RebootMessage = $RebootMessage + FailureCommand = $FailureCommand + FailureActionsOnNonCrashFailures = $FailureActionsOnNonCrashFailures + FailureActionsCollection = @( + DSC_xFailureAction + { + Type = 'RESTART' + DelayMilliSeconds = 60000 + } + DSC_xFailureAction + { + Type = 'RESTART' + DelayMilliSeconds = 120000 + } + DSC_xFailureAction + { + Type = 'Reboot' + DelayMilliSeconds = 240000 + } + DSC_xFailureAction + { + Type = 'RUN_COMMAND' + DelayMilliSeconds = 240000 + } + ) + } + } +} diff --git a/tests/Unit/DSC_xServiceResource.Tests.ps1 b/tests/Unit/DSC_xServiceResource.Tests.ps1 index da6a5642d..9f1c2bf4a 100644 --- a/tests/Unit/DSC_xServiceResource.Tests.ps1 +++ b/tests/Unit/DSC_xServiceResource.Tests.ps1 @@ -217,8 +217,137 @@ try $getTargetResourceResult.Dependencies | Should -Be $ExpectedValues.Dependencies } } + + if ($ExpectedValues.ContainsKey('ResetPeriodSeconds')) + { + it 'Should return the reset period in seconds' { + $getTargetResourceResult.resetPeriodSeconds | Should -Be $ExpectedValues.resetPeriodSeconds + } + } + + if ($ExpectedValues.ContainsKey('FailureCommand')) + { + it 'Should return the failure command' { + $getTargetResourceResult.failureCommand | Should -Be $ExpectedValues.FailureCommand + } + } + + if ($ExpectedValues.ContainsKey('RebootMessage')) + { + it 'Should return the reboot message' { + $getTargetResourceResult.rebootMessage | Should -Be $ExpectedValues.RebootMessage + } + } + + if ($ExpectedValues.ContainsKey('FailureActionsCollection')) + { + foreach ($index in 0..($ExpectedValues.failureActionsCollection.count - 1)) { + it "Should return the correct failure action for index $index" { + $getTargetResourceResult.failureActionsCollection[$index].type | Should -Be $ExpectedValues.FailureActionsCollection[$index].type + $getTargetResourceResult.failureActionsCollection[$index].delaySeconds | Should -Be $ExpectedValues.FailureActionsCollection[$index].delaySeconds + } + } + } + + if ($ExpectedValues.ContainsKey('FailureActionsOnNonCrashFailures')) + { + it 'Should return the failure actions on non crash failures flag' { + $getTargetResourceResult.failureActionsOnNonCrashFailures | Should -Be $ExpectedValues.failureActionsOnNonCrashFailures + } + } } + function Get-MockFailureActionsData + { + [CmdletBinding()] + param ( + [Parameter()] + [System.String] + $DataSetName + ) + + switch ($DataSetName) { + '1ActionNoCommandOrMessage' { + $data = @{ + resetPeriodSeconds = 86400 + hasRebootMessage = 0 + hasFailureCommand = 0 + failureActionCount = 1 + failureCommand = $null + rebootMessage = $null + actionsCollection = @(@{ + type = 'RESTART' + delaySeconds = 30000 + }) + failureActionsOnNonCrashFailures = $true + } + } + '3ActionsCommandAndMessage' { + $data = @{ + resetPeriodSeconds = 172800 + hasRebootMessage = 1 + hasFailureCommand = 1 + failureActionCount = 3 + failureCommand = 'C:\new\command.exe' + rebootMessage = 'Mocked Reboot Message' + actionsCollection = @(@{ + type = 'RESTART' + delaySeconds = 30000 + },@{ + type = 'RUN_COMMAND' + delaySeconds = 30000 + },@{ + type = 'REBOOT' + delaySeconds = 30000 + }) + failureActionsOnNonCrashFailures = $true + } + } + Default {} + } + + $keyData = New-Object 'System.Collections.Generic.List[int]' + + $keyData.add($data.resetPeriodSeconds) | Out-Null + $keyData.add($data.hasRebootMessage) | Out-Null + $keyData.add($data.hasFailureCommand) | Out-Null + $keyData.add($data.failureActionCount) | Out-Null + $keyData.add(20) | Out-Null + + foreach ($action in $data.ActionsCollection) { + $keyData.add([ACTION_TYPE]$action.type) | Out-Null + $keyData.add($action.delaySeconds) | Out-Null + } + + $key = @{ + FailureActions = $keyData | Format-Hex -raw | Select-Object -ExpandProperty bytes + } + + if ($data.failureActionsOnNonCrashFailures) + { + $key.failureActionsOnNonCrashFailures = 1 + } + + if ($data.hasRebootMessage) + { + $key.RebootMessage = 'Mocked Reboot Message' + } + + if ($data.hasFailureCommand) + { + $key.FailureCommand = 'C:\new\command.exe' + } + + $GetValueNames = {$this.keys} + $GetValue = {param($key) $this[$key]} + + Add-Member -InputObject $key -MemberType ScriptMethod -Name GetValueNames -Value $GetValueNames + Add-Member -InputObject $key -MemberType ScriptMethod -Name GetValue -Value $GetValue + + @([PSCustomObject]$data, $key) + } + + Mock -CommandName 'Get-Service' -MockWith { } Mock -CommandName 'Get-ServiceCimInstance' -MockWith { } Mock -CommandName 'ConvertTo-StartupTypeString' -MockWith { } @@ -265,23 +394,31 @@ try DesktopInteract = $true } + $testServiceFailureActions, $failureRegistryKey = Get-MockFailureActionsData -DataSetName '1ActionNoCommandOrMessage' + Mock -CommandName 'Get-Service' -MockWith { return $testService } Mock -CommandName 'Get-ServiceCimInstance' -MockWith { return $testServiceCimInstance } Mock -CommandName 'ConvertTo-StartupTypeString' -MockWith { return $convertToStartupTypeStringResult } + Mock -CommandName 'Get-Item' -ParameterFilter { $Path -eq "HKLM:\SYSTEM\CurrentControlSet\Services\$($testService.name)" } -MockWith { return $failureRegistryKey} Test-GetTargetResourceDoesntThrow -GetTargetResourceParameters $getTargetResourceParameters -TestServiceCimInstance $testServiceCimInstance $expectedValues = @{ - Name = $getTargetResourceParameters.Name - Ensure = 'Present' - Path = $testServiceCimInstance.PathName - StartupType = $convertToStartupTypeStringResult - BuiltInAccount = $testServiceCimInstance.StartName - State = $testService.Status - DisplayName = $testService.DisplayName - Description = $testServiceCimInstance.Description - DesktopInteract = $testServiceCimInstance.DesktopInteract - Dependencies = [System.Object[]] $testService.ServicesDependedOn.Name + Name = $getTargetResourceParameters.Name + Ensure = 'Present' + Path = $testServiceCimInstance.PathName + StartupType = $convertToStartupTypeStringResult + BuiltInAccount = $testServiceCimInstance.StartName + State = $testService.Status + DisplayName = $testService.DisplayName + Description = $testServiceCimInstance.Description + DesktopInteract = $testServiceCimInstance.DesktopInteract + Dependencies = [System.Object[]] $testService.ServicesDependedOn.Name + ResetPeriodSeconds = $testServiceFailureActions.resetPeriodSeconds + FailureCommand = $testServiceFailureActions.failureCommand + RebootMessage = $testServiceFailureActions.rebootMessage + FailureActionsCollection = $testServiceFailureActions.actionsCollection + failureActionsOnNonCrashFailures = $testServiceFailureActions.failureActionsOnNonCrashFailures } Test-GetTargetResourceResult -GetTargetResourceParameters $getTargetResourceParameters -ExpectedValues $expectedValues @@ -307,23 +444,31 @@ try DesktopInteract = $false } + $testServiceFailureActions, $failureRegistryKey = Get-MockFailureActionsData -DataSetName '3ActionsCommandAndMessage' + Mock -CommandName 'Get-Service' -MockWith { return $testService } Mock -CommandName 'Get-ServiceCimInstance' -MockWith { return $testServiceCimInstance } Mock -CommandName 'ConvertTo-StartupTypeString' -MockWith { return $convertToStartupTypeStringResult } + Mock -CommandName 'Get-Item' -ParameterFilter { $Path -eq "HKLM:\SYSTEM\CurrentControlSet\Services\$($testService.name)" } -MockWith { return $failureRegistryKey} Test-GetTargetResourceDoesntThrow -GetTargetResourceParameters $getTargetResourceParameters -TestServiceCimInstance $testServiceCimInstance $expectedValues = @{ - Name = $getTargetResourceParameters.Name - Ensure = 'Present' - Path = $testServiceCimInstance.PathName - StartupType = $convertToStartupTypeStringResult - BuiltInAccount = $expectedBuiltInAccountValue - State = $testService.Status - DisplayName = $testService.DisplayName - Description = $testServiceCimInstance.Description - DesktopInteract = $testServiceCimInstance.DesktopInteract - Dependencies = $null + Name = $getTargetResourceParameters.Name + Ensure = 'Present' + Path = $testServiceCimInstance.PathName + StartupType = $convertToStartupTypeStringResult + BuiltInAccount = $expectedBuiltInAccountValue + State = $testService.Status + DisplayName = $testService.DisplayName + Description = $testServiceCimInstance.Description + DesktopInteract = $testServiceCimInstance.DesktopInteract + Dependencies = $null + ResetPeriodSeconds = $testServiceFailureActions.resetPeriodSeconds + FailureCommand = $testServiceFailureActions.failureCommand + RebootMessage = $testServiceFailureActions.rebootMessage + FailureActionsCollection = $testServiceFailureActions.actionsCollection + failureActionsOnNonCrashFailures = $testServiceFailureActions.failureActionsOnNonCrashFailures } Test-GetTargetResourceResult -GetTargetResourceParameters $getTargetResourceParameters -ExpectedValues $expectedValues @@ -356,23 +501,31 @@ try DesktopInteract = $false } + $testServiceFailureActions, $failureRegistryKey = Get-MockFailureActionsData -DataSetName '1ActionNoCommandOrMessage' + Mock -CommandName 'Get-Service' -MockWith { return $testService } Mock -CommandName 'Get-ServiceCimInstance' -MockWith { return $testServiceCimInstance } Mock -CommandName 'ConvertTo-StartupTypeString' -MockWith { return $convertToStartupTypeStringResult } + Mock -CommandName 'Get-Item' -ParameterFilter { $Path -eq "HKLM:\SYSTEM\CurrentControlSet\Services\$($testService.name)" } -MockWith { return $failureRegistryKey} Test-GetTargetResourceDoesntThrow -GetTargetResourceParameters $getTargetResourceParameters -TestServiceCimInstance $testServiceCimInstance $expectedValues = @{ - Name = $getTargetResourceParameters.Name - Ensure = 'Present' - Path = $testServiceCimInstance.PathName - StartupType = $convertToStartupTypeStringResult - BuiltInAccount = $expectedBuiltInAccountValue - State = $testService.Status - DisplayName = $null - Description = $null - DesktopInteract = $testServiceCimInstance.DesktopInteract - Dependencies = [System.Object[]] $testService.ServicesDependedOn.Name + Name = $getTargetResourceParameters.Name + Ensure = 'Present' + Path = $testServiceCimInstance.PathName + StartupType = $convertToStartupTypeStringResult + BuiltInAccount = $expectedBuiltInAccountValue + State = $testService.Status + DisplayName = $null + Description = $null + DesktopInteract = $testServiceCimInstance.DesktopInteract + Dependencies = [System.Object[]] $testService.ServicesDependedOn.Name + ResetPeriodSeconds = $testServiceFailureActions.resetPeriodSeconds + FailureCommand = $testServiceFailureActions.failureCommand + RebootMessage = $testServiceFailureActions.rebootMessage + FailureActionsCollection = $testServiceFailureActions.actionsCollection + failureActionsOnNonCrashFailures = $testServiceFailureActions.failureActionsOnNonCrashFailures } Test-GetTargetResourceResult -GetTargetResourceParameters $getTargetResourceParameters -ExpectedValues $expectedValues @@ -442,10 +595,13 @@ try DesktopInteract = $false } + $testServiceFailureActions, $failureRegistryKey = Get-MockFailureActionsData -DataSetName '1ActionNoCommandOrMessage' + Mock -CommandName 'Get-Service' -MockWith { return $testService } Mock -CommandName 'Get-ServiceCimInstance' -MockWith { return $testServiceCimInstance } Mock -CommandName 'ConvertTo-StartupTypeString' -MockWith { return $convertToStartupTypeStringResult } Mock -CommandName 'Write-Warning' + Mock -CommandName 'Get-Item' -ParameterFilter { $Path -eq "HKLM:\SYSTEM\CurrentControlSet\Services\$($testService.name)" } -MockWith { return $failureRegistryKey} Test-GetTargetResourceDoesntThrow -GetTargetResourceParameters $getTargetResourceParameters -TestServiceCimInstance $testServiceCimInstance @@ -454,16 +610,21 @@ try } $expectedValues = @{ - Name = $getTargetResourceParameters.Name - Ensure = 'Present' - Path = $testServiceCimInstance.PathName - StartupType = $convertToStartupTypeStringResult - BuiltInAccount = $testServiceCimInstance.StartName - State = $testService.Status - DisplayName = $testService.DisplayName - Description = $testServiceCimInstance.Description - DesktopInteract = $testServiceCimInstance.DesktopInteract - Dependencies = [System.Object[]] ($testService.ServicesDependedOn | Where-Object -FilterScript {![System.String]::IsNullOrEmpty($_.Name)}).Name + Name = $getTargetResourceParameters.Name + Ensure = 'Present' + Path = $testServiceCimInstance.PathName + StartupType = $convertToStartupTypeStringResult + BuiltInAccount = $testServiceCimInstance.StartName + State = $testService.Status + DisplayName = $testService.DisplayName + Description = $testServiceCimInstance.Description + DesktopInteract = $testServiceCimInstance.DesktopInteract + Dependencies = [System.Object[]] ($testService.ServicesDependedOn | Where-Object -FilterScript {![System.String]::IsNullOrEmpty($_.Name)}).Name + ResetPeriodSeconds = $testServiceFailureActions.resetPeriodSeconds + FailureCommand = $testServiceFailureActions.failureCommand + RebootMessage = $testServiceFailureActions.rebootMessage + FailureActionsCollection = $testServiceFailureActions.actionsCollection + failureActionsOnNonCrashFailures = $testServiceFailureActions.failureActionsOnNonCrashFailures } Test-GetTargetResourceResult -GetTargetResourceParameters $getTargetResourceParameters -ExpectedValues $expectedValues @@ -629,6 +790,14 @@ try DisplayName = 'TestDisplayName' Description = 'Test device description' Dependencies = @( 'TestServiceDependency1', 'TestServiceDependency2' ) + ResetPeriodSeconds = 86400 + RebootMessage = 'RebootMessage' + FailureCommand = 'C:\Path\To\Command.exe' + FailureActionsCollection = @(@{ + type = 'RESTART' + delaySeconds = 500 + }) + FailureActionsOnNonCrashFailures = $true } It 'Should not throw' { @@ -656,7 +825,20 @@ try } It 'Should change all service properties except Credential' { - Assert-MockCalled -CommandName 'Set-ServiceProperty' -ParameterFilter { $ServiceName -eq $setTargetResourceParameters.Name -and $StartupType -eq $setTargetResourceParameters.StartupType -and $BuiltInAccount -eq $setTargetResourceParameters.BuiltInAccount -and $DesktopInteract -eq $setTargetResourceParameters.DesktopInteract -and $DisplayName -eq $setTargetResourceParameters.DisplayName -and $Description -eq $setTargetResourceParameters.Description -and $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.Dependencies -DifferenceObject $Dependencies) } -Times 1 -Scope 'Context' + Assert-MockCalled -CommandName 'Set-ServiceProperty' -ParameterFilter { + $ServiceName -eq $setTargetResourceParameters.Name -and + $StartupType -eq $setTargetResourceParameters.StartupType -and + $BuiltInAccount -eq $setTargetResourceParameters.BuiltInAccount -and + $DesktopInteract -eq $setTargetResourceParameters.DesktopInteract -and + $DisplayName -eq $setTargetResourceParameters.DisplayName -and + $Description -eq $setTargetResourceParameters.Description -and + $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.Dependencies -DifferenceObject $Dependencies) -and + $ResetPeriodSeconds -eq $setTargetResourceParameters.resetPeriodSeconds -and + $RebootMessage -eq $setTargetResourceParameters.rebootMessage -and + $FailureCommand -eq $setTargetResourceParameters.failureCommand -and + $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.failureActionsCollection -DifferenceObject $FailureActionsCollection) -and + $FailureActionsOnNonCrashFailures -eq $setTargetResourceParameters.failureActionsOnNonCrashFailures + } -Times 1 -Scope 'Context' } It 'Should start the service' { @@ -680,6 +862,14 @@ try DisplayName = 'TestDisplayName' Description = 'Test device description' Dependencies = @( 'TestServiceDependency1', 'TestServiceDependency2' ) + ResetPeriodSeconds = 86400 + RebootMessage = 'RebootMessage' + FailureCommand = 'C:\Path\To\Command.exe' + FailureActionsCollection = @(@{ + type = 'RESTART' + delaySeconds = 500 + }) + FailureActionsOnNonCrashFailures = $true } It 'Should not throw' { @@ -707,7 +897,20 @@ try } It 'Should change all service properties except BuiltInAccount' { - Assert-MockCalled -CommandName 'Set-ServiceProperty' -ParameterFilter { $ServiceName -eq $setTargetResourceParameters.Name -and $StartupType -eq $setTargetResourceParameters.StartupType -and $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.Credential -DifferenceObject $Credential) -and $DesktopInteract -eq $setTargetResourceParameters.DesktopInteract -and $DisplayName -eq $setTargetResourceParameters.DisplayName -and $Description -eq $setTargetResourceParameters.Description -and $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.Dependencies -DifferenceObject $Dependencies) } -Times 1 -Scope 'Context' + Assert-MockCalled -CommandName 'Set-ServiceProperty' -ParameterFilter { + $ServiceName -eq $setTargetResourceParameters.Name -and + $StartupType -eq $setTargetResourceParameters.StartupType -and + $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.Credential -DifferenceObject $Credential) -and + $DesktopInteract -eq $setTargetResourceParameters.DesktopInteract -and + $DisplayName -eq $setTargetResourceParameters.DisplayName -and + $Description -eq $setTargetResourceParameters.Description -and + $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.Dependencies -DifferenceObject $Dependencies) + $ResetPeriodSeconds -eq $setTargetResourceParameters.resetPeriodSeconds -and + $RebootMessage -eq $setTargetResourceParameters.rebootMessage -and + $FailureCommand -eq $setTargetResourceParameters.failureCommand -and + $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.failureActionsCollection -DifferenceObject $FailureActionsCollection) -and + $FailureActionsOnNonCrashFailures -eq $setTargetResourceParameters.failureActionsOnNonCrashFailures + } -Times 1 -Scope 'Context' } It 'Should not attempt to start the service' { @@ -758,7 +961,9 @@ try } It 'Should set the service to start with the GroupManagedServiceAccount' { - Assert-MockCalled -CommandName 'Set-ServiceProperty' -ParameterFilter { $ServiceName -eq $setTargetResourceParameters.Name -and $StartupType -eq $setTargetResourceParameters.StartupType -and $GroupManagedServiceAccount -eq $setTargetResourceParameters.GroupManagedServiceAccount } -Times 1 -Scope 'Context' + Assert-MockCalled -CommandName 'Set-ServiceProperty' -ParameterFilter { + $ServiceName -eq $setTargetResourceParameters.Name -and$StartupType -eq $setTargetResourceParameters.StartupType -and$GroupManagedServiceAccount -eq $setTargetResourceParameters.GroupManagedServiceAccount + } -Times 1 -Scope 'Context' } It 'Should not attempt to start the service' { @@ -929,6 +1134,14 @@ try DisplayName = 'TestDisplayName' Description = 'Test device description' Dependencies = @( 'TestServiceDependency1', 'TestServiceDependency2' ) + ResetPeriodSeconds = 86400 + RebootMessage = 'RebootMessage' + FailureCommand = 'C:\Path\To\Command.exe' + FailureActionsCollection = @(@{ + type = 'RESTART' + delaySeconds = 500 + }) + FailureActionsOnNonCrashFailures = $true } It 'Should not throw' { @@ -956,7 +1169,20 @@ try } It 'Should change all service properties except Credential' { - Assert-MockCalled -CommandName 'Set-ServiceProperty' -ParameterFilter { $ServiceName -eq $setTargetResourceParameters.Name -and $StartupType -eq $setTargetResourceParameters.StartupType -and $BuiltInAccount -eq $setTargetResourceParameters.BuiltInAccount -and $DesktopInteract -eq $setTargetResourceParameters.DesktopInteract -and $DisplayName -eq $setTargetResourceParameters.DisplayName -and $Description -eq $setTargetResourceParameters.Description -and $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.Dependencies -DifferenceObject $Dependencies) } -Times 1 -Scope 'Context' + Assert-MockCalled -CommandName 'Set-ServiceProperty' -ParameterFilter { + $ServiceName -eq $setTargetResourceParameters.Name -and + $StartupType -eq $setTargetResourceParameters.StartupType -and + $BuiltInAccount -eq $setTargetResourceParameters.BuiltInAccount -and + $DesktopInteract -eq $setTargetResourceParameters.DesktopInteract -and + $DisplayName -eq $setTargetResourceParameters.DisplayName -and + $Description -eq $setTargetResourceParameters.Description -and + $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.Dependencies -DifferenceObject $Dependencies) + $ResetPeriodSeconds -eq $setTargetResourceParameters.resetPeriodSeconds -and + $RebootMessage -eq $setTargetResourceParameters.rebootMessage -and + $FailureCommand -eq $setTargetResourceParameters.failureCommand -and + $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.failureActionsCollection -DifferenceObject $FailureActionsCollection) -and + $FailureActionsOnNonCrashFailures -eq $setTargetResourceParameters.failureActionsOnNonCrashFailures + } -Times 1 -Scope 'Context' } It 'Should not attempt to start the service' { @@ -979,6 +1205,14 @@ try DisplayName = 'TestDisplayName' Description = 'Test device description' Dependencies = @( 'TestServiceDependency1', 'TestServiceDependency2' ) + ResetPeriodSeconds = 86400 + RebootMessage = 'RebootMessage' + FailureCommand = 'C:\Path\To\Command.exe' + FailureActionsCollection = @(@{ + type = 'RESTART' + delaySeconds = 500 + }) + FailureActionsOnNonCrashFailures = $true } It 'Should not throw' { @@ -1006,7 +1240,19 @@ try } It 'Should change all service properties except BuiltInAccount' { - Assert-MockCalled -CommandName 'Set-ServiceProperty' -ParameterFilter { $ServiceName -eq $setTargetResourceParameters.Name -and $StartupType -eq $setTargetResourceParameters.StartupType -and $BuiltInAccount -eq $setTargetResourceParameters.BuiltInAccount -and $DesktopInteract -eq $setTargetResourceParameters.DesktopInteract -and $DisplayName -eq $setTargetResourceParameters.DisplayName -and $Description -eq $setTargetResourceParameters.Description -and $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.Dependencies -DifferenceObject $Dependencies) } -Times 1 -Scope 'Context' + Assert-MockCalled -CommandName 'Set-ServiceProperty' -ParameterFilter { $ServiceName -eq $setTargetResourceParameters.Name -and + $StartupType -eq $setTargetResourceParameters.StartupType -and + $BuiltInAccount -eq $setTargetResourceParameters.BuiltInAccount -and + $DesktopInteract -eq $setTargetResourceParameters.DesktopInteract -and + $DisplayName -eq $setTargetResourceParameters.DisplayName -and + $Description -eq $setTargetResourceParameters.Description -and + $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.Dependencies -DifferenceObject $Dependencies) + $ResetPeriodSeconds -eq $setTargetResourceParameters.resetPeriodSeconds -and + $RebootMessage -eq $setTargetResourceParameters.rebootMessage -and + $FailureCommand -eq $setTargetResourceParameters.failureCommand -and + $null -eq (Compare-Object -ReferenceObject $setTargetResourceParameters.failureActionsCollection -DifferenceObject $FailureActionsCollection) -and + $FailureActionsOnNonCrashFailures -eq $setTargetResourceParameters.failureActionsOnNonCrashFailures + } -Times 1 -Scope 'Context' } It 'Should not attempt to start the service' { @@ -1153,6 +1399,17 @@ try $expectedErrorMessage = $script:localizedData.CredentialParametersAreMutallyExclusive -f $testTargetResourceParameters.Name { Test-TargetResource @testTargetResourceParameters } | Should -Throw -ExpectedMessage $expectedErrorMessage } + + $testTargetResourceParameters = @{ + Name = $script:testServiceName + FailureCommand = 'C:\Path\To\Command.exe' + FailureActionsCollection = @{Type = 'RESTART'; Delay = 180} + } + + It 'Should throw an error for invalid use of FailureCommand parameter' { + $expectedErrorMessage = $script:localizedData.MustSpecifyRestartFailureAction + { Test-TargetResource @testTargetResourceParameters } | Should -Throw -ExpectedMessage $expectedErrorMessage + } } Context 'When a service does not exist and Ensure set to Absent' { diff --git a/todo.txt b/todo.txt new file mode 100644 index 000000000..0ac8cb3be --- /dev/null +++ b/todo.txt @@ -0,0 +1,10 @@ +1. When the current collection of failure actions has only a single item, and perhaps also if it has zero items, there is a problem somewhere in Test-TargetResource, and the `count` property cannot be found on an object. + Make sure that the collection of actions on both sides is always cast to an array so that the count of actions can be found. + + PowerShell DSC resource DSC_xServiceResource failed to execute Test-TargetResource functionality with error message: The property 'count' cannot be found on this object. + Verify that the property exists. + + CategoryInfo : InvalidOperation: (:) [], CimException + + FullyQualifiedErrorId : ProviderOperationExecutionFailure + + PSComputerName : localhost + +2. Ensure that the unit and acceptance testing suites account for the possibility of the above.