Mass audit — everything that doesn't fail over
One read-only script, run on the new primary after failover (or on the DR replica before the exercise). Each section returns a labeled result set — empty sections are good news. Compare CONFIG / LINKED_SERVER / CREDENTIAL sections against the same run on prod.
/* =====================================================
HADR DR AUDIT — run on the NEW PRIMARY
Read-only. Each section returns a labeled result set.
===================================================== */
SET NOCOUNT ON;
/* 1 · Replica + database sync state */
SELECT 'AG_STATE' AS chk, ag.name AS ag,
ar.replica_server_name AS replica, ars.role_desc AS role_now,
DB_NAME(drs.database_id) AS db,
drs.synchronization_state_desc AS sync_state,
drs.suspend_reason_desc AS suspend_reason,
drs.log_send_queue_size AS send_kb,
drs.redo_queue_size AS redo_kb
FROM sys.dm_hadr_database_replica_states drs
JOIN sys.availability_replicas ar ON drs.replica_id = ar.replica_id
JOIN sys.availability_groups ag ON drs.group_id = ag.group_id
JOIN sys.dm_hadr_availability_replica_states ars
ON drs.replica_id = ars.replica_id;
/* 2 · Orphaned users in every writable user db */
DECLARE @sql nvarchar(max) = N'';
SELECT @sql += N'USE ' + QUOTENAME(name) + N';
SELECT ''ORPHANED_USER'' AS chk, DB_NAME() AS db,
dp.name AS db_user, dp.type_desc
FROM sys.database_principals dp
LEFT JOIN sys.server_principals sp ON dp.sid = sp.sid
WHERE sp.sid IS NULL AND dp.type IN (''S'',''U'',''G'')
AND dp.name NOT IN
(''guest'',''dbo'',''sys'',''INFORMATION_SCHEMA'');
'
FROM sys.databases
WHERE database_id > 4 AND state_desc = 'ONLINE'
AND DATABASEPROPERTYEX(name,'Updateability') = 'READ_WRITE';
EXEC sys.sp_executesql @sql;
/* 3 · Log truncation blockers */
SELECT 'LOG_REUSE' AS chk, name AS db,
log_reuse_wait_desc, recovery_model_desc
FROM sys.databases
WHERE database_id > 4
AND log_reuse_wait_desc <> 'NOTHING';
/* 4 · Backup gaps (FULL-recovery dbs: log 1h+, full 7d+) */
SELECT 'BACKUP_GAP' AS chk, d.name AS db,
MAX(CASE WHEN b.type='D' THEN b.backup_finish_date END) AS last_full,
MAX(CASE WHEN b.type='L' THEN b.backup_finish_date END) AS last_log
FROM sys.databases d
LEFT JOIN msdb.dbo.backupset b ON b.database_name = d.name
WHERE d.database_id > 4 AND d.recovery_model_desc = 'FULL'
GROUP BY d.name
HAVING MAX(CASE WHEN b.type='L' THEN b.backup_finish_date END)
< DATEADD(HOUR,-1,GETDATE())
OR MAX(CASE WHEN b.type='D' THEN b.backup_finish_date END)
< DATEADD(DAY,-7,GETDATE())
OR MAX(b.backup_finish_date) IS NULL;
/* 5 · Services + disabled Agent jobs */
SELECT 'SERVICE' AS chk, servicename, status_desc, startup_type_desc
FROM sys.dm_server_services;
SELECT 'JOB_DISABLED' AS chk, name AS job
FROM msdb.dbo.sysjobs WHERE enabled = 0;
/* 6 · Backup preference — do backups run HERE now? */
SELECT 'BACKUP_PREF' AS chk, ag.name AS ag,
ag.automated_backup_preference_desc,
adc.database_name,
sys.fn_hadr_backup_is_preferred_replica(adc.database_name)
AS backs_up_here
FROM sys.availability_groups ag
JOIN sys.availability_databases_cluster adc
ON ag.group_id = adc.group_id;
/* 7 · TDE state + certificates present on this server */
SELECT 'TDE_DB' AS chk, DB_NAME(database_id) AS db,
CASE encryption_state WHEN 3 THEN 'ENCRYPTED'
WHEN 2 THEN 'IN PROGRESS' WHEN 1 THEN 'UNENCRYPTED'
ELSE CONVERT(varchar(10),encryption_state) END AS tde_state
FROM sys.dm_database_encryption_keys;
SELECT 'CERT' AS chk, name, expiry_date
FROM master.sys.certificates WHERE name NOT LIKE '##%';
/* 8 · Server-level objects apps depend on */
SELECT 'LINKED_SERVER' AS chk, name, data_source
FROM sys.servers WHERE is_linked = 1;
SELECT 'CREDENTIAL' AS chk, name, credential_identity
FROM sys.credentials;
SELECT 'AGENT_PROXY' AS chk, name FROM msdb.dbo.sysproxies;
/* 9 · Config fingerprint — diff by eye vs prod */
SELECT 'CONFIG' AS chk, name, value_in_use
FROM sys.configurations
WHERE name IN ('max server memory (MB)',
'max degree of parallelism','cost threshold for parallelism',
'optimize for ad hoc workloads','backup compression default',
'remote admin connections');
Login transfer — generate CREATE LOGIN scripts
Run on the prod primary. Output = ready-to-run statements for the DR replica. SQL logins carry their original SID + password hash, so db users bind automatically — no orphans, no password resets. Review output before running (skip accounts that shouldn't exist in DR).
/* SQL logins — same SID + hashed password */
SELECT 'CREATE LOGIN ' + QUOTENAME(p.name)
+ ' WITH PASSWORD = '
+ CONVERT(varchar(512), l.password_hash, 1) + ' HASHED'
+ ', SID = ' + CONVERT(varchar(100), p.sid, 1)
+ ', DEFAULT_DATABASE = ' + QUOTENAME(p.default_database_name)
+ ', CHECK_POLICY = '
+ CASE WHEN l.is_policy_checked = 1 THEN 'ON' ELSE 'OFF' END
+ ', CHECK_EXPIRATION = '
+ CASE WHEN l.is_expiration_checked = 1 THEN 'ON' ELSE 'OFF' END
+ ';' AS stmt
FROM sys.server_principals p
JOIN sys.sql_logins l ON p.principal_id = l.principal_id
WHERE p.type = 'S' AND p.name NOT LIKE '##%' AND p.name <> 'sa'
UNION ALL
/* Windows logins / groups */
SELECT 'CREATE LOGIN ' + QUOTENAME(name)
+ ' FROM WINDOWS WITH DEFAULT_DATABASE = '
+ QUOTENAME(default_database_name) + ';'
FROM sys.server_principals
WHERE type IN ('U','G') AND name NOT LIKE 'NT %';
/* Server role memberships */
SELECT 'ALTER SERVER ROLE ' + QUOTENAME(r.name)
+ ' ADD MEMBER ' + QUOTENAME(m.name) + ';' AS stmt
FROM sys.server_role_members rm
JOIN sys.server_principals r
ON rm.role_principal_id = r.principal_id
JOIN sys.server_principals m
ON rm.member_principal_id = m.principal_id
WHERE m.name NOT LIKE '##%' AND m.name <> 'sa';
/* Explicit server-level grants — REVIEW before running */
SELECT 'GRANT ' + sp.permission_name
+ ' TO ' + QUOTENAME(p.name) + ';' AS stmt
FROM sys.server_permissions sp
JOIN sys.server_principals p
ON sp.grantee_principal_id = p.principal_id
WHERE sp.state = 'G' AND sp.class = 100
AND p.name NOT LIKE '##%'
AND p.name NOT IN ('sa','public');
Rebuild linked servers on DR
Run on prod: first the inventory (what exists + how logins map), then the generator. Remote passwords can never be scripted out — fill those from your vault.
/* inventory — servers + login mappings */
SELECT s.name, s.data_source, s.provider, s.product,
ll.uses_self_credential AS self_map,
ll.remote_name AS remote_login,
sp.name AS for_local_login
FROM sys.servers s
LEFT JOIN sys.linked_logins ll ON s.server_id = ll.server_id
LEFT JOIN sys.server_principals sp
ON ll.local_principal_id = sp.principal_id
WHERE s.is_linked = 1;
/* generator — recreate on DR */
SELECT 'EXEC sp_addlinkedserver @server=N''' + name
+ ''', @srvproduct=N''' + ISNULL(product,'')
+ ''', @provider=N''' + provider
+ ''', @datasrc=N''' + ISNULL(data_source,'') + ''';' AS stmt
FROM sys.servers WHERE is_linked = 1;
/* then per server — map the login (password from vault): */
-- EXEC sp_addlinkedsrvlogin @rmtsrvname = N'LINKEDNAME',
-- @useself = 'FALSE', @locallogin = NULL,
-- @rmtuser = N'remote_user', @rmtpassword = '<vault>';
/* verify from DR: */
-- EXEC sp_testlinkedserver N'LINKEDNAME';
DR gotcha: even recreated perfectly, a linked server pointing at a prod-side hostname is dead if that site is down — check whether it should point at the target's own DR partner instead.
Rebuild credentials & Agent proxies on DR
/* generator — secrets can't be extracted, pull from vault */
SELECT 'CREATE CREDENTIAL ' + QUOTENAME(name)
+ ' WITH IDENTITY = N''' + credential_identity
+ ''', SECRET = N''<vault>'';' AS stmt
FROM sys.credentials;
/* proxies that ride on those credentials */
SELECT 'EXEC msdb.dbo.sp_add_proxy @proxy_name=N''' + p.name
+ ''', @credential_name=N''' + c.name
+ ''', @enabled=1;' AS stmt
FROM msdb.dbo.sysproxies p
JOIN sys.credentials c ON p.credential_id = c.credential_id;
/* which subsystems each proxy is granted (redo on DR) */
SELECT p.name AS proxy, s.subsystem
FROM msdb.dbo.sysproxysubsystem ps
JOIN msdb.dbo.sysproxies p ON ps.proxy_id = p.proxy_id
JOIN msdb.dbo.syssubsystems s
ON ps.subsystem_id = s.subsystem_id;
Server-level permissions: the grants generator is in the login-transfer section above. Easy button when tooling is allowed: Copy-DbaLogin -Source PROD -Destination DR (dbatools) moves logins, SIDs, passwords, and roles in one shot. Agent jobs: Copy-DbaAgentJob.
Fleet sweep — PowerShell mass-runner
Runs every section above against a whole server list via Invoke-Sqlcmd — one CSV per section plus summary.csv, findings table at the end, connect failures flagged per server. Read-only. Copy the script below and save it as Invoke-HadrAudit.ps1.
# usage
.\Invoke-HadrAudit.ps1 -Servers 'DRSQL01','DRSQL02\INST1'
.\Invoke-HadrAudit.ps1 -ServerList .\servers.txt -OutDir C:\Temp\hadr-audit
.\Invoke-HadrAudit.ps1 -Servers DRSQL01 -SqlCredential (Get-Credential)
# servers.txt = one instance per line, # comments allowed
<#
.SYNOPSIS
HADR DR fleet audit — runs the read-only "things that don't fail over" checks
against a list of SQL Server instances and writes per-section CSVs plus a
findings summary. Companion to the SQL HADR Triage page (Detect tab).
.DESCRIPTION
Read-only. Requires Invoke-Sqlcmd (SqlServer module, or legacy SQLPS).
Windows auth by default; pass -SqlCredential for SQL auth.
Works on Windows PowerShell 5.1 and PowerShell 7+.
.EXAMPLE
.\Invoke-HadrAudit.ps1 -Servers 'DRSQL01','DRSQL02\INST1'
.EXAMPLE
.\Invoke-HadrAudit.ps1 -ServerList .\servers.txt -OutDir C:\Temp\hadr-audit
.EXAMPLE
.\Invoke-HadrAudit.ps1 -Servers DRSQL01 -SqlCredential (Get-Credential)
#>
[CmdletBinding()]
param(
[string[]]$Servers,
[string]$ServerList, # text file, one instance per line, # = comment
[string]$OutDir = ".\hadr-audit",
[pscredential]$SqlCredential,
[int]$QueryTimeout = 60
)
$ErrorActionPreference = 'Stop'
# ---------- resolve server list ----------
if ($ServerList) {
if (-not (Test-Path $ServerList)) { throw "Server list not found: $ServerList" }
$Servers = @($Servers) + @(Get-Content $ServerList | Where-Object { $_ -and $_ -notmatch '^\s*#' })
}
$Servers = @($Servers | Where-Object { $_ } | ForEach-Object { $_.Trim() } | Select-Object -Unique)
if ($Servers.Count -eq 0) { throw "No servers given. Use -Servers or -ServerList." }
# ---------- ensure Invoke-Sqlcmd ----------
if (-not (Get-Command Invoke-Sqlcmd -ErrorAction SilentlyContinue)) {
$loaded = $false
foreach ($m in 'SqlServer','SQLPS') {
try { Import-Module $m -DisableNameChecking -ErrorAction Stop; $loaded = $true; break } catch { }
}
if (-not $loaded) {
throw "Invoke-Sqlcmd not available. Run: Install-Module SqlServer -Scope CurrentUser"
}
}
$sqlcmdParams = (Get-Command Invoke-Sqlcmd).Parameters
# ---------- audit sections (all read-only) ----------
$Sections = [ordered]@{
AG_STATE = @'
SELECT ag.name AS ag, ar.replica_server_name AS replica,
ars.role_desc AS role_now, DB_NAME(drs.database_id) AS db,
drs.synchronization_state_desc AS sync_state,
drs.suspend_reason_desc AS suspend_reason,
drs.log_send_queue_size AS send_kb, drs.redo_queue_size AS redo_kb
FROM sys.dm_hadr_database_replica_states drs
JOIN sys.availability_replicas ar ON drs.replica_id = ar.replica_id
JOIN sys.availability_groups ag ON drs.group_id = ag.group_id
JOIN sys.dm_hadr_availability_replica_states ars ON drs.replica_id = ars.replica_id;
'@
SEEDING_MODE = @'
SELECT ag.name AS ag, ar.replica_server_name AS replica,
ar.seeding_mode_desc, ar.availability_mode_desc, ar.failover_mode_desc
FROM sys.availability_replicas ar
JOIN sys.availability_groups ag ON ar.group_id = ag.group_id;
'@
ORPHANED_USER = @'
SET NOCOUNT ON;
DECLARE @sql nvarchar(max) = N'';
SELECT @sql += N'USE ' + QUOTENAME(name) + N';
SELECT DB_NAME() AS db, dp.name AS db_user, dp.type_desc
FROM sys.database_principals dp
LEFT JOIN sys.server_principals sp ON dp.sid = sp.sid
WHERE sp.sid IS NULL AND dp.type IN (''S'',''U'',''G'')
AND dp.name NOT IN (''guest'',''dbo'',''sys'',''INFORMATION_SCHEMA'');
'
FROM sys.databases
WHERE database_id > 4 AND state_desc = 'ONLINE'
AND DATABASEPROPERTYEX(name,'Updateability') = 'READ_WRITE';
EXEC sys.sp_executesql @sql;
'@
LOG_REUSE = @'
SELECT name AS db, log_reuse_wait_desc, recovery_model_desc
FROM sys.databases
WHERE database_id > 4 AND log_reuse_wait_desc <> 'NOTHING';
'@
BACKUP_GAP = @'
SELECT d.name AS db,
MAX(CASE WHEN b.type='D' THEN b.backup_finish_date END) AS last_full,
MAX(CASE WHEN b.type='L' THEN b.backup_finish_date END) AS last_log
FROM sys.databases d
LEFT JOIN msdb.dbo.backupset b ON b.database_name = d.name
WHERE d.database_id > 4 AND d.recovery_model_desc = 'FULL'
GROUP BY d.name
HAVING MAX(CASE WHEN b.type='L' THEN b.backup_finish_date END) < DATEADD(HOUR,-1,GETDATE())
OR MAX(CASE WHEN b.type='D' THEN b.backup_finish_date END) < DATEADD(DAY,-7,GETDATE())
OR MAX(b.backup_finish_date) IS NULL;
'@
SERVICE = @'
SELECT servicename, status_desc, startup_type_desc
FROM sys.dm_server_services;
'@
JOB_DISABLED = @'
SELECT name AS job FROM msdb.dbo.sysjobs WHERE enabled = 0;
'@
BACKUP_PREF = @'
SELECT ag.name AS ag, ag.automated_backup_preference_desc,
adc.database_name,
sys.fn_hadr_backup_is_preferred_replica(adc.database_name) AS backs_up_here
FROM sys.availability_groups ag
JOIN sys.availability_databases_cluster adc ON ag.group_id = adc.group_id;
'@
TDE_DB = @'
SELECT DB_NAME(database_id) AS db,
CASE encryption_state WHEN 3 THEN 'ENCRYPTED' WHEN 2 THEN 'IN PROGRESS'
WHEN 1 THEN 'UNENCRYPTED' ELSE CONVERT(varchar(10),encryption_state) END AS tde_state
FROM sys.dm_database_encryption_keys;
'@
CERT = @'
SELECT name, expiry_date FROM master.sys.certificates WHERE name NOT LIKE '##%';
'@
LINKED_SERVER = @'
SELECT name, data_source FROM sys.servers WHERE is_linked = 1;
'@
CREDENTIAL = @'
SELECT name, credential_identity FROM sys.credentials;
'@
AGENT_PROXY = @'
SELECT name FROM msdb.dbo.sysproxies;
'@
CONFIG = @'
SELECT name, CONVERT(bigint, value_in_use) AS value_in_use
FROM sys.configurations
WHERE name IN ('max server memory (MB)','max degree of parallelism',
'cost threshold for parallelism','optimize for ad hoc workloads',
'backup compression default','remote admin connections');
'@
}
# ---------- which rows count as ISSUES (vs informational) ----------
$IssueFilters = @{
AG_STATE = { ($_.suspend_reason -isnot [System.DBNull] -and $_.suspend_reason) -or
($_.sync_state -notin @('SYNCHRONIZED','SYNCHRONIZING')) }
ORPHANED_USER = { $true }
LOG_REUSE = { $true }
BACKUP_GAP = { $true }
SERVICE = { $_.status_desc -ne 'Running' -and $_.startup_type_desc -ne 'Disabled' }
TDE_DB = { $_.tde_state -eq 'IN PROGRESS' }
}
$noise = 'RowError','RowState','Table','ItemArray','HasErrors' # DataRow baggage
function Invoke-Audit {
param([string]$Server,[string]$Query)
$p = @{ ServerInstance = $Server; Query = $Query; QueryTimeout = $QueryTimeout; ErrorAction = 'Stop' }
if ($SqlCredential) {
if ($sqlcmdParams.ContainsKey('Credential')) { $p.Credential = $SqlCredential }
else {
$p.Username = $SqlCredential.UserName
$p.Password = $SqlCredential.GetNetworkCredential().Password
}
}
if ($sqlcmdParams.ContainsKey('TrustServerCertificate')) { $p.TrustServerCertificate = $true }
if ($sqlcmdParams.ContainsKey('Encrypt')) { $p.Encrypt = 'Optional' }
Invoke-Sqlcmd @p
}
# ---------- run ----------
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$runDir = Join-Path $OutDir $stamp
New-Item -ItemType Directory -Path $runDir -Force | Out-Null
$summary = New-Object System.Collections.Generic.List[object]
foreach ($srv in $Servers) {
Write-Host "`n== $srv ==" -ForegroundColor Cyan
try { Invoke-Audit -Server $srv -Query 'SELECT @@SERVERNAME AS s;' | Out-Null }
catch {
Write-Warning " CONNECT FAIL: $($_.Exception.Message)"
$summary.Add([pscustomobject]@{ Server=$srv; Section='CONNECT'; Rows=0; Issues=1; Note=$_.Exception.Message })
continue
}
foreach ($name in $Sections.Keys) {
try {
$rows = @(Invoke-Audit -Server $srv -Query $Sections[$name])
$issues = 0
if ($IssueFilters.ContainsKey($name) -and $rows.Count -gt 0) {
$issues = @($rows | Where-Object $IssueFilters[$name]).Count
}
if ($rows.Count -gt 0) {
$rows |
Select-Object -Property @{n='Server';e={$srv}},* -ExcludeProperty $noise |
Export-Csv -Path (Join-Path $runDir "$name.csv") -NoTypeInformation -Append
}
$summary.Add([pscustomobject]@{ Server=$srv; Section=$name; Rows=$rows.Count; Issues=$issues; Note='' })
$color = 'DarkGray'
if ($issues -gt 0) { $color = 'Yellow' }
Write-Host (" {0,-14} rows: {1,4} issues: {2}" -f $name, $rows.Count, $issues) -ForegroundColor $color
}
catch {
# Section can fail legitimately (e.g. no AG on this instance) — record and move on
$summary.Add([pscustomobject]@{ Server=$srv; Section=$name; Rows=0; Issues=0; Note=$_.Exception.Message })
Write-Host (" {0,-14} skipped: {1}" -f $name, $_.Exception.Message.Split("`n")[0]) -ForegroundColor DarkYellow
}
}
}
# ---------- summary ----------
$summary | Export-Csv -Path (Join-Path $runDir 'summary.csv') -NoTypeInformation
$flagged = @($summary | Where-Object { $_.Issues -gt 0 })
Write-Host "`n================ FINDINGS ================" -ForegroundColor Cyan
if ($flagged.Count -eq 0) {
Write-Host "No issues flagged across $($Servers.Count) server(s). CSVs in $runDir" -ForegroundColor Green
} else {
$flagged | Format-Table Server, Section, Rows, Issues, Note -AutoSize
Write-Host "Details per section in: $runDir" -ForegroundColor Yellow
}