############################################################################### #### windows-sql-smoke-tests.ps1 — Verify SQL Server health #### #### Checks services, TCP/IP, auth, database states, AG sync, #### #### backup age, TempDB, error log, memory, and disk space. #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: .\windows-sql-smoke-tests.ps1 #### #### .\windows-sql-smoke-tests.ps1 -SqlInstance SERVER01\PROD #### #### .\windows-sql-smoke-tests.ps1 -OutputFormat tap #### #### #### #### See -Help for all options. #### ############################################################################### [CmdletBinding()] param( [string]$SqlInstance = "localhost", [int]$SqlPort = 1433, [int]$MaxFullBackupAgeHours = 25, [int]$MaxLogBackupAgeHours = 1, [ValidateSet("text","tap")] [string]$OutputFormat = "text", [switch]$NoColor, [switch]$Help ) $ErrorActionPreference = "Continue" # ============================================================================ # HELP # ============================================================================ if ($Help) { @" Usage: .\windows-sql-smoke-tests.ps1 [OPTIONS] Smoke-test SQL Server health. PowerShell 5.1+. Uses Windows authentication. Prefers Invoke-Sqlcmd, falls back to SqlClient. Parameters: -SqlInstance INSTANCE SQL instance (default: localhost). Use SERVER\INSTANCE for named. -SqlPort PORT TCP port (default: 1433) -MaxFullBackupAgeHours N Max hours since last full backup (default: 25) -MaxLogBackupAgeHours N Max hours since last log backup (default: 1) -OutputFormat FORMAT Output: text (default), tap -NoColor Disable coloured output -Verbose Show debug output -Help Show this help Examples: .\windows-sql-smoke-tests.ps1 .\windows-sql-smoke-tests.ps1 -SqlInstance "SERVER01\SQLPROD" .\windows-sql-smoke-tests.ps1 -MaxFullBackupAgeHours 48 -OutputFormat tap .\windows-sql-smoke-tests.ps1 -NoColor "@ exit 0 } # ============================================================================ # STATE # ============================================================================ $script:Pass = 0 $script:Fail = 0 $script:Skip = 0 $script:Total = 0 $script:Results = @() $script:StartTime = Get-Date $script:SqlConn = $null $script:UseSqlcmd = $false # ============================================================================ # COLORS # ============================================================================ function Write-Color { param([string]$Text, [string]$Color = "White") if ($NoColor) { Write-Host $Text } else { Write-Host $Text -ForegroundColor $Color } } function Write-Log { param([string]$Msg) Write-Color "[INFO] $Msg" "Cyan" } function Write-Warn { param([string]$Msg) Write-Color "[WARN] $Msg" "Yellow" } function Write-Err { param([string]$Msg) Write-Color "[ERROR] $Msg" "Red" } # ============================================================================ # TEST RESULT RECORDING # ============================================================================ function Record-Pass { param([string]$Name, [string]$Detail = "") $script:Pass++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="PASS"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name$(if($Detail){" ($Detail)"})" } else { $mark = if ($NoColor) { "[PASS]" } else { [char]0x2713 } $msg = " $mark $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Green" } } function Record-Fail { param([string]$Name, [string]$Detail = "") $script:Fail++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="FAIL"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "not ok $($script:Total) - $Name" if ($Detail) { Write-Host " # $Detail" } } else { $mark = if ($NoColor) { "[FAIL]" } else { [char]0x2717 } $msg = " $mark $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Red" } } function Record-Skip { param([string]$Name, [string]$Reason = "") $script:Skip++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="SKIP"; Name=$Name; Detail=$Reason } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name # SKIP $Reason" } else { $mark = if ($NoColor) { "[SKIP]" } else { [char]0x2298 } $msg = " $mark $Name" if ($Reason) { $msg += " - $Reason" } Write-Color $msg "Yellow" } } # ============================================================================ # HELPERS # ============================================================================ function Test-CommandExists { param([string]$Command) $null -ne (Get-Command $Command -ErrorAction SilentlyContinue) } function Write-Section { param([string]$Name) if ($OutputFormat -eq "text") { Write-Host "" Write-Color $Name "White" } } function Get-ServiceName { if ($SqlInstance -eq "localhost" -or $SqlInstance -notmatch "\\") { return @{ Engine = "MSSQLSERVER"; Agent = "SQLSERVERAGENT" } } $instanceName = ($SqlInstance -split "\\")[1] return @{ Engine = "MSSQL`$$instanceName"; Agent = "SQLAgent`$$instanceName" } } function Get-ConnectionString { $server = $SqlInstance if ($SqlPort -ne 1433 -and $SqlInstance -notmatch ",") { $host_ = if ($SqlInstance -match "\\") { ($SqlInstance -split "\\")[0] } else { $SqlInstance } $server = "$host_,$SqlPort" if ($SqlInstance -match "\\") { $server = "$host_\$(($SqlInstance -split '\\')[1]),$SqlPort" } } return "Server=$server;Integrated Security=True;Connection Timeout=10" } function Invoke-SqlQuery { param([string]$Query) if ($script:UseSqlcmd) { try { $params = @{ ServerInstance = $SqlInstance Query = $Query ErrorAction = "Stop" QueryTimeout = 30 } return Invoke-Sqlcmd @params } catch { Write-Verbose "Invoke-Sqlcmd failed: $($_.Exception.Message)" return $null } } try { $connStr = Get-ConnectionString $conn = New-Object System.Data.SqlClient.SqlConnection($connStr) $conn.Open() $cmd = $conn.CreateCommand() $cmd.CommandText = $Query $cmd.CommandTimeout = 30 $adapter = New-Object System.Data.SqlClient.SqlDataAdapter($cmd) $dataset = New-Object System.Data.DataSet $adapter.Fill($dataset) | Out-Null $conn.Close() return $dataset.Tables[0] } catch { Write-Verbose "SqlClient failed: $($_.Exception.Message)" return $null } } # ============================================================================ # TESTS # ============================================================================ # -- 1. SQL Server Service --------------------------------------------------- function Test-SqlService { Write-Section "Services" $svcNames = Get-ServiceName try { $svc = Get-Service -Name $svcNames.Engine -ErrorAction Stop if ($svc.Status -eq "Running") { Record-Pass "SQL Server service running" $svcNames.Engine } else { Record-Fail "SQL Server service running" "$($svcNames.Engine) status: $($svc.Status)" } } catch { Record-Fail "SQL Server service running" "$($svcNames.Engine) - service not found" } } # -- 2. SQL Server Agent Service --------------------------------------------- function Test-AgentService { $svcNames = Get-ServiceName try { $svc = Get-Service -Name $svcNames.Agent -ErrorAction Stop if ($svc.Status -eq "Running") { Record-Pass "SQL Server Agent running" $svcNames.Agent } else { Record-Fail "SQL Server Agent running" "$($svcNames.Agent) status: $($svc.Status)" } } catch { Record-Skip "SQL Server Agent running" "Agent service not found" } } # -- 3. TCP/IP Connectivity ------------------------------------------------- function Test-TcpConnectivity { Write-Section "Connectivity" $host_ = if ($SqlInstance -match "\\") { ($SqlInstance -split "\\")[0] } else { $SqlInstance } if ($host_ -eq "localhost" -or $host_ -eq "." -or $host_ -eq "(local)") { $host_ = "127.0.0.1" } try { $tcp = Test-NetConnection -ComputerName $host_ -Port $SqlPort -WarningAction SilentlyContinue -ErrorAction Stop if ($tcp.TcpTestSucceeded) { Record-Pass "TCP/IP connectivity" "${host_}:${SqlPort}" } else { Record-Fail "TCP/IP connectivity" "${host_}:${SqlPort} - port closed" } } catch { Record-Fail "TCP/IP connectivity" "${host_}:${SqlPort} - $($_.Exception.Message)" } } # -- 4. SQL Authentication -------------------------------------------------- function Test-SqlAuth { $result = Invoke-SqlQuery "SELECT 1 AS TestResult" if ($result) { Record-Pass "SQL authentication" "SELECT 1 succeeded" return $true } else { Record-Fail "SQL authentication" "could not connect or execute query" return $false } } # -- 5. Database States ------------------------------------------------------ function Test-DatabaseStates { Write-Section "Databases" $result = Invoke-SqlQuery @" SELECT name, state_desc FROM sys.databases WHERE database_id > 4 ORDER BY name "@ if (-not $result) { Record-Skip "database states" "could not query sys.databases" return } $rows = @($result) $offline = @($rows | Where-Object { $_.state_desc -ne "ONLINE" }) if ($offline.Count -eq 0) { Record-Pass "database states" "$($rows.Count) databases ONLINE" } else { $offList = ($offline | ForEach-Object { "$($_.name)=$($_.state_desc)" }) -join ", " Record-Fail "database states" "$($offline.Count) not ONLINE: $offList" } } # -- 6. Availability Group Sync Health -------------------------------------- function Test-AGSyncHealth { Write-Section "Availability Groups" $result = Invoke-SqlQuery @" SELECT ag.name AS ag_name, ags.synchronization_health_desc FROM sys.availability_groups ag JOIN sys.dm_hadr_availability_group_states ags ON ag.group_id = ags.group_id "@ if (-not $result -or @($result).Count -eq 0) { Record-Skip "AG sync health" "no Availability Groups configured" return } foreach ($row in @($result)) { $health = $row.synchronization_health_desc if ($health -eq "HEALTHY") { Record-Pass "AG sync health" "$($row.ag_name) $health" } else { Record-Fail "AG sync health" "$($row.ag_name) $health" } } } # -- 7. AG Replica Health ---------------------------------------------------- function Test-AGReplicaHealth { $result = Invoke-SqlQuery @" SELECT ar.replica_server_name, ars.synchronization_state_desc, ars.connected_state_desc FROM sys.availability_replicas ar JOIN sys.dm_hadr_availability_replica_states ars ON ar.replica_id = ars.replica_id "@ if (-not $result -or @($result).Count -eq 0) { Record-Skip "AG replica health" "no Availability Groups configured" return } $rows = @($result) $unhealthy = @($rows | Where-Object { $_.synchronization_state_desc -notin @("SYNCHRONIZED","SYNCHRONIZING") -or $_.connected_state_desc -ne "CONNECTED" }) if ($unhealthy.Count -eq 0) { Record-Pass "AG replica health" "$($rows.Count) replicas healthy" } else { $list = ($unhealthy | ForEach-Object { "$($_.replica_server_name)=$($_.synchronization_state_desc)/$($_.connected_state_desc)" }) -join ", " Record-Fail "AG replica health" "$($unhealthy.Count) unhealthy: $list" } } # -- 8. AG Listener Connectivity -------------------------------------------- function Test-AGListener { $result = Invoke-SqlQuery @" SELECT dns_name, port FROM sys.availability_group_listeners "@ if (-not $result -or @($result).Count -eq 0) { Record-Skip "AG listener" "no AG listener configured" return } foreach ($row in @($result)) { try { $tcp = Test-NetConnection -ComputerName $row.dns_name -Port $row.port -WarningAction SilentlyContinue -ErrorAction Stop if ($tcp.TcpTestSucceeded) { Record-Pass "AG listener" "$($row.dns_name):$($row.port)" } else { Record-Fail "AG listener" "$($row.dns_name):$($row.port) - unreachable" } } catch { Record-Fail "AG listener" "$($row.dns_name):$($row.port) - $($_.Exception.Message)" } } } # -- 9. Full Backup Age ----------------------------------------------------- function Test-FullBackupAge { Write-Section "Backups" $result = Invoke-SqlQuery @" SELECT d.name, MAX(b.backup_finish_date) AS last_full FROM sys.databases d LEFT JOIN msdb.dbo.backupset b ON d.name = b.database_name AND b.type = 'D' WHERE d.database_id > 4 AND d.state = 0 GROUP BY d.name ORDER BY d.name "@ if (-not $result) { Record-Skip "full backup age" "could not query backup history" return } $now = Get-Date $failures = @() foreach ($row in @($result)) { if (-not $row.last_full -or $row.last_full -eq [DBNull]::Value) { $failures += "$($row.name)=NEVER" continue } $age = ($now - [datetime]$row.last_full).TotalHours if ($age -gt $MaxFullBackupAgeHours) { $failures += "$($row.name)=$([math]::Floor($age))h ago" } } if ($failures.Count -eq 0) { Record-Pass "full backup age" "all databases within ${MaxFullBackupAgeHours}h threshold" } else { Record-Fail "full backup age" ($failures -join ", ") } } # -- 10. Log Backup Age ---------------------------------------------------- function Test-LogBackupAge { $result = Invoke-SqlQuery @" SELECT d.name, d.recovery_model_desc, MAX(b.backup_finish_date) AS last_log FROM sys.databases d LEFT JOIN msdb.dbo.backupset b ON d.name = b.database_name AND b.type = 'L' WHERE d.database_id > 4 AND d.state = 0 AND d.recovery_model_desc IN ('FULL','BULK_LOGGED') GROUP BY d.name, d.recovery_model_desc ORDER BY d.name "@ if (-not $result -or @($result).Count -eq 0) { Record-Skip "log backup age" "no FULL/BULK_LOGGED recovery model databases" return } $now = Get-Date $failures = @() foreach ($row in @($result)) { if (-not $row.last_log -or $row.last_log -eq [DBNull]::Value) { $failures += "$($row.name)=NEVER" continue } $age = ($now - [datetime]$row.last_log).TotalHours if ($age -gt $MaxLogBackupAgeHours) { $ageMin = [math]::Floor(($now - [datetime]$row.last_log).TotalMinutes) $failures += "$($row.name)=${ageMin}m ago" } } if ($failures.Count -eq 0) { Record-Pass "log backup age" "all databases within ${MaxLogBackupAgeHours}h threshold" } else { Record-Fail "log backup age" ($failures -join ", ") } } # -- 11. TempDB Configuration ----------------------------------------------- function Test-TempDBConfig { Write-Section "TempDB" $result = Invoke-SqlQuery @" SELECT (SELECT COUNT(*) FROM tempdb.sys.database_files WHERE type = 0) AS file_count, (SELECT cpu_count FROM sys.dm_os_sys_info) AS cpu_count "@ if (-not $result) { Record-Skip "TempDB configuration" "could not query TempDB info" return } $row = @($result)[0] $files = [int]$row.file_count $cpus = [int]$row.cpu_count $recommended = [math]::Min($cpus, 8) if ($files -ge $recommended) { Record-Pass "TempDB file count" "$files files, $cpus cores" } else { Record-Fail "TempDB file count" "$files files but $cpus cores (recommend $recommended)" } } # -- 12. TempDB Free Space ------------------------------------------------- function Test-TempDBSpace { $result = Invoke-SqlQuery @" SELECT SUM(unallocated_extent_page_count) * 8.0 / 1024 AS free_mb FROM tempdb.sys.dm_db_file_space_usage "@ if (-not $result) { Record-Skip "TempDB free space" "could not query TempDB space" return } $freeMB = [math]::Round([double]@($result)[0].free_mb, 1) $freeGB = [math]::Round($freeMB / 1024, 2) if ($freeMB -gt 100) { Record-Pass "TempDB free space" "${freeGB} GB free" } else { Record-Fail "TempDB free space" "${freeMB} MB free — low" } } # -- 13. Error Log ----------------------------------------------------------- function Test-ErrorLog { Write-Section "Error Log" $cutoff = (Get-Date).AddHours(-24).ToString("yyyy-MM-ddTHH:mm:ss") $result = Invoke-SqlQuery @" EXEC xp_readerrorlog 0, 1, NULL, NULL, '$cutoff', NULL "@ if (-not $result) { Record-Pass "error log" "no entries or xp_readerrorlog not accessible" return } $severe = @($result | Where-Object { $text = if ($_.Text) { $_.Text } elseif ($_.LogText) { $_.LogText } else { "" } $text -match "Severity:\s*(1[7-9]|2[0-5])" }) if ($severe.Count -eq 0) { Record-Pass "error log clean" "0 severity 17+ in last 24h" } else { $sample = $severe[0] $text = if ($sample.Text) { $sample.Text } elseif ($sample.LogText) { $sample.LogText } else { "unknown" } $short = $text.Substring(0, [math]::Min(80, $text.Length)) Record-Fail "error log" "$($severe.Count) severity 17+ in 24h — latest: $short" } } # -- 14. Max Memory Configuration ------------------------------------------- function Test-MaxMemory { Write-Section "Configuration" $result = Invoke-SqlQuery @" SELECT value_in_use FROM sys.configurations WHERE name = 'max server memory (MB)' "@ if (-not $result) { Record-Skip "max memory" "could not query sys.configurations" return } $maxMB = [int64]@($result)[0].value_in_use if ($maxMB -ge 2147483647) { Record-Fail "max memory configured" "still default (2 TB) — set an explicit limit" } else { Record-Pass "max memory configured" "$maxMB MB" } } # -- 15. Disk Space ---------------------------------------------------------- function Test-DiskSpace { Write-Section "Disk Space" $result = Invoke-SqlQuery @" SELECT DISTINCT LEFT(physical_name, 3) AS drive FROM sys.master_files WHERE database_id > 0 "@ if (-not $result) { Record-Skip "disk space" "could not query sys.master_files" return } $drives = @($result) | ForEach-Object { $_.drive.TrimEnd("\") } $driveInfo = @() $allOk = $true foreach ($d in ($drives | Sort-Object -Unique)) { try { $vol = Get-PSDrive -Name $d.TrimEnd(":") -ErrorAction Stop $freeGB = [math]::Round($vol.Free / 1GB, 1) $usedGB = [math]::Round($vol.Used / 1GB, 1) $totalGB = $freeGB + $usedGB $pctFree = if ($totalGB -gt 0) { [math]::Round(($freeGB / $totalGB) * 100, 0) } else { 0 } $driveInfo += "${d}: ${pctFree}% free (${freeGB} GB)" if ($pctFree -lt 10) { $allOk = $false } } catch { $driveInfo += "${d}: unknown" } } $detail = $driveInfo -join ", " if ($allOk) { Record-Pass "disk space" $detail } else { Record-Fail "disk space" "$detail — drive below 10% free" } } # ============================================================================ # OUTPUT # ============================================================================ function Write-Header { if ($OutputFormat -eq "tap") { Write-Host "TAP version 13" } else { Write-Host "" Write-Color "Windows SQL Smoke Tests" "White" Write-Host "Instance: $SqlInstance" Write-Host "Port: $SqlPort" Write-Host "Time: $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')" } } function Write-Summary { $duration = [math]::Floor(((Get-Date) - $script:StartTime).TotalSeconds) if ($OutputFormat -eq "tap") { Write-Host "1..$($script:Total)" Write-Host "# pass $($script:Pass)" Write-Host "# fail $($script:Fail)" Write-Host "# skip $($script:Skip)" } else { Write-Host "" $separator = [string]::new([char]0x2500, 40) Write-Color $separator "White" Write-Color "Summary $SqlInstance" "White" $summaryLine = " $($script:Pass) passed $($script:Fail) failed $($script:Skip) skipped (${duration}s)" Write-Host $summaryLine Write-Color $separator "White" if ($script:Fail -eq 0) { Write-Color "All tests passed." "Green" } else { Write-Color "$($script:Fail) test(s) failed." "Red" } } } # ============================================================================ # MAIN # ============================================================================ Write-Header # Detect SQL connectivity method try { Import-Module SqlServer -ErrorAction Stop $script:UseSqlcmd = $true Write-Verbose "Using SqlServer module (Invoke-Sqlcmd)" } catch { try { Import-Module SQLPS -DisableNameChecking -ErrorAction Stop $script:UseSqlcmd = $true Write-Verbose "Using SQLPS module (Invoke-Sqlcmd)" } catch { $script:UseSqlcmd = $false Write-Verbose "Using System.Data.SqlClient (no SQL module)" } } # Run all tests Test-SqlService Test-AgentService Test-TcpConnectivity $authOk = Test-SqlAuth if ($authOk) { Test-DatabaseStates Test-AGSyncHealth Test-AGReplicaHealth Test-AGListener Test-FullBackupAge Test-LogBackupAge Test-TempDBConfig Test-TempDBSpace Test-ErrorLog Test-MaxMemory Test-DiskSpace } else { Write-Warn "Skipping SQL query tests — authentication failed" Record-Skip "database states" "auth failed" Record-Skip "AG sync health" "auth failed" Record-Skip "AG replica health" "auth failed" Record-Skip "AG listener" "auth failed" Record-Skip "full backup age" "auth failed" Record-Skip "log backup age" "auth failed" Record-Skip "TempDB configuration" "auth failed" Record-Skip "TempDB free space" "auth failed" Record-Skip "error log" "auth failed" Record-Skip "max memory" "auth failed" Record-Skip "disk space" "auth failed" } Write-Summary if ($script:Fail -eq 0) { exit 0 } else { exit 1 }