diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b9ecbe9..c310c0afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ - Fixed the logic so that if a parameter is not supplied to the resource, the resource will not attempt to apply the defaults on subsequent checks ([issue #517](https://github.com/PowerShell/xSQLServer/issues/517)). + - Made the resource cluster aware. When ProcessOnlyOnActiveNode is specified, + the resource will only determine if a change is needed if the target node + is the active host of the SQL Server instance ([issue #868](https://github.com/PowerShell/xSQLServer/issues/868)). - Added the CommonTestHelper.psm1 to store common testing functions. - Added the Import-SQLModuleStub function to ensure the correct version of the module stubs are loaded ([issue #784](https://github.com/PowerShell/xSQLServer/issues/784)). diff --git a/DSCResources/MSFT_xSQLServerAlwaysOnAvailabilityGroup/MSFT_xSQLServerAlwaysOnAvailabilityGroup.psm1 b/DSCResources/MSFT_xSQLServerAlwaysOnAvailabilityGroup/MSFT_xSQLServerAlwaysOnAvailabilityGroup.psm1 index f239a9dd3..1c87a2bb6 100644 --- a/DSCResources/MSFT_xSQLServerAlwaysOnAvailabilityGroup/MSFT_xSQLServerAlwaysOnAvailabilityGroup.psm1 +++ b/DSCResources/MSFT_xSQLServerAlwaysOnAvailabilityGroup/MSFT_xSQLServerAlwaysOnAvailabilityGroup.psm1 @@ -50,27 +50,34 @@ function Get-TargetResource # Get the Availability Group $availabilityGroup = $serverObject.AvailabilityGroups[$Name] + # Is this node actively hosting the SQL instance? + $isActiveNode = Test-ActiveNode -ServerObject $serverObject + + # Create the return object. Default ensure to Absent. + $alwaysOnAvailabilityGroupResource = @{ + Name = $Name + SQLServer = $SQLServer + SQLInstanceName = $SQLInstanceName + Ensure = 'Absent' + IsActiveNode = $isActiveNode + } + if ( $availabilityGroup ) { # Get all of the properties that can be set using this resource - $alwaysOnAvailabilityGroupResource = @{ - Name = $Name - SQLServer = $SQLServer - SQLInstanceName = $SQLInstanceName - Ensure = 'Present' - AutomatedBackupPreference = $availabilityGroup.AutomatedBackupPreference - AvailabilityMode = $availabilityGroup.AvailabilityReplicas[$serverObject.DomainInstanceName].AvailabilityMode - BackupPriority = $availabilityGroup.AvailabilityReplicas[$serverObject.DomainInstanceName].BackupPriority - ConnectionModeInPrimaryRole = $availabilityGroup.AvailabilityReplicas[$serverObject.DomainInstanceName].ConnectionModeInPrimaryRole - ConnectionModeInSecondaryRole = $availabilityGroup.AvailabilityReplicas[$serverObject.DomainInstanceName].ConnectionModeInSecondaryRole - FailureConditionLevel = $availabilityGroup.FailureConditionLevel - FailoverMode = $availabilityGroup.AvailabilityReplicas[$serverObject.DomainInstanceName].FailoverMode - HealthCheckTimeout = $availabilityGroup.HealthCheckTimeout - EndpointURL = $availabilityGroup.AvailabilityReplicas[$serverObject.DomainInstanceName].EndpointUrl - EndpointPort = $endpointPort - SQLServerNetName = $serverObject.NetName - Version = $sqlMajorVersion - } + $alwaysOnAvailabilityGroupResource.Ensure = 'Present' + $alwaysOnAvailabilityGroupResource.AutomatedBackupPreference = $availabilityGroup.AutomatedBackupPreference + $alwaysOnAvailabilityGroupResource.AvailabilityMode = $availabilityGroup.AvailabilityReplicas[$serverObject.DomainInstanceName].AvailabilityMode + $alwaysOnAvailabilityGroupResource.BackupPriority = $availabilityGroup.AvailabilityReplicas[$serverObject.DomainInstanceName].BackupPriority + $alwaysOnAvailabilityGroupResource.ConnectionModeInPrimaryRole = $availabilityGroup.AvailabilityReplicas[$serverObject.DomainInstanceName].ConnectionModeInPrimaryRole + $alwaysOnAvailabilityGroupResource.ConnectionModeInSecondaryRole = $availabilityGroup.AvailabilityReplicas[$serverObject.DomainInstanceName].ConnectionModeInSecondaryRole + $alwaysOnAvailabilityGroupResource.FailureConditionLevel = $availabilityGroup.FailureConditionLevel + $alwaysOnAvailabilityGroupResource.FailoverMode = $availabilityGroup.AvailabilityReplicas[$serverObject.DomainInstanceName].FailoverMode + $alwaysOnAvailabilityGroupResource.HealthCheckTimeout = $availabilityGroup.HealthCheckTimeout + $alwaysOnAvailabilityGroupResource.EndpointURL = $availabilityGroup.AvailabilityReplicas[$serverObject.DomainInstanceName].EndpointUrl + $alwaysOnAvailabilityGroupResource.EndpointPort = $endpointPort + $alwaysOnAvailabilityGroupResource.SQLServerNetName = $serverObject.NetName + $alwaysOnAvailabilityGroupResource.Version = $sqlMajorVersion # Add properties that are only present in SQL 2016 or newer if ( $sqlMajorVersion -ge 13 ) @@ -80,16 +87,6 @@ function Get-TargetResource $alwaysOnAvailabilityGroupResource.Add('DtcSupportEnabled', $availabilityGroup.DtcSupportEnabled) } } - else - { - # Return the minimum amount of properties showing that the Availability Group is absent - $alwaysOnAvailabilityGroupResource = @{ - Name = $Name - SQLServer = $SQLServer - SQLInstanceName = $SQLInstanceName - Ensure = 'Absent' - } - } return $alwaysOnAvailabilityGroupResource } @@ -142,6 +139,10 @@ function Get-TargetResource .PARAMETER HealthCheckTimeout Specifies the length of time, in milliseconds, after which AlwaysOn availability groups declare an unresponsive server to be unhealthy. Default is 30,000. + + .PARAMETER ProcessOnlyOnActiveNode + Specifies that the resource will only determine if a change is needed if the target node is the active host of the SQL Server Instance. + Not used in Set-TargetResource. #> function Set-TargetResource { @@ -224,7 +225,11 @@ function Set-TargetResource [Parameter()] [UInt32] - $HealthCheckTimeout = 30000 + $HealthCheckTimeout = 30000, + + [Parameter()] + [Boolean] + $ProcessOnlyOnActiveNode ) Import-SQLPSModule @@ -513,6 +518,9 @@ function Set-TargetResource .PARAMETER HealthCheckTimeout Specifies the length of time, in milliseconds, after which AlwaysOn availability groups declare an unresponsive server to be unhealthy. Default is 30,000. + + .PARAMETER ProcessOnlyOnActiveNode + Specifies that the resource will only determine if a change is needed if the target node is the active host of the SQL Server Instance. #> function Test-TargetResource { @@ -590,7 +598,11 @@ function Test-TargetResource [Parameter()] [UInt32] - $HealthCheckTimeout = 30000 + $HealthCheckTimeout = 30000, + + [Parameter()] + [Boolean] + $ProcessOnlyOnActiveNode ) $getTargetResourceParameters = @{ @@ -604,6 +616,16 @@ function Test-TargetResource $getTargetResourceResult = Get-TargetResource @getTargetResourceParameters + <# + If this is supposed to process only the active node, and this is not the + active node, don't bother evaluating the test. + #> + if ( $ProcessOnlyOnActiveNode -and -not $getTargetResourceResult.IsActiveNode ) + { + New-VerboseMessage -Message ( 'The node "{0}" is not actively hosting the instance "{1}". Exiting the test.' -f $env:COMPUTERNAME,$SQLInstanceName ) + return $result + } + # Define current version for check compatibility $sqlMajorVersion = $getTargetResourceResult.Version diff --git a/DSCResources/MSFT_xSQLServerAlwaysOnAvailabilityGroup/MSFT_xSQLServerAlwaysOnAvailabilityGroup.schema.mof b/DSCResources/MSFT_xSQLServerAlwaysOnAvailabilityGroup/MSFT_xSQLServerAlwaysOnAvailabilityGroup.schema.mof index 6dfa752bc..7cf156426 100644 --- a/DSCResources/MSFT_xSQLServerAlwaysOnAvailabilityGroup/MSFT_xSQLServerAlwaysOnAvailabilityGroup.schema.mof +++ b/DSCResources/MSFT_xSQLServerAlwaysOnAvailabilityGroup/MSFT_xSQLServerAlwaysOnAvailabilityGroup.schema.mof @@ -17,8 +17,10 @@ class MSFT_xSQLServerAlwaysOnAvailabilityGroup : OMI_BaseResource [Write, Description("Specifies the automatic failover behavior of the availability group."), ValueMap{"OnServerDown","OnServerUnresponsive","OnCriticalServerErrors","OnModerateServerErrors","OnAnyQualifiedFailureCondition"}, Values{"OnServerDown","OnServerUnresponsive","OnCriticalServerErrors","OnModerateServerErrors","OnAnyQualifiedFailureCondition"}] String FailureConditionLevel; [Write, Description("Specifies the failover mode. Default is 'Manual'."), ValueMap{"Automatic","Manual"}, Values{"Automatic","Manual"}] String FailoverMode; [Write, Description("Specifies the length of time, in milliseconds, after which AlwaysOn availability groups declare an unresponsive server to be unhealthy. Default is 30000.")] UInt32 HealthCheckTimeout; + [Write, Description("Specifies that the resource will only determine if a change is needed if the target node is the active host of the SQL Server Instance.")] Boolean ProcessOnlyOnActiveNode; [Read, Description("Gets the Endpoint URL of the availability group replica endpoint.")] String EndpointUrl; [Read, Description("Gets the port the database mirroring endpoint is listening on.")] UInt32 EndpointPort; [Read, Description("Gets the hostname the SQL Server instance is listening on.")] String SQLServerNetName; [Read, Description("Gets the major version of the SQL Server instance.")] UInt32 Version; + [Read, Description("Determines if the current node is actively hosting the SQL Server instance.")] Boolean IsActiveNode; }; diff --git a/Examples/Resources/xSQLServerAlwaysOnAvailabilityGroup/3-CreateAvailabilityGroupDetailed.ps1 b/Examples/Resources/xSQLServerAlwaysOnAvailabilityGroup/3-CreateAvailabilityGroupDetailed.ps1 index 0e6a6bfd4..51ddbe8ee 100644 --- a/Examples/Resources/xSQLServerAlwaysOnAvailabilityGroup/3-CreateAvailabilityGroupDetailed.ps1 +++ b/Examples/Resources/xSQLServerAlwaysOnAvailabilityGroup/3-CreateAvailabilityGroupDetailed.ps1 @@ -1,6 +1,11 @@ <# .EXAMPLE This example shows how to ensure that the Availability Group 'TestAG' exists. + + In the event this is applied to a Failover Cluster Instance (FCI), the + ProcessOnlyOnActiveNode property will tell the Test-TargetResource function + to evaluate if any changes are needed if the node is actively hosting the + SQL Server Instance. #> $ConfigurationData = @{ @@ -8,6 +13,7 @@ $ConfigurationData = @{ @{ NodeName = '*' SQLInstanceName = 'MSSQLSERVER' + ProcessOnlyOnActiveNode = $true AutomatedBackupPreference = 'Primary' AvailabilityMode = 'SynchronousCommit' @@ -16,7 +22,7 @@ $ConfigurationData = @{ ConnectionModeInSecondaryRole = 'AllowNoConnections' FailoverMode = 'Automatic' HealthCheckTimeout = 15000 - + BasicAvailabilityGroup = $False DatabaseHealthTrigger = $True DtcSupportEnabled = $True @@ -83,7 +89,8 @@ Configuration Example Name = 'TestAG' SQLInstanceName = $Node.SQLInstanceName SQLServer = $Node.NodeName - + ProcessOnlyOnActiveNode = $Node.ProcessOnlyOnActiveNode + AutomatedBackupPreference = $Node.AutomatedBackupPreference AvailabilityMode = $Node.AvailabilityMode BackupPriority = $Node.BackupPriority @@ -91,7 +98,7 @@ Configuration Example ConnectionModeInSecondaryRole = $Node.ConnectionModeInSecondaryRole FailoverMode = $Node.FailoverMode HealthCheckTimeout = $Node.HealthCheckTimeout - + # sql server 2016 or later only BasicAvailabilityGroup = $Node.BasicAvailabilityGroup DatabaseHealthTrigger = $Node.DatabaseHealthTrigger diff --git a/README.md b/README.md index ced143560..af8c35a98 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,9 @@ It will also manage the Availability Group replica on the specified node. * **`[Uint32]` HealthCheckTimeout** _(Write)_: Specifies the length of time, in milliseconds, after which AlwaysOn availability groups declare an unresponsive server to be unhealthy. Default is 30000. +* **`[Boolean]` ProcessOnlyOnActiveNode** _(Write)_: Specifies that the resource + will only determine if a change is needed if the target node is the active + host of the SQL Server Instance. #### Read-Only Properties from Get-TargetResource @@ -254,6 +257,8 @@ It will also manage the Availability Group replica on the specified node. instance is listening on. * **`[Uint32]` Version** _(Read)_: Gets the major version of the SQL Server instance. +* **`[Boolean]` IsActiveNode** _(Read)_: Determines if the current node is + actively hosting the SQL Server instance. #### Examples diff --git a/Tests/Unit/MSFT_xSQLServerAlwaysOnAvailabilityGroup.Tests.ps1 b/Tests/Unit/MSFT_xSQLServerAlwaysOnAvailabilityGroup.Tests.ps1 index 1cab112e8..0a0409bca 100644 --- a/Tests/Unit/MSFT_xSQLServerAlwaysOnAvailabilityGroup.Tests.ps1 +++ b/Tests/Unit/MSFT_xSQLServerAlwaysOnAvailabilityGroup.Tests.ps1 @@ -69,6 +69,8 @@ try 'NamedInstance' ) + $mockProcessOnlyOnActiveNode = $true + #endregion parameter mocks #region property mocks @@ -148,6 +150,7 @@ try $testTargetResourceEndpointIncorrectTestCases = @() $testTargetResourcePresentTestCases = @() $testTargetResourcePropertyIncorrectTestCases = @() + $testTargetResourceProcessOnlyOnActiveNodeTestCases = @() $majorVersionsToTest = @(12,13) $ensureCasesToTest = @('Absent','Present') @@ -163,7 +166,7 @@ try ( Get-Command -Name Test-TargetResource ).Parameters.Values | Where-Object -FilterScript { ( # Ignore these specific parameters. These get tested enough. - @('Ensure', 'Name', 'SQLServer', 'SQLInstanceName', 'DtcSupportEnabled') -notcontains $_.Name + @('Ensure', 'Name', 'SQLServer', 'SQLInstanceName', 'DtcSupportEnabled', 'ProcessOnlyOnActiveNode') -notcontains $_.Name ) -and ( # Ignore the CmdletBinding parameters $_.Attributes.TypeId.Name -notcontains 'AliasAttribute' @@ -284,6 +287,18 @@ try Version = $majorVersionToTest } + foreach ( $processOnlyOnActiveNode in @($true,$false) ) + { + $testTargetResourceProcessOnlyOnActiveNodeTestCases += @{ + Ensure = 'Present' + Name = $mockNameParameters.PresentAvailabilityGroup + ProcessOnlyOnActiveNode = $processOnlyOnActiveNode + SQLServer = $mockSqlServerParameter + SQLInstanceName = $mockSqlInstanceNameParameter + Version = $majorVersionToTest + } + } + # Create test cases for Absent/Present foreach ( $ensureCaseToTest in $ensureCasesToTest ) { @@ -1193,6 +1208,9 @@ try Describe 'xSQLServerAlwaysOnAvailabilityGroup\Test-TargetResource' -Tag 'Test' { BeforeAll { Mock -CommandName Connect-SQL -MockWith $mockConnectSql -Verifiable + Mock -CommandName Test-ActiveNode -MockWith { + $mockProcessOnlyOnActiveNode + } -Verifiable } Context 'When the Availability Group is Absent' { @@ -1220,6 +1238,7 @@ try Test-TargetResource @testTargetResourceParameters | Should -Be $Result Assert-MockCalled -CommandName Connect-SQL -Scope It -Times 1 -Exactly + Assert-MockCalled -CommandName Test-ActiveNode -Scope It -Times 1 -Exactly } } @@ -1248,6 +1267,7 @@ try Test-TargetResource @testTargetResourceParameters | Should -Be $Result Assert-MockCalled -CommandName Connect-SQL -Scope It -Times 1 -Exactly + Assert-MockCalled -CommandName Test-ActiveNode -Scope It -Times 1 -Exactly } } @@ -1279,6 +1299,7 @@ try Test-TargetResource @testTargetResourceParameters | Should -Be $Result Assert-MockCalled -CommandName Connect-SQL -Scope It -Times 1 -Exactly + Assert-MockCalled -CommandName Test-ActiveNode -Scope It -Times 1 -Exactly } } @@ -1324,6 +1345,46 @@ try Test-TargetResource @testTargetResourceParameters | Should -Be $Result Assert-MockCalled -CommandName Connect-SQL -Scope It -Times 1 -Exactly + Assert-MockCalled -CommandName Test-ActiveNode -Scope It -Times 1 -Exactly + } + } + + Context 'When the ProcessOnlyOnActiveNode parameter is passed' { + AfterAll { + $mockProcessOnlyOnActiveNode = $mockProcessOnlyOnActiveNodeOriginal + } + + BeforeAll { + $mockProcessOnlyOnActiveNodeOriginal = $mockProcessOnlyOnActiveNode + $mockProcessOnlyOnActiveNode = $false + } + + It 'Should be "true" when ProcessOnlyOnActiveNode is "", Ensure is "", Name is "", SQLServer is "", SQLInstanceName is "", and the SQL version is ""' -TestCases $testTargetResourceProcessOnlyOnActiveNodeTestCases { + param + ( + $Ensure, + $Name, + $ProcessOnlyOnActiveNode, + $SQLServer, + $SQLInstanceName, + $Version + ) + + # Ensure the correct stubs are loaded for the SQL version + Import-SQLModuleStub -SQLVersion $Version + + $testTargetResourceParameters = @{ + Ensure = $Ensure + Name = $Name + ProcessOnlyOnActiveNode = $ProcessOnlyOnActiveNode + SQLServer = $SQLServer + SQLInstanceName = $SQLInstanceName + } + + Test-TargetResource @testTargetResourceParameters | Should Be $true + + Assert-MockCalled -CommandName Connect-SQL -Scope It -Times 1 -Exactly + Assert-MockCalled -CommandName Test-ActiveNode -Scope It -Times 1 -Exactly } } } diff --git a/Tests/Unit/Stubs/SMO.cs b/Tests/Unit/Stubs/SMO.cs index c712299ce..577722d08 100644 --- a/Tests/Unit/Stubs/SMO.cs +++ b/Tests/Unit/Stubs/SMO.cs @@ -248,6 +248,7 @@ public class Server public string InstanceName; public bool IsClustered = false; public bool IsHadrEnabled = false; + public bool IsMemberOfWsfcCluster = false; public Hashtable Logins = new Hashtable(); public string Name; public string NetName; diff --git a/Tests/Unit/xSQLServerHelper.Tests.ps1 b/Tests/Unit/xSQLServerHelper.Tests.ps1 index 646fd7713..e8fb6d30b 100644 --- a/Tests/Unit/xSQLServerHelper.Tests.ps1 +++ b/Tests/Unit/xSQLServerHelper.Tests.ps1 @@ -1683,4 +1683,49 @@ InModuleScope $script:moduleName { } } } + + Describe 'Testing Test-ActiveNode' { + BeforeAll { + $mockServerObject = New-Object -TypeName Microsoft.SqlServer.Management.Smo.Server + + $failoverClusterInstanceTestCases = @( + @{ + ComputerNamePhysicalNetBIOS = $env:COMPUTERNAME + Result = $true + }, + @{ + ComputerNamePhysicalNetBIOS = 'AnotherNode' + Result = $false + } + ) + } + + Context 'When function is executed on a standalone instance' { + BeforeAll { + $mockServerObject.IsMemberOfWsfcCluster = $false + } + + It 'Should return "$true"' { + Test-ActiveNode -ServerObject $mockServerObject | Should Be $true + } + } + + Context 'When function is executed on a failover cluster instance (FCI)' { + BeforeAll { + $mockServerObject.IsMemberOfWsfcCluster = $true + } + + It 'Should return "" when the node name is ""' -TestCases $failoverClusterInstanceTestCases { + param + ( + $ComputerNamePhysicalNetBIOS, + $Result + ) + + $mockServerObject.ComputerNamePhysicalNetBIOS = $ComputerNamePhysicalNetBIOS + + Test-ActiveNode -ServerObject $mockServerObject | Should Be $Result + } + } + } } diff --git a/xSQLServerHelper.psm1 b/xSQLServerHelper.psm1 index 771db880e..62e6e1d25 100644 --- a/xSQLServerHelper.psm1 +++ b/xSQLServerHelper.psm1 @@ -1321,3 +1321,44 @@ function Test-ClusterPermissions return $clusterPermissionsPresent } + +<# + .SYNOPSIS + Determine if the current node is hosting the instance. + + .PARAMETER ServerObject + The server object on which to perform the test. +#> +function Test-ActiveNode +{ + [CmdletBinding()] + [OutputType([System.Boolean])] + param + ( + [Parameter(Mandatory = $true)] + [Microsoft.SqlServer.Management.Smo.Server] + $ServerObject + ) + + $result = $false + + # Determine if this is a failover cluster instance (FCI) + if ( $ServerObject.IsMemberOfWsfcCluster ) + { + <# + If the current node name is the same as the name the instances is + running on, then this is the active node + #> + $result = $ServerObject.ComputerNamePhysicalNetBIOS -eq $env:COMPUTERNAME + } + else + { + <# + This is a standalone instance, therefore the node will always host + the instance. + #> + $result = $true + } + + return $result +}