From d2636b3e34a0bff5128c86db4024abfa6615db67 Mon Sep 17 00:00:00 2001 From: Brandon Date: Mon, 23 Jan 2017 14:39:51 -0500 Subject: [PATCH] xSQLServerSetup: Add support for clustered installations (#326) - Added support for clustered installations to xSQLServerSetup - Migrated relevant code from xSQLServerFailoverClusterSetup - Removed Get-WmiObject usage - Clustered storage mapping now supports asymmetric cluster storage - Added support for multi-subnet clusters - Added localized error messages for cluster object mapping - Updated README.md to reflect new parameters - Updated description for xSQLServerFailoverClusterSetup to indicate it is deprecated. --- CHANGELOG.md | 8 + .../MSFT_xSQLServerSetup.psm1 | 457 +++++++-- .../MSFT_xSQLServerSetup.schema.mof | 4 + README.md | 8 +- Tests/Unit/MSFT_xSQLServerSetup.Tests.ps1 | 904 +++++++++++++++--- en-US/xSQLServer.strings.psd1 | 5 + 6 files changed, 1175 insertions(+), 211 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec8ecf61a..b91d0fffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,14 @@ - Changes to xSQLServerDatabasePermission - BREAKING CHANGE: Renamed xSQLServerDatabasePermissions to xSQLServerDatabasePermission to align wíth naming convention. - BREAKING CHANGE: The mandatory parameters now include PermissionState, SQLServer, and SQLInstanceName. +- Added support for clustered installations to xSQLServerSetup + - Migrated relevant code from xSQLServerFailoverClusterSetup + - Removed Get-WmiObject usage + - Clustered storage mapping now supports asymmetric cluster storage + - Added support for multi-subnet clusters + - Added localized error messages for cluster object mapping + - Updated README.md to reflect new parameters +- Updated description for xSQLServerFailoverClusterSetup to indicate it is deprecated. ## 4.0.0.0 diff --git a/DSCResources/MSFT_xSQLServerSetup/MSFT_xSQLServerSetup.psm1 b/DSCResources/MSFT_xSQLServerSetup/MSFT_xSQLServerSetup.psm1 index a64ce672c..eb96f9090 100644 --- a/DSCResources/MSFT_xSQLServerSetup/MSFT_xSQLServerSetup.psm1 +++ b/DSCResources/MSFT_xSQLServerSetup/MSFT_xSQLServerSetup.psm1 @@ -93,6 +93,9 @@ function Get-TargetResource $integrationServiceName = "MsDtsServer$($sqlVersion)0" $features = '' + $clusteredSqlGroupName = '' + $clusteredSqlHostname = '' + $clusteredSqlIPAddress = '' $services = Get-Service if ($services | Where-Object {$_.Name -eq $databaseServiceName}) @@ -149,6 +152,36 @@ function Get-TargetResource $sqlUserDatabaseDirectory = $databaseServer.DefaultFile $sqlUserDatabaseLogDirectory = $databaseServer.DefaultLog $sqlBackupDirectory = $databaseServer.BackupDirectory + + if ($databaseServer.IsClustered) + { + New-VerboseMessage -Message 'Clustered instance detected' + + $clusteredSqlInstance = Get-CimInstance -Namespace root/MSCluster -ClassName MSCluster_Resource -Filter "Type = 'SQL Server'" | + Where-Object { $_.PrivateProperties.InstanceName -eq $InstanceName } + + if (!$clusteredSqlInstance) + { + throw New-TerminatingError -ErrorType FailoverClusterResourceNotFound -FormatArgs $InstanceName -ErrorCategory 'ObjectNotFound' + } + + New-VerboseMessage -Message 'Clustered SQL Server resource located' + + $clusteredSqlGroup = $clusteredSqlInstance | Get-CimAssociatedInstance -ResultClassName MSCluster_ResourceGroup + $clusteredSqlNetworkName = $clusteredSqlGroup | Get-CimAssociatedInstance -ResultClassName MSCluster_Resource | + Where-Object { $_.Type -eq "Network Name" } + + $clusteredSqlIPAddress = ($clusteredSqlNetworkName | Get-CimAssociatedInstance -ResultClassName MSCluster_Resource | + Where-Object { $_.Type -eq "IP Address" }).PrivateProperties.Address + + # Extract the required values + $clusteredSqlGroupName = $clusteredSqlGroup.Name + $clusteredSqlHostname = $clusteredSqlNetworkName.PrivateProperties.DnsName + } + else + { + New-VerboseMessage -Message 'Clustered instance not detected' + } } if ($services | Where-Object {$_.Name -eq $fullTextServiceName}) @@ -310,6 +343,9 @@ function Get-TargetResource ASTempDir = $analysisTempDirectory ASConfigDir = $analysisConfigDirectory ISSvcAccountUsername = $integrationServiceAccountUsername + FailoverClusterGroupName = $clusteredSqlGroupName + FailoverClusterNetworkName = $clusteredSqlHostname + FailoverClusterIPAddress = $clusteredSqlIPAddress } } @@ -317,6 +353,10 @@ function Get-TargetResource .SYNOPSIS Installs the SQL Server features to the node. + .PARAMETER Action + The action to be performed. Default value is 'Install'. + Possible values are 'Install', 'InstallFailoverCluster', 'AddNode', 'PrepareFailoverCluster', and 'CompleteFailoverCluster' + .PARAMETER SourcePath The path to the root of the source files for installation. I.e and UNC path to a shared resource. Environment variables can be used in the path. @@ -442,6 +482,15 @@ function Get-TargetResource .PARAMETER BrowserSvcStartupType Specifies the startup mode for SQL Server Browser service + + .PARAMETER FailoverClusterGroupName + The name of the resource group to create for the clustered SQL Server instance + + .PARAMETER FailoverClusterIPAddress + Array of IP Addresses to be assigned to the clustered SQL Server instance + + .PARAMETER FailoverClusterNetworkName + Host name to be assigned to the clustered SQL Server instance #> function Set-TargetResource { @@ -450,6 +499,10 @@ function Set-TargetResource [CmdletBinding()] param ( + [ValidateSet('Install','InstallFailoverCluster','AddNode','PrepareFailoverCluster','CompleteFailoverCluster')] + [System.String] + $Action = 'Install', + [System.String] $SourcePath, @@ -571,7 +624,16 @@ function Set-TargetResource [System.String] [ValidateSet('Automatic', 'Disabled', 'Manual')] - $BrowserSvcStartupType + $BrowserSvcStartupType, + + [System.String] + $FailoverClusterGroupName = "SQL Server ($InstanceName)", + + [System.String[]] + $FailoverClusterIPAddress, + + [System.String] + $FailoverClusterNetworkName ) $parameters = @{ @@ -713,8 +775,113 @@ function Set-TargetResource } } - # Create install arguments - $arguments = "/Quiet=`"True`" /IAcceptSQLServerLicenseTerms=`"True`" /Action=`"Install`"" + $setupArguments = @{} + + if ($Action -in @('PrepareFailoverCluster','CompleteFailoverCluster','InstallFailoverCluster','AddNode')) + { + # Set the group name for this clustered instance. + $setupArguments += @{ + FailoverClusterGroup = $FailoverClusterGroupName + + # This was brought over from the old module. Should be removed (breaking change). + SkipRules = 'Cluster_VerifyForErrors' + } + } + + # Perform disk mapping for specific cluster installation types + if ($Action -in @('CompleteFailoverCluster','InstallFailoverCluster')) + { + $failoverClusterDisks = @() + + # Get a required lising of drives based on user parameters + $requiredDrives = Get-Variable -Name 'SQL*Dir' -ValueOnly | Where-Object { -not [String]::IsNullOrEmpty($_) } | Split-Path -Qualifier | Sort-Object -Unique + + # Get the disk resources that are available (not assigned to a cluster role) + $availableStorage = Get-CimInstance -Namespace 'root/MSCluster' -ClassName 'MSCluster_ResourceGroup' -Filter "Name = 'Available Storage'" | + Get-CimAssociatedInstance -Association MSCluster_ResourceGroupToResource -ResultClassName MSCluster_Resource + + foreach ($diskResource in $availableStorage) + { + # Determine whether the current node is a possible owner of the disk resource + $possibleOwners = $diskResource | Get-CimAssociatedInstance -Association 'MSCluster_ResourceToPossibleOwner' -KeyOnly | Select-Object -ExpandProperty Name + + if ($possibleOwners -icontains $env:COMPUTERNAME) + { + # Determine whether this disk contains one of our required partitions + if ($requiredDrives -icontains ($diskResource | Get-CimAssociatedInstance -ResultClassName 'MSCluster_DiskPartition' | Select-Object -ExpandProperty Path)) + { + $failoverClusterDisks += $diskResource.Name + } + } + } + + # Ensure we have a unique listing of disks + $failoverClusterDisks = $failoverClusterDisks | Sort-Object -Unique + + # Ensure we mapped all required drives + $requiredDriveCount = $requiredDrives.Count + $mappedDriveCount = $failoverClusterDisks.Count + + if ($mappedDriveCount -ne $requiredDriveCount) + { + throw New-TerminatingError -ErrorType FailoverClusterDiskMappingError -FormatArgs ($failoverClusterDisks -join '; ') -ErrorCategory InvalidResult + } + + # Add the cluster disks as a setup argument + $setupArguments += @{ FailoverClusterDisks = ($failoverClusterDisks | Sort-Object) } + } + + # Determine network mapping for specific cluster installation types + if ($Action -in @('CompleteFailoverCluster','InstallFailoverCluster','AddNode')) + { + $clusterIPAddresses = @() + + # If no IP Address has been specified, use "DEFAULT" + if ($FailoverClusterIPAddress.Count -eq 0) + { + $clusterIPAddresses += "DEFAULT" + } + else + { + # Get the available client networks + $availableNetworks = @(Get-CimInstance -Namespace root/MSCluster -ClassName MSCluster_Network -Filter 'Role >= 2') + + # Add supplied IP Addresses that are valid for available cluster networks + foreach ($address in $FailoverClusterIPAddress) + { + foreach ($network in $availableNetworks) + { + # Determine whether the IP address is valid for this network + if (Test-IPAddress -IPAddress $address -NetworkID $network.Address -SubnetMask $network.AddressMask) + { + # Add the formatted string to our array + $clusterIPAddresses += "IPv4; $address; $($network.Name); $($network.AddressMask)" + } + } + } + } + + # Ensure we mapped all required networks + $suppliedNetworkCount = $FailoverClusterIPAddress.Count + $mappedNetworkCount = $clusterIPAddresses.Count + + # Determine whether we have mapping issues for the IP Address(es) + if ($mappedNetworkCount -lt $suppliedNetworkCount) + { + throw New-TerminatingError -ErrorType FailoverClusterIPAddressNotValid -ErrorCategory InvalidArgument + } + + # Add the networks to the installation arguments + $setupArguments += @{ FailoverClusterIPAddresses = $clusterIPAddresses } + } + + # Add standard install arguments + $setupArguments += @{ + Quiet = $true + IAcceptSQLServerLicenseTerms = $true + Action = $Action + } + $argumentVars = @( 'InstanceName', 'InstanceID', @@ -749,22 +916,33 @@ function Set-TargetResource if ($PSBoundParameters.ContainsKey('SQLSvcAccount')) { - $arguments = $arguments | Join-ServiceAccountInfo -UsernameArgumentName 'SQLSVCACCOUNT' -PasswordArgumentName 'SQLSVCPASSWORD' -User $SQLSvcAccount + $setupArguments += (Get-ServiceAccountParameters -ServiceAccount $SQLSvcAccount -ServiceType 'SQL') } if($PSBoundParameters.ContainsKey('AgtSvcAccount')) { - $arguments = $arguments | Join-ServiceAccountInfo -UsernameArgumentName 'AGTSVCACCOUNT' -PasswordArgumentName 'AGTSVCPASSWORD' -User $AgtSvcAccount + $setupArguments += (Get-ServiceAccountParameters -ServiceAccount $AgtSvcAccount -ServiceType 'AGT') + } + + $setupArguments += @{ SQLSysAdminAccounts = @($SetupCredential.UserName) } + if ($PSBoundParameters -icontains 'SQLSysAdminAccounts') + { + $setupArguments['SQLSysAdminAccounts'] += $SQLSysAdminAccounts + } + + if ($SecurityMode -eq 'SQL') + { + $setupArguments += @{ SAPwd = $SAPwd.GetNetworkCredential().Password } } - $arguments += ' /AGTSVCSTARTUPTYPE=Automatic' + $setupArguments += @{ AgtSvcStartupType = 'Automatic' } } if ($Features.Contains('FULLTEXT')) { if ($PSBoundParameters.ContainsKey('FTSvcAccount')) { - $arguments = $arguments | Join-ServiceAccountInfo -UsernameArgumentName 'FTSVCACCOUNT' -PasswordArgumentName 'FTSVCPASSWORD' -User $FTSvcAccount + $setupArguments += (Get-ServiceAccountParameters -ServiceAccount $FTSvcAccount -ServiceType 'FT') } } @@ -772,7 +950,7 @@ function Set-TargetResource { if ($PSBoundParameters.ContainsKey('RSSvcAccount')) { - $arguments = $arguments | Join-ServiceAccountInfo -UsernameArgumentName 'RSSVCACCOUNT' -PasswordArgumentName 'RSSVCPASSWORD' -User $RSSvcAccount + $setupArguments += (Get-ServiceAccountParameters -ServiceAccount $RSSvcAccount -ServiceType 'RS') } } @@ -789,7 +967,14 @@ function Set-TargetResource if ($PSBoundParameters.ContainsKey('ASSvcAccount')) { - $arguments = $arguments | Join-ServiceAccountInfo -UsernameArgumentName 'ASSVCACCOUNT' -PasswordArgumentName 'ASSVCPASSWORD' -User $ASSvcAccount + $setupArguments += (Get-ServiceAccountParameters -ServiceAccount $ASSvcAccount -ServiceType 'AS') + } + + $setupArguments += @{ ASSysAdminAccounts = @($SetupCredential.UserName) } + + if($PSBoundParameters.ContainsKey("ASSysAdminAccounts")) + { + $setupArguments['ASSysAdminAccounts'] += $ASSysAdminAccounts } } @@ -797,45 +982,49 @@ function Set-TargetResource { if ($PSBoundParameters.ContainsKey('ISSvcAccount')) { - $arguments = $arguments | Join-ServiceAccountInfo -UsernameArgumentName 'ISSVCACCOUNT' -PasswordArgumentName 'ISSVCPASSWORD' -User $ISSvcAccount + $setupArguments += (Get-ServiceAccountParameters -ServiceAccount $ISSvcAccount -ServiceType 'IS') } } - foreach ($argumentVar in $argumentVars) + # Automatically include any additional arguments + foreach ($argument in $argumentVars) { - if ((Get-Variable -Name $argumentVar).Value -ne '') - { - $arguments += " /$argumentVar=`"" + (Get-Variable -Name $argumentVar).Value + "`"" - } + $setupArguments += @{ $argument = (Get-Variable -Name $argument -ValueOnly) } } - if ($Features.Contains('SQLENGINE')) + # Build the argument string to be passed to setup + $arguments = '' + foreach ($currentSetupArgument in $setupArguments.GetEnumerator()) { - $arguments += " /SQLSysAdminAccounts=`"" + $SetupCredential.UserName + "`"" - if ($PSBoundParameters.ContainsKey('SQLSysAdminAccounts')) + if ($currentSetupArgument.Value -ne '') { - foreach ($adminAccount in $SQLSysAdminAccounts) + # Arrays are handled specially + if ($currentSetupArgument.Value -is [array]) { - $arguments += " `"$adminAccount`"" + # Sort and format the array + $setupArgumentValue = ($currentSetupArgument.Value | Sort-Object | ForEach-Object { '"{0}"' -f $_ }) -join ' ' } - } - - if ($SecurityMode -eq 'SQL') - { - $arguments += " /SAPwd=" + $SAPwd.GetNetworkCredential().Password - } - } - - if ($Features.Contains('AS')) - { - $arguments += " /ASSysAdminAccounts=`"" + $SetupCredential.UserName + "`"" - if($PSBoundParameters.ContainsKey("ASSysAdminAccounts")) - { - foreach($adminAccount in $ASSysAdminAccounts) + elseif ($currentSetupArgument.Value -is [Boolean]) + { + $setupArgumentValue = @{ $true = 'True'; $false = 'False' }[$currentSetupArgument.Value] + $setupArgumentValue = '"{0}"' -f $setupArgumentValue + } + else { - $arguments += " `"$adminAccount`"" + # Features are comma-separated, no quotes + if ($currentSetupArgument.Key -eq 'Features') + { + $setupArgumentValue = $currentSetupArgument.Value + } + else + { + $setupArgumentValue = '"{0}"' -f $currentSetupArgument.Value + } } + + $arguments += "/$($currentSetupArgument.Key.ToUpper())=$($setupArgumentValue) " } + } # Replace sensitive values for verbose output @@ -861,7 +1050,9 @@ function Set-TargetResource New-VerboseMessage -Message "Starting setup using arguments: $log" + $arguments = $arguments.Trim() $process = StartWin32Process -Path $pathToSetupExecutable -Arguments $arguments + New-VerboseMessage -Message $process WaitForWin32ProcessEnd -Path $pathToSetupExecutable -Arguments $arguments @@ -887,6 +1078,10 @@ function Set-TargetResource .SYNOPSIS Tests if the SQL Server features are installed on the node. + .PARAMETER Action + The action to be performed. Default value is 'Install'. + Possible values are 'Install', 'InstallFailoverCluster', 'AddNode', 'PrepareFailoverCluster', and 'CompleteFailoverCluster' + .PARAMETER SourcePath The path to the root of the source files for installation. I.e and UNC path to a shared resource. Environment variables can be used in the path. @@ -1012,6 +1207,15 @@ function Set-TargetResource .PARAMETER BrowserSvcStartupType Specifies the startup mode for SQL Server Browser service + + .PARAMETER FailoverClusterGroupName + The name of the resource group to create for the clustered SQL Server instance + + .PARAMETER FailoverClusterIPAddress + Array of IP Addresses to be assigned to the clustered SQL Server instance + + .PARAMETER FailoverClusterNetworkName + Host name to be assigned to the clustered SQL Server instance #> function Test-TargetResource { @@ -1019,6 +1223,10 @@ function Test-TargetResource [OutputType([System.Boolean])] param ( + [ValidateSet('Install','InstallFailoverCluster','AddNode','PrepareFailoverCluster','CompleteFailoverCluster')] + [System.String] + $Action = 'Install', + [System.String] $SourcePath, @@ -1140,7 +1348,19 @@ function Test-TargetResource [System.String] [ValidateSet('Automatic', 'Disabled', 'Manual')] - $BrowserSvcStartupType + $BrowserSvcStartupType, + + [Parameter(ParameterSetName = 'ClusterInstall')] + [System.String] + $FailoverClusterGroupName = "SQL Server ($InstanceName)", + + [Parameter(ParameterSetName = 'ClusterInstall')] + [System.String[]] + $FailoverClusterIPAddress, + + [Parameter(ParameterSetName = 'ClusterInstall')] + [System.String] + $FailoverClusterNetworkName ) $parameters = @{ @@ -1151,7 +1371,7 @@ function Test-TargetResource } $getTargetResourceResult = Get-TargetResource @parameters - New-VerboseMessage -Message "Features found: '$($SQLData.Features)'" + New-VerboseMessage -Message "Features found: '$($getTargetResourceResult.Features)'" $result = $false if ($getTargetResourceResult.Features ) @@ -1170,6 +1390,22 @@ function Test-TargetResource } } } + + if ($PSCmdlet.ParameterSetName -eq 'ClusterInstall') + { + New-VerboseMessage -Message "Clustered install, checking parameters." + + $result = $true + + Get-Variable -Name FailoverCluster* | ForEach-Object { + $variableName = $_.Name + + if ($getTargetResourceResult.$variableName -ne $_.Value) { + New-VerboseMessage -Message "$variableName '$($_.Value)' is not in the desired state for this cluster." + $result = $false + } + } + } $result } @@ -1330,61 +1566,132 @@ function Get-TemporaryFolder <# .SYNOPSIS - Returns the argument string appeneded with the account information as is given in UserAlias and User parameters + Returns the decimal representation of an IP Addresses + + .PARAMETER IPAddress + The IP Address to be converted #> -function Join-ServiceAccountInfo +function ConvertTo-Decimal { - <# - Suppressing this rule because there are parameters that contain the text 'UserName' and 'Password' - but they are not actually used to pass any credentials. Instead the parameters are used to provide the - argument that should be evaluated for setup.exe. - #> - [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingUsernameAndPasswordParams', '')] - [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '')] + [CmdletBinding()] + [OutputType([System.UInt32])] param( - [Parameter(Mandatory, ValueFromPipeline=$true)] - [string] - $ArgumentString, + [Parameter(Mandatory = $true)] + [System.Net.IPAddress] + $IPAddress + ) + + $i = 3 + $DecimalIP = 0 + $IPAddress.GetAddressBytes() | ForEach-Object { + $DecimalIP += $_ * [Math]::Pow(256,$i) + $i-- + } + + return [UInt32]$DecimalIP +} - [Parameter(Mandatory)] - [PSCredential] - $User, +<# + .SYNOPSIS + Determines whether an IP Address is valid for a given network / subnet + + .PARAMETER IPAddress + IP Address to be checked + + .PARAMETER NetworkID + IP Address of the network identifier + + .PARAMETER SubnetMask + Subnet mask of the network to be checked +#> +function Test-IPAddress +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.Net.IPAddress] + $IPAddress, - [Parameter(Mandatory)] - [string] - $UsernameArgumentName, + [Parameter(Mandatory = $true)] + [System.Net.IPAddress] + $NetworkID, - [Parameter(Mandatory)] - [string] - $PasswordArgumentName + [Parameter(Mandatory = $true)] + [System.Net.IPAddress] + $SubnetMask ) + + # Convert all values to decimal + $IPAddressDecimal = ConvertTo-Decimal -IPAddress $IPAddress + $NetworkDecimal = ConvertTo-Decimal -IPAddress $NetworkID + $SubnetDecimal = ConvertTo-Decimal -IPAddress $SubnetMask + + # Determine whether the IP Address is valid for this network / subnet + return (($IPAddressDecimal -band $SubnetDecimal) -eq ($NetworkDecimal -band $SubnetDecimal)) +} - process { +<# + .SYNOPSIS + Builds service account parameters for setup + + .PARAMETER ServiceAccount + Credential for the service account - <# - Regex to determine if given username is an NT Authority account or not. - Accepted inputs are optional ntauthority with or without space between nt and authority - then a predefined list of users system, networkservice and localservice - #> - if($User.UserName.ToUpper() -match '^(NT ?AUTHORITY\\)?(SYSTEM|LOCALSERVICE|NETWORKSERVICE)$') + .PARAMETER ServiceType + Type of service account +#> +function Get-ServiceAccountParameters +{ + [CmdletBinding()] + [OutputType([Hashtable])] + param + ( + [Parameter(Mandatory = $true)] + [PSCredential] + $ServiceAccount, + + [Parameter(Mandatory = $true)] + [ValidateSet('SQL','AGT','IS','RS','AS','FT')] + [String] + $ServiceType + ) + + $parameters = @{} + + switch -Regex ($ServiceAccount.UserName.ToUpper()) + { + '^(?:NT ?AUTHORITY\\)?(SYSTEM|LOCALSERVICE|LOCAL SERVICE|NETWORKSERVICE|NETWORK SERVICE)$' { - # Dealing with NT Authority user - $ArgumentString += (' /{0}="NT AUTHORITY\{1}"' -f $UsernameArgumentName, $matches[2]) + $parameters = @{ + "$($ServiceType)SVCACCOUNT" = "NT AUTHORITY\$($Matches[1])" + } } - elseif ($User.UserName -like '*$') + + '^(?:NT SERVICE\\)(.*)$' { - # Dealing with Managed Service Account - $ArgumentString += (' /{0}="{1}"' -f $UsernameArgumentName, $User.UserName) + $parameters = @{ + "$($ServiceType)SVCACCOUNT" = "NT SERVICE\$($Matches[1])" + } } - else + + '.*\$' { - # Dealing with local or domain user - $ArgumentString += (' /{0}="{1}"' -f $UsernameArgumentName, $User.UserName) - $ArgumentString += (' /{0}="{1}"' -f $PasswordArgumentName, $User.GetNetworkCredential().Password) + $parameters = @{ + "$($ServiceType)SVCACCOUNT" = $ServiceAccount.UserName + } } - return $ArgumentString + default + { + $parameters = @{ + "$($ServiceType)SVCACCOUNT" = $ServiceAccount.UserName + "$($ServiceType)SVCPASSWORD" = $ServiceAccount.GetNetworkCredential().Password + } + } } + + return $parameters } Export-ModuleMember -Function *-TargetResource diff --git a/DSCResources/MSFT_xSQLServerSetup/MSFT_xSQLServerSetup.schema.mof b/DSCResources/MSFT_xSQLServerSetup/MSFT_xSQLServerSetup.schema.mof index f4b028d81..5c178bfe9 100644 --- a/DSCResources/MSFT_xSQLServerSetup/MSFT_xSQLServerSetup.schema.mof +++ b/DSCResources/MSFT_xSQLServerSetup/MSFT_xSQLServerSetup.schema.mof @@ -1,6 +1,7 @@ [ClassVersion("1.0.0.0"), FriendlyName("xSQLServerSetup")] class MSFT_xSQLServerSetup : OMI_BaseResource { + [Write, Description("The action to be performed. Default value is 'Install'."), ValueMap{"Install","InstallFailoverCluster","AddNode","PrepareFailoverCluster","CompleteFailoverCluster"}, Values{"Install","InstallFailoverCluster","AddNode","PrepareFailoverCluster","CompleteFailoverCluster"}] String Action; [Write, Description("The path to the root of the source files for installation. I.e and UNC path to a shared resource. Environment variables can be used in the path.")] String SourcePath; [Required, EmbeddedInstance("MSFT_Credential"), Description("Credential to be used to perform the installation.")] String SetupCredential; [Write, EmbeddedInstance("MSFT_Credential"), Description("Credentials used to access the path set in the parameter 'SourcePath'.")] String SourceCredential; @@ -47,4 +48,7 @@ class MSFT_xSQLServerSetup : OMI_BaseResource [Write, EmbeddedInstance("MSFT_Credential"), Description("Service account for Integration Services service.")] String ISSvcAccount; [Read, Description("Output username for the Integration Services service.")] String ISSvcAccountUsername; [Write, Description("Specifies the startup mode for SQL Server Browser service."), ValueMap{"Automatic", "Disabled", "Manual"}, Values{"Automatic", "Disabled", "Manual"}] String BrowserSvcStartupType; + [Write, Description("The name of the resource group to create for the clustered SQL Server instance.")] String FailoverClusterGroupName; + [Write, Description("Array of IP Addresses to be assigned to the clustered SQL Server instance.")] String FailoverClusterIPAddress[]; + [Write, Description("Host name to be assigend to the clustered SQL Server instance.")] String FailoverClusterNetworkName; }; diff --git a/README.md b/README.md index b6420dcad..3aba6f0bd 100644 --- a/README.md +++ b/README.md @@ -418,7 +418,7 @@ None. ### xSQLServerFailoverClusterSetup -No description. +**This resource is deprecated.** The functionality of this resource has been merged with [xSQLServerSetup](#xsqlserversetup). Please do not use this resource for new development efforts. #### Requirements @@ -758,6 +758,7 @@ Installs SQL Server on the target node. #### Parameters +* **[String] Action** _(Write)_: The action to be performed. Defaults to 'Install'. { _Install_ | InstallFailoverCluster | AddNode | PrepareFailoverCluster | CompleteFailoverCluster } * **[String] InstanceName** _(Key)_: SQL instance to be installed. * **[PSCredential] SetupCredential** _(Required)_: Credential to be used to perform the installation. * **[String] SourcePath** _(Write)_: The path to the root of the source files for installation. I.e and UNC path to a shared resource. Environment variables can be used in the path. @@ -796,8 +797,11 @@ Installs SQL Server on the target node. * **[String] ASBackupDir** _(Write)_: Path for Analysis Services backup files. * **[String] ASTempDir** _(Write)_: Path for Analysis Services temp files. * **[String] ASConfigDir** _(Write)_: Path for Analysis Services config. -* **ISSvcAccount** _(Write)_: Service account for Integration Services service. +* **[PSCredential] ISSvcAccount** _(Write)_: Service account for Integration Services service. * **[String] BrowserSvcStartupType** _(Write)_: Specifies the startup mode for SQL Server Browser service. { Automatic | Disabled | 'Manual' } +* **[String] FailoverClusterGroupName** _(Write)_: The name of the resource group to create for the clustered SQL Server instance. Defaults to 'SQL Server (_InstanceName_)'. +* **[String[]]FailoverClusterIPAddress** _(Write)_: Array of IP Addresses to be assigned to the clustered SQL Server instance. IP addresses must be in [dotted-decimal notation](https://en.wikipedia.org/wiki/Dot-decimal_notation), for example ````10.0.0.100````. If no IP address is specified, uses 'DEFAULT' for this setup parameter. +* **[String] FailoverClusterNetworkName** _(Write)_: Host name to be assigned to the clustered SQL Server instance. #### Read-Only Properties from Get-TargetResource diff --git a/Tests/Unit/MSFT_xSQLServerSetup.Tests.ps1 b/Tests/Unit/MSFT_xSQLServerSetup.Tests.ps1 index 49646a28b..f7891b512 100644 --- a/Tests/Unit/MSFT_xSQLServerSetup.Tests.ps1 +++ b/Tests/Unit/MSFT_xSQLServerSetup.Tests.ps1 @@ -76,6 +76,10 @@ try $mockDefaultInstance_IntegrationServiceName = $mockSqlIntegrationName $mockDefaultInstance_AnalysisServiceName = 'MSSQLServerOLAPService' + $mockDefaultInstance_FailoverClusterNetworkName = 'TestDefaultCluster' + $mockDefaultInstance_FailoverClusterIPAddress = '10.0.0.10' + $mockDefaultInstance_FailoverClusterGroupName = "SQL Server ($mockDefaultInstance_InstanceName)" + $mockNamedInstance_InstanceName = 'TEST' $mockNamedInstance_DatabaseServiceName = "$($mockSqlDatabaseEngineName)`$$($mockNamedInstance_InstanceName)" $mockNamedInstance_AgentServiceName = "$($mockSqlAgentName)`$$($mockNamedInstance_InstanceName)" @@ -84,13 +88,41 @@ try $mockNamedInstance_IntegrationServiceName = $mockSqlIntegrationName $mockNamedInstance_AnalysisServiceName = "$($mockSqlAnalysisName)`$$($mockNamedInstance_InstanceName)" - $mockmockSetupCredentialUserName = "COMPANY\sqladmin" + $mockNamedInstance_FailoverClusterNetworkName = 'TestDefaultCluster' + $mockNamedInstance_FailoverClusterIPAddress = '10.0.0.20' + $mockNamedInstance_FailoverClusterGroupName = "SQL Server ($mockNamedInstance_InstanceName)" + + $mockmockSetupCredentialUserName = "COMPANY\sqladmin" + $mockmockSetupCredentialPassword = "dummyPassw0rd" | ConvertTo-SecureString -asPlainText -Force $mockSetupCredential = New-Object System.Management.Automation.PSCredential( $mockmockSetupCredentialUserName, $mockmockSetupCredentialPassword ) $mockSqlServiceAccount = 'COMPANY\SqlAccount' $mockAgentServiceAccount = 'COMPANY\AgentAccount' + $mockClusterNodes = @($env:COMPUTERNAME,'SQL01','SQL02') + + $mockClusterDiskMap = @{ + UserData = 'K:' + UserLogs = 'L:' + TempDbData = 'M:' + TempDbLogs = 'N:' + SQLBackup = 'O:' + } + + $mockClusterSites = @( + @{ + Name = 'SiteA' + Address = '10.0.0.100' + Mask = '255.255.255.0' + }, + @{ + Name = 'SiteB' + Address = '10.0.10.100' + Mask = '255.255.255.0' + } + ) + #region Function mocks $mockGetSqlMajorVersion = { return $mockSqlMajorVersion @@ -431,6 +463,31 @@ try ) } + $mockConnectSQLCluster = { + return @( + ( + New-Object Object | + Add-Member -MemberType NoteProperty -Name 'LoginMode' -Value $mockSqlLoginMode -PassThru | + Add-Member -MemberType NoteProperty -Name 'Collation' -Value $mockSqlCollation -PassThru | + Add-Member -MemberType NoteProperty -Name 'InstallDataDirectory' -Value $mockSqlInstallPath -PassThru | + Add-Member -MemberType NoteProperty -Name 'BackupDirectory' -Value $mockSqlBackupPath -PassThru | + Add-Member -MemberType NoteProperty -Name 'SQLTempDBDir' -Value $mockSqlTempDatabasePath -PassThru | + Add-Member -MemberType NoteProperty -Name 'SQLTempDBLogDir' -Value $mockSqlTempDatabaseLogPath -PassThru | + Add-Member -MemberType NoteProperty -Name 'DefaultFile' -Value $mockSqlDefaultDatabaseFilePath -PassThru | + Add-Member -MemberType NoteProperty -Name 'DefaultLog' -Value $mockSqlDefaultDatabaseLogPath -PassThru | + Add-Member -MemberType NoteProperty -Name 'IsClustered' -Value $true -PassThru | + Add-Member ScriptProperty Logins { + return @( ( New-Object Object | + Add-Member -MemberType NoteProperty -Name 'Name' -Value $mockSqlSystemAdministrator -PassThru | + Add-Member ScriptMethod ListMembers { + return @('sysadmin') + } -PassThru -Force + ) ) + } -PassThru -Force + ) + ) + } + $mockConnectSQLAnalysis = { return @( ( @@ -513,20 +570,169 @@ try return $mockSourcePathUNC } + $mockGetCimInstance_MSClusterResource = { + return @( + ( + New-Object Microsoft.Management.Infrastructure.CimInstance 'MSCluster_Resource','root/MSCluster' | + Add-Member -MemberType NoteProperty -Name 'Name' -Value "SQL Server ($mockCurrentInstanceName)" -PassThru -Force | + Add-Member -MemberType NoteProperty -Name 'Type' -Value 'SQL Server' -TypeName 'String' -PassThru -Force | + Add-Member -MemberType NoteProperty -Name 'PrivateProperties' -Value @{ InstanceName = $mockCurrentInstanceName } -PassThru -Force + ) + ) + } + + $mockGetCimInstance_MSClusterResourceGroup_AvailableStorage = { + return @( + ( + New-Object Microsoft.Management.Infrastructure.CimInstance 'MSCluster_ResourceGroup', 'root/MSCluster' | + Add-Member -MemberType NoteProperty -Name 'Name' -Value 'Available Storage' -PassThru -Force + ) + ) + } + + $mockGetCimInstance_MSClusterNetwork = { + return @( + ( + $mockClusterSites | ForEach-Object { + $network = $_ + + New-Object Microsoft.Management.Infrastructure.CimInstance 'MSCluster_Network', 'root/MSCluster' | + Add-Member -MemberType NoteProperty -Name 'Name' -Value "$($network.Name)_Prod" -PassThru -Force | + Add-Member -MemberType NoteProperty -Name 'Role' -Value 2 -PassThru -Force | + Add-Member -MemberType NoteProperty -Name 'Address' -Value $network.Address -PassThru -Force | + Add-Member -MemberType NoteProperty -Name 'AddressMask' -Value $network.Mask -PassThru -Force + } + ) + ) + } + + $mockGetCimAssociatedInstance_MSClusterResourceGroup_DefaultInstance = { + return @( + ( + New-Object Microsoft.Management.Infrastructure.CimInstance 'MSCluster_ResourceGroup', 'root/MSCluster' | + Add-Member -MemberType NoteProperty -Name 'Name' -Value $mockDefaultInstance_FailoverClusterGroupName -PassThru -Force + ) + ) + } + + $mockGetCimAssociatedInstance_MSClusterResource_DefaultInstance = { + return @( + ( + @('Network Name','IP Address') | ForEach-Object { + $resourceType = $_ + + $propertyValue = @{ + MemberType = 'NoteProperty' + Name = 'PrivateProperties' + Value = $null + } + + switch ($resourceType) + { + 'Network Name' + { + $propertyValue.Value = @{ DnsName = $mockDefaultInstance_FailoverClusterNetworkName } + } + + 'IP Address' + { + $propertyValue.Value = @{ Address = $mockDefaultInstance_FailoverClusterIPAddress } + } + } + + return New-Object Microsoft.Management.Infrastructure.CimInstance 'MSCluster_Resource', 'root/MSCluster' | + Add-Member -MemberType NoteProperty -Name 'Type' -Value $resourceType -PassThru -Force | + Add-Member @propertyValue -PassThru -Force + } + ) + ) + } + + # Mock to return physical disks that are part of the "Available Storage" cluster role + $mockGetCimAssociatedInstance_MSCluster_ResourceGroupToResource = { + return @( + ( + $mockClusterDiskMap.Keys | ForEach-Object { + $diskName = $_ + New-Object Microsoft.Management.Infrastructure.CimInstance 'MSCluster_Resource','root/MSCluster' | + Add-Member -MemberType NoteProperty -Name 'Name' -Value $diskName -PassThru -Force | + Add-Member -MemberType NoteProperty -Name 'State' -Value 2 -PassThru -Force | + Add-Member -MemberType NoteProperty -Name 'Type' -Value 'Physical Disk' -PassThru -Force + } + ) + ) + } + + $mockGetCimAssociatedInstance_MSCluster_ResourceToPossibleOwner = { + return @( + ( + $mockClusterNodes | ForEach-Object { + $node = $_ + New-Object Microsoft.Management.Infrastructure.CimInstance 'MSCluster_Node', 'root/MSCluster' | + Add-Member -MemberType NoteProperty -Name 'Name' -Value $node -PassThru -Force + } + ) + ) + } + + $mockGetCimAssociatedInstance_MSCluster_DiskPartition = { + $clusterDiskName = $InputObject.Name + $clusterDiskPath = $mockClusterDiskMap.$clusterDiskName + + return @( + ( + New-Object Microsoft.Management.Infrastructure.CimInstance 'MSCluster_DiskPartition','root/MSCluster' | + Add-Member -MemberType NoteProperty -Name 'Path' -Value $clusterDiskPath -PassThru -Force + ) + ) + } + <# Needed a way to see into the Set-method for the arguments the Set-method is building and sending to 'setup.exe', and fail the test if the arguments is different from the expected arguments. Solved this by dynamically set the expected arguments before each It-block. If the arguments differs the mock of StartWin32Process throws an error message, similiar to what Pester would have reported (expected -> but was). #> - $mockStartWin32ProcessExpectedArgument = '' # Set dynamically during runtime + $mockStartWin32ProcessExpectedArgument = @{} + + $mockStartWin32ProcessExpectedArgumentClusterDefault = @{ + IAcceptSQLServerLicenseTerms = 'True' + Quiet = 'True' + InstanceName = 'MSSQLSERVER' + AGTSVCSTARTUPTYPE = 'Automatic' + Features = 'SQLENGINE' + SQLSysAdminAccounts = 'COMPANY\sqladmin' + FailoverClusterGroup = 'SQL Server (MSSQLSERVER)' + } + $mockStartWin32Process = { - if ( $Arguments -ne $mockStartWin32ProcessExpectedArgument ) + $argumentHashTable = @{} + + # Break the argument string into a hash table + ($Arguments -split ' ?/') | ForEach-Object { + if ($_ -imatch '(\w+)="?([^/]+)"?') + { + $key = $Matches[1] + $value = ($Matches[2] -replace '" "','; ') -replace '"','' + $null = $argumentHashTable.Add($key, $value) + } + } + + # Start by checking whether we have the same number of parameters + New-VerboseMessage 'Verifying argument count (expected vs actual)' + $mockStartWin32ProcessExpectedArgument.Keys.Count | Should BeExactly $argumentHashTable.Keys.Count + + foreach ($argumentKey in $mockStartWin32ProcessExpectedArgument.Keys) { - throw "Expected arguments was not the same as the arguments in the function call.`nExpected: '$mockStartWin32ProcessExpectedArgument' `n But was: '$Arguments'" + New-VerboseMessage "Testing Parameter [$argumentKey]" + $argumentPassed = $argumentHashTable.ContainsKey($argumentKey) + $argumentPassed | Should Be $true + + $argumentValue = $argumentHashTable.$argumentKey + $argumentValue | Should Be $mockStartWin32ProcessExpectedArgument.$argumentKey } - return 'Process started' + return 'Process Started' } #endregion Function mocks @@ -537,6 +743,19 @@ try Features = 'SQLEngine,Replication,FullText,Rs,Is,As' } + $mockDefaultClusterParameters = @{ + SetupCredential = $mockSetupCredential + + # Feature support is tested elsewhere, so just include the minimum + Features = 'SQLEngine' + + # Ensure we use "clustered" disks for our paths + SQLUserDBDir = 'K:\MSSQL\Data\' + SQLUserDBLogDir = 'L:\MSSQL\Logs' + SQLTempDbDir = 'M:\MSSQL\TempDb\Data\' + SQLTempDbLogDir = 'N:\MSSQL\TempDb\Logs' + } + Describe "xSQLServerSetup\Get-TargetResource" -Tag 'Get' { #region Setting up TestDrive:\ @@ -1496,6 +1715,90 @@ try $result.ISSvcAccountUsername | Should Be $mockSqlServiceAccount } } + + Context "When SQL Server version is $mockSqlMajorVersion and the system is not in the desired state for a clustered default instance" { + + BeforeAll { + $testParams = $mockDefaultParameters.Clone() + $testParams.Remove('Features') + $testParams += @{ + InstanceName = $mockDefaultInstance_InstanceName + SourceCredential = $null + SourcePath = $mockSourcePath + } + + Mock -CommandName Connect-SQL -MockWith $mockConnectSQL -Verifiable + + Mock -CommandName Get-CimInstance -MockWith {} -Verifiable + + Mock -CommandName Get-CimAssociatedInstance -MockWith {} -Verifiable + + Mock -CommandName Get-ItemProperty -MockWith $mockGetItemProperty_Setup -Verifiable + + Mock -CommandName Get-Service -MockWith $mockEmptyHashtable -Verifiable + } + + It 'Should not attempt to collect cluster information for a standalone instance' { + + $currentState = Get-TargetResource @testParams + + Assert-MockCalled -CommandName Connect-SQL -Exactly -Times 0 -Scope It + Assert-MockCalled -CommandName Get-CimInstance -Exactly -Times 0 -Scope It + Assert-MockCalled -CommandName Get-CimAssociatedInstance -Exactly -Times 0 -Scope It + + $currentState.FailoverClusterGroupName | Should BeNullOrEmpty + $currentState.FailoverClusterNetworkName | Should BeNullOrEmpty + $currentState.FailoverClusterIPAddress | Should BeNullOrEmpty + } + } + + Context "When SQL Server version is $mockSqlMajorVersion and the system is in the desired state for a clustered default instance" { + + BeforeEach { + $testParams = $mockDefaultParameters.Clone() + $testParams.Remove('Features') + $testParams += @{ + InstanceName = $mockDefaultInstance_InstanceName + SourceCredential = $null + SourcePath = $mockSourcePath + } + + $mockCurrentInstanceName = $mockDefaultInstance_InstanceName + + Mock -CommandName Connect-SQL -MockWith $mockConnectSQLCluster -Verifiable + + Mock -CommandName Get-CimInstance -MockWith $mockGetCimInstance_MSClusterResource -Verifiable -ParameterFilter { + $Filter -eq "Type = 'SQL Server'" + } + + Mock -CommandName Get-CimAssociatedInstance -MockWith $mockGetCimAssociatedInstance_MSClusterResourceGroup_DefaultInstance -Verifiable -ParameterFilter { $ResultClassName -eq 'MSCluster_ResourceGroup' } + + Mock -CommandName Get-CimAssociatedInstance -MockWith $mockGetCimAssociatedInstance_MSClusterResource_DefaultInstance -Verifiable -ParameterFilter { $ResultClassName -eq 'MSCluster_Resource' } + + Mock -CommandName Get-ItemProperty -MockWith $mockGetItemProperty_Setup -Verifiable + + Mock -CommandName Get-Service -MockWith $mockGetService_DefaultInstance -Verifiable + } + + It 'Should collect information for a clustered instance' { + $currentState = Get-TargetResource @testParams + + Assert-MockCalled -CommandName Connect-SQL -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Get-CimInstance -Exactly -Times 1 -Scope It -ParameterFilter { $Filter -eq "Type = 'SQL Server'" } + Assert-MockCalled -CommandName Get-CimAssociatedInstance -Exactly -Times 1 -Scope It -ParameterFilter { $ResultClassName -eq 'MSCluster_ResourceGroup' } + Assert-MockCalled -CommandName Get-CimAssociatedInstance -Exactly -Times 2 -Scope It -ParameterFilter { $ResultClassName -eq 'MSCluster_Resource' } + + $currentState.InstanceName | Should Be $testParams.InstanceName + } + + It 'Should return correct cluster information' { + $currentState = Get-TargetResource @testParams + + $currentState.FailoverClusterGroupName | Should Be $mockDefaultInstance_FailoverClusterGroupName + $currentState.FailoverClusterIPAddress | Should Be $mockDefaultInstance_FailoverClusterIPAddress + $currentSTate.FailoverClusterNetworkName | Should Be $mockDefaultInstance_FailoverClusterNetworkName + } + } } Assert-VerifiableMocks @@ -1566,7 +1869,7 @@ try $mockSqlDefaultDatabaseFilePath = "C:\Program Files\Microsoft SQL Server\$($mockDefaultInstance_InstanceId)\MSSQL\DATA\" $mockSqlDefaultDatabaseLogPath = "C:\Program Files\Microsoft SQL Server\$($mockDefaultInstance_InstanceId)\MSSQL\DATA\" - Context "When the system is not in the desired state" { + Context 'When the system is not in the desired state' { BeforeEach { $testParameters = $mockDefaultParameters $testParameters += @{ @@ -1822,6 +2125,20 @@ try } -Exactly -Times 1 -Scope It #endregion Assert Get-CimInstance } + + It 'Should return that the desired state is absent when a clustered instance cannot be found' { + $testClusterParameters = $testParameters.Clone() + + $testClusterParameters += @{ + FailoverClusterGroupName = $mockDefaultInstance_FailoverClusterGroupName + FailoverClusterIPAddress = $mockDefaultInstance_FailoverClusterIPAddress + FailoverClusterNetworkName = $mockDefaultInstance_FailoverClusterNetworkName + } + + $result = Test-TargetResource @testClusterParameters + + $result | Should Be $false + } } Context "When the system is in the desired state" { @@ -1952,6 +2269,36 @@ try } -Exactly -Times 1 -Scope It #endregion Assert Get-CimInstance } + + It 'Should return that the desired state is present when the correct clustered instance was found' { + $mockCurrentInstanceName = $mockDefaultInstance_InstanceName + + Mock -CommandName Connect-SQL -MockWith $mockConnectSQLCluster -Verifiable + + Mock -CommandName Get-CimInstance -MockWith $mockGetCimInstance_MSClusterResource -Verifiable -ParameterFilter { + $Filter -eq "Type = 'SQL Server'" + } + + Mock -CommandName Get-CimAssociatedInstance -MockWith $mockGetCimAssociatedInstance_MSClusterResourceGroup_DefaultInstance -Verifiable -ParameterFilter { $ResultClassName -eq 'MSCluster_ResourceGroup' } + + Mock -CommandName Get-CimAssociatedInstance -MockWith $mockGetCimAssociatedInstance_MSClusterResource_DefaultInstance -Verifiable -ParameterFilter { $ResultClassName -eq 'MSCluster_Resource' } + + $testClusterParameters = $testParameters.Clone() + + $testClusterParameters += @{ + FailoverClusterGroupName = $mockDefaultInstance_FailoverClusterGroupName + FailoverClusterIPAddress = $mockDefaultInstance_FailoverClusterIPAddress + FailoverClusterNetworkName = $mockDefaultInstance_FailoverClusterNetworkName + } + + $result = Test-TargetResource @testClusterParameters + + $result | Should Be $true + + Assert-MockCalled -CommandName Connect-SQL -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Get-CimInstance -Exactly -Times 1 -Scope It -ParameterFilter { $Filter -eq "Type = 'SQL Server'" } + Assert-MockCalled -CommandName Get-CimAssociatedInstance -Exactly -Times 3 -Scope It + } } Assert-VerifiableMocks @@ -2090,15 +2437,16 @@ try } It 'Should set the system in the desired state when feature is SQLENGINE' { - $mockStartWin32ProcessExpectedArgument = - '/Quiet="True"', - '/IAcceptSQLServerLicenseTerms="True"', - '/Action="Install"', - '/AGTSVCSTARTUPTYPE=Automatic', - '/InstanceName="MSSQLSERVER"', - '/Features="SQLENGINE,REPLICATION,FULLTEXT,RS,IS,AS"', - '/SQLSysAdminAccounts="COMPANY\sqladmin"', - '/ASSysAdminAccounts="COMPANY\sqladmin"' -join ' ' + $mockStartWin32ProcessExpectedArgument = @{ + Quiet = 'True' + IAcceptSQLServerLicenseTerms = 'True' + Action = 'Install' + AGTSVCSTARTUPTYPE = 'Automatic' + InstanceName = 'MSSQLSERVER' + Features = 'SQLENGINE,REPLICATION,FULLTEXT,RS,IS,AS' + SQLSysAdminAccounts = 'COMPANY\sqladmin' + ASSysAdminAccounts = 'COMPANY\sqladmin' + } { Set-TargetResource @testParameters } | Should Not Throw @@ -2133,14 +2481,14 @@ try if( $mockSqlMajorVersion -eq 13 ) { It 'Should throw when feature parameter contains ''SSMS'' when installing SQL Server 2016' { $testParameters.Features = 'SSMS' - $mockStartWin32ProcessExpectedArgument = '' + $mockStartWin32ProcessExpectedArgument = @{} { Set-TargetResource @testParameters } | Should Throw "'SSMS' is not a valid value for setting 'FEATURES'. Refer to SQL Help for more information." } It 'Should throw when feature parameter contains ''ADV_SSMS'' when installing SQL Server 2016' { $testParameters.Features = 'ADV_SSMS' - $mockStartWin32ProcessExpectedArgument = '' + $mockStartWin32ProcessExpectedArgument = @{} { Set-TargetResource @testParameters } | Should Throw "'ADV_SSMS' is not a valid value for setting 'FEATURES'. Refer to SQL Help for more information." } @@ -2148,12 +2496,13 @@ try It 'Should set the system in the desired state when feature is SSMS' { $testParameters.Features = 'SSMS' - $mockStartWin32ProcessExpectedArgument = - '/Quiet="True"', - '/IAcceptSQLServerLicenseTerms="True"', - '/Action="Install"', - '/InstanceName="MSSQLSERVER"', - '/Features="SSMS"' -join ' ' + $mockStartWin32ProcessExpectedArgument = @{ + Quiet = 'True' + IAcceptSQLServerLicenseTerms = 'True' + Action = 'Install' + InstanceName = 'MSSQLSERVER' + Features = 'SSMS' + } { Set-TargetResource @testParameters } | Should Not Throw @@ -2183,12 +2532,13 @@ try It 'Should set the system in the desired state when feature is ADV_SSMS' { $testParameters.Features = 'ADV_SSMS' - $mockStartWin32ProcessExpectedArgument = - '/Quiet="True"', - '/IAcceptSQLServerLicenseTerms="True"', - '/Action="Install"', - '/InstanceName="MSSQLSERVER"', - '/Features="ADV_SSMS"' -join ' ' + $mockStartWin32ProcessExpectedArgument = @{ + Quiet = 'True' + IAcceptSQLServerLicenseTerms = 'True' + Action = 'Install' + InstanceName = 'MSSQLSERVER' + Features = 'ADV_SSMS' + } { Set-TargetResource @testParameters } | Should Not Throw @@ -2246,15 +2596,17 @@ try } It 'Should set the system in the desired state when feature is SQLENGINE' { - $mockStartWin32ProcessExpectedArgument = - '/Quiet="True"', - '/IAcceptSQLServerLicenseTerms="True"', - '/Action="Install"', - '/AGTSVCSTARTUPTYPE=Automatic', - '/InstanceName="MSSQLSERVER"', - '/Features="SQLENGINE,REPLICATION,FULLTEXT,RS,IS,AS"', - '/SQLSysAdminAccounts="COMPANY\sqladmin"', - '/ASSysAdminAccounts="COMPANY\sqladmin"' -join ' ' + + $mockStartWin32ProcessExpectedArgument = @{ + Quiet = 'True' + IAcceptSQLServerLicenseTerms = 'True' + Action = 'Install' + AgtSvcStartupType = 'Automatic' + InstanceName = 'MSSQLSERVER' + Features = 'SQLENGINE,REPLICATION,FULLTEXT,RS,IS,AS' + SQLSysAdminAccounts = 'COMPANY\sqladmin' + ASSysAdminAccounts = 'COMPANY\sqladmin' + } { Set-TargetResource @testParameters } | Should Not Throw @@ -2305,12 +2657,13 @@ try It 'Should set the system in the desired state when feature is SSMS' { $testParameters.Features = 'SSMS' - $mockStartWin32ProcessExpectedArgument = - '/Quiet="True"', - '/IAcceptSQLServerLicenseTerms="True"', - '/Action="Install"', - '/InstanceName="MSSQLSERVER"', - '/Features="SSMS"' -join ' ' + $mockStartWin32ProcessExpectedArgument = @{ + Quiet = 'True' + IAcceptSQLServerLicenseTerms = 'True' + Action = 'Install' + InstanceName = 'MSSQLSERVER' + Features = 'SSMS' + } { Set-TargetResource @testParameters } | Should Not Throw @@ -2341,12 +2694,13 @@ try It 'Should set the system in the desired state when feature is ADV_SSMS' { $testParameters.Features = 'ADV_SSMS' - $mockStartWin32ProcessExpectedArgument = - '/Quiet="True"', - '/IAcceptSQLServerLicenseTerms="True"', - '/Action="Install"', - '/InstanceName="MSSQLSERVER"', - '/Features="ADV_SSMS"' -join ' ' + $mockStartWin32ProcessExpectedArgument = @{ + Quiet = 'True' + IAcceptSQLServerLicenseTerms = 'True' + Action = 'Install' + InstanceName = 'MSSQLSERVER' + Features = 'ADV_SSMS' + } { Set-TargetResource @testParameters } | Should Not Throw @@ -2401,16 +2755,17 @@ try } -MockWith $mockEmptyHashtable -Verifiable } - It 'Should set the system in the desired state when feature is SQLENGINE' { - $mockStartWin32ProcessExpectedArgument = - '/Quiet="True"', - '/IAcceptSQLServerLicenseTerms="True"', - '/Action="Install"', - '/AGTSVCSTARTUPTYPE=Automatic', - '/InstanceName="MSSQLSERVER"', - '/Features="SQLENGINE,REPLICATION,FULLTEXT,RS,IS,AS"', - '/SQLSysAdminAccounts="COMPANY\sqladmin"', - '/ASSysAdminAccounts="COMPANY\sqladmin"' -join ' ' + It 'Should set the system in the desired state when feature is SQLENGINE' { + $mockStartWin32ProcessExpectedArgument = @{ + Quiet = 'True' + IAcceptSQLServerLicenseTerms = 'True' + Action = 'Install' + AGTSVCSTARTUPTYPE = 'Automatic' + InstanceName = 'MSSQLSERVER' + Features = 'SQLENGINE,REPLICATION,FULLTEXT,RS,IS,AS' + SQLSysAdminAccounts = 'COMPANY\sqladmin' + ASSysAdminAccounts = 'COMPANY\sqladmin' + } { Set-TargetResource @testParameters } | Should Not Throw @@ -2446,14 +2801,14 @@ try if( $mockSqlMajorVersion -eq 13 ) { It 'Should throw when feature parameter contains ''SSMS'' when installing SQL Server 2016' { $testParameters.Features = 'SSMS' - $mockStartWin32ProcessExpectedArgument = '' + $mockStartWin32ProcessExpectedArgument = @{} { Set-TargetResource @testParameters } | Should Throw "'SSMS' is not a valid value for setting 'FEATURES'. Refer to SQL Help for more information." } It 'Should throw when feature parameter contains ''ADV_SSMS'' when installing SQL Server 2016' { $testParameters.Features = 'ADV_SSMS' - $mockStartWin32ProcessExpectedArgument = '' + $mockStartWin32ProcessExpectedArgument = @{} { Set-TargetResource @testParameters } | Should Throw "'ADV_SSMS' is not a valid value for setting 'FEATURES'. Refer to SQL Help for more information." } @@ -2461,12 +2816,13 @@ try It 'Should set the system in the desired state when feature is SSMS' { $testParameters.Features = 'SSMS' - $mockStartWin32ProcessExpectedArgument = - '/Quiet="True"', - '/IAcceptSQLServerLicenseTerms="True"', - '/Action="Install"', - '/InstanceName="MSSQLSERVER"', - '/Features="SSMS"' -join ' ' + $mockStartWin32ProcessExpectedArgument = @{ + Quiet = 'True' + IAcceptSQLServerLicenseTerms = 'True' + Action = 'Install' + InstanceName = 'MSSQLSERVER' + Features = 'SSMS' + } { Set-TargetResource @testParameters } | Should Not Throw @@ -2497,12 +2853,13 @@ try It 'Should set the system in the desired state when feature is ADV_SSMS' { $testParameters.Features = 'ADV_SSMS' - $mockStartWin32ProcessExpectedArgument = - '/Quiet="True"', - '/IAcceptSQLServerLicenseTerms="True"', - '/Action="Install"', - '/InstanceName="MSSQLSERVER"', - '/Features="ADV_SSMS"' -join ' ' + $mockStartWin32ProcessExpectedArgument = @{ + Quiet = 'True' + IAcceptSQLServerLicenseTerms = 'True' + Action = 'Install' + InstanceName = 'MSSQLSERVER' + Features = 'ADV_SSMS' + } { Set-TargetResource @testParameters } | Should Not Throw @@ -2562,15 +2919,16 @@ try } It 'Should set the system in the desired state when feature is SQLENGINE' { - $mockStartWin32ProcessExpectedArgument = - '/Quiet="True"', - '/IAcceptSQLServerLicenseTerms="True"', - '/Action="Install"', - '/AGTSVCSTARTUPTYPE=Automatic', - '/InstanceName="TEST"', - '/Features="SQLENGINE,REPLICATION,FULLTEXT,RS,IS,AS"', - '/SQLSysAdminAccounts="COMPANY\sqladmin"', - '/ASSysAdminAccounts="COMPANY\sqladmin"' -join ' ' + $mockStartWin32ProcessExpectedArgument = @{ + Quiet = 'True' + IAcceptSQLServerLicenseTerms = 'True' + Action = 'Install' + AGTSVCSTARTUPTYPE = 'Automatic' + InstanceName = 'TEST' + Features = 'SQLENGINE,REPLICATION,FULLTEXT,RS,IS,AS' + SQLSysAdminAccounts = 'COMPANY\sqladmin' + ASSysAdminAccounts = 'COMPANY\sqladmin' + } { Set-TargetResource @testParameters } | Should Not Throw @@ -2601,14 +2959,14 @@ try if( $mockSqlMajorVersion -eq 13 ) { It 'Should throw when feature parameter contains ''SSMS'' when installing SQL Server 2016' { $testParameters.Features = $($testParameters.Features), 'SSMS' -join ',' - $mockStartWin32ProcessExpectedArgument = '' + $mockStartWin32ProcessExpectedArgument = @{} { Set-TargetResource @testParameters } | Should Throw "'SSMS' is not a valid value for setting 'FEATURES'. Refer to SQL Help for more information." } It 'Should throw when feature parameter contains ''ADV_SSMS'' when installing SQL Server 2016' { $testParameters.Features = $($testParameters.Features), 'ADV_SSMS' -join ',' - $mockStartWin32ProcessExpectedArgument = '' + $mockStartWin32ProcessExpectedArgument = @{} { Set-TargetResource @testParameters } | Should Throw "'ADV_SSMS' is not a valid value for setting 'FEATURES'. Refer to SQL Help for more information." } @@ -2616,12 +2974,13 @@ try It 'Should set the system in the desired state when feature is SSMS' { $testParameters.Features = 'SSMS' - $mockStartWin32ProcessExpectedArgument = - '/Quiet="True"', - '/IAcceptSQLServerLicenseTerms="True"', - '/Action="Install"', - '/InstanceName="TEST"', - '/Features="SSMS"' -join ' ' + $mockStartWin32ProcessExpectedArgument = @{ + Quiet = 'True' + IAcceptSQLServerLicenseTerms = 'True' + Action = 'Install' + InstanceName = 'TEST' + Features = 'SSMS' + } { Set-TargetResource @testParameters } | Should Not Throw @@ -2651,12 +3010,13 @@ try It 'Should set the system in the desired state when feature is ADV_SSMS' { $testParameters.Features = 'ADV_SSMS' - $mockStartWin32ProcessExpectedArgument = - '/Quiet="True"', - '/IAcceptSQLServerLicenseTerms="True"', - '/Action="Install"', - '/InstanceName="TEST"', - '/Features="ADV_SSMS"' -join ' ' + $mockStartWin32ProcessExpectedArgument = @{ + Quiet = 'True' + IAcceptSQLServerLicenseTerms = 'True' + Action = 'Install' + InstanceName = 'TEST' + Features = 'ADV_SSMS' + } { Set-TargetResource @testParameters } | Should Not Throw @@ -2683,57 +3043,273 @@ try } } } - } - Assert-VerifiableMocks - } + Context "When SQL Server version is $mockSqlMajorVersion and the system is not in the desired state and the action is PrepareFailoverCluster" { + BeforeAll { + $testParameters = $mockDefaultParameters.Clone() + $testParameters.Remove('Features') + $testParameters.Remove('SourceCredential') + $testParameters.Remove('ASSysAdminAccounts') - Describe 'Join-ServiceAccountInfo' -Tag 'Helper' { - Context 'When called it should return a string with service account information appended to the original argument string' { + $testParameters += @{ + Features = 'SQLENGINE' + InstanceName = 'MSSQLSERVER' + SourcePath = $mockSourcePath + Action = 'PrepareFailoverCluster' + } - $Params = @{ UsernameArgumentname = 'SQLSVCACCOUNT'; PasswordArgumentName = 'SQLSVCPASSWORD'; ArgumentString = "C:\Install\SQLSource\setup.exe" } + Mock -CommandName NetUse -Verifiable + Mock -CommandName Copy-ItemWithRoboCopy -Verifiable + Mock -CommandName Get-TemporaryFolder -MockWith $mockGetTemporaryFolder -Verifiable + Mock -CommandName Get-Service -MockWith $mockEmptyHashtable -Verifiable - $mockSetupDomainCredential = New-Object System.Management.Automation.PSCredential( 'Company\SQLServer', (ConvertTo-SecureString 'password' -AsPlainText -Force) ) - $mockSetupMSACredential = New-Object System.Management.Automation.PSCredential( 'Company\SQLServer$', (ConvertTo-SecureString 'password' -AsPlainText -Force) ) - $mockSetupSystemCredential = New-Object System.Management.Automation.PSCredential( 'SYSTEM', (ConvertTo-SecureString 'password' -AsPlainText -Force) ) - $mockSetupLocalServiceCredential = New-Object System.Management.Automation.PSCredential( 'LOCALSERVICE', (ConvertTo-SecureString 'password' -AsPlainText -Force) ) - $mockSetupNetworkServiceCredential = New-Object System.Management.Automation.PSCredential( 'NETWORKSERVICE', (ConvertTo-SecureString 'password' -AsPlainText -Force) ) - $mockSetupNTSystemCredential = New-Object System.Management.Automation.PSCredential( 'NT AUTHORITY\SYSTEM', (ConvertTo-SecureString 'password' -AsPlainText -Force) ) - $mockSetupNTLocalServiceCredential = New-Object System.Management.Automation.PSCredential( 'NT AUTHORITY\LOCALSERVICE', (ConvertTo-SecureString 'password' -AsPlainText -Force) ) - $mockSetupNTNetworkServiceCredential = New-Object System.Management.Automation.PSCredential( 'NT AUTHORITY\NETWORKSERVICE', (ConvertTo-SecureString 'password' -AsPlainText -Force) ) + Mock -CommandName Get-ItemProperty -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\$mockDefaultInstance_InstanceId\ConfigurationState" + } -MockWith $mockGetItemProperty_ConfigurationState -Verifiable - It 'Should return string with service account information and password appended Domain/Local Account' { - Join-ServiceAccountInfo @Params -User $mockSetupDomainCredential | Should BeExactly ('{0} /{1}="{2}" /{3}="{4}"' -f $Params['ArgumentString'], $Params['UsernameArgumentname'], $mockSetupDomainCredential.UserName, $Params['PasswordArgumentname'], $mockSetupDomainCredential.GetNetworkCredential().Password) - } + Mock -CommandName Get-ItemProperty -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\$mockDefaultInstance_InstanceId\Setup" -and $Name -eq 'SqlProgramDir' + } -MockWith $mockGetItemProperty_Setup -Verifiable - It 'Should return string service account information and no password appended for Managed Service Account' { - Join-ServiceAccountInfo @Params -User $mockSetupMSACredential | Should BeExactly ('{0} /{1}="{2}"' -f $Params['ArgumentString'], $Params['UsernameArgumentname'], $mockSetupMSACredential.UserName) - } + Mock -CommandName StartWin32Process -MockWith $mockStartWin32Process -Verifiable - It 'Should return string service account information and no password appended for NT AUTHORITY\SYSTEM. Test without NT AUTHORITY prepended' { - Join-ServiceAccountInfo @Params -User $mockSetupSystemCredential | Should BeExactly ('{0} /{1}="NT AUTHORITY\{2}"' -f $Params['ArgumentString'], $Params['UsernameArgumentname'], $mockSetupSystemCredential.UserName) - } + Mock -CommandName Get-CimInstance -MockWith {} -ParameterFilter { + ($Namespace -eq 'root/MSCluster') -and ($ClassName -eq 'MSCluster_ResourceGroup') -and ($Filter -eq "Name = 'Available Storage'") + } -Verifiable - It 'Should return string service account information and no password appended for NT AUTHORITY\LOCALSERVICE. Test without NT AUTHORITY prepended' { - Join-ServiceAccountInfo @Params -User $mockSetupLocalServiceCredential | Should BeExactly ('{0} /{1}="NT AUTHORITY\{2}"' -f $Params['ArgumentString'], $Params['UsernameArgumentname'], $mockSetupLocalServiceCredential.UserName) - } + Mock -CommandName Get-CimAssociatedInstance -MockWith {} -ParameterFilter { + ($Association -eq 'MSCluster_ResourceGroupToResource') -and ($ResultClassName -eq 'MSCluster_Resource') + } -Verfiable - It 'Should return string service account information and no password appended for NT AUTHORITY\NETWORKSERVICE. Test without NT AUTHORITY prepended' { - Join-ServiceAccountInfo @Params -User $mockSetupNetworkServiceCredential | Should BeExactly ('{0} /{1}="NT AUTHORITY\{2}"' -f $Params['ArgumentString'], $Params['UsernameArgumentname'], $mockSetupNetworkServiceCredential.UserName) - } + Mock -CommandName Get-CimAssociatedInstance -MockWith {} -ParameterFilter { + $Association -eq 'MSCluster_ResourceToPossibleOwner' + } -Verifiable - It 'Should return string service account information and no password appended for NT AUTHORITY\SYSTEM. Test with NT AUTHORITY prepended' { - Join-ServiceAccountInfo @Params -User $mockSetupNTSystemCredential | Should BeExactly ('{0} /{1}="{2}"' -f $Params['ArgumentString'], $Params['UsernameArgumentname'], $mockSetupNTSystemCredential.UserName) - } + Mock -CommandName Get-CimAssociatedInstance -MockWith {} -ParameterFilter { + $ResultClass -eq 'MSCluster_DiskPartition' + } -Verifiable + + Mock -CommandName Get-CimInstance -MockWith {} -ParameterFilter { + ($Namespace -eq 'root/MSCluster') -and ($ClassName -eq 'MSCluster_Network') -and ($Filter -eq 'Role >= 2') + } -Verifiable + } + + It 'Should add the SkipRules parameter to the installation arguments' { + + $mockStartWin32ProcessExpectedArgument = $mockStartWin32ProcessExpectedArgumentClusterDefault.Clone() + $mockStartWin32ProcessExpectedArgument += @{ + Action = 'PrepareFailoverCluster' + SkipRules = 'Cluster_VerifyForErrors' + } + + { Set-TargetResource @testParameters } | Should Not throw + + Assert-MockCalled -CommandName Connect-SQL -Exactly -Times 0 -Scope It + Assert-MockCalled -CommandName Connect-SQLAnalysis -Exactly -Times 0 -Scope It + Assert-MockCalled -CommandName Get-Service -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Get-ItemProperty -ParameterFilter { + $Path -eq 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL' -and + ($Name -eq $mockDefaultInstance_InstanceName) + } -Exactly -Times 0 -Scope It + Assert-MockCalled -CommandName StartWin32Process -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName WaitForWin32ProcessEnd -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Test-TargetResource -Exactly -Times 1 -Scope It + + Assert-MockCalled -CommandName Get-CimInstance -ParameterFilter { + ($Namespace -eq 'root/MSCluster') -and ($ClassName -eq 'MSCluster_ResourceGroup') -and ($Filter -eq "Name = 'Available Storage'") + } -Exactly -Times 0 -Scope It + + Assert-MockCalled -CommandName Get-CimAssociatedInstance -ParameterFilter { + ($Association -eq 'MSCluster_ResourceGroupToResource') -and ($ResultClassName -eq 'MSCluster_Resource') + } -Exactly -Times 0 -Scope It + + Assert-MockCalled -CommandName Get-CimAssociatedInstance -ParameterFilter { + $Association -eq 'MSCluster_ResourceToPossibleOwner' + } -Exactly -Times 0 -Scope It + + Assert-MockCalled -CommandName Get-CimAssociatedInstance -ParameterFilter { + $ResultClass -eq 'MSCluster_DiskPartition' + } -Exactly -Times 0 -Scope It - It 'Should return string service account information and no password appended for NT AUTHORITY\LOCALSERVICE. Test with NT AUTHORITY prepended' { - Join-ServiceAccountInfo @Params -User $mockSetupNTLocalServiceCredential | Should BeExactly ('{0} /{1}="{2}"' -f $Params['ArgumentString'], $Params['UsernameArgumentname'], $mockSetupNTLocalServiceCredential.UserName) + Assert-MockCalled -CommandName Get-CimInstance -ParameterFilter { + ($Namespace -eq 'root/MSCluster') -and ($ClassName -eq 'MSCluster_Network') -and ($Filter -eq 'Role >= 2') + } -Exactly -Times 0 -Scope It + } } - It 'Should return string service account information and no password appended for NT AUTHORITY\NETWORKSERVICE. Test with NT AUTHORITY prepended' { - Join-ServiceAccountInfo @Params -User $mockSetupNTNetworkServiceCredential | Should BeExactly ('{0} /{1}="{2}"' -f $Params['ArgumentString'], $Params['UsernameArgumentname'], $mockSetupNTNetworkServiceCredential.UserName) + Context "When SQL Server version is $mockSqlMajorVersion and the system is not in the desired state and the action is CompleteFailoverCluster." { + BeforeEach { + $testParameters = $mockDefaultClusterParameters.Clone() + + $testParameters += @{ + InstanceName = 'MSSQLSERVER' + SourcePath = $mockSourcePath + Action = 'CompleteFailoverCluster' + FailoverClusterGroupName = 'SQL Server (MSSQLSERVER)' + FailoverClusterNetworkName = 'TestCluster' + FailoverClusterIPAddress = '10.0.0.100' + } + } + + BeforeAll { + Mock -CommandName Get-CimInstance -MockWith $mockGetCimInstance_MSClusterResourceGroup_AvailableStorage -ParameterFilter { + $Filter -eq "Name = 'Available Storage'" + } -Verifiable + + Mock -CommandName Get-CimAssociatedInstance -MockWith $mockGetCimAssociatedInstance_MSCluster_ResourceGroupToResource -ParameterFilter { + ($Association -eq 'MSCluster_ResourceGroupToResource') -and ($ResultClassName -eq 'MSCluster_Resource') + } -Verfiable + + Mock -CommandName Get-CimAssociatedInstance -MockWith $mockGetCimAssociatedInstance_MSCluster_ResourceToPossibleOwner -ParameterFilter { + $Association -eq 'MSCluster_ResourceToPossibleOwner' + } -Verifiable + + Mock -CommandName Get-CimAssociatedInstance -MockWith $mockGetCimAssociatedInstance_MSCluster_DiskPartition -ParameterFilter { + $ResultClassName -eq 'MSCluster_DiskPartition' + } -Verifiable + + Mock -CommandName Get-CimInstance -MockWith $mockGetCimInstance_MSClusterNetwork -ParameterFilter { + ($Namespace -eq 'root/MSCluster') -and ($ClassName -eq 'MSCluster_Network') -and ($Filter -eq 'Role >= 2') + } -Verifiable + } + + It 'Should throw an error when one or more paths are not resolved to clustered storage' { + $badPathParameters = $testParameters.Clone() + + # Pass in a bad path + $badPathParameters.SQLUserDBDir = 'C:\MSSQL\' + + { Set-TargetResource @badPathParameters } | Should Throw 'Unable to map the specified paths to valid cluster storage. Drives mapped: TempDbData; TempDbLogs; UserLogs' + } + + It 'Should properly map paths to clustered disk resources' { + + $mockStartWin32ProcessExpectedArgument = $mockStartWin32ProcessExpectedArgumentClusterDefault.Clone() + $mockStartWin32ProcessExpectedArgument += @{ + Action = 'CompleteFailoverCluster' + FailoverClusterIPAddresses = 'IPV4; 10.0.0.100; SiteA_Prod; 255.255.255.0' + SQLUserDBDir = 'K:\MSSQL\Data' + SQLUserDBLogDir = 'L:\MSSQL\Logs' + SQLTempDBDir = 'M:\MSSQL\TempDb\Data' + SQLTempDBLogDir = 'N:\MSSQL\TempDb\Logs' + SkipRules = 'Cluster_VerifyForErrors' + + FailoverClusterDisks = 'TempDbData; TempDbLogs; UserData; UserLogs' + } + + { Set-TargetResource @testParameters } | Should Not Throw + } + + It 'Should build a DEFAULT address string when no network is specified' { + $missingNetworkParams = $testParameters.Clone() + $missingNetworkParams.Remove('FailoverClusterIPAddress') + + $mockStartWin32ProcessExpectedArgument = $mockStartWin32ProcessExpectedArgumentClusterDefault.Clone() + $mockStartWin32ProcessExpectedArgument += @{ + Action = 'CompleteFailoverCluster' + FailoverClusterIPAddresses = 'DEFAULT' + + SQLUserDBDir = 'K:\MSSQL\Data' + SQLUserDBLogDir = 'L:\MSSQL\Logs' + SQLTempDBDir = 'M:\MSSQL\TempDb\Data' + SQLTempDBLogDir = 'N:\MSSQL\TempDb\Logs' + FailoverClusterDisks = 'TempDbData; TempDbLogs; UserData; UserLogs' + SkipRules = 'Cluster_VerifyForErrors' + } + + { Set-TargetResource @missingNetworkParams } | Should Not Throw + } + + It 'Should throw an error when an invalid IP Address is specified' { + $invalidAddressParameters = $testParameters.Clone() + + $invalidAddressParameters.Remove('FailoverClusterIPAddress') + $invalidAddressParameters += @{ + FailoverClusterIPAddress = '192.168.0.100' + } + + { Set-TargetResource @invalidAddressParameters } | Should Throw 'Unable to map the specified IP Address(es) to valid cluster networks.' + } + + It 'Should throw an error when an invalid IP Address is specified for a multi-subnet instance' { + $invalidAddressParameters = $testParameters.Clone() + + $invalidAddressParameters.Remove('FailoverClusterIPAddress') + $invalidAddressParameters += @{ + FailoverClusterIPAddress = @('10.0.0.100','192.168.0.100') + } + + { Set-TargetResource @invalidAddressParameters } | Should Throw 'Unable to map the specified IP Address(es) to valid cluster networks.' + } + + It 'Should build a valid IP address string for a single address' { + + $mockStartWin32ProcessExpectedArgument = $mockStartWin32ProcessExpectedArgumentClusterDefault.Clone() + $mockStartWin32ProcessExpectedArgument += @{ + FailoverClusterIPAddresses = 'IPv4; 10.0.0.100; SiteA_Prod; 255.255.255.0' + + SQLUserDBDir = 'K:\MSSQL\Data' + SQLUserDBLogDir = 'L:\MSSQL\Logs' + SQLTempDBDir = 'M:\MSSQL\TempDb\Data' + SQLTempDBLogDir = 'N:\MSSQL\TempDb\Logs' + FailoverClusterDisks = 'TempDbData; TempDbLogs; UserData; UserLogs' + SkipRules = 'Cluster_VerifyForErrors' + Action = 'CompleteFailoverCluster' + } + + { Set-TargetResource @testParameters } | Should Not Throw + } + + It 'Should build a valid IP address string for a multi-subnet cluster' { + $multiSubnetParameters = $testParameters.Clone() + $multiSubnetParameters.Remove('FailoverClusterIPAddress') + $multiSubnetParameters += @{ + FailoverClusterIPAddress = ($mockClusterSites | ForEach-Object { $_.Address }) + } + + $mockStartWin32ProcessExpectedArgument = $mockStartWin32ProcessExpectedArgumentClusterDefault.Clone() + $mockStartWin32ProcessExpectedArgument += @{ + FailoverClusterIPAddresses = 'IPv4; 10.0.0.100; SiteA_Prod; 255.255.255.0; IPv4; 10.0.10.100; SiteB_Prod; 255.255.255.0' + + SQLUserDBDir = 'K:\MSSQL\Data' + SQLUserDBLogDir = 'L:\MSSQL\Logs' + SQLTempDBDir = 'M:\MSSQL\TempDb\Data' + SQLTempDBLogDir = 'N:\MSSQL\TempDb\Logs' + FailoverClusterDisks = 'TempDbData; TempDbLogs; UserData; UserLogs' + SkipRules = 'Cluster_VerifyForErrors' + Action = 'CompleteFailoverCluster' + } + + { Set-TargetResource @multiSubnetParameters } | Should Not Throw + } + + It 'Should pass proper parameters to setup' { + $mockStartWin32ProcessExpectedArgument = @{ + IAcceptSQLServerLicenseTerms = 'True' + SkipRules = 'Cluster_VerifyForErrors' + Quiet = 'True' + AgtSvcStartupType = 'Automatic' + SQLSysAdminAccounts = 'COMPANY\sqladmin' + + Action = 'CompleteFailoverCluster' + InstanceName = 'MSSQLSERVER' + Features = 'SQLEngine' + FailoverClusterDisks = 'TempDbData; TempDbLogs; UserData; UserLogs' + FailoverClusterIPAddresses = 'IPV4; 10.0.0.100; SiteA_Prod; 255.255.255.0' + FailoverClusterGroup = 'SQL Server (MSSQLSERVER)' + SQLUserDBDir = 'K:\MSSQL\Data' + SQLUserDBLogDir = 'L:\MSSQL\Logs' + SQLTempDBDir = 'M:\MSSQL\TempDb\Data' + SQLTempDBLogDir = 'N:\MSSQL\TempDb\Logs' + } + + { Set-TargetResource @testParameters } | Should Not Throw + } } + } + + Assert-VerifiableMocks } # Tests only the parts of the code that does not already get tested thru the other tests. @@ -2892,7 +3468,67 @@ try } } } - } + + Describe 'Get-ServiceAccountParameters' -Tag 'Helper' { + $serviceTypes = @('SQL','AGT','IS','RS','AS','FT') + + BeforeAll { + $mockServiceAccountPassword = ConvertTo-SecureString 'Password' -AsPlainText -Force + + $mockSystemServiceAccount = ( + New-Object System.Management.Automation.PSCredential 'NT AUTHORITY\SYSTEM', $mockServiceAccountPassword + ) + + $mockVirtualServiceAccount = ( + New-Object System.Management.Automation.PSCredential 'NT SERVICE\MSSQLSERVER', $mockServiceAccountPassword + ) + + $mockManagedServiceAccount = ( + New-Object System.Management.Automation.PSCredential 'COMPANY\ManagedAccount$', $mockServiceAccountPassword + ) + + $mockDomainServiceAccount = ( + New-Object System.Management.Automation.PSCredential 'COMPANY\sql.service', $mockServiceAccountPassword + ) + } + + $serviceTypes | ForEach-Object { + + $serviceType = $_ + + Context "When service type is $serviceType" { + + It "Should return the correct parameters when the account is a system account." { + $result = Get-ServiceAccountParameters -ServiceAccount $mockSystemServiceAccount -ServiceType $serviceType + + $result.$("$($serviceType)SVCACCOUNT") | Should BeExactly $mockSystemServiceAccount.UserName + $result.ContainsKey("$($serviceType)SVCPASSWORD") | Should Be $false + } + + It "Should return the correct parameters when the account is a virtual service account" { + $result = Get-ServiceAccountParameters -ServiceAccount $mockVirtualServiceAccount -ServiceType $serviceType + + $result.$("$($serviceType)SVCACCOUNT") | Should BeExactly $mockVirtualServiceAccount.UserName + $result.ContainsKey("$($serviceType)SVCPASSWORD") | Should Be $false + } + + It "Should return the correct parameters when the account is a managed service account" { + $result = Get-ServiceAccountParameters -ServiceAccount $mockManagedServiceAccount -ServiceType $serviceType + + $result.$("$($serviceType)SVCACCOUNT") | Should BeExactly $mockManagedServiceAccount.UserName + $result.ContainsKey("$($serviceType)SVCPASSWORD") | Should Be $false + } + + It "Should return the correct parameters when the account is a domain account" { + $result = Get-ServiceAccountParameters -ServiceAccount $mockDomainServiceAccount -ServiceType $serviceType + + $result.$("$($serviceType)SVCACCOUNT") | Should BeExactly $mockDomainServiceAccount.UserName + $result.$("$($serviceType)SVCPASSWORD") | Should BeExactly $mockDomainServiceAccount.GetNetworkCredential().Password + } + } + } + } + } } finally { diff --git a/en-US/xSQLServer.strings.psd1 b/en-US/xSQLServer.strings.psd1 index 9ba278863..95a86b1f6 100644 --- a/en-US/xSQLServer.strings.psd1 +++ b/en-US/xSQLServer.strings.psd1 @@ -52,4 +52,9 @@ PasswordChangeFailed = Setting the password failed for the SQL Login '{0}'. AlterLoginFailed = Altering the login '{0}' failed. CreateLoginFailed = Creating the login '{0}' failed. DropLoginFailed = Dropping the login '{0}' failed. + +# Clustered Setup +FailoverClusterDiskMappingError = Unable to map the specified paths to valid cluster storage. Drives mapped: {0} +FailoverClusterIPAddressNotValid = Unable to map the specified IP Address(es) to valid cluster networks. +FailoverClusterResourceNotFound = Could not locate a SQL Server cluster resource for instance {0}. '@