compute/sqlserver/powershell/create-sql-instance-availability-group.ps1 (394 lines of code) (raw):

#Requires -Version 5 # Copyright(c) 2020 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); you may not # use this file except in compliance with the License. You may obtain a copy of # the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under # the License. ############################################################################### #.SYNOPSIS # Create two Windows instances and set up a SQL Server Availability Group # using Alias IPs # #.DESCRIPTION # This script will automate the steps described on: # "Configuring SQL Server Availability Groups" # https://cloud.google.com/compute/docs/instances/sql-server/configure-availability # # PREREQUISITES: # 1. Before running the script, create the three subnetworks: # wsfcsubnet1, wsfcsubnet2, wsfcsubnet3 (Optional) # 2. A domain controller for a domain called "dbeng.com" must also exist. # The IP for the domain controller is 10.2.0.100 and resides in wsfcsubnet3 (optional) # # Quick summary of the configuration: # + Custom network "wsfcnet" with three subnetworks: wsfcsubnet1, wsfcsubnet2, wsfcsubnet3 # - wsfcsubnet1: 10.0.0.0/24 # - wsfcsubnet1: 10.1.0.0/24 # - wsfcsubnet3: 10.2.0.0/24 # # + Two Nodes # - cluster-sql1: 10.0.0.4 in wsfcsubnet1 # This node has two extra IP Aliases (10.0.0.5, 10.0.0.6) # # - cluster-sql2: 10.1.0.4 in wsfcsubnet1 # This node has two extra IP Aliases (10.1.0.5, 10.1.0.6) # # + Windows Server Failover Cluster (WSFC) # - Name: cluster-dbclus # - Two IPs from the IP Aliases: 10.0.0.5, 10.1.0.5 # # + Availability Group & Listener # - Availability Group Name : cluster-ag # - Listener Name : ag-listener # - Two IPs from the IP Aliases: 10.0.0.6, 10.1.0.6 # # # You must have the 'Google Cloud SDK' which includes the Powershell # CmdLets for Google Cloud. # See https://cloud.google.com/tools/powershell/docs/ # # The script expects the files parameters-config.ps1 and # create-sql-instance-availability-group.ps1 to exist in the same directory. # #.NOTES # AUTHOR: Anibal Santiago - @SQLThinker # #.EXAMPLE # create-sql-instance-availability-group.ps1 # Before running the script make you need to modify the file # parameters-config.ps1 with the parameters specific to your configuration ############################################################################### cls Set-Location $PSScriptRoot ################################################################################ # Read the parameters for this script. They are found in the file # parameters-config.ps1. We assume it is in the same folder as this script ################################################################################ . "$PSScriptRoot\parameters-config.ps1" ################################################################################ # Remove the instances and boot disks if they exists ################################################################################ $ErrorActionPreference = 'Stop' if ( $force_delete -eq $true ) { Write-Host "$(Get-Date) Removing instance $node1 if it exists" Get-GceInstance -Zone $zone | Where Name -EQ $node1 | Remove-GceInstance Get-GceDisk -Zone $zone | Where Name -EQ $node1 | Remove-GceDisk Write-Host "$(Get-Date) Removing instance $node2 if it exists" Get-GceInstance -Zone $zone | Where Name -EQ $node2 | Remove-GceInstance Get-GceDisk -Zone $zone | Where Name -EQ $node2 | Remove-GceDisk # Remove the routes for these nodes if they exist Write-Host "$(Get-Date) Removing routes for those instances" Get-GceRoute | Where Name -In $node1-route, $node1-route-listener, ` $node2-route, $node2-route-listener | Remove-GceRoute } ################################################################################ # Create a boot disk based on the image family that we want to create ################################################################################ $image_disk = Get-GceImage -Family $image_family Write-Host "$(Get-Date) Creating boot disk for instance $node1" $boot_disk1 = New-GceDisk -DiskName $node1 -DiskType $disk_type -Image $image_disk ` -SizeGb $size_gb -Zone $zone Write-Host "$(Get-Date) Creating boot disk for instance $node2" $boot_disk2 = New-GceDisk -DiskName $node2 -DiskType $disk_type -Image $image_disk ` -SizeGb $size_gb -Zone $zone ################################################################################ # Create the instances # Add an IP alias with the Listener IP ################################################################################ # Continue on errors as the gcloud command is reported as failed even when it # was successful $ErrorActionPreference = 'continue' Write-Host "$(Get-Date) Creating instance $node1" Invoke-Command -ScriptBlock { gcloud compute instances create $node1 --machine-type $machine_type ` --zone $zone ` --can-ip-forward ` --disk="name=$node1,boot=yes,auto-delete=yes" ` --network-interface subnet=$subnet1_name,private-network-ip=$ip_node1,aliases="$ip_wsfc1;$ip_ag_listener1" --quiet 2> $null } Write-Host "$(Get-Date) Creating instance $node2" Invoke-Command -ScriptBlock { gcloud compute instances create $node2 --machine-type $machine_type ` --zone $zone ` --can-ip-forward ` --disk="name=$node2,boot=yes,auto-delete=yes" ` --network-interface subnet=$subnet2_name,private-network-ip=$ip_node2,aliases="$ip_wsfc2;$ip_ag_listener2" --quiet 2> $null } $ErrorActionPreference = 'stop' ################################################################################ # Wait until the instances are configured # Note: We wait until the text 'Instance setup finished' shows in serial console # If this text changes in the future we need to modify this script ################################################################################ $creation_status = Get-GceInstance -zone $zone -Name $node1 -SerialPortOutput | Select-String -Pattern 'Instance setup finished' -Quiet $n = 20 while (!($creation_status)) { Write-Host "$(Get-Date) Waiting for instance $node1 to be ready" Start-Sleep -s 30 $n -= 1 if ($n -eq 0) {break} $creation_status = Get-GceInstance -zone $zone -Name $node1 -SerialPortOutput | Select-String -Pattern 'Instance setup finished' -Quiet } Write-Host "$(Get-Date) Instance $node1 is now ready" $creation_status = Get-GceInstance -zone $zone -Name $node2 -SerialPortOutput | Select-String -Pattern 'Instance setup finished' -Quiet $n = 20 while (!($creation_status)) { Write-Host "$(Get-Date) Waiting for instance $node2 to be ready" Start-Sleep -s 30 $n -= 1 if ($n -eq 0) {break} $creation_status = Get-GceInstance -zone $zone -Name $node2 -SerialPortOutput | Select-String -Pattern 'Instance setup finished' -Quiet } Write-Host "$(Get-Date) Instance $node2 is now ready" # Wait 2 minutes before trying to connect to make sure the server is ready for connections Start-Sleep -s 120 ################################################################################ # Generate a password for the account that will connect to the remote servers. # The 'gcloud' command apparently generates an error in Powershell but we ignore # it as it is not a real error. ################################################################################ $ErrorActionPreference = 'continue' # Generate the password for Node 1. We get a false error when running gcloud.exe # from PowerShell, so we ignore it. $results = $null $results = Invoke-Command -ScriptBlock { gcloud compute reset-windows-password $node1 --zone $zone ` --user $config_user --quiet 2> $null } | ConvertFrom-String # If the command fails, run it again but this time show the error message if ( !($lastexitcode -eq 0)) { Write-Host "An error ocurred when trying to generate a password for $node1" Invoke-Command -ScriptBlock { gcloud compute reset-windows-password $node1 --zone $zone ` --user $config_user --quiet } } # Grab the IP and Password from the results of the 'gcloud.exe' command $ip_address1 = $results.P2[0] $password1 = ConvertTo-SecureString -String $results.P2[1] -AsPlainText -Force # Generate the password for Node 2. We get a false error when running gcloud.exe # from PowerShell, so we ignore it. $results = $null $results = Invoke-Command -ScriptBlock { gcloud compute reset-windows-password $node2 --zone $zone ` --user $config_user --quiet 2> $null } | ConvertFrom-String # If the command fails, run it again but this time show the error message if ( !($lastexitcode -eq 0)) { Write-Host "An error ocurred when trying to generate a password for $node2" Invoke-Command -ScriptBlock { gcloud compute reset-windows-password $node2 --zone $zone ` --user $config_user --quiet } } # Grab the IP and Password from the results of the 'gcloud.exe' command $ip_address2 = $results.P2[0] $password2 = ConvertTo-SecureString -String $results.P2[1] -AsPlainText -Force $results = $null $ErrorActionPreference = 'stop' ################################################################################ # In the following sections we do remote connections to nodes to configure them ################################################################################ $session_options = New-PSSessionOption -SkipCACheck -SkipCNCheck ` -SkipRevocationCheck $credential1 = New-Object System.Management.Automation.PSCredential($config_user, $password1) $credential2 = New-Object System.Management.Automation.PSCredential($config_user, $password2) Write-Host " Using PowerShell remoting to connect to both nodes" Write-Host " If you get errors verify that WinRM port 5986 is open in Firewall" # Create a remote session in each server. Write-Host "$(Get-Date) Creating remote session to $node1 - $ip_address1" $session1 = New-PSSession -ComputerName $ip_address1 -UseSSL ` -Credential $credential1 -SessionOption $session_options Write-Host "$(Get-Date) Creating remote session to $node2 - $ip_address2" $session2 = New-PSSession -ComputerName $ip_address2 -UseSSL ` -Credential $credential2 -SessionOption $session_options # The following commands run in both nodes. # Change the DNS server to be the domain controller. # This makes it easier to add the nodes to the AD domain. Invoke-Command -Session $session1,$session2 -ScriptBlock { param($ip_domain_cntr) $hostname = [System.Net.Dns]::GetHostName() Write-Host "$(Get-Date) $hostname - Changing DNS to domain controller: $ip_domain_cntr" # Change the Dns server $adapter = Get-NetAdapter -Name Ethernet $adapter | Set-DnsClientServerAddress -ServerAddresses $ip_domain_cntr } -ArgumentList $ip_domain_cntr 2>&1 | Out-Null # The following commands run in both nodes. # They are needed in preparation to create the database. Invoke-Command -Session $session1,$session2 -ScriptBlock { param($sql_data, $sql_log) $hostname = [System.Net.Dns]::GetHostName() # Create directories for data and transaction log Write-Host "$(Get-Date) $hostname - Creating folders: $sql_data; $sql_log" if (!(Test-Path -Path $sql_data )) { New-item -ItemType Directory $sql_data | Out-Null } if (!(Test-Path -Path $sql_log )) { New-item -ItemType Directory $sql_log | Out-Null } } -ArgumentList $sql_data, $sql_log # The following commands run in node 1. # Create a directory to run a PowerShell script locally. Invoke-Command -Session $session1 -ScriptBlock { $hostname = [System.Net.Dns]::GetHostName() # Create directory C:\Scripts Write-Host "$(Get-Date) $hostname - Creating folder: C:\Scripts" if (!(Test-Path -Path "C:\Scripts" )) { New-item -ItemType Directory "C:\Scripts" | Out-Null } } # The following code will run in both nodes. # It has all other configurations that we want to do in the servers. Invoke-Command -Session $session1,$session2 -ScriptBlock { param($node1, $node2, $ip_domain_cntr, $domain, $cred, $sql_data, $sql_log) $hostname = [System.Net.Dns]::GetHostName() # Rename the SQL Server instance $query = "exec sp_dropserver @@servername; exec sp_addserver '$hostname', local" Invoke-Sqlcmd -Query $query # Open firewall ports. Display output as table to save vertical space in the output. Write-Host "$(Get-Date) $hostname - Open firewall ports in $hostname" New-NetFirewallRule -DisplayName 'Open Port 5022 for Availability Groups' ` -Direction Inbound -Protocol TCP -LocalPort 5022 -Action allow | SELECT DisplayName, Enabled, Direction, Action New-NetFirewallRule -DisplayName 'Open Port 1433 for SQL Server' ` -Direction Inbound -Protocol TCP -LocalPort 1433 -Action allow | SELECT DisplayName, Enabled, Direction, Action # Enable clustering Write-Host "$(Get-Date) $hostname - Enable clustering" Install-WindowsFeature Failover-Clustering -IncludeManagementTools | Format-Table | Out-String # Add computer to domain Write-Host "$(Get-Date) $hostname - Adding the server to the Domain: $domain" Add-Computer -DomainName $domain -Credential $cred -ComputerName $hostname -Restart -Force Write-Host "$(Get-Date) Restarting the server $hostname" } -ArgumentList $node1, $node2, $ip_domain_cntr, $domain, $cred, $sql_data, $sql_log # Close the remote sessions. They need to be re-created after the restarts. Remove-PSSession $session1 Remove-Variable session1 Remove-PSSession $session2 Remove-Variable session2 ################################################################################## # Wait until the instances are restarted after joining them to the domain # Note: We wait until the text 'Starting shutdown scripts' shows in serial console # If this text changes in the future we need to modify this script ################################################################################## $creation_status = Get-GceInstance -zone $zone -Name $node1 -SerialPortOutput | Select-String -Pattern 'Starting shutdown scripts' -Quiet $n = 20 while (!($creation_status)) { Write-Host "$(Get-Date) Waiting for instance $node1 to restart" Start-Sleep -s 30 $n -= 1 if ($n -eq 0) {break} $creation_status = Get-GceInstance -zone $zone -Name $node1 -SerialPortOutput | Select-String -Pattern 'Starting shutdown scripts' -Quiet } $creation_status = Get-GceInstance -zone $zone -Name $node2 -SerialPortOutput | Select-String -Pattern 'Starting shutdown scripts' -Quiet $n = 20 while (!($creation_status)) { Write-Host "$(Get-Date) Waiting for instance $node2 to restart" Start-Sleep -s 30 $n -= 1 if ($n -eq 0) {break} $creation_status = Get-GceInstance -zone $zone -Name $node2 -SerialPortOutput | Select-String -Pattern 'Starting shutdown scripts' -Quiet } # Wait 2 minutes to make sure all services have started Start-Sleep -s 120 Write-Host "$(Get-Date) Ready now to create a Windows Failover Cluster (WSFC)" Write-Host " $node1 - $ip_address1" Write-Host " $node2 - $ip_address2" # Create a remote session only to Node 1 # If we are running the script from a computer in the same domain, we need to specify the FQDN # Note: If run from a computer in a different domain you may still get an error # In that case just run create-availability-group.ps1 manually from one of the nodes Write-Host "$(Get-Date) Create a remote session to each server again" if ( (gwmi win32_computersystem).Domain.ToLower() -eq $domain.ToLower() ) { $fqdn1="$node1.$env:USERDNSDOMAIN".ToLower() Write-Host "$(Get-Date) Creating remote session to $fqdn1" $session1 = New-PSSession -ComputerName $fqdn1 -UseSSL -SessionOption $session_options } else { Write-Host "$(Get-Date) Creating remote session to $node1 - $ip_address1" $session1 = New-PSSession -ComputerName $ip_address1 -UseSSL ` -Credential $cred -SessionOption $session_options } ################################################################################## # Create a Windows Failover Cluster (WSFC) and Availability Group ################################################################################## # Copy the files create-availability-group.ps1 and parameters-config.ps1 to Node 1 Write-Host "$(Get-Date) Copy PowerShell scripts to $node1" Copy-Item ".\create-availability-group.ps1" ` -Destination "C:\Scripts\" ` -ToSession $session1 Copy-Item ".\parameters-config.ps1" ` -Destination "C:\Scripts\" ` -ToSession $session1 # Do a remote session to Node 1 to create a scheduled job to setup the cluster. # Seting up the cluster requires connecting to more than one server which creates # a double-hop issue. We create a scheduled job to setup the cluster outside of # our WinRM connection to avoid this issue. Invoke-Command -Session $session1 -ScriptBlock { param($cred) $hostname = [System.Net.Dns]::GetHostName() Write-Host "$(Get-Date) $hostname - Creating scheduled job to create Availability Group" -BackgroundColor Green -ForegroundColor Yellow # Delete job if it exists Get-ScheduledJob | Where Name -eq 'Create-Availability-Group' | Unregister-ScheduledJob -Confirm:$false # Create a scheduled job to create the cluster and AG # Just for this session bypass the execution policy to allow this Powershell script to run Register-ScheduledJob -Name Create-Availability-Group ` -ScriptBlock { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force; C:\Scripts\create-availability-group.ps1 -Verbose *> 'C:\Scripts\create-availability-group.log' } ` -Credential $Cred -RunNow # Wait 30 seconds before checking if the job is running Start-Sleep -s 30 # Wait until the scheduled job finish $status = Get-Job | Where-Object { ($_.Name -eq 'Create-Availability-Group') -and ($_.State -eq 'Running') } | SELECT State $n = 40 while ($status) { Write-Host "$(Get-Date) $hostname - Waiting for Availability Group to be ready" Start-Sleep -s 30 $n -= 1 if ($n -eq 0) {break} $status = Get-Job | Where-Object { ($_.Name -eq 'Create-Availability-Group') -and ($_.State -eq 'Running') } | SELECT State } Write-Host "$(Get-Date) $hostname - Scheduled job to create Availability Group finished" # Display the contents of the log file created by the scheduled job Write-Host "$(Get-Date) $hostname - Displaying the content of scheduled job log file" -BackgroundColor Green -ForegroundColor Yellow Get-Content -Path 'C:\Scripts\create-availability-group.log' Write-Host "$(Get-Date) $hostname - End of log file" -BackgroundColor Green -ForegroundColor Yellow # Delete job if it exists # Get-ScheduledJob | Where Name -eq 'Create-Availability-Group' | Unregister-ScheduledJob -Confirm:$false } -ArgumentList $cred # Verify that the AG was created. This only works if ran from a local node. # Left commented as an example for reference #Invoke-Command -Session $session1 -ScriptBlock { # param($node1, $name_ag) # Verify if the AG exists # Import-Module SQLPS -DisableNameChecking # if (Test-Path -Path "SQLSERVER:\SQL\$($node1)\DEFAULT\AvailabilityGroups\$($name_ag)") { # Write-Host "$(Get-Date) Verified that the Availability Group exists" # } else { # Throw "Unable to verify that the Availability Group exists" # } #} -ArgumentList $node1, $name_ag # Close the remote sessions Remove-PSSession $session1 Remove-Variable session1 Write-Host "$(Get-Date) End of create-sql-instance-availability-group.ps1" # Get-PSSession