diff --git a/README.md b/README.md index dfe4a37..2365d9d 100644 --- a/README.md +++ b/README.md @@ -233,5 +233,219 @@ Default: `C:\Rubrik\backup-multi-{InstanceName}.log` ## License -This script is provided as-is for database backup automation. Ensure compliance with your organization's backup and retention policies. +This script is provided as-is for database backup automation. Ensure compliance with your organization's backup and retention policies. + +--- + +# RestoreScript.ps1 - SQL Server Database Restore Script + +A PowerShell script for restoring SQL Server databases from backup files, with support for full, differential, and log backups. It automatically catalogs backups from a specified directory and handles file relocation during restore operations. + +## Features + +- **Automatic Backup Cataloging**: Scans a directory for backup files and organizes them by database, type (FULL, DIFF, LOG), and timestamp +- **Flexible Restore Options**: Supports restoring from full backups only, or full + diffs + logs in the correct sequence +- **File Relocation**: Moves database and log files to specified paths during restore (e.g., for different drive layouts) +- **Backup Verification**: Validates backup integrity and provides LSN range information +- **LSN-Aware Sequencing**: Applies backups in the correct order to avoid LSN conflicts (full → all diffs → logs after latest diff) +- **Error Handling**: Robust error detection with detailed SQL error messages + +## Requirements + +### System Requirements +- **PowerShell**: Version 5.1 or higher +- **Operating System**: Windows Server 2016 or later (or Windows 10/11 for development/testing) +- **SQL Server**: SQL Server 2016 or later (Express, Standard, Enterprise, or Developer editions) +- **Permissions**: SQL Server sysadmin privileges or appropriate database restore permissions + +### Software Dependencies +- **SQL Server PowerShell Module**: Either `SqlServer` or `SQLPS` module must be available + - Install with: `Install-Module -Name SqlServer -AllowClobber` + +### Network Requirements +- **SQL Server Connectivity**: The script must be able to connect to the target SQL Server instance +- **Backup Source Access**: Read access to the backup files directory +- **Restore Destination Access**: Read/write access to the data and log file directories + +## Installation + +1. **Download the Script**: + ```powershell + # Place RestoreScript.ps1 in your preferred scripts directory + # Example: C:\Rubrik\Scripts\ + ``` + +2. **Install SQL Server PowerShell Module** (if not already installed): + ```powershell + Install-Module -Name SqlServer -AllowClobber -Force + ``` + +3. **Verify Permissions**: + - Ensure the account running the script has SQL Server sysadmin privileges + - Verify read access to backup directories and write access to restore destinations + +## Usage + +### Basic Syntax +```powershell +.\RestoreScript.ps1 -SqlInstance "ServerName\InstanceName" -LiveMountRoot "C:\BackupPath" -Action [parameters] +``` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `SqlInstance` | String | Yes | sqlfcsql\TESTINST | SQL Server instance name (e.g., "SERVER01\SQL2019") | +| `LiveMountRoot` | String | Yes | - | Root directory containing backup files | +| `Action` | String | Yes | - | Action to perform: `catalog`, `restore`, or `verify` | +| `DatabaseName` | String | No | - | Specific database name (required for restore, optional for others) | +| `DataPath` | String | No | - | Directory for data files (required for restore) | +| `LogPath` | String | No | - | Directory for log files (required for restore) | + +### Actions + +#### Catalog Action +Lists all backup files found in the LiveMountRoot directory, organized by database and backup type. + +**Example**: +```powershell +.\RestoreScript.ps1 -SqlInstance "SQLFC1\SQLFC1P" -LiveMountRoot "C:\Rubrik\lm\" -Action catalog +``` + +**Output**: +``` +Database Backups Catalog: +========================= +Database: DataAW2 + Type: FULL + Backup: 20251127093305 + C:\Rubrik\lm\DataAW2_FULL_20251127093305_0001.bak + Type: DIFF + Backup: 20251128005350 + C:\Rubrik\lm\DataAW2_DIFF_20251128005350_0001.bak + ... + Type: LOG + Backup: 20251130130213 + C:\Rubrik\lm\DataAW2_LOG_20251130130213_0001.trn + ... + +Summary of Databases: + - DataAW2 +``` + +#### Restore Action +Restores a specific database using the latest full backup, all subsequent diffs, and logs taken after the latest diff. Files are relocated to the specified DataPath and LogPath. + +**Example**: +```powershell +.\RestoreScript.ps1 -SqlInstance "SQLFC1\SQLFC1P" -LiveMountRoot "C:\Rubrik\lm\" -Action restore -DatabaseName "DataAW2" -DataPath "F:\Data" -LogPath "G:\Logs" +``` + +**Output**: +``` +Restoring FULL backup for DataAW2... +Applying DIFF backup 20251128005350 for DataAW2... +Applying DIFF backup 20251129010111 for DataAW2... +Applying DIFF backup 20251130000053 for DataAW2... +Applying DIFF backup 20251201113159 for DataAW2... +Applying LOG backup 20251201124800 for DataAW2... +Finalizing restore for DataAW2... +Restore completed for DataAW2 +``` + +#### Verify Action +Verifies backup file integrity and displays LSN ranges and backup details. + +**Example**: +```powershell +.\RestoreScript.ps1 -SqlInstance "SQLFC1\SQLFC1P" -LiveMountRoot "C:\Rubrik\lm\" -Action verify -DatabaseName "DataAW2" +``` + +**Output**: +``` +Verifying backups for database: DataAW2 +Verifying FULL backup 20251127093305 for DataAW2... +Verification successful for FULL 20251127093305 + Backup Details: + Start Date: 11/27/2025 09:33:05 + Finish Date: 11/27/2025 09:33:07 + First LSN: 56000000289000001 + Last LSN: 56000000289300001 + Database Backup LSN: 56000000182200001 + Differential Base LSN: +... +Backup Summary for database: DataAW2 +=================================== +FULL Backups: + Date: 11/27/2025 09:33:07 | LSN Range: 56000000289000001 - 56000000289300001 +DIFFERENTIAL Backups: + Date: 11/25/2025 00:21:45 | Base LSN: 56000000182200001 | LSN Range: 56000000270100001 - 56000000270400001 + ... +LOG Backups: + Point-in-Time Range: 11/30/2025 13:02:13 to 12/01/2025 12:48:00 + LSN Range: 56000000345300001 - 56000000353400001 + No gaps detected in LOG sequence +``` + +## Restore Logic + +The script follows a specific restore sequence to ensure data consistency: + +1. **Full Backup**: Always starts with the latest full backup +2. **Differential Backups**: Applies all differential backups taken after the full backup +3. **Log Backups**: Applies only log backups taken after the latest differential backup + +This approach avoids LSN conflicts that can occur when mixing diffs and logs from different time ranges. + +## Error Handling + +### Common Issues + +#### "Directory lookup for the file failed" +**Cause**: Original file paths don't exist on the restore server +**Solution**: Use `-DataPath` and `-LogPath` to relocate files to valid directories + +#### "The log in this backup set terminates at LSN X, which is too early to apply" +**Cause**: Log backup is from before the applied differential backup +**Solution**: The script automatically handles this by only applying logs after the latest diff + +#### "Database not found in catalog" +**Cause**: No backups found for the specified database +**Solution**: Run catalog action first to verify available databases + +#### "Access denied" to directories +**Cause**: Insufficient permissions on data/log paths +**Solution**: Ensure the SQL Server service account has write access to the specified directories + +## Troubleshooting + +### Debug Steps +1. Run the `catalog` action to verify backup files are detected correctly +2. Run the `verify` action to check backup integrity and LSN ranges +3. Ensure DataPath and LogPath directories exist and are writable +4. Check SQL Server error logs for additional details + +### Performance Considerations +- Restore operations can be time-intensive for large databases +- Ensure sufficient disk space on target directories +- Monitor SQL Server performance during restore operations + +## Security Considerations + +- **SQL Permissions**: Requires sysadmin or database restore permissions +- **File System Access**: Read access to backup sources, write access to restore destinations +- **Data Protection**: Ensure backup files are from trusted sources + +## Support and Maintenance + +### Monitoring +- Verify restore completion and database accessibility +- Check SQL Server logs for any restore-related errors +- Validate file locations after restore + +### Best Practices +- Test restores in development environments first +- Keep backup files organized and accessible +- Document custom DataPath/LogPath configurations +- Regularly verify backup integrity with the verify action README.md \ No newline at end of file diff --git a/RestoreScript.ps1 b/RestoreScript.ps1 index 12276f0..3ed81f4 100644 --- a/RestoreScript.ps1 +++ b/RestoreScript.ps1 @@ -9,7 +9,7 @@ param( [ValidateSet("catalog", "restore", "verify")] [string]$Action, - [Parameter(Mandatory=$true)] + [Parameter(Mandatory=$false)] [string]$SqlInstance = "sqlfcsql\TESTINST", [Parameter(Mandatory=$false)] @@ -66,7 +66,11 @@ function Catalog-Backups { $timeStr = $parts[$typeIndex + 2] $stripe = 0 if ($parts.Length -gt $typeIndex + 3) { - $stripe = [int]$parts[$typeIndex + 3] + $stripeVal = 0 + if (-not [int]::TryParse($parts[$typeIndex + 3], [ref]$stripeVal)) { + continue + } + $stripe = $stripeVal } $key = "$dateStr$timeStr" @@ -140,9 +144,10 @@ function Restore-Database { if ($StopAtTime) { try { - $targetStopAtTime = [datetime]::Parse($StopAtTime) + # Parse using the current OS locale so users can enter dates in their own format. + $targetStopAtTime = [datetime]::Parse($StopAtTime, [System.Globalization.CultureInfo]::CurrentCulture) } catch { - Write-Error "StopAtTime '$StopAtTime' is not a valid datetime value" + Write-Error "StopAtTime '$StopAtTime' is not a valid datetime value (expected format for your locale: $([System.Globalization.CultureInfo]::CurrentCulture.DateTimeFormat.ShortDatePattern) HH:mm:ss)" return } } @@ -207,10 +212,7 @@ function Restore-Database { $fullFiles = $selectedFull.Files $fullHeader = $selectedFull.Header - $fullBaseLsn = $fullHeader.CheckpointLSN - if (-not $fullBaseLsn) { - $fullBaseLsn = $fullHeader.FirstLSN - } + $fullBaseLsn = if ($fullHeader.CheckpointLSN) { [decimal]$fullHeader.CheckpointLSN } else { [decimal]$fullHeader.FirstLSN } # Create directories if specified if ($DataPath -and -not (Test-Path $DataPath)) { @@ -277,7 +279,7 @@ function Restore-Database { continue } - if ($diffHeader.DifferentialBaseLSN -ne $fullBaseLsn) { + if ([decimal]$diffHeader.DifferentialBaseLSN -ne $fullBaseLsn) { continue } @@ -309,15 +311,52 @@ function Restore-Database { $logStartKey = if ($selectedDiffKey) { $selectedDiffKey } else { $latestFullKey } $logKeys = @() + # Determine the LastLSN of the last backup that will be restored (FULL or DIFF). + $lastRestoredHeader = if ($selectedDiff) { + Get-BackupInfo -Files $selectedDiff.Files -Instance $Instance + } else { + $fullHeader + } + $lastRestoredLsn = if ($lastRestoredHeader) { [decimal]$lastRestoredHeader.LastLSN } else { $null } + # Select LOG backups after the latest restored FULL/DIFF point. if ($dbCatalog.ContainsKey('LOG')) { $candidateLogKeys = @($dbCatalog['LOG'].Keys | Where-Object { $_ -gt $logStartKey } | Sort-Object) - if ($isPointInTimeRestore -and $candidateLogKeys.Count -eq 0) { - Write-Error "Point-in-time restore requested but no LOG backups are available after the selected FULL/DIFF restore point" - return + + # Validate LSN continuity: the first candidate LOG must connect to the end of the FULL/DIFF chain. + # If there is a gap (first LOG's FirstLSN > FULL/DIFF LastLSN), no logs can be applied. + if ($candidateLogKeys.Count -gt 0 -and $lastRestoredLsn) { + $firstLogFiles = $dbCatalog['LOG'][$candidateLogKeys[0]] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } + $firstLogHeader = Get-BackupInfo -Files $firstLogFiles -Instance $Instance + if ($firstLogHeader -and [decimal]$firstLogHeader.FirstLSN -gt $lastRestoredLsn) { + Write-Warning "LSN gap detected: first available LOG backup ($($candidateLogKeys[0])) starts at LSN $($firstLogHeader.FirstLSN), but the restored FULL/DIFF ends at LSN $lastRestoredLsn. No log backups can be applied." + $candidateLogKeys = @() + } } - if ($StopAtTime) { + $skipLogSelection = $false + if ($isPointInTimeRestore -and $candidateLogKeys.Count -eq 0) { + # Only error if the target point is beyond the last restored backup's finish time. + # If the target is at or before that time, no logs are required and restore can proceed. + $lastRestoredFinish = if ($lastRestoredHeader) { + try { [datetime]$lastRestoredHeader.BackupFinishDate } catch { $null } + } else { $null } + $targetIsBeyondRestored = $true + if ($targetStopAtTime -and $lastRestoredFinish -and $targetStopAtTime -le $lastRestoredFinish) { + $targetIsBeyondRestored = $false + } + if ($targetStopAtLsn -and $lastRestoredLsn -and $targetStopAtLsn -le $lastRestoredLsn) { + $targetIsBeyondRestored = $false + } + if ($targetIsBeyondRestored) { + Write-Error "Point-in-time restore requested but no LOG backups are available after the selected FULL/DIFF restore point" + return + } + # Target is within the FULL/DIFF range; no logs needed. + $skipLogSelection = $true + } + + if (-not $skipLogSelection -and $StopAtTime) { $cutoffFound = $false foreach ($key in $candidateLogKeys) { $logFiles = $dbCatalog['LOG'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } @@ -353,7 +392,7 @@ function Restore-Database { Write-Error "StopAtTime '$StopAtTime' is not covered by available LOG backups after the selected FULL/DIFF restore point" return } - } elseif ($StopAtLSN) { + } elseif (-not $skipLogSelection -and $StopAtLSN) { $cutoffFound = $false foreach ($key in $candidateLogKeys) { $logFiles = $dbCatalog['LOG'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } @@ -395,7 +434,7 @@ function Restore-Database { $logKeys = $candidateLogKeys } - if ($isPointInTimeRestore -and $logKeys.Count -eq 0) { + if (-not $skipLogSelection -and $isPointInTimeRestore -and $logKeys.Count -eq 0) { Write-Error "Point-in-time restore requested but no usable LOG backups were selected" return } @@ -431,7 +470,7 @@ function Restore-Database { } if ($isPointInTimeRestore) { if ($StopAtTime) { - Write-Host "PITR Target: STOPAT '$StopAtTime'" + Write-Host "PITR Target: STOPAT '$($targetStopAtTime.ToString([System.Globalization.CultureInfo]::CurrentCulture))'" } else { $previewLsn = if ($StopAtLSN -like 'lsn:*') { $StopAtLSN } else { "lsn:$StopAtLSN" } Write-Host "PITR Target: STOPATMARK '$previewLsn'" @@ -473,8 +512,11 @@ function Restore-Database { # Apply STOPAT/STOPATMARK only on the final LOG restore statement. if ($isPointInTimeRestore -and $i -eq ($logKeys.Count - 1)) { if ($StopAtTime) { - $escapedStopAtTime = $StopAtTime.Replace("'", "''") - $logWithClause = "$logWithClause, STOPAT = '$escapedStopAtTime'" + # Use the parsed datetime formatted as ISO 8601, not the raw input string. + # SQL Server requires a consistent datetime format; the user's input may be + # locale-specific (e.g. dd/MM/yyyy) which SQL Server does not accept. + $sqlStopAt = $targetStopAtTime.ToString("yyyy-MM-dd HH:mm:ss") + $logWithClause = "$logWithClause, STOPAT = '$sqlStopAt'" } elseif ($StopAtLSN) { $normalizedLsn = if ($StopAtLSN -like "lsn:*") { $StopAtLSN } else { "lsn:$StopAtLSN" } $escapedLsn = $normalizedLsn.Replace("'", "''") @@ -510,7 +552,7 @@ function Print-BackupSummary { foreach ($item in $fullItems) { $header = @($item.Header)[0] if (-not $header) { continue } - Write-Host " Date: $($header.BackupFinishDate) | LSN Range: $($header.FirstLSN) - $($header.LastLSN)" + Write-Host " Date: $(([datetime]$header.BackupFinishDate).ToString([System.Globalization.CultureInfo]::CurrentCulture)) | LSN Range: $($header.FirstLSN) - $($header.LastLSN)" } } @@ -521,7 +563,7 @@ function Print-BackupSummary { foreach ($item in $diffItems) { $header = @($item.Header)[0] if (-not $header) { continue } - Write-Host " Date: $($header.BackupFinishDate) | Base LSN: $($header.DifferentialBaseLSN) | LSN Range: $($header.FirstLSN) - $($header.LastLSN)" + Write-Host " Date: $(([datetime]$header.BackupFinishDate).ToString([System.Globalization.CultureInfo]::CurrentCulture)) | Base LSN: $($header.DifferentialBaseLSN) | LSN Range: $($header.FirstLSN) - $($header.LastLSN)" } } @@ -532,7 +574,7 @@ function Print-BackupSummary { $firstLog = @($logItems[0].Header)[0] $lastLog = @($logItems[-1].Header)[0] Write-Host "LOG Backups:" - Write-Host " Point-in-Time Range: $($firstLog.BackupStartDate) to $($lastLog.BackupFinishDate)" + Write-Host " Point-in-Time Range: $(([datetime]$firstLog.BackupStartDate).ToString([System.Globalization.CultureInfo]::CurrentCulture)) to $(([datetime]$lastLog.BackupFinishDate).ToString([System.Globalization.CultureInfo]::CurrentCulture))" Write-Host " LSN Range: $($firstLog.FirstLSN) - $($lastLog.LastLSN)" # Check for gaps @@ -590,8 +632,8 @@ function Verify-Backups { $header = Get-BackupInfo -Files $files -Instance $Instance if ($header) { Write-Host " Backup Details:" - Write-Host " Start Date: $($header.BackupStartDate)" - Write-Host " Finish Date: $($header.BackupFinishDate)" + Write-Host " Start Date: $(([datetime]$header.BackupStartDate).ToString([System.Globalization.CultureInfo]::CurrentCulture))" + Write-Host " Finish Date: $(([datetime]$header.BackupFinishDate).ToString([System.Globalization.CultureInfo]::CurrentCulture))" Write-Host " First LSN: $($header.FirstLSN)" Write-Host " Last LSN: $($header.LastLSN)" if ($header.DatabaseBackupLSN) { diff --git a/backup.ps1 b/backup.ps1 index e09f238..b9c1ce4 100644 --- a/backup.ps1 +++ b/backup.ps1 @@ -93,7 +93,8 @@ function Get-ExceptionMessageList { function Write-ExceptionDiagnostics { param( [string]$Prefix, - [System.Exception]$Exception + [System.Exception]$Exception, + [System.Management.Automation.ErrorRecord]$ErrorRecord ) if (-not $Exception) { @@ -101,7 +102,8 @@ function Write-ExceptionDiagnostics { return } - $detailLines = Get-ExceptionMessageList -Exception $Exception + # Force array semantics so single detail lines are not treated as scalar strings. + $detailLines = @(Get-ExceptionMessageList -Exception $Exception) if ($detailLines.Count -eq 0) { Write-Log "ERROR: $Prefix $($Exception.Message)" return @@ -111,6 +113,31 @@ function Write-ExceptionDiagnostics { for ($i = 1; $i -lt $detailLines.Count; $i++) { Write-Log "ERROR: detail[$i]: $($detailLines[$i])" } + + if ($ErrorRecord) { + if ($ErrorRecord.FullyQualifiedErrorId) { + Write-Log "ERROR: FullyQualifiedErrorId: $($ErrorRecord.FullyQualifiedErrorId)" + } + if ($ErrorRecord.CategoryInfo) { + Write-Log "ERROR: CategoryInfo: $($ErrorRecord.CategoryInfo)" + } + + $recordText = $ErrorRecord.ToString() + if (-not [string]::IsNullOrWhiteSpace($recordText)) { + $recordLines = @($recordText -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + foreach ($line in $recordLines) { + Write-Log "ERROR: Record: $line" + } + } + } + + $exceptionText = $Exception.ToString() + if (-not [string]::IsNullOrWhiteSpace($exceptionText)) { + $exceptionLines = @($exceptionText -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + foreach ($line in $exceptionLines) { + Write-Log "ERROR: Exception: $line" + } + } } function Test-ServiceAccountFile { @@ -153,7 +180,7 @@ function Connect-RscWithDiagnostics { Write-Log "INFO: $Context Connected to Rubrik Security Cloud." } catch { $prefix = "($Context Connect-Rsc -ServiceAccountFile $ServiceAccountFile) failed." - Write-ExceptionDiagnostics -Prefix $prefix -Exception $_.Exception + Write-ExceptionDiagnostics -Prefix $prefix -Exception $_.Exception -ErrorRecord $_ Write-Log "ERROR: Troubleshooting hints: verify file permissions/content, outbound HTTPS connectivity, proxy configuration, and local system time sync." throw } @@ -379,7 +406,7 @@ try { } Write-Log "INFO: Retrieved paths: $($paths -join ', ')" } catch { - Write-ExceptionDiagnostics -Prefix "Failed to retrieve paths from Rubrik." -Exception $_.Exception + Write-ExceptionDiagnostics -Prefix "Failed to retrieve paths from Rubrik." -Exception $_.Exception -ErrorRecord $_ exit 1 } diff --git a/backupSingle.ps1 b/backupSingle.ps1 deleted file mode 100644 index 615d110..0000000 --- a/backupSingle.ps1 +++ /dev/null @@ -1,271 +0,0 @@ -param( - [Parameter(Mandatory=$true)] - [string]$SqlInstance, - - [Parameter(Mandatory=$false)] - [string]$Directory, - - [Parameter(Mandatory=$false)] - [switch]$Force -) - -# backup.ps1 -# -# TODO: Parallelize backups for multiple DBs in the instance - -# Import SQL Server PowerShell module -try { - # Try to import the newer SqlServer module first - if (Get-Module -ListAvailable -Name SqlServer) { - Import-Module SqlServer -ErrorAction Stop - Write-Host "INFO: SqlServer PowerShell module loaded successfully." - } - # Fall back to older SQLPS module if available - elseif (Get-Module -ListAvailable -Name SQLPS) { - Import-Module SQLPS -ErrorAction Stop - Write-Host "INFO: SQLPS PowerShell module loaded successfully." - } - else { - throw "No SQL Server PowerShell module found" - } - - # Verify Invoke-Sqlcmd is available - if (-not (Get-Command Invoke-Sqlcmd -ErrorAction SilentlyContinue)) { - throw "Invoke-Sqlcmd command not available" - } -} -catch { - Write-Host "ERROR: Failed to import SQL Server PowerShell module. Please install it using: Install-Module -Name SqlServer -AllowClobber" - Write-Host "ERROR: $($_.Exception.Message)" - exit 1 -} - -$instanceName = $SqlInstance.Split('\')[1] - -# Use provided directory parameter or default to instance-based path -if ($Directory) { - $directory = $Directory - Write-Host "INFO: Using provided directory: $directory" -} else { - $directory = "C:\Rubrik\$instanceName" - Write-Host "INFO: Using default directory: $directory" -} - -$fullBackupDay = 'Thursday' -$fullBackupOverdueDays = 7 # Force full backup if last full backup is older than this many days -$checkCluster = $false -#$logFile = "C:\Rubrik\backup-$instanceName.log" -$logFile = "H:\Backup\backup-$instanceName.log" - -$fullFlag = $directory + "\last_full.flag" -$diffFlag = $directory + "\last_diff.flag" -$today = (Get-Date).Date - -function FlagTakenToday($flagPath) { - if (Test-Path $flagPath) { - $flagDate = (Get-Content $flagPath | Out-String).Trim() - return ($flagDate -eq $today.ToString("yyyy-MM-dd")) - } - return $false -} - -function GetLastFullBackupDate($flagPath) { - if (Test-Path $flagPath) { - $flagDate = (Get-Content $flagPath | Out-String).Trim() - try { - return [DateTime]::ParseExact($flagDate, "yyyy-MM-dd", $null) - } - catch { - Write-Log "WARNING: Could not parse last full backup date from flag file: $flagDate" - return $null - } - } - return $null -} - -function IsFullBackupOverdue($flagPath, $overdueDays) { - $lastFullDate = GetLastFullBackupDate $flagPath - if ($null -eq $lastFullDate) { - Write-Log "WARNING: No last full backup date found. Full backup is considered overdue." - return $true - } - - $daysSinceLastFull = ($today - $lastFullDate).Days - $isOverdue = $daysSinceLastFull -gt $overdueDays - - Write-Log "INFO: Last full backup was $daysSinceLastFull days ago on $($lastFullDate.ToString('yyyy-MM-dd')). Overdue threshold: $overdueDays days." - - return $isOverdue -} - -function Write-Log($message) { - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - $logEntry = "$timestamp $message" - Add-Content -Path $logFile -Value $logEntry - Write-Host $logEntry -} - -# Check if directory exists and is a symbolic link (unless -Force is specified) -if (-not (Test-Path $directory)) { - Write-Log "ERROR: Directory '$directory' does not exist. Exiting script." - exit 1 -} - -if (-not $Force) { - $directoryInfo = Get-Item $directory - if (-not ($directoryInfo.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { - Write-Log "ERROR: Directory '$directory' is not a symbolic link. Exiting script." - exit 1 - } - Write-Log "INFO: Directory '$directory' exists and is a symbolic link. Target: $($directoryInfo.Target). Proceeding." -} else { - Write-Log "INFO: Force parameter specified. Skipping symbolic link check for directory '$directory'." -} - -if ($checkCluster) { - # Check if SQL instance is running locally - $localNode = $env:COMPUTERNAME - - $clusterInstance = Get-ClusterResource | Where-Object { $_.ResourceType -eq "SQL Server" -and $_.Name -eq "SQL Server ($instanceName)" } - if ($clusterInstance) { - $ownerNode = $clusterInstance.OwnerNode.Name - if ($ownerNode -ne $localNode) { - Write-Log "SQL instance '$SqlInstance' is not running on local node '$localNode'. Exiting script." - exit 1 - } else { - Write-Log "SQL instance '$SqlInstance' is running on local node '$localNode'. Proceeding." - } - } else { - Write-Log "ERROR: SQL instance '$SqlInstance' not found in cluster resources." - exit 1 - } -} else { - Write-Log "INFO: Cluster check is disabled. Proceeding without verification." -} - -# Check if full backup is overdue regardless of the day -$isFullBackupOverdue = IsFullBackupOverdue $fullFlag $fullBackupOverdueDays - -if ((Get-Date).DayOfWeek -eq $fullBackupDay) { - if (-not (FlagTakenToday $fullFlag)) { - $backupType = "FULL" - $cleanupTime = 168 - Set-Content $fullFlag $today.ToString("yyyy-MM-dd") - Write-Log "Selected FULL backup (scheduled day). Flag updated." - } else { - $backupType = "LOG" - $cleanupTime = 24 - Write-Log "FULL backup already taken today. Selected LOG backup." - } -} elseif ($isFullBackupOverdue) { - if (-not (FlagTakenToday $fullFlag)) { - $backupType = "FULL" - $cleanupTime = 168 - Set-Content $fullFlag $today.ToString("yyyy-MM-dd") - Write-Log "Selected FULL backup (overdue - forcing full backup). Flag updated." - } else { - $backupType = "LOG" - $cleanupTime = 24 - Write-Log "FULL backup already taken today (was overdue). Selected LOG backup." - } -} else { - if (-not (FlagTakenToday $diffFlag)) { - $backupType = "DIFF" - $cleanupTime = 168 - Set-Content $diffFlag $today.ToString("yyyy-MM-dd") - Write-Log "Selected DIFF backup. Flag updated." - } else { - $backupType = "LOG" - $cleanupTime = 24 - Write-Log "DIFF backup already taken today. Selected LOG backup." - } -} - -$query = "EXECUTE [dbo].[DatabaseBackup] @Databases = 'ALL_DATABASES', @Directory = '$directory', @BackupType = '$backupType', @Verify = 'N', @CleanupTime = $cleanupTime, @CheckSum = 'Y', @LogToTable = 'Y'" -Write-Log "Executing backup type: $backupType" -Write-Log "SQL Query: $query" - -try { - # Execute the backup using PowerShell SQL module with better error handling - # Capture verbose output from Ola H scripts - $infoMessages = @() - - # Create event handlers to capture SQL Server messages - $connection = New-Object System.Data.SqlClient.SqlConnection - $connection.ConnectionString = "Server=$SqlInstance;Integrated Security=true;Connection Timeout=30" - - # Event handler for informational messages (PRINT statements) - $connection.add_InfoMessage({ - param($sqlSender, $e) - $message = $e.Message - if ($message -and $message.Trim() -ne "") { - $script:infoMessages += $message - Write-Log "SQL INFO: $message" - } - }) - - try { - $connection.Open() - - $command = New-Object System.Data.SqlClient.SqlCommand - $command.Connection = $connection - $command.CommandText = $query - $command.CommandTimeout = 0 # No timeout for backup operations - - Write-Log "Executing SQL command with message capture..." - - # Execute and capture any result sets - $reader = $command.ExecuteReader() - - # Process any result sets - while ($reader.Read()) { - $rowData = @() - for ($i = 0; $i -lt $reader.FieldCount; $i++) { - $rowData += "$($reader.GetName($i)): $($reader.GetValue($i))" - } - if ($rowData.Count -gt 0) { - Write-Log "SQL RESULT: $($rowData -join ', ')" - } - } - - $reader.Close() - } - finally { - if ($connection.State -eq [System.Data.ConnectionState]::Open) { - $connection.Close() - } - $connection.Dispose() - } - - Write-Log "$backupType Backup execution completed successfully." - Write-Log "Total informational messages captured: $($infoMessages.Count)" -} -catch { - Write-Log "ERROR: Backup execution failed with exception: $($_.Exception.Message)" - - # Log additional SQL Server error details if available - if ($_.Exception.InnerException) { - Write-Log "ERROR: Inner Exception: $($_.Exception.InnerException.Message)" - } - - # Check for SQL Server specific errors - if ($_.Exception -is [System.Data.SqlClient.SqlException]) { - Write-Log "ERROR: SQL Server Error Details:" - foreach ($sqlError in $_.Exception.Errors) { - Write-Log "ERROR: Severity: $($sqlError.Class), State: $($sqlError.State), Number: $($sqlError.Number)" - Write-Log "ERROR: Message: $($sqlError.Message)" - if ($sqlError.Procedure) { - Write-Log "ERROR: Procedure: $($sqlError.Procedure), Line: $($sqlError.LineNumber)" - } - } - } - - # Clean up connection if it exists - if ($connection -and $connection.State -eq [System.Data.ConnectionState]::Open) { - $connection.Close() - $connection.Dispose() - } - - exit 1 -} - diff --git a/claimInstance.ps1 b/claimInstance.ps1 deleted file mode 100644 index 40890f5..0000000 --- a/claimInstance.ps1 +++ /dev/null @@ -1,123 +0,0 @@ -########################################################################## -# -# Update an SLA MV to point to the local host -# Created by Rubrik PS for ZF, September 2025 -# -# Must be run with a Global service account. -# -# Requires RubrikSecurityCloud module to be installed and working with -# a Global Service Account with the following rights (TBC) -# -# Create the service account file with: -# Set-RscServiceAccountFile sa.json -OutputFilePath Global.xml -# -# Example invocation -# .\claimInstance.ps1 -sqlInstance "sqlfcsql\TESTINST" -mvName "JP-ZF-SQL" -# -# v0.1 Initial Release -# -########################################################################## - -param ( - [Parameter(Mandatory=$False, - HelpMessage="Instance to claim")] - [string]$sqlInstance, - - [Parameter(Mandatory=$False, - HelpMessage="Do not change the MV")] - [switch]$dryrun -) - -# SA File must be an absolute path -$GlobalSAFile = "C:\Rubrik\scripts\sa.xml" -$logFile = "C:\Rubrik\scripts\claimInstance.log" -$mvName = "JP-ZF-SQL" -$sqlInstance = "sqlfcsql\TESTINST" -$checkCluster = $true - -########################### -# Script begins -########################### - -$ErrorActionPreference = 'Stop' - -function Write-Log($message) { - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - $logEntry = "$timestamp $message" - Add-Content -Path $logFile -Value $logEntry - Write-Host $logEntry -} - -Import-Module RubrikSecurityCloud - -if ($checkCluster) { - # Check if SQL instance is running locally - $localNode = $env:COMPUTERNAME - $instanceName = $sqlInstance.Split('\')[1] - $clusterInstance = Get-ClusterResource | Where-Object { $_.ResourceType -eq "SQL Server" -and $_.Name -eq "SQL Server ($instanceName)" } - if ($clusterInstance) { - $ownerNode = $clusterInstance.OwnerNode - if ($ownerNode -ne $localNode) { - Write-Log "SQL instance '$sqlInstance' is not running on local node '$localNode'. Exiting script." - exit 1 - } else { - Write-Log "SQL instance '$sqlInstance' is running on local node '$localNode'. Proceeding." - } - } else { - Write-Log "ERROR: SQL instance '$sqlInstance' not found in cluster resources." - exit 1 - } -} else { - Write-Log "INFO: Cluster check is disabled. Proceeding without verification." -} - -Connect-Rsc -ServiceAccountFile $GlobalSAFile - -Write-Log "Connected to Rubrik Security Cloud." - -$myHost = Get-RscHost -Name $env:COMPUTERNAME -OsType WINDOWS - -$query = New-RscQuery -GqlQuery slaManagedVolumes -AddField Nodes.HostDetail, Nodes.SmbShare, Nodes.ClientConfig, Nodes.ClientConfig.BackupScript -$query.var.filter = @(Get-RscType -Name Filter) -$query.var.filter[0].field = "NAME_EXACT_MATCH" -$query.var.filter[0].Texts = $mvName - -#$query.Field.Nodes = @(Get-RscType -Name ManagedVolume -InitialProperties name, Id, hostDetail.Id, hostDetail.Status, hostDetail.Name) - -$mvDetail = $query.Invoke().nodes[0] - -Write-Log "Found Managed Volume: $($mvDetail.Name) (ID: $($mvDetail.Id), Status: $($mvDetail.hostDetail.Status), HostDetail Name: $($mvDetail.hostDetail.Name))" - -if ($myHost.Id -ne $mvDetail.hostDetail.Id) { - Write-Log "WARNING: Host ID ($($myHost.Id)) does not match Managed Volume HostDetail ID ($($mvDetail.hostDetail.Id))." - - $query = New-RscMutation -GqlMutation updateManagedVolume - $query.Var.input = Get-RscType -Name UpdateManagedVolumeInput - $query.Var.input.update = Get-RscType -Name ManagedVolumeUpdateInput - $query.Var.input.update.config = Get-RscType -Name ManagedVolumePatchConfigInput - $query.Var.input.update.slaClientConfig = Get-RscType -Name ManagedVolumePatchSlaClientConfigInput - - $query.Var.input.Id = $mvDetail.Id - $query.Var.input.update.Name = $mvName - $query.Var.input.update.config.SmbDomainName = $mvDetail.SmbShare.DomainName - $query.Var.input.update.config.SmbValidIps = $myHost.Name - $query.Var.input.update.config.SmbValidUsers = $mvDetail.SmbShare.ValidUsers + $mvDetail.SmbShare.ActiveDirectoryGroups - $query.Var.input.update.slaClientConfig.clientHostId = $myHost.Id - $query.Var.input.update.slaClientConfig.channelHostMountPaths = $mvDetail.ClientConfig.ChannelHostMountPaths - $query.Var.input.update.slaClientConfig.backupScriptCommand = $mvDetail.ClientConfig.BackupScript.ScriptCommand - $query.Var.input.update.slaClientConfig.shouldDisablePostBackupScriptOnBackupFailure = $true - $query.Var.input.update.slaClientConfig.shouldDisablePostBackupScriptOnBackupSuccess = $true - $query.Var.input.update.slaClientConfig.shouldDisablePreBackupScript = $true - - $query.gqlRequest().Variables - - if (-not $dryrun) { - $result = $query.Invoke() - } else { - Write-Log "Dry run mode: Managed Volume update not invoked." - } -} else { - Write-Log "Host ID ($($myHost.Id)) matches Managed Volume HostDetail ID ($($mvDetail.hostDetail.Id)). No action needed." -} - -Disconnect-Rsc \ No newline at end of file diff --git a/prescript.cmd b/prescript.cmd deleted file mode 100644 index 2e0f7d2..0000000 --- a/prescript.cmd +++ /dev/null @@ -1,2 +0,0 @@ -@echo off -powershell -ExecutionPolicy Bypass -File "%~dp0setActiveNode.ps1" %* diff --git a/setActiveNode.ps1 b/setActiveNode.ps1 deleted file mode 100644 index ebdbb4c..0000000 --- a/setActiveNode.ps1 +++ /dev/null @@ -1,128 +0,0 @@ -########################################################################## -# -# Update an SLA MV to point to the correct host -# Created by Rubrik PS for ZF, September 2025 -# -# Must be run with a Global service account. -# -# Requires RubrikSecurityCloud module to be installed and working with -# a Global Service Account with the following rights (TBC) -# -# Create the service account file with: -# Set-RscServiceAccountFile sa.json -OutputFilePath sa-rbksql.xml -# -# Example invocation -# .\setActiveNode.ps1 -SqlInstance "sqlfcsql\TESTINST" -mvName "JP-ZF-SQL" -# -# v0.1 Initial Release -# -########################################################################## - -param ( - [Parameter(Mandatory=$True, - HelpMessage="Instance to claim")] - [string]$SqlInstance, - - [Parameter(Mandatory=$True, - HelpMessage="Managed Volume name")] - [string]$mvName, - - [Parameter(Mandatory=$False, - HelpMessage="Do not change the MV")] - [switch]$dryrun -) - -# SA File must be an absolute path -$SAFile = "C:\Rubrik\scripts\rbksql.xml" -$logFile = "C:\Rubrik\scripts\setActiveNode.log" -$checkCluster = $true - -########################### -# Script begins -########################### - -$ErrorActionPreference = 'Stop' - -function Write-Log($message) { - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - $logEntry = "$timestamp $message" - Add-Content -Path $logFile -Value $logEntry - Write-Host $logEntry -} - -Import-Module RubrikSecurityCloud - -if ($checkCluster) { - # Check if SQL instance is running locally - $localNode = $env:COMPUTERNAME - $instanceName = $SqlInstance.Split('\')[1] - $clusterInstance = Get-ClusterResource | Where-Object { $_.ResourceType -eq "SQL Server" -and $_.Name -eq "SQL Server ($instanceName)" } - - if ($clusterInstance) { - $ownerNode = $clusterInstance.OwnerNode - if ($ownerNode -ne $localNode) { - Write-Log "SQL instance '$SqlInstance' is not running on local node '$localNode'. Updating the MV." - - Connect-Rsc -ServiceAccountFile $SAFile - Write-Log "Connected to Rubrik Security Cloud." - - $newHost = Get-RscHost -Name $ownerNode -OsType WINDOWS - - $query = New-RscQuery -GqlQuery slaManagedVolumes -AddField Nodes.HostDetail, Nodes.SmbShare, Nodes.ClientConfig, Nodes.ClientConfig.BackupScript, Nodes.ClientConfig.PreBackupScript - $query.var.filter = @(Get-RscType -Name Filter) - $query.var.filter[0].field = "NAME_EXACT_MATCH" - $query.var.filter[0].Texts = $mvName - $mvDetail = $query.Invoke().nodes[0] - - Write-Log "Found Managed Volume: $($mvDetail.Name) (ID: $($mvDetail.Id), Status: $($mvDetail.hostDetail.Status), HostDetail Name: $($mvDetail.hostDetail.Name))" - - $query = New-RscMutation -GqlMutation updateManagedVolume - $query.Var.input = Get-RscType -Name UpdateManagedVolumeInput - $query.Var.input.update = Get-RscType -Name ManagedVolumeUpdateInput - $query.Var.input.update.config = Get-RscType -Name ManagedVolumePatchConfigInput - $query.Var.input.update.slaClientConfig = Get-RscType -Name ManagedVolumePatchSlaClientConfigInput - - $query.Var.input.Id = $mvDetail.Id - $query.Var.input.update.Name = $mvName - $query.Var.input.update.config.SmbDomainName = $mvDetail.SmbShare.DomainName - $query.Var.input.update.config.SmbValidIps = $newHost.Name - $query.Var.input.update.config.SmbValidUsers = $mvDetail.SmbShare.ValidUsers + $mvDetail.SmbShare.ActiveDirectoryGroups - $query.Var.input.update.slaClientConfig.clientHostId = $newHost.Id - $query.Var.input.update.slaClientConfig.channelHostMountPaths = $mvDetail.ClientConfig.ChannelHostMountPaths - $query.Var.input.update.slaClientConfig.backupScriptCommand = $mvDetail.ClientConfig.BackupScript.ScriptCommand - # Only set pre-backup script fields if a pre-backup script was configured - if ($mvDetail.ClientConfig.PreBackupScript.ScriptCommand) { - $query.Var.input.update.slaClientConfig.preBackupScriptCommand = $mvDetail.ClientConfig.PreBackupScript.ScriptCommand - $query.Var.input.update.slaClientConfig.preBackupScriptTimeout = $mvDetail.ClientConfig.PreBackupScript.Timeout - $query.Var.input.update.slaClientConfig.shouldCancelBackupOnPreBackupScriptFailure = $mvDetail.ClientConfig.ShouldCancelBackupOnPreBackupScriptFailure - $query.Var.input.update.slaClientConfig.shouldDisablePreBackupScript = $false - } else { - $query.Var.input.update.slaClientConfig.shouldDisablePreBackupScript = $true - } - - $query.Var.input.update.slaClientConfig.shouldDisablePostBackupScriptOnBackupFailure = $true - $query.Var.input.update.slaClientConfig.shouldDisablePostBackupScriptOnBackupSuccess = $true - - $query.gqlRequest().Variables - - if (-not $dryrun) { - $result = $query.Invoke() - } else { - Write-Log "Dry run mode: Managed Volume update not invoked." - } - - # Now must exit 1 to stop the backup continuing on the wrong node - Disconnect-Rsc - exit 1 - - } else { - Write-Log "SQL instance '$SqlInstance' is running on local node '$localNode'. No action needed." - } - } else { - Write-Log "ERROR: SQL instance '$SqlInstance' not found in cluster resources." - exit 1 - } -} else { - Write-Log "INFO: Cluster check is disabled. Proceeding without verification." -} -