From 7894d4ec97ceddb5319f5f764e4fc01f584a99ac Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 09:56:12 -0400 Subject: [PATCH 01/77] Add Veeam S3 bucket inventory script with NinjaOne WYSIWYG output Queries all S3-compatible object storage repositories from Veeam B&R, collects bucket names and storage usage, and writes an HTML table to a NinjaOne WYSIWYG custom field. Includes PS7 bootstrap for Veeam 12.x module compatibility and dual interactive/RMM execution modes. Co-Authored-By: Claude Sonnet 4.6 --- bdr-veeam/veeam-s3-bucket-inventory.ps1 | 419 ++++++++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 bdr-veeam/veeam-s3-bucket-inventory.ps1 diff --git a/bdr-veeam/veeam-s3-bucket-inventory.ps1 b/bdr-veeam/veeam-s3-bucket-inventory.ps1 new file mode 100644 index 0000000..9b02e6e --- /dev/null +++ b/bdr-veeam/veeam-s3-bucket-inventory.ps1 @@ -0,0 +1,419 @@ +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## THIS IS HOW WE EASILY LET PEOPLE KNOW WHAT VARIABLES NEED SET IN THE RMM +## $env:CUSTOM_FIELD_S3_INVENTORY - Name of the NinjaOne WYSIWYG custom field to write the HTML inventory table to +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform +## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) + +# ============================================================ +# PS7 BOOTSTRAP +# Veeam.Backup.PowerShell in Veeam 12.x requires PowerShell 7+. +# If we are running under PS5, re-launch this script in pwsh.exe. +# NOTE: No #Requires directive here - the bootstrap handles version +# enforcement gracefully without a hard parse-time exit. +# ============================================================ +if ($PSVersionTable.PSVersion.Major -lt 7) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } + if (-not $PWSH_PATH) { + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + } + if (Test-Path $PWSH_PATH) { + Write-Host "PowerShell $($PSVersionTable.PSVersion) detected. Re-launching in PowerShell 7 at: $PWSH_PATH" + $PS_ARGS = @('-NonInteractive', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $MyInvocation.MyCommand.Path) + & $PWSH_PATH @PS_ARGS + exit $LASTEXITCODE + } else { + Write-Warning "PowerShell 7 (pwsh.exe) not found. Attempting to continue in PS$($PSVersionTable.PSVersion.Major), but Veeam module load may fail." + } +} + +# ============================================================ +# SECTION 1: RMM VARIABLE DECLARATION +# ============================================================ + +$SCRIPT_LOG_NAME = "veeam-s3-bucket-inventory.log" + +# ============================================================ +# SECTION 2: INPUT HANDLING +# ============================================================ + +if ($env:RMM -ne "1") { + # Interactive mode + $ValidInput = 0 + while ($ValidInput -ne 1) { + $DESCRIPTION = Read-Host "Please enter the ticket # and/or your initials (used for audit trail)" + if ($DESCRIPTION) { + $ValidInput = 1 + } else { + Write-Host "Invalid input. Please try again." + } + } + + $ValidInput = 0 + while ($ValidInput -ne 1) { + $CUSTOM_FIELD_S3_INVENTORY = Read-Host "Enter the NinjaOne WYSIWYG custom field name to write the inventory table to (leave blank to skip)" + # Allow blank - skip NinjaOne write if not provided + $ValidInput = 1 + } + + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" + +} else { + # RMM mode - pull from environment variables + $DESCRIPTION = $env:DESCRIPTION + $CUSTOM_FIELD_S3_INVENTORY = $env:CUSTOM_FIELD_S3_INVENTORY + $RMM_SCRIPT_PATH = $env:RMM_SCRIPT_PATH + + if ($RMM_SCRIPT_PATH) { + $LOG_DIR = "$RMM_SCRIPT_PATH\logs" + if (-not (Test-Path $LOG_DIR)) { + New-Item -ItemType Directory -Path $LOG_DIR -Force | Out-Null + } + $LOG_PATH = "$LOG_DIR\$SCRIPT_LOG_NAME" + } else { + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" + } + + if (-not $DESCRIPTION) { + Write-Host "DESCRIPTION is null. This was most likely run automatically from the RMM with no description passed." + $DESCRIPTION = "No Description" + } +} + +# ============================================================ +# SECTION 3: SCRIPT LOGIC +# ============================================================ + +Start-Transcript -Path $LOG_PATH + +Write-Host "=== Veeam S3 Bucket Inventory ===" +Write-Host "Description: $DESCRIPTION" +Write-Host "Log path: $LOG_PATH" +Write-Host "RMM mode: $($env:RMM -eq '1')" +Write-Host "PS version: $($PSVersionTable.PSVersion)" +Write-Host "Custom field: $CUSTOM_FIELD_S3_INVENTORY" +Write-Host "" + +# ------------------------------------------------------------ +# Load Veeam PowerShell module +# ------------------------------------------------------------ +Write-Host "Loading Veeam PowerShell module..." +$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" +$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" + +if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { + try { + $VBR_MODULES | Import-Module -WarningAction SilentlyContinue + Write-Host " [OK] Veeam module loaded." + } catch { + Stop-Transcript + throw "Failed to load Veeam modules: $_" + } +} else { + Stop-Transcript + throw "Veeam.Backup.PowerShell module not found. Is the Veeam console installed on this machine?" +} + +# ------------------------------------------------------------ +# Connect to local VBR server +# ------------------------------------------------------------ +Write-Host "Connecting to local VBR server..." +try { + Connect-VBRServer -Server localhost -ErrorAction Stop + Write-Host " [OK] Connected to VBR server." +} catch { + Stop-Transcript + throw "Failed to connect to VBR server: $_" +} + +# ------------------------------------------------------------ +# Collect S3-compatible object storage repositories +# ------------------------------------------------------------ +Write-Host "" +Write-Host "Querying S3-compatible object storage repositories..." + +$REPO_ROWS = [System.Collections.Generic.List[hashtable]]::new() +$SCAN_TIMESTAMP = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + +# Primary method: Get-VBRObjectStorageRepository (Veeam 12+) +# Returns dedicated object storage repo objects with UsedSpace, BucketName, etc. +$OBJECT_STORAGE_REPOS = $null +try { + $OBJECT_STORAGE_REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop + Write-Host " [OK] Get-VBRObjectStorageRepository returned $($OBJECT_STORAGE_REPOS.Count) repositories." +} catch { + Write-Warning " Get-VBRObjectStorageRepository not available or failed: $_" + Write-Host " Falling back to Get-VBRBackupRepository filter..." +} + +# Fallback: filter all repos for S3-compatible types +$FALLBACK_REPOS = $null +if (-not $OBJECT_STORAGE_REPOS) { + try { + $FALLBACK_REPOS = Get-VBRBackupRepository | Where-Object { + $_.Type -like "AmazonS3*" -or $_.Type -eq "S3Compatible" + } + Write-Host " [OK] Fallback found $($FALLBACK_REPOS.Count) S3-compatible repositories." + } catch { + Write-Warning " Fallback repository query failed: $_" + } +} + +# Process Get-VBRObjectStorageRepository results (preferred path) +if ($OBJECT_STORAGE_REPOS) { + foreach ($REPO in $OBJECT_STORAGE_REPOS) { + Write-Host " Processing: $($REPO.Name)" + + # Bucket name - available as a direct property on the object storage repo + $BUCKET_NAME = "N/A" + try { + if ($REPO.BucketName) { $BUCKET_NAME = $REPO.BucketName } + elseif ($REPO.Bucket) { $BUCKET_NAME = $REPO.Bucket } + elseif ($REPO.AmazonS3Folder -and $REPO.AmazonS3Folder.BucketName) { $BUCKET_NAME = $REPO.AmazonS3Folder.BucketName } + } catch { } + + # Service endpoint / region + $ENDPOINT = "N/A" + try { + if ($REPO.ConnectionInfo -and $REPO.ConnectionInfo.Endpoint) { $ENDPOINT = $REPO.ConnectionInfo.Endpoint } + elseif ($REPO.ServicePoint) { $ENDPOINT = $REPO.ServicePoint } + elseif ($REPO.Endpoint) { $ENDPOINT = $REPO.Endpoint } + } catch { } + + # Used space - Veeam 12.1+ exposes UsedSpace in bytes + $USED_SPACE_DISPLAY = "N/A" + try { + if ($null -ne $REPO.UsedSpace -and $REPO.UsedSpace -gt 0) { + $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $REPO.UsedSpace + } elseif ($null -ne $REPO.UsedSpaceGB -and $REPO.UsedSpaceGB -gt 0) { + $USED_SPACE_DISPLAY = "{0:N2} GB" -f $REPO.UsedSpaceGB + } + } catch { } + + # Total capacity + $TOTAL_SPACE_DISPLAY = "N/A" + try { + if ($null -ne $REPO.TotalSpace -and $REPO.TotalSpace -gt 0) { + $TOTAL_SPACE_DISPLAY = Format-SizeBytes -Bytes $REPO.TotalSpace + } + } catch { } + + # Description / type label + $REPO_TYPE = "N/A" + try { + if ($REPO.Type) { $REPO_TYPE = $REPO.Type.ToString() } + } catch { } + + $REPO_ROWS.Add(@{ + RepoName = $REPO.Name + BucketName = $BUCKET_NAME + Endpoint = $ENDPOINT + RepoType = $REPO_TYPE + UsedSpace = $USED_SPACE_DISPLAY + TotalSpace = $TOTAL_SPACE_DISPLAY + }) + } +} + +# Process fallback results (Get-VBRBackupRepository filtered) +if ($FALLBACK_REPOS) { + foreach ($REPO in $FALLBACK_REPOS) { + Write-Host " Processing (fallback): $($REPO.Name)" + + $BUCKET_NAME = "N/A" + try { + # Extents may expose bucket info for SOBR extents + $EXTENTS = $null + if ($REPO.PSObject.Methods.Name -contains 'GetExtentList') { + $EXTENTS = $REPO.GetExtentList() + } + if ($EXTENTS) { + $FIRST_EXTENT = $EXTENTS | Select-Object -First 1 + if ($FIRST_EXTENT.AmazonS3Folder -and $FIRST_EXTENT.AmazonS3Folder.BucketName) { + $BUCKET_NAME = $FIRST_EXTENT.AmazonS3Folder.BucketName + } + } + + # Direct property checks on the repo object itself + if ($BUCKET_NAME -eq "N/A") { + if ($REPO.AmazonS3Folder -and $REPO.AmazonS3Folder.BucketName) { $BUCKET_NAME = $REPO.AmazonS3Folder.BucketName } + elseif ($REPO.Info -and $REPO.Info.BucketName) { $BUCKET_NAME = $REPO.Info.BucketName } + elseif ($REPO.BucketName) { $BUCKET_NAME = $REPO.BucketName } + } + } catch { } + + $ENDPOINT = "N/A" + try { + if ($REPO.ServicePoint) { $ENDPOINT = $REPO.ServicePoint } + elseif ($REPO.Info -and $REPO.Info.Endpoint) { $ENDPOINT = $REPO.Info.Endpoint } + } catch { } + + # Cached space from repository Info object + $USED_SPACE_DISPLAY = "N/A" + $TOTAL_SPACE_DISPLAY = "N/A" + try { + $CACHED_TOTAL = $REPO.Info.CachedTotalSpace + $CACHED_FREE = $REPO.Info.CachedFreeSpace + if ($null -ne $CACHED_TOTAL -and $CACHED_TOTAL -gt 0) { + $TOTAL_SPACE_DISPLAY = Format-SizeBytes -Bytes $CACHED_TOTAL + if ($null -ne $CACHED_FREE) { + $USED_BYTES = $CACHED_TOTAL - $CACHED_FREE + if ($USED_BYTES -ge 0) { + $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $USED_BYTES + } + } + } + } catch { } + + $REPO_TYPE = "N/A" + try { + if ($REPO.Type) { $REPO_TYPE = $REPO.Type.ToString() } + } catch { } + + $REPO_ROWS.Add(@{ + RepoName = $REPO.Name + BucketName = $BUCKET_NAME + Endpoint = $ENDPOINT + RepoType = $REPO_TYPE + UsedSpace = $USED_SPACE_DISPLAY + TotalSpace = $TOTAL_SPACE_DISPLAY + }) + } +} + +# Disconnect from VBR +try { Disconnect-VBRServer } catch { } + +Write-Host "" +Write-Host "Found $($REPO_ROWS.Count) S3-compatible repositories." +Write-Host "" + +# Log a plain-text summary to the transcript +if ($REPO_ROWS.Count -eq 0) { + Write-Host "No S3-compatible object storage repositories found." +} else { + Write-Host "=== Repository Summary ===" + foreach ($ROW in $REPO_ROWS) { + Write-Host " Repo: $($ROW.RepoName)" + Write-Host " Bucket: $($ROW.BucketName)" + Write-Host " Type: $($ROW.RepoType)" + Write-Host " Endpoint: $($ROW.Endpoint)" + Write-Host " Used Space: $($ROW.UsedSpace)" + Write-Host " Total: $($ROW.TotalSpace)" + Write-Host "" + } +} + +# ------------------------------------------------------------ +# Build HTML table +# ------------------------------------------------------------ +Write-Host "Building HTML table..." + +$HTML_TABLE = Build-HtmlInventoryTable -Rows $REPO_ROWS -ScanTimestamp $SCAN_TIMESTAMP + +Write-Host "HTML table built ($($HTML_TABLE.Length) characters)." + +# ------------------------------------------------------------ +# Write to NinjaOne WYSIWYG custom field +# ------------------------------------------------------------ +if ($CUSTOM_FIELD_S3_INVENTORY) { + Write-Host "" + Write-Host "Writing to NinjaOne custom field: $CUSTOM_FIELD_S3_INVENTORY" + + $NINJA_CMD = Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue + if ($NINJA_CMD) { + try { + Ninja-Property-Set $CUSTOM_FIELD_S3_INVENTORY $HTML_TABLE + Write-Host " [OK] Custom field updated successfully." + } catch { + Write-Warning " Failed to write to NinjaOne custom field: $_" + } + } else { + Write-Warning " Ninja-Property-Set command not found. Skipping custom field write." + Write-Host " HTML content that would have been written:" + Write-Host $HTML_TABLE + } +} else { + Write-Host "No NinjaOne custom field specified. Skipping field write." + Write-Host "" + Write-Host "=== Generated HTML ===" + Write-Host $HTML_TABLE +} + +Write-Host "" +Write-Host "=== Script complete ===" + +Stop-Transcript + +# ============================================================ +# HELPER FUNCTIONS +# (Defined after the main logic block because PowerShell +# resolves function names at parse time, but placing them +# here keeps the readable top-to-bottom narrative intact. +# Move above the logic if preferred.) +# ============================================================ + +function Format-SizeBytes { + param([long]$Bytes) + if ($Bytes -ge 1TB) { return "{0:N2} TB" -f ($Bytes / 1TB) } + if ($Bytes -ge 1GB) { return "{0:N2} GB" -f ($Bytes / 1GB) } + if ($Bytes -ge 1MB) { return "{0:N2} MB" -f ($Bytes / 1MB) } + if ($Bytes -ge 1KB) { return "{0:N2} KB" -f ($Bytes / 1KB) } + return "$Bytes B" +} + +function Build-HtmlInventoryTable { + param( + [System.Collections.Generic.List[hashtable]]$Rows, + [string]$ScanTimestamp + ) + + $NO_DATA_ROW = "" + if ($Rows.Count -eq 0) { + $NO_DATA_ROW = @" + + + No S3-compatible object storage repositories found. + + +"@ + } + + $DATA_ROWS = "" + $ROW_INDEX = 0 + foreach ($ROW in $Rows) { + $BG = if ($ROW_INDEX % 2 -eq 0) { "#ffffff" } else { "#f9fafb" } + $DATA_ROWS += @" + + $([System.Web.HttpUtility]::HtmlEncode($ROW.BucketName)) + $([System.Web.HttpUtility]::HtmlEncode($ROW.RepoName)) + $([System.Web.HttpUtility]::HtmlEncode($ROW.RepoType)) + $([System.Web.HttpUtility]::HtmlEncode($ROW.UsedSpace)) + $([System.Web.HttpUtility]::HtmlEncode($ROW.TotalSpace)) + +"@ + $ROW_INDEX++ + } + + if ($NO_DATA_ROW) { $DATA_ROWS = $NO_DATA_ROW } + + return @" +
+ + + + + + + + + + + +$DATA_ROWS +
Bucket NameRepository NameTypeUsed SpaceTotal Capacity
+

Last scanned: $ScanTimestamp

+
+"@ +} From 0853c8fafc88573d714815073e487c0a3a5c35d8 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 09:58:45 -0400 Subject: [PATCH 02/77] Fix function ordering and HTML encoding for S3 bucket inventory Move helper functions (Format-SizeBytes, Build-HtmlInventoryTable, ConvertTo-SafeHtml) above script logic so they're defined before use. Replace System.Web.HttpUtility dependency with lightweight ConvertTo-SafeHtml function for PS7 compatibility. Use SCREAMING_SNAKE_CASE for VALID_INPUT. Clean up comment header to match repo convention. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-s3-bucket-inventory.ps1 | 187 +++++++++++------------- 1 file changed, 85 insertions(+), 102 deletions(-) diff --git a/bdr-veeam/veeam-s3-bucket-inventory.ps1 b/bdr-veeam/veeam-s3-bucket-inventory.ps1 index 9b02e6e..b00be42 100644 --- a/bdr-veeam/veeam-s3-bucket-inventory.ps1 +++ b/bdr-veeam/veeam-s3-bucket-inventory.ps1 @@ -1,5 +1,4 @@ -## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM -## THIS IS HOW WE EASILY LET PEOPLE KNOW WHAT VARIABLES NEED SET IN THE RMM +## PLEASE SET THE FOLLOWING ENVIRONMENT VARIABLES IN YOUR RMM BEFORE RUNNING ## $env:CUSTOM_FIELD_S3_INVENTORY - Name of the NinjaOne WYSIWYG custom field to write the HTML inventory table to ## $env:DESCRIPTION - Ticket # or initials for audit trail ## $env:RMM - Set to 1 when running from RMM platform @@ -8,9 +7,7 @@ # ============================================================ # PS7 BOOTSTRAP # Veeam.Backup.PowerShell in Veeam 12.x requires PowerShell 7+. -# If we are running under PS5, re-launch this script in pwsh.exe. -# NOTE: No #Requires directive here - the bootstrap handles version -# enforcement gracefully without a hard parse-time exit. +# If running under PS5, re-launch this script in pwsh.exe. # ============================================================ if ($PSVersionTable.PSVersion.Major -lt 7) { $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue @@ -28,6 +25,78 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { } } +# ============================================================ +# HELPER FUNCTIONS +# ============================================================ + +function Format-SizeBytes { + param([long]$Bytes) + if ($Bytes -ge 1TB) { return "{0:N2} TB" -f ($Bytes / 1TB) } + if ($Bytes -ge 1GB) { return "{0:N2} GB" -f ($Bytes / 1GB) } + if ($Bytes -ge 1MB) { return "{0:N2} MB" -f ($Bytes / 1MB) } + if ($Bytes -ge 1KB) { return "{0:N2} KB" -f ($Bytes / 1KB) } + return "$Bytes B" +} + +function ConvertTo-SafeHtml { + param([string]$Text) + if (-not $Text) { return "" } + return $Text.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace('"', """) +} + +function Build-HtmlInventoryTable { + param( + [System.Collections.Generic.List[hashtable]]$Rows, + [string]$ScanTimestamp + ) + + $DATA_ROWS = "" + + if ($Rows.Count -eq 0) { + $DATA_ROWS = @" + + + No S3-compatible object storage repositories found. + + +"@ + } else { + $ROW_INDEX = 0 + foreach ($ROW in $Rows) { + $BG = if ($ROW_INDEX % 2 -eq 0) { "#ffffff" } else { "#f9fafb" } + $DATA_ROWS += @" + + $(ConvertTo-SafeHtml $ROW.BucketName) + $(ConvertTo-SafeHtml $ROW.RepoName) + $(ConvertTo-SafeHtml $ROW.RepoType) + $(ConvertTo-SafeHtml $ROW.UsedSpace) + $(ConvertTo-SafeHtml $ROW.TotalSpace) + +"@ + $ROW_INDEX++ + } + } + + return @" +
+ + + + + + + + + + + +$DATA_ROWS +
Bucket NameRepository NameTypeUsed SpaceTotal Capacity
+

Last scanned: $ScanTimestamp

+
+"@ +} + # ============================================================ # SECTION 1: RMM VARIABLE DECLARATION # ============================================================ @@ -40,30 +109,25 @@ $SCRIPT_LOG_NAME = "veeam-s3-bucket-inventory.log" if ($env:RMM -ne "1") { # Interactive mode - $ValidInput = 0 - while ($ValidInput -ne 1) { + $VALID_INPUT = 0 + while ($VALID_INPUT -ne 1) { $DESCRIPTION = Read-Host "Please enter the ticket # and/or your initials (used for audit trail)" if ($DESCRIPTION) { - $ValidInput = 1 + $VALID_INPUT = 1 } else { Write-Host "Invalid input. Please try again." } } - $ValidInput = 0 - while ($ValidInput -ne 1) { - $CUSTOM_FIELD_S3_INVENTORY = Read-Host "Enter the NinjaOne WYSIWYG custom field name to write the inventory table to (leave blank to skip)" - # Allow blank - skip NinjaOne write if not provided - $ValidInput = 1 - } + $CUSTOM_FIELD_S3_INVENTORY = Read-Host "Enter the NinjaOne WYSIWYG custom field name to write the inventory table to (leave blank to skip)" $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" } else { - # RMM mode - pull from environment variables - $DESCRIPTION = $env:DESCRIPTION + # RMM mode + $DESCRIPTION = $env:DESCRIPTION $CUSTOM_FIELD_S3_INVENTORY = $env:CUSTOM_FIELD_S3_INVENTORY - $RMM_SCRIPT_PATH = $env:RMM_SCRIPT_PATH + $RMM_SCRIPT_PATH = $env:RMM_SCRIPT_PATH if ($RMM_SCRIPT_PATH) { $LOG_DIR = "$RMM_SCRIPT_PATH\logs" @@ -137,7 +201,6 @@ $REPO_ROWS = [System.Collections.Generic.List[hashtable]]::new() $SCAN_TIMESTAMP = Get-Date -Format "yyyy-MM-dd HH:mm:ss" # Primary method: Get-VBRObjectStorageRepository (Veeam 12+) -# Returns dedicated object storage repo objects with UsedSpace, BucketName, etc. $OBJECT_STORAGE_REPOS = $null try { $OBJECT_STORAGE_REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop @@ -165,7 +228,6 @@ if ($OBJECT_STORAGE_REPOS) { foreach ($REPO in $OBJECT_STORAGE_REPOS) { Write-Host " Processing: $($REPO.Name)" - # Bucket name - available as a direct property on the object storage repo $BUCKET_NAME = "N/A" try { if ($REPO.BucketName) { $BUCKET_NAME = $REPO.BucketName } @@ -173,7 +235,6 @@ if ($OBJECT_STORAGE_REPOS) { elseif ($REPO.AmazonS3Folder -and $REPO.AmazonS3Folder.BucketName) { $BUCKET_NAME = $REPO.AmazonS3Folder.BucketName } } catch { } - # Service endpoint / region $ENDPOINT = "N/A" try { if ($REPO.ConnectionInfo -and $REPO.ConnectionInfo.Endpoint) { $ENDPOINT = $REPO.ConnectionInfo.Endpoint } @@ -181,7 +242,6 @@ if ($OBJECT_STORAGE_REPOS) { elseif ($REPO.Endpoint) { $ENDPOINT = $REPO.Endpoint } } catch { } - # Used space - Veeam 12.1+ exposes UsedSpace in bytes $USED_SPACE_DISPLAY = "N/A" try { if ($null -ne $REPO.UsedSpace -and $REPO.UsedSpace -gt 0) { @@ -191,7 +251,6 @@ if ($OBJECT_STORAGE_REPOS) { } } catch { } - # Total capacity $TOTAL_SPACE_DISPLAY = "N/A" try { if ($null -ne $REPO.TotalSpace -and $REPO.TotalSpace -gt 0) { @@ -199,10 +258,9 @@ if ($OBJECT_STORAGE_REPOS) { } } catch { } - # Description / type label $REPO_TYPE = "N/A" try { - if ($REPO.Type) { $REPO_TYPE = $REPO.Type.ToString() } + if ($REPO.Type) { $REPO_TYPE = $REPO.Type.ToString() } } catch { } $REPO_ROWS.Add(@{ @@ -223,7 +281,6 @@ if ($FALLBACK_REPOS) { $BUCKET_NAME = "N/A" try { - # Extents may expose bucket info for SOBR extents $EXTENTS = $null if ($REPO.PSObject.Methods.Name -contains 'GetExtentList') { $EXTENTS = $REPO.GetExtentList() @@ -235,7 +292,6 @@ if ($FALLBACK_REPOS) { } } - # Direct property checks on the repo object itself if ($BUCKET_NAME -eq "N/A") { if ($REPO.AmazonS3Folder -and $REPO.AmazonS3Folder.BucketName) { $BUCKET_NAME = $REPO.AmazonS3Folder.BucketName } elseif ($REPO.Info -and $REPO.Info.BucketName) { $BUCKET_NAME = $REPO.Info.BucketName } @@ -245,11 +301,10 @@ if ($FALLBACK_REPOS) { $ENDPOINT = "N/A" try { - if ($REPO.ServicePoint) { $ENDPOINT = $REPO.ServicePoint } - elseif ($REPO.Info -and $REPO.Info.Endpoint) { $ENDPOINT = $REPO.Info.Endpoint } + if ($REPO.ServicePoint) { $ENDPOINT = $REPO.ServicePoint } + elseif ($REPO.Info -and $REPO.Info.Endpoint) { $ENDPOINT = $REPO.Info.Endpoint } } catch { } - # Cached space from repository Info object $USED_SPACE_DISPLAY = "N/A" $TOTAL_SPACE_DISPLAY = "N/A" try { @@ -289,7 +344,7 @@ Write-Host "" Write-Host "Found $($REPO_ROWS.Count) S3-compatible repositories." Write-Host "" -# Log a plain-text summary to the transcript +# Log plain-text summary to transcript if ($REPO_ROWS.Count -eq 0) { Write-Host "No S3-compatible object storage repositories found." } else { @@ -345,75 +400,3 @@ Write-Host "" Write-Host "=== Script complete ===" Stop-Transcript - -# ============================================================ -# HELPER FUNCTIONS -# (Defined after the main logic block because PowerShell -# resolves function names at parse time, but placing them -# here keeps the readable top-to-bottom narrative intact. -# Move above the logic if preferred.) -# ============================================================ - -function Format-SizeBytes { - param([long]$Bytes) - if ($Bytes -ge 1TB) { return "{0:N2} TB" -f ($Bytes / 1TB) } - if ($Bytes -ge 1GB) { return "{0:N2} GB" -f ($Bytes / 1GB) } - if ($Bytes -ge 1MB) { return "{0:N2} MB" -f ($Bytes / 1MB) } - if ($Bytes -ge 1KB) { return "{0:N2} KB" -f ($Bytes / 1KB) } - return "$Bytes B" -} - -function Build-HtmlInventoryTable { - param( - [System.Collections.Generic.List[hashtable]]$Rows, - [string]$ScanTimestamp - ) - - $NO_DATA_ROW = "" - if ($Rows.Count -eq 0) { - $NO_DATA_ROW = @" - - - No S3-compatible object storage repositories found. - - -"@ - } - - $DATA_ROWS = "" - $ROW_INDEX = 0 - foreach ($ROW in $Rows) { - $BG = if ($ROW_INDEX % 2 -eq 0) { "#ffffff" } else { "#f9fafb" } - $DATA_ROWS += @" - - $([System.Web.HttpUtility]::HtmlEncode($ROW.BucketName)) - $([System.Web.HttpUtility]::HtmlEncode($ROW.RepoName)) - $([System.Web.HttpUtility]::HtmlEncode($ROW.RepoType)) - $([System.Web.HttpUtility]::HtmlEncode($ROW.UsedSpace)) - $([System.Web.HttpUtility]::HtmlEncode($ROW.TotalSpace)) - -"@ - $ROW_INDEX++ - } - - if ($NO_DATA_ROW) { $DATA_ROWS = $NO_DATA_ROW } - - return @" -
- - - - - - - - - - - -$DATA_ROWS -
Bucket NameRepository NameTypeUsed SpaceTotal Capacity
-

Last scanned: $ScanTimestamp

-
-"@ -} From df1e8822f77e56c9cde89986b980dd6d6cda46c2 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 10:01:07 -0400 Subject: [PATCH 03/77] Use $env: variables directly instead of local variable copies RMM injects variables as environment variables. Read them directly via $env:DESCRIPTION and $env:CUSTOM_FIELD_S3_INVENTORY throughout the script instead of copying to local variables. Interactive mode now writes to $env: too for consistency. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-s3-bucket-inventory.ps1 | 32 +++++++++++-------------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/bdr-veeam/veeam-s3-bucket-inventory.ps1 b/bdr-veeam/veeam-s3-bucket-inventory.ps1 index b00be42..501c64b 100644 --- a/bdr-veeam/veeam-s3-bucket-inventory.ps1 +++ b/bdr-veeam/veeam-s3-bucket-inventory.ps1 @@ -108,29 +108,25 @@ $SCRIPT_LOG_NAME = "veeam-s3-bucket-inventory.log" # ============================================================ if ($env:RMM -ne "1") { - # Interactive mode + # Interactive mode - prompt and store directly in env vars $VALID_INPUT = 0 while ($VALID_INPUT -ne 1) { - $DESCRIPTION = Read-Host "Please enter the ticket # and/or your initials (used for audit trail)" - if ($DESCRIPTION) { + $env:DESCRIPTION = Read-Host "Please enter the ticket # and/or your initials (used for audit trail)" + if ($env:DESCRIPTION) { $VALID_INPUT = 1 } else { Write-Host "Invalid input. Please try again." } } - $CUSTOM_FIELD_S3_INVENTORY = Read-Host "Enter the NinjaOne WYSIWYG custom field name to write the inventory table to (leave blank to skip)" + $env:CUSTOM_FIELD_S3_INVENTORY = Read-Host "Enter the NinjaOne WYSIWYG custom field name to write the inventory table to (leave blank to skip)" $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" } else { - # RMM mode - $DESCRIPTION = $env:DESCRIPTION - $CUSTOM_FIELD_S3_INVENTORY = $env:CUSTOM_FIELD_S3_INVENTORY - $RMM_SCRIPT_PATH = $env:RMM_SCRIPT_PATH - - if ($RMM_SCRIPT_PATH) { - $LOG_DIR = "$RMM_SCRIPT_PATH\logs" + # RMM mode - env vars are already set by the RMM platform + if ($env:RMM_SCRIPT_PATH) { + $LOG_DIR = "$env:RMM_SCRIPT_PATH\logs" if (-not (Test-Path $LOG_DIR)) { New-Item -ItemType Directory -Path $LOG_DIR -Force | Out-Null } @@ -139,9 +135,9 @@ if ($env:RMM -ne "1") { $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" } - if (-not $DESCRIPTION) { + if (-not $env:DESCRIPTION) { Write-Host "DESCRIPTION is null. This was most likely run automatically from the RMM with no description passed." - $DESCRIPTION = "No Description" + $env:DESCRIPTION = "No Description" } } @@ -152,11 +148,11 @@ if ($env:RMM -ne "1") { Start-Transcript -Path $LOG_PATH Write-Host "=== Veeam S3 Bucket Inventory ===" -Write-Host "Description: $DESCRIPTION" +Write-Host "Description: $env:DESCRIPTION" Write-Host "Log path: $LOG_PATH" Write-Host "RMM mode: $($env:RMM -eq '1')" Write-Host "PS version: $($PSVersionTable.PSVersion)" -Write-Host "Custom field: $CUSTOM_FIELD_S3_INVENTORY" +Write-Host "Custom field: $env:CUSTOM_FIELD_S3_INVENTORY" Write-Host "" # ------------------------------------------------------------ @@ -372,14 +368,14 @@ Write-Host "HTML table built ($($HTML_TABLE.Length) characters)." # ------------------------------------------------------------ # Write to NinjaOne WYSIWYG custom field # ------------------------------------------------------------ -if ($CUSTOM_FIELD_S3_INVENTORY) { +if ($env:CUSTOM_FIELD_S3_INVENTORY) { Write-Host "" - Write-Host "Writing to NinjaOne custom field: $CUSTOM_FIELD_S3_INVENTORY" + Write-Host "Writing to NinjaOne custom field: $env:CUSTOM_FIELD_S3_INVENTORY" $NINJA_CMD = Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue if ($NINJA_CMD) { try { - Ninja-Property-Set $CUSTOM_FIELD_S3_INVENTORY $HTML_TABLE + Ninja-Property-Set $env:CUSTOM_FIELD_S3_INVENTORY $HTML_TABLE Write-Host " [OK] Custom field updated successfully." } catch { Write-Warning " Failed to write to NinjaOne custom field: $_" From 46aaa9b893d9777e498c0cd1ac7420edd1ecc3b6 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 10:29:35 -0400 Subject: [PATCH 04/77] Remove explicit Connect-VBRServer, use implicit local connection Veeam module auto-connects when running on the local BDR server. Connect-VBRServer hits the Identity service which fails in PS7 under SYSTEM context. Other Veeam scripts in the repo use the same implicit connection pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-s3-bucket-inventory.ps1 | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/bdr-veeam/veeam-s3-bucket-inventory.ps1 b/bdr-veeam/veeam-s3-bucket-inventory.ps1 index 501c64b..151ecb7 100644 --- a/bdr-veeam/veeam-s3-bucket-inventory.ps1 +++ b/bdr-veeam/veeam-s3-bucket-inventory.ps1 @@ -175,18 +175,6 @@ if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { throw "Veeam.Backup.PowerShell module not found. Is the Veeam console installed on this machine?" } -# ------------------------------------------------------------ -# Connect to local VBR server -# ------------------------------------------------------------ -Write-Host "Connecting to local VBR server..." -try { - Connect-VBRServer -Server localhost -ErrorAction Stop - Write-Host " [OK] Connected to VBR server." -} catch { - Stop-Transcript - throw "Failed to connect to VBR server: $_" -} - # ------------------------------------------------------------ # Collect S3-compatible object storage repositories # ------------------------------------------------------------ @@ -333,8 +321,6 @@ if ($FALLBACK_REPOS) { } } -# Disconnect from VBR -try { Disconnect-VBRServer } catch { } Write-Host "" Write-Host "Found $($REPO_ROWS.Count) S3-compatible repositories." From 0c40c1bfff0665e2a5f89ec07b56007d8b62728d Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 10:55:54 -0400 Subject: [PATCH 05/77] Rewrite S3 inventory data collection based on Veeam 12 diagnostics Bucket name: drill into ArchiveRepository, AmazonCompatibleOptions, Options, Info sub-objects, and copy job TargetRepository. Direct top-level properties are null on Veeam 12 skeleton objects. Size: sum GetAllStorages() on backup objects per RepositoryId as fallback when repo-level UsedSpace is unavailable. Drop Total Capacity column (S3 buckets have no fixed capacity). Update diag script to test all 5 extraction paths so we can validate which properties actually return data on live BDR servers. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-s3-bucket-inventory.ps1 | 234 +++++++++++++----------- bdr-veeam/veeam-s3-diag.ps1 | 174 ++++++++++++++++++ 2 files changed, 306 insertions(+), 102 deletions(-) create mode 100644 bdr-veeam/veeam-s3-diag.ps1 diff --git a/bdr-veeam/veeam-s3-bucket-inventory.ps1 b/bdr-veeam/veeam-s3-bucket-inventory.ps1 index 151ecb7..84fa453 100644 --- a/bdr-veeam/veeam-s3-bucket-inventory.ps1 +++ b/bdr-veeam/veeam-s3-bucket-inventory.ps1 @@ -55,7 +55,7 @@ function Build-HtmlInventoryTable { if ($Rows.Count -eq 0) { $DATA_ROWS = @" - + No S3-compatible object storage repositories found. @@ -70,7 +70,6 @@ function Build-HtmlInventoryTable { $(ConvertTo-SafeHtml $ROW.RepoName) $(ConvertTo-SafeHtml $ROW.RepoType) $(ConvertTo-SafeHtml $ROW.UsedSpace) - $(ConvertTo-SafeHtml $ROW.TotalSpace) "@ $ROW_INDEX++ @@ -86,7 +85,6 @@ function Build-HtmlInventoryTable { Repository Name Type Used Space - Total Capacity @@ -184,141 +182,175 @@ Write-Host "Querying S3-compatible object storage repositories..." $REPO_ROWS = [System.Collections.Generic.List[hashtable]]::new() $SCAN_TIMESTAMP = Get-Date -Format "yyyy-MM-dd HH:mm:ss" -# Primary method: Get-VBRObjectStorageRepository (Veeam 12+) +# Get object storage repos $OBJECT_STORAGE_REPOS = $null try { $OBJECT_STORAGE_REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop - Write-Host " [OK] Get-VBRObjectStorageRepository returned $($OBJECT_STORAGE_REPOS.Count) repositories." + Write-Host " [OK] Found $($OBJECT_STORAGE_REPOS.Count) object storage repositories." } catch { - Write-Warning " Get-VBRObjectStorageRepository not available or failed: $_" - Write-Host " Falling back to Get-VBRBackupRepository filter..." + Write-Warning " Get-VBRObjectStorageRepository failed: $_" } -# Fallback: filter all repos for S3-compatible types -$FALLBACK_REPOS = $null if (-not $OBJECT_STORAGE_REPOS) { + # Fallback: filter Get-VBRBackupRepository for S3 types try { - $FALLBACK_REPOS = Get-VBRBackupRepository | Where-Object { - $_.Type -like "AmazonS3*" -or $_.Type -eq "S3Compatible" + $OBJECT_STORAGE_REPOS = Get-VBRBackupRepository | Where-Object { + $_.Type -like "AmazonS3*" -or $_.Type -eq "S3Compatible" -or $_.Type -match "S3" } - Write-Host " [OK] Fallback found $($FALLBACK_REPOS.Count) S3-compatible repositories." + Write-Host " [OK] Fallback found $($OBJECT_STORAGE_REPOS.Count) S3-compatible repositories." } catch { - Write-Warning " Fallback repository query failed: $_" + Write-Warning " Fallback query failed: $_" } } -# Process Get-VBRObjectStorageRepository results (preferred path) -if ($OBJECT_STORAGE_REPOS) { - foreach ($REPO in $OBJECT_STORAGE_REPOS) { - Write-Host " Processing: $($REPO.Name)" - - $BUCKET_NAME = "N/A" +# Build a lookup of backup sizes per RepositoryId by summing GetAllStorages() +Write-Host " Calculating storage sizes from backup objects..." +$SIZE_BY_REPO_ID = @{} +try { + $ALL_BACKUPS = Get-VBRBackup + foreach ($BACKUP in $ALL_BACKUPS) { + $RID = $BACKUP.RepositoryId.ToString() + if (-not $SIZE_BY_REPO_ID.ContainsKey($RID)) { + $SIZE_BY_REPO_ID[$RID] = [long]0 + } try { - if ($REPO.BucketName) { $BUCKET_NAME = $REPO.BucketName } - elseif ($REPO.Bucket) { $BUCKET_NAME = $REPO.Bucket } - elseif ($REPO.AmazonS3Folder -and $REPO.AmazonS3Folder.BucketName) { $BUCKET_NAME = $REPO.AmazonS3Folder.BucketName } + $STORAGES = $BACKUP.GetAllStorages() + foreach ($ST in $STORAGES) { + # Try multiple known size properties on storage objects + $ST_SIZE = 0 + try { if ($ST.Stats -and $ST.Stats.BackupSize -gt 0) { $ST_SIZE = $ST.Stats.BackupSize } } catch {} + if ($ST_SIZE -eq 0) { try { if ($ST.BackupSize -gt 0) { $ST_SIZE = $ST.BackupSize } } catch {} } + if ($ST_SIZE -eq 0) { try { if ($ST.DataSize -gt 0) { $ST_SIZE = $ST.DataSize } } catch {} } + $SIZE_BY_REPO_ID[$RID] += $ST_SIZE + } } catch { } + } +} catch { + Write-Warning " Could not enumerate backups for size calculation: $_" +} - $ENDPOINT = "N/A" - try { - if ($REPO.ConnectionInfo -and $REPO.ConnectionInfo.Endpoint) { $ENDPOINT = $REPO.ConnectionInfo.Endpoint } - elseif ($REPO.ServicePoint) { $ENDPOINT = $REPO.ServicePoint } - elseif ($REPO.Endpoint) { $ENDPOINT = $REPO.Endpoint } - } catch { } +# Also try getting size from backup copy jobs via TargetRepository +Write-Host " Checking backup copy jobs for additional repo details..." +$COPY_JOB_REPOS = @{} +try { + $COPY_JOBS = Get-VBRBackupCopyJob -ErrorAction Stop + foreach ($CJ in $COPY_JOBS) { + if ($null -ne $CJ.TargetRepository) { + $TR = $CJ.TargetRepository + $COPY_JOB_REPOS[$TR.Id.ToString()] = $TR + } + } + Write-Host " [OK] Found $($COPY_JOBS.Count) backup copy jobs." +} catch { + Write-Warning " Get-VBRBackupCopyJob failed: $_" +} + +# Process each S3 repo +foreach ($REPO in $OBJECT_STORAGE_REPOS) { + Write-Host " Processing: $($REPO.Name)" + $REPO_ID = $REPO.Id.ToString() + + # --- BUCKET NAME --- + # Try multiple paths to find the bucket name from sub-objects + $BUCKET_NAME = "N/A" - $USED_SPACE_DISPLAY = "N/A" + # Path 1: ArchiveRepository sub-object + try { + $AR = $REPO.ArchiveRepository + if ($null -ne $AR) { + if ($AR.BucketName) { $BUCKET_NAME = $AR.BucketName } + elseif ($AR.Bucket) { $BUCKET_NAME = $AR.Bucket } + elseif ($AR.AmazonS3Folder -and $AR.AmazonS3Folder.BucketName) { $BUCKET_NAME = $AR.AmazonS3Folder.BucketName } + } + } catch { } + + # Path 2: AmazonCompatibleOptions / Options sub-object + if ($BUCKET_NAME -eq "N/A") { try { - if ($null -ne $REPO.UsedSpace -and $REPO.UsedSpace -gt 0) { - $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $REPO.UsedSpace - } elseif ($null -ne $REPO.UsedSpaceGB -and $REPO.UsedSpaceGB -gt 0) { - $USED_SPACE_DISPLAY = "{0:N2} GB" -f $REPO.UsedSpaceGB + $OPTS = $REPO.AmazonCompatibleOptions + if ($null -eq $OPTS) { $OPTS = $REPO.AmazonOptions } + if ($null -eq $OPTS) { $OPTS = $REPO.Options } + if ($null -ne $OPTS) { + if ($OPTS.BucketName) { $BUCKET_NAME = $OPTS.BucketName } + elseif ($OPTS.Bucket) { $BUCKET_NAME = $OPTS.Bucket } + elseif ($OPTS.AmazonS3Folder -and $OPTS.AmazonS3Folder.BucketName) { $BUCKET_NAME = $OPTS.AmazonS3Folder.BucketName } } } catch { } + } + + # Path 3: Direct top-level properties + if ($BUCKET_NAME -eq "N/A") { + try { if ($REPO.BucketName) { $BUCKET_NAME = $REPO.BucketName } } catch {} + try { if ($BUCKET_NAME -eq "N/A" -and $REPO.Bucket) { $BUCKET_NAME = $REPO.Bucket } } catch {} + try { if ($BUCKET_NAME -eq "N/A" -and $REPO.AmazonS3Folder -and $REPO.AmazonS3Folder.BucketName) { $BUCKET_NAME = $REPO.AmazonS3Folder.BucketName } } catch {} + } - $TOTAL_SPACE_DISPLAY = "N/A" + # Path 4: Copy job TargetRepository (richer object from Get-VBRBackupCopyJob) + if ($BUCKET_NAME -eq "N/A" -and $COPY_JOB_REPOS.ContainsKey($REPO_ID)) { + $CJ_TR = $COPY_JOB_REPOS[$REPO_ID] try { - if ($null -ne $REPO.TotalSpace -and $REPO.TotalSpace -gt 0) { - $TOTAL_SPACE_DISPLAY = Format-SizeBytes -Bytes $REPO.TotalSpace + $CJ_AR = $CJ_TR.ArchiveRepository + if ($null -ne $CJ_AR) { + if ($CJ_AR.BucketName) { $BUCKET_NAME = $CJ_AR.BucketName } + elseif ($CJ_AR.Bucket) { $BUCKET_NAME = $CJ_AR.Bucket } } } catch { } + if ($BUCKET_NAME -eq "N/A") { + try { + $CJ_OPTS = $CJ_TR.AmazonCompatibleOptions + if ($null -eq $CJ_OPTS) { $CJ_OPTS = $CJ_TR.Options } + if ($null -ne $CJ_OPTS -and $CJ_OPTS.BucketName) { $BUCKET_NAME = $CJ_OPTS.BucketName } + } catch { } + } + } - $REPO_TYPE = "N/A" + # Path 5: Info sub-object + if ($BUCKET_NAME -eq "N/A") { try { - if ($REPO.Type) { $REPO_TYPE = $REPO.Type.ToString() } + $INFO = $REPO.Info + if ($null -ne $INFO) { + if ($INFO.BucketName) { $BUCKET_NAME = $INFO.BucketName } + } } catch { } - - $REPO_ROWS.Add(@{ - RepoName = $REPO.Name - BucketName = $BUCKET_NAME - Endpoint = $ENDPOINT - RepoType = $REPO_TYPE - UsedSpace = $USED_SPACE_DISPLAY - TotalSpace = $TOTAL_SPACE_DISPLAY - }) } -} -# Process fallback results (Get-VBRBackupRepository filtered) -if ($FALLBACK_REPOS) { - foreach ($REPO in $FALLBACK_REPOS) { - Write-Host " Processing (fallback): $($REPO.Name)" + # --- USED SPACE --- + $USED_SPACE_DISPLAY = "N/A" - $BUCKET_NAME = "N/A" - try { - $EXTENTS = $null - if ($REPO.PSObject.Methods.Name -contains 'GetExtentList') { - $EXTENTS = $REPO.GetExtentList() - } - if ($EXTENTS) { - $FIRST_EXTENT = $EXTENTS | Select-Object -First 1 - if ($FIRST_EXTENT.AmazonS3Folder -and $FIRST_EXTENT.AmazonS3Folder.BucketName) { - $BUCKET_NAME = $FIRST_EXTENT.AmazonS3Folder.BucketName - } - } - - if ($BUCKET_NAME -eq "N/A") { - if ($REPO.AmazonS3Folder -and $REPO.AmazonS3Folder.BucketName) { $BUCKET_NAME = $REPO.AmazonS3Folder.BucketName } - elseif ($REPO.Info -and $REPO.Info.BucketName) { $BUCKET_NAME = $REPO.Info.BucketName } - elseif ($REPO.BucketName) { $BUCKET_NAME = $REPO.BucketName } - } - } catch { } + # Try direct repo properties first + try { if ($null -ne $REPO.UsedSpace -and $REPO.UsedSpace -gt 0) { $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $REPO.UsedSpace } } catch {} + if ($USED_SPACE_DISPLAY -eq "N/A") { + try { if ($null -ne $REPO.UsedSpaceGB -and $REPO.UsedSpaceGB -gt 0) { $USED_SPACE_DISPLAY = "{0:N2} GB" -f $REPO.UsedSpaceGB } } catch {} + } - $ENDPOINT = "N/A" - try { - if ($REPO.ServicePoint) { $ENDPOINT = $REPO.ServicePoint } - elseif ($REPO.Info -and $REPO.Info.Endpoint) { $ENDPOINT = $REPO.Info.Endpoint } - } catch { } + # Fall back to summed storage sizes from backup objects + if ($USED_SPACE_DISPLAY -eq "N/A" -and $SIZE_BY_REPO_ID.ContainsKey($REPO_ID) -and $SIZE_BY_REPO_ID[$REPO_ID] -gt 0) { + $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $SIZE_BY_REPO_ID[$REPO_ID] + } - $USED_SPACE_DISPLAY = "N/A" - $TOTAL_SPACE_DISPLAY = "N/A" + # Try CachedTotalSpace/CachedFreeSpace from Info + if ($USED_SPACE_DISPLAY -eq "N/A") { try { $CACHED_TOTAL = $REPO.Info.CachedTotalSpace $CACHED_FREE = $REPO.Info.CachedFreeSpace - if ($null -ne $CACHED_TOTAL -and $CACHED_TOTAL -gt 0) { - $TOTAL_SPACE_DISPLAY = Format-SizeBytes -Bytes $CACHED_TOTAL - if ($null -ne $CACHED_FREE) { - $USED_BYTES = $CACHED_TOTAL - $CACHED_FREE - if ($USED_BYTES -ge 0) { - $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $USED_BYTES - } - } + if ($null -ne $CACHED_TOTAL -and $CACHED_TOTAL -gt 0 -and $null -ne $CACHED_FREE) { + $USED_BYTES = $CACHED_TOTAL - $CACHED_FREE + if ($USED_BYTES -ge 0) { $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $USED_BYTES } } } catch { } - - $REPO_TYPE = "N/A" - try { - if ($REPO.Type) { $REPO_TYPE = $REPO.Type.ToString() } - } catch { } - - $REPO_ROWS.Add(@{ - RepoName = $REPO.Name - BucketName = $BUCKET_NAME - Endpoint = $ENDPOINT - RepoType = $REPO_TYPE - UsedSpace = $USED_SPACE_DISPLAY - TotalSpace = $TOTAL_SPACE_DISPLAY - }) } + + # --- REPO TYPE --- + $REPO_TYPE = "N/A" + try { if ($REPO.Type) { $REPO_TYPE = $REPO.Type.ToString() } } catch {} + try { if ($REPO_TYPE -eq "N/A" -and $REPO.TypeDisplay) { $REPO_TYPE = $REPO.TypeDisplay } } catch {} + + $REPO_ROWS.Add(@{ + RepoName = $REPO.Name + BucketName = $BUCKET_NAME + RepoType = $REPO_TYPE + UsedSpace = $USED_SPACE_DISPLAY + }) } @@ -335,9 +367,7 @@ if ($REPO_ROWS.Count -eq 0) { Write-Host " Repo: $($ROW.RepoName)" Write-Host " Bucket: $($ROW.BucketName)" Write-Host " Type: $($ROW.RepoType)" - Write-Host " Endpoint: $($ROW.Endpoint)" Write-Host " Used Space: $($ROW.UsedSpace)" - Write-Host " Total: $($ROW.TotalSpace)" Write-Host "" } } diff --git a/bdr-veeam/veeam-s3-diag.ps1 b/bdr-veeam/veeam-s3-diag.ps1 new file mode 100644 index 0000000..4c86deb --- /dev/null +++ b/bdr-veeam/veeam-s3-diag.ps1 @@ -0,0 +1,174 @@ +# Diagnostic script - test bucket name + size extraction from Veeam +# Run on a BDR server in PS7 to validate which properties return data + +if ($PSVersionTable.PSVersion.Major -lt 7) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { "$env:ProgramFiles\PowerShell\7\pwsh.exe" } + if (Test-Path $PWSH_PATH) { + Write-Host "Re-launching in PS7..." + & $PWSH_PATH -NonInteractive -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path + exit $LASTEXITCODE + } +} + +$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" +$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" +Get-Module -ListAvailable -Name Veeam.Backup.PowerShell | Import-Module -WarningAction SilentlyContinue + +Write-Host "==========================================" +Write-Host "1: ArchiveRepository + AmazonCompatibleOptions + Options + Info" +Write-Host "==========================================" +try { + $REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop + Write-Host "Found $($REPOS.Count) repos`n" + + foreach ($R in $REPOS) { + Write-Host "--- $($R.Name) ---" + + Write-Host "`n .ArchiveRepository:" + if ($null -ne $R.ArchiveRepository) { + Write-Host " Type: $($R.ArchiveRepository.GetType().FullName)" + $R.ArchiveRepository | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($R.ArchiveRepository.$P)" } catch { Write-Host " $P = [ERROR]" } + } + } else { Write-Host " NULL" } + + Write-Host "`n .AmazonCompatibleOptions:" + if ($null -ne $R.AmazonCompatibleOptions) { + Write-Host " Type: $($R.AmazonCompatibleOptions.GetType().FullName)" + $R.AmazonCompatibleOptions | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($R.AmazonCompatibleOptions.$P)" } catch { Write-Host " $P = [ERROR]" } + } + } else { Write-Host " NULL" } + + Write-Host "`n .Options:" + if ($null -ne $R.Options) { + Write-Host " Type: $($R.Options.GetType().FullName)" + $R.Options | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($R.Options.$P)" } catch { Write-Host " $P = [ERROR]" } + } + } else { Write-Host " NULL" } + + Write-Host "`n .Info:" + if ($null -ne $R.Info) { + Write-Host " Type: $($R.Info.GetType().FullName)" + $R.Info | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($R.Info.$P)" } catch { Write-Host " $P = [ERROR]" } + } + } else { Write-Host " NULL" } + + Write-Host "" + } +} catch { Write-Host "FAILED: $_" } + +Write-Host "`n==========================================" +Write-Host "2: Backup.GetAllStorages() on the S3 backup copy" +Write-Host "==========================================" +try { + $B = Get-VBRBackup | Where-Object { $_.TypeToString -eq "Backup Copy" } | Select-Object -First 1 + Write-Host "Backup: $($B.Name)" + Write-Host "RepositoryId: $($B.RepositoryId)`n" + + Write-Host "--- GetAllStorages() ---" + $STORAGES = $B.GetAllStorages() + Write-Host " Count: $($STORAGES.Count)" + + if ($STORAGES.Count -gt 0) { + $FIRST = $STORAGES | Select-Object -First 1 + Write-Host "`n First storage - ALL properties:" + Write-Host " Type: $($FIRST.GetType().FullName)" + $FIRST | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($FIRST.$P)" } catch { Write-Host " $P = [ERROR]" } + } + + # Try Stats sub-object + Write-Host "`n First storage .Stats:" + if ($null -ne $FIRST.Stats) { + $FIRST.Stats | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($FIRST.Stats.$P)" } catch { Write-Host " $P = [ERROR]" } + } + } else { Write-Host " NULL" } + } +} catch { Write-Host "FAILED: $_" } + +Write-Host "`n==========================================" +Write-Host "3: Backup.GetRepository() on the S3 backup copy" +Write-Host "==========================================" +try { + $B = Get-VBRBackup | Where-Object { $_.TypeToString -eq "Backup Copy" } | Select-Object -First 1 + $REPO = $B.GetRepository() + Write-Host " Type: $($REPO.GetType().FullName)" + $REPO | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($REPO.$P)" } catch { Write-Host " $P = [ERROR]" } + } +} catch { Write-Host "FAILED: $_" } + +Write-Host "`n==========================================" +Write-Host "4: Copy job TargetRepository sub-objects" +Write-Host "==========================================" +try { + $CJ = (Get-VBRBackupCopyJob)[0] + $TR = $CJ.TargetRepository + Write-Host "Job: $($CJ.Name)" + Write-Host "TargetRepository: $($TR.Name) ($($TR.GetType().FullName))`n" + + Write-Host " .ArchiveRepository:" + if ($null -ne $TR.ArchiveRepository) { + Write-Host " Type: $($TR.ArchiveRepository.GetType().FullName)" + $TR.ArchiveRepository | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($TR.ArchiveRepository.$P)" } catch { Write-Host " $P = [ERROR]" } + } + } else { Write-Host " NULL" } + + Write-Host "`n .AmazonCompatibleOptions:" + if ($null -ne $TR.AmazonCompatibleOptions) { + Write-Host " Type: $($TR.AmazonCompatibleOptions.GetType().FullName)" + $TR.AmazonCompatibleOptions | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($TR.AmazonCompatibleOptions.$P)" } catch { Write-Host " $P = [ERROR]" } + } + } else { Write-Host " NULL" } + + Write-Host "`n .Options:" + if ($null -ne $TR.Options) { + Write-Host " Type: $($TR.Options.GetType().FullName)" + $TR.Options | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($TR.Options.$P)" } catch { Write-Host " $P = [ERROR]" } + } + } else { Write-Host " NULL" } + + Write-Host "`n .Info:" + if ($null -ne $TR.Info) { + Write-Host " Type: $($TR.Info.GetType().FullName)" + $TR.Info | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($TR.Info.$P)" } catch { Write-Host " $P = [ERROR]" } + } + } else { Write-Host " NULL" } +} catch { Write-Host "FAILED: $_" } + +Write-Host "`n==========================================" +Write-Host "5: PostgreSQL direct query" +Write-Host "==========================================" +try { + # Find psql + $PSQL = Get-Command psql.exe -ErrorAction SilentlyContinue + if (-not $PSQL) { + $PG_PATHS = @( + "C:\Program Files\PostgreSQL\15\bin\psql.exe", + "C:\Program Files\Veeam\Backup and Replication\PostgreSQL\15\bin\psql.exe" + ) + foreach ($PP in $PG_PATHS) { + if (Test-Path $PP) { $PSQL = Get-Item $PP; break } + } + } + if ($PSQL) { + Write-Host " psql found: $($PSQL.FullName)" + Write-Host "`n Databases:" + & $PSQL.FullName -h localhost -U postgres -p 5432 -t -c "SELECT datname FROM pg_database WHERE datistemplate = false;" 2>&1 | ForEach-Object { Write-Host " $_" } + } else { + Write-Host " psql not found" + Write-Host " Checking for PostgreSQL service..." + Get-Service -Name "*postgres*" -ErrorAction SilentlyContinue | ForEach-Object { + Write-Host " Service: $($_.Name) Status: $($_.Status)" + } + } +} catch { Write-Host "FAILED: $_" } From fb7c70694ea15d0f3cceec7036d2b60c8ca96671 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 10:59:14 -0400 Subject: [PATCH 06/77] Use copy job TargetRepository for bucket name, child backups for size Bucket name: Get-VBRObjectStorageRepository returns skeleton objects with null sub-objects on Veeam 12. But Get-VBRBackupCopyJob's TargetRepository has populated AmazonCompatibleOptions.BucketName. Use copy job path as primary source. Size: Parent backups with IsTruePerVmContainer=true have 0 storages. Enumerate FindChildBackups() per-VM children and sum their GetAllStorages() entries for actual size data. Simplified the inventory script by removing dead paths that returned null on real Veeam 12 servers. Updated diag to test child backup size extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-s3-bucket-inventory.ps1 | 147 ++++++--------- bdr-veeam/veeam-s3-diag.ps1 | 228 +++++++++--------------- 2 files changed, 140 insertions(+), 235 deletions(-) diff --git a/bdr-veeam/veeam-s3-bucket-inventory.ps1 b/bdr-veeam/veeam-s3-bucket-inventory.ps1 index 84fa453..521ded2 100644 --- a/bdr-veeam/veeam-s3-bucket-inventory.ps1 +++ b/bdr-veeam/veeam-s3-bucket-inventory.ps1 @@ -182,7 +182,7 @@ Write-Host "Querying S3-compatible object storage repositories..." $REPO_ROWS = [System.Collections.Generic.List[hashtable]]::new() $SCAN_TIMESTAMP = Get-Date -Format "yyyy-MM-dd HH:mm:ss" -# Get object storage repos +# Get object storage repos (for the list of S3 repos + their IDs) $OBJECT_STORAGE_REPOS = $null try { $OBJECT_STORAGE_REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop @@ -192,7 +192,6 @@ try { } if (-not $OBJECT_STORAGE_REPOS) { - # Fallback: filter Get-VBRBackupRepository for S3 types try { $OBJECT_STORAGE_REPOS = Get-VBRBackupRepository | Where-Object { $_.Type -like "AmazonS3*" -or $_.Type -eq "S3Compatible" -or $_.Type -match "S3" @@ -203,7 +202,27 @@ if (-not $OBJECT_STORAGE_REPOS) { } } -# Build a lookup of backup sizes per RepositoryId by summing GetAllStorages() +# Get backup copy jobs - their TargetRepository has populated sub-objects +# (Get-VBRObjectStorageRepository returns skeleton objects with null sub-objects, +# but Get-VBRBackupCopyJob TargetRepository has AmazonCompatibleOptions.BucketName etc.) +Write-Host " Querying backup copy jobs for bucket details..." +$COPY_JOB_REPOS = @{} +try { + $COPY_JOBS = Get-VBRBackupCopyJob -ErrorAction Stop + foreach ($CJ in $COPY_JOBS) { + if ($null -ne $CJ.TargetRepository) { + $TR = $CJ.TargetRepository + $COPY_JOB_REPOS[$TR.Id.ToString()] = $TR + } + } + Write-Host " [OK] Found $($COPY_JOBS.Count) backup copy jobs." +} catch { + Write-Warning " Get-VBRBackupCopyJob failed: $_" +} + +# Calculate storage sizes per repo by summing backup storages +# Parent backups with IsTruePerVmContainer have 0 storages, so we +# enumerate child backups via FindChildBackups() to get actual data Write-Host " Calculating storage sizes from backup objects..." $SIZE_BY_REPO_ID = @{} try { @@ -213,36 +232,31 @@ try { if (-not $SIZE_BY_REPO_ID.ContainsKey($RID)) { $SIZE_BY_REPO_ID[$RID] = [long]0 } + + # Collect backups to check: the backup itself + any child backups + $BACKUPS_TO_CHECK = @($BACKUP) try { - $STORAGES = $BACKUP.GetAllStorages() - foreach ($ST in $STORAGES) { - # Try multiple known size properties on storage objects - $ST_SIZE = 0 - try { if ($ST.Stats -and $ST.Stats.BackupSize -gt 0) { $ST_SIZE = $ST.Stats.BackupSize } } catch {} - if ($ST_SIZE -eq 0) { try { if ($ST.BackupSize -gt 0) { $ST_SIZE = $ST.BackupSize } } catch {} } - if ($ST_SIZE -eq 0) { try { if ($ST.DataSize -gt 0) { $ST_SIZE = $ST.DataSize } } catch {} } - $SIZE_BY_REPO_ID[$RID] += $ST_SIZE + if ($BACKUP.IsTruePerVmContainer) { + $CHILDREN = $BACKUP.FindChildBackups() + if ($CHILDREN) { $BACKUPS_TO_CHECK += $CHILDREN } } } catch { } - } -} catch { - Write-Warning " Could not enumerate backups for size calculation: $_" -} -# Also try getting size from backup copy jobs via TargetRepository -Write-Host " Checking backup copy jobs for additional repo details..." -$COPY_JOB_REPOS = @{} -try { - $COPY_JOBS = Get-VBRBackupCopyJob -ErrorAction Stop - foreach ($CJ in $COPY_JOBS) { - if ($null -ne $CJ.TargetRepository) { - $TR = $CJ.TargetRepository - $COPY_JOB_REPOS[$TR.Id.ToString()] = $TR + foreach ($B in $BACKUPS_TO_CHECK) { + try { + $STORAGES = $B.GetAllStorages() + foreach ($ST in $STORAGES) { + $ST_SIZE = [long]0 + try { if ($ST.Stats -and $ST.Stats.BackupSize -gt 0) { $ST_SIZE = [long]$ST.Stats.BackupSize } } catch {} + if ($ST_SIZE -eq 0) { try { if ($ST.BackupSize -gt 0) { $ST_SIZE = [long]$ST.BackupSize } } catch {} } + if ($ST_SIZE -eq 0) { try { if ($ST.DataSize -gt 0) { $ST_SIZE = [long]$ST.DataSize } } catch {} } + $SIZE_BY_REPO_ID[$RID] += $ST_SIZE + } + } catch { } } } - Write-Host " [OK] Found $($COPY_JOBS.Count) backup copy jobs." } catch { - Write-Warning " Get-VBRBackupCopyJob failed: $_" + Write-Warning " Could not enumerate backups for size calculation: $_" } # Process each S3 repo @@ -251,95 +265,46 @@ foreach ($REPO in $OBJECT_STORAGE_REPOS) { $REPO_ID = $REPO.Id.ToString() # --- BUCKET NAME --- - # Try multiple paths to find the bucket name from sub-objects $BUCKET_NAME = "N/A" - # Path 1: ArchiveRepository sub-object - try { - $AR = $REPO.ArchiveRepository - if ($null -ne $AR) { - if ($AR.BucketName) { $BUCKET_NAME = $AR.BucketName } - elseif ($AR.Bucket) { $BUCKET_NAME = $AR.Bucket } - elseif ($AR.AmazonS3Folder -and $AR.AmazonS3Folder.BucketName) { $BUCKET_NAME = $AR.AmazonS3Folder.BucketName } - } - } catch { } - - # Path 2: AmazonCompatibleOptions / Options sub-object - if ($BUCKET_NAME -eq "N/A") { + # Primary: copy job TargetRepository (confirmed working on Veeam 12) + # AmazonCompatibleOptions.BucketName is populated here but NOT on + # objects from Get-VBRObjectStorageRepository (those are skeleton/null) + if ($COPY_JOB_REPOS.ContainsKey($REPO_ID)) { + $CJ_TR = $COPY_JOB_REPOS[$REPO_ID] try { - $OPTS = $REPO.AmazonCompatibleOptions - if ($null -eq $OPTS) { $OPTS = $REPO.AmazonOptions } - if ($null -eq $OPTS) { $OPTS = $REPO.Options } - if ($null -ne $OPTS) { - if ($OPTS.BucketName) { $BUCKET_NAME = $OPTS.BucketName } - elseif ($OPTS.Bucket) { $BUCKET_NAME = $OPTS.Bucket } - elseif ($OPTS.AmazonS3Folder -and $OPTS.AmazonS3Folder.BucketName) { $BUCKET_NAME = $OPTS.AmazonS3Folder.BucketName } + $CJ_OPTS = $CJ_TR.AmazonCompatibleOptions + if ($null -eq $CJ_OPTS) { $CJ_OPTS = $CJ_TR.Options } + if ($null -ne $CJ_OPTS -and $CJ_OPTS.BucketName) { + $BUCKET_NAME = $CJ_OPTS.BucketName } } catch { } } - # Path 3: Direct top-level properties + # Fallback: try direct repo sub-objects (may work on other Veeam versions) if ($BUCKET_NAME -eq "N/A") { - try { if ($REPO.BucketName) { $BUCKET_NAME = $REPO.BucketName } } catch {} - try { if ($BUCKET_NAME -eq "N/A" -and $REPO.Bucket) { $BUCKET_NAME = $REPO.Bucket } } catch {} - try { if ($BUCKET_NAME -eq "N/A" -and $REPO.AmazonS3Folder -and $REPO.AmazonS3Folder.BucketName) { $BUCKET_NAME = $REPO.AmazonS3Folder.BucketName } } catch {} - } - - # Path 4: Copy job TargetRepository (richer object from Get-VBRBackupCopyJob) - if ($BUCKET_NAME -eq "N/A" -and $COPY_JOB_REPOS.ContainsKey($REPO_ID)) { - $CJ_TR = $COPY_JOB_REPOS[$REPO_ID] try { - $CJ_AR = $CJ_TR.ArchiveRepository - if ($null -ne $CJ_AR) { - if ($CJ_AR.BucketName) { $BUCKET_NAME = $CJ_AR.BucketName } - elseif ($CJ_AR.Bucket) { $BUCKET_NAME = $CJ_AR.Bucket } - } + $OPTS = $REPO.AmazonCompatibleOptions + if ($null -eq $OPTS) { $OPTS = $REPO.AmazonOptions } + if ($null -eq $OPTS) { $OPTS = $REPO.Options } + if ($null -ne $OPTS -and $OPTS.BucketName) { $BUCKET_NAME = $OPTS.BucketName } } catch { } - if ($BUCKET_NAME -eq "N/A") { - try { - $CJ_OPTS = $CJ_TR.AmazonCompatibleOptions - if ($null -eq $CJ_OPTS) { $CJ_OPTS = $CJ_TR.Options } - if ($null -ne $CJ_OPTS -and $CJ_OPTS.BucketName) { $BUCKET_NAME = $CJ_OPTS.BucketName } - } catch { } - } } - - # Path 5: Info sub-object if ($BUCKET_NAME -eq "N/A") { - try { - $INFO = $REPO.Info - if ($null -ne $INFO) { - if ($INFO.BucketName) { $BUCKET_NAME = $INFO.BucketName } - } - } catch { } + try { if ($REPO.BucketName) { $BUCKET_NAME = $REPO.BucketName } } catch {} } # --- USED SPACE --- $USED_SPACE_DISPLAY = "N/A" - # Try direct repo properties first + # Try direct repo properties try { if ($null -ne $REPO.UsedSpace -and $REPO.UsedSpace -gt 0) { $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $REPO.UsedSpace } } catch {} - if ($USED_SPACE_DISPLAY -eq "N/A") { - try { if ($null -ne $REPO.UsedSpaceGB -and $REPO.UsedSpaceGB -gt 0) { $USED_SPACE_DISPLAY = "{0:N2} GB" -f $REPO.UsedSpaceGB } } catch {} - } # Fall back to summed storage sizes from backup objects if ($USED_SPACE_DISPLAY -eq "N/A" -and $SIZE_BY_REPO_ID.ContainsKey($REPO_ID) -and $SIZE_BY_REPO_ID[$REPO_ID] -gt 0) { $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $SIZE_BY_REPO_ID[$REPO_ID] } - # Try CachedTotalSpace/CachedFreeSpace from Info - if ($USED_SPACE_DISPLAY -eq "N/A") { - try { - $CACHED_TOTAL = $REPO.Info.CachedTotalSpace - $CACHED_FREE = $REPO.Info.CachedFreeSpace - if ($null -ne $CACHED_TOTAL -and $CACHED_TOTAL -gt 0 -and $null -ne $CACHED_FREE) { - $USED_BYTES = $CACHED_TOTAL - $CACHED_FREE - if ($USED_BYTES -ge 0) { $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $USED_BYTES } - } - } catch { } - } - # --- REPO TYPE --- $REPO_TYPE = "N/A" try { if ($REPO.Type) { $REPO_TYPE = $REPO.Type.ToString() } } catch {} diff --git a/bdr-veeam/veeam-s3-diag.ps1 b/bdr-veeam/veeam-s3-diag.ps1 index 4c86deb..372a0d2 100644 --- a/bdr-veeam/veeam-s3-diag.ps1 +++ b/bdr-veeam/veeam-s3-diag.ps1 @@ -16,159 +16,99 @@ $env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_M Get-Module -ListAvailable -Name Veeam.Backup.PowerShell | Import-Module -WarningAction SilentlyContinue Write-Host "==========================================" -Write-Host "1: ArchiveRepository + AmazonCompatibleOptions + Options + Info" +Write-Host "1: BUCKET NAME (confirmed path)" Write-Host "==========================================" try { - $REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop - Write-Host "Found $($REPOS.Count) repos`n" - - foreach ($R in $REPOS) { - Write-Host "--- $($R.Name) ---" - - Write-Host "`n .ArchiveRepository:" - if ($null -ne $R.ArchiveRepository) { - Write-Host " Type: $($R.ArchiveRepository.GetType().FullName)" - $R.ArchiveRepository | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($R.ArchiveRepository.$P)" } catch { Write-Host " $P = [ERROR]" } - } - } else { Write-Host " NULL" } - - Write-Host "`n .AmazonCompatibleOptions:" - if ($null -ne $R.AmazonCompatibleOptions) { - Write-Host " Type: $($R.AmazonCompatibleOptions.GetType().FullName)" - $R.AmazonCompatibleOptions | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($R.AmazonCompatibleOptions.$P)" } catch { Write-Host " $P = [ERROR]" } - } - } else { Write-Host " NULL" } - - Write-Host "`n .Options:" - if ($null -ne $R.Options) { - Write-Host " Type: $($R.Options.GetType().FullName)" - $R.Options | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($R.Options.$P)" } catch { Write-Host " $P = [ERROR]" } - } - } else { Write-Host " NULL" } - - Write-Host "`n .Info:" - if ($null -ne $R.Info) { - Write-Host " Type: $($R.Info.GetType().FullName)" - $R.Info | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($R.Info.$P)" } catch { Write-Host " $P = [ERROR]" } - } - } else { Write-Host " NULL" } - - Write-Host "" - } -} catch { Write-Host "FAILED: $_" } - -Write-Host "`n==========================================" -Write-Host "2: Backup.GetAllStorages() on the S3 backup copy" -Write-Host "==========================================" -try { - $B = Get-VBRBackup | Where-Object { $_.TypeToString -eq "Backup Copy" } | Select-Object -First 1 - Write-Host "Backup: $($B.Name)" - Write-Host "RepositoryId: $($B.RepositoryId)`n" - - Write-Host "--- GetAllStorages() ---" - $STORAGES = $B.GetAllStorages() - Write-Host " Count: $($STORAGES.Count)" - - if ($STORAGES.Count -gt 0) { - $FIRST = $STORAGES | Select-Object -First 1 - Write-Host "`n First storage - ALL properties:" - Write-Host " Type: $($FIRST.GetType().FullName)" - $FIRST | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($FIRST.$P)" } catch { Write-Host " $P = [ERROR]" } + $COPY_JOBS = Get-VBRBackupCopyJob -ErrorAction Stop + Write-Host "Found $($COPY_JOBS.Count) copy jobs`n" + foreach ($CJ in $COPY_JOBS) { + $TR = $CJ.TargetRepository + Write-Host "--- $($CJ.Name) ---" + Write-Host " Target repo: $($TR.Name)" + + $OPTS = $TR.AmazonCompatibleOptions + if ($null -ne $OPTS) { + Write-Host " BucketName: $($OPTS.BucketName)" + Write-Host " ServicePoint: $($OPTS.ServicePoint)" + Write-Host " RegionId: $($OPTS.RegionId)" + Write-Host " FolderName: $($OPTS.FolderName)" + } else { + Write-Host " AmazonCompatibleOptions: NULL" } - - # Try Stats sub-object - Write-Host "`n First storage .Stats:" - if ($null -ne $FIRST.Stats) { - $FIRST.Stats | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($FIRST.Stats.$P)" } catch { Write-Host " $P = [ERROR]" } - } - } else { Write-Host " NULL" } - } -} catch { Write-Host "FAILED: $_" } - -Write-Host "`n==========================================" -Write-Host "3: Backup.GetRepository() on the S3 backup copy" -Write-Host "==========================================" -try { - $B = Get-VBRBackup | Where-Object { $_.TypeToString -eq "Backup Copy" } | Select-Object -First 1 - $REPO = $B.GetRepository() - Write-Host " Type: $($REPO.GetType().FullName)" - $REPO | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($REPO.$P)" } catch { Write-Host " $P = [ERROR]" } + Write-Host "" } } catch { Write-Host "FAILED: $_" } -Write-Host "`n==========================================" -Write-Host "4: Copy job TargetRepository sub-objects" Write-Host "==========================================" -try { - $CJ = (Get-VBRBackupCopyJob)[0] - $TR = $CJ.TargetRepository - Write-Host "Job: $($CJ.Name)" - Write-Host "TargetRepository: $($TR.Name) ($($TR.GetType().FullName))`n" - - Write-Host " .ArchiveRepository:" - if ($null -ne $TR.ArchiveRepository) { - Write-Host " Type: $($TR.ArchiveRepository.GetType().FullName)" - $TR.ArchiveRepository | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($TR.ArchiveRepository.$P)" } catch { Write-Host " $P = [ERROR]" } - } - } else { Write-Host " NULL" } - - Write-Host "`n .AmazonCompatibleOptions:" - if ($null -ne $TR.AmazonCompatibleOptions) { - Write-Host " Type: $($TR.AmazonCompatibleOptions.GetType().FullName)" - $TR.AmazonCompatibleOptions | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($TR.AmazonCompatibleOptions.$P)" } catch { Write-Host " $P = [ERROR]" } - } - } else { Write-Host " NULL" } - - Write-Host "`n .Options:" - if ($null -ne $TR.Options) { - Write-Host " Type: $($TR.Options.GetType().FullName)" - $TR.Options | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($TR.Options.$P)" } catch { Write-Host " $P = [ERROR]" } - } - } else { Write-Host " NULL" } - - Write-Host "`n .Info:" - if ($null -ne $TR.Info) { - Write-Host " Type: $($TR.Info.GetType().FullName)" - $TR.Info | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($TR.Info.$P)" } catch { Write-Host " $P = [ERROR]" } - } - } else { Write-Host " NULL" } -} catch { Write-Host "FAILED: $_" } - -Write-Host "`n==========================================" -Write-Host "5: PostgreSQL direct query" +Write-Host "2: SIZE - Child backups (TruePerVmContainer)" Write-Host "==========================================" try { - # Find psql - $PSQL = Get-Command psql.exe -ErrorAction SilentlyContinue - if (-not $PSQL) { - $PG_PATHS = @( - "C:\Program Files\PostgreSQL\15\bin\psql.exe", - "C:\Program Files\Veeam\Backup and Replication\PostgreSQL\15\bin\psql.exe" - ) - foreach ($PP in $PG_PATHS) { - if (Test-Path $PP) { $PSQL = Get-Item $PP; break } - } - } - if ($PSQL) { - Write-Host " psql found: $($PSQL.FullName)" - Write-Host "`n Databases:" - & $PSQL.FullName -h localhost -U postgres -p 5432 -t -c "SELECT datname FROM pg_database WHERE datistemplate = false;" 2>&1 | ForEach-Object { Write-Host " $_" } - } else { - Write-Host " psql not found" - Write-Host " Checking for PostgreSQL service..." - Get-Service -Name "*postgres*" -ErrorAction SilentlyContinue | ForEach-Object { - Write-Host " Service: $($_.Name) Status: $($_.Status)" + $ALL_BACKUPS = Get-VBRBackup + Write-Host "Total backups: $($ALL_BACKUPS.Count)`n" + + foreach ($B in $ALL_BACKUPS) { + Write-Host "--- $($B.Name) ---" + Write-Host " Type: $($B.TypeToString)" + Write-Host " RepositoryId: $($B.RepositoryId)" + Write-Host " IsTruePerVmContainer: $($B.IsTruePerVmContainer)" + + # Direct storages + $STORAGES = $B.GetAllStorages() + Write-Host " GetAllStorages count: $($STORAGES.Count)" + + # Child backups + if ($B.IsTruePerVmContainer) { + try { + $CHILDREN = $B.FindChildBackups() + Write-Host " FindChildBackups count: $($CHILDREN.Count)" + + $TOTAL_SIZE = [long]0 + foreach ($CHILD in $CHILDREN) { + $CHILD_STORAGES = $CHILD.GetAllStorages() + $CHILD_SIZE = [long]0 + foreach ($ST in $CHILD_STORAGES) { + # Try every known size property + $ST_SIZE = [long]0 + try { if ($ST.Stats -and $ST.Stats.BackupSize -gt 0) { $ST_SIZE = [long]$ST.Stats.BackupSize } } catch {} + if ($ST_SIZE -eq 0) { try { if ($ST.BackupSize -gt 0) { $ST_SIZE = [long]$ST.BackupSize } } catch {} } + if ($ST_SIZE -eq 0) { try { if ($ST.DataSize -gt 0) { $ST_SIZE = [long]$ST.DataSize } } catch {} } + $CHILD_SIZE += $ST_SIZE + } + if ($CHILD_STORAGES.Count -gt 0) { + Write-Host " Child: $($CHILD.Name) | Storages: $($CHILD_STORAGES.Count) | Size: $CHILD_SIZE" + + # Dump first storage properties on first child with data + if ($TOTAL_SIZE -eq 0 -and $CHILD_STORAGES.Count -gt 0) { + $FIRST_ST = $CHILD_STORAGES | Select-Object -First 1 + Write-Host " First storage type: $($FIRST_ST.GetType().FullName)" + $FIRST_ST | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($FIRST_ST.$P)" } catch { Write-Host " $P = [ERROR]" } + } + if ($null -ne $FIRST_ST.Stats) { + Write-Host " .Stats properties:" + $FIRST_ST.Stats | Get-Member -MemberType Property,NoteProperty | ForEach-Object { + $P = $_.Name; try { Write-Host " $P = $($FIRST_ST.Stats.$P)" } catch { Write-Host " $P = [ERROR]" } + } + } + } + } + $TOTAL_SIZE += $CHILD_SIZE + } + Write-Host " TOTAL child backup size: $TOTAL_SIZE bytes" + if ($TOTAL_SIZE -gt 0) { + if ($TOTAL_SIZE -ge 1TB) { Write-Host " Formatted: $("{0:N2} TB" -f ($TOTAL_SIZE / 1TB))" } + elseif ($TOTAL_SIZE -ge 1GB) { Write-Host " Formatted: $("{0:N2} GB" -f ($TOTAL_SIZE / 1GB))" } + else { Write-Host " Formatted: $("{0:N2} MB" -f ($TOTAL_SIZE / 1MB))" } + } + } catch { Write-Host " FindChildBackups FAILED: $_" } + } elseif ($STORAGES.Count -gt 0) { + # Non-container backup with direct storages + $DIRECT_SIZE = [long]0 + foreach ($ST in $STORAGES) { + try { if ($ST.Stats -and $ST.Stats.BackupSize -gt 0) { $DIRECT_SIZE += [long]$ST.Stats.BackupSize } } catch {} + } + Write-Host " Direct storage size: $DIRECT_SIZE bytes" } + Write-Host "" } } catch { Write-Host "FAILED: $_" } From cf4184671f233f53bd944430e75f26342cfc3bbf Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 11:30:49 -0400 Subject: [PATCH 07/77] Add multi-field output: bucket name, size, orphan detection, HTML tables NinjaOne custom fields: - S3 bucket name (text): last used bucket name for device group columns - S3 bucket size (text): last used bucket size for device group columns - S3 orphans found (integer): 1/0 flag for alerting - S3 inventory (WYSIWYG): full HTML table of all S3 repos - S3 orphans (WYSIWYG): HTML table of orphaned backup data Orphan detection: compares child backup machine names in S3 repos against active machines in source backup jobs linked to copy jobs. Child backups for machines no longer in any active source job are flagged as orphaned. Refactored HTML generation to a generic Build-HtmlTable function and NinjaOne writes to a Set-NinjaField helper. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-s3-bucket-inventory.ps1 | 384 ++++++++++++++++-------- bdr-veeam/veeam-s3-diag.ps1 | 168 ++++++----- 2 files changed, 363 insertions(+), 189 deletions(-) diff --git a/bdr-veeam/veeam-s3-bucket-inventory.ps1 b/bdr-veeam/veeam-s3-bucket-inventory.ps1 index 521ded2..69bf41c 100644 --- a/bdr-veeam/veeam-s3-bucket-inventory.ps1 +++ b/bdr-veeam/veeam-s3-bucket-inventory.ps1 @@ -1,8 +1,12 @@ ## PLEASE SET THE FOLLOWING ENVIRONMENT VARIABLES IN YOUR RMM BEFORE RUNNING -## $env:CUSTOM_FIELD_S3_INVENTORY - Name of the NinjaOne WYSIWYG custom field to write the HTML inventory table to -## $env:DESCRIPTION - Ticket # or initials for audit trail -## $env:RMM - Set to 1 when running from RMM platform -## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) +## $env:CUSTOM_FIELD_S3_BUCKET_NAME - Text field: last used S3 bucket name +## $env:CUSTOM_FIELD_S3_BUCKET_SIZE - Text field: last used S3 bucket size (human readable) +## $env:CUSTOM_FIELD_S3_ORPHANS_FOUND - Integer field: 1 if orphaned backups found, 0 if not +## $env:CUSTOM_FIELD_S3_INVENTORY - WYSIWYG field: HTML table of all S3 repos +## $env:CUSTOM_FIELD_S3_ORPHANS - WYSIWYG field: HTML table of orphaned backup data +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform +## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) # ============================================================ # PS7 BOOTSTRAP @@ -44,19 +48,25 @@ function ConvertTo-SafeHtml { return $Text.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace('"', """) } -function Build-HtmlInventoryTable { +function Build-HtmlTable { param( - [System.Collections.Generic.List[hashtable]]$Rows, - [string]$ScanTimestamp + [string[]]$Headers, + [System.Collections.Generic.List[string[]]]$Rows, + [string]$ScanTimestamp, + [string]$EmptyMessage = "No data found." ) - $DATA_ROWS = "" + $HEADER_CELLS = "" + foreach ($H in $Headers) { + $HEADER_CELLS += " $(ConvertTo-SafeHtml $H)`n" + } + $DATA_ROWS = "" if ($Rows.Count -eq 0) { $DATA_ROWS = @" - - No S3-compatible object storage repositories found. + + $EmptyMessage "@ @@ -64,13 +74,13 @@ function Build-HtmlInventoryTable { $ROW_INDEX = 0 foreach ($ROW in $Rows) { $BG = if ($ROW_INDEX % 2 -eq 0) { "#ffffff" } else { "#f9fafb" } + $CELLS = "" + foreach ($CELL in $ROW) { + $CELLS += " $(ConvertTo-SafeHtml $CELL)`n" + } $DATA_ROWS += @" - $(ConvertTo-SafeHtml $ROW.BucketName) - $(ConvertTo-SafeHtml $ROW.RepoName) - $(ConvertTo-SafeHtml $ROW.RepoType) - $(ConvertTo-SafeHtml $ROW.UsedSpace) - +$CELLS "@ $ROW_INDEX++ } @@ -81,11 +91,7 @@ function Build-HtmlInventoryTable { - - - - - +$HEADER_CELLS $DATA_ROWS @@ -95,6 +101,22 @@ $DATA_ROWS "@ } +function Set-NinjaField { + param([string]$FieldName, [string]$Value) + if (-not $FieldName) { return } + $NINJA_CMD = Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue + if ($NINJA_CMD) { + try { + Ninja-Property-Set $FieldName $Value + Write-Host " [OK] $FieldName updated." + } catch { + Write-Warning " Failed to write $FieldName : $_" + } + } else { + Write-Host " [SKIP] Ninja-Property-Set not available. $FieldName = $($Value.Substring(0, [Math]::Min(100, $Value.Length)))..." + } +} + # ============================================================ # SECTION 1: RMM VARIABLE DECLARATION # ============================================================ @@ -106,7 +128,7 @@ $SCRIPT_LOG_NAME = "veeam-s3-bucket-inventory.log" # ============================================================ if ($env:RMM -ne "1") { - # Interactive mode - prompt and store directly in env vars + # Interactive mode $VALID_INPUT = 0 while ($VALID_INPUT -ne 1) { $env:DESCRIPTION = Read-Host "Please enter the ticket # and/or your initials (used for audit trail)" @@ -117,12 +139,16 @@ if ($env:RMM -ne "1") { } } - $env:CUSTOM_FIELD_S3_INVENTORY = Read-Host "Enter the NinjaOne WYSIWYG custom field name to write the inventory table to (leave blank to skip)" + $env:CUSTOM_FIELD_S3_BUCKET_NAME = Read-Host "NinjaOne text field for S3 bucket name (blank to skip)" + $env:CUSTOM_FIELD_S3_BUCKET_SIZE = Read-Host "NinjaOne text field for S3 bucket size (blank to skip)" + $env:CUSTOM_FIELD_S3_ORPHANS_FOUND = Read-Host "NinjaOne integer field for orphans found flag (blank to skip)" + $env:CUSTOM_FIELD_S3_INVENTORY = Read-Host "NinjaOne WYSIWYG field for S3 inventory table (blank to skip)" + $env:CUSTOM_FIELD_S3_ORPHANS = Read-Host "NinjaOne WYSIWYG field for orphaned backups table (blank to skip)" $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" } else { - # RMM mode - env vars are already set by the RMM platform + # RMM mode if ($env:RMM_SCRIPT_PATH) { $LOG_DIR = "$env:RMM_SCRIPT_PATH\logs" if (-not (Test-Path $LOG_DIR)) { @@ -150,7 +176,6 @@ Write-Host "Description: $env:DESCRIPTION" Write-Host "Log path: $LOG_PATH" Write-Host "RMM mode: $($env:RMM -eq '1')" Write-Host "PS version: $($PSVersionTable.PSVersion)" -Write-Host "Custom field: $env:CUSTOM_FIELD_S3_INVENTORY" Write-Host "" # ------------------------------------------------------------ @@ -174,15 +199,14 @@ if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { } # ------------------------------------------------------------ -# Collect S3-compatible object storage repositories +# Collect data from Veeam # ------------------------------------------------------------ Write-Host "" -Write-Host "Querying S3-compatible object storage repositories..." +Write-Host "Querying Veeam data..." -$REPO_ROWS = [System.Collections.Generic.List[hashtable]]::new() $SCAN_TIMESTAMP = Get-Date -Format "yyyy-MM-dd HH:mm:ss" -# Get object storage repos (for the list of S3 repos + their IDs) +# Get object storage repos $OBJECT_STORAGE_REPOS = $null try { $OBJECT_STORAGE_REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop @@ -190,7 +214,6 @@ try { } catch { Write-Warning " Get-VBRObjectStorageRepository failed: $_" } - if (-not $OBJECT_STORAGE_REPOS) { try { $OBJECT_STORAGE_REPOS = Get-VBRBackupRepository | Where-Object { @@ -202,13 +225,12 @@ if (-not $OBJECT_STORAGE_REPOS) { } } -# Get backup copy jobs - their TargetRepository has populated sub-objects -# (Get-VBRObjectStorageRepository returns skeleton objects with null sub-objects, -# but Get-VBRBackupCopyJob TargetRepository has AmazonCompatibleOptions.BucketName etc.) -Write-Host " Querying backup copy jobs for bucket details..." +# Get backup copy jobs (TargetRepository has populated AmazonCompatibleOptions) +Write-Host " Querying backup copy jobs..." +$COPY_JOBS = @() $COPY_JOB_REPOS = @{} try { - $COPY_JOBS = Get-VBRBackupCopyJob -ErrorAction Stop + $COPY_JOBS = @(Get-VBRBackupCopyJob -ErrorAction Stop) foreach ($CJ in $COPY_JOBS) { if ($null -ne $CJ.TargetRepository) { $TR = $CJ.TargetRepository @@ -220,56 +242,140 @@ try { Write-Warning " Get-VBRBackupCopyJob failed: $_" } -# Calculate storage sizes per repo by summing backup storages -# Parent backups with IsTruePerVmContainer have 0 storages, so we -# enumerate child backups via FindChildBackups() to get actual data -Write-Host " Calculating storage sizes from backup objects..." +# Get all backups and enumerate child backups for S3 repos +Write-Host " Enumerating backups and child backups..." +$ALL_BACKUPS = @() +try { $ALL_BACKUPS = @(Get-VBRBackup) } catch { Write-Warning " Get-VBRBackup failed: $_" } + +# Build: size per repo, child backup details per repo, set of active linked job source names $SIZE_BY_REPO_ID = @{} -try { - $ALL_BACKUPS = Get-VBRBackup - foreach ($BACKUP in $ALL_BACKUPS) { - $RID = $BACKUP.RepositoryId.ToString() - if (-not $SIZE_BY_REPO_ID.ContainsKey($RID)) { - $SIZE_BY_REPO_ID[$RID] = [long]0 - } +$CHILDREN_BY_REPO_ID = @{} +$S3_REPO_IDS = @{} +foreach ($REPO in $OBJECT_STORAGE_REPOS) { + $S3_REPO_IDS[$REPO.Id.ToString()] = $true +} - # Collect backups to check: the backup itself + any child backups - $BACKUPS_TO_CHECK = @($BACKUP) +# Get linked source job IDs from copy jobs targeting S3 repos +$ACTIVE_SOURCE_JOB_IDS = @{} +foreach ($CJ in $COPY_JOBS) { + if ($null -ne $CJ.TargetRepository -and $S3_REPO_IDS.ContainsKey($CJ.TargetRepository.Id.ToString())) { + # LinkedJobIds contains the source backup job IDs + try { + if ($CJ.BackupJob) { + foreach ($LJ in $CJ.BackupJob) { + $ACTIVE_SOURCE_JOB_IDS[$LJ.Id.ToString()] = $LJ.Name + } + } + } catch { } + } +} +Write-Host " Active source jobs linked to S3 copy jobs: $($ACTIVE_SOURCE_JOB_IDS.Count)" + +# Get source machine names from active backup jobs (servers01, workstations01 etc.) +$ACTIVE_MACHINE_NAMES = @{} +foreach ($BACKUP in $ALL_BACKUPS) { + $JID = $BACKUP.JobId.ToString() + if ($ACTIVE_SOURCE_JOB_IDS.ContainsKey($JID)) { + # This backup belongs to an active source job, get its objects (machine names) + try { + $OBJECTS = $BACKUP.GetObjects() + foreach ($OBJ in $OBJECTS) { + $MACHINE = $OBJ.Name + if ($MACHINE) { $ACTIVE_MACHINE_NAMES[$MACHINE.ToUpper()] = $true } + } + } catch { } + # Also try child backups for machine names try { if ($BACKUP.IsTruePerVmContainer) { $CHILDREN = $BACKUP.FindChildBackups() - if ($CHILDREN) { $BACKUPS_TO_CHECK += $CHILDREN } + foreach ($CHILD in $CHILDREN) { + # Child names are like "workstations01 - BUS2", extract machine name + $PARTS = $CHILD.Name -split ' - ', 2 + if ($PARTS.Count -ge 2) { + $ACTIVE_MACHINE_NAMES[$PARTS[1].Trim().ToUpper()] = $true + } + } } } catch { } + } +} +Write-Host " Active machine names from source jobs: $($ACTIVE_MACHINE_NAMES.Count)" - foreach ($B in $BACKUPS_TO_CHECK) { - try { - $STORAGES = $B.GetAllStorages() - foreach ($ST in $STORAGES) { - $ST_SIZE = [long]0 - try { if ($ST.Stats -and $ST.Stats.BackupSize -gt 0) { $ST_SIZE = [long]$ST.Stats.BackupSize } } catch {} - if ($ST_SIZE -eq 0) { try { if ($ST.BackupSize -gt 0) { $ST_SIZE = [long]$ST.BackupSize } } catch {} } - if ($ST_SIZE -eq 0) { try { if ($ST.DataSize -gt 0) { $ST_SIZE = [long]$ST.DataSize } } catch {} } - $SIZE_BY_REPO_ID[$RID] += $ST_SIZE - } - } catch { } +# Now enumerate S3-targeted backups for size + orphan detection +foreach ($BACKUP in $ALL_BACKUPS) { + $RID = $BACKUP.RepositoryId.ToString() + if (-not $S3_REPO_IDS.ContainsKey($RID)) { continue } + + if (-not $SIZE_BY_REPO_ID.ContainsKey($RID)) { $SIZE_BY_REPO_ID[$RID] = [long]0 } + if (-not $CHILDREN_BY_REPO_ID.ContainsKey($RID)) { $CHILDREN_BY_REPO_ID[$RID] = [System.Collections.Generic.List[hashtable]]::new() } + + $BACKUPS_TO_CHECK = @($BACKUP) + try { + if ($BACKUP.IsTruePerVmContainer) { + $CHILDREN = $BACKUP.FindChildBackups() + if ($CHILDREN) { $BACKUPS_TO_CHECK = @($CHILDREN) } } + } catch { } + + foreach ($B in $BACKUPS_TO_CHECK) { + $CHILD_SIZE = [long]0 + $LAST_POINT_TIME = $null + try { + $STORAGES = $B.GetAllStorages() + foreach ($ST in $STORAGES) { + $ST_SIZE = [long]0 + try { if ($ST.Stats -and $ST.Stats.BackupSize -gt 0) { $ST_SIZE = [long]$ST.Stats.BackupSize } } catch {} + if ($ST_SIZE -eq 0) { try { if ($ST.BackupSize -gt 0) { $ST_SIZE = [long]$ST.BackupSize } } catch {} } + $CHILD_SIZE += $ST_SIZE + } + # Get most recent storage creation time as proxy for last restore point + $LATEST_STORAGE = $STORAGES | Sort-Object CreationTime -Descending | Select-Object -First 1 + if ($LATEST_STORAGE) { $LAST_POINT_TIME = $LATEST_STORAGE.CreationTime } + } catch { } + + $SIZE_BY_REPO_ID[$RID] += $CHILD_SIZE + + # Extract machine name from child backup name (e.g. "S3 Copy 1686654096\workstations01 - BUS2") + $MACHINE_NAME = "Unknown" + $BACKUP_DISPLAY_NAME = $B.Name + if ($BACKUP_DISPLAY_NAME -match '\\(.+?) - (.+)$') { + $MACHINE_NAME = $Matches[2].Trim() + } elseif ($BACKUP_DISPLAY_NAME -match ' - (.+)$') { + $MACHINE_NAME = $Matches[1].Trim() + } + + $CHILDREN_BY_REPO_ID[$RID].Add(@{ + Name = $BACKUP_DISPLAY_NAME + MachineName = $MACHINE_NAME + Size = $CHILD_SIZE + SizeDisplay = if ($CHILD_SIZE -gt 0) { Format-SizeBytes -Bytes $CHILD_SIZE } else { "N/A" } + LastPoint = $LAST_POINT_TIME + LastPointStr = if ($LAST_POINT_TIME) { $LAST_POINT_TIME.ToString("yyyy-MM-dd") } else { "N/A" } + }) } -} catch { - Write-Warning " Could not enumerate backups for size calculation: $_" } -# Process each S3 repo +# ------------------------------------------------------------ +# Build inventory rows + find the "last used" S3 bucket +# ------------------------------------------------------------ +Write-Host "" +Write-Host "Processing repositories..." + +$REPO_ROWS = [System.Collections.Generic.List[string[]]]::new() +$ORPHAN_ROWS = [System.Collections.Generic.List[string[]]]::new() +$ORPHAN_COUNT = 0 + +$LAST_USED_BUCKET = "N/A" +$LAST_USED_SIZE = "N/A" +$LAST_USED_SIZE_BYTES = [long]0 +$LAST_USED_TIME = [datetime]::MinValue + foreach ($REPO in $OBJECT_STORAGE_REPOS) { - Write-Host " Processing: $($REPO.Name)" $REPO_ID = $REPO.Id.ToString() + Write-Host " Processing: $($REPO.Name)" # --- BUCKET NAME --- $BUCKET_NAME = "N/A" - - # Primary: copy job TargetRepository (confirmed working on Veeam 12) - # AmazonCompatibleOptions.BucketName is populated here but NOT on - # objects from Get-VBRObjectStorageRepository (those are skeleton/null) if ($COPY_JOB_REPOS.ContainsKey($REPO_ID)) { $CJ_TR = $COPY_JOB_REPOS[$REPO_ID] try { @@ -280,8 +386,6 @@ foreach ($REPO in $OBJECT_STORAGE_REPOS) { } } catch { } } - - # Fallback: try direct repo sub-objects (may work on other Veeam versions) if ($BUCKET_NAME -eq "N/A") { try { $OPTS = $REPO.AmazonCompatibleOptions @@ -296,13 +400,11 @@ foreach ($REPO in $OBJECT_STORAGE_REPOS) { # --- USED SPACE --- $USED_SPACE_DISPLAY = "N/A" - - # Try direct repo properties - try { if ($null -ne $REPO.UsedSpace -and $REPO.UsedSpace -gt 0) { $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $REPO.UsedSpace } } catch {} - - # Fall back to summed storage sizes from backup objects + $USED_SPACE_BYTES = [long]0 + try { if ($null -ne $REPO.UsedSpace -and $REPO.UsedSpace -gt 0) { $USED_SPACE_BYTES = [long]$REPO.UsedSpace; $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $USED_SPACE_BYTES } } catch {} if ($USED_SPACE_DISPLAY -eq "N/A" -and $SIZE_BY_REPO_ID.ContainsKey($REPO_ID) -and $SIZE_BY_REPO_ID[$REPO_ID] -gt 0) { - $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $SIZE_BY_REPO_ID[$REPO_ID] + $USED_SPACE_BYTES = $SIZE_BY_REPO_ID[$REPO_ID] + $USED_SPACE_DISPLAY = Format-SizeBytes -Bytes $USED_SPACE_BYTES } # --- REPO TYPE --- @@ -310,67 +412,111 @@ foreach ($REPO in $OBJECT_STORAGE_REPOS) { try { if ($REPO.Type) { $REPO_TYPE = $REPO.Type.ToString() } } catch {} try { if ($REPO_TYPE -eq "N/A" -and $REPO.TypeDisplay) { $REPO_TYPE = $REPO.TypeDisplay } } catch {} - $REPO_ROWS.Add(@{ - RepoName = $REPO.Name - BucketName = $BUCKET_NAME - RepoType = $REPO_TYPE - UsedSpace = $USED_SPACE_DISPLAY - }) + $REPO_ROWS.Add(@($BUCKET_NAME, $REPO.Name, $REPO_TYPE, $USED_SPACE_DISPLAY)) + + # --- LAST USED tracking --- + # Find the most recent restore point time across all children in this repo + $REPO_LATEST_TIME = [datetime]::MinValue + if ($CHILDREN_BY_REPO_ID.ContainsKey($REPO_ID)) { + foreach ($CHILD in $CHILDREN_BY_REPO_ID[$REPO_ID]) { + if ($null -ne $CHILD.LastPoint -and $CHILD.LastPoint -gt $REPO_LATEST_TIME) { + $REPO_LATEST_TIME = $CHILD.LastPoint + } + } + } + if ($REPO_LATEST_TIME -gt $LAST_USED_TIME) { + $LAST_USED_TIME = $REPO_LATEST_TIME + $LAST_USED_BUCKET = $BUCKET_NAME + $LAST_USED_SIZE = $USED_SPACE_DISPLAY + $LAST_USED_SIZE_BYTES = $USED_SPACE_BYTES + } + + # --- ORPHAN DETECTION --- + # A child backup is orphaned if its machine name is not in any active + # source backup job linked to an S3-targeting copy job + if ($CHILDREN_BY_REPO_ID.ContainsKey($REPO_ID)) { + foreach ($CHILD in $CHILDREN_BY_REPO_ID[$REPO_ID]) { + $IS_ORPHAN = $false + if ($ACTIVE_MACHINE_NAMES.Count -gt 0) { + $IS_ORPHAN = -not $ACTIVE_MACHINE_NAMES.ContainsKey($CHILD.MachineName.ToUpper()) + } + if ($IS_ORPHAN) { + $ORPHAN_ROWS.Add(@($CHILD.MachineName, $CHILD.Name, $CHILD.SizeDisplay, $CHILD.LastPointStr, $BUCKET_NAME)) + $ORPHAN_COUNT++ + } + } + } } +# If only one S3 repo exists and last-used tracking didn't match via time, use it directly +if ($LAST_USED_BUCKET -eq "N/A" -and $REPO_ROWS.Count -eq 1) { + $LAST_USED_BUCKET = $REPO_ROWS[0][0] + $LAST_USED_SIZE = $REPO_ROWS[0][3] +} +# ------------------------------------------------------------ +# Log summary +# ------------------------------------------------------------ Write-Host "" -Write-Host "Found $($REPO_ROWS.Count) S3-compatible repositories." +Write-Host "=== Results ===" +Write-Host " Last used S3 bucket: $LAST_USED_BUCKET" +Write-Host " Last used S3 size: $LAST_USED_SIZE" +Write-Host " S3 repos found: $($REPO_ROWS.Count)" +Write-Host " Orphaned backups: $ORPHAN_COUNT" Write-Host "" -# Log plain-text summary to transcript -if ($REPO_ROWS.Count -eq 0) { - Write-Host "No S3-compatible object storage repositories found." -} else { - Write-Host "=== Repository Summary ===" +if ($REPO_ROWS.Count -gt 0) { + Write-Host "=== Inventory ===" foreach ($ROW in $REPO_ROWS) { - Write-Host " Repo: $($ROW.RepoName)" - Write-Host " Bucket: $($ROW.BucketName)" - Write-Host " Type: $($ROW.RepoType)" - Write-Host " Used Space: $($ROW.UsedSpace)" - Write-Host "" + Write-Host " Bucket: $($ROW[0]) | Repo: $($ROW[1]) | Type: $($ROW[2]) | Size: $($ROW[3])" } + Write-Host "" +} + +if ($ORPHAN_COUNT -gt 0) { + Write-Host "=== Orphaned Backups ===" + foreach ($ROW in $ORPHAN_ROWS) { + Write-Host " Machine: $($ROW[0]) | Backup: $($ROW[1]) | Size: $($ROW[2]) | Last: $($ROW[3]) | Bucket: $($ROW[4])" + } + Write-Host "" } # ------------------------------------------------------------ -# Build HTML table +# Build HTML tables # ------------------------------------------------------------ -Write-Host "Building HTML table..." - -$HTML_TABLE = Build-HtmlInventoryTable -Rows $REPO_ROWS -ScanTimestamp $SCAN_TIMESTAMP - -Write-Host "HTML table built ($($HTML_TABLE.Length) characters)." +$HTML_INVENTORY = Build-HtmlTable ` + -Headers @("Bucket Name", "Repository Name", "Type", "Used Space") ` + -Rows $REPO_ROWS ` + -ScanTimestamp $SCAN_TIMESTAMP ` + -EmptyMessage "No S3-compatible object storage repositories found." + +$HTML_ORPHANS = Build-HtmlTable ` + -Headers @("Machine", "Backup Name", "Size", "Last Backup", "Bucket") ` + -Rows $ORPHAN_ROWS ` + -ScanTimestamp $SCAN_TIMESTAMP ` + -EmptyMessage "No orphaned backups detected." # ------------------------------------------------------------ -# Write to NinjaOne WYSIWYG custom field +# Write to NinjaOne custom fields # ------------------------------------------------------------ -if ($env:CUSTOM_FIELD_S3_INVENTORY) { - Write-Host "" - Write-Host "Writing to NinjaOne custom field: $env:CUSTOM_FIELD_S3_INVENTORY" +Write-Host "Writing to NinjaOne custom fields..." - $NINJA_CMD = Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue - if ($NINJA_CMD) { - try { - Ninja-Property-Set $env:CUSTOM_FIELD_S3_INVENTORY $HTML_TABLE - Write-Host " [OK] Custom field updated successfully." - } catch { - Write-Warning " Failed to write to NinjaOne custom field: $_" - } - } else { - Write-Warning " Ninja-Property-Set command not found. Skipping custom field write." - Write-Host " HTML content that would have been written:" - Write-Host $HTML_TABLE +Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_NAME $LAST_USED_BUCKET +Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_SIZE $LAST_USED_SIZE +Set-NinjaField $env:CUSTOM_FIELD_S3_ORPHANS_FOUND "$([int]($ORPHAN_COUNT -gt 0))" +Set-NinjaField $env:CUSTOM_FIELD_S3_INVENTORY $HTML_INVENTORY +Set-NinjaField $env:CUSTOM_FIELD_S3_ORPHANS $HTML_ORPHANS + +# If no Ninja available and no fields set, dump HTML to console +if (-not (Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue)) { + if (-not $env:CUSTOM_FIELD_S3_INVENTORY -and -not $env:CUSTOM_FIELD_S3_ORPHANS) { + Write-Host "" + Write-Host "=== Inventory HTML ===" + Write-Host $HTML_INVENTORY + Write-Host "" + Write-Host "=== Orphans HTML ===" + Write-Host $HTML_ORPHANS } -} else { - Write-Host "No NinjaOne custom field specified. Skipping field write." - Write-Host "" - Write-Host "=== Generated HTML ===" - Write-Host $HTML_TABLE } Write-Host "" diff --git a/bdr-veeam/veeam-s3-diag.ps1 b/bdr-veeam/veeam-s3-diag.ps1 index 372a0d2..79bf427 100644 --- a/bdr-veeam/veeam-s3-diag.ps1 +++ b/bdr-veeam/veeam-s3-diag.ps1 @@ -1,5 +1,5 @@ -# Diagnostic script - test bucket name + size extraction from Veeam -# Run on a BDR server in PS7 to validate which properties return data +# Diagnostic script - test all data extraction paths for S3 inventory +# Run on a BDR server in PS7 if ($PSVersionTable.PSVersion.Major -lt 7) { $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue @@ -16,99 +16,127 @@ $env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_M Get-Module -ListAvailable -Name Veeam.Backup.PowerShell | Import-Module -WarningAction SilentlyContinue Write-Host "==========================================" -Write-Host "1: BUCKET NAME (confirmed path)" +Write-Host "1: BUCKET NAME" Write-Host "==========================================" try { $COPY_JOBS = Get-VBRBackupCopyJob -ErrorAction Stop Write-Host "Found $($COPY_JOBS.Count) copy jobs`n" foreach ($CJ in $COPY_JOBS) { $TR = $CJ.TargetRepository + $OPTS = $TR.AmazonCompatibleOptions Write-Host "--- $($CJ.Name) ---" - Write-Host " Target repo: $($TR.Name)" + Write-Host " BucketName: $(if ($OPTS) { $OPTS.BucketName } else { 'NULL' })" + Write-Host " ServicePoint: $(if ($OPTS) { $OPTS.ServicePoint } else { 'NULL' })" + Write-Host "" + } +} catch { Write-Host "FAILED: $_" } - $OPTS = $TR.AmazonCompatibleOptions - if ($null -ne $OPTS) { - Write-Host " BucketName: $($OPTS.BucketName)" - Write-Host " ServicePoint: $($OPTS.ServicePoint)" - Write-Host " RegionId: $($OPTS.RegionId)" - Write-Host " FolderName: $($OPTS.FolderName)" - } else { - Write-Host " AmazonCompatibleOptions: NULL" +Write-Host "==========================================" +Write-Host "2: SIZE (child backup storages)" +Write-Host "==========================================" +try { + $ALL_BACKUPS = Get-VBRBackup + foreach ($B in $ALL_BACKUPS) { + Write-Host "--- $($B.Name) ---" + Write-Host " Type: $($B.TypeToString) | RepoId: $($B.RepositoryId) | PerVm: $($B.IsTruePerVmContainer)" + + if ($B.IsTruePerVmContainer) { + $CHILDREN = $B.FindChildBackups() + Write-Host " Children: $($CHILDREN.Count)" + $TOTAL = [long]0 + foreach ($CHILD in $CHILDREN) { + $STORAGES = $CHILD.GetAllStorages() + $CHILD_SIZE = [long]0 + foreach ($ST in $STORAGES) { + try { if ($ST.Stats.BackupSize -gt 0) { $CHILD_SIZE += [long]$ST.Stats.BackupSize } } catch {} + } + $LATEST = $STORAGES | Sort-Object CreationTime -Descending | Select-Object -First 1 + $LATEST_DATE = if ($LATEST) { $LATEST.CreationTime.ToString("yyyy-MM-dd") } else { "N/A" } + + if ($CHILD_SIZE -ge 1GB) { $DISPLAY = "{0:N2} GB" -f ($CHILD_SIZE / 1GB) } + elseif ($CHILD_SIZE -ge 1MB) { $DISPLAY = "{0:N2} MB" -f ($CHILD_SIZE / 1MB) } + else { $DISPLAY = "$CHILD_SIZE B" } + + Write-Host " $($CHILD.Name) | $($STORAGES.Count) storages | $DISPLAY | Last: $LATEST_DATE" + $TOTAL += $CHILD_SIZE + } + if ($TOTAL -ge 1TB) { Write-Host " TOTAL: $("{0:N2} TB" -f ($TOTAL / 1TB))" } + elseif ($TOTAL -ge 1GB) { Write-Host " TOTAL: $("{0:N2} GB" -f ($TOTAL / 1GB))" } } Write-Host "" } } catch { Write-Host "FAILED: $_" } Write-Host "==========================================" -Write-Host "2: SIZE - Child backups (TruePerVmContainer)" +Write-Host "3: ORPHAN DETECTION" Write-Host "==========================================" try { + $COPY_JOBS = Get-VBRBackupCopyJob -ErrorAction Stop $ALL_BACKUPS = Get-VBRBackup - Write-Host "Total backups: $($ALL_BACKUPS.Count)`n" - foreach ($B in $ALL_BACKUPS) { - Write-Host "--- $($B.Name) ---" - Write-Host " Type: $($B.TypeToString)" - Write-Host " RepositoryId: $($B.RepositoryId)" - Write-Host " IsTruePerVmContainer: $($B.IsTruePerVmContainer)" + # Get S3 repo IDs + $S3_REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop + $S3_IDS = @{} + foreach ($R in $S3_REPOS) { $S3_IDS[$R.Id.ToString()] = $true } - # Direct storages - $STORAGES = $B.GetAllStorages() - Write-Host " GetAllStorages count: $($STORAGES.Count)" + # Get active source job IDs from copy jobs + $ACTIVE_JOB_IDS = @{} + foreach ($CJ in $COPY_JOBS) { + if ($null -ne $CJ.TargetRepository -and $S3_IDS.ContainsKey($CJ.TargetRepository.Id.ToString())) { + if ($CJ.BackupJob) { + foreach ($LJ in $CJ.BackupJob) { + $ACTIVE_JOB_IDS[$LJ.Id.ToString()] = $LJ.Name + Write-Host " Active source job: $($LJ.Name) ($($LJ.Id))" + } + } + } + } - # Child backups - if ($B.IsTruePerVmContainer) { + # Get machine names from active source backups + $ACTIVE_MACHINES = @{} + foreach ($B in $ALL_BACKUPS) { + if ($ACTIVE_JOB_IDS.ContainsKey($B.JobId.ToString())) { + Write-Host " Source backup: $($B.Name)" try { - $CHILDREN = $B.FindChildBackups() - Write-Host " FindChildBackups count: $($CHILDREN.Count)" - - $TOTAL_SIZE = [long]0 - foreach ($CHILD in $CHILDREN) { - $CHILD_STORAGES = $CHILD.GetAllStorages() - $CHILD_SIZE = [long]0 - foreach ($ST in $CHILD_STORAGES) { - # Try every known size property - $ST_SIZE = [long]0 - try { if ($ST.Stats -and $ST.Stats.BackupSize -gt 0) { $ST_SIZE = [long]$ST.Stats.BackupSize } } catch {} - if ($ST_SIZE -eq 0) { try { if ($ST.BackupSize -gt 0) { $ST_SIZE = [long]$ST.BackupSize } } catch {} } - if ($ST_SIZE -eq 0) { try { if ($ST.DataSize -gt 0) { $ST_SIZE = [long]$ST.DataSize } } catch {} } - $CHILD_SIZE += $ST_SIZE - } - if ($CHILD_STORAGES.Count -gt 0) { - Write-Host " Child: $($CHILD.Name) | Storages: $($CHILD_STORAGES.Count) | Size: $CHILD_SIZE" + $OBJECTS = $B.GetObjects() + Write-Host " GetObjects count: $($OBJECTS.Count)" + foreach ($OBJ in $OBJECTS) { + Write-Host " Object: $($OBJ.Name)" + if ($OBJ.Name) { $ACTIVE_MACHINES[$OBJ.Name.ToUpper()] = $true } + } + } catch { Write-Host " GetObjects failed: $_" } - # Dump first storage properties on first child with data - if ($TOTAL_SIZE -eq 0 -and $CHILD_STORAGES.Count -gt 0) { - $FIRST_ST = $CHILD_STORAGES | Select-Object -First 1 - Write-Host " First storage type: $($FIRST_ST.GetType().FullName)" - $FIRST_ST | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($FIRST_ST.$P)" } catch { Write-Host " $P = [ERROR]" } - } - if ($null -ne $FIRST_ST.Stats) { - Write-Host " .Stats properties:" - $FIRST_ST.Stats | Get-Member -MemberType Property,NoteProperty | ForEach-Object { - $P = $_.Name; try { Write-Host " $P = $($FIRST_ST.Stats.$P)" } catch { Write-Host " $P = [ERROR]" } - } - } + if ($B.IsTruePerVmContainer) { + try { + $CHILDREN = $B.FindChildBackups() + foreach ($CHILD in $CHILDREN) { + $PARTS = $CHILD.Name -split ' - ', 2 + if ($PARTS.Count -ge 2) { + $MACHINE = $PARTS[1].Trim() + Write-Host " Child machine: $MACHINE" + $ACTIVE_MACHINES[$MACHINE.ToUpper()] = $true } } - $TOTAL_SIZE += $CHILD_SIZE - } - Write-Host " TOTAL child backup size: $TOTAL_SIZE bytes" - if ($TOTAL_SIZE -gt 0) { - if ($TOTAL_SIZE -ge 1TB) { Write-Host " Formatted: $("{0:N2} TB" -f ($TOTAL_SIZE / 1TB))" } - elseif ($TOTAL_SIZE -ge 1GB) { Write-Host " Formatted: $("{0:N2} GB" -f ($TOTAL_SIZE / 1GB))" } - else { Write-Host " Formatted: $("{0:N2} MB" -f ($TOTAL_SIZE / 1MB))" } - } - } catch { Write-Host " FindChildBackups FAILED: $_" } - } elseif ($STORAGES.Count -gt 0) { - # Non-container backup with direct storages - $DIRECT_SIZE = [long]0 - foreach ($ST in $STORAGES) { - try { if ($ST.Stats -and $ST.Stats.BackupSize -gt 0) { $DIRECT_SIZE += [long]$ST.Stats.BackupSize } } catch {} + } catch { } } - Write-Host " Direct storage size: $DIRECT_SIZE bytes" } - Write-Host "" + } + + Write-Host "`n Active machines: $($ACTIVE_MACHINES.Keys -join ', ')`n" + + # Check S3 copy children against active machines + foreach ($B in $ALL_BACKUPS) { + $RID = $B.RepositoryId.ToString() + if (-not $S3_IDS.ContainsKey($RID)) { continue } + if (-not $B.IsTruePerVmContainer) { continue } + + $CHILDREN = $B.FindChildBackups() + foreach ($CHILD in $CHILDREN) { + $PARTS = $CHILD.Name -split ' - ', 2 + $MACHINE = if ($PARTS.Count -ge 2) { $PARTS[1].Trim() } else { $CHILD.Name } + $IS_ORPHAN = -not $ACTIVE_MACHINES.ContainsKey($MACHINE.ToUpper()) + $STATUS = if ($IS_ORPHAN) { "ORPHANED" } else { "ACTIVE" } + Write-Host " [$STATUS] $MACHINE ($($CHILD.Name))" + } } } catch { Write-Host "FAILED: $_" } From 4d7d4a70ab6acdd964ae40e1694e57d1ae0eb476 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 11:34:12 -0400 Subject: [PATCH 08/77] Switch orphan detection to time-based + unlinked job approach GetObjects() returns 0 on Windows Agent backup types, so the active machine name matching approach failed (everything looked orphaned). Two detection methods now: 1. Unlinked: S3 backup data whose parent job ID doesn't match any active copy job targeting an S3 repo 2. Stale: child backups whose last storage date exceeds the ORPHAN_DAYS_THRESHOLD (default 30 days) Added $env:ORPHAN_DAYS_THRESHOLD for per-client tuning. Orphan table now shows Reason column (No active job / Stale X days). Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-s3-bucket-inventory.ps1 | 86 ++++++++++--------------- bdr-veeam/veeam-s3-diag.ps1 | 78 +++++++++------------- 2 files changed, 64 insertions(+), 100 deletions(-) diff --git a/bdr-veeam/veeam-s3-bucket-inventory.ps1 b/bdr-veeam/veeam-s3-bucket-inventory.ps1 index 69bf41c..a9fba83 100644 --- a/bdr-veeam/veeam-s3-bucket-inventory.ps1 +++ b/bdr-veeam/veeam-s3-bucket-inventory.ps1 @@ -4,6 +4,7 @@ ## $env:CUSTOM_FIELD_S3_ORPHANS_FOUND - Integer field: 1 if orphaned backups found, 0 if not ## $env:CUSTOM_FIELD_S3_INVENTORY - WYSIWYG field: HTML table of all S3 repos ## $env:CUSTOM_FIELD_S3_ORPHANS - WYSIWYG field: HTML table of orphaned backup data +## $env:ORPHAN_DAYS_THRESHOLD - Days since last backup to consider orphaned (default: 30) ## $env:DESCRIPTION - Ticket # or initials for audit trail ## $env:RMM - Set to 1 when running from RMM platform ## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) @@ -247,61 +248,29 @@ Write-Host " Enumerating backups and child backups..." $ALL_BACKUPS = @() try { $ALL_BACKUPS = @(Get-VBRBackup) } catch { Write-Warning " Get-VBRBackup failed: $_" } -# Build: size per repo, child backup details per repo, set of active linked job source names +# Orphan threshold: child backups with no new data in this many days are stale +$ORPHAN_DAYS = 30 +if ($env:ORPHAN_DAYS_THRESHOLD) { + try { $ORPHAN_DAYS = [int]$env:ORPHAN_DAYS_THRESHOLD } catch {} +} +$ORPHAN_CUTOFF = (Get-Date).AddDays(-$ORPHAN_DAYS) +Write-Host " Orphan threshold: $ORPHAN_DAYS days (before $($ORPHAN_CUTOFF.ToString('yyyy-MM-dd')))" + +# Build a set of active copy job IDs targeting S3 repos $SIZE_BY_REPO_ID = @{} $CHILDREN_BY_REPO_ID = @{} $S3_REPO_IDS = @{} foreach ($REPO in $OBJECT_STORAGE_REPOS) { $S3_REPO_IDS[$REPO.Id.ToString()] = $true } - -# Get linked source job IDs from copy jobs targeting S3 repos -$ACTIVE_SOURCE_JOB_IDS = @{} +$ACTIVE_COPY_JOB_IDS = @{} foreach ($CJ in $COPY_JOBS) { if ($null -ne $CJ.TargetRepository -and $S3_REPO_IDS.ContainsKey($CJ.TargetRepository.Id.ToString())) { - # LinkedJobIds contains the source backup job IDs - try { - if ($CJ.BackupJob) { - foreach ($LJ in $CJ.BackupJob) { - $ACTIVE_SOURCE_JOB_IDS[$LJ.Id.ToString()] = $LJ.Name - } - } - } catch { } + $ACTIVE_COPY_JOB_IDS[$CJ.Id.ToString()] = $true } } -Write-Host " Active source jobs linked to S3 copy jobs: $($ACTIVE_SOURCE_JOB_IDS.Count)" -# Get source machine names from active backup jobs (servers01, workstations01 etc.) -$ACTIVE_MACHINE_NAMES = @{} -foreach ($BACKUP in $ALL_BACKUPS) { - $JID = $BACKUP.JobId.ToString() - if ($ACTIVE_SOURCE_JOB_IDS.ContainsKey($JID)) { - # This backup belongs to an active source job, get its objects (machine names) - try { - $OBJECTS = $BACKUP.GetObjects() - foreach ($OBJ in $OBJECTS) { - $MACHINE = $OBJ.Name - if ($MACHINE) { $ACTIVE_MACHINE_NAMES[$MACHINE.ToUpper()] = $true } - } - } catch { } - # Also try child backups for machine names - try { - if ($BACKUP.IsTruePerVmContainer) { - $CHILDREN = $BACKUP.FindChildBackups() - foreach ($CHILD in $CHILDREN) { - # Child names are like "workstations01 - BUS2", extract machine name - $PARTS = $CHILD.Name -split ' - ', 2 - if ($PARTS.Count -ge 2) { - $ACTIVE_MACHINE_NAMES[$PARTS[1].Trim().ToUpper()] = $true - } - } - } - } catch { } - } -} -Write-Host " Active machine names from source jobs: $($ACTIVE_MACHINE_NAMES.Count)" - -# Now enumerate S3-targeted backups for size + orphan detection +# Enumerate S3-targeted backups for size + child details foreach ($BACKUP in $ALL_BACKUPS) { $RID = $BACKUP.RepositoryId.ToString() if (-not $S3_REPO_IDS.ContainsKey($RID)) { continue } @@ -344,6 +313,10 @@ foreach ($BACKUP in $ALL_BACKUPS) { $MACHINE_NAME = $Matches[1].Trim() } + # Check if this backup's parent job is an active copy job + $PARENT_JOB_ID = $BACKUP.JobId.ToString() + $IS_JOB_LINKED = $ACTIVE_COPY_JOB_IDS.ContainsKey($PARENT_JOB_ID) + $CHILDREN_BY_REPO_ID[$RID].Add(@{ Name = $BACKUP_DISPLAY_NAME MachineName = $MACHINE_NAME @@ -351,6 +324,7 @@ foreach ($BACKUP in $ALL_BACKUPS) { SizeDisplay = if ($CHILD_SIZE -gt 0) { Format-SizeBytes -Bytes $CHILD_SIZE } else { "N/A" } LastPoint = $LAST_POINT_TIME LastPointStr = if ($LAST_POINT_TIME) { $LAST_POINT_TIME.ToString("yyyy-MM-dd") } else { "N/A" } + IsJobLinked = $IS_JOB_LINKED }) } } @@ -432,16 +406,22 @@ foreach ($REPO in $OBJECT_STORAGE_REPOS) { } # --- ORPHAN DETECTION --- - # A child backup is orphaned if its machine name is not in any active - # source backup job linked to an S3-targeting copy job + # Two cases: + # 1. Unlinked: backup data in S3 not associated with any active copy job + # 2. Stale: last backup older than threshold (machine removed from job) if ($CHILDREN_BY_REPO_ID.ContainsKey($REPO_ID)) { foreach ($CHILD in $CHILDREN_BY_REPO_ID[$REPO_ID]) { - $IS_ORPHAN = $false - if ($ACTIVE_MACHINE_NAMES.Count -gt 0) { - $IS_ORPHAN = -not $ACTIVE_MACHINE_NAMES.ContainsKey($CHILD.MachineName.ToUpper()) + $REASON = $null + + if (-not $CHILD.IsJobLinked) { + $REASON = "No active job" + } elseif ($null -ne $CHILD.LastPoint -and $CHILD.LastPoint -lt $ORPHAN_CUTOFF) { + $DAYS_AGO = [int]((Get-Date) - $CHILD.LastPoint).TotalDays + $REASON = "Stale ($DAYS_AGO days)" } - if ($IS_ORPHAN) { - $ORPHAN_ROWS.Add(@($CHILD.MachineName, $CHILD.Name, $CHILD.SizeDisplay, $CHILD.LastPointStr, $BUCKET_NAME)) + + if ($REASON) { + $ORPHAN_ROWS.Add(@($CHILD.MachineName, $CHILD.SizeDisplay, $CHILD.LastPointStr, $REASON, $BUCKET_NAME)) $ORPHAN_COUNT++ } } @@ -476,7 +456,7 @@ if ($REPO_ROWS.Count -gt 0) { if ($ORPHAN_COUNT -gt 0) { Write-Host "=== Orphaned Backups ===" foreach ($ROW in $ORPHAN_ROWS) { - Write-Host " Machine: $($ROW[0]) | Backup: $($ROW[1]) | Size: $($ROW[2]) | Last: $($ROW[3]) | Bucket: $($ROW[4])" + Write-Host " Machine: $($ROW[0]) | Size: $($ROW[1]) | Last: $($ROW[2]) | Reason: $($ROW[3]) | Bucket: $($ROW[4])" } Write-Host "" } @@ -491,7 +471,7 @@ $HTML_INVENTORY = Build-HtmlTable ` -EmptyMessage "No S3-compatible object storage repositories found." $HTML_ORPHANS = Build-HtmlTable ` - -Headers @("Machine", "Backup Name", "Size", "Last Backup", "Bucket") ` + -Headers @("Machine", "Size", "Last Backup", "Reason", "Bucket") ` -Rows $ORPHAN_ROWS ` -ScanTimestamp $SCAN_TIMESTAMP ` -EmptyMessage "No orphaned backups detected." diff --git a/bdr-veeam/veeam-s3-diag.ps1 b/bdr-veeam/veeam-s3-diag.ps1 index 79bf427..79abea2 100644 --- a/bdr-veeam/veeam-s3-diag.ps1 +++ b/bdr-veeam/veeam-s3-diag.ps1 @@ -15,6 +15,9 @@ $MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" $env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" Get-Module -ListAvailable -Name Veeam.Backup.PowerShell | Import-Module -WarningAction SilentlyContinue +$ORPHAN_DAYS = 30 +$ORPHAN_CUTOFF = (Get-Date).AddDays(-$ORPHAN_DAYS) + Write-Host "==========================================" Write-Host "1: BUCKET NAME" Write-Host "==========================================" @@ -32,7 +35,7 @@ try { } catch { Write-Host "FAILED: $_" } Write-Host "==========================================" -Write-Host "2: SIZE (child backup storages)" +Write-Host "2: SIZE + LAST BACKUP DATE (per child)" Write-Host "==========================================" try { $ALL_BACKUPS = Get-VBRBackup @@ -68,75 +71,56 @@ try { } catch { Write-Host "FAILED: $_" } Write-Host "==========================================" -Write-Host "3: ORPHAN DETECTION" +Write-Host "3: ORPHAN DETECTION (time-based + unlinked)" +Write-Host " Threshold: $ORPHAN_DAYS days (before $($ORPHAN_CUTOFF.ToString('yyyy-MM-dd')))" Write-Host "==========================================" try { $COPY_JOBS = Get-VBRBackupCopyJob -ErrorAction Stop $ALL_BACKUPS = Get-VBRBackup - - # Get S3 repo IDs $S3_REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop $S3_IDS = @{} foreach ($R in $S3_REPOS) { $S3_IDS[$R.Id.ToString()] = $true } - # Get active source job IDs from copy jobs - $ACTIVE_JOB_IDS = @{} + # Active copy job IDs targeting S3 + $ACTIVE_CJ_IDS = @{} foreach ($CJ in $COPY_JOBS) { if ($null -ne $CJ.TargetRepository -and $S3_IDS.ContainsKey($CJ.TargetRepository.Id.ToString())) { - if ($CJ.BackupJob) { - foreach ($LJ in $CJ.BackupJob) { - $ACTIVE_JOB_IDS[$LJ.Id.ToString()] = $LJ.Name - Write-Host " Active source job: $($LJ.Name) ($($LJ.Id))" - } - } - } - } - - # Get machine names from active source backups - $ACTIVE_MACHINES = @{} - foreach ($B in $ALL_BACKUPS) { - if ($ACTIVE_JOB_IDS.ContainsKey($B.JobId.ToString())) { - Write-Host " Source backup: $($B.Name)" - try { - $OBJECTS = $B.GetObjects() - Write-Host " GetObjects count: $($OBJECTS.Count)" - foreach ($OBJ in $OBJECTS) { - Write-Host " Object: $($OBJ.Name)" - if ($OBJ.Name) { $ACTIVE_MACHINES[$OBJ.Name.ToUpper()] = $true } - } - } catch { Write-Host " GetObjects failed: $_" } - - if ($B.IsTruePerVmContainer) { - try { - $CHILDREN = $B.FindChildBackups() - foreach ($CHILD in $CHILDREN) { - $PARTS = $CHILD.Name -split ' - ', 2 - if ($PARTS.Count -ge 2) { - $MACHINE = $PARTS[1].Trim() - Write-Host " Child machine: $MACHINE" - $ACTIVE_MACHINES[$MACHINE.ToUpper()] = $true - } - } - } catch { } - } + $ACTIVE_CJ_IDS[$CJ.Id.ToString()] = $CJ.Name + Write-Host " Active copy job: $($CJ.Name) ($($CJ.Id))" } } - Write-Host "`n Active machines: $($ACTIVE_MACHINES.Keys -join ', ')`n" + Write-Host "" - # Check S3 copy children against active machines foreach ($B in $ALL_BACKUPS) { $RID = $B.RepositoryId.ToString() if (-not $S3_IDS.ContainsKey($RID)) { continue } if (-not $B.IsTruePerVmContainer) { continue } + $IS_JOB_LINKED = $ACTIVE_CJ_IDS.ContainsKey($B.JobId.ToString()) + Write-Host " Backup: $($B.Name) | JobId: $($B.JobId) | Linked: $IS_JOB_LINKED" + $CHILDREN = $B.FindChildBackups() foreach ($CHILD in $CHILDREN) { + $STORAGES = $CHILD.GetAllStorages() + $LATEST = $STORAGES | Sort-Object CreationTime -Descending | Select-Object -First 1 + $LATEST_DATE = if ($LATEST) { $LATEST.CreationTime } else { $null } + $LATEST_STR = if ($LATEST_DATE) { $LATEST_DATE.ToString("yyyy-MM-dd") } else { "N/A" } + $PARTS = $CHILD.Name -split ' - ', 2 $MACHINE = if ($PARTS.Count -ge 2) { $PARTS[1].Trim() } else { $CHILD.Name } - $IS_ORPHAN = -not $ACTIVE_MACHINES.ContainsKey($MACHINE.ToUpper()) - $STATUS = if ($IS_ORPHAN) { "ORPHANED" } else { "ACTIVE" } - Write-Host " [$STATUS] $MACHINE ($($CHILD.Name))" + + $REASON = $null + if (-not $IS_JOB_LINKED) { + $REASON = "No active job" + } elseif ($null -ne $LATEST_DATE -and $LATEST_DATE -lt $ORPHAN_CUTOFF) { + $DAYS = [int]((Get-Date) - $LATEST_DATE).TotalDays + $REASON = "Stale ($DAYS days)" + } + + $STATUS = if ($REASON) { "ORPHANED - $REASON" } else { "ACTIVE" } + Write-Host " [$STATUS] $MACHINE | Last: $LATEST_STR" } + Write-Host "" } } catch { Write-Host "FAILED: $_" } From 91aa6744223b3c6a67af3e8bb58b065ce3d86566 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 11:47:53 -0400 Subject: [PATCH 09/77] Expand orphan detection to ALL backups, not just S3 Orphan detection now scans every backup across all repositories (local, S3, etc.) for stale or unlinked entries. S3-specific fields (bucket name, size, inventory table) remain S3-only. Active job detection uses Get-VBRJob + Get-VBRBackupCopyJob + Get-VBRComputerBackupJob to build a complete set of active job IDs. Backups whose JobId doesn't match any active job = unlinked. Orphan table column changed from "Bucket" to "Repository" since entries can now come from any repo type. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-s3-bucket-inventory.ps1 | 136 ++++++++++++++---------- bdr-veeam/veeam-s3-diag.ps1 | 119 +++++++++++---------- 2 files changed, 139 insertions(+), 116 deletions(-) diff --git a/bdr-veeam/veeam-s3-bucket-inventory.ps1 b/bdr-veeam/veeam-s3-bucket-inventory.ps1 index a9fba83..0594a07 100644 --- a/bdr-veeam/veeam-s3-bucket-inventory.ps1 +++ b/bdr-veeam/veeam-s3-bucket-inventory.ps1 @@ -256,37 +256,61 @@ if ($env:ORPHAN_DAYS_THRESHOLD) { $ORPHAN_CUTOFF = (Get-Date).AddDays(-$ORPHAN_DAYS) Write-Host " Orphan threshold: $ORPHAN_DAYS days (before $($ORPHAN_CUTOFF.ToString('yyyy-MM-dd')))" -# Build a set of active copy job IDs targeting S3 repos +# S3 repo ID lookup for bucket name/size calculations $SIZE_BY_REPO_ID = @{} -$CHILDREN_BY_REPO_ID = @{} +$S3_CHILDREN_BY_REPO_ID = @{} $S3_REPO_IDS = @{} foreach ($REPO in $OBJECT_STORAGE_REPOS) { $S3_REPO_IDS[$REPO.Id.ToString()] = $true } -$ACTIVE_COPY_JOB_IDS = @{} -foreach ($CJ in $COPY_JOBS) { - if ($null -ne $CJ.TargetRepository -and $S3_REPO_IDS.ContainsKey($CJ.TargetRepository.Id.ToString())) { - $ACTIVE_COPY_JOB_IDS[$CJ.Id.ToString()] = $true - } -} -# Enumerate S3-targeted backups for size + child details +# Build set of all active job IDs (any job that currently exists in Veeam) +$ACTIVE_JOB_IDS = @{} +try { + $ALL_JOBS = @(Get-VBRJob -WarningAction SilentlyContinue) + foreach ($J in $ALL_JOBS) { $ACTIVE_JOB_IDS[$J.Id.ToString()] = $true } +} catch { } +foreach ($CJ in $COPY_JOBS) { $ACTIVE_JOB_IDS[$CJ.Id.ToString()] = $true } +try { + $COMPUTER_JOBS = @(Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) + foreach ($J in $COMPUTER_JOBS) { $ACTIVE_JOB_IDS[$J.Id.ToString()] = $true } +} catch { } +Write-Host " Active jobs found: $($ACTIVE_JOB_IDS.Count)" + +# Get all repo names for display +$REPO_NAMES = @{} +try { + $ALL_REPOS = @(Get-VBRBackupRepository) + foreach ($R in $ALL_REPOS) { $REPO_NAMES[$R.Id.ToString()] = $R.Name } +} catch { } +foreach ($REPO in $OBJECT_STORAGE_REPOS) { $REPO_NAMES[$REPO.Id.ToString()] = $REPO.Name } + +# Enumerate ALL backups for orphan detection + S3 size calculation +$ALL_ORPHAN_ENTRIES = [System.Collections.Generic.List[hashtable]]::new() + foreach ($BACKUP in $ALL_BACKUPS) { $RID = $BACKUP.RepositoryId.ToString() - if (-not $S3_REPO_IDS.ContainsKey($RID)) { continue } + $IS_S3 = $S3_REPO_IDS.ContainsKey($RID) + $REPO_DISPLAY = if ($REPO_NAMES.ContainsKey($RID)) { $REPO_NAMES[$RID] } else { $RID.Substring(0, 8) } + + if ($IS_S3) { + if (-not $SIZE_BY_REPO_ID.ContainsKey($RID)) { $SIZE_BY_REPO_ID[$RID] = [long]0 } + if (-not $S3_CHILDREN_BY_REPO_ID.ContainsKey($RID)) { $S3_CHILDREN_BY_REPO_ID[$RID] = [System.Collections.Generic.List[hashtable]]::new() } + } - if (-not $SIZE_BY_REPO_ID.ContainsKey($RID)) { $SIZE_BY_REPO_ID[$RID] = [long]0 } - if (-not $CHILDREN_BY_REPO_ID.ContainsKey($RID)) { $CHILDREN_BY_REPO_ID[$RID] = [System.Collections.Generic.List[hashtable]]::new() } + # Check if this backup's job still exists + $JOB_EXISTS = $ACTIVE_JOB_IDS.ContainsKey($BACKUP.JobId.ToString()) - $BACKUPS_TO_CHECK = @($BACKUP) + # Expand per-VM containers into child backups, or use the backup itself + $ENTRIES_TO_CHECK = @($BACKUP) try { if ($BACKUP.IsTruePerVmContainer) { $CHILDREN = $BACKUP.FindChildBackups() - if ($CHILDREN) { $BACKUPS_TO_CHECK = @($CHILDREN) } + if ($CHILDREN) { $ENTRIES_TO_CHECK = @($CHILDREN) } } } catch { } - foreach ($B in $BACKUPS_TO_CHECK) { + foreach ($B in $ENTRIES_TO_CHECK) { $CHILD_SIZE = [long]0 $LAST_POINT_TIME = $null try { @@ -297,35 +321,49 @@ foreach ($BACKUP in $ALL_BACKUPS) { if ($ST_SIZE -eq 0) { try { if ($ST.BackupSize -gt 0) { $ST_SIZE = [long]$ST.BackupSize } } catch {} } $CHILD_SIZE += $ST_SIZE } - # Get most recent storage creation time as proxy for last restore point $LATEST_STORAGE = $STORAGES | Sort-Object CreationTime -Descending | Select-Object -First 1 if ($LATEST_STORAGE) { $LAST_POINT_TIME = $LATEST_STORAGE.CreationTime } } catch { } - $SIZE_BY_REPO_ID[$RID] += $CHILD_SIZE + # S3 size tracking + if ($IS_S3) { $SIZE_BY_REPO_ID[$RID] += $CHILD_SIZE } - # Extract machine name from child backup name (e.g. "S3 Copy 1686654096\workstations01 - BUS2") - $MACHINE_NAME = "Unknown" - $BACKUP_DISPLAY_NAME = $B.Name - if ($BACKUP_DISPLAY_NAME -match '\\(.+?) - (.+)$') { + # Extract machine name + $MACHINE_NAME = $B.Name + if ($MACHINE_NAME -match '\\(.+?) - (.+)$') { $MACHINE_NAME = $Matches[2].Trim() - } elseif ($BACKUP_DISPLAY_NAME -match ' - (.+)$') { + } elseif ($MACHINE_NAME -match ' - (.+)$') { $MACHINE_NAME = $Matches[1].Trim() } - # Check if this backup's parent job is an active copy job - $PARENT_JOB_ID = $BACKUP.JobId.ToString() - $IS_JOB_LINKED = $ACTIVE_COPY_JOB_IDS.ContainsKey($PARENT_JOB_ID) - - $CHILDREN_BY_REPO_ID[$RID].Add(@{ - Name = $BACKUP_DISPLAY_NAME + $ENTRY = @{ + Name = $B.Name MachineName = $MACHINE_NAME Size = $CHILD_SIZE SizeDisplay = if ($CHILD_SIZE -gt 0) { Format-SizeBytes -Bytes $CHILD_SIZE } else { "N/A" } LastPoint = $LAST_POINT_TIME LastPointStr = if ($LAST_POINT_TIME) { $LAST_POINT_TIME.ToString("yyyy-MM-dd") } else { "N/A" } - IsJobLinked = $IS_JOB_LINKED - }) + JobExists = $JOB_EXISTS + RepoName = $REPO_DISPLAY + IsS3 = $IS_S3 + } + + # Track S3 children for last-used bucket detection + if ($IS_S3) { $S3_CHILDREN_BY_REPO_ID[$RID].Add($ENTRY) } + + # Orphan detection: no active job OR stale (applies to ALL repos) + $REASON = $null + if (-not $JOB_EXISTS) { + $REASON = "No active job" + } elseif ($null -ne $LAST_POINT_TIME -and $LAST_POINT_TIME -lt $ORPHAN_CUTOFF) { + $DAYS_AGO = [int]((Get-Date) - $LAST_POINT_TIME).TotalDays + $REASON = "Stale ($DAYS_AGO days)" + } + + if ($REASON) { + $ENTRY.Reason = $REASON + $ALL_ORPHAN_ENTRIES.Add($ENTRY) + } } } @@ -336,8 +374,6 @@ Write-Host "" Write-Host "Processing repositories..." $REPO_ROWS = [System.Collections.Generic.List[string[]]]::new() -$ORPHAN_ROWS = [System.Collections.Generic.List[string[]]]::new() -$ORPHAN_COUNT = 0 $LAST_USED_BUCKET = "N/A" $LAST_USED_SIZE = "N/A" @@ -389,10 +425,9 @@ foreach ($REPO in $OBJECT_STORAGE_REPOS) { $REPO_ROWS.Add(@($BUCKET_NAME, $REPO.Name, $REPO_TYPE, $USED_SPACE_DISPLAY)) # --- LAST USED tracking --- - # Find the most recent restore point time across all children in this repo $REPO_LATEST_TIME = [datetime]::MinValue - if ($CHILDREN_BY_REPO_ID.ContainsKey($REPO_ID)) { - foreach ($CHILD in $CHILDREN_BY_REPO_ID[$REPO_ID]) { + if ($S3_CHILDREN_BY_REPO_ID.ContainsKey($REPO_ID)) { + foreach ($CHILD in $S3_CHILDREN_BY_REPO_ID[$REPO_ID]) { if ($null -ne $CHILD.LastPoint -and $CHILD.LastPoint -gt $REPO_LATEST_TIME) { $REPO_LATEST_TIME = $CHILD.LastPoint } @@ -404,28 +439,13 @@ foreach ($REPO in $OBJECT_STORAGE_REPOS) { $LAST_USED_SIZE = $USED_SPACE_DISPLAY $LAST_USED_SIZE_BYTES = $USED_SPACE_BYTES } +} - # --- ORPHAN DETECTION --- - # Two cases: - # 1. Unlinked: backup data in S3 not associated with any active copy job - # 2. Stale: last backup older than threshold (machine removed from job) - if ($CHILDREN_BY_REPO_ID.ContainsKey($REPO_ID)) { - foreach ($CHILD in $CHILDREN_BY_REPO_ID[$REPO_ID]) { - $REASON = $null - - if (-not $CHILD.IsJobLinked) { - $REASON = "No active job" - } elseif ($null -ne $CHILD.LastPoint -and $CHILD.LastPoint -lt $ORPHAN_CUTOFF) { - $DAYS_AGO = [int]((Get-Date) - $CHILD.LastPoint).TotalDays - $REASON = "Stale ($DAYS_AGO days)" - } - - if ($REASON) { - $ORPHAN_ROWS.Add(@($CHILD.MachineName, $CHILD.SizeDisplay, $CHILD.LastPointStr, $REASON, $BUCKET_NAME)) - $ORPHAN_COUNT++ - } - } - } +# --- ORPHAN ROWS (all repos) --- +$ORPHAN_ROWS = [System.Collections.Generic.List[string[]]]::new() +$ORPHAN_COUNT = $ALL_ORPHAN_ENTRIES.Count +foreach ($ENTRY in $ALL_ORPHAN_ENTRIES) { + $ORPHAN_ROWS.Add(@($ENTRY.MachineName, $ENTRY.SizeDisplay, $ENTRY.LastPointStr, $ENTRY.Reason, $ENTRY.RepoName)) } # If only one S3 repo exists and last-used tracking didn't match via time, use it directly @@ -456,7 +476,7 @@ if ($REPO_ROWS.Count -gt 0) { if ($ORPHAN_COUNT -gt 0) { Write-Host "=== Orphaned Backups ===" foreach ($ROW in $ORPHAN_ROWS) { - Write-Host " Machine: $($ROW[0]) | Size: $($ROW[1]) | Last: $($ROW[2]) | Reason: $($ROW[3]) | Bucket: $($ROW[4])" + Write-Host " Machine: $($ROW[0]) | Size: $($ROW[1]) | Last: $($ROW[2]) | Reason: $($ROW[3]) | Repo: $($ROW[4])" } Write-Host "" } @@ -471,7 +491,7 @@ $HTML_INVENTORY = Build-HtmlTable ` -EmptyMessage "No S3-compatible object storage repositories found." $HTML_ORPHANS = Build-HtmlTable ` - -Headers @("Machine", "Size", "Last Backup", "Reason", "Bucket") ` + -Headers @("Machine", "Size", "Last Backup", "Reason", "Repository") ` -Rows $ORPHAN_ROWS ` -ScanTimestamp $SCAN_TIMESTAMP ` -EmptyMessage "No orphaned backups detected." diff --git a/bdr-veeam/veeam-s3-diag.ps1 b/bdr-veeam/veeam-s3-diag.ps1 index 79abea2..d82e4f9 100644 --- a/bdr-veeam/veeam-s3-diag.ps1 +++ b/bdr-veeam/veeam-s3-diag.ps1 @@ -1,4 +1,4 @@ -# Diagnostic script - test all data extraction paths for S3 inventory +# Diagnostic script - test all data extraction paths for S3 inventory + orphan detection # Run on a BDR server in PS7 if ($PSVersionTable.PSVersion.Major -lt 7) { @@ -35,83 +35,85 @@ try { } catch { Write-Host "FAILED: $_" } Write-Host "==========================================" -Write-Host "2: SIZE + LAST BACKUP DATE (per child)" +Write-Host "2: ALL ACTIVE JOBS" Write-Host "==========================================" +$ACTIVE_JOB_IDS = @{} try { - $ALL_BACKUPS = Get-VBRBackup - foreach ($B in $ALL_BACKUPS) { - Write-Host "--- $($B.Name) ---" - Write-Host " Type: $($B.TypeToString) | RepoId: $($B.RepositoryId) | PerVm: $($B.IsTruePerVmContainer)" - - if ($B.IsTruePerVmContainer) { - $CHILDREN = $B.FindChildBackups() - Write-Host " Children: $($CHILDREN.Count)" - $TOTAL = [long]0 - foreach ($CHILD in $CHILDREN) { - $STORAGES = $CHILD.GetAllStorages() - $CHILD_SIZE = [long]0 - foreach ($ST in $STORAGES) { - try { if ($ST.Stats.BackupSize -gt 0) { $CHILD_SIZE += [long]$ST.Stats.BackupSize } } catch {} - } - $LATEST = $STORAGES | Sort-Object CreationTime -Descending | Select-Object -First 1 - $LATEST_DATE = if ($LATEST) { $LATEST.CreationTime.ToString("yyyy-MM-dd") } else { "N/A" } - - if ($CHILD_SIZE -ge 1GB) { $DISPLAY = "{0:N2} GB" -f ($CHILD_SIZE / 1GB) } - elseif ($CHILD_SIZE -ge 1MB) { $DISPLAY = "{0:N2} MB" -f ($CHILD_SIZE / 1MB) } - else { $DISPLAY = "$CHILD_SIZE B" } - - Write-Host " $($CHILD.Name) | $($STORAGES.Count) storages | $DISPLAY | Last: $LATEST_DATE" - $TOTAL += $CHILD_SIZE - } - if ($TOTAL -ge 1TB) { Write-Host " TOTAL: $("{0:N2} TB" -f ($TOTAL / 1TB))" } - elseif ($TOTAL -ge 1GB) { Write-Host " TOTAL: $("{0:N2} GB" -f ($TOTAL / 1GB))" } - } - Write-Host "" + $ALL_JOBS = @(Get-VBRJob -WarningAction SilentlyContinue) + Write-Host "Get-VBRJob: $($ALL_JOBS.Count)" + foreach ($J in $ALL_JOBS) { + $ACTIVE_JOB_IDS[$J.Id.ToString()] = $true + Write-Host " $($J.Name) ($($J.JobType)) - $($J.Id)" } -} catch { Write-Host "FAILED: $_" } +} catch { Write-Host "Get-VBRJob FAILED: $_" } +try { + $COPY_JOBS2 = @(Get-VBRBackupCopyJob -ErrorAction Stop) + Write-Host "Get-VBRBackupCopyJob: $($COPY_JOBS2.Count)" + foreach ($CJ in $COPY_JOBS2) { + $ACTIVE_JOB_IDS[$CJ.Id.ToString()] = $true + Write-Host " $($CJ.Name) - $($CJ.Id)" + } +} catch { Write-Host "Get-VBRBackupCopyJob FAILED: $_" } +try { + $COMPUTER_JOBS = @(Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) + Write-Host "Get-VBRComputerBackupJob: $($COMPUTER_JOBS.Count)" + foreach ($J in $COMPUTER_JOBS) { + $ACTIVE_JOB_IDS[$J.Id.ToString()] = $true + Write-Host " $($J.Name) - $($J.Id)" + } +} catch { Write-Host "Get-VBRComputerBackupJob FAILED: $_" } +Write-Host "Total active job IDs: $($ACTIVE_JOB_IDS.Count)`n" Write-Host "==========================================" -Write-Host "3: ORPHAN DETECTION (time-based + unlinked)" +Write-Host "3: ORPHAN DETECTION (all repos, time + unlinked)" Write-Host " Threshold: $ORPHAN_DAYS days (before $($ORPHAN_CUTOFF.ToString('yyyy-MM-dd')))" Write-Host "==========================================" + +# Repo name lookup +$REPO_NAMES = @{} +try { + $ALL_REPOS = @(Get-VBRBackupRepository) + foreach ($R in $ALL_REPOS) { $REPO_NAMES[$R.Id.ToString()] = $R.Name } +} catch { } try { - $COPY_JOBS = Get-VBRBackupCopyJob -ErrorAction Stop - $ALL_BACKUPS = Get-VBRBackup $S3_REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop - $S3_IDS = @{} - foreach ($R in $S3_REPOS) { $S3_IDS[$R.Id.ToString()] = $true } - - # Active copy job IDs targeting S3 - $ACTIVE_CJ_IDS = @{} - foreach ($CJ in $COPY_JOBS) { - if ($null -ne $CJ.TargetRepository -and $S3_IDS.ContainsKey($CJ.TargetRepository.Id.ToString())) { - $ACTIVE_CJ_IDS[$CJ.Id.ToString()] = $CJ.Name - Write-Host " Active copy job: $($CJ.Name) ($($CJ.Id))" - } - } + foreach ($R in $S3_REPOS) { $REPO_NAMES[$R.Id.ToString()] = $R.Name } +} catch { } - Write-Host "" +try { + $ALL_BACKUPS = Get-VBRBackup + Write-Host "Total backups: $($ALL_BACKUPS.Count)`n" foreach ($B in $ALL_BACKUPS) { $RID = $B.RepositoryId.ToString() - if (-not $S3_IDS.ContainsKey($RID)) { continue } - if (-not $B.IsTruePerVmContainer) { continue } + $REPO_NAME = if ($REPO_NAMES.ContainsKey($RID)) { $REPO_NAMES[$RID] } else { $RID.Substring(0, 8) } + $JOB_EXISTS = $ACTIVE_JOB_IDS.ContainsKey($B.JobId.ToString()) - $IS_JOB_LINKED = $ACTIVE_CJ_IDS.ContainsKey($B.JobId.ToString()) - Write-Host " Backup: $($B.Name) | JobId: $($B.JobId) | Linked: $IS_JOB_LINKED" + Write-Host "--- $($B.Name) ---" + Write-Host " Type: $($B.TypeToString) | Repo: $REPO_NAME | JobId: $($B.JobId) | JobExists: $JOB_EXISTS | PerVm: $($B.IsTruePerVmContainer)" + + $ENTRIES = @($B) + if ($B.IsTruePerVmContainer) { + try { + $CHILDREN = $B.FindChildBackups() + if ($CHILDREN) { $ENTRIES = @($CHILDREN) } + Write-Host " Children: $($CHILDREN.Count)" + } catch { } + } - $CHILDREN = $B.FindChildBackups() - foreach ($CHILD in $CHILDREN) { - $STORAGES = $CHILD.GetAllStorages() + foreach ($E in $ENTRIES) { + $STORAGES = @() + try { $STORAGES = @($E.GetAllStorages()) } catch { } $LATEST = $STORAGES | Sort-Object CreationTime -Descending | Select-Object -First 1 $LATEST_DATE = if ($LATEST) { $LATEST.CreationTime } else { $null } $LATEST_STR = if ($LATEST_DATE) { $LATEST_DATE.ToString("yyyy-MM-dd") } else { "N/A" } - $PARTS = $CHILD.Name -split ' - ', 2 - $MACHINE = if ($PARTS.Count -ge 2) { $PARTS[1].Trim() } else { $CHILD.Name } + $MACHINE = $E.Name + if ($MACHINE -match '\\(.+?) - (.+)$') { $MACHINE = $Matches[2].Trim() } + elseif ($MACHINE -match ' - (.+)$') { $MACHINE = $Matches[1].Trim() } $REASON = $null - if (-not $IS_JOB_LINKED) { + if (-not $JOB_EXISTS) { $REASON = "No active job" } elseif ($null -ne $LATEST_DATE -and $LATEST_DATE -lt $ORPHAN_CUTOFF) { $DAYS = [int]((Get-Date) - $LATEST_DATE).TotalDays @@ -119,7 +121,8 @@ try { } $STATUS = if ($REASON) { "ORPHANED - $REASON" } else { "ACTIVE" } - Write-Host " [$STATUS] $MACHINE | Last: $LATEST_STR" + $SIZE_STR = if ($STORAGES.Count -gt 0) { "$($STORAGES.Count) storages" } else { "0 storages" } + Write-Host " [$STATUS] $MACHINE | $SIZE_STR | Last: $LATEST_STR | Repo: $REPO_NAME" } Write-Host "" } From ac1e57b3e7b463181a72cf57485874bba1c8e246 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 11:48:34 -0400 Subject: [PATCH 10/77] Rename orphan env vars to drop S3 prefix since they cover all repos CUSTOM_FIELD_S3_ORPHANS_FOUND -> CUSTOM_FIELD_ORPHANS_FOUND CUSTOM_FIELD_S3_ORPHANS -> CUSTOM_FIELD_ORPHANED_BACKUPS Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-s3-bucket-inventory.ps1 | 28 ++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/bdr-veeam/veeam-s3-bucket-inventory.ps1 b/bdr-veeam/veeam-s3-bucket-inventory.ps1 index 0594a07..37714e8 100644 --- a/bdr-veeam/veeam-s3-bucket-inventory.ps1 +++ b/bdr-veeam/veeam-s3-bucket-inventory.ps1 @@ -1,13 +1,13 @@ ## PLEASE SET THE FOLLOWING ENVIRONMENT VARIABLES IN YOUR RMM BEFORE RUNNING -## $env:CUSTOM_FIELD_S3_BUCKET_NAME - Text field: last used S3 bucket name -## $env:CUSTOM_FIELD_S3_BUCKET_SIZE - Text field: last used S3 bucket size (human readable) -## $env:CUSTOM_FIELD_S3_ORPHANS_FOUND - Integer field: 1 if orphaned backups found, 0 if not -## $env:CUSTOM_FIELD_S3_INVENTORY - WYSIWYG field: HTML table of all S3 repos -## $env:CUSTOM_FIELD_S3_ORPHANS - WYSIWYG field: HTML table of orphaned backup data -## $env:ORPHAN_DAYS_THRESHOLD - Days since last backup to consider orphaned (default: 30) -## $env:DESCRIPTION - Ticket # or initials for audit trail -## $env:RMM - Set to 1 when running from RMM platform -## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) +## $env:CUSTOM_FIELD_S3_BUCKET_NAME - Text field: last used S3 bucket name +## $env:CUSTOM_FIELD_S3_BUCKET_SIZE - Text field: last used S3 bucket size (human readable) +## $env:CUSTOM_FIELD_S3_INVENTORY - WYSIWYG field: HTML table of all S3 repos +## $env:CUSTOM_FIELD_ORPHANS_FOUND - Integer field: 1 if orphaned backups found, 0 if not +## $env:CUSTOM_FIELD_ORPHANED_BACKUPS - WYSIWYG field: HTML table of orphaned/stale backup data (all repos) +## $env:ORPHAN_DAYS_THRESHOLD - Days since last backup to consider orphaned (default: 30) +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform +## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) # ============================================================ # PS7 BOOTSTRAP @@ -142,9 +142,9 @@ if ($env:RMM -ne "1") { $env:CUSTOM_FIELD_S3_BUCKET_NAME = Read-Host "NinjaOne text field for S3 bucket name (blank to skip)" $env:CUSTOM_FIELD_S3_BUCKET_SIZE = Read-Host "NinjaOne text field for S3 bucket size (blank to skip)" - $env:CUSTOM_FIELD_S3_ORPHANS_FOUND = Read-Host "NinjaOne integer field for orphans found flag (blank to skip)" $env:CUSTOM_FIELD_S3_INVENTORY = Read-Host "NinjaOne WYSIWYG field for S3 inventory table (blank to skip)" - $env:CUSTOM_FIELD_S3_ORPHANS = Read-Host "NinjaOne WYSIWYG field for orphaned backups table (blank to skip)" + $env:CUSTOM_FIELD_ORPHANS_FOUND = Read-Host "NinjaOne integer field for orphans found flag (blank to skip)" + $env:CUSTOM_FIELD_ORPHANED_BACKUPS = Read-Host "NinjaOne WYSIWYG field for orphaned backups table (blank to skip)" $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" @@ -503,13 +503,13 @@ Write-Host "Writing to NinjaOne custom fields..." Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_NAME $LAST_USED_BUCKET Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_SIZE $LAST_USED_SIZE -Set-NinjaField $env:CUSTOM_FIELD_S3_ORPHANS_FOUND "$([int]($ORPHAN_COUNT -gt 0))" Set-NinjaField $env:CUSTOM_FIELD_S3_INVENTORY $HTML_INVENTORY -Set-NinjaField $env:CUSTOM_FIELD_S3_ORPHANS $HTML_ORPHANS +Set-NinjaField $env:CUSTOM_FIELD_ORPHANS_FOUND "$([int]($ORPHAN_COUNT -gt 0))" +Set-NinjaField $env:CUSTOM_FIELD_ORPHANED_BACKUPS $HTML_ORPHANS # If no Ninja available and no fields set, dump HTML to console if (-not (Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue)) { - if (-not $env:CUSTOM_FIELD_S3_INVENTORY -and -not $env:CUSTOM_FIELD_S3_ORPHANS) { + if (-not $env:CUSTOM_FIELD_S3_INVENTORY -and -not $env:CUSTOM_FIELD_ORPHANED_BACKUPS) { Write-Host "" Write-Host "=== Inventory HTML ===" Write-Host $HTML_INVENTORY From b886f13923fea35627846e63ea384e518bc4abb4 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 11:52:44 -0400 Subject: [PATCH 11/77] Rename script to veeam-inventory.ps1 Covers S3 bucket details, storage sizing, and orphan detection across all repository types - not just S3 bucket inventory anymore. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...cket-inventory.ps1 => veeam-inventory.ps1} | 0 veeam/veeam-add-backup-repo.ps1 | 130 ------------------ 2 files changed, 130 deletions(-) rename bdr-veeam/{veeam-s3-bucket-inventory.ps1 => veeam-inventory.ps1} (100%) delete mode 100644 veeam/veeam-add-backup-repo.ps1 diff --git a/bdr-veeam/veeam-s3-bucket-inventory.ps1 b/bdr-veeam/veeam-inventory.ps1 similarity index 100% rename from bdr-veeam/veeam-s3-bucket-inventory.ps1 rename to bdr-veeam/veeam-inventory.ps1 diff --git a/veeam/veeam-add-backup-repo.ps1 b/veeam/veeam-add-backup-repo.ps1 deleted file mode 100644 index 8d94110..0000000 --- a/veeam/veeam-add-backup-repo.ps1 +++ /dev/null @@ -1,130 +0,0 @@ -## Please note this script can only support the following backup repository types ## -# S3 Compabitble & Local. Both are forced. - -Start-Transcript -Path $env:WINDIR\logs\veeam-add-backup-repo.log - -Write-Host "Checking if we are running from a RMM or not." - -# if ($rmm -ne 1) { - # Set the repository details - # $repositoryType = "Please enter the repository type (1 S3 Compatible; 2 Windows Local; 3 Both)" - # $moveBackups = "Enter 1 if you wish to move your local backups" - # $description = Read-Host "Please enter the ticket # or project ticket # related to this configuration" - # $immutabilityPeriod = Read-Host "Enter how many days every object is immutable for" - # $repositoryName = Read-Host "Enter the repository name" | Out-String - # $accessKey = Read-Host "Enter the access key" - # $secretKey = Read-Host "Enter the secret key" - # $endpoint = Read-Host "Enter the S3 endpoint url" - # $regionId = Read-Host "Enter the region ID" - # $bucketName = Read-Host "Enter the bucket name" - -# } - -Write-Host "The varialbes are now set." -Write-Host "Repository type: $repositoryType" -Write-Host "Move backups: $moveBackups" -Write-Host "Description: $description" -Write-Host "Immutability period: $immutabilityPeriod" -Write-Host "Access key: $accessKey" -Write-Host "Secret key: **redacted for sensativity**" -Write-Host "S3 Endpoint: $endpoint" -Write-Host "S3 Region ID: $regionId" -Write-Host "S3 Bucket Name: $bucketName" -Write-Host "Automatic volume letter to create WinLocal repo: " -Write-Host "Drive letters to create WinLocal Repos: $driveLetters" - - -# Make sure PSModulePath includes Veeam Console -Write-Host "Installing Veeam PowerShell Module if not installed already." -$MyModulePath = "C:\Program Files\Veeam\Backup and Replication\Console\" -$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MyModulePath" -if ($Modules = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { - try { - $Modules | Import-Module -WarningAction SilentlyContinue - } - catch { - throw "Failed to load Veeam Modules" - } - } - -# Set Timestamp -Write-Host "Getting timestamp for repository names." -$timeStamp = [int](Get-Date -UFormat %s -Millisecond 0) -$folderName = $timeStamp - - -if ($repositoryType -eq 1 -Or $repositoryType -eq 3){ - Write-Host "Creating S3 Repository: S3 $timeStamp" - # Add the S3 Account - $account = Add-VBRAmazonAccount -AccessKey $accessKey -SecretKey $secretKey -Description "$description $bucketName" - - # Create the S3 repository - $connect = Connect-VBRAmazonS3CompatibleService -Account $account -CustomRegionId $regionId -ServicePoint $endpoint - $bucket = Get-VBRAmazonS3Bucket -Connection $connect -Name $bucketName - $folder = New-VBRAmazonS3Folder -Name $folderName -Connection $connect -Bucket $bucket - Add-VBRAmazonS3CompatibleRepository -AmazonS3Folder $folder -Connection $connect -Name "S3 $timeStamp" -EnableBackupImmutability -ImmutabilityPeriod $immutabilityPeriod -Description "$description $bucketName" - - # Display the added repository details - $repository - -} - - - -if ($repositoryType -eq 2 -Or $repositoryType -eq 3){ - Write-Host "Creating local repository Local $timeStamp" - # Get all logical drives on the system - $drives = Get-WmiObject -Class Win32_LogicalDisk | Where-Object { $_.DriveType -eq 3 } - - # Find the drive with the largest total capacity - if ($drives.Count -gt 1){ - $filteredDrives = $drives | Where-Object { $_.DeviceId -ne 'C:' } - - } else { - $filteredDrives = $drives - } - - # Create the local repository - $filteredDrives | ForEach-Object { - $timeStamp = [int](Get-Date -UFormat %s -Millisecond 0) - $repositoryPath = Join-Path -Path $_.DeviceID -ChildPath "\veeam\$timeStamp" - $repositoryName = "Local $timeStamp" - Write-Host "Repository name: $repositoryName" - Write-Host "Repository path: $repositoryPath" - $repository = Add-VBRBackupRepository -Type WinLocal -Name "$repositoryName" -Folder $repositoryPath -Description "$description" - # Display the added repository details - $repository - $localRepository = $repository - Start-Sleep -Seconds 1 - } -} - - - -# Move all local backups - - -if ($moveBackups){ - $backups = Get-VBRBackup - $localRepository = Get-VBRBackupRepository | Where -Property Name -like "Local*" | Select -First 1 - $spaceAvailable = - Write-Host "moving all backups to $localRepository" - $backups | ForEach-Object { - Move-VBRBackup -Repository $localRepository -Backup $_ -RunAsync - } -} - -# Move listed backups -if ($moveListedBackups){ - Write-Host "Moving $moveListedBackups to $localRepository." - - $moveListedBackups | ForEach-Object { - Move-VBRBackup -Repository $localRepository -Backup $_ -RunAsync - } - -} - -# Move local/scale out repo to new repo - - -Stop-Transcript \ No newline at end of file From c25407da97b9766f1b6e75afa4f1c5e22119bca6 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 11:54:57 -0400 Subject: [PATCH 12/77] Add failed backup detection with checkbox and WYSIWYG fields Queries Get-VBRBackupSession for completed sessions with Failed or Warning result within FAILED_BACKUP_HOURS (default 24). New NinjaOne fields: - CUSTOM_FIELD_FAILED_BACKUP: checkbox (1/0) for device group alerts - CUSTOM_FIELD_FAILED_BACKUPS: WYSIWYG HTML table with job name, result, end time, and duration Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-inventory.ps1 | 64 +++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/bdr-veeam/veeam-inventory.ps1 b/bdr-veeam/veeam-inventory.ps1 index 37714e8..2bd37f2 100644 --- a/bdr-veeam/veeam-inventory.ps1 +++ b/bdr-veeam/veeam-inventory.ps1 @@ -4,6 +4,9 @@ ## $env:CUSTOM_FIELD_S3_INVENTORY - WYSIWYG field: HTML table of all S3 repos ## $env:CUSTOM_FIELD_ORPHANS_FOUND - Integer field: 1 if orphaned backups found, 0 if not ## $env:CUSTOM_FIELD_ORPHANED_BACKUPS - WYSIWYG field: HTML table of orphaned/stale backup data (all repos) +## $env:CUSTOM_FIELD_FAILED_BACKUP - Checkbox field: checked if any backup jobs failed recently +## $env:CUSTOM_FIELD_FAILED_BACKUPS - WYSIWYG field: HTML table of failed/warning backup sessions +## $env:FAILED_BACKUP_HOURS - Hours to look back for failed sessions (default: 24) ## $env:ORPHAN_DAYS_THRESHOLD - Days since last backup to consider orphaned (default: 30) ## $env:DESCRIPTION - Ticket # or initials for audit trail ## $env:RMM - Set to 1 when running from RMM platform @@ -145,6 +148,8 @@ if ($env:RMM -ne "1") { $env:CUSTOM_FIELD_S3_INVENTORY = Read-Host "NinjaOne WYSIWYG field for S3 inventory table (blank to skip)" $env:CUSTOM_FIELD_ORPHANS_FOUND = Read-Host "NinjaOne integer field for orphans found flag (blank to skip)" $env:CUSTOM_FIELD_ORPHANED_BACKUPS = Read-Host "NinjaOne WYSIWYG field for orphaned backups table (blank to skip)" + $env:CUSTOM_FIELD_FAILED_BACKUP = Read-Host "NinjaOne checkbox field for failed backup flag (blank to skip)" + $env:CUSTOM_FIELD_FAILED_BACKUPS = Read-Host "NinjaOne WYSIWYG field for failed backups table (blank to skip)" $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" @@ -172,7 +177,7 @@ if ($env:RMM -ne "1") { Start-Transcript -Path $LOG_PATH -Write-Host "=== Veeam S3 Bucket Inventory ===" +Write-Host "=== Veeam Inventory ===" Write-Host "Description: $env:DESCRIPTION" Write-Host "Log path: $LOG_PATH" Write-Host "RMM mode: $($env:RMM -eq '1')" @@ -448,6 +453,41 @@ foreach ($ENTRY in $ALL_ORPHAN_ENTRIES) { $ORPHAN_ROWS.Add(@($ENTRY.MachineName, $ENTRY.SizeDisplay, $ENTRY.LastPointStr, $ENTRY.Reason, $ENTRY.RepoName)) } +# ------------------------------------------------------------ +# Failed backup detection +# ------------------------------------------------------------ +$FAILED_HOURS = 24 +if ($env:FAILED_BACKUP_HOURS) { + try { $FAILED_HOURS = [int]$env:FAILED_BACKUP_HOURS } catch {} +} +$FAILED_CUTOFF = (Get-Date).AddHours(-$FAILED_HOURS) +Write-Host " Checking for failed backup sessions in the last $FAILED_HOURS hours..." + +$FAILED_ROWS = [System.Collections.Generic.List[string[]]]::new() +$FAILED_COUNT = 0 + +try { + $RECENT_SESSIONS = Get-VBRBackupSession -ErrorAction Stop | Where-Object { + $_.EndTime -gt $FAILED_CUTOFF -and $_.IsCompleted -and ($_.Result -eq "Failed" -or $_.Result -eq "Warning") + } + + foreach ($SESS in $RECENT_SESSIONS) { + # Extract job name (strip child worker suffixes for cleaner display) + $JOB_NAME = $SESS.OrigJobName + if (-not $JOB_NAME) { $JOB_NAME = $SESS.JobName } + + $RESULT_STR = $SESS.Result.ToString() + $END_TIME_STR = $SESS.EndTime.ToString("yyyy-MM-dd HH:mm") + $DURATION_STR = if ($SESS.Progress -and $SESS.Progress.Duration) { $SESS.Progress.Duration.ToString("hh\:mm\:ss") } else { "N/A" } + + $FAILED_ROWS.Add(@($JOB_NAME, $RESULT_STR, $END_TIME_STR, $DURATION_STR)) + $FAILED_COUNT++ + } + Write-Host " [OK] Found $FAILED_COUNT failed/warning sessions." +} catch { + Write-Warning " Get-VBRBackupSession failed: $_" +} + # If only one S3 repo exists and last-used tracking didn't match via time, use it directly if ($LAST_USED_BUCKET -eq "N/A" -and $REPO_ROWS.Count -eq 1) { $LAST_USED_BUCKET = $REPO_ROWS[0][0] @@ -463,6 +503,7 @@ Write-Host " Last used S3 bucket: $LAST_USED_BUCKET" Write-Host " Last used S3 size: $LAST_USED_SIZE" Write-Host " S3 repos found: $($REPO_ROWS.Count)" Write-Host " Orphaned backups: $ORPHAN_COUNT" +Write-Host " Failed backups: $FAILED_COUNT (last $FAILED_HOURS hrs)" Write-Host "" if ($REPO_ROWS.Count -gt 0) { @@ -481,6 +522,14 @@ if ($ORPHAN_COUNT -gt 0) { Write-Host "" } +if ($FAILED_COUNT -gt 0) { + Write-Host "=== Failed Backups ===" + foreach ($ROW in $FAILED_ROWS) { + Write-Host " Job: $($ROW[0]) | Result: $($ROW[1]) | Ended: $($ROW[2]) | Duration: $($ROW[3])" + } + Write-Host "" +} + # ------------------------------------------------------------ # Build HTML tables # ------------------------------------------------------------ @@ -496,6 +545,12 @@ $HTML_ORPHANS = Build-HtmlTable ` -ScanTimestamp $SCAN_TIMESTAMP ` -EmptyMessage "No orphaned backups detected." +$HTML_FAILED = Build-HtmlTable ` + -Headers @("Job Name", "Result", "Ended", "Duration") ` + -Rows $FAILED_ROWS ` + -ScanTimestamp $SCAN_TIMESTAMP ` + -EmptyMessage "No failed backup sessions in the last $FAILED_HOURS hours." + # ------------------------------------------------------------ # Write to NinjaOne custom fields # ------------------------------------------------------------ @@ -506,16 +561,21 @@ Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_SIZE $LAST_USED_SIZE Set-NinjaField $env:CUSTOM_FIELD_S3_INVENTORY $HTML_INVENTORY Set-NinjaField $env:CUSTOM_FIELD_ORPHANS_FOUND "$([int]($ORPHAN_COUNT -gt 0))" Set-NinjaField $env:CUSTOM_FIELD_ORPHANED_BACKUPS $HTML_ORPHANS +Set-NinjaField $env:CUSTOM_FIELD_FAILED_BACKUP "$([int]($FAILED_COUNT -gt 0))" +Set-NinjaField $env:CUSTOM_FIELD_FAILED_BACKUPS $HTML_FAILED # If no Ninja available and no fields set, dump HTML to console if (-not (Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue)) { - if (-not $env:CUSTOM_FIELD_S3_INVENTORY -and -not $env:CUSTOM_FIELD_ORPHANED_BACKUPS) { + if (-not $env:CUSTOM_FIELD_S3_INVENTORY -and -not $env:CUSTOM_FIELD_ORPHANED_BACKUPS -and -not $env:CUSTOM_FIELD_FAILED_BACKUPS) { Write-Host "" Write-Host "=== Inventory HTML ===" Write-Host $HTML_INVENTORY Write-Host "" Write-Host "=== Orphans HTML ===" Write-Host $HTML_ORPHANS + Write-Host "" + Write-Host "=== Failed Backups HTML ===" + Write-Host $HTML_FAILED } } From 4a1064f9aa114187abd788ef8f056465561a9513 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 11:56:59 -0400 Subject: [PATCH 13/77] Only flag failed backups if the most recent run failed Groups all sessions by JobId, keeps only the latest per job. If a job failed but then re-ran successfully, it's no longer flagged. The checkbox clears automatically on the next script run after the job succeeds. Removed FAILED_BACKUP_HOURS config since we check last run status regardless of time window. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-inventory.ps1 | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/bdr-veeam/veeam-inventory.ps1 b/bdr-veeam/veeam-inventory.ps1 index 2bd37f2..962f02c 100644 --- a/bdr-veeam/veeam-inventory.ps1 +++ b/bdr-veeam/veeam-inventory.ps1 @@ -4,9 +4,8 @@ ## $env:CUSTOM_FIELD_S3_INVENTORY - WYSIWYG field: HTML table of all S3 repos ## $env:CUSTOM_FIELD_ORPHANS_FOUND - Integer field: 1 if orphaned backups found, 0 if not ## $env:CUSTOM_FIELD_ORPHANED_BACKUPS - WYSIWYG field: HTML table of orphaned/stale backup data (all repos) -## $env:CUSTOM_FIELD_FAILED_BACKUP - Checkbox field: checked if any backup jobs failed recently -## $env:CUSTOM_FIELD_FAILED_BACKUPS - WYSIWYG field: HTML table of failed/warning backup sessions -## $env:FAILED_BACKUP_HOURS - Hours to look back for failed sessions (default: 24) +## $env:CUSTOM_FIELD_FAILED_BACKUP - Checkbox field: checked if any backup job's last run failed +## $env:CUSTOM_FIELD_FAILED_BACKUPS - WYSIWYG field: HTML table of jobs whose last run failed/warned ## $env:ORPHAN_DAYS_THRESHOLD - Days since last backup to consider orphaned (default: 30) ## $env:DESCRIPTION - Ticket # or initials for audit trail ## $env:RMM - Set to 1 when running from RMM platform @@ -456,23 +455,27 @@ foreach ($ENTRY in $ALL_ORPHAN_ENTRIES) { # ------------------------------------------------------------ # Failed backup detection # ------------------------------------------------------------ -$FAILED_HOURS = 24 -if ($env:FAILED_BACKUP_HOURS) { - try { $FAILED_HOURS = [int]$env:FAILED_BACKUP_HOURS } catch {} -} -$FAILED_CUTOFF = (Get-Date).AddHours(-$FAILED_HOURS) -Write-Host " Checking for failed backup sessions in the last $FAILED_HOURS hours..." +Write-Host " Checking for jobs whose last run failed..." $FAILED_ROWS = [System.Collections.Generic.List[string[]]]::new() $FAILED_COUNT = 0 try { - $RECENT_SESSIONS = Get-VBRBackupSession -ErrorAction Stop | Where-Object { - $_.EndTime -gt $FAILED_CUTOFF -and $_.IsCompleted -and ($_.Result -eq "Failed" -or $_.Result -eq "Warning") + # Group all completed sessions by JobId, keep only the most recent per job + $ALL_SESSIONS = Get-VBRBackupSession -ErrorAction Stop | Where-Object { $_.IsCompleted } + $LATEST_BY_JOB = @{} + foreach ($SESS in $ALL_SESSIONS) { + $JID = $SESS.JobId.ToString() + if (-not $LATEST_BY_JOB.ContainsKey($JID) -or $SESS.EndTime -gt $LATEST_BY_JOB[$JID].EndTime) { + $LATEST_BY_JOB[$JID] = $SESS + } } - foreach ($SESS in $RECENT_SESSIONS) { - # Extract job name (strip child worker suffixes for cleaner display) + # Only flag jobs whose MOST RECENT session is failed/warning + # (if the job re-ran and succeeded, the failure is resolved) + foreach ($SESS in $LATEST_BY_JOB.Values) { + if ($SESS.Result -ne "Failed" -and $SESS.Result -ne "Warning") { continue } + $JOB_NAME = $SESS.OrigJobName if (-not $JOB_NAME) { $JOB_NAME = $SESS.JobName } @@ -483,7 +486,7 @@ try { $FAILED_ROWS.Add(@($JOB_NAME, $RESULT_STR, $END_TIME_STR, $DURATION_STR)) $FAILED_COUNT++ } - Write-Host " [OK] Found $FAILED_COUNT failed/warning sessions." + Write-Host " [OK] Found $FAILED_COUNT jobs with failed/warning last run." } catch { Write-Warning " Get-VBRBackupSession failed: $_" } @@ -503,7 +506,7 @@ Write-Host " Last used S3 bucket: $LAST_USED_BUCKET" Write-Host " Last used S3 size: $LAST_USED_SIZE" Write-Host " S3 repos found: $($REPO_ROWS.Count)" Write-Host " Orphaned backups: $ORPHAN_COUNT" -Write-Host " Failed backups: $FAILED_COUNT (last $FAILED_HOURS hrs)" +Write-Host " Failed backups: $FAILED_COUNT" Write-Host "" if ($REPO_ROWS.Count -gt 0) { @@ -549,7 +552,7 @@ $HTML_FAILED = Build-HtmlTable ` -Headers @("Job Name", "Result", "Ended", "Duration") ` -Rows $FAILED_ROWS ` -ScanTimestamp $SCAN_TIMESTAMP ` - -EmptyMessage "No failed backup sessions in the last $FAILED_HOURS hours." + -EmptyMessage "All backup jobs completed successfully on their last run." # ------------------------------------------------------------ # Write to NinjaOne custom fields From 75d80490a61f6d6f6a9f24c7b48742a8d63fb343 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 12:13:29 -0400 Subject: [PATCH 14/77] Add B2 bucket audit script to compare against active bucket list Standalone script in iaas-backblaze/ that queries the Backblaze B2 API for all buckets in the account, compares against a known-good list of active buckets (from ACTIVE_BUCKETS_CSV env var - accepts comma-separated names or a file path), and generates an HTML table with unused buckets highlighted in red and sorted to the top. Outputs to CUSTOM_FIELD_B2_AUDIT WYSIWYG field in NinjaOne. Runs centrally, not per-device. Co-Authored-By: Claude Opus 4.6 (1M context) --- iaas-backblaze/b2-bucket-audit.ps1 | 232 +++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 iaas-backblaze/b2-bucket-audit.ps1 diff --git a/iaas-backblaze/b2-bucket-audit.ps1 b/iaas-backblaze/b2-bucket-audit.ps1 new file mode 100644 index 0000000..f5ef762 --- /dev/null +++ b/iaas-backblaze/b2-bucket-audit.ps1 @@ -0,0 +1,232 @@ +## B2 Bucket Audit - List all Backblaze B2 buckets and flag unused ones +## Run centrally (not per-device). Compares B2 account buckets against +## a known-good list of active bucket names. Unused buckets show in red. +## +## $env:B2_KEY_ID - Backblaze B2 application key ID +## $env:B2_APP_KEY - Backblaze B2 application key +## $env:ACTIVE_BUCKETS_CSV - Comma-separated list of bucket names in active use +## (or path to a .txt/.csv file with one bucket per line) +## $env:CUSTOM_FIELD_B2_AUDIT - NinjaOne WYSIWYG field for the bucket audit table +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform + +# ============================================================ +# HELPER FUNCTIONS +# ============================================================ + +function ConvertTo-SafeHtml { + param([string]$Text) + if (-not $Text) { return "" } + return $Text.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace('"', """) +} + +function Format-SizeBytes { + param([long]$Bytes) + if ($Bytes -ge 1TB) { return "{0:N2} TB" -f ($Bytes / 1TB) } + if ($Bytes -ge 1GB) { return "{0:N2} GB" -f ($Bytes / 1GB) } + if ($Bytes -ge 1MB) { return "{0:N2} MB" -f ($Bytes / 1MB) } + if ($Bytes -ge 1KB) { return "{0:N2} KB" -f ($Bytes / 1KB) } + return "$Bytes B" +} + +function Set-NinjaField { + param([string]$FieldName, [string]$Value) + if (-not $FieldName) { return } + $NINJA_CMD = Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue + if ($NINJA_CMD) { + try { + Ninja-Property-Set $FieldName $Value + Write-Host " [OK] $FieldName updated." + } catch { + Write-Warning " Failed to write $FieldName : $_" + } + } else { + Write-Host " [SKIP] Ninja-Property-Set not available." + } +} + +# ============================================================ +# INPUT HANDLING +# ============================================================ + +if ($env:RMM -ne "1") { + if (-not $env:B2_KEY_ID) { + $env:B2_KEY_ID = Read-Host "Backblaze B2 application key ID" + } + if (-not $env:B2_APP_KEY) { + $env:B2_APP_KEY = Read-Host "Backblaze B2 application key" + } + if (-not $env:ACTIVE_BUCKETS_CSV) { + $env:ACTIVE_BUCKETS_CSV = Read-Host "Active bucket names (comma-separated, or path to file, or blank to show all)" + } + if (-not $env:CUSTOM_FIELD_B2_AUDIT) { + $env:CUSTOM_FIELD_B2_AUDIT = Read-Host "NinjaOne WYSIWYG field for audit table (blank to skip)" + } +} + +if (-not $env:B2_KEY_ID -or -not $env:B2_APP_KEY) { + Write-Error "B2_KEY_ID and B2_APP_KEY are required." + exit 1 +} + +# Parse active bucket list +$ACTIVE_BUCKETS = @{} +if ($env:ACTIVE_BUCKETS_CSV) { + $RAW = $env:ACTIVE_BUCKETS_CSV.Trim() + # Check if it's a file path + if (Test-Path $RAW -ErrorAction SilentlyContinue) { + $LINES = Get-Content $RAW | Where-Object { $_.Trim() -ne "" } + foreach ($L in $LINES) { + # Handle CSV with headers or plain list + $NAME = ($L -split ',')[0].Trim().Trim('"') + if ($NAME -and $NAME -ne "BucketName" -and $NAME -ne "bucket_name") { + $ACTIVE_BUCKETS[$NAME.ToLower()] = $true + } + } + } else { + # Comma-separated string + foreach ($NAME in ($RAW -split ',')) { + $TRIMMED = $NAME.Trim() + if ($TRIMMED) { $ACTIVE_BUCKETS[$TRIMMED.ToLower()] = $true } + } + } +} +Write-Host "Active buckets in known-good list: $($ACTIVE_BUCKETS.Count)" + +# ============================================================ +# B2 API +# ============================================================ + +Write-Host "" +Write-Host "=== B2 Bucket Audit ===" +Write-Host "" + +# Authorize +Write-Host "Authenticating to Backblaze B2..." +try { + $B2_CREDS = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($env:B2_KEY_ID):$($env:B2_APP_KEY)")) + + $AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/v3/b2_authorize_account" ` + -Method GET ` + -Headers @{ Authorization = "Basic $B2_CREDS" } ` + -ErrorAction Stop + + $API_URL = $AUTH.apiInfo.storageApi.apiUrl + $AUTH_TOKEN = $AUTH.authorizationToken + $ACCOUNT_ID = $AUTH.accountId + + Write-Host " [OK] Account: $ACCOUNT_ID" +} catch { + Write-Error "B2 authentication failed: $_" + exit 1 +} + +# List buckets +Write-Host "Listing buckets..." +try { + $BUCKET_RESPONSE = Invoke-RestMethod -Uri "$API_URL/b2api/v3/b2_list_buckets" ` + -Method POST ` + -Headers @{ Authorization = $AUTH_TOKEN } ` + -ContentType "application/json" ` + -Body (@{ accountId = $ACCOUNT_ID } | ConvertTo-Json) ` + -ErrorAction Stop + + $BUCKETS = $BUCKET_RESPONSE.buckets + Write-Host " [OK] Found $($BUCKETS.Count) buckets." +} catch { + Write-Error "Failed to list B2 buckets: $_" + exit 1 +} + +# Build table rows +$SCAN_TIMESTAMP = Get-Date -Format "yyyy-MM-dd HH:mm:ss" +$TABLE_ROWS = "" +$ROW_INDEX = 0 +$UNUSED_COUNT = 0 +$USED_COUNT = 0 + +# Sort: unused first (so they're at the top), then alphabetical +$SORTED_BUCKETS = $BUCKETS | Sort-Object @{ + Expression = { $ACTIVE_BUCKETS.ContainsKey($_.bucketName.ToLower()) } +}, bucketName + +foreach ($BUCKET in $SORTED_BUCKETS) { + $BNAME = $BUCKET.bucketName + $BTYPE = $BUCKET.bucketType + $BNAME_LOWER = $BNAME.ToLower() + + if ($ACTIVE_BUCKETS.Count -gt 0) { + $IS_USED = $ACTIVE_BUCKETS.ContainsKey($BNAME_LOWER) + } else { + # No active list provided, mark all as unknown + $IS_USED = $null + } + + if ($IS_USED -eq $false) { + $STATUS = "Unused" + $ROW_BG = "#fef2f2" + $ROW_COLOR = "color:#991b1b;font-weight:600;" + $UNUSED_COUNT++ + } elseif ($IS_USED -eq $true) { + $STATUS = "In Use" + $ROW_BG = if ($ROW_INDEX % 2 -eq 0) { "#ffffff" } else { "#f9fafb" } + $ROW_COLOR = "" + $USED_COUNT++ + } else { + $STATUS = "Unknown" + $ROW_BG = if ($ROW_INDEX % 2 -eq 0) { "#ffffff" } else { "#f9fafb" } + $ROW_COLOR = "color:#6b7280;" + } + + $MARKER = if ($IS_USED -eq $false) { "[!]" } elseif ($IS_USED) { "[OK]" } else { "[?]" } + Write-Host " $MARKER $BNAME ($BTYPE) - $STATUS" + + $TABLE_ROWS += @" + + + + + +"@ + $ROW_INDEX++ +} + +# Summary +Write-Host "" +Write-Host "=== Summary ===" +Write-Host " Total buckets: $($BUCKETS.Count)" +if ($ACTIVE_BUCKETS.Count -gt 0) { + Write-Host " In use: $USED_COUNT" + Write-Host " Unused: $UNUSED_COUNT" +} + +# Build HTML +$HTML = @" +
+
Bucket NameRepository NameTypeUsed Space
$(ConvertTo-SafeHtml $BNAME)$(ConvertTo-SafeHtml $BTYPE)$(ConvertTo-SafeHtml $STATUS)
+ + + + + + + + +$TABLE_ROWS +
Bucket NameTypeStatus
+

Last scanned: $SCAN_TIMESTAMP | Total: $($BUCKETS.Count) | In Use: $USED_COUNT | Unused: $UNUSED_COUNT

+ +"@ + +# Write to NinjaOne +Set-NinjaField $env:CUSTOM_FIELD_B2_AUDIT $HTML + +# Dump HTML if no Ninja and no field set +if (-not (Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue) -and -not $env:CUSTOM_FIELD_B2_AUDIT) { + Write-Host "" + Write-Host "=== HTML Output ===" + Write-Host $HTML +} + +Write-Host "" +Write-Host "=== Script complete ===" From b75970c04d498d5945cd5f6b75bf564712d8cbd1 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 12:16:51 -0400 Subject: [PATCH 15/77] Fix B2 API: use v4 endpoint and ASCII encoding v3 endpoint returns 401. B2 API v4 is current as of April 2025. Also switch base64 encoding from UTF8 to ASCII per B2 docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- iaas-backblaze/b2-bucket-audit.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/iaas-backblaze/b2-bucket-audit.ps1 b/iaas-backblaze/b2-bucket-audit.ps1 index f5ef762..a736227 100644 --- a/iaas-backblaze/b2-bucket-audit.ps1 +++ b/iaas-backblaze/b2-bucket-audit.ps1 @@ -104,9 +104,9 @@ Write-Host "" # Authorize Write-Host "Authenticating to Backblaze B2..." try { - $B2_CREDS = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($env:B2_KEY_ID):$($env:B2_APP_KEY)")) + $B2_CREDS = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("$($env:B2_KEY_ID):$($env:B2_APP_KEY)")) - $AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/v3/b2_authorize_account" ` + $AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/v4/b2_authorize_account" ` -Method GET ` -Headers @{ Authorization = "Basic $B2_CREDS" } ` -ErrorAction Stop @@ -124,7 +124,7 @@ try { # List buckets Write-Host "Listing buckets..." try { - $BUCKET_RESPONSE = Invoke-RestMethod -Uri "$API_URL/b2api/v3/b2_list_buckets" ` + $BUCKET_RESPONSE = Invoke-RestMethod -Uri "$API_URL/b2api/v4/b2_list_buckets" ` -Method POST ` -Headers @{ Authorization = $AUTH_TOKEN } ` -ContentType "application/json" ` From 990e025e26f23ebaeb160929b1e7647688751160 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 12:30:14 -0400 Subject: [PATCH 16/77] Add bucket sizes from B2 daily usage reports, fix auth fallback B2 has no real-time bucket size API. Instead, pulls the daily audit CSV from the b2-reports-{accountId} bucket (auto-generated by Backblaze) and extracts storageByteCount per bucket. Auth now tries v2, v4, v3 in order since v2 is most reliable. Also handles both v2 (apiUrl) and v4 (apiInfo.storageApi.apiUrl) response shapes. Table now shows: Bucket Name, Type, Size, Status. Reports bucket is excluded from the output table. Co-Authored-By: Claude Opus 4.6 (1M context) --- iaas-backblaze/b2-bucket-audit.ps1 | 220 +++++++++++++++++++++++------ 1 file changed, 175 insertions(+), 45 deletions(-) diff --git a/iaas-backblaze/b2-bucket-audit.ps1 b/iaas-backblaze/b2-bucket-audit.ps1 index a736227..040be0a 100644 --- a/iaas-backblaze/b2-bucket-audit.ps1 +++ b/iaas-backblaze/b2-bucket-audit.ps1 @@ -1,6 +1,8 @@ -## B2 Bucket Audit - List all Backblaze B2 buckets and flag unused ones -## Run centrally (not per-device). Compares B2 account buckets against -## a known-good list of active bucket names. Unused buckets show in red. +## B2 Bucket Audit - List all Backblaze B2 buckets with storage sizes +## and flag unused ones. Run centrally (not per-device). +## +## Bucket sizes come from Backblaze's daily usage reports stored in +## the b2-reports-{accountId} bucket (generated automatically by B2). ## ## $env:B2_KEY_ID - Backblaze B2 application key ID ## $env:B2_APP_KEY - Backblaze B2 application key @@ -20,15 +22,6 @@ function ConvertTo-SafeHtml { return $Text.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace('"', """) } -function Format-SizeBytes { - param([long]$Bytes) - if ($Bytes -ge 1TB) { return "{0:N2} TB" -f ($Bytes / 1TB) } - if ($Bytes -ge 1GB) { return "{0:N2} GB" -f ($Bytes / 1GB) } - if ($Bytes -ge 1MB) { return "{0:N2} MB" -f ($Bytes / 1MB) } - if ($Bytes -ge 1KB) { return "{0:N2} KB" -f ($Bytes / 1KB) } - return "$Bytes B" -} - function Set-NinjaField { param([string]$FieldName, [string]$Value) if (-not $FieldName) { return } @@ -73,18 +66,15 @@ if (-not $env:B2_KEY_ID -or -not $env:B2_APP_KEY) { $ACTIVE_BUCKETS = @{} if ($env:ACTIVE_BUCKETS_CSV) { $RAW = $env:ACTIVE_BUCKETS_CSV.Trim() - # Check if it's a file path if (Test-Path $RAW -ErrorAction SilentlyContinue) { $LINES = Get-Content $RAW | Where-Object { $_.Trim() -ne "" } foreach ($L in $LINES) { - # Handle CSV with headers or plain list $NAME = ($L -split ',')[0].Trim().Trim('"') if ($NAME -and $NAME -ne "BucketName" -and $NAME -ne "bucket_name") { $ACTIVE_BUCKETS[$NAME.ToLower()] = $true } } } else { - # Comma-separated string foreach ($NAME in ($RAW -split ',')) { $TRIMMED = $NAME.Trim() if ($TRIMMED) { $ACTIVE_BUCKETS[$TRIMMED.ToLower()] = $true } @@ -94,37 +84,57 @@ if ($env:ACTIVE_BUCKETS_CSV) { Write-Host "Active buckets in known-good list: $($ACTIVE_BUCKETS.Count)" # ============================================================ -# B2 API +# B2 API - Try v2 first (most reliable), fall back to v4 # ============================================================ Write-Host "" Write-Host "=== B2 Bucket Audit ===" Write-Host "" -# Authorize Write-Host "Authenticating to Backblaze B2..." -try { - $B2_CREDS = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("$($env:B2_KEY_ID):$($env:B2_APP_KEY)")) - - $AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/v4/b2_authorize_account" ` - -Method GET ` - -Headers @{ Authorization = "Basic $B2_CREDS" } ` - -ErrorAction Stop +$B2_CREDS = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("$($env:B2_KEY_ID):$($env:B2_APP_KEY)")) - $API_URL = $AUTH.apiInfo.storageApi.apiUrl - $AUTH_TOKEN = $AUTH.authorizationToken - $ACCOUNT_ID = $AUTH.accountId +$AUTH = $null +foreach ($API_VER in @("v2", "v4", "v3")) { + try { + $AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/$API_VER/b2_authorize_account" ` + -Method GET ` + -Headers @{ Authorization = "Basic $B2_CREDS" } ` + -ErrorAction Stop + Write-Host " [OK] Authenticated via $API_VER" + break + } catch { + Write-Host " [$API_VER] Failed: $($_.Exception.Message)" + } +} - Write-Host " [OK] Account: $ACCOUNT_ID" -} catch { - Write-Error "B2 authentication failed: $_" +if (-not $AUTH) { + Write-Error "B2 authentication failed on all API versions. Check B2_KEY_ID and B2_APP_KEY." exit 1 } -# List buckets +$API_URL = $AUTH.apiUrl +if (-not $API_URL) { $API_URL = $AUTH.apiInfo.storageApi.apiUrl } +$AUTH_TOKEN = $AUTH.authorizationToken +$ACCOUNT_ID = $AUTH.accountId +$DOWNLOAD_URL = $AUTH.downloadUrl +if (-not $DOWNLOAD_URL) { $DOWNLOAD_URL = $AUTH.apiInfo.storageApi.downloadUrl } + +# Determine which API version worked for subsequent calls +$B2_VER = "v2" +if ($API_URL -match '/b2api/(v\d+)') { $B2_VER = $Matches[1] } + +Write-Host " Account: $ACCOUNT_ID" +Write-Host " API URL: $API_URL" +Write-Host " Download URL: $DOWNLOAD_URL" + +# ============================================================ +# List all buckets +# ============================================================ +Write-Host "" Write-Host "Listing buckets..." try { - $BUCKET_RESPONSE = Invoke-RestMethod -Uri "$API_URL/b2api/v4/b2_list_buckets" ` + $BUCKET_RESPONSE = Invoke-RestMethod -Uri "$API_URL/b2api/$B2_VER/b2_list_buckets" ` -Method POST ` -Headers @{ Authorization = $AUTH_TOKEN } ` -ContentType "application/json" ` @@ -138,27 +148,140 @@ try { exit 1 } -# Build table rows +# Build bucket ID -> name lookup +$BUCKET_ID_TO_NAME = @{} +foreach ($B in $BUCKETS) { + $BUCKET_ID_TO_NAME[$B.bucketId] = $B.bucketName +} + +# ============================================================ +# Pull daily usage report for bucket sizes +# ============================================================ +Write-Host "" +Write-Host "Fetching daily usage report from b2-reports-$ACCOUNT_ID..." +$BUCKET_SIZES = @{} # bucketId -> stored_gb + +$REPORTS_BUCKET_NAME = "b2-reports-$ACCOUNT_ID" +# Find the reports bucket ID +$REPORTS_BUCKET_ID = $null +foreach ($B in $BUCKETS) { + if ($B.bucketName -eq $REPORTS_BUCKET_NAME) { + $REPORTS_BUCKET_ID = $B.bucketId + break + } +} + +if ($REPORTS_BUCKET_ID) { + try { + # List recent files in the reports bucket to find the latest daily report + $FILES_RESPONSE = Invoke-RestMethod -Uri "$API_URL/b2api/$B2_VER/b2_list_file_names" ` + -Method POST ` + -Headers @{ Authorization = $AUTH_TOKEN } ` + -ContentType "application/json" ` + -Body (@{ + bucketId = $REPORTS_BUCKET_ID + maxFileCount = 100 + } | ConvertTo-Json) ` + -ErrorAction Stop + + # Find the most recent audit CSV + $REPORT_FILE = $FILES_RESPONSE.files | + Where-Object { $_.fileName -match "audit" -and $_.fileName -match "\.csv$" } | + Sort-Object fileName -Descending | + Select-Object -First 1 + + if ($REPORT_FILE) { + Write-Host " Found report: $($REPORT_FILE.fileName)" + + # Download the CSV + $CSV_URL = "$DOWNLOAD_URL/file/$REPORTS_BUCKET_NAME/$($REPORT_FILE.fileName)" + $CSV_CONTENT = Invoke-RestMethod -Uri $CSV_URL ` + -Method GET ` + -Headers @{ Authorization = $AUTH_TOKEN } ` + -ErrorAction Stop + + # Parse CSV - columns vary but we need bucketId and stored bytes/GB + $CSV_LINES = $CSV_CONTENT -split "`n" | Where-Object { $_.Trim() -ne "" } + if ($CSV_LINES.Count -gt 1) { + $HEADERS = ($CSV_LINES[0] -split ',') | ForEach-Object { $_.Trim().Trim('"') } + + # Find column indices + $IDX_BUCKET_ID = [array]::IndexOf($HEADERS, "bucketId") + if ($IDX_BUCKET_ID -eq -1) { $IDX_BUCKET_ID = [array]::IndexOf($HEADERS, "bucket_id") } + $IDX_STORED = -1 + foreach ($COL in @("storageByteCount", "stored_bytes", "storedBytes", "bytesStored")) { + $IDX_STORED = [array]::IndexOf($HEADERS, $COL) + if ($IDX_STORED -ne -1) { break } + } + + if ($IDX_BUCKET_ID -ne -1 -and $IDX_STORED -ne -1) { + Write-Host " Parsing report (bucketId col=$IDX_BUCKET_ID, bytes col=$IDX_STORED)..." + for ($I = 1; $I -lt $CSV_LINES.Count; $I++) { + $FIELDS = ($CSV_LINES[$I] -split ',') | ForEach-Object { $_.Trim().Trim('"') } + if ($FIELDS.Count -gt [Math]::Max($IDX_BUCKET_ID, $IDX_STORED)) { + $BID = $FIELDS[$IDX_BUCKET_ID] + $BYTES = [long]0 + try { $BYTES = [long]$FIELDS[$IDX_STORED] } catch {} + if ($BID) { $BUCKET_SIZES[$BID] = $BYTES } + } + } + Write-Host " [OK] Parsed sizes for $($BUCKET_SIZES.Count) buckets." + } else { + Write-Warning " Could not find expected columns in report. Headers: $($HEADERS -join ', ')" + Write-Host " Dumping first 3 lines for debugging:" + for ($I = 0; $I -lt [Math]::Min(3, $CSV_LINES.Count); $I++) { + Write-Host " $($CSV_LINES[$I])" + } + } + } + } else { + Write-Warning " No audit CSV found in reports bucket." + Write-Host " Files found:" + foreach ($F in $FILES_RESPONSE.files | Select-Object -First 10) { + Write-Host " $($F.fileName)" + } + } + } catch { + Write-Warning " Failed to fetch usage report: $_" + } +} else { + Write-Warning " Reports bucket ($REPORTS_BUCKET_NAME) not found. Size data unavailable." +} + +# ============================================================ +# Build output table +# ============================================================ $SCAN_TIMESTAMP = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $TABLE_ROWS = "" $ROW_INDEX = 0 $UNUSED_COUNT = 0 $USED_COUNT = 0 -# Sort: unused first (so they're at the top), then alphabetical -$SORTED_BUCKETS = $BUCKETS | Sort-Object @{ +# Sort: unused first, then alphabetical +$SORTED_BUCKETS = $BUCKETS | Where-Object { $_.bucketName -ne $REPORTS_BUCKET_NAME } | Sort-Object @{ Expression = { $ACTIVE_BUCKETS.ContainsKey($_.bucketName.ToLower()) } }, bucketName foreach ($BUCKET in $SORTED_BUCKETS) { $BNAME = $BUCKET.bucketName $BTYPE = $BUCKET.bucketType - $BNAME_LOWER = $BNAME.ToLower() + $BID = $BUCKET.bucketId + # Size from daily report + $SIZE_DISPLAY = "N/A" + if ($BUCKET_SIZES.ContainsKey($BID)) { + $BYTES = $BUCKET_SIZES[$BID] + if ($BYTES -ge 1TB) { $SIZE_DISPLAY = "{0:N2} TB" -f ($BYTES / 1TB) } + elseif ($BYTES -ge 1GB) { $SIZE_DISPLAY = "{0:N2} GB" -f ($BYTES / 1GB) } + elseif ($BYTES -ge 1MB) { $SIZE_DISPLAY = "{0:N2} MB" -f ($BYTES / 1MB) } + elseif ($BYTES -gt 0) { $SIZE_DISPLAY = "{0:N2} KB" -f ($BYTES / 1KB) } + else { $SIZE_DISPLAY = "0 B" } + } + + # Status if ($ACTIVE_BUCKETS.Count -gt 0) { - $IS_USED = $ACTIVE_BUCKETS.ContainsKey($BNAME_LOWER) + $IS_USED = $ACTIVE_BUCKETS.ContainsKey($BNAME.ToLower()) } else { - # No active list provided, mark all as unknown $IS_USED = $null } @@ -173,18 +296,19 @@ foreach ($BUCKET in $SORTED_BUCKETS) { $ROW_COLOR = "" $USED_COUNT++ } else { - $STATUS = "Unknown" + $STATUS = "" $ROW_BG = if ($ROW_INDEX % 2 -eq 0) { "#ffffff" } else { "#f9fafb" } - $ROW_COLOR = "color:#6b7280;" + $ROW_COLOR = "" } - $MARKER = if ($IS_USED -eq $false) { "[!]" } elseif ($IS_USED) { "[OK]" } else { "[?]" } - Write-Host " $MARKER $BNAME ($BTYPE) - $STATUS" + $MARKER = if ($IS_USED -eq $false) { "[!]" } elseif ($IS_USED) { "[OK]" } else { " " } + Write-Host " $MARKER $BNAME | $BTYPE | $SIZE_DISPLAY | $STATUS" $TABLE_ROWS += @" $(ConvertTo-SafeHtml $BNAME) $(ConvertTo-SafeHtml $BTYPE) + $(ConvertTo-SafeHtml $SIZE_DISPLAY) $(ConvertTo-SafeHtml $STATUS) "@ @@ -194,13 +318,18 @@ foreach ($BUCKET in $SORTED_BUCKETS) { # Summary Write-Host "" Write-Host "=== Summary ===" -Write-Host " Total buckets: $($BUCKETS.Count)" +Write-Host " Total buckets: $($SORTED_BUCKETS.Count)" if ($ACTIVE_BUCKETS.Count -gt 0) { Write-Host " In use: $USED_COUNT" Write-Host " Unused: $UNUSED_COUNT" } # Build HTML +$SUMMARY_LINE = "Last scanned: $SCAN_TIMESTAMP | Total: $($SORTED_BUCKETS.Count)" +if ($ACTIVE_BUCKETS.Count -gt 0) { + $SUMMARY_LINE += " | In Use: $USED_COUNT | Unused: $UNUSED_COUNT" +} + $HTML = @"
@@ -208,20 +337,21 @@ $HTML = @" + $TABLE_ROWS
Bucket Name TypeSize Status
-

Last scanned: $SCAN_TIMESTAMP | Total: $($BUCKETS.Count) | In Use: $USED_COUNT | Unused: $UNUSED_COUNT

+

$SUMMARY_LINE

"@ # Write to NinjaOne Set-NinjaField $env:CUSTOM_FIELD_B2_AUDIT $HTML -# Dump HTML if no Ninja and no field set +# Dump if no Ninja if (-not (Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue) -and -not $env:CUSTOM_FIELD_B2_AUDIT) { Write-Host "" Write-Host "=== HTML Output ===" From 4e1f462ff0cc151d5fe72da77afc9fd2da675c44 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 13:35:48 -0400 Subject: [PATCH 17/77] Add veeam-create-s3-repo.ps1 with scoped B2 key generation Full automated flow: 1. Auth to B2 with admin/master key (org-level in NinjaRMM) 2. Create bucket: {org_guid_nodashes}-{time_based_id}-veeam 3. Enable object lock with governance retention 4. Create a scoped B2 application key restricted to that bucket only (read/write/delete + immutability capabilities) 5. Register Veeam S3 repository using the SCOPED key (not admin) 6. Store bucket name + scoped key ID + scoped app key in device- level NinjaRMM fields Each BDR server only has credentials for its own bucket. Admin key never stored on endpoints. Keys are rotatable by re-running or via a future rotation script. Replaces the old veeam-add-backup-repo.ps1 for S3 use cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 415 +++++++++++++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 bdr-veeam/veeam-create-s3-repo.ps1 diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 new file mode 100644 index 0000000..dc1f9be --- /dev/null +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -0,0 +1,415 @@ +## Creates a Backblaze B2 bucket, generates a scoped application key with +## read/write access to only that bucket, registers the Veeam S3 repository +## using the scoped key, and stores credentials in NinjaRMM device fields. +## +## Bucket naming: {ORG_GUID_no_dashes}-{time_based_short_id}-veeam +## Veeam repository name = bucket name +## +## ADMIN CREDENTIALS (org-level in NinjaRMM, used to create bucket + scoped key): +## $env:B2_ADMIN_KEY_ID - Master/admin B2 application key ID +## $env:B2_ADMIN_APP_KEY - Master/admin B2 application key +## +## CONFIGURATION: +## $env:ORG_GUID - Organization GUID from NinjaRMM (falls back to "ORG" if empty) +## $env:B2_ENDPOINT - S3 endpoint (e.g. https://s3.us-west-002.backblazeb2.com) +## $env:B2_REGION - S3 region ID (e.g. us-west-002) +## $env:IMMUTABILITY_DAYS - Object lock immutability period in days (default: 30) +## +## OUTPUT FIELDS (device-level, written after creation): +## $env:CUSTOM_FIELD_S3_BUCKET_NAME - Text: bucket name +## $env:CUSTOM_FIELD_S3_KEY_ID - Text: scoped B2 key ID (bucket-only access) +## $env:CUSTOM_FIELD_S3_APP_KEY - Text: scoped B2 app key (bucket-only access) +## +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform +## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) + +# ============================================================ +# PS7 BOOTSTRAP +# ============================================================ +if ($PSVersionTable.PSVersion.Major -lt 7) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } + if (-not $PWSH_PATH) { + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + } + if (Test-Path $PWSH_PATH) { + Write-Host "PowerShell $($PSVersionTable.PSVersion) detected. Re-launching in PowerShell 7 at: $PWSH_PATH" + $PS_ARGS = @('-NonInteractive', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $MyInvocation.MyCommand.Path) + & $PWSH_PATH @PS_ARGS + exit $LASTEXITCODE + } else { + Write-Warning "PowerShell 7 (pwsh.exe) not found. Veeam module may fail to load." + } +} + +# ============================================================ +# HELPER FUNCTIONS +# ============================================================ + +function New-TimeBasedShortId { + # Generates a short time-based ID from a UUIDv7-style timestamp. + # Uses milliseconds since Unix epoch encoded in base36 for compactness. + # 8 chars of base36 = ~2.8 trillion values, unique to the millisecond. + $MS = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) + $CHARS = "0123456789abcdefghijklmnopqrstuvwxyz" + $RESULT = "" + while ($MS -gt 0) { + $RESULT = $CHARS[$MS % 36] + $RESULT + $MS = [Math]::Floor($MS / 36) + } + # Pad to 8 chars or truncate + return $RESULT.PadLeft(8, '0').Substring(0, 8) +} + +function Set-NinjaField { + param([string]$FieldName, [string]$Value) + if (-not $FieldName) { return } + $NINJA_CMD = Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue + if ($NINJA_CMD) { + try { + Ninja-Property-Set $FieldName $Value + Write-Host " [OK] $FieldName = $Value" + } catch { + Write-Warning " Failed to write $FieldName : $_" + } + } else { + Write-Host " [SKIP] Ninja-Property-Set not available. $FieldName = $Value" + } +} + +# ============================================================ +# INPUT HANDLING +# ============================================================ + +$SCRIPT_LOG_NAME = "veeam-create-s3-repo.log" + +if ($env:RMM -ne "1") { + # Interactive mode + $VALID_INPUT = 0 + while ($VALID_INPUT -ne 1) { + $env:DESCRIPTION = Read-Host "Ticket # or initials for audit trail" + if ($env:DESCRIPTION) { $VALID_INPUT = 1 } else { Write-Host "Required." } + } + if (-not $env:ORG_GUID) { $env:ORG_GUID = Read-Host "Organization GUID (blank for 'ORG' fallback)" } + if (-not $env:B2_ADMIN_KEY_ID) { $env:B2_ADMIN_KEY_ID = Read-Host "B2 admin key ID (master key)" } + if (-not $env:B2_ADMIN_APP_KEY) { $env:B2_ADMIN_APP_KEY = Read-Host "B2 admin app key (master key)" } + if (-not $env:B2_ENDPOINT) { $env:B2_ENDPOINT = Read-Host "B2 S3 endpoint (e.g. https://s3.us-west-002.backblazeb2.com)" } + if (-not $env:B2_REGION) { $env:B2_REGION = Read-Host "B2 region (e.g. us-west-002)" } + if (-not $env:IMMUTABILITY_DAYS) { $env:IMMUTABILITY_DAYS = Read-Host "Immutability period in days (default 30)" } + + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" +} else { + if ($env:RMM_SCRIPT_PATH) { + $LOG_DIR = "$env:RMM_SCRIPT_PATH\logs" + if (-not (Test-Path $LOG_DIR)) { New-Item -ItemType Directory -Path $LOG_DIR -Force | Out-Null } + $LOG_PATH = "$LOG_DIR\$SCRIPT_LOG_NAME" + } else { + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" + } + if (-not $env:DESCRIPTION) { $env:DESCRIPTION = "No Description" } +} + +# Validate required inputs +if (-not $env:B2_ADMIN_KEY_ID -or -not $env:B2_ADMIN_APP_KEY) { + Write-Error "B2_ADMIN_KEY_ID and B2_ADMIN_APP_KEY are required." + exit 1 +} +if (-not $env:B2_ENDPOINT) { + Write-Error "B2_ENDPOINT is required." + exit 1 +} +if (-not $env:B2_REGION) { + Write-Error "B2_REGION is required." + exit 1 +} + +# Defaults +$IMMUTABILITY_DAYS = 30 +if ($env:IMMUTABILITY_DAYS) { + try { $IMMUTABILITY_DAYS = [int]$env:IMMUTABILITY_DAYS } catch {} +} + +# ============================================================ +# GENERATE BUCKET NAME +# ============================================================ + +Start-Transcript -Path $LOG_PATH + +Write-Host "=== Veeam S3 Repository Creation ===" +Write-Host "Description: $env:DESCRIPTION" +Write-Host "" + +# Org GUID: strip dashes, lowercase. Fall back to "ORG" if empty. +$ORG_PREFIX = "ORG" +if ($env:ORG_GUID) { + $ORG_PREFIX = $env:ORG_GUID.Replace("-", "").ToLower() +} + +# Generate time-based short ID for this bucket +$BUCKET_SHORT_ID = New-TimeBasedShortId +$BUCKET_NAME = "$ORG_PREFIX-$BUCKET_SHORT_ID-veeam" + +# S3 bucket name validation: lowercase alphanumeric + hyphens, 3-50 chars +$BUCKET_NAME = $BUCKET_NAME -replace '[^a-z0-9\-]', '' +if ($BUCKET_NAME.Length -gt 50) { + # Truncate org prefix to fit + $MAX_ORG = 50 - 1 - 8 - 1 - 5 # dash + shortid + dash + "veeam" + $ORG_PREFIX = $ORG_PREFIX.Substring(0, $MAX_ORG) + $BUCKET_NAME = "$ORG_PREFIX-$BUCKET_SHORT_ID-veeam" +} + +Write-Host "Bucket name: $BUCKET_NAME" +Write-Host "Org GUID: $($env:ORG_GUID)" +Write-Host "Org prefix: $ORG_PREFIX" +Write-Host "Bucket short ID: $BUCKET_SHORT_ID" +Write-Host "Endpoint: $env:B2_ENDPOINT" +Write-Host "Region: $env:B2_REGION" +Write-Host "Immutability: $IMMUTABILITY_DAYS days" +Write-Host "" + +# ============================================================ +# LOAD VEEAM MODULE +# ============================================================ + +Write-Host "Loading Veeam PowerShell module..." +$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" +$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" + +if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { + try { + $VBR_MODULES | Import-Module -WarningAction SilentlyContinue + Write-Host " [OK] Veeam module loaded." + } catch { + Stop-Transcript + throw "Failed to load Veeam modules: $_" + } +} else { + Stop-Transcript + throw "Veeam.Backup.PowerShell module not found." +} + +# ============================================================ +# B2 API: AUTH WITH ADMIN KEY +# ============================================================ + +Write-Host "" +Write-Host "Authenticating to B2 with admin key..." + +$B2_CREDS = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("$($env:B2_ADMIN_KEY_ID):$($env:B2_ADMIN_APP_KEY)")) + +$B2_AUTH = $null +$B2_API_VER = "v2" +foreach ($VER in @("v2", "v4")) { + try { + $B2_AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/$VER/b2_authorize_account" ` + -Method GET ` + -Headers @{ Authorization = "Basic $B2_CREDS" } ` + -ErrorAction Stop + $B2_API_VER = $VER + break + } catch { } +} +if (-not $B2_AUTH) { + Write-Error "B2 authentication failed. Check B2_ADMIN_KEY_ID and B2_ADMIN_APP_KEY." + Stop-Transcript + exit 1 +} + +$B2_API_URL = $B2_AUTH.apiUrl +if (-not $B2_API_URL) { $B2_API_URL = $B2_AUTH.apiInfo.storageApi.apiUrl } +$B2_AUTH_TOKEN = $B2_AUTH.authorizationToken +$B2_ACCOUNT_ID = $B2_AUTH.accountId + +Write-Host " [OK] Account: $B2_ACCOUNT_ID (api: $B2_API_VER)" + +# ============================================================ +# CREATE B2 BUCKET +# ============================================================ + +Write-Host "" +Write-Host "Creating B2 bucket: $BUCKET_NAME" + +try { + $CREATE_BODY = @{ + accountId = $B2_ACCOUNT_ID + bucketName = $BUCKET_NAME + bucketType = "allPrivate" + fileLockEnabled = $true + } | ConvertTo-Json + + $B2_BUCKET = Invoke-RestMethod -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_create_bucket" ` + -Method POST ` + -Headers @{ Authorization = $B2_AUTH_TOKEN } ` + -ContentType "application/json" ` + -Body $CREATE_BODY ` + -ErrorAction Stop + + Write-Host " [OK] Bucket created: $($B2_BUCKET.bucketName) (ID: $($B2_BUCKET.bucketId))" +} catch { + Write-Error "B2 bucket creation failed: $_" + Stop-Transcript + exit 1 +} + +# Set default retention (object lock) +try { + $RETENTION_BODY = @{ + bucketId = $B2_BUCKET.bucketId + defaultRetention = @{ + mode = "governance" + period = @{ + duration = $IMMUTABILITY_DAYS + unit = "days" + } + } + } | ConvertTo-Json -Depth 5 + + Invoke-RestMethod -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_update_bucket" ` + -Method POST ` + -Headers @{ Authorization = $B2_AUTH_TOKEN } ` + -ContentType "application/json" ` + -Body $RETENTION_BODY ` + -ErrorAction Stop | Out-Null + + Write-Host " [OK] Default retention: $IMMUTABILITY_DAYS days (governance mode)" +} catch { + Write-Warning " Failed to set default retention: $_" +} + +# ============================================================ +# CREATE SCOPED APPLICATION KEY (bucket-only access) +# ============================================================ + +Write-Host "" +Write-Host "Creating scoped application key for bucket: $BUCKET_NAME" + +$SCOPED_KEY_ID = $null +$SCOPED_APP_KEY = $null + +try { + $KEY_BODY = @{ + accountId = $B2_ACCOUNT_ID + capabilities = @( + "listBuckets" + "readBuckets" + "listFiles" + "readFiles" + "writeFiles" + "deleteFiles" + "readBucketEncryption" + "writeBucketEncryption" + "readBucketRetentions" + "writeBucketRetentions" + "readFileRetentions" + "writeFileRetentions" + "readFileLegalHolds" + "writeFileLegalHolds" + "bypassGovernance" + ) + keyName = $BUCKET_NAME + bucketId = $B2_BUCKET.bucketId + } | ConvertTo-Json + + $KEY_RESPONSE = Invoke-RestMethod -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_create_key" ` + -Method POST ` + -Headers @{ Authorization = $B2_AUTH_TOKEN } ` + -ContentType "application/json" ` + -Body $KEY_BODY ` + -ErrorAction Stop + + $SCOPED_KEY_ID = $KEY_RESPONSE.applicationKeyId + $SCOPED_APP_KEY = $KEY_RESPONSE.applicationKey + + Write-Host " [OK] Scoped key created: $SCOPED_KEY_ID" + Write-Host " Capabilities: bucket-scoped read/write/delete + immutability" + Write-Host " Bucket restriction: $($B2_BUCKET.bucketName) ($($B2_BUCKET.bucketId))" +} catch { + Write-Error "Failed to create scoped application key: $_" + Write-Host " Bucket ($BUCKET_NAME) was created but no scoped key exists." + Write-Host " Create a scoped key manually in the B2 console." + Stop-Transcript + exit 1 +} + +# ============================================================ +# CREATE VEEAM S3 REPOSITORY +# ============================================================ + +Write-Host "" +Write-Host "Creating Veeam S3 repository: $BUCKET_NAME" + +try { + # Add the SCOPED B2 credentials to Veeam (not the admin key) + $VEEAM_ACCOUNT = Add-VBRAmazonAccount ` + -AccessKey $SCOPED_KEY_ID ` + -SecretKey $SCOPED_APP_KEY ` + -Description "$env:DESCRIPTION $BUCKET_NAME" + + Write-Host " [OK] Veeam account added." + + # Connect to the S3-compatible service + $VEEAM_CONNECTION = Connect-VBRAmazonS3CompatibleService ` + -Account $VEEAM_ACCOUNT ` + -CustomRegionId $env:B2_REGION ` + -ServicePoint $env:B2_ENDPOINT + + Write-Host " [OK] Connected to S3 endpoint." + + # Get the bucket + $VEEAM_BUCKET = Get-VBRAmazonS3Bucket -Connection $VEEAM_CONNECTION -Name $BUCKET_NAME + Write-Host " [OK] Bucket found in Veeam." + + # Create a folder inside the bucket (use the short ID as folder name) + $VEEAM_FOLDER = New-VBRAmazonS3Folder -Name $BUCKET_SHORT_ID -Connection $VEEAM_CONNECTION -Bucket $VEEAM_BUCKET + Write-Host " [OK] Folder created: $BUCKET_SHORT_ID" + + # Detect Veeam version for the EnableBucketAutoProvision parameter + $VEEAM_VERSION = [version](Get-Item 'C:\Program Files\Veeam\Backup and Replication\Backup\Packages\VeeamDeploymentDll.dll').VersionInfo.ProductVersion + $NEEDS_AUTOPROVISION = $VEEAM_VERSION -ge [version]"12.3.1.1139" + + # Create the repository (name = bucket name) + $REPO_PARAMS = @{ + AmazonS3Folder = $VEEAM_FOLDER + Connection = $VEEAM_CONNECTION + Name = $BUCKET_NAME + EnableBackupImmutability = $true + ImmutabilityPeriod = $IMMUTABILITY_DAYS + Description = "$env:DESCRIPTION $BUCKET_NAME" + } + if ($NEEDS_AUTOPROVISION) { + $REPO_PARAMS['EnableBucketAutoProvision'] = $false + } + + $VEEAM_REPO = Add-VBRAmazonS3CompatibleRepository @REPO_PARAMS + + Write-Host " [OK] Veeam repository created: $($VEEAM_REPO.Name)" +} catch { + Write-Error "Veeam repository creation failed: $_" + Write-Host " The B2 bucket ($BUCKET_NAME) was created but the Veeam repo was not." + Write-Host " You may need to add it manually in the Veeam console." + Stop-Transcript + exit 1 +} + +# ============================================================ +# STORE RESULT +# ============================================================ + +Write-Host "" +Write-Host "=== Complete ===" +Write-Host " Bucket: $BUCKET_NAME" +Write-Host " Repository: $BUCKET_NAME" +Write-Host " Folder: $BUCKET_SHORT_ID" +Write-Host " Scoped key: $SCOPED_KEY_ID" +Write-Host " Immutability: $IMMUTABILITY_DAYS days" +Write-Host "" + +Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_NAME $BUCKET_NAME +Set-NinjaField $env:CUSTOM_FIELD_S3_KEY_ID $SCOPED_KEY_ID +Set-NinjaField $env:CUSTOM_FIELD_S3_APP_KEY $SCOPED_APP_KEY + +Write-Host "" +Write-Host "=== Script complete ===" + +Stop-Transcript From 384a440c19f958b0e4682295c0876cd2a8a9beba Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 13:38:12 -0400 Subject: [PATCH 18/77] Require ORG_UUID (fail if empty), rename GUID -> UUID throughout Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index dc1f9be..9e67457 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -2,7 +2,7 @@ ## read/write access to only that bucket, registers the Veeam S3 repository ## using the scoped key, and stores credentials in NinjaRMM device fields. ## -## Bucket naming: {ORG_GUID_no_dashes}-{time_based_short_id}-veeam +## Bucket naming: {ORG_UUID_no_dashes}-{time_based_short_id}-veeam ## Veeam repository name = bucket name ## ## ADMIN CREDENTIALS (org-level in NinjaRMM, used to create bucket + scoped key): @@ -10,7 +10,7 @@ ## $env:B2_ADMIN_APP_KEY - Master/admin B2 application key ## ## CONFIGURATION: -## $env:ORG_GUID - Organization GUID from NinjaRMM (falls back to "ORG" if empty) +## $env:ORG_UUID - Organization UUID from NinjaRMM (REQUIRED, fails if empty) ## $env:B2_ENDPOINT - S3 endpoint (e.g. https://s3.us-west-002.backblazeb2.com) ## $env:B2_REGION - S3 region ID (e.g. us-west-002) ## $env:IMMUTABILITY_DAYS - Object lock immutability period in days (default: 30) @@ -91,7 +91,7 @@ if ($env:RMM -ne "1") { $env:DESCRIPTION = Read-Host "Ticket # or initials for audit trail" if ($env:DESCRIPTION) { $VALID_INPUT = 1 } else { Write-Host "Required." } } - if (-not $env:ORG_GUID) { $env:ORG_GUID = Read-Host "Organization GUID (blank for 'ORG' fallback)" } + if (-not $env:ORG_UUID) { $env:ORG_UUID = Read-Host "Organization UUID (REQUIRED)" } if (-not $env:B2_ADMIN_KEY_ID) { $env:B2_ADMIN_KEY_ID = Read-Host "B2 admin key ID (master key)" } if (-not $env:B2_ADMIN_APP_KEY) { $env:B2_ADMIN_APP_KEY = Read-Host "B2 admin app key (master key)" } if (-not $env:B2_ENDPOINT) { $env:B2_ENDPOINT = Read-Host "B2 S3 endpoint (e.g. https://s3.us-west-002.backblazeb2.com)" } @@ -140,11 +140,13 @@ Write-Host "=== Veeam S3 Repository Creation ===" Write-Host "Description: $env:DESCRIPTION" Write-Host "" -# Org GUID: strip dashes, lowercase. Fall back to "ORG" if empty. -$ORG_PREFIX = "ORG" -if ($env:ORG_GUID) { - $ORG_PREFIX = $env:ORG_GUID.Replace("-", "").ToLower() +# Org UUID is required. Every bucket must be traceable to an org. +if (-not $env:ORG_UUID) { + Write-Error "ORG_UUID is required. Set the organization UUID in NinjaRMM before running." + Stop-Transcript + exit 1 } +$ORG_PREFIX = $env:ORG_UUID.Replace("-", "").ToLower() # Generate time-based short ID for this bucket $BUCKET_SHORT_ID = New-TimeBasedShortId @@ -160,7 +162,7 @@ if ($BUCKET_NAME.Length -gt 50) { } Write-Host "Bucket name: $BUCKET_NAME" -Write-Host "Org GUID: $($env:ORG_GUID)" +Write-Host "Org UUID: $($env:ORG_UUID)" Write-Host "Org prefix: $ORG_PREFIX" Write-Host "Bucket short ID: $BUCKET_SHORT_ID" Write-Host "Endpoint: $env:B2_ENDPOINT" From 493428b7095302c5e89b52e4bc1f20425aeee796 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 13:40:33 -0400 Subject: [PATCH 19/77] Default immutability to 14 days Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index 9e67457..b098cf4 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -13,7 +13,7 @@ ## $env:ORG_UUID - Organization UUID from NinjaRMM (REQUIRED, fails if empty) ## $env:B2_ENDPOINT - S3 endpoint (e.g. https://s3.us-west-002.backblazeb2.com) ## $env:B2_REGION - S3 region ID (e.g. us-west-002) -## $env:IMMUTABILITY_DAYS - Object lock immutability period in days (default: 30) +## $env:IMMUTABILITY_DAYS - Object lock immutability period in days (default: 14) ## ## OUTPUT FIELDS (device-level, written after creation): ## $env:CUSTOM_FIELD_S3_BUCKET_NAME - Text: bucket name @@ -96,7 +96,7 @@ if ($env:RMM -ne "1") { if (-not $env:B2_ADMIN_APP_KEY) { $env:B2_ADMIN_APP_KEY = Read-Host "B2 admin app key (master key)" } if (-not $env:B2_ENDPOINT) { $env:B2_ENDPOINT = Read-Host "B2 S3 endpoint (e.g. https://s3.us-west-002.backblazeb2.com)" } if (-not $env:B2_REGION) { $env:B2_REGION = Read-Host "B2 region (e.g. us-west-002)" } - if (-not $env:IMMUTABILITY_DAYS) { $env:IMMUTABILITY_DAYS = Read-Host "Immutability period in days (default 30)" } + if (-not $env:IMMUTABILITY_DAYS) { $env:IMMUTABILITY_DAYS = Read-Host "Immutability period in days (default 14)" } $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" } else { @@ -125,7 +125,7 @@ if (-not $env:B2_REGION) { } # Defaults -$IMMUTABILITY_DAYS = 30 +$IMMUTABILITY_DAYS = 14 if ($env:IMMUTABILITY_DAYS) { try { $IMMUTABILITY_DAYS = [int]$env:IMMUTABILITY_DAYS } catch {} } From 6153e11bd7abbfa562317d7f3ed8d9edcef22986 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 13:49:03 -0400 Subject: [PATCH 20/77] ORG_UUID reads from NinjaOne custom field (CUSTOM_FIELD_ORG_UUID) Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index b098cf4..77b24e8 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -10,7 +10,7 @@ ## $env:B2_ADMIN_APP_KEY - Master/admin B2 application key ## ## CONFIGURATION: -## $env:ORG_UUID - Organization UUID from NinjaRMM (REQUIRED, fails if empty) +## $env:CUSTOM_FIELD_ORG_UUID - NinjaOne text field containing the organization UUID (REQUIRED) ## $env:B2_ENDPOINT - S3 endpoint (e.g. https://s3.us-west-002.backblazeb2.com) ## $env:B2_REGION - S3 region ID (e.g. us-west-002) ## $env:IMMUTABILITY_DAYS - Object lock immutability period in days (default: 14) @@ -91,7 +91,7 @@ if ($env:RMM -ne "1") { $env:DESCRIPTION = Read-Host "Ticket # or initials for audit trail" if ($env:DESCRIPTION) { $VALID_INPUT = 1 } else { Write-Host "Required." } } - if (-not $env:ORG_UUID) { $env:ORG_UUID = Read-Host "Organization UUID (REQUIRED)" } + if (-not $env:CUSTOM_FIELD_ORG_UUID) { $env:CUSTOM_FIELD_ORG_UUID = Read-Host "Organization UUID (REQUIRED)" } if (-not $env:B2_ADMIN_KEY_ID) { $env:B2_ADMIN_KEY_ID = Read-Host "B2 admin key ID (master key)" } if (-not $env:B2_ADMIN_APP_KEY) { $env:B2_ADMIN_APP_KEY = Read-Host "B2 admin app key (master key)" } if (-not $env:B2_ENDPOINT) { $env:B2_ENDPOINT = Read-Host "B2 S3 endpoint (e.g. https://s3.us-west-002.backblazeb2.com)" } @@ -141,12 +141,12 @@ Write-Host "Description: $env:DESCRIPTION" Write-Host "" # Org UUID is required. Every bucket must be traceable to an org. -if (-not $env:ORG_UUID) { +if (-not $env:CUSTOM_FIELD_ORG_UUID) { Write-Error "ORG_UUID is required. Set the organization UUID in NinjaRMM before running." Stop-Transcript exit 1 } -$ORG_PREFIX = $env:ORG_UUID.Replace("-", "").ToLower() +$ORG_PREFIX = $env:CUSTOM_FIELD_ORG_UUID.Replace("-", "").ToLower() # Generate time-based short ID for this bucket $BUCKET_SHORT_ID = New-TimeBasedShortId @@ -162,7 +162,7 @@ if ($BUCKET_NAME.Length -gt 50) { } Write-Host "Bucket name: $BUCKET_NAME" -Write-Host "Org UUID: $($env:ORG_UUID)" +Write-Host "Org UUID: $($env:CUSTOM_FIELD_ORG_UUID)" Write-Host "Org prefix: $ORG_PREFIX" Write-Host "Bucket short ID: $BUCKET_SHORT_ID" Write-Host "Endpoint: $env:B2_ENDPOINT" From 046ec0655850c94a113eb21988d2c2829ebc4a05 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 13:55:18 -0400 Subject: [PATCH 21/77] Fix B2 auth: use -Credential instead of manual Basic header Invoke-RestMethod rejects manually-constructed Authorization headers when the base64 value contains characters like = or +. Using -Authentication Basic with -Credential (PSCredential) lets PowerShell handle the encoding correctly. Fixed in both veeam-create-s3-repo.ps1 and b2-bucket-audit.ps1. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 6 ++++-- iaas-backblaze/b2-bucket-audit.ps1 | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index 77b24e8..7d965f3 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -198,7 +198,8 @@ if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { Write-Host "" Write-Host "Authenticating to B2 with admin key..." -$B2_CREDS = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("$($env:B2_ADMIN_KEY_ID):$($env:B2_ADMIN_APP_KEY)")) +$B2_SECURE_KEY = ConvertTo-SecureString $env:B2_ADMIN_APP_KEY -AsPlainText -Force +$B2_CREDENTIAL = [PSCredential]::new($env:B2_ADMIN_KEY_ID, $B2_SECURE_KEY) $B2_AUTH = $null $B2_API_VER = "v2" @@ -206,7 +207,8 @@ foreach ($VER in @("v2", "v4")) { try { $B2_AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/$VER/b2_authorize_account" ` -Method GET ` - -Headers @{ Authorization = "Basic $B2_CREDS" } ` + -Authentication Basic ` + -Credential $B2_CREDENTIAL ` -ErrorAction Stop $B2_API_VER = $VER break diff --git a/iaas-backblaze/b2-bucket-audit.ps1 b/iaas-backblaze/b2-bucket-audit.ps1 index 040be0a..bf2c517 100644 --- a/iaas-backblaze/b2-bucket-audit.ps1 +++ b/iaas-backblaze/b2-bucket-audit.ps1 @@ -92,14 +92,16 @@ Write-Host "=== B2 Bucket Audit ===" Write-Host "" Write-Host "Authenticating to Backblaze B2..." -$B2_CREDS = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("$($env:B2_KEY_ID):$($env:B2_APP_KEY)")) +$B2_SECURE_KEY = ConvertTo-SecureString $env:B2_APP_KEY -AsPlainText -Force +$B2_CREDENTIAL = [PSCredential]::new($env:B2_KEY_ID, $B2_SECURE_KEY) $AUTH = $null -foreach ($API_VER in @("v2", "v4", "v3")) { +foreach ($API_VER in @("v2", "v4")) { try { $AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/$API_VER/b2_authorize_account" ` -Method GET ` - -Headers @{ Authorization = "Basic $B2_CREDS" } ` + -Authentication Basic ` + -Credential $B2_CREDENTIAL ` -ErrorAction Stop Write-Host " [OK] Authenticated via $API_VER" break From 3e04637b2c2b38e267bfdbf6aa4393fba1c6d22e Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 13:58:49 -0400 Subject: [PATCH 22/77] Read ORG_UUID from org-level NinjaOne field via Ninja-Property-Get Org-level custom fields aren't injected as env vars by the RMM. Use Ninja-Property-Get -Organization dtcOrgGuid to read the value at runtime. Falls back to CUSTOM_FIELD_ORG_UUID env var if set. Also fixed B2 auth to use -Credential instead of manual header. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index 7d965f3..a0219a6 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -140,13 +140,21 @@ Write-Host "=== Veeam S3 Repository Creation ===" Write-Host "Description: $env:DESCRIPTION" Write-Host "" -# Org UUID is required. Every bucket must be traceable to an org. -if (-not $env:CUSTOM_FIELD_ORG_UUID) { - Write-Error "ORG_UUID is required. Set the organization UUID in NinjaRMM before running." +# Org UUID: read from org-level NinjaOne field (device-level env vars don't +# carry org-level fields, so we must query it explicitly) +$ORG_UUID = $env:CUSTOM_FIELD_ORG_UUID +if (-not $ORG_UUID) { + try { + $ORG_UUID = Ninja-Property-Get -Organization dtcOrgGuid 2>$null + } catch { } +} +if (-not $ORG_UUID) { + Write-Error "ORG_UUID is required. Set the dtcOrgGuid field at the organization level in NinjaRMM." Stop-Transcript exit 1 } -$ORG_PREFIX = $env:CUSTOM_FIELD_ORG_UUID.Replace("-", "").ToLower() +Write-Host "Org UUID: $ORG_UUID" +$ORG_PREFIX = $ORG_UUID.Replace("-", "").ToLower() # Generate time-based short ID for this bucket $BUCKET_SHORT_ID = New-TimeBasedShortId @@ -162,7 +170,7 @@ if ($BUCKET_NAME.Length -gt 50) { } Write-Host "Bucket name: $BUCKET_NAME" -Write-Host "Org UUID: $($env:CUSTOM_FIELD_ORG_UUID)" +Write-Host "Org UUID: $ORG_UUID" Write-Host "Org prefix: $ORG_PREFIX" Write-Host "Bucket short ID: $BUCKET_SHORT_ID" Write-Host "Endpoint: $env:B2_ENDPOINT" From cac752fecf53d618db8022e68e2496b7b53a05cd Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:02:06 -0400 Subject: [PATCH 23/77] Fix B2 API header validation and simplify org UUID read B2 auth tokens contain = characters that Invoke-RestMethod rejects in manual headers. Added Invoke-B2Api wrapper using -SkipHeaderValidation for all post-auth B2 API calls. Simplified org UUID: reads directly from env var since org-level NinjaRMM fields inherit to device level as script variables. Removed the broken Ninja-Property-Get -Organization approach. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 53 ++++++++++++++++-------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index a0219a6..b5cd2ae 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -62,6 +62,27 @@ function New-TimeBasedShortId { return $RESULT.PadLeft(8, '0').Substring(0, 8) } +function Invoke-B2Api { + # Wrapper for B2 API calls that handles PowerShell's strict header validation. + # The B2 auth token contains = characters which Invoke-RestMethod rejects. + param( + [string]$Uri, + [string]$Method = "POST", + [string]$AuthToken, + [string]$Body + ) + $PARAMS = @{ + Uri = $Uri + Method = $Method + ContentType = "application/json" + SkipHeaderValidation = $true + Headers = @{ Authorization = $AuthToken } + ErrorAction = "Stop" + } + if ($Body) { $PARAMS['Body'] = $Body } + return Invoke-RestMethod @PARAMS +} + function Set-NinjaField { param([string]$FieldName, [string]$Value) if (-not $FieldName) { return } @@ -140,14 +161,8 @@ Write-Host "=== Veeam S3 Repository Creation ===" Write-Host "Description: $env:DESCRIPTION" Write-Host "" -# Org UUID: read from org-level NinjaOne field (device-level env vars don't -# carry org-level fields, so we must query it explicitly) +# Org UUID: inherited from org-level field in NinjaRMM, arrives as env var $ORG_UUID = $env:CUSTOM_FIELD_ORG_UUID -if (-not $ORG_UUID) { - try { - $ORG_UUID = Ninja-Property-Get -Organization dtcOrgGuid 2>$null - } catch { } -} if (-not $ORG_UUID) { Write-Error "ORG_UUID is required. Set the dtcOrgGuid field at the organization level in NinjaRMM." Stop-Transcript @@ -250,12 +265,8 @@ try { fileLockEnabled = $true } | ConvertTo-Json - $B2_BUCKET = Invoke-RestMethod -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_create_bucket" ` - -Method POST ` - -Headers @{ Authorization = $B2_AUTH_TOKEN } ` - -ContentType "application/json" ` - -Body $CREATE_BODY ` - -ErrorAction Stop + $B2_BUCKET = Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_create_bucket" ` + -AuthToken $B2_AUTH_TOKEN -Body $CREATE_BODY Write-Host " [OK] Bucket created: $($B2_BUCKET.bucketName) (ID: $($B2_BUCKET.bucketId))" } catch { @@ -277,12 +288,8 @@ try { } } | ConvertTo-Json -Depth 5 - Invoke-RestMethod -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_update_bucket" ` - -Method POST ` - -Headers @{ Authorization = $B2_AUTH_TOKEN } ` - -ContentType "application/json" ` - -Body $RETENTION_BODY ` - -ErrorAction Stop | Out-Null + Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_update_bucket" ` + -AuthToken $B2_AUTH_TOKEN -Body $RETENTION_BODY | Out-Null Write-Host " [OK] Default retention: $IMMUTABILITY_DAYS days (governance mode)" } catch { @@ -323,12 +330,8 @@ try { bucketId = $B2_BUCKET.bucketId } | ConvertTo-Json - $KEY_RESPONSE = Invoke-RestMethod -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_create_key" ` - -Method POST ` - -Headers @{ Authorization = $B2_AUTH_TOKEN } ` - -ContentType "application/json" ` - -Body $KEY_BODY ` - -ErrorAction Stop + $KEY_RESPONSE = Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_create_key" ` + -AuthToken $B2_AUTH_TOKEN -Body $KEY_BODY $SCOPED_KEY_ID = $KEY_RESPONSE.applicationKeyId $SCOPED_APP_KEY = $KEY_RESPONSE.applicationKey From 8931a08894012b324cbbe817f73db8412781a0cd Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:08:39 -0400 Subject: [PATCH 24/77] Add missing accountId to b2_update_bucket retention call Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index b5cd2ae..dcbc2bf 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -278,6 +278,7 @@ try { # Set default retention (object lock) try { $RETENTION_BODY = @{ + accountId = $B2_ACCOUNT_ID bucketId = $B2_BUCKET.bucketId defaultRetention = @{ mode = "governance" From f49340e0738c86bcf2082af3c95153f7af3e954f Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:10:35 -0400 Subject: [PATCH 25/77] Validate ORG_UUID is actually a UUID before proceeding Regex check for standard UUID format. Catches cases where the field name gets passed instead of the value (e.g. "dtcOrgGuid" instead of "a1b2c3d4-5e6f-..."). Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index dcbc2bf..04c17d6 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -164,7 +164,13 @@ Write-Host "" # Org UUID: inherited from org-level field in NinjaRMM, arrives as env var $ORG_UUID = $env:CUSTOM_FIELD_ORG_UUID if (-not $ORG_UUID) { - Write-Error "ORG_UUID is required. Set the dtcOrgGuid field at the organization level in NinjaRMM." + Write-Error "CUSTOM_FIELD_ORG_UUID is empty. Set the org UUID field in NinjaRMM." + Stop-Transcript + exit 1 +} +# Validate it's actually a UUID, not a field name or garbage +if ($ORG_UUID -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + Write-Error "CUSTOM_FIELD_ORG_UUID value '$ORG_UUID' is not a valid UUID. Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" Stop-Transcript exit 1 } From a0cd47fe231233fcbbe68b7b9d3628fac5258dfd Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:14:09 -0400 Subject: [PATCH 26/77] Read org UUID via Ninja-Property-Get using field name from env var CUSTOM_FIELD_ORG_UUID holds the field name (e.g. "dtcOrgGuid"), not the value. Use Ninja-Property-Get to fetch the actual UUID. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index 04c17d6..ab63999 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -161,8 +161,14 @@ Write-Host "=== Veeam S3 Repository Creation ===" Write-Host "Description: $env:DESCRIPTION" Write-Host "" -# Org UUID: inherited from org-level field in NinjaRMM, arrives as env var -$ORG_UUID = $env:CUSTOM_FIELD_ORG_UUID +# Org UUID: CUSTOM_FIELD_ORG_UUID contains the NinjaOne field NAME (e.g. "dtcOrgGuid"). +# We pass that to Ninja-Property-Get to read the actual UUID value. +$ORG_UUID = $null +if ($env:CUSTOM_FIELD_ORG_UUID) { + try { + $ORG_UUID = Ninja-Property-Get $env:CUSTOM_FIELD_ORG_UUID 2>$null + } catch { } +} if (-not $ORG_UUID) { Write-Error "CUSTOM_FIELD_ORG_UUID is empty. Set the org UUID field in NinjaRMM." Stop-Transcript From 276a081295be5d815b0d338359c22c04c477e527 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:21:08 -0400 Subject: [PATCH 27/77] Add idempotency, disable versioning, key propagation delay If bucket name + scoped keys already exist in NinjaRMM fields, skip B2 creation entirely and just create the Veeam repository. This allows re-running after a Veeam failure without creating duplicate buckets/keys in B2. Added 5-second delay after scoped key creation to allow B2 to propagate the key before Veeam tries to authenticate with it. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 280 +++++++++++++++++------------ 1 file changed, 162 insertions(+), 118 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index ab63999..5e88a3e 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -227,137 +227,178 @@ if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { } # ============================================================ -# B2 API: AUTH WITH ADMIN KEY +# CHECK IF BUCKET + KEYS ALREADY EXIST (idempotency) # ============================================================ -Write-Host "" -Write-Host "Authenticating to B2 with admin key..." +# Try reading existing bucket name and scoped keys from NinjaOne +$EXISTING_BUCKET = $null +$EXISTING_KEY_ID = $null +$EXISTING_APP_KEY = $null +try { + if ($env:CUSTOM_FIELD_S3_BUCKET_NAME) { $EXISTING_BUCKET = Ninja-Property-Get $env:CUSTOM_FIELD_S3_BUCKET_NAME 2>$null } + if ($env:CUSTOM_FIELD_S3_KEY_ID) { $EXISTING_KEY_ID = Ninja-Property-Get $env:CUSTOM_FIELD_S3_KEY_ID 2>$null } + if ($env:CUSTOM_FIELD_S3_APP_KEY) { $EXISTING_APP_KEY = Ninja-Property-Get $env:CUSTOM_FIELD_S3_APP_KEY 2>$null } +} catch { } + +$SKIP_B2_CREATION = $false +if ($EXISTING_BUCKET -and $EXISTING_KEY_ID -and $EXISTING_APP_KEY) { + Write-Host "Existing B2 bucket and keys found in RMM:" + Write-Host " Bucket: $EXISTING_BUCKET" + Write-Host " Key ID: $EXISTING_KEY_ID" + Write-Host " Skipping B2 bucket/key creation. Will create Veeam repo only." + Write-Host "" + $BUCKET_NAME = $EXISTING_BUCKET + $SCOPED_KEY_ID = $EXISTING_KEY_ID + $SCOPED_APP_KEY = $EXISTING_APP_KEY + # Extract short ID from existing bucket name for folder creation + if ($BUCKET_NAME -match '-([a-z0-9]{8})-veeam$') { + $BUCKET_SHORT_ID = $Matches[1] + } + $SKIP_B2_CREATION = $true +} -$B2_SECURE_KEY = ConvertTo-SecureString $env:B2_ADMIN_APP_KEY -AsPlainText -Force -$B2_CREDENTIAL = [PSCredential]::new($env:B2_ADMIN_KEY_ID, $B2_SECURE_KEY) +$SCOPED_KEY_ID_OUT = $null +$SCOPED_APP_KEY_OUT = $null -$B2_AUTH = $null -$B2_API_VER = "v2" -foreach ($VER in @("v2", "v4")) { - try { - $B2_AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/$VER/b2_authorize_account" ` - -Method GET ` - -Authentication Basic ` - -Credential $B2_CREDENTIAL ` - -ErrorAction Stop - $B2_API_VER = $VER - break - } catch { } -} -if (-not $B2_AUTH) { - Write-Error "B2 authentication failed. Check B2_ADMIN_KEY_ID and B2_ADMIN_APP_KEY." - Stop-Transcript - exit 1 -} +if (-not $SKIP_B2_CREATION) { + # ============================================================ + # B2 API: AUTH WITH ADMIN KEY + # ============================================================ -$B2_API_URL = $B2_AUTH.apiUrl -if (-not $B2_API_URL) { $B2_API_URL = $B2_AUTH.apiInfo.storageApi.apiUrl } -$B2_AUTH_TOKEN = $B2_AUTH.authorizationToken -$B2_ACCOUNT_ID = $B2_AUTH.accountId + Write-Host "" + Write-Host "Authenticating to B2 with admin key..." -Write-Host " [OK] Account: $B2_ACCOUNT_ID (api: $B2_API_VER)" + $B2_SECURE_KEY = ConvertTo-SecureString $env:B2_ADMIN_APP_KEY -AsPlainText -Force + $B2_CREDENTIAL = [PSCredential]::new($env:B2_ADMIN_KEY_ID, $B2_SECURE_KEY) -# ============================================================ -# CREATE B2 BUCKET -# ============================================================ + $B2_AUTH = $null + $B2_API_VER = "v2" + foreach ($VER in @("v2", "v4")) { + try { + $B2_AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/$VER/b2_authorize_account" ` + -Method GET ` + -Authentication Basic ` + -Credential $B2_CREDENTIAL ` + -ErrorAction Stop + $B2_API_VER = $VER + break + } catch { } + } + if (-not $B2_AUTH) { + Write-Error "B2 authentication failed. Check B2_ADMIN_KEY_ID and B2_ADMIN_APP_KEY." + Stop-Transcript + exit 1 + } -Write-Host "" -Write-Host "Creating B2 bucket: $BUCKET_NAME" + $B2_API_URL = $B2_AUTH.apiUrl + if (-not $B2_API_URL) { $B2_API_URL = $B2_AUTH.apiInfo.storageApi.apiUrl } + $B2_AUTH_TOKEN = $B2_AUTH.authorizationToken + $B2_ACCOUNT_ID = $B2_AUTH.accountId -try { - $CREATE_BODY = @{ - accountId = $B2_ACCOUNT_ID - bucketName = $BUCKET_NAME - bucketType = "allPrivate" - fileLockEnabled = $true - } | ConvertTo-Json + Write-Host " [OK] Account: $B2_ACCOUNT_ID (api: $B2_API_VER)" - $B2_BUCKET = Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_create_bucket" ` - -AuthToken $B2_AUTH_TOKEN -Body $CREATE_BODY + # ============================================================ + # CREATE B2 BUCKET + # ============================================================ - Write-Host " [OK] Bucket created: $($B2_BUCKET.bucketName) (ID: $($B2_BUCKET.bucketId))" -} catch { - Write-Error "B2 bucket creation failed: $_" - Stop-Transcript - exit 1 -} + Write-Host "" + Write-Host "Creating B2 bucket: $BUCKET_NAME" -# Set default retention (object lock) -try { - $RETENTION_BODY = @{ - accountId = $B2_ACCOUNT_ID - bucketId = $B2_BUCKET.bucketId - defaultRetention = @{ - mode = "governance" - period = @{ - duration = $IMMUTABILITY_DAYS - unit = "days" + try { + $CREATE_BODY = @{ + accountId = $B2_ACCOUNT_ID + bucketName = $BUCKET_NAME + bucketType = "allPrivate" + fileLockEnabled = $true + } | ConvertTo-Json + + $B2_BUCKET = Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_create_bucket" ` + -AuthToken $B2_AUTH_TOKEN -Body $CREATE_BODY + + Write-Host " [OK] Bucket created: $($B2_BUCKET.bucketName) (ID: $($B2_BUCKET.bucketId))" + } catch { + Write-Error "B2 bucket creation failed: $_" + Stop-Transcript + exit 1 + } + + # Set default retention and disable versioning + try { + $UPDATE_BODY = @{ + accountId = $B2_ACCOUNT_ID + bucketId = $B2_BUCKET.bucketId + defaultRetention = @{ + mode = "governance" + period = @{ + duration = $IMMUTABILITY_DAYS + unit = "days" + } } - } - } | ConvertTo-Json -Depth 5 + } | ConvertTo-Json -Depth 5 - Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_update_bucket" ` - -AuthToken $B2_AUTH_TOKEN -Body $RETENTION_BODY | Out-Null + Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_update_bucket" ` + -AuthToken $B2_AUTH_TOKEN -Body $UPDATE_BODY | Out-Null - Write-Host " [OK] Default retention: $IMMUTABILITY_DAYS days (governance mode)" -} catch { - Write-Warning " Failed to set default retention: $_" -} + Write-Host " [OK] Default retention: $IMMUTABILITY_DAYS days (governance mode)" + } catch { + Write-Warning " Failed to set default retention: $_" + } -# ============================================================ -# CREATE SCOPED APPLICATION KEY (bucket-only access) -# ============================================================ + # ============================================================ + # CREATE SCOPED APPLICATION KEY (bucket-only access) + # ============================================================ -Write-Host "" -Write-Host "Creating scoped application key for bucket: $BUCKET_NAME" + Write-Host "" + Write-Host "Creating scoped application key for bucket: $BUCKET_NAME" -$SCOPED_KEY_ID = $null -$SCOPED_APP_KEY = $null + try { + $KEY_BODY = @{ + accountId = $B2_ACCOUNT_ID + capabilities = @( + "listBuckets" + "readBuckets" + "listFiles" + "readFiles" + "writeFiles" + "deleteFiles" + "readBucketEncryption" + "writeBucketEncryption" + "readBucketRetentions" + "writeBucketRetentions" + "readFileRetentions" + "writeFileRetentions" + "readFileLegalHolds" + "writeFileLegalHolds" + "bypassGovernance" + ) + keyName = $BUCKET_NAME + bucketId = $B2_BUCKET.bucketId + } | ConvertTo-Json + + $KEY_RESPONSE = Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_create_key" ` + -AuthToken $B2_AUTH_TOKEN -Body $KEY_BODY + + $SCOPED_KEY_ID = $KEY_RESPONSE.applicationKeyId + $SCOPED_APP_KEY = $KEY_RESPONSE.applicationKey + + Write-Host " [OK] Scoped key created: $SCOPED_KEY_ID" + Write-Host " Bucket restriction: $($B2_BUCKET.bucketName) ($($B2_BUCKET.bucketId))" + } catch { + Write-Error "Failed to create scoped application key: $_" + Write-Host " Bucket ($BUCKET_NAME) was created but no scoped key exists." + Stop-Transcript + exit 1 + } -try { - $KEY_BODY = @{ - accountId = $B2_ACCOUNT_ID - capabilities = @( - "listBuckets" - "readBuckets" - "listFiles" - "readFiles" - "writeFiles" - "deleteFiles" - "readBucketEncryption" - "writeBucketEncryption" - "readBucketRetentions" - "writeBucketRetentions" - "readFileRetentions" - "writeFileRetentions" - "readFileLegalHolds" - "writeFileLegalHolds" - "bypassGovernance" - ) - keyName = $BUCKET_NAME - bucketId = $B2_BUCKET.bucketId - } | ConvertTo-Json - - $KEY_RESPONSE = Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_create_key" ` - -AuthToken $B2_AUTH_TOKEN -Body $KEY_BODY - - $SCOPED_KEY_ID = $KEY_RESPONSE.applicationKeyId - $SCOPED_APP_KEY = $KEY_RESPONSE.applicationKey - - Write-Host " [OK] Scoped key created: $SCOPED_KEY_ID" - Write-Host " Capabilities: bucket-scoped read/write/delete + immutability" - Write-Host " Bucket restriction: $($B2_BUCKET.bucketName) ($($B2_BUCKET.bucketId))" -} catch { - Write-Error "Failed to create scoped application key: $_" - Write-Host " Bucket ($BUCKET_NAME) was created but no scoped key exists." - Write-Host " Create a scoped key manually in the B2 console." - Stop-Transcript - exit 1 + # Let the scoped key propagate before Veeam tries to use it + Write-Host " Waiting 5 seconds for key propagation..." + Start-Sleep -Seconds 5 + + $SCOPED_KEY_ID_OUT = $SCOPED_KEY_ID + $SCOPED_APP_KEY_OUT = $SCOPED_APP_KEY +} else { + $SCOPED_KEY_ID_OUT = $SCOPED_KEY_ID + $SCOPED_APP_KEY_OUT = $SCOPED_APP_KEY } # ============================================================ @@ -370,8 +411,8 @@ Write-Host "Creating Veeam S3 repository: $BUCKET_NAME" try { # Add the SCOPED B2 credentials to Veeam (not the admin key) $VEEAM_ACCOUNT = Add-VBRAmazonAccount ` - -AccessKey $SCOPED_KEY_ID ` - -SecretKey $SCOPED_APP_KEY ` + -AccessKey $SCOPED_KEY_ID_OUT ` + -SecretKey $SCOPED_APP_KEY_OUT ` -Description "$env:DESCRIPTION $BUCKET_NAME" Write-Host " [OK] Veeam account added." @@ -429,13 +470,16 @@ Write-Host "=== Complete ===" Write-Host " Bucket: $BUCKET_NAME" Write-Host " Repository: $BUCKET_NAME" Write-Host " Folder: $BUCKET_SHORT_ID" -Write-Host " Scoped key: $SCOPED_KEY_ID" +Write-Host " Scoped key: $SCOPED_KEY_ID_OUT" Write-Host " Immutability: $IMMUTABILITY_DAYS days" Write-Host "" -Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_NAME $BUCKET_NAME -Set-NinjaField $env:CUSTOM_FIELD_S3_KEY_ID $SCOPED_KEY_ID -Set-NinjaField $env:CUSTOM_FIELD_S3_APP_KEY $SCOPED_APP_KEY +# Only write to NinjaOne if we created new B2 resources +if (-not $SKIP_B2_CREATION) { + Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_NAME $BUCKET_NAME + Set-NinjaField $env:CUSTOM_FIELD_S3_KEY_ID $SCOPED_KEY_ID_OUT + Set-NinjaField $env:CUSTOM_FIELD_S3_APP_KEY $SCOPED_APP_KEY_OUT +} Write-Host "" Write-Host "=== Script complete ===" From 44e4a6c0e8ed9e9c07ea8c216e9b93305c08caf1 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:23:24 -0400 Subject: [PATCH 28/77] Save B2 credentials to NinjaOne immediately after creation Previously the NinjaField writes were at the end of the script, after the Veeam repo creation. If Veeam failed, the script exited and the bucket name + scoped keys were never saved. Now saves right after B2 key creation, before attempting Veeam setup. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index 5e88a3e..441b04c 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -390,12 +390,19 @@ if (-not $SKIP_B2_CREATION) { exit 1 } + $SCOPED_KEY_ID_OUT = $SCOPED_KEY_ID + $SCOPED_APP_KEY_OUT = $SCOPED_APP_KEY + + # Save to NinjaOne immediately so credentials aren't lost if Veeam fails + Write-Host "" + Write-Host "Saving B2 credentials to NinjaOne..." + Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_NAME $BUCKET_NAME + Set-NinjaField $env:CUSTOM_FIELD_S3_KEY_ID $SCOPED_KEY_ID_OUT + Set-NinjaField $env:CUSTOM_FIELD_S3_APP_KEY $SCOPED_APP_KEY_OUT + # Let the scoped key propagate before Veeam tries to use it Write-Host " Waiting 5 seconds for key propagation..." Start-Sleep -Seconds 5 - - $SCOPED_KEY_ID_OUT = $SCOPED_KEY_ID - $SCOPED_APP_KEY_OUT = $SCOPED_APP_KEY } else { $SCOPED_KEY_ID_OUT = $SCOPED_KEY_ID $SCOPED_APP_KEY_OUT = $SCOPED_APP_KEY @@ -474,12 +481,6 @@ Write-Host " Scoped key: $SCOPED_KEY_ID_OUT" Write-Host " Immutability: $IMMUTABILITY_DAYS days" Write-Host "" -# Only write to NinjaOne if we created new B2 resources -if (-not $SKIP_B2_CREATION) { - Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_NAME $BUCKET_NAME - Set-NinjaField $env:CUSTOM_FIELD_S3_KEY_ID $SCOPED_KEY_ID_OUT - Set-NinjaField $env:CUSTOM_FIELD_S3_APP_KEY $SCOPED_APP_KEY_OUT -} Write-Host "" Write-Host "=== Script complete ===" From e219791117843199b2228f4704cec1777322eb77 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:28:57 -0400 Subject: [PATCH 29/77] Add listAllBucketNames capability to scoped key Required for bucket-restricted keys to work with Veeam's S3 ListBuckets call during repository setup. Without it, Veeam gets "Invalid credentials" because it can't enumerate buckets to find the target bucket. Per Backblaze docs: bucket-scoped keys must explicitly include listAllBucketNames for S3-compatible ListBuckets to work. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index 441b04c..e796225 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -356,6 +356,7 @@ if (-not $SKIP_B2_CREATION) { accountId = $B2_ACCOUNT_ID capabilities = @( "listBuckets" + "listAllBucketNames" "readBuckets" "listFiles" "readFiles" From 6410780b9ad6e6141be4d74f4b4c5d2ee14d7839 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:32:56 -0400 Subject: [PATCH 30/77] Add lifecycle rule to purge hidden versions after immutability + 1 day Without a lifecycle rule, B2 keeps ALL file versions forever. This causes massive storage bloat (e.g. 11 TB active data but 50 TB in B2 due to accumulated hidden versions from Veeam overwrites). New buckets: lifecycle rule added during creation, deletes hidden file versions after IMMUTABILITY_DAYS + 1 (default: 15 days). This ensures immutability-locked files survive their full retention window before being eligible for cleanup. Existing buckets: b2-fix-lifecycle.ps1 applies the same lifecycle rule retroactively. Supports single bucket or ALL *-veeam buckets. B2 processes lifecycle rules daily so existing hidden versions will be cleaned up over the following days. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 18 ++++- iaas-backblaze/b2-fix-lifecycle.ps1 | 101 ++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 iaas-backblaze/b2-fix-lifecycle.ps1 diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index e796225..a07f12d 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -322,7 +322,13 @@ if (-not $SKIP_B2_CREATION) { exit 1 } - # Set default retention and disable versioning + # Set default retention + lifecycle rule to purge hidden versions + # Hidden files = old versions replaced by newer uploads or deleted by Veeam. + # Without this rule, B2 keeps ALL versions forever and storage balloons. + # daysFromHidingToDeleting = immutability + 1 day buffer so locked files + # are never deleted before retention expires. + $LIFECYCLE_PURGE_DAYS = $IMMUTABILITY_DAYS + 1 + try { $UPDATE_BODY = @{ accountId = $B2_ACCOUNT_ID @@ -334,14 +340,22 @@ if (-not $SKIP_B2_CREATION) { unit = "days" } } + lifecycleRules = @( + @{ + daysFromHidingToDeleting = $LIFECYCLE_PURGE_DAYS + daysFromUploadingToHiding = $null + fileNamePrefix = "" + } + ) } | ConvertTo-Json -Depth 5 Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_update_bucket" ` -AuthToken $B2_AUTH_TOKEN -Body $UPDATE_BODY | Out-Null Write-Host " [OK] Default retention: $IMMUTABILITY_DAYS days (governance mode)" + Write-Host " [OK] Lifecycle rule: delete hidden versions after $LIFECYCLE_PURGE_DAYS days" } catch { - Write-Warning " Failed to set default retention: $_" + Write-Warning " Failed to set retention/lifecycle: $_" } # ============================================================ diff --git a/iaas-backblaze/b2-fix-lifecycle.ps1 b/iaas-backblaze/b2-fix-lifecycle.ps1 new file mode 100644 index 0000000..0769f7e --- /dev/null +++ b/iaas-backblaze/b2-fix-lifecycle.ps1 @@ -0,0 +1,101 @@ +## Apply lifecycle rule to existing B2 buckets to purge hidden (old) file versions. +## Fixes the storage bloat where B2 keeps all versions forever. +## +## $env:B2_ADMIN_KEY_ID - Master B2 application key ID +## $env:B2_ADMIN_APP_KEY - Master B2 application key +## $env:BUCKET_NAME - Target bucket name (or "ALL" to apply to all *-veeam buckets) +## $env:PURGE_DAYS - Days after hiding before deletion (default: 15) + +function Invoke-B2Api { + param([string]$Uri, [string]$Method = "POST", [string]$AuthToken, [string]$Body) + $PARAMS = @{ + Uri = $Uri; Method = $Method; ContentType = "application/json" + SkipHeaderValidation = $true; Headers = @{ Authorization = $AuthToken }; ErrorAction = "Stop" + } + if ($Body) { $PARAMS['Body'] = $Body } + return Invoke-RestMethod @PARAMS +} + +if (-not $env:B2_ADMIN_KEY_ID) { $env:B2_ADMIN_KEY_ID = Read-Host "B2 admin key ID" } +if (-not $env:B2_ADMIN_APP_KEY) { $env:B2_ADMIN_APP_KEY = Read-Host "B2 admin app key" } +if (-not $env:BUCKET_NAME) { $env:BUCKET_NAME = Read-Host "Bucket name (or ALL for all *-veeam buckets)" } + +$PURGE_DAYS = 15 +if ($env:PURGE_DAYS) { try { $PURGE_DAYS = [int]$env:PURGE_DAYS } catch {} } + +# Auth +$B2_SECURE_KEY = ConvertTo-SecureString $env:B2_ADMIN_APP_KEY -AsPlainText -Force +$B2_CREDENTIAL = [PSCredential]::new($env:B2_ADMIN_KEY_ID, $B2_SECURE_KEY) + +$B2_AUTH = $null +foreach ($VER in @("v2", "v4")) { + try { + $B2_AUTH = Invoke-RestMethod -Uri "https://api.backblazeb2.com/b2api/$VER/b2_authorize_account" ` + -Method GET -Authentication Basic -Credential $B2_CREDENTIAL -ErrorAction Stop + $B2_API_VER = $VER + break + } catch { } +} +if (-not $B2_AUTH) { Write-Error "Auth failed."; exit 1 } + +$B2_API_URL = $B2_AUTH.apiUrl +if (-not $B2_API_URL) { $B2_API_URL = $B2_AUTH.apiInfo.storageApi.apiUrl } +$B2_AUTH_TOKEN = $B2_AUTH.authorizationToken +$B2_ACCOUNT_ID = $B2_AUTH.accountId + +Write-Host "Authenticated: $B2_ACCOUNT_ID" + +# List buckets +$BUCKET_RESPONSE = Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_list_buckets" ` + -AuthToken $B2_AUTH_TOKEN -Body (@{ accountId = $B2_ACCOUNT_ID } | ConvertTo-Json) + +$BUCKETS = $BUCKET_RESPONSE.buckets + +if ($env:BUCKET_NAME -eq "ALL") { + $TARGET_BUCKETS = $BUCKETS | Where-Object { $_.bucketName -like "*-veeam" } +} else { + $TARGET_BUCKETS = $BUCKETS | Where-Object { $_.bucketName -eq $env:BUCKET_NAME } +} + +Write-Host "Targeting $($TARGET_BUCKETS.Count) bucket(s)`n" + +foreach ($BUCKET in $TARGET_BUCKETS) { + Write-Host "--- $($BUCKET.bucketName) ---" + + # Show current lifecycle rules + $CURRENT_RULES = $BUCKET.lifecycleRules + if ($CURRENT_RULES -and $CURRENT_RULES.Count -gt 0) { + Write-Host " Current lifecycle rules:" + foreach ($R in $CURRENT_RULES) { + Write-Host " prefix='$($R.fileNamePrefix)' hideAfter=$($R.daysFromUploadingToHiding) deleteAfter=$($R.daysFromHidingToDeleting)" + } + } else { + Write-Host " No lifecycle rules (hidden versions kept forever!)" + } + + # Apply the fix + try { + $UPDATE_BODY = @{ + accountId = $B2_ACCOUNT_ID + bucketId = $BUCKET.bucketId + lifecycleRules = @( + @{ + daysFromHidingToDeleting = $PURGE_DAYS + daysFromUploadingToHiding = $null + fileNamePrefix = "" + } + ) + } | ConvertTo-Json -Depth 5 + + Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_update_bucket" ` + -AuthToken $B2_AUTH_TOKEN -Body $UPDATE_BODY | Out-Null + + Write-Host " [OK] Lifecycle rule applied: delete hidden versions after $PURGE_DAYS days" + } catch { + Write-Warning " FAILED: $_" + } + Write-Host "" +} + +Write-Host "Done. Hidden file versions older than $PURGE_DAYS days will be automatically purged by B2." +Write-Host "Note: existing hidden versions won't be deleted instantly. B2 processes lifecycle rules daily." From eade4dc0013a8cae2d61618f4ecf96adc7684f81 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:35:53 -0400 Subject: [PATCH 31/77] Fix Veeam repo creation: remove bucket-level retention, fix DLL path 1. Removed bucket-level defaultRetention from b2_update_bucket. Veeam manages immutability per-object via its own lock mechanism. Setting bucket default retention causes "default retention is not supported" error in Add-VBRAmazonS3CompatibleRepository. Lifecycle rule for hidden version cleanup is still applied. 2. Version detection now checks multiple DLL paths with fallback. The Packages subfolder doesn't exist on all Veeam installations. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 41 +++++++++++++++++------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index a07f12d..45da680 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -322,25 +322,20 @@ if (-not $SKIP_B2_CREATION) { exit 1 } - # Set default retention + lifecycle rule to purge hidden versions - # Hidden files = old versions replaced by newer uploads or deleted by Veeam. - # Without this rule, B2 keeps ALL versions forever and storage balloons. + # Set lifecycle rule to purge hidden (old) file versions. + # Without this, B2 keeps ALL versions forever and storage balloons. # daysFromHidingToDeleting = immutability + 1 day buffer so locked files # are never deleted before retention expires. + # NOTE: Do NOT set bucket-level defaultRetention here. Veeam manages + # immutability/retention itself via per-object locks. Setting a bucket + # default causes "default retention is not supported" errors in Veeam. $LIFECYCLE_PURGE_DAYS = $IMMUTABILITY_DAYS + 1 try { $UPDATE_BODY = @{ - accountId = $B2_ACCOUNT_ID - bucketId = $B2_BUCKET.bucketId - defaultRetention = @{ - mode = "governance" - period = @{ - duration = $IMMUTABILITY_DAYS - unit = "days" - } - } - lifecycleRules = @( + accountId = $B2_ACCOUNT_ID + bucketId = $B2_BUCKET.bucketId + lifecycleRules = @( @{ daysFromHidingToDeleting = $LIFECYCLE_PURGE_DAYS daysFromUploadingToHiding = $null @@ -352,10 +347,9 @@ if (-not $SKIP_B2_CREATION) { Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_update_bucket" ` -AuthToken $B2_AUTH_TOKEN -Body $UPDATE_BODY | Out-Null - Write-Host " [OK] Default retention: $IMMUTABILITY_DAYS days (governance mode)" Write-Host " [OK] Lifecycle rule: delete hidden versions after $LIFECYCLE_PURGE_DAYS days" } catch { - Write-Warning " Failed to set retention/lifecycle: $_" + Write-Warning " Failed to set lifecycle rule: $_" } # ============================================================ @@ -456,10 +450,23 @@ try { Write-Host " [OK] Folder created: $BUCKET_SHORT_ID" # Detect Veeam version for the EnableBucketAutoProvision parameter - $VEEAM_VERSION = [version](Get-Item 'C:\Program Files\Veeam\Backup and Replication\Backup\Packages\VeeamDeploymentDll.dll').VersionInfo.ProductVersion - $NEEDS_AUTOPROVISION = $VEEAM_VERSION -ge [version]"12.3.1.1139" + $NEEDS_AUTOPROVISION = $false + $DLL_PATHS = @( + 'C:\Program Files\Veeam\Backup and Replication\Backup\Packages\VeeamDeploymentDll.dll', + 'C:\Program Files\Veeam\Backup and Replication\Backup\VeeamDeploymentDll.dll', + 'C:\Program Files\Veeam\Backup and Replication\Console\VeeamDeploymentDll.dll' + ) + foreach ($DLL in $DLL_PATHS) { + if (Test-Path $DLL) { + $VEEAM_VERSION = [version](Get-Item $DLL).VersionInfo.ProductVersion + $NEEDS_AUTOPROVISION = $VEEAM_VERSION -ge [version]"12.3.1.1139" + Write-Host " Veeam version: $VEEAM_VERSION (AutoProvision param: $NEEDS_AUTOPROVISION)" + break + } + } # Create the repository (name = bucket name) + # Veeam manages immutability per-object, not bucket-level retention $REPO_PARAMS = @{ AmazonS3Folder = $VEEAM_FOLDER Connection = $VEEAM_CONNECTION From 001e859769c11af41d80f8b122b9651577531fdd Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:37:57 -0400 Subject: [PATCH 32/77] Fix NonInteractive prompt error in Veeam repo creation Veeam cmdlets prompt for certificate trust confirmation which fails in NonInteractive mode (RMM scripts). Added: - \$ConfirmPreference = 'None' to suppress all confirm prompts - -Force parameter on Connect-VBRAmazonS3CompatibleService if available (Veeam 12.1+) to bypass cert trust dialog Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index 45da680..698457e 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -425,6 +425,9 @@ Write-Host "" Write-Host "Creating Veeam S3 repository: $BUCKET_NAME" try { + # Suppress all confirmation prompts (Veeam cmdlets prompt for cert trust etc.) + $ConfirmPreference = 'None' + # Add the SCOPED B2 credentials to Veeam (not the admin key) $VEEAM_ACCOUNT = Add-VBRAmazonAccount ` -AccessKey $SCOPED_KEY_ID_OUT ` @@ -434,10 +437,17 @@ try { Write-Host " [OK] Veeam account added." # Connect to the S3-compatible service - $VEEAM_CONNECTION = Connect-VBRAmazonS3CompatibleService ` - -Account $VEEAM_ACCOUNT ` - -CustomRegionId $env:B2_REGION ` - -ServicePoint $env:B2_ENDPOINT + # -Force bypasses certificate trust prompts in non-interactive mode + $CONNECT_PARAMS = @{ + Account = $VEEAM_ACCOUNT + CustomRegionId = $env:B2_REGION + ServicePoint = $env:B2_ENDPOINT + } + # Add -Force if the cmdlet supports it (Veeam 12.1+) + if ((Get-Command Connect-VBRAmazonS3CompatibleService).Parameters.ContainsKey('Force')) { + $CONNECT_PARAMS['Force'] = $true + } + $VEEAM_CONNECTION = Connect-VBRAmazonS3CompatibleService @CONNECT_PARAMS Write-Host " [OK] Connected to S3 endpoint." From 71a5577c61fe904bcc62cc9192d3e9099d29ccb9 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:43:11 -0400 Subject: [PATCH 33/77] Enable SSE-B2 (AES256) server-side encryption on all buckets Applied to both new bucket creation and the fix-lifecycle script for existing buckets. Uses B2-managed keys (SSE-B2), no customer key management needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 13 +++++++++---- iaas-backblaze/b2-fix-lifecycle.ps1 | 11 ++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index 698457e..e89726f 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -333,9 +333,13 @@ if (-not $SKIP_B2_CREATION) { try { $UPDATE_BODY = @{ - accountId = $B2_ACCOUNT_ID - bucketId = $B2_BUCKET.bucketId - lifecycleRules = @( + accountId = $B2_ACCOUNT_ID + bucketId = $B2_BUCKET.bucketId + defaultServerSideEncryption = @{ + mode = "SSE-B2" + algorithm = "AES256" + } + lifecycleRules = @( @{ daysFromHidingToDeleting = $LIFECYCLE_PURGE_DAYS daysFromUploadingToHiding = $null @@ -347,9 +351,10 @@ if (-not $SKIP_B2_CREATION) { Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_update_bucket" ` -AuthToken $B2_AUTH_TOKEN -Body $UPDATE_BODY | Out-Null + Write-Host " [OK] Server-side encryption: SSE-B2 (AES256)" Write-Host " [OK] Lifecycle rule: delete hidden versions after $LIFECYCLE_PURGE_DAYS days" } catch { - Write-Warning " Failed to set lifecycle rule: $_" + Write-Warning " Failed to set encryption/lifecycle: $_" } # ============================================================ diff --git a/iaas-backblaze/b2-fix-lifecycle.ps1 b/iaas-backblaze/b2-fix-lifecycle.ps1 index 0769f7e..c2c73bc 100644 --- a/iaas-backblaze/b2-fix-lifecycle.ps1 +++ b/iaas-backblaze/b2-fix-lifecycle.ps1 @@ -76,9 +76,13 @@ foreach ($BUCKET in $TARGET_BUCKETS) { # Apply the fix try { $UPDATE_BODY = @{ - accountId = $B2_ACCOUNT_ID - bucketId = $BUCKET.bucketId - lifecycleRules = @( + accountId = $B2_ACCOUNT_ID + bucketId = $BUCKET.bucketId + defaultServerSideEncryption = @{ + mode = "SSE-B2" + algorithm = "AES256" + } + lifecycleRules = @( @{ daysFromHidingToDeleting = $PURGE_DAYS daysFromUploadingToHiding = $null @@ -90,6 +94,7 @@ foreach ($BUCKET in $TARGET_BUCKETS) { Invoke-B2Api -Uri "$B2_API_URL/b2api/$B2_API_VER/b2_update_bucket" ` -AuthToken $B2_AUTH_TOKEN -Body $UPDATE_BODY | Out-Null + Write-Host " [OK] Server-side encryption: SSE-B2 (AES256)" Write-Host " [OK] Lifecycle rule applied: delete hidden versions after $PURGE_DAYS days" } catch { Write-Warning " FAILED: $_" From 0e5be3196f605fd396b7a99165698017d1ee763e Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:45:04 -0400 Subject: [PATCH 34/77] Remove -NonInteractive from PS7 bootstrap Veeam cmdlets use Read-Host internally for certificate trust prompts which fails hard in NonInteractive mode. Removing the flag lets Veeam auto-accept since ConfirmPreference is already set to None. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index e89726f..42b8412 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -35,7 +35,7 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { } if (Test-Path $PWSH_PATH) { Write-Host "PowerShell $($PSVersionTable.PSVersion) detected. Re-launching in PowerShell 7 at: $PWSH_PATH" - $PS_ARGS = @('-NonInteractive', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $MyInvocation.MyCommand.Path) + $PS_ARGS = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $MyInvocation.MyCommand.Path) & $PWSH_PATH @PS_ARGS exit $LASTEXITCODE } else { From 00c23d9dd78c4ac858acce1df02f043441fd550a Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:49:03 -0400 Subject: [PATCH 35/77] Redact secrets from log output Key IDs truncated to first 8 chars in logs. App keys show only first 4 chars. Set-NinjaField now accepts -Secret flag to control display. No secrets written to transcript or console output. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index 42b8412..ef85e63 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -84,18 +84,23 @@ function Invoke-B2Api { } function Set-NinjaField { - param([string]$FieldName, [string]$Value) + param( + [string]$FieldName, + [string]$Value, + [switch]$Secret + ) if (-not $FieldName) { return } + $DISPLAY = if ($Secret -and $Value.Length -gt 4) { "$($Value.Substring(0, 4))***" } else { $Value } $NINJA_CMD = Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue if ($NINJA_CMD) { try { Ninja-Property-Set $FieldName $Value - Write-Host " [OK] $FieldName = $Value" + Write-Host " [OK] $FieldName = $DISPLAY" } catch { Write-Warning " Failed to write $FieldName : $_" } } else { - Write-Host " [SKIP] Ninja-Property-Set not available. $FieldName = $Value" + Write-Host " [SKIP] Ninja-Property-Set not available. $FieldName = $DISPLAY" } } @@ -244,7 +249,7 @@ $SKIP_B2_CREATION = $false if ($EXISTING_BUCKET -and $EXISTING_KEY_ID -and $EXISTING_APP_KEY) { Write-Host "Existing B2 bucket and keys found in RMM:" Write-Host " Bucket: $EXISTING_BUCKET" - Write-Host " Key ID: $EXISTING_KEY_ID" + Write-Host " Key ID: $($EXISTING_KEY_ID.Substring(0, [Math]::Min(8, $EXISTING_KEY_ID.Length)))..." Write-Host " Skipping B2 bucket/key creation. Will create Veeam repo only." Write-Host "" $BUCKET_NAME = $EXISTING_BUCKET @@ -395,7 +400,7 @@ if (-not $SKIP_B2_CREATION) { $SCOPED_KEY_ID = $KEY_RESPONSE.applicationKeyId $SCOPED_APP_KEY = $KEY_RESPONSE.applicationKey - Write-Host " [OK] Scoped key created: $SCOPED_KEY_ID" + Write-Host " [OK] Scoped key created: $($SCOPED_KEY_ID.Substring(0, [Math]::Min(8, $SCOPED_KEY_ID.Length)))..." Write-Host " Bucket restriction: $($B2_BUCKET.bucketName) ($($B2_BUCKET.bucketId))" } catch { Write-Error "Failed to create scoped application key: $_" @@ -411,8 +416,8 @@ if (-not $SKIP_B2_CREATION) { Write-Host "" Write-Host "Saving B2 credentials to NinjaOne..." Set-NinjaField $env:CUSTOM_FIELD_S3_BUCKET_NAME $BUCKET_NAME - Set-NinjaField $env:CUSTOM_FIELD_S3_KEY_ID $SCOPED_KEY_ID_OUT - Set-NinjaField $env:CUSTOM_FIELD_S3_APP_KEY $SCOPED_APP_KEY_OUT + Set-NinjaField $env:CUSTOM_FIELD_S3_KEY_ID $SCOPED_KEY_ID_OUT -Secret + Set-NinjaField $env:CUSTOM_FIELD_S3_APP_KEY $SCOPED_APP_KEY_OUT -Secret # Let the scoped key propagate before Veeam tries to use it Write-Host " Waiting 5 seconds for key propagation..." @@ -514,7 +519,7 @@ Write-Host "=== Complete ===" Write-Host " Bucket: $BUCKET_NAME" Write-Host " Repository: $BUCKET_NAME" Write-Host " Folder: $BUCKET_SHORT_ID" -Write-Host " Scoped key: $SCOPED_KEY_ID_OUT" +Write-Host " Scoped key: $($SCOPED_KEY_ID_OUT.Substring(0, [Math]::Min(8, $SCOPED_KEY_ID_OUT.Length)))..." Write-Host " Immutability: $IMMUTABILITY_DAYS days" Write-Host "" From e9d56d5895dc347330ea7e13270d5253d15288f0 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:50:27 -0400 Subject: [PATCH 36/77] Pre-trust B2 SSL certificate to prevent Veeam interactive prompt Veeam's Connect-VBRAmazonS3CompatibleService prompts for SSL certificate trust which hangs in RMM (no console for input). Now fetches the B2 endpoint certificate and imports it to LocalMachine Trusted Root before Veeam touches it. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 44 ++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index ef85e63..413d07f 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -435,9 +435,36 @@ Write-Host "" Write-Host "Creating Veeam S3 repository: $BUCKET_NAME" try { - # Suppress all confirmation prompts (Veeam cmdlets prompt for cert trust etc.) + # Suppress all confirmation prompts $ConfirmPreference = 'None' + # Pre-trust the B2 endpoint SSL certificate so Veeam doesn't prompt + Write-Host " Pre-trusting SSL certificate for $env:B2_ENDPOINT..." + try { + $ENDPOINT_URI = [System.Uri]$env:B2_ENDPOINT + $TCP = [System.Net.Sockets.TcpClient]::new($ENDPOINT_URI.Host, 443) + $SSL = [System.Net.Security.SslStream]::new($TCP.GetStream(), $false, { $true }) + $SSL.AuthenticateAsClient($ENDPOINT_URI.Host) + $CERT = $SSL.RemoteCertificate + $SSL.Dispose() + $TCP.Dispose() + + if ($CERT) { + # Import the cert to Trusted Root if not already there + $X509 = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CERT) + $STORE = [System.Security.Cryptography.X509Certificates.X509Store]::new( + [System.Security.Cryptography.X509Certificates.StoreName]::Root, + [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine + ) + $STORE.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + $STORE.Add($X509) + $STORE.Close() + Write-Host " [OK] Certificate trusted: $($X509.Subject)" + } + } catch { + Write-Warning " Could not pre-trust certificate: $_" + } + # Add the SCOPED B2 credentials to Veeam (not the admin key) $VEEAM_ACCOUNT = Add-VBRAmazonAccount ` -AccessKey $SCOPED_KEY_ID_OUT ` @@ -447,17 +474,10 @@ try { Write-Host " [OK] Veeam account added." # Connect to the S3-compatible service - # -Force bypasses certificate trust prompts in non-interactive mode - $CONNECT_PARAMS = @{ - Account = $VEEAM_ACCOUNT - CustomRegionId = $env:B2_REGION - ServicePoint = $env:B2_ENDPOINT - } - # Add -Force if the cmdlet supports it (Veeam 12.1+) - if ((Get-Command Connect-VBRAmazonS3CompatibleService).Parameters.ContainsKey('Force')) { - $CONNECT_PARAMS['Force'] = $true - } - $VEEAM_CONNECTION = Connect-VBRAmazonS3CompatibleService @CONNECT_PARAMS + $VEEAM_CONNECTION = Connect-VBRAmazonS3CompatibleService ` + -Account $VEEAM_ACCOUNT ` + -CustomRegionId $env:B2_REGION ` + -ServicePoint $env:B2_ENDPOINT Write-Host " [OK] Connected to S3 endpoint." From de3bed62cc535aa88bb5456ca8ff565ff739c594 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 14:58:24 -0400 Subject: [PATCH 37/77] Fix hanging: add -Confirm:\$false and force -EnableBucketAutoProvision:\$false Two causes of the non-interactive hang: 1. EnableBackupImmutability triggers ShouldContinue() which hangs waiting for user input. -Confirm:\$false suppresses this. 2. EnableBucketAutoProvision defaults to \$true in Veeam 12.3.1+ and hangs on S3-compatible (non-AWS) endpoints. Now always set to \$false regardless of Veeam version. Removed the DLL version detection logic since we always pass both parameters now. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 37 +++++++++--------------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index 413d07f..ab486bc 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -489,34 +489,19 @@ try { $VEEAM_FOLDER = New-VBRAmazonS3Folder -Name $BUCKET_SHORT_ID -Connection $VEEAM_CONNECTION -Bucket $VEEAM_BUCKET Write-Host " [OK] Folder created: $BUCKET_SHORT_ID" - # Detect Veeam version for the EnableBucketAutoProvision parameter - $NEEDS_AUTOPROVISION = $false - $DLL_PATHS = @( - 'C:\Program Files\Veeam\Backup and Replication\Backup\Packages\VeeamDeploymentDll.dll', - 'C:\Program Files\Veeam\Backup and Replication\Backup\VeeamDeploymentDll.dll', - 'C:\Program Files\Veeam\Backup and Replication\Console\VeeamDeploymentDll.dll' - ) - foreach ($DLL in $DLL_PATHS) { - if (Test-Path $DLL) { - $VEEAM_VERSION = [version](Get-Item $DLL).VersionInfo.ProductVersion - $NEEDS_AUTOPROVISION = $VEEAM_VERSION -ge [version]"12.3.1.1139" - Write-Host " Veeam version: $VEEAM_VERSION (AutoProvision param: $NEEDS_AUTOPROVISION)" - break - } - } - # Create the repository (name = bucket name) - # Veeam manages immutability per-object, not bucket-level retention + # -Confirm:$false suppresses ShouldProcess prompts + # -EnableBucketAutoProvision:$false prevents hang on S3-compatible endpoints + # (default changed to $true in Veeam 12.3.1, causes hang on non-AWS S3) $REPO_PARAMS = @{ - AmazonS3Folder = $VEEAM_FOLDER - Connection = $VEEAM_CONNECTION - Name = $BUCKET_NAME - EnableBackupImmutability = $true - ImmutabilityPeriod = $IMMUTABILITY_DAYS - Description = "$env:DESCRIPTION $BUCKET_NAME" - } - if ($NEEDS_AUTOPROVISION) { - $REPO_PARAMS['EnableBucketAutoProvision'] = $false + AmazonS3Folder = $VEEAM_FOLDER + Connection = $VEEAM_CONNECTION + Name = $BUCKET_NAME + EnableBackupImmutability = $true + ImmutabilityPeriod = $IMMUTABILITY_DAYS + EnableBucketAutoProvision = $false + Description = "$env:DESCRIPTION $BUCKET_NAME" + Confirm = $false } $VEEAM_REPO = Add-VBRAmazonS3CompatibleRepository @REPO_PARAMS From d00abf668f61f53dc849ca0c64b89b866c528ff4 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 15:00:37 -0400 Subject: [PATCH 38/77] Remove -Confirm param, not supported on this Veeam cmdlet Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-create-s3-repo.ps1 index ab486bc..73dd202 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-create-s3-repo.ps1 @@ -501,7 +501,6 @@ try { ImmutabilityPeriod = $IMMUTABILITY_DAYS EnableBucketAutoProvision = $false Description = "$env:DESCRIPTION $BUCKET_NAME" - Confirm = $false } $VEEAM_REPO = Add-VBRAmazonS3CompatibleRepository @REPO_PARAMS From 920c840dad7867f8404083de1fde2340d2a19810 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 15:01:29 -0400 Subject: [PATCH 39/77] Add interactive diagnostic for S3 repo creation Steps through each Veeam cmdlet individually to isolate where it hangs. Pre-filled with the existing bucket from the last run. Step 6 tries WITHOUT immutability first, then enables it via Set-VBRAmazonS3CompatibleRepository separately. Also dumps all available parameters on the cmdlet so we can see exactly what's supported on this Veeam version. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-create-s3-repo-diag.ps1 | 152 ++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 bdr-veeam/veeam-create-s3-repo-diag.ps1 diff --git a/bdr-veeam/veeam-create-s3-repo-diag.ps1 b/bdr-veeam/veeam-create-s3-repo-diag.ps1 new file mode 100644 index 0000000..3ae1d04 --- /dev/null +++ b/bdr-veeam/veeam-create-s3-repo-diag.ps1 @@ -0,0 +1,152 @@ +# Diagnostic: step-by-step Veeam S3 repo creation +# Run interactively on the BDR server in PS7 + +if ($PSVersionTable.PSVersion.Major -lt 7) { + $PWSH = Get-Command pwsh.exe -ErrorAction SilentlyContinue + if ($PWSH) { & $PWSH.Source -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path; exit $LASTEXITCODE } +} + +$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" +$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" +Get-Module -ListAvailable -Name Veeam.Backup.PowerShell | Import-Module -WarningAction SilentlyContinue + +$ConfirmPreference = 'None' + +# ---- Fill in from last successful B2 run ---- +$BUCKET_NAME = "7d591672a90f426db980ba861165c2aa-mn3jar51-veeam" +$SHORT_ID = "mn3jar51" +$ENDPOINT = "https://s3.us-west-002.backblazeb2.com" +$REGION = "us-west-002" +$IMMUTABILITY = 14 + +$KEY_ID = Read-Host "Scoped B2 Key ID (from NinjaOne S3_KEY_ID field)" +$APP_KEY = Read-Host "Scoped B2 App Key (from NinjaOne S3_APP_KEY field)" + +Write-Host "" +Write-Host "==========================================" +Write-Host "Config" +Write-Host "==========================================" +Write-Host " Bucket: $BUCKET_NAME" +Write-Host " Folder: $SHORT_ID" +Write-Host " Endpoint: $ENDPOINT" +Write-Host " Region: $REGION" +Write-Host " Immutability: $IMMUTABILITY days" +Write-Host " Key ID: $($KEY_ID.Substring(0,8))..." +Write-Host "" + +Write-Host "==========================================" +Write-Host "Step 1: Get-Help Add-VBRAmazonS3CompatibleRepository" +Write-Host "==========================================" +$CMD = Get-Command Add-VBRAmazonS3CompatibleRepository +Write-Host "Parameters:" +$CMD.Parameters.Keys | Sort-Object | ForEach-Object { Write-Host " $_" } +Write-Host "" + +Write-Host "==========================================" +Write-Host "Step 2: Add-VBRAmazonAccount" +Write-Host "==========================================" +try { + $ACCOUNT = Add-VBRAmazonAccount -AccessKey $KEY_ID -SecretKey $APP_KEY -Description "diag $BUCKET_NAME" + Write-Host " [OK] Account: $($ACCOUNT.Id)" +} catch { + Write-Host " FAILED: $_" + exit 1 +} + +Write-Host "" +Write-Host "==========================================" +Write-Host "Step 3: Connect-VBRAmazonS3CompatibleService" +Write-Host "==========================================" +try { + $CONN = Connect-VBRAmazonS3CompatibleService -Account $ACCOUNT -CustomRegionId $REGION -ServicePoint $ENDPOINT + Write-Host " [OK] Connected" +} catch { + Write-Host " FAILED: $_" + exit 1 +} + +Write-Host "" +Write-Host "==========================================" +Write-Host "Step 4: Get-VBRAmazonS3Bucket" +Write-Host "==========================================" +try { + $BUCKET = Get-VBRAmazonS3Bucket -Connection $CONN -Name $BUCKET_NAME + Write-Host " [OK] Bucket found: $($BUCKET.Name)" +} catch { + Write-Host " FAILED: $_" + exit 1 +} + +Write-Host "" +Write-Host "==========================================" +Write-Host "Step 5: New-VBRAmazonS3Folder" +Write-Host "==========================================" +try { + $FOLDER = New-VBRAmazonS3Folder -Name $SHORT_ID -Connection $CONN -Bucket $BUCKET + Write-Host " [OK] Folder: $($FOLDER.Name)" +} catch { + Write-Host " FAILED: $_" + # Folder might already exist, try to get it + try { + $FOLDER = Get-VBRAmazonS3Folder -Connection $CONN -Bucket $BUCKET | Where-Object { $_.Name -eq $SHORT_ID } + Write-Host " [OK] Folder already exists: $($FOLDER.Name)" + } catch { + Write-Host " Could not get existing folder: $_" + exit 1 + } +} + +Write-Host "" +Write-Host "==========================================" +Write-Host "Step 6: Add-VBRAmazonS3CompatibleRepository (WITHOUT immutability)" +Write-Host "==========================================" +Write-Host " Trying without immutability first to isolate the hang..." +Write-Host "" +try { + $REPO = Add-VBRAmazonS3CompatibleRepository ` + -Name $BUCKET_NAME ` + -Connection $CONN ` + -AmazonS3Folder $FOLDER ` + -EnableBucketAutoProvision:$false ` + -Description "diag $BUCKET_NAME" + + Write-Host " [OK] Repo created WITHOUT immutability: $($REPO.Name)" + Write-Host "" + Write-Host " Now try enabling immutability via Set-VBRAmazonS3CompatibleRepository..." + try { + Set-VBRAmazonS3CompatibleRepository -Repository $REPO ` + -EnableBackupImmutability ` + -ImmutabilityPeriod $IMMUTABILITY ` + -EnableBucketAutoProvision:$false + + Write-Host " [OK] Immutability enabled!" + } catch { + Write-Host " Set immutability FAILED: $_" + Write-Host " Repo exists but without immutability. Enable it in the Veeam console." + } +} catch { + Write-Host " FAILED: $_" + Write-Host "" + Write-Host "==========================================" + Write-Host "Step 6b: Try with immutability in one shot" + Write-Host "==========================================" + try { + $REPO = Add-VBRAmazonS3CompatibleRepository ` + -Name "$BUCKET_NAME-v2" ` + -Connection $CONN ` + -AmazonS3Folder $FOLDER ` + -EnableBackupImmutability ` + -ImmutabilityPeriod $IMMUTABILITY ` + -EnableBucketAutoProvision:$false ` + -Description "diag $BUCKET_NAME" + + Write-Host " [OK] Repo created WITH immutability: $($REPO.Name)" + } catch { + Write-Host " FAILED: $_" + } +} + +Write-Host "" +Write-Host "==========================================" +Write-Host "Done" +Write-Host "==========================================" From 78c418b3e9441cd3b763fc2ea9405fe8c4572e41 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 15:23:49 -0400 Subject: [PATCH 40/77] Add script to clear old Veeam management server from endpoints Fixes "host is managed by another backup server" error when migrating endpoints to a new Veeam B&R server. Run on the endpoint (not the server). Removes Veeam certificates from Trusted Root, clears management server registry entries, and restarts the Veeam agent service. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-clear-management-server.ps1 | 130 ++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 bdr-veeam/veeam-clear-management-server.ps1 diff --git a/bdr-veeam/veeam-clear-management-server.ps1 b/bdr-veeam/veeam-clear-management-server.ps1 new file mode 100644 index 0000000..f7c5331 --- /dev/null +++ b/bdr-veeam/veeam-clear-management-server.ps1 @@ -0,0 +1,130 @@ +## Removes the old Veeam management server registration from an endpoint. +## Use when migrating an endpoint to a new Veeam B&R server and you get: +## "host is managed by another backup server" +## +## Run on the ENDPOINT (not the Veeam server). Requires admin privileges. +## +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform + +$SCRIPT_LOG_NAME = "veeam-clear-management-server.log" + +if ($env:RMM -ne "1") { + $env:DESCRIPTION = Read-Host "Ticket # or initials for audit trail" + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" +} else { + if (-not $env:DESCRIPTION) { $env:DESCRIPTION = "No Description" } + if ($env:RMM_SCRIPT_PATH) { + $LOG_DIR = "$env:RMM_SCRIPT_PATH\logs" + if (-not (Test-Path $LOG_DIR)) { New-Item -ItemType Directory -Path $LOG_DIR -Force | Out-Null } + $LOG_PATH = "$LOG_DIR\$SCRIPT_LOG_NAME" + } else { + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" + } +} + +Start-Transcript -Path $LOG_PATH + +Write-Host "=== Veeam Clear Management Server ===" +Write-Host "Description: $env:DESCRIPTION" +Write-Host "Hostname: $env:COMPUTERNAME" +Write-Host "" + +# ============================================================ +# 1. Remove Veeam certificates from Trusted Root +# ============================================================ +Write-Host "Checking for Veeam certificates in Trusted Root store..." + +$VEEAM_CERTS = Get-ChildItem Cert:\LocalMachine\Root | Where-Object { + $_.Issuer -like "*Veeam*" -or $_.Subject -like "*Veeam*" +} + +if ($VEEAM_CERTS) { + foreach ($CERT in $VEEAM_CERTS) { + Write-Host " Removing: $($CERT.Subject)" + Write-Host " Issuer: $($CERT.Issuer)" + Write-Host " Thumbprint: $($CERT.Thumbprint)" + try { + Remove-Item $CERT.PSPath -Force + Write-Host " [OK] Removed." + } catch { + Write-Warning " Failed to remove: $_" + } + } +} else { + Write-Host " No Veeam certificates found." +} + +Write-Host "" + +# ============================================================ +# 2. Clear management server registration from registry +# ============================================================ +Write-Host "Clearing management server registry entries..." + +$REG_PATHS = @( + "HKLM:\SOFTWARE\Veeam\Veeam Agent for Microsoft Windows", + "HKLM:\SOFTWARE\Veeam\Veeam Backup and Replication", + "HKLM:\SOFTWARE\Veeam\Veeam Agent" +) + +$REG_KEYS = @( + "ManagementServerAddress", + "ManagementServerPort", + "ManagementServerId", + "ManagementServerCertificateThumbprint", + "ManagementServerCertificateSubject", + "BackupServerAddress", + "BackupServerPort", + "BackupServerId" +) + +foreach ($PATH in $REG_PATHS) { + if (-not (Test-Path $PATH)) { continue } + + Write-Host " Registry: $PATH" + $PROPS = Get-ItemProperty -Path $PATH -ErrorAction SilentlyContinue + + foreach ($KEY in $REG_KEYS) { + if ($null -ne $PROPS.$KEY) { + Write-Host " $KEY = $($PROPS.$KEY)" + try { + Remove-ItemProperty -Path $PATH -Name $KEY -Force -ErrorAction Stop + Write-Host " [OK] Cleared." + } catch { + Write-Warning " Failed to clear: $_" + } + } + } +} + +Write-Host "" + +# ============================================================ +# 3. Restart Veeam agent service +# ============================================================ +Write-Host "Restarting Veeam agent service..." + +$VEEAM_SERVICES = @( + "VeeamEndpointBackupSvc", + "VeeamAgentSvc" +) + +foreach ($SVC_NAME in $VEEAM_SERVICES) { + $SVC = Get-Service -Name $SVC_NAME -ErrorAction SilentlyContinue + if ($SVC) { + Write-Host " Restarting: $SVC_NAME (currently $($SVC.Status))" + try { + Restart-Service -Name $SVC_NAME -Force -ErrorAction Stop + Write-Host " [OK] Restarted." + } catch { + Write-Warning " Failed to restart: $_" + } + } +} + +Write-Host "" +Write-Host "=== Complete ===" +Write-Host "This endpoint can now be added to a new Veeam management server." + +Stop-Transcript From 11c86df6903c9ece7e1d997d71ef013b66455838 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 15:34:11 -0400 Subject: [PATCH 41/77] Add S3 backup copy job configuration script Creates or updates a single S3 backup copy job that includes ALL backup jobs as sources. Uses immediate/simple mode which copies only the latest restore point when source jobs complete (pruning mode, not full history). Logic: - If a copy job targeting the S3 repo already exists, checks for missing source jobs and adds them via Set-VBRBackupCopyJob (with internal API fallback for older Veeam versions) - If no copy job exists, creates one with all source jobs - Collects sources from Get-VBRJob (VM/agent) and Get-VBRComputerBackupJob, excludes copy jobs from sources - Jobs are created disabled, then enabled after creation - Default retention: 30 days (configurable via COPY_JOB_RETENTION_DAYS) Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 323 ++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 bdr-veeam/veeam-configure-s3-copy-job.ps1 diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 new file mode 100644 index 0000000..8d8fdfb --- /dev/null +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -0,0 +1,323 @@ +## Ensures a single S3 backup copy job exists targeting the S3 repository, +## with ALL backup jobs as sources. If the copy job exists, adds any missing +## source jobs. If it doesn't exist, creates it. +## +## Uses immediate/simple mode (copies latest restore point when source job +## finishes, not full history). This is the "pruning" mode. +## +## $env:CUSTOM_FIELD_S3_BUCKET_NAME - NinjaOne field name for the S3 bucket/repo name +## $env:COPY_JOB_RETENTION_DAYS - Retention in days (default: 30) +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform +## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) + +# ============================================================ +# PS7 BOOTSTRAP +# ============================================================ +if ($PSVersionTable.PSVersion.Major -lt 7) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } + if (-not $PWSH_PATH) { $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" } + if (Test-Path $PWSH_PATH) { + Write-Host "Re-launching in PowerShell 7..." + & $PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path + exit $LASTEXITCODE + } +} + +# ============================================================ +# INPUT HANDLING +# ============================================================ + +$SCRIPT_LOG_NAME = "veeam-configure-s3-copy-job.log" +$ConfirmPreference = 'None' + +if ($env:RMM -ne "1") { + if (-not $env:DESCRIPTION) { $env:DESCRIPTION = Read-Host "Ticket # or initials" } + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" +} else { + if (-not $env:DESCRIPTION) { $env:DESCRIPTION = "No Description" } + if ($env:RMM_SCRIPT_PATH) { + $LOG_DIR = "$env:RMM_SCRIPT_PATH\logs" + if (-not (Test-Path $LOG_DIR)) { New-Item -ItemType Directory -Path $LOG_DIR -Force | Out-Null } + $LOG_PATH = "$LOG_DIR\$SCRIPT_LOG_NAME" + } else { + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" + } +} + +$RETENTION_DAYS = 30 +if ($env:COPY_JOB_RETENTION_DAYS) { + try { $RETENTION_DAYS = [int]$env:COPY_JOB_RETENTION_DAYS } catch {} +} + +Start-Transcript -Path $LOG_PATH + +Write-Host "=== Veeam S3 Copy Job Configuration ===" +Write-Host "Description: $env:DESCRIPTION" +Write-Host "Retention: $RETENTION_DAYS days" +Write-Host "" + +# ============================================================ +# LOAD VEEAM MODULE +# ============================================================ + +Write-Host "Loading Veeam PowerShell module..." +$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" +$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" + +if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { + try { + $VBR_MODULES | Import-Module -WarningAction SilentlyContinue + Write-Host " [OK] Veeam module loaded." + } catch { + Stop-Transcript + throw "Failed to load Veeam modules: $_" + } +} else { + Stop-Transcript + throw "Veeam.Backup.PowerShell module not found." +} + +# ============================================================ +# FIND THE S3 REPOSITORY +# ============================================================ + +Write-Host "" +Write-Host "Finding S3 repository..." + +$S3_REPO_NAME = $null +if ($env:CUSTOM_FIELD_S3_BUCKET_NAME) { + try { + $S3_REPO_NAME = Ninja-Property-Get $env:CUSTOM_FIELD_S3_BUCKET_NAME 2>$null + } catch { } +} + +# Find the repo in Veeam by name +$S3_REPO = $null +if ($S3_REPO_NAME) { + $S3_REPO = Get-VBRBackupRepository -Name $S3_REPO_NAME -ErrorAction SilentlyContinue +} + +# If not found by NinjaOne field, look for any S3-compatible repo +if (-not $S3_REPO) { + Write-Host " S3 repo not found by NinjaOne field. Searching for S3-compatible repositories..." + $ALL_REPOS = Get-VBRBackupRepository + $S3_REPOS = $ALL_REPOS | Where-Object { $_.Type -like "AmazonS3*" -or $_.Type -eq "S3Compatible" -or $_.Type -match "S3" } + + if ($S3_REPOS.Count -eq 1) { + $S3_REPO = $S3_REPOS[0] + } elseif ($S3_REPOS.Count -gt 1) { + # Also check object storage repos + try { + $OBJ_REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop + if ($OBJ_REPOS.Count -eq 1) { + $S3_REPO_NAME = $OBJ_REPOS[0].Name + $S3_REPO = Get-VBRBackupRepository -Name $S3_REPO_NAME -ErrorAction SilentlyContinue + } + } catch { } + } + + if (-not $S3_REPO) { + Write-Error "No S3 repository found. Run veeam-create-s3-repo.ps1 first." + Stop-Transcript + exit 1 + } +} + +Write-Host " [OK] S3 repository: $($S3_REPO.Name)" + +# ============================================================ +# GET ALL SOURCE BACKUP JOBS +# ============================================================ + +Write-Host "" +Write-Host "Collecting source backup jobs..." + +$SOURCE_JOBS = @() + +# Get regular backup jobs (VM, etc.) +try { + $VM_JOBS = @(Get-VBRJob -WarningAction SilentlyContinue | Where-Object { + $_.JobType -eq 'Backup' -or $_.JobType -eq 'EpAgentBackup' -or + $_.JobType -eq 'EpAgentPolicy' -or $_.JobType -eq 'EpAgentManagement' + }) + if ($VM_JOBS.Count -gt 0) { + Write-Host " Get-VBRJob: $($VM_JOBS.Count) backup jobs" + foreach ($J in $VM_JOBS) { Write-Host " $($J.Name) ($($J.JobType))" } + $SOURCE_JOBS += $VM_JOBS + } +} catch { + Write-Warning " Get-VBRJob failed: $_" +} + +# Get computer/agent backup jobs +try { + $AGENT_JOBS = @(Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) + if ($AGENT_JOBS.Count -gt 0) { + Write-Host " Get-VBRComputerBackupJob: $($AGENT_JOBS.Count) agent jobs" + foreach ($J in $AGENT_JOBS) { Write-Host " $($J.Name)" } + $SOURCE_JOBS += $AGENT_JOBS + } +} catch { + Write-Warning " Get-VBRComputerBackupJob failed: $_" +} + +# Filter out any backup copy jobs from the source list (don't copy a copy) +$SOURCE_JOBS = @($SOURCE_JOBS | Where-Object { + $_.JobType -ne 'SimpleBackupCopyPolicy' -and + $_.JobType -ne 'BackupSync' -and + $_.JobType -ne 'SimpleBackupCopyWorker' -and + -not $_.IsBackupCopy +}) + +if ($SOURCE_JOBS.Count -eq 0) { + Write-Error "No source backup jobs found to copy." + Stop-Transcript + exit 1 +} + +Write-Host " Total source jobs: $($SOURCE_JOBS.Count)" + +# ============================================================ +# CHECK FOR EXISTING S3 COPY JOB +# ============================================================ + +Write-Host "" +Write-Host "Checking for existing S3 copy job..." + +$EXISTING_COPY_JOB = $null +try { + $ALL_COPY_JOBS = @(Get-VBRBackupCopyJob -ErrorAction Stop) + foreach ($CJ in $ALL_COPY_JOBS) { + if ($null -ne $CJ.TargetRepository -and $CJ.TargetRepository.Name -eq $S3_REPO.Name) { + $EXISTING_COPY_JOB = $CJ + break + } + } +} catch { + Write-Warning " Get-VBRBackupCopyJob failed: $_" +} + +if ($EXISTING_COPY_JOB) { + # ============================================================ + # UPDATE EXISTING COPY JOB - add missing source jobs + # ============================================================ + + Write-Host " [OK] Found existing copy job: $($EXISTING_COPY_JOB.Name)" + Write-Host "" + + # Get currently linked source job IDs + $LINKED_IDS = @() + try { + if ($EXISTING_COPY_JOB.LinkedJobIds) { + $LINKED_IDS = @($EXISTING_COPY_JOB.LinkedJobIds) + } + } catch { } + + # Also check via BackupJob property + if ($LINKED_IDS.Count -eq 0) { + try { + if ($EXISTING_COPY_JOB.BackupJob) { + $LINKED_IDS = @($EXISTING_COPY_JOB.BackupJob | ForEach-Object { $_.Id }) + } + } catch { } + } + + Write-Host " Currently linked: $($LINKED_IDS.Count) jobs" + foreach ($LID in $LINKED_IDS) { + $LINKED_NAME = ($SOURCE_JOBS | Where-Object { $_.Id -eq $LID }).Name + if (-not $LINKED_NAME) { $LINKED_NAME = $LID } + Write-Host " $LINKED_NAME" + } + + # Find jobs NOT yet linked + $MISSING_JOBS = @($SOURCE_JOBS | Where-Object { $LINKED_IDS -notcontains $_.Id }) + + if ($MISSING_JOBS.Count -eq 0) { + Write-Host "" + Write-Host " All backup jobs are already linked. Nothing to do." + } else { + Write-Host "" + Write-Host " Missing from copy job ($($MISSING_JOBS.Count)):" + foreach ($MJ in $MISSING_JOBS) { Write-Host " $($MJ.Name) ($($MJ.JobType))" } + + # Rebuild the full source list (existing + missing) + # Set-VBRBackupCopyJob replaces the source list, so include everything + Write-Host "" + Write-Host " Updating copy job with all source jobs..." + try { + Set-VBRBackupCopyJob -Job $EXISTING_COPY_JOB -BackupJob $SOURCE_JOBS + Write-Host " [OK] Copy job updated with $($SOURCE_JOBS.Count) source jobs." + } catch { + Write-Warning " Set-VBRBackupCopyJob failed: $_" + Write-Host " Trying alternative: adding missing jobs individually..." + + # Fallback: try the internal API to add linked jobs + foreach ($MJ in $MISSING_JOBS) { + try { + $GUID = [guid]::NewGuid() + $LINKED_INFO = [Veeam.Backup.Model.CLinkedObjectInfo]::new( + $GUID, $EXISTING_COPY_JOB.Id, $MJ.Id, (Get-Date), 0) + [Veeam.Backup.Core.CLinkedJobs]::Create($LINKED_INFO) + Write-Host " [OK] Added: $($MJ.Name)" + } catch { + Write-Warning " Failed to add $($MJ.Name): $_" + } + } + + # Refresh the job + try { + $EXISTING_COPY_JOB_OBJ = Get-VBRJob | Where-Object { $_.Id -eq $EXISTING_COPY_JOB.Id } + if ($EXISTING_COPY_JOB_OBJ) { $EXISTING_COPY_JOB_OBJ.Update() } + } catch { } + } + } +} else { + # ============================================================ + # CREATE NEW COPY JOB + # ============================================================ + + Write-Host " No existing S3 copy job found. Creating one..." + Write-Host "" + + $COPY_JOB_NAME = "S3 Copy - $($S3_REPO.Name)" + + Write-Host " Job name: $COPY_JOB_NAME" + Write-Host " Target repo: $($S3_REPO.Name)" + Write-Host " Source jobs: $($SOURCE_JOBS.Count)" + Write-Host " Mode: Immediate (copies latest restore point)" + Write-Host " Retention: $RETENTION_DAYS days" + Write-Host "" + + try { + $COPY_JOB = Add-VBRBackupCopyJob ` + -Name $COPY_JOB_NAME ` + -Description "$env:DESCRIPTION" ` + -BackupJob $SOURCE_JOBS ` + -TargetRepository $S3_REPO ` + -DirectOperation ` + -RetentionType Days ` + -RetentionNumber $RETENTION_DAYS + + Write-Host " [OK] Copy job created: $($COPY_JOB.Name)" + + # Enable the job (created disabled by default) + try { + Enable-VBRBackupCopyJob -Job $COPY_JOB + Write-Host " [OK] Copy job enabled." + } catch { + Write-Warning " Failed to enable: $_" + Write-Host " Enable it manually in the Veeam console." + } + } catch { + Write-Error "Failed to create copy job: $_" + Stop-Transcript + exit 1 + } +} + +Write-Host "" +Write-Host "=== Script complete ===" + +Stop-Transcript From 329646327bfeb8d5627c7031dbf582d1ad00f887 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 15:37:53 -0400 Subject: [PATCH 42/77] Read retention from org-level Cloud RPO custom field CUSTOM_FIELD_CLOUD_RPO contains the NinjaOne field name (e.g. "cloudRpo"). Value is read via Ninja-Property-Get at runtime. Defaults to 30 days if the field is empty or not set. Allows per-org RPO tuning from NinjaRMM without modifying scripts. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 8d8fdfb..5c957f7 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -6,7 +6,7 @@ ## finishes, not full history). This is the "pruning" mode. ## ## $env:CUSTOM_FIELD_S3_BUCKET_NAME - NinjaOne field name for the S3 bucket/repo name -## $env:COPY_JOB_RETENTION_DAYS - Retention in days (default: 30) +## $env:CUSTOM_FIELD_CLOUD_RPO - NinjaOne org-level field name for cloud RPO retention days (default: 30) ## $env:DESCRIPTION - Ticket # or initials for audit trail ## $env:RMM - Set to 1 when running from RMM platform ## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) @@ -46,9 +46,13 @@ if ($env:RMM -ne "1") { } } +# Cloud RPO: read from org-level NinjaOne field, default 30 $RETENTION_DAYS = 30 -if ($env:COPY_JOB_RETENTION_DAYS) { - try { $RETENTION_DAYS = [int]$env:COPY_JOB_RETENTION_DAYS } catch {} +if ($env:CUSTOM_FIELD_CLOUD_RPO) { + try { + $RPO_VALUE = Ninja-Property-Get $env:CUSTOM_FIELD_CLOUD_RPO 2>$null + if ($RPO_VALUE) { $RETENTION_DAYS = [int]$RPO_VALUE } + } catch { } } Start-Transcript -Path $LOG_PATH From 2a16e290a9e60ee1c85df2eff444261604f07ac5 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 15:44:40 -0400 Subject: [PATCH 43/77] Detect missing or incomplete S3 backup copy job New checkbox field CUSTOM_FIELD_S3_COPY_MISSING, checked (1) when: - No S3 repository exists on the server - S3 repo exists but no copy job targets it - Copy job exists but not all backup jobs are linked to it Compares backup job IDs (from Get-VBRBackup + Get-VBRComputerBackupJob) against LinkedJobIds on copy jobs targeting S3 repos. Excludes copy jobs themselves from the source check. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-inventory.ps1 | 72 +++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/bdr-veeam/veeam-inventory.ps1 b/bdr-veeam/veeam-inventory.ps1 index 962f02c..af9330b 100644 --- a/bdr-veeam/veeam-inventory.ps1 +++ b/bdr-veeam/veeam-inventory.ps1 @@ -6,6 +6,7 @@ ## $env:CUSTOM_FIELD_ORPHANED_BACKUPS - WYSIWYG field: HTML table of orphaned/stale backup data (all repos) ## $env:CUSTOM_FIELD_FAILED_BACKUP - Checkbox field: checked if any backup job's last run failed ## $env:CUSTOM_FIELD_FAILED_BACKUPS - WYSIWYG field: HTML table of jobs whose last run failed/warned +## $env:CUSTOM_FIELD_S3_COPY_MISSING - Checkbox field: checked if S3 copy job is missing or incomplete ## $env:ORPHAN_DAYS_THRESHOLD - Days since last backup to consider orphaned (default: 30) ## $env:DESCRIPTION - Ticket # or initials for audit trail ## $env:RMM - Set to 1 when running from RMM platform @@ -491,6 +492,75 @@ try { Write-Warning " Get-VBRBackupSession failed: $_" } +# ------------------------------------------------------------ +# S3 copy job detection +# ------------------------------------------------------------ +$S3_COPY_MISSING = $false +$S3_COPY_MISSING_REASON = "" + +if ($OBJECT_STORAGE_REPOS -and $OBJECT_STORAGE_REPOS.Count -gt 0) { + Write-Host " Checking S3 backup copy job status..." + + # Get all copy jobs targeting S3 repos + $S3_COPY_JOBS = @() + foreach ($CJ in $COPY_JOBS) { + if ($null -ne $CJ.TargetRepository -and $S3_REPO_IDS.ContainsKey($CJ.TargetRepository.Id.ToString())) { + $S3_COPY_JOBS += $CJ + } + } + + if ($S3_COPY_JOBS.Count -eq 0) { + $S3_COPY_MISSING = $true + $S3_COPY_MISSING_REASON = "No S3 backup copy job exists" + Write-Host " [!] No S3 copy job found." + } else { + # Check if all backup jobs are linked to the copy job + $LINKED_IDS = @() + foreach ($CJ in $S3_COPY_JOBS) { + try { + if ($CJ.LinkedJobIds) { $LINKED_IDS += @($CJ.LinkedJobIds) } + } catch { } + try { + if ($CJ.BackupJob) { $LINKED_IDS += @($CJ.BackupJob | ForEach-Object { $_.Id }) } + } catch { } + } + $LINKED_IDS = @($LINKED_IDS | Select-Object -Unique) + + # Get all non-copy backup jobs + $ALL_SOURCE_JOBS = @($ALL_BACKUPS | Where-Object { + -not $_.IsBackupCopy -and + $_.JobType -ne 'SimpleBackupCopyPolicy' -and + $_.JobType -ne 'BackupSync' -and + $_.JobType -ne 'SimpleBackupCopyWorker' + }) + # Also check computer backup jobs + try { + $COMPUTER_JOBS = @(Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) + $COMPUTER_JOB_IDS = @($COMPUTER_JOBS | ForEach-Object { $_.Id }) + } catch { $COMPUTER_JOB_IDS = @() } + + # Combine all source job IDs + $ALL_SOURCE_IDS = @() + $ALL_SOURCE_IDS += @($ALL_SOURCE_JOBS | ForEach-Object { $_.JobId } | Select-Object -Unique) + $ALL_SOURCE_IDS += $COMPUTER_JOB_IDS + $ALL_SOURCE_IDS = @($ALL_SOURCE_IDS | Select-Object -Unique) + + $UNLINKED = @($ALL_SOURCE_IDS | Where-Object { $LINKED_IDS -notcontains $_ }) + + if ($UNLINKED.Count -gt 0) { + $S3_COPY_MISSING = $true + $S3_COPY_MISSING_REASON = "$($UNLINKED.Count) backup job(s) not in S3 copy job" + Write-Host " [!] $($UNLINKED.Count) backup jobs missing from S3 copy job." + } else { + Write-Host " [OK] S3 copy job exists with all backup jobs linked." + } + } +} else { + $S3_COPY_MISSING = $true + $S3_COPY_MISSING_REASON = "No S3 repository configured" + Write-Host " [!] No S3 repository found." +} + # If only one S3 repo exists and last-used tracking didn't match via time, use it directly if ($LAST_USED_BUCKET -eq "N/A" -and $REPO_ROWS.Count -eq 1) { $LAST_USED_BUCKET = $REPO_ROWS[0][0] @@ -507,6 +577,7 @@ Write-Host " Last used S3 size: $LAST_USED_SIZE" Write-Host " S3 repos found: $($REPO_ROWS.Count)" Write-Host " Orphaned backups: $ORPHAN_COUNT" Write-Host " Failed backups: $FAILED_COUNT" +Write-Host " S3 copy job missing: $S3_COPY_MISSING $(if ($S3_COPY_MISSING_REASON) { "($S3_COPY_MISSING_REASON)" })" Write-Host "" if ($REPO_ROWS.Count -gt 0) { @@ -566,6 +637,7 @@ Set-NinjaField $env:CUSTOM_FIELD_ORPHANS_FOUND "$([int]($ORPHAN_COUNT -gt 0))" Set-NinjaField $env:CUSTOM_FIELD_ORPHANED_BACKUPS $HTML_ORPHANS Set-NinjaField $env:CUSTOM_FIELD_FAILED_BACKUP "$([int]($FAILED_COUNT -gt 0))" Set-NinjaField $env:CUSTOM_FIELD_FAILED_BACKUPS $HTML_FAILED +Set-NinjaField $env:CUSTOM_FIELD_S3_COPY_MISSING "$([int]$S3_COPY_MISSING)" # If no Ninja available and no fields set, dump HTML to console if (-not (Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue)) { From 75e9fe22845b32b2fdbe1501fcd13c9b991f3a7c Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 15:57:20 -0400 Subject: [PATCH 44/77] Add backup window: Mon-Sat 10 PM - 5 AM, Sunday all day Uses -BackupWindowEnabled and -BackupWindowOptions on the copy job. The window is a 168-char binary string (24h x 7 days, starting Sunday). Immediate mode still triggers after source jobs finish, but data transfer only runs during the window. Mon-Sat: allowed 10 PM (22:00) through 4:59 AM, blocked 5 AM - 9:59 PM Sunday: allowed all 24 hours Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 26 ++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 5c957f7..c80579c 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -291,10 +291,31 @@ if ($EXISTING_COPY_JOB) { Write-Host " Target repo: $($S3_REPO.Name)" Write-Host " Source jobs: $($SOURCE_JOBS.Count)" Write-Host " Mode: Immediate (copies latest restore point)" + Write-Host " Window: Mon-Sat 10 PM - 5 AM, Sunday all day" Write-Host " Retention: $RETENTION_DAYS days" Write-Host "" try { + # Build backup window: Mon-Sat 10 PM - 5 AM, Sunday all day + # BackupWindowOptions is a binary string: 168 chars (24 hours x 7 days) + # Each char is 1 (allowed) or 0 (blocked). Order: Sun 00-23, Mon 00-23, ... Sat 00-23 + $WINDOW = "" + for ($DAY = 0; $DAY -lt 7; $DAY++) { + for ($HOUR = 0; $HOUR -lt 24; $HOUR++) { + if ($DAY -eq 0) { + # Sunday: all day + $WINDOW += "1" + } else { + # Mon-Sat: 10 PM (22) through 4 AM (0-4), blocked 5 AM - 9 PM (5-21) + if ($HOUR -le 4 -or $HOUR -ge 22) { + $WINDOW += "1" + } else { + $WINDOW += "0" + } + } + } + } + $COPY_JOB = Add-VBRBackupCopyJob ` -Name $COPY_JOB_NAME ` -Description "$env:DESCRIPTION" ` @@ -302,9 +323,12 @@ if ($EXISTING_COPY_JOB) { -TargetRepository $S3_REPO ` -DirectOperation ` -RetentionType Days ` - -RetentionNumber $RETENTION_DAYS + -RetentionNumber $RETENTION_DAYS ` + -BackupWindowEnabled ` + -BackupWindowOptions $WINDOW Write-Host " [OK] Copy job created: $($COPY_JOB.Name)" + Write-Host " Backup window: Mon-Sat 10 PM - 5 AM, Sunday all day" # Enable the job (created disabled by default) try { From 79b5987c4aa796deff1658bccc9302eeb46bce49 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 15:59:25 -0400 Subject: [PATCH 45/77] Rename Cloud RPO to Cloud Retention RPO is recovery point objective (time between backups), not retention. This field controls how many days/versions of cloud backup copies to keep. CUSTOM_FIELD_CLOUD_RPO -> CUSTOM_FIELD_CLOUD_RETENTION Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index c80579c..0a3dce3 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -6,7 +6,7 @@ ## finishes, not full history). This is the "pruning" mode. ## ## $env:CUSTOM_FIELD_S3_BUCKET_NAME - NinjaOne field name for the S3 bucket/repo name -## $env:CUSTOM_FIELD_CLOUD_RPO - NinjaOne org-level field name for cloud RPO retention days (default: 30) +## $env:CUSTOM_FIELD_CLOUD_RETENTION - NinjaOne org-level field name for cloud backup retention days (versions to keep) (default: 30) ## $env:DESCRIPTION - Ticket # or initials for audit trail ## $env:RMM - Set to 1 when running from RMM platform ## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) @@ -46,11 +46,11 @@ if ($env:RMM -ne "1") { } } -# Cloud RPO: read from org-level NinjaOne field, default 30 +# Cloud Retention: read from org-level NinjaOne field, default 30 $RETENTION_DAYS = 30 -if ($env:CUSTOM_FIELD_CLOUD_RPO) { +if ($env:CUSTOM_FIELD_CLOUD_RETENTION) { try { - $RPO_VALUE = Ninja-Property-Get $env:CUSTOM_FIELD_CLOUD_RPO 2>$null + $RPO_VALUE = Ninja-Property-Get $env:CUSTOM_FIELD_CLOUD_RETENTION 2>$null if ($RPO_VALUE) { $RETENTION_DAYS = [int]$RPO_VALUE } } catch { } } From f973ebf16e6558a43c6054a7d4754b898f02b344 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:03:23 -0400 Subject: [PATCH 46/77] Add Saturday to all-day backup window Mon-Fri: 10 PM - 5 AM Sat-Sun: all day Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 0a3dce3..3240e69 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -291,22 +291,25 @@ if ($EXISTING_COPY_JOB) { Write-Host " Target repo: $($S3_REPO.Name)" Write-Host " Source jobs: $($SOURCE_JOBS.Count)" Write-Host " Mode: Immediate (copies latest restore point)" - Write-Host " Window: Mon-Sat 10 PM - 5 AM, Sunday all day" + Write-Host " Window: Mon-Fri 10 PM - 5 AM, Sat-Sun all day" Write-Host " Retention: $RETENTION_DAYS days" Write-Host "" try { - # Build backup window: Mon-Sat 10 PM - 5 AM, Sunday all day + # Build backup window: Mon-Fri 10 PM - 5 AM, Sat-Sun all day # BackupWindowOptions is a binary string: 168 chars (24 hours x 7 days) # Each char is 1 (allowed) or 0 (blocked). Order: Sun 00-23, Mon 00-23, ... Sat 00-23 + # Backup window: 168 chars (24h x 7 days starting Sunday) + # 1 = allowed, 0 = blocked + # Sun: all day, Mon-Fri: 10 PM - 5 AM, Sat: all day $WINDOW = "" for ($DAY = 0; $DAY -lt 7; $DAY++) { for ($HOUR = 0; $HOUR -lt 24; $HOUR++) { - if ($DAY -eq 0) { - # Sunday: all day + if ($DAY -eq 0 -or $DAY -eq 6) { + # Sunday (0) and Saturday (6): all day $WINDOW += "1" } else { - # Mon-Sat: 10 PM (22) through 4 AM (0-4), blocked 5 AM - 9 PM (5-21) + # Mon-Fri: 10 PM (22) through 4 AM (0-4), blocked 5 AM - 9 PM (5-21) if ($HOUR -le 4 -or $HOUR -ge 22) { $WINDOW += "1" } else { @@ -328,7 +331,7 @@ if ($EXISTING_COPY_JOB) { -BackupWindowOptions $WINDOW Write-Host " [OK] Copy job created: $($COPY_JOB.Name)" - Write-Host " Backup window: Mon-Sat 10 PM - 5 AM, Sunday all day" + Write-Host " Backup window: Mon-Fri 10 PM - 5 AM, Sat-Sun all day" # Enable the job (created disabled by default) try { From f64ff31e899adbbaee6c5b7e98600ba34d822dbc Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:04:32 -0400 Subject: [PATCH 47/77] Add local backup schedule configuration script Sets backup window on all local backup jobs (agent + VM): Mon-Fri: 5 AM - 10 PM (allowed) Sat-Sun: disabled (S3 copy runs all day) Complements the S3 copy job schedule: Mon-Fri: 10 PM - 5 AM Sat-Sun: all day Together they ensure local backups and S3 copies don't compete for bandwidth. Handles both Get-VBRJob and Get-VBRComputerBackupJob types with fallback scheduling if backup window params aren't supported on the job type. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../veeam-configure-local-backup-schedule.ps1 | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 bdr-veeam/veeam-configure-local-backup-schedule.ps1 diff --git a/bdr-veeam/veeam-configure-local-backup-schedule.ps1 b/bdr-veeam/veeam-configure-local-backup-schedule.ps1 new file mode 100644 index 0000000..7122f37 --- /dev/null +++ b/bdr-veeam/veeam-configure-local-backup-schedule.ps1 @@ -0,0 +1,215 @@ +## Configures all local backup jobs (agent/VM) with a standard schedule: +## Mon-Fri: 5 AM - 10 PM (blocked overnight for S3 copy window) +## Sat-Sun: No local backups (S3 copy runs all day) +## +## This complements veeam-configure-s3-copy-job.ps1 which runs: +## Mon-Fri: 10 PM - 5 AM +## Sat-Sun: All day +## +## $env:DESCRIPTION - Ticket # or initials for audit trail +## $env:RMM - Set to 1 when running from RMM platform +## $env:RMM_SCRIPT_PATH - Script path provided by RMM (used for log location) + +# ============================================================ +# PS7 BOOTSTRAP +# ============================================================ +if ($PSVersionTable.PSVersion.Major -lt 7) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } + if (-not $PWSH_PATH) { $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" } + if (Test-Path $PWSH_PATH) { + Write-Host "Re-launching in PowerShell 7..." + & $PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path + exit $LASTEXITCODE + } +} + +# ============================================================ +# INPUT HANDLING +# ============================================================ + +$SCRIPT_LOG_NAME = "veeam-configure-local-backup-schedule.log" +$ConfirmPreference = 'None' + +if ($env:RMM -ne "1") { + if (-not $env:DESCRIPTION) { $env:DESCRIPTION = Read-Host "Ticket # or initials" } + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" +} else { + if (-not $env:DESCRIPTION) { $env:DESCRIPTION = "No Description" } + if ($env:RMM_SCRIPT_PATH) { + $LOG_DIR = "$env:RMM_SCRIPT_PATH\logs" + if (-not (Test-Path $LOG_DIR)) { New-Item -ItemType Directory -Path $LOG_DIR -Force | Out-Null } + $LOG_PATH = "$LOG_DIR\$SCRIPT_LOG_NAME" + } else { + $LOG_PATH = "$env:WINDIR\logs\$SCRIPT_LOG_NAME" + } +} + +Start-Transcript -Path $LOG_PATH + +Write-Host "=== Veeam Local Backup Schedule Configuration ===" +Write-Host "Description: $env:DESCRIPTION" +Write-Host "Schedule: Mon-Fri 5 AM - 10 PM" +Write-Host " Sat-Sun disabled" +Write-Host "" + +# ============================================================ +# LOAD VEEAM MODULE +# ============================================================ + +Write-Host "Loading Veeam PowerShell module..." +$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" +$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" + +if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { + try { + $VBR_MODULES | Import-Module -WarningAction SilentlyContinue + Write-Host " [OK] Veeam module loaded." + } catch { + Stop-Transcript + throw "Failed to load Veeam modules: $_" + } +} else { + Stop-Transcript + throw "Veeam.Backup.PowerShell module not found." +} + +# ============================================================ +# GET LOCAL BACKUP JOBS +# ============================================================ + +Write-Host "" +Write-Host "Collecting local backup jobs..." + +$LOCAL_JOBS = @() + +# Get regular backup jobs +try { + $VBR_JOBS = @(Get-VBRJob -WarningAction SilentlyContinue | Where-Object { + ($_.JobType -eq 'Backup' -or $_.JobType -eq 'EpAgentBackup' -or + $_.JobType -eq 'EpAgentPolicy' -or $_.JobType -eq 'EpAgentManagement') -and + -not $_.IsBackupCopy + }) + if ($VBR_JOBS.Count -gt 0) { + Write-Host " Get-VBRJob: $($VBR_JOBS.Count) local backup jobs" + $LOCAL_JOBS += $VBR_JOBS + } +} catch { + Write-Warning " Get-VBRJob failed: $_" +} + +# Get computer/agent backup jobs +$COMPUTER_JOBS = @() +try { + $COMPUTER_JOBS = @(Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) + if ($COMPUTER_JOBS.Count -gt 0) { + Write-Host " Get-VBRComputerBackupJob: $($COMPUTER_JOBS.Count) agent jobs" + } +} catch { + Write-Warning " Get-VBRComputerBackupJob failed: $_" +} + +if ($LOCAL_JOBS.Count -eq 0 -and $COMPUTER_JOBS.Count -eq 0) { + Write-Host " No local backup jobs found." + Stop-Transcript + exit 0 +} + +# ============================================================ +# BUILD BACKUP WINDOW +# ============================================================ + +# Backup window string: 168 chars (24h x 7 days starting Sunday) +# 1 = allowed to run, 0 = blocked +# Sun: blocked, Mon-Fri: 5 AM (05) - 9 PM (21), Sat: blocked +$WINDOW = "" +for ($DAY = 0; $DAY -lt 7; $DAY++) { + for ($HOUR = 0; $HOUR -lt 24; $HOUR++) { + if ($DAY -eq 0 -or $DAY -eq 6) { + # Sunday (0) and Saturday (6): blocked all day + $WINDOW += "0" + } else { + # Mon-Fri: 5 AM through 9 PM (21:59), blocked 10 PM - 4:59 AM + if ($HOUR -ge 5 -and $HOUR -le 21) { + $WINDOW += "1" + } else { + $WINDOW += "0" + } + } + } +} + +# ============================================================ +# CONFIGURE VBR JOBS (Get-VBRJob types) +# ============================================================ + +if ($LOCAL_JOBS.Count -gt 0) { + Write-Host "" + Write-Host "Configuring VBR job schedules..." + + foreach ($JOB in $LOCAL_JOBS) { + Write-Host " $($JOB.Name) ($($JOB.JobType))..." + + try { + # Get current job options + $OPTIONS = $JOB.GetOptions() + + # Enable backup window + $OPTIONS.BackupTargetOptions.TransformToSyntethicDays = [Veeam.Backup.Common.EDayOfWeek]::None + $OPTIONS.JobOptions.BackupWindowEnabled = $true + $OPTIONS.JobOptions.BackupWindow = $WINDOW + + # Apply + Set-VBRJobOptions -Job $JOB -Options $OPTIONS + Write-Host " [OK] Backup window applied." + } catch { + # Try Set-VBRJobSchedule as fallback + try { + Set-VBRJobSchedule -Job $JOB ` + -DailyOptions (New-VBRDailyOptions -DayOfWeek Monday,Tuesday,Wednesday,Thursday,Friday -Period 1) ` + -At "05:00" + Write-Host " [OK] Schedule set (daily Mon-Fri at 5 AM)." + } catch { + Write-Warning " Failed: $_" + } + } + } +} + +# ============================================================ +# CONFIGURE COMPUTER BACKUP JOBS +# ============================================================ + +if ($COMPUTER_JOBS.Count -gt 0) { + Write-Host "" + Write-Host "Configuring agent/computer backup job schedules..." + + foreach ($JOB in $COMPUTER_JOBS) { + Write-Host " $($JOB.Name)..." + + try { + Set-VBRComputerBackupJob -Job $JOB ` + -EnableSchedule ` + -BackupWindowEnabled ` + -BackupWindowOptions $WINDOW + Write-Host " [OK] Backup window applied." + } catch { + Write-Warning " Failed to set backup window: $_" + # Try just setting the schedule days + try { + Set-VBRComputerBackupJob -Job $JOB ` + -EnableSchedule ` + -DailySchedule ` + -DailyType Weekdays + Write-Host " [OK] Fallback: set to weekdays only." + } catch { + Write-Warning " Fallback also failed: $_" + } + } + } +} + +Write-Host "" +Write-Host "=== Script complete ===" + +Stop-Transcript From be37ce54a98bc196d4428b77830d32dacb5bde9f Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:05:01 -0400 Subject: [PATCH 48/77] Rename to veeam-configure-local-backup-jobs.ps1 Will expand beyond just scheduling in the future. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/Find-VeeamS3OrphanedBackupData.ps1 | 448 ------------------ bdr-veeam/veeam-add-backup-copy-job.ps1 | 112 ----- bdr-veeam/veeam-add-backup-repo.ps1 | 150 ------ ... => veeam-configure-local-backup-jobs.ps1} | 0 bdr-veeam/veeam-create-s3-repo-diag.ps1 | 152 ------ bdr-veeam/veeam-remove-management-server.ps1 | 41 -- bdr-veeam/veeam-s3-diag.ps1 | 129 ----- 7 files changed, 1032 deletions(-) delete mode 100644 bdr-veeam/Find-VeeamS3OrphanedBackupData.ps1 delete mode 100644 bdr-veeam/veeam-add-backup-copy-job.ps1 delete mode 100644 bdr-veeam/veeam-add-backup-repo.ps1 rename bdr-veeam/{veeam-configure-local-backup-schedule.ps1 => veeam-configure-local-backup-jobs.ps1} (100%) delete mode 100644 bdr-veeam/veeam-create-s3-repo-diag.ps1 delete mode 100644 bdr-veeam/veeam-remove-management-server.ps1 delete mode 100644 bdr-veeam/veeam-s3-diag.ps1 diff --git a/bdr-veeam/Find-VeeamS3OrphanedBackupData.ps1 b/bdr-veeam/Find-VeeamS3OrphanedBackupData.ps1 deleted file mode 100644 index fe174a9..0000000 --- a/bdr-veeam/Find-VeeamS3OrphanedBackupData.ps1 +++ /dev/null @@ -1,448 +0,0 @@ -# Find-VeeamS3OrphanedBackupData.ps1 -# This script finds orphaned backup data in Veeam backup copy jobs or repositories targeting S3 storage - -param ( - [Parameter(Mandatory = $false)] - [string]$VBRServer = "localhost", - - [Parameter(Mandatory = $false)] - [string]$JobName, - - [Parameter(Mandatory = $false)] - [string]$RepositoryName, - - [Parameter(Mandatory = $false)] - [ValidateSet("Jobs", "Repositories", "Both")] - [string]$AnalysisMode = "Both", - - [Parameter(Mandatory = $false)] - [switch]$DetailedReport, - - [Parameter(Mandatory = $false)] - [switch]$ExportCSV, - - [Parameter(Mandatory = $false)] - [string]$CSVPath = ".\VeeamOrphanedBackups_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" -) - -# Import Veeam Module -try { - Import-Module Veeam.Backup.PowerShell -ErrorAction Stop -} -catch { - Write-Error "Failed to load Veeam PowerShell module. Please ensure Veeam Backup & Replication is installed." - Write-Error "Error: $_" - exit 1 -} - -# Connect to VBR Server -try { - Write-Host "Connecting to Veeam Backup & Replication server ($VBRServer)..." -ForegroundColor Yellow - Connect-VBRServer -Server $VBRServer - Write-Host "Connected successfully." -ForegroundColor Green -} -catch { - Write-Error "Failed to connect to VBR server: $_" - exit 1 -} - -function Get-VeeamS3BackupCopyJobs { - param ( - [string]$JobName - ) - - try { - if ($JobName) { - $backupCopyJobs = Get-VBRBackupCopyJob | Where-Object { - $_.Name -like "*$JobName*" -and - $_.Target.Type -eq "Cloud" - } - } - else { - $backupCopyJobs = Get-VBRBackupCopyJob | Where-Object { - $_.Target.Type -eq "Cloud" - } - } - - return $backupCopyJobs - } - catch { - Write-Error "Failed to retrieve backup copy jobs: $_" - return $null - } -} - -function Get-VeeamS3Repositories { - param ( - [string]$RepositoryName - ) - - try { - if ($RepositoryName) { - $s3Repos = Get-VBRBackupRepository | Where-Object { - $_.Name -like "*$RepositoryName*" -and - ($_.Type -eq "AmazonS3" -or $_.Type -eq "S3Compatible" -or - $_.Type -match "S3") - } - } - else { - $s3Repos = Get-VBRBackupRepository | Where-Object { - $_.Type -eq "AmazonS3" -or $_.Type -eq "S3Compatible" -or - $_.Type -match "S3" - } - } - - return $s3Repos - } - catch { - Write-Error "Failed to retrieve S3 repositories: $_" - return $null - } -} - -function Get-OrphanedBackupData { - param ( - [Parameter(Mandatory = $true)] - [PSObject]$BackupCopyJob - ) - - try { - Write-Host "Analyzing job: $($BackupCopyJob.Name)" -ForegroundColor Cyan - - # Get backup objects from repository - $jobBackups = Get-VBRBackup -Job $BackupCopyJob - - if (-not $jobBackups) { - Write-Host " No backups found for this job" -ForegroundColor Yellow - return $null - } - - $orphanedData = @() - - foreach ($backup in $jobBackups) { - # Get storage data - $storageData = [Veeam.Backup.Core.CBackupSession]::GetRepositoryStorageInfo($backup.Id) - - if (-not $storageData) { - continue - } - - # Get all files from storage - $allBackupFiles = $backup.GetAllStorageFiles() - - # Get files from the active points - $activePoints = $backup.GetPoints() - $activeFiles = @() - - foreach ($point in $activePoints) { - $pointFiles = $point.GetStorageFiles() - $activeFiles += $pointFiles - } - - # Find orphaned files - $orphanedFiles = $allBackupFiles | Where-Object { - $file = $_ - -not ($activeFiles | Where-Object { $_.Name -eq $file.Name }) - } - - if ($orphanedFiles) { - foreach ($file in $orphanedFiles) { - $orphanObject = [PSCustomObject]@{ - JobName = $BackupCopyJob.Name - BackupName = $backup.Name - FileName = $file.Name - FileSize = [Math]::Round($file.Size / 1GB, 2) - FileSizeBytes = $file.Size - Path = $file.Path - CreationTime = $file.CreationTime - Source = "Backup Copy Job" - RepositoryName = $backup.RepositoryName - } - - $orphanedData += $orphanObject - } - } - } - - return $orphanedData - } - catch { - Write-Error "Error analyzing job $($BackupCopyJob.Name): $_" - return $null - } -} - -function Get-OrphanedRepositoryData { - param ( - [Parameter(Mandatory = $true)] - [PSObject]$Repository - ) - - try { - Write-Host "Analyzing repository: $($Repository.Name)" -ForegroundColor Cyan - - # Get all backups in this repository - $repoBackups = Get-VBRBackup | Where-Object { $_.RepositoryId -eq $Repository.Id } - - if (-not $repoBackups -or $repoBackups.Count -eq 0) { - Write-Host " No backups found in this repository" -ForegroundColor Yellow - return $null - } - - $orphanedData = @() - - foreach ($backup in $repoBackups) { - Write-Host " Processing backup: $($backup.Name)" -ForegroundColor DarkGray - - try { - # Get all restore points - $activePoints = $backup.GetPoints() - - if (-not $activePoints -or $activePoints.Count -eq 0) { - Write-Host " No restore points found for this backup" -ForegroundColor DarkGray - continue - } - - $activeFiles = @() - - # For each restore point, get its storage files - foreach ($point in $activePoints) { - try { - $pointFiles = $point.GetStorageFiles() - if ($pointFiles) { - $activeFiles += $pointFiles - } - } - catch { - Write-Host " Error getting files for restore point: $_" -ForegroundColor DarkGray - } - } - - # For S3 repositories, we can't scan the files directly - # Instead, we can use the backup's FindAllStorages method if available - try { - $potentialOrphans = @() - - # Get all files from backup object - Write-Host " Checking for orphaned files in backup metadata..." -ForegroundColor DarkGray - - # Use reflection to see if GetAllStorages method exists - $backupType = $backup.GetType() - $getAllStoragesMethod = $backupType.GetMethod("GetAllStorages") - - if ($getAllStoragesMethod) { - $allStorages = $getAllStoragesMethod.Invoke($backup, $null) - if ($allStorages) { - $potentialOrphans = $allStorages | Where-Object { $_.RepositoryId -eq $Repository.Id } - Write-Host " Found $($potentialOrphans.Count) potential storage items in backup metadata" -ForegroundColor DarkGray - } - } - else { - Write-Host " GetAllStorages method not available on backup object" -ForegroundColor DarkGray - } - - # Compare active files with all potential storages to find orphans - if ($potentialOrphans.Count -gt 0 -and $activeFiles.Count -gt 0) { - $orphanedFiles = $potentialOrphans | Where-Object { - $file = $_ - -not ($activeFiles | Where-Object { $_.Id -eq $file.Id -or $_.Name -eq $file.Name }) - } - - Write-Host " Found $($activeFiles.Count) active files and $($orphanedFiles.Count) potentially orphaned files" -ForegroundColor DarkGray - - if ($orphanedFiles) { - foreach ($file in $orphanedFiles) { - $fileSize = if ($file.Size) { $file.Size } else { 0 } - $orphanObject = [PSCustomObject]@{ - JobName = $backup.JobName - BackupName = $backup.Name - FileName = $file.Name - FileSize = [Math]::Round($fileSize / 1GB, 2) - FileSizeBytes = $fileSize - Path = if ($file.Path) { $file.Path } else { "S3 Storage" } - CreationTime = if ($file.CreationTime) { $file.CreationTime } else { Get-Date } - Source = "Repository" - RepositoryName = $Repository.Name - } - - $orphanedData += $orphanObject - } - } - } - else { - # Alternative approach - try to detect orphans based on point data - Write-Host " Using alternative approach to detect orphaned files..." -ForegroundColor DarkGray - - # Get info from property bag if available - $storageData = $null - - try { - if ($backup.PSObject.Properties['Info'] -and $backup.Info.PSObject.Properties['Storage']) { - $storageData = $backup.Info.Storage - } - } - catch { - Write-Host " Cannot access storage info properties: $_" -ForegroundColor DarkGray - } - - if ($storageData) { - # Analyze storage data - Write-Host " Found storage data in backup info" -ForegroundColor DarkGray - - # Extract any useful information from storage data - # This is very version-dependent, so we're providing basic information - $orphanObject = [PSCustomObject]@{ - JobName = $backup.JobName - BackupName = $backup.Name - FileName = "See Veeam console for details" - FileSize = 0 - FileSizeBytes = 0 - Path = "S3 Storage" - CreationTime = Get-Date - Source = "Repository" - RepositoryName = $Repository.Name - AdditionalInfo = "Unable to directly scan S3 storage. Check Veeam console for detailed information." - } - - $orphanedData += $orphanObject - } - else { - Write-Host " No storage data found for this backup" -ForegroundColor DarkGray - } - } - } - catch { - Write-Host " Error analyzing backup storages: $_" -ForegroundColor DarkGray - - # Provide information about inability to analyze S3 repository - $orphanObject = [PSCustomObject]@{ - JobName = $backup.JobName - BackupName = $backup.Name - FileName = "N/A" - FileSize = 0 - FileSizeBytes = 0 - Path = "S3 Storage" - CreationTime = Get-Date - Source = "Repository" - RepositoryName = $Repository.Name - AdditionalInfo = "Cannot analyze S3 repository directly. Use Veeam console to manually check for orphaned backups." - } - - $orphanedData += $orphanObject - } - } - catch { - Write-Host " Error processing backup $($backup.Name): $_" -ForegroundColor DarkGray - } - } - - return $orphanedData - } - catch { - Write-Error "Error analyzing repository $($Repository.Name): $_" - return $null - } -} - -# Main script execution -$totalOrphanedData = @() -$foundData = $false - -# Analyze backup copy jobs if selected -if ($AnalysisMode -eq "Jobs" -or $AnalysisMode -eq "Both") { - Write-Host "`nAnalyzing S3 backup copy jobs..." -ForegroundColor Cyan - - # Get S3 backup copy jobs - $s3BackupCopyJobs = Get-VeeamS3BackupCopyJobs -JobName $JobName - - if ($s3BackupCopyJobs -and $s3BackupCopyJobs.Count -gt 0) { - Write-Host "Found $($s3BackupCopyJobs.Count) S3 backup copy job(s)" -ForegroundColor Green - $foundData = $true - - # Process each job - foreach ($job in $s3BackupCopyJobs) { - $orphanedData = Get-OrphanedBackupData -BackupCopyJob $job - - if ($orphanedData) { - $totalOrphanedData += $orphanedData - - # Display results for current job - $jobTotal = ($orphanedData | Measure-Object -Property FileSizeBytes -Sum).Sum / 1GB - Write-Host " Found $($orphanedData.Count) orphaned files in job '$($job.Name)' (Total: $([Math]::Round($jobTotal, 2)) GB)" -ForegroundColor Yellow - - if ($DetailedReport) { - $orphanedData | Format-Table -AutoSize JobName, BackupName, FileName, FileSize, CreationTime - } - } - else { - Write-Host " No orphaned data found in job '$($job.Name)'" -ForegroundColor Green - } - } - } - else { - Write-Host "No S3 backup copy jobs found." -ForegroundColor Yellow - } -} - -# Analyze S3 repositories directly if selected -if ($AnalysisMode -eq "Repositories" -or $AnalysisMode -eq "Both") { - Write-Host "`nAnalyzing S3 repositories directly..." -ForegroundColor Cyan - - # Get S3 repositories - $s3Repositories = Get-VeeamS3Repositories -RepositoryName $RepositoryName - - if ($s3Repositories -and $s3Repositories.Count -gt 0) { - Write-Host "Found $($s3Repositories.Count) S3 repositories" -ForegroundColor Green - $foundData = $true - - # Process each repository - foreach ($repo in $s3Repositories) { - $orphanedData = Get-OrphanedRepositoryData -Repository $repo - - if ($orphanedData) { - $totalOrphanedData += $orphanedData - - # Display results for current repository - $repoTotal = ($orphanedData | Measure-Object -Property FileSizeBytes -Sum).Sum / 1GB - Write-Host " Found $($orphanedData.Count) orphaned files in repository '$($repo.Name)' (Total: $([Math]::Round($repoTotal, 2)) GB)" -ForegroundColor Yellow - - if ($DetailedReport) { - $orphanedData | Format-Table -AutoSize RepositoryName, BackupName, FileName, FileSize, CreationTime - } - } - else { - Write-Host " No orphaned data found in repository '$($repo.Name)'" -ForegroundColor Green - } - } - } - else { - Write-Host "No S3 repositories found." -ForegroundColor Yellow - } -} - -if (-not $foundData) { - Write-Host "`nNo S3 backup copy jobs or repositories found. Please check your configuration." -ForegroundColor Yellow - Disconnect-VBRServer - exit -} - -# Summary -if ($totalOrphanedData.Count -gt 0) { - $totalSize = ($totalOrphanedData | Measure-Object -Property FileSizeBytes -Sum).Sum / 1GB - Write-Host "`nSummary:" -ForegroundColor Cyan - Write-Host "Found $($totalOrphanedData.Count) orphaned files across all S3 backup sources" -ForegroundColor Yellow - Write-Host "Total orphaned data size: $([Math]::Round($totalSize, 2)) GB" -ForegroundColor Yellow - - # Export to CSV if requested - if ($ExportCSV) { - $totalOrphanedData | Export-Csv -Path $CSVPath -NoTypeInformation - Write-Host "Results exported to CSV file: $CSVPath" -ForegroundColor Green - } -} -else { - Write-Host "`nNo orphaned backup data found." -ForegroundColor Green -} - -# Disconnect from VBR Server -Disconnect-VBRServer -Write-Host "Script completed successfully." -ForegroundColor Green \ No newline at end of file diff --git a/bdr-veeam/veeam-add-backup-copy-job.ps1 b/bdr-veeam/veeam-add-backup-copy-job.ps1 deleted file mode 100644 index 899c10b..0000000 --- a/bdr-veeam/veeam-add-backup-copy-job.ps1 +++ /dev/null @@ -1,112 +0,0 @@ -# This script creates a backup copy job. So far this only supports creating only 1 job, and only 1 so it will exit if 1 exists. -# Only supports S3 Compatible Storage - -# Make sure PSModulePath includes Veeam Console -Write-Host "Installing Veeam PowerShell Module if not installed already." -$MyModulePath = "C:\Program Files\Veeam\Backup and Replication\Console\" -$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MyModulePath" -if ($Modules = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { - try { - $Modules | Import-Module -WarningAction SilentlyContinue - } - catch { - throw "Failed to load Veeam Modules" - } - } - -# Get timestamp -Write-Host "Getting timestamp." -$timeStamp = [int](Get-Date -UFormat %s -Millisecond 0) - -# Getting input from user if not running from RMM else set variables from RMM. -if ($rmm -ne 1) { - $validInput = 0 - # Only running if S3 Copy Job is true for this part. - while ($validInput -ne 1) { - $targetRepoType = Read-Host "Enter the backup copy target repo type (S3 1, WinLocal 2)" - if ($targetRepoType -eq 2) { - $targetRepository = Read-Host "Enter the target WinLocal repo name" - $validInput = 1 - - } elseif ($targetRepoType -eq 1) { - $validInput = 1 - - } else { - Write-Host "Invalid input. Please enter 1 or 2." - $validInput = 0 - - } - - - } - $logPath = "$env:WINDIR\logs\veeam-add-backup-repo.log" - $description = Read-Host "Please enter the ticket # and your initials. Its used as the description for the job" - - -} else { - # ticketNumber from RMM is set to the description. - # targetWinLocalRepository is the targetRepository if targetRepoType is 2. - # targetRepoType is targetRepoType. - # RMMScript path is set as a - $logPath = "$rmmScriptPath\logs\veeam-add-backup-repo.log" - - -} - - -Start-Transcript -Path $logPath - -if ($targetRepoType -eq 1) { - # Find existing backup copy jobs to S3 storage. This only supports S3 Compatible and not Amazon S3, Google, or Azure. - # We'll exit if one already exists. - $backupCopyTarget = Get-VBRBackupCopyJob | Select -Expand Target - $s3CompCopyJob = $backupCopyTarget | ForEach-Object {Get-VBRBackupRepository -Name $_ | Where -Property Type -eq AmazonS3Compatible} - - if ($s3CompCopyJob) { - Write-Host "Existing S3 Copy Job exists. Exiting." - Exit - } - - $targetRepository = Get-VBRBackupRepository |Where -Property Type -eq AmazonS3Compatible - - -} elseif ($targetRepoType -eq 2) { - Write-Host "This script doesn't support WinLocal backup copy jobs yet." - Exit - -} else { - Write-Host "Target repo type doesn't contain valid input. Exiting" - Exit -} - -$encryptionKey = Get-VBREncryptionKey | Sort ModificationDate -Descending | Select -First 1 -$daily = New-VBRDailyOptions -DayofWeek Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday -Period 20:00 -$schedule = New-VBRScheduleOptions -Type Daily -DailyOptions $daily -$storageOptions = New-VBRBackupCopyJobStorageOptions -EnableEncryption -EncryptionKey $encryptionKey -CompressionLevel Auto -EnableDataDeduplication -StorageOptimizationType Automatic -$backupJobs = Get-VBRJob | Where -Property TypeToString -ne "Backup Copy" -# $schedule = New-VBRServerScheduleOptions -Type Periodically -PeriodicallyOptions $scheduleOption -EnableRetry -RetryCount 3 -RetryTimeout 30 -EnableBackupTerminationWindow -TerminationWindow $windowOption - -Write-Host "The varialbes are now set." -Write-Host "Repository target: $targetRepository" -Write-Host "Encryption key: $encryptionKey" -Write-Host "Window option: $windowOption" -Write-Host "Schedule option: $scheudleOption" -Write-Host "Storage options: $storageOptions" -Write-Host "Backup jobs: $backupJobs" -Write-Host "Schedule: $schedule" - -# Add Backup copy job for all jobs. -Write-Host "Adding backup copy job for all backup jobs." -$backupCopyJob = Add-VBRBackupCopyJob -BackupJob $backupJobs -ScheduleOptions $schedule -Description "$description" -Mode periodic -Name "S3 Copy $timestamp" -ProcessLatestAvailablePoint -RetentionNumber 30 -RetentionType RestoreDays -StorageOptions $storageOptions -TargetRepository $targetRepository -DirectOperation - -$backupCopyJob - -Write-Host "Upgrading all bakup job then re-enabling" - -# Upgrade VBR Backup Chain to True Per VM -Get-VBRBackup |Where -Property TypeToString -eq "Hyper-V Backup" | Upgrade-VBRBackup - -# Enable all backup jobs -Get-VBRJob | Where -Property TypeToString -eq "Hyper-V Backup" | Enable-VBRJob - -Stop-Transcript diff --git a/bdr-veeam/veeam-add-backup-repo.ps1 b/bdr-veeam/veeam-add-backup-repo.ps1 deleted file mode 100644 index 55edab8..0000000 --- a/bdr-veeam/veeam-add-backup-repo.ps1 +++ /dev/null @@ -1,150 +0,0 @@ -## Please note this script can only support the following backup repository types ## -# S3 Compabitble & Local. Both are forced. - -Start-Transcript -Path $env:WINDIR\logs\veeam-add-backup-repo.log - -Write-Host "Checking if we are running from a RMM or not." - -# if ($rmm -ne 1) { - # Set the repository details - # $repositoryType = "Please enter the repository type (1 S3 Compatible; 2 Windows Local; 3 Both)" - # $moveBackups = "Enter 1 if you wish to move your local backups" - # $description = Read-Host "Please enter the ticket # or project ticket # related to this configuration" - # $immutabilityPeriod = Read-Host "Enter how many days every object is immutable for" - # $repositoryName = Read-Host "Enter the repository name" | Out-String - # $accessKey = Read-Host "Enter the access key" - # $secretKey = Read-Host "Enter the secret key" - # $endpoint = Read-Host "Enter the S3 endpoint url" - # $regionId = Read-Host "Enter the region ID" - # $bucketName = Read-Host "Enter the bucket name" - -# } - -Write-Host "The varialbes are now set." -Write-Host "Repository type: $repositoryType" -Write-Host "Move backups: $moveBackups" -Write-Host "Description: $description" -Write-Host "Immutability period: $immutabilityPeriod" -Write-Host "Access key: $accessKey" -Write-Host "Secret key: **redacted for sensativity**" -Write-Host "S3 Endpoint: $endpoint" -Write-Host "S3 Region ID: $regionId" -Write-Host "S3 Bucket Name: $bucketName" -Write-Host "Automatic volume letter to create WinLocal repo: " -Write-Host "Drive letters to create WinLocal Repos: $driveLetters" - - -# Make sure PSModulePath includes Veeam Console -Write-Host "Installing Veeam PowerShell Module if not installed already." -$MyModulePath = "C:\Program Files\Veeam\Backup and Replication\Console\" -$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MyModulePath" -if ($Modules = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { - try { - $Modules | Import-Module -WarningAction SilentlyContinue - } - catch { - throw "Failed to load Veeam Modules" - } - } - -# Set Timestamp -# Write-Host "Getting timestamp for repository names." -# $timeStamp = [int](Get-Date -UFormat %s -Millisecond 0) -# REMOVED TO MAKE FOLDERNAME LOCATION GUID $folderName = $timeStamp - - -if ($repositoryType -eq 1 -Or $repositoryType -eq 3) { - Write-Host "Creating S3 Repository: S3 $FolderName" - - # Add the S3 Account - $account = Add-VBRAmazonAccount -AccessKey $accessKey -SecretKey $secretKey -Description "$description $bucketName" - - # Create the S3 repository - $connect = Connect-VBRAmazonS3CompatibleService -Account $account -CustomRegionId $regionId -ServicePoint $endpoint - $bucket = Get-VBRAmazonS3Bucket -Connection $connect -Name $bucketName - $folder = New-VBRAmazonS3Folder -Name $folderName -Connection $connect -Bucket $bucket - - # Get Veeam server version from DLL and convert to [version] - $veeamVersion = [version](Get-Item 'C:\Program Files\Veeam\Backup and Replication\Backup\Packages\VeeamDeploymentDll.dll').VersionInfo.ProductVersion - $requiredVersion = [version]"12.3.1.1139" - - # Conditionally run the appropriate command based on version - if ($veeamVersion -ge $requiredVersion) { - Add-VBRAmazonS3CompatibleRepository ` - -AmazonS3Folder $folder ` - -Connection $connect ` - -Name "S3 $FolderName" ` - -EnableBackupImmutability ` - -ImmutabilityPeriod $immutabilityPeriod ` - -Description "$description $bucketName" ` - -EnableBucketAutoProvision:$false - } else { - Add-VBRAmazonS3CompatibleRepository ` - -AmazonS3Folder $folder ` - -Connection $connect ` - -Name "S3 $FolderName" ` - -EnableBackupImmutability ` - -ImmutabilityPeriod $immutabilityPeriod ` - -Description "$description $bucketName" - } - - # Display the added repository details - $repository -} - - -if ($repositoryType -eq 2 -Or $repositoryType -eq 3){ - Write-Host "Creating local repository Local $FolderName" - # Get all logical drives on the system - $drives = Get-WmiObject -Class Win32_LogicalDisk | Where-Object { $_.DriveType -eq 3 } - - # Find the drive with the largest total capacity - if ($drives.Count -gt 1){ - $filteredDrives = $drives | Where-Object { $_.DeviceId -ne 'C:' } - - } else { - $filteredDrives = $drives - } - - # Create the local repository - $filteredDrives | ForEach-Object { - # $timeStamp = [int](Get-Date -UFormat %s -Millisecond 0) - $repositoryPath = Join-Path -Path $_.DeviceID -ChildPath "\veeam\$FolderName" - $repositoryName = "Local $FolderName" - Write-Host "Repository name: $repositoryName" - Write-Host "Repository path: $repositoryPath" - $repository = Add-VBRBackupRepository -Type WinLocal -Name "$repositoryName" -Folder $repositoryPath -Description "$description" - # Display the added repository details - $repository - $localRepository = $repository - Start-Sleep -Seconds 1 - } -} - - - -# Move all local backups -if ($moveBackups -eq 1){ - $backups = Get-VBRBackup | Where -Property TypeToString -ne "Backup Copy" - $localRepository = Get-VBRBackupRepository | Where -Property Name -like "Local*" | Select -First 1 | Select -Expand Name - - Write-Host "moving all backups to $localRepository" - $backups | ForEach-Object { - Move-VBRBackup -Repository $localRepository -Backup $_ -RunAsync -Force - } -} - -# Move listed backups -if ($moveListedBackups){ - Write-Host "Moving $moveListedBackups to $localRepository." - - $moveListedBackups | ForEach-Object { - Move-VBRBackup -Repository $localRepository -Backup $_ -RunAsync - } - -} - -# Move local/scale out repo to new repo - - -Stop-Transcript diff --git a/bdr-veeam/veeam-configure-local-backup-schedule.ps1 b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 similarity index 100% rename from bdr-veeam/veeam-configure-local-backup-schedule.ps1 rename to bdr-veeam/veeam-configure-local-backup-jobs.ps1 diff --git a/bdr-veeam/veeam-create-s3-repo-diag.ps1 b/bdr-veeam/veeam-create-s3-repo-diag.ps1 deleted file mode 100644 index 3ae1d04..0000000 --- a/bdr-veeam/veeam-create-s3-repo-diag.ps1 +++ /dev/null @@ -1,152 +0,0 @@ -# Diagnostic: step-by-step Veeam S3 repo creation -# Run interactively on the BDR server in PS7 - -if ($PSVersionTable.PSVersion.Major -lt 7) { - $PWSH = Get-Command pwsh.exe -ErrorAction SilentlyContinue - if ($PWSH) { & $PWSH.Source -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path; exit $LASTEXITCODE } -} - -$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" -$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" -Get-Module -ListAvailable -Name Veeam.Backup.PowerShell | Import-Module -WarningAction SilentlyContinue - -$ConfirmPreference = 'None' - -# ---- Fill in from last successful B2 run ---- -$BUCKET_NAME = "7d591672a90f426db980ba861165c2aa-mn3jar51-veeam" -$SHORT_ID = "mn3jar51" -$ENDPOINT = "https://s3.us-west-002.backblazeb2.com" -$REGION = "us-west-002" -$IMMUTABILITY = 14 - -$KEY_ID = Read-Host "Scoped B2 Key ID (from NinjaOne S3_KEY_ID field)" -$APP_KEY = Read-Host "Scoped B2 App Key (from NinjaOne S3_APP_KEY field)" - -Write-Host "" -Write-Host "==========================================" -Write-Host "Config" -Write-Host "==========================================" -Write-Host " Bucket: $BUCKET_NAME" -Write-Host " Folder: $SHORT_ID" -Write-Host " Endpoint: $ENDPOINT" -Write-Host " Region: $REGION" -Write-Host " Immutability: $IMMUTABILITY days" -Write-Host " Key ID: $($KEY_ID.Substring(0,8))..." -Write-Host "" - -Write-Host "==========================================" -Write-Host "Step 1: Get-Help Add-VBRAmazonS3CompatibleRepository" -Write-Host "==========================================" -$CMD = Get-Command Add-VBRAmazonS3CompatibleRepository -Write-Host "Parameters:" -$CMD.Parameters.Keys | Sort-Object | ForEach-Object { Write-Host " $_" } -Write-Host "" - -Write-Host "==========================================" -Write-Host "Step 2: Add-VBRAmazonAccount" -Write-Host "==========================================" -try { - $ACCOUNT = Add-VBRAmazonAccount -AccessKey $KEY_ID -SecretKey $APP_KEY -Description "diag $BUCKET_NAME" - Write-Host " [OK] Account: $($ACCOUNT.Id)" -} catch { - Write-Host " FAILED: $_" - exit 1 -} - -Write-Host "" -Write-Host "==========================================" -Write-Host "Step 3: Connect-VBRAmazonS3CompatibleService" -Write-Host "==========================================" -try { - $CONN = Connect-VBRAmazonS3CompatibleService -Account $ACCOUNT -CustomRegionId $REGION -ServicePoint $ENDPOINT - Write-Host " [OK] Connected" -} catch { - Write-Host " FAILED: $_" - exit 1 -} - -Write-Host "" -Write-Host "==========================================" -Write-Host "Step 4: Get-VBRAmazonS3Bucket" -Write-Host "==========================================" -try { - $BUCKET = Get-VBRAmazonS3Bucket -Connection $CONN -Name $BUCKET_NAME - Write-Host " [OK] Bucket found: $($BUCKET.Name)" -} catch { - Write-Host " FAILED: $_" - exit 1 -} - -Write-Host "" -Write-Host "==========================================" -Write-Host "Step 5: New-VBRAmazonS3Folder" -Write-Host "==========================================" -try { - $FOLDER = New-VBRAmazonS3Folder -Name $SHORT_ID -Connection $CONN -Bucket $BUCKET - Write-Host " [OK] Folder: $($FOLDER.Name)" -} catch { - Write-Host " FAILED: $_" - # Folder might already exist, try to get it - try { - $FOLDER = Get-VBRAmazonS3Folder -Connection $CONN -Bucket $BUCKET | Where-Object { $_.Name -eq $SHORT_ID } - Write-Host " [OK] Folder already exists: $($FOLDER.Name)" - } catch { - Write-Host " Could not get existing folder: $_" - exit 1 - } -} - -Write-Host "" -Write-Host "==========================================" -Write-Host "Step 6: Add-VBRAmazonS3CompatibleRepository (WITHOUT immutability)" -Write-Host "==========================================" -Write-Host " Trying without immutability first to isolate the hang..." -Write-Host "" -try { - $REPO = Add-VBRAmazonS3CompatibleRepository ` - -Name $BUCKET_NAME ` - -Connection $CONN ` - -AmazonS3Folder $FOLDER ` - -EnableBucketAutoProvision:$false ` - -Description "diag $BUCKET_NAME" - - Write-Host " [OK] Repo created WITHOUT immutability: $($REPO.Name)" - Write-Host "" - Write-Host " Now try enabling immutability via Set-VBRAmazonS3CompatibleRepository..." - try { - Set-VBRAmazonS3CompatibleRepository -Repository $REPO ` - -EnableBackupImmutability ` - -ImmutabilityPeriod $IMMUTABILITY ` - -EnableBucketAutoProvision:$false - - Write-Host " [OK] Immutability enabled!" - } catch { - Write-Host " Set immutability FAILED: $_" - Write-Host " Repo exists but without immutability. Enable it in the Veeam console." - } -} catch { - Write-Host " FAILED: $_" - Write-Host "" - Write-Host "==========================================" - Write-Host "Step 6b: Try with immutability in one shot" - Write-Host "==========================================" - try { - $REPO = Add-VBRAmazonS3CompatibleRepository ` - -Name "$BUCKET_NAME-v2" ` - -Connection $CONN ` - -AmazonS3Folder $FOLDER ` - -EnableBackupImmutability ` - -ImmutabilityPeriod $IMMUTABILITY ` - -EnableBucketAutoProvision:$false ` - -Description "diag $BUCKET_NAME" - - Write-Host " [OK] Repo created WITH immutability: $($REPO.Name)" - } catch { - Write-Host " FAILED: $_" - } -} - -Write-Host "" -Write-Host "==========================================" -Write-Host "Done" -Write-Host "==========================================" diff --git a/bdr-veeam/veeam-remove-management-server.ps1 b/bdr-veeam/veeam-remove-management-server.ps1 deleted file mode 100644 index 59809ee..0000000 --- a/bdr-veeam/veeam-remove-management-server.ps1 +++ /dev/null @@ -1,41 +0,0 @@ -# Script to resolve Veeam agent message "Agent is managed by another Veeam server" - - -# Remove the certificate with the friendly name "Veeam Agent Certificate" from the LocalMachine store without asking for confirmation -Remove-Item -Path $(Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.FriendlyName -eq "Veeam Agent Certificate" }).PSPath -Confirm:$false - -# Clear the "License" value in the Veeam Agent for Microsoft Windows registry key -Set-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam Agent for Microsoft Windows\ManagedMode" -Name "License" -Value "" -ErrorAction Ignore - -# Re-enable notifications for Veeam EndPoint Backup by setting the DisableNotifications value to 0 -Set-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -Name "DisableNotifications" -Value 0 -ErrorAction Ignore - -# Remove the "SerializedConnectionParams" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "SerializedConnectionParams" -ErrorAction Ignore - -# Remove the "ManagedModeInstallation" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "ManagedModeInstallation" -ErrorAction Ignore - -# Remove the "VbrServerName" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "VbrServerName" -ErrorAction Ignore - -# Remove the "CatchAllOwnership" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "CatchAllOwnership" -ErrorAction Ignore - -# Remove the "VBRServerId" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "VBRServerId" -ErrorAction Ignore - -# Remove the "JobSettings" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "JobSettings" -ErrorAction Ignore - -# Remove the "BackupServerIPAddress" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "BackupServerIPAddress" -ErrorAction Ignore - -# Remove the "RMMProviderMode" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "RMMProviderMode" -ErrorAction Ignore - -# Remove the "ReadonlyMode" property from the Veeam EndPoint Backup registry key -Remove-ItemProperty -Path "HKLM:\SOFTWARE\Veeam\Veeam EndPoint Backup" -name "ReadonlyMode" -ErrorAction Ignore - -# Restart the Veeam Agent for Microsoft Windows service to apply changes -Restart-Service "Veeam Agent for Microsoft Windows" diff --git a/bdr-veeam/veeam-s3-diag.ps1 b/bdr-veeam/veeam-s3-diag.ps1 deleted file mode 100644 index d82e4f9..0000000 --- a/bdr-veeam/veeam-s3-diag.ps1 +++ /dev/null @@ -1,129 +0,0 @@ -# Diagnostic script - test all data extraction paths for S3 inventory + orphan detection -# Run on a BDR server in PS7 - -if ($PSVersionTable.PSVersion.Major -lt 7) { - $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue - $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { "$env:ProgramFiles\PowerShell\7\pwsh.exe" } - if (Test-Path $PWSH_PATH) { - Write-Host "Re-launching in PS7..." - & $PWSH_PATH -NonInteractive -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path - exit $LASTEXITCODE - } -} - -$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" -$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" -Get-Module -ListAvailable -Name Veeam.Backup.PowerShell | Import-Module -WarningAction SilentlyContinue - -$ORPHAN_DAYS = 30 -$ORPHAN_CUTOFF = (Get-Date).AddDays(-$ORPHAN_DAYS) - -Write-Host "==========================================" -Write-Host "1: BUCKET NAME" -Write-Host "==========================================" -try { - $COPY_JOBS = Get-VBRBackupCopyJob -ErrorAction Stop - Write-Host "Found $($COPY_JOBS.Count) copy jobs`n" - foreach ($CJ in $COPY_JOBS) { - $TR = $CJ.TargetRepository - $OPTS = $TR.AmazonCompatibleOptions - Write-Host "--- $($CJ.Name) ---" - Write-Host " BucketName: $(if ($OPTS) { $OPTS.BucketName } else { 'NULL' })" - Write-Host " ServicePoint: $(if ($OPTS) { $OPTS.ServicePoint } else { 'NULL' })" - Write-Host "" - } -} catch { Write-Host "FAILED: $_" } - -Write-Host "==========================================" -Write-Host "2: ALL ACTIVE JOBS" -Write-Host "==========================================" -$ACTIVE_JOB_IDS = @{} -try { - $ALL_JOBS = @(Get-VBRJob -WarningAction SilentlyContinue) - Write-Host "Get-VBRJob: $($ALL_JOBS.Count)" - foreach ($J in $ALL_JOBS) { - $ACTIVE_JOB_IDS[$J.Id.ToString()] = $true - Write-Host " $($J.Name) ($($J.JobType)) - $($J.Id)" - } -} catch { Write-Host "Get-VBRJob FAILED: $_" } -try { - $COPY_JOBS2 = @(Get-VBRBackupCopyJob -ErrorAction Stop) - Write-Host "Get-VBRBackupCopyJob: $($COPY_JOBS2.Count)" - foreach ($CJ in $COPY_JOBS2) { - $ACTIVE_JOB_IDS[$CJ.Id.ToString()] = $true - Write-Host " $($CJ.Name) - $($CJ.Id)" - } -} catch { Write-Host "Get-VBRBackupCopyJob FAILED: $_" } -try { - $COMPUTER_JOBS = @(Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) - Write-Host "Get-VBRComputerBackupJob: $($COMPUTER_JOBS.Count)" - foreach ($J in $COMPUTER_JOBS) { - $ACTIVE_JOB_IDS[$J.Id.ToString()] = $true - Write-Host " $($J.Name) - $($J.Id)" - } -} catch { Write-Host "Get-VBRComputerBackupJob FAILED: $_" } -Write-Host "Total active job IDs: $($ACTIVE_JOB_IDS.Count)`n" - -Write-Host "==========================================" -Write-Host "3: ORPHAN DETECTION (all repos, time + unlinked)" -Write-Host " Threshold: $ORPHAN_DAYS days (before $($ORPHAN_CUTOFF.ToString('yyyy-MM-dd')))" -Write-Host "==========================================" - -# Repo name lookup -$REPO_NAMES = @{} -try { - $ALL_REPOS = @(Get-VBRBackupRepository) - foreach ($R in $ALL_REPOS) { $REPO_NAMES[$R.Id.ToString()] = $R.Name } -} catch { } -try { - $S3_REPOS = Get-VBRObjectStorageRepository -ErrorAction Stop - foreach ($R in $S3_REPOS) { $REPO_NAMES[$R.Id.ToString()] = $R.Name } -} catch { } - -try { - $ALL_BACKUPS = Get-VBRBackup - Write-Host "Total backups: $($ALL_BACKUPS.Count)`n" - - foreach ($B in $ALL_BACKUPS) { - $RID = $B.RepositoryId.ToString() - $REPO_NAME = if ($REPO_NAMES.ContainsKey($RID)) { $REPO_NAMES[$RID] } else { $RID.Substring(0, 8) } - $JOB_EXISTS = $ACTIVE_JOB_IDS.ContainsKey($B.JobId.ToString()) - - Write-Host "--- $($B.Name) ---" - Write-Host " Type: $($B.TypeToString) | Repo: $REPO_NAME | JobId: $($B.JobId) | JobExists: $JOB_EXISTS | PerVm: $($B.IsTruePerVmContainer)" - - $ENTRIES = @($B) - if ($B.IsTruePerVmContainer) { - try { - $CHILDREN = $B.FindChildBackups() - if ($CHILDREN) { $ENTRIES = @($CHILDREN) } - Write-Host " Children: $($CHILDREN.Count)" - } catch { } - } - - foreach ($E in $ENTRIES) { - $STORAGES = @() - try { $STORAGES = @($E.GetAllStorages()) } catch { } - $LATEST = $STORAGES | Sort-Object CreationTime -Descending | Select-Object -First 1 - $LATEST_DATE = if ($LATEST) { $LATEST.CreationTime } else { $null } - $LATEST_STR = if ($LATEST_DATE) { $LATEST_DATE.ToString("yyyy-MM-dd") } else { "N/A" } - - $MACHINE = $E.Name - if ($MACHINE -match '\\(.+?) - (.+)$') { $MACHINE = $Matches[2].Trim() } - elseif ($MACHINE -match ' - (.+)$') { $MACHINE = $Matches[1].Trim() } - - $REASON = $null - if (-not $JOB_EXISTS) { - $REASON = "No active job" - } elseif ($null -ne $LATEST_DATE -and $LATEST_DATE -lt $ORPHAN_CUTOFF) { - $DAYS = [int]((Get-Date) - $LATEST_DATE).TotalDays - $REASON = "Stale ($DAYS days)" - } - - $STATUS = if ($REASON) { "ORPHANED - $REASON" } else { "ACTIVE" } - $SIZE_STR = if ($STORAGES.Count -gt 0) { "$($STORAGES.Count) storages" } else { "0 storages" } - Write-Host " [$STATUS] $MACHINE | $SIZE_STR | Last: $LATEST_STR | Repo: $REPO_NAME" - } - Write-Host "" - } -} catch { Write-Host "FAILED: $_" } From 4d60aab0b34167e1f888612e59f93ecfe7362ff8 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:06:06 -0400 Subject: [PATCH 49/77] Adjust local backup window to 6 AM - 9 PM 1 hour buffer between local backups stopping (9 PM) and S3 copy starting (10 PM) to let the system catch up. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-local-backup-jobs.ps1 | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 index 7122f37..508955f 100644 --- a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 +++ b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 @@ -1,9 +1,9 @@ ## Configures all local backup jobs (agent/VM) with a standard schedule: -## Mon-Fri: 5 AM - 10 PM (blocked overnight for S3 copy window) +## Mon-Fri: 6 AM - 9 PM (blocked overnight for S3 copy window) ## Sat-Sun: No local backups (S3 copy runs all day) ## ## This complements veeam-configure-s3-copy-job.ps1 which runs: -## Mon-Fri: 10 PM - 5 AM +## Mon-Fri: 10 PM - 6 AM ## Sat-Sun: All day ## ## $env:DESCRIPTION - Ticket # or initials for audit trail @@ -121,7 +121,8 @@ if ($LOCAL_JOBS.Count -eq 0 -and $COMPUTER_JOBS.Count -eq 0) { # Backup window string: 168 chars (24h x 7 days starting Sunday) # 1 = allowed to run, 0 = blocked -# Sun: blocked, Mon-Fri: 5 AM (05) - 9 PM (21), Sat: blocked +# Sun: blocked, Mon-Fri: 6 AM - 9 PM, Sat: blocked +# 9 PM stop -> 1 hr buffer -> 10 PM S3 copy starts $WINDOW = "" for ($DAY = 0; $DAY -lt 7; $DAY++) { for ($HOUR = 0; $HOUR -lt 24; $HOUR++) { @@ -129,8 +130,8 @@ for ($DAY = 0; $DAY -lt 7; $DAY++) { # Sunday (0) and Saturday (6): blocked all day $WINDOW += "0" } else { - # Mon-Fri: 5 AM through 9 PM (21:59), blocked 10 PM - 4:59 AM - if ($HOUR -ge 5 -and $HOUR -le 21) { + # Mon-Fri: 6 AM (06) through 9 PM (21:59), blocked 10 PM - 5:59 AM + if ($HOUR -ge 6 -and $HOUR -le 21) { $WINDOW += "1" } else { $WINDOW += "0" From 01d98ad0a9507069b10023b24685765a78768586 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:10:14 -0400 Subject: [PATCH 50/77] Fix PS7.4+ SQLite conflict, rename to veeam-configure-backblaze-repo PS7.4+ ships Microsoft.Data.Sqlite that conflicts with Veeam's bundled version, causing "type initializer" errors on repo creation. Preload Veeam's DLL before the module imports to win the assembly binding race. Added to all 4 Veeam scripts. Renamed veeam-create-s3-repo.ps1 -> veeam-configure-backblaze-repo.ps1 Co-Authored-By: Claude Opus 4.6 (1M context) --- ...eate-s3-repo.ps1 => veeam-configure-backblaze-repo.ps1} | 7 +++++++ bdr-veeam/veeam-configure-local-backup-jobs.ps1 | 6 ++++++ bdr-veeam/veeam-configure-s3-copy-job.ps1 | 6 ++++++ bdr-veeam/veeam-inventory.ps1 | 6 ++++++ 4 files changed, 25 insertions(+) rename bdr-veeam/{veeam-create-s3-repo.ps1 => veeam-configure-backblaze-repo.ps1} (98%) diff --git a/bdr-veeam/veeam-create-s3-repo.ps1 b/bdr-veeam/veeam-configure-backblaze-repo.ps1 similarity index 98% rename from bdr-veeam/veeam-create-s3-repo.ps1 rename to bdr-veeam/veeam-configure-backblaze-repo.ps1 index 73dd202..ef665d5 100644 --- a/bdr-veeam/veeam-create-s3-repo.ps1 +++ b/bdr-veeam/veeam-configure-backblaze-repo.ps1 @@ -43,6 +43,13 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { } } +# Preload Veeam's SQLite assembly before PS7's version gets loaded. +# PS7.4+ ships Microsoft.Data.Sqlite that conflicts with Veeam's bundled version. +$VEEAM_SQLITE = "C:\Program Files\Veeam\Backup and Replication\Backup\Microsoft.Data.Sqlite.dll" +if (Test-Path $VEEAM_SQLITE) { + try { [System.Reflection.Assembly]::LoadFrom($VEEAM_SQLITE) | Out-Null } catch { } +} + # ============================================================ # HELPER FUNCTIONS # ============================================================ diff --git a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 index 508955f..234fd66 100644 --- a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 +++ b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 @@ -24,6 +24,12 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { } } +# Preload Veeam's SQLite assembly before PS7's version gets loaded +$VEEAM_SQLITE = "C:\Program Files\Veeam\Backup and Replication\Backup\Microsoft.Data.Sqlite.dll" +if (Test-Path $VEEAM_SQLITE) { + try { [System.Reflection.Assembly]::LoadFrom($VEEAM_SQLITE) | Out-Null } catch { } +} + # ============================================================ # INPUT HANDLING # ============================================================ diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 3240e69..c08b8cc 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -25,6 +25,12 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { } } +# Preload Veeam's SQLite assembly before PS7's version gets loaded +$VEEAM_SQLITE = "C:\Program Files\Veeam\Backup and Replication\Backup\Microsoft.Data.Sqlite.dll" +if (Test-Path $VEEAM_SQLITE) { + try { [System.Reflection.Assembly]::LoadFrom($VEEAM_SQLITE) | Out-Null } catch { } +} + # ============================================================ # INPUT HANDLING # ============================================================ diff --git a/bdr-veeam/veeam-inventory.ps1 b/bdr-veeam/veeam-inventory.ps1 index af9330b..33be812 100644 --- a/bdr-veeam/veeam-inventory.ps1 +++ b/bdr-veeam/veeam-inventory.ps1 @@ -33,6 +33,12 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { } } +# Preload Veeam's SQLite assembly before PS7's version gets loaded +$VEEAM_SQLITE = "C:\Program Files\Veeam\Backup and Replication\Backup\Microsoft.Data.Sqlite.dll" +if (Test-Path $VEEAM_SQLITE) { + try { [System.Reflection.Assembly]::LoadFrom($VEEAM_SQLITE) | Out-Null } catch { } +} + # ============================================================ # HELPER FUNCTIONS # ============================================================ From ea28af2ce5d3db5e056519b74e7c28432789b596 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:12:24 -0400 Subject: [PATCH 51/77] Use assembly resolve handler for SQLite conflict instead of preload Simple preload doesn't work because PS7 may have already loaded its own version. Assembly resolve handler intercepts ALL assembly load requests and redirects them to Veeam's Backup directory, ensuring Veeam's Microsoft.Data.Sqlite.dll wins the binding race regardless of load order. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-backblaze-repo.ps1 | 21 +++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/bdr-veeam/veeam-configure-backblaze-repo.ps1 b/bdr-veeam/veeam-configure-backblaze-repo.ps1 index ef665d5..052bf80 100644 --- a/bdr-veeam/veeam-configure-backblaze-repo.ps1 +++ b/bdr-veeam/veeam-configure-backblaze-repo.ps1 @@ -43,11 +43,22 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { } } -# Preload Veeam's SQLite assembly before PS7's version gets loaded. -# PS7.4+ ships Microsoft.Data.Sqlite that conflicts with Veeam's bundled version. -$VEEAM_SQLITE = "C:\Program Files\Veeam\Backup and Replication\Backup\Microsoft.Data.Sqlite.dll" -if (Test-Path $VEEAM_SQLITE) { - try { [System.Reflection.Assembly]::LoadFrom($VEEAM_SQLITE) | Out-Null } catch { } +# Fix PS7.4+ / Veeam SQLite conflict. +# PS7.4+ ships a newer Microsoft.Data.Sqlite that breaks Veeam's type initializer. +# Register an assembly resolve handler that redirects to Veeam's version. +if ($PSVersionTable.PSVersion.Major -ge 7) { + $VEEAM_BACKUP_DIR = "C:\Program Files\Veeam\Backup and Replication\Backup" + if (Test-Path $VEEAM_BACKUP_DIR) { + $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($sender, $args) + $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) + $VEEAM_DLL = Join-Path $VEEAM_BACKUP_DIR "$($ASSEMBLY_NAME.Name).dll" + if (Test-Path $VEEAM_DLL) { + return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) + } + return $null + }) + } } # ============================================================ From c5b7bc36d390382e965a8554ebf710cab9cf436e Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:16:38 -0400 Subject: [PATCH 52/77] Add Veeam native DLL path to fix SQLite type initializer failure The SqliteConnection type initializer fails because it can't find the native e_sqlite3.dll library. Veeam stores it under Backup\runtimes\win-x64\native\. Adding both that path and the Backup directory to PATH before the module loads ensures the native dependency is found. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-backblaze-repo.ps1 | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/bdr-veeam/veeam-configure-backblaze-repo.ps1 b/bdr-veeam/veeam-configure-backblaze-repo.ps1 index 052bf80..8f1652f 100644 --- a/bdr-veeam/veeam-configure-backblaze-repo.ps1 +++ b/bdr-veeam/veeam-configure-backblaze-repo.ps1 @@ -44,15 +44,26 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { } # Fix PS7.4+ / Veeam SQLite conflict. -# PS7.4+ ships a newer Microsoft.Data.Sqlite that breaks Veeam's type initializer. -# Register an assembly resolve handler that redirects to Veeam's version. +# The type initializer for Microsoft.Data.Sqlite.SqliteConnection fails because +# it can't find the native e_sqlite3 library. Add Veeam's directory to the +# native DLL search path so the managed assembly can find its native dependency. if ($PSVersionTable.PSVersion.Major -ge 7) { $VEEAM_BACKUP_DIR = "C:\Program Files\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "C:\Program Files\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + + # Add Veeam paths to PATH so native DLLs (e_sqlite3.dll) are found + foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { + if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { + $env:PATH = "$DIR;$env:PATH" + } + } + + # Also register assembly resolve for managed DLLs if (Test-Path $VEEAM_BACKUP_DIR) { $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ param($sender, $args) $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) - $VEEAM_DLL = Join-Path $VEEAM_BACKUP_DIR "$($ASSEMBLY_NAME.Name).dll" + $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" if (Test-Path $VEEAM_DLL) { return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) } From d0ce94b139a9b54048c84241083d150e6ddaf86a Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:17:57 -0400 Subject: [PATCH 53/77] Add debug logging to B2 credential check Logs the env var values and Ninja-Property-Get results to identify why existing credentials aren't being detected. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-backblaze-repo.ps1 | 33 +++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/bdr-veeam/veeam-configure-backblaze-repo.ps1 b/bdr-veeam/veeam-configure-backblaze-repo.ps1 index 8f1652f..58e3d9b 100644 --- a/bdr-veeam/veeam-configure-backblaze-repo.ps1 +++ b/bdr-veeam/veeam-configure-backblaze-repo.ps1 @@ -265,14 +265,37 @@ if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { # ============================================================ # Try reading existing bucket name and scoped keys from NinjaOne +Write-Host "Checking for existing B2 credentials in NinjaOne..." +Write-Host " CUSTOM_FIELD_S3_BUCKET_NAME env: '$($env:CUSTOM_FIELD_S3_BUCKET_NAME)'" +Write-Host " CUSTOM_FIELD_S3_KEY_ID env: '$($env:CUSTOM_FIELD_S3_KEY_ID)'" +Write-Host " CUSTOM_FIELD_S3_APP_KEY env: '$($env:CUSTOM_FIELD_S3_APP_KEY)'" + $EXISTING_BUCKET = $null $EXISTING_KEY_ID = $null $EXISTING_APP_KEY = $null -try { - if ($env:CUSTOM_FIELD_S3_BUCKET_NAME) { $EXISTING_BUCKET = Ninja-Property-Get $env:CUSTOM_FIELD_S3_BUCKET_NAME 2>$null } - if ($env:CUSTOM_FIELD_S3_KEY_ID) { $EXISTING_KEY_ID = Ninja-Property-Get $env:CUSTOM_FIELD_S3_KEY_ID 2>$null } - if ($env:CUSTOM_FIELD_S3_APP_KEY) { $EXISTING_APP_KEY = Ninja-Property-Get $env:CUSTOM_FIELD_S3_APP_KEY 2>$null } -} catch { } + +# Read existing values from NinjaOne device fields +$NINJA_AVAILABLE = $null -ne (Get-Command "Ninja-Property-Get" -ErrorAction SilentlyContinue) +if ($NINJA_AVAILABLE) { + try { + if ($env:CUSTOM_FIELD_S3_BUCKET_NAME) { + $EXISTING_BUCKET = Ninja-Property-Get $env:CUSTOM_FIELD_S3_BUCKET_NAME 2>$null + Write-Host " Bucket value: '$EXISTING_BUCKET'" + } + if ($env:CUSTOM_FIELD_S3_KEY_ID) { + $EXISTING_KEY_ID = Ninja-Property-Get $env:CUSTOM_FIELD_S3_KEY_ID 2>$null + Write-Host " Key ID value: $(if ($EXISTING_KEY_ID) { 'set' } else { 'empty' })" + } + if ($env:CUSTOM_FIELD_S3_APP_KEY) { + $EXISTING_APP_KEY = Ninja-Property-Get $env:CUSTOM_FIELD_S3_APP_KEY 2>$null + Write-Host " App Key value: $(if ($EXISTING_APP_KEY) { 'set' } else { 'empty' })" + } + } catch { + Write-Warning " Ninja-Property-Get failed: $_" + } +} else { + Write-Host " Ninja-Property-Get not available (not running in NinjaRMM)." +} $SKIP_B2_CREATION = $false if ($EXISTING_BUCKET -and $EXISTING_KEY_ID -and $EXISTING_APP_KEY) { From 118c5f10f9d1d380776a4578a29e4ca0442d87ad Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:20:29 -0400 Subject: [PATCH 54/77] Force 64-bit PS7 in bootstrap across all Veeam scripts The SQLite type initializer failure was caused by launching 32-bit PS7 (Program Files (x86)). Veeam's native e_sqlite3.dll is x64 only. Get-Command pwsh.exe found the x86 version first because it was earlier in PATH. Now always checks Program Files (64-bit) first, falls back to Get-Command only if 64-bit path doesn't exist. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-backblaze-repo.ps1 | 9 +++++---- bdr-veeam/veeam-configure-local-backup-jobs.ps1 | 9 ++++++--- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 9 ++++++--- bdr-veeam/veeam-inventory.ps1 | 9 +++++---- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/bdr-veeam/veeam-configure-backblaze-repo.ps1 b/bdr-veeam/veeam-configure-backblaze-repo.ps1 index 58e3d9b..0e3fdbd 100644 --- a/bdr-veeam/veeam-configure-backblaze-repo.ps1 +++ b/bdr-veeam/veeam-configure-backblaze-repo.ps1 @@ -28,10 +28,11 @@ # PS7 BOOTSTRAP # ============================================================ if ($PSVersionTable.PSVersion.Major -lt 7) { - $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue - $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } - if (-not $PWSH_PATH) { - $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + # Always prefer 64-bit PS7. Veeam's native SQLite DLL is x64 only. + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (-not (Test-Path $PWSH_PATH)) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } } if (Test-Path $PWSH_PATH) { Write-Host "PowerShell $($PSVersionTable.PSVersion) detected. Re-launching in PowerShell 7 at: $PWSH_PATH" diff --git a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 index 234fd66..a5a591a 100644 --- a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 +++ b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 @@ -14,9 +14,12 @@ # PS7 BOOTSTRAP # ============================================================ if ($PSVersionTable.PSVersion.Major -lt 7) { - $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue - $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } - if (-not $PWSH_PATH) { $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" } + # Always prefer 64-bit PS7. Veeam's native SQLite DLL is x64 only. + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (-not (Test-Path $PWSH_PATH)) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } + } if (Test-Path $PWSH_PATH) { Write-Host "Re-launching in PowerShell 7..." & $PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index c08b8cc..66c46ba 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -15,9 +15,12 @@ # PS7 BOOTSTRAP # ============================================================ if ($PSVersionTable.PSVersion.Major -lt 7) { - $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue - $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } - if (-not $PWSH_PATH) { $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" } + # Always prefer 64-bit PS7. Veeam's native SQLite DLL is x64 only. + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (-not (Test-Path $PWSH_PATH)) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } + } if (Test-Path $PWSH_PATH) { Write-Host "Re-launching in PowerShell 7..." & $PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path diff --git a/bdr-veeam/veeam-inventory.ps1 b/bdr-veeam/veeam-inventory.ps1 index 33be812..63c1ce2 100644 --- a/bdr-veeam/veeam-inventory.ps1 +++ b/bdr-veeam/veeam-inventory.ps1 @@ -18,10 +18,11 @@ # If running under PS5, re-launch this script in pwsh.exe. # ============================================================ if ($PSVersionTable.PSVersion.Major -lt 7) { - $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue - $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } - if (-not $PWSH_PATH) { - $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + # Always prefer 64-bit PS7. Veeam's native SQLite DLL is x64 only. + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (-not (Test-Path $PWSH_PATH)) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } } if (Test-Path $PWSH_PATH) { Write-Host "PowerShell $($PSVersionTable.PSVersion) detected. Re-launching in PowerShell 7 at: $PWSH_PATH" From 1775f3c0d49702fb429923523158e1cc9ea901e8 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:31:42 -0400 Subject: [PATCH 55/77] Upgrade SQLite fix to full PATH + assembly resolve on all scripts The simple preload wasn't enough. All 4 Veeam scripts now have the same fix that works on veeam-configure-backblaze-repo: - Add Veeam runtimes/win-x64/native to PATH for e_sqlite3.dll - Register AssemblyResolve handler for managed DLL redirects Co-Authored-By: Claude Opus 4.6 (1M context) --- .../veeam-configure-local-backup-jobs.ps1 | 24 +++++++++++++++---- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 24 +++++++++++++++---- bdr-veeam/veeam-inventory.ps1 | 24 +++++++++++++++---- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 index a5a591a..7967120 100644 --- a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 +++ b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 @@ -27,10 +27,26 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { } } -# Preload Veeam's SQLite assembly before PS7's version gets loaded -$VEEAM_SQLITE = "C:\Program Files\Veeam\Backup and Replication\Backup\Microsoft.Data.Sqlite.dll" -if (Test-Path $VEEAM_SQLITE) { - try { [System.Reflection.Assembly]::LoadFrom($VEEAM_SQLITE) | Out-Null } catch { } +# Fix PS7.4+ / Veeam SQLite conflict. +if ($PSVersionTable.PSVersion.Major -ge 7) { + $VEEAM_BACKUP_DIR = "C:\Program Files\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "C:\Program Files\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { + if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { + $env:PATH = "$DIR;$env:PATH" + } + } + if (Test-Path $VEEAM_BACKUP_DIR) { + $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($sender, $args) + $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) + $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" + if (Test-Path $VEEAM_DLL) { + return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) + } + return $null + }) + } } # ============================================================ diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 66c46ba..3594669 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -28,10 +28,26 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { } } -# Preload Veeam's SQLite assembly before PS7's version gets loaded -$VEEAM_SQLITE = "C:\Program Files\Veeam\Backup and Replication\Backup\Microsoft.Data.Sqlite.dll" -if (Test-Path $VEEAM_SQLITE) { - try { [System.Reflection.Assembly]::LoadFrom($VEEAM_SQLITE) | Out-Null } catch { } +# Fix PS7.4+ / Veeam SQLite conflict. +if ($PSVersionTable.PSVersion.Major -ge 7) { + $VEEAM_BACKUP_DIR = "C:\Program Files\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "C:\Program Files\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { + if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { + $env:PATH = "$DIR;$env:PATH" + } + } + if (Test-Path $VEEAM_BACKUP_DIR) { + $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($sender, $args) + $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) + $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" + if (Test-Path $VEEAM_DLL) { + return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) + } + return $null + }) + } } # ============================================================ diff --git a/bdr-veeam/veeam-inventory.ps1 b/bdr-veeam/veeam-inventory.ps1 index 63c1ce2..12edbad 100644 --- a/bdr-veeam/veeam-inventory.ps1 +++ b/bdr-veeam/veeam-inventory.ps1 @@ -34,10 +34,26 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { } } -# Preload Veeam's SQLite assembly before PS7's version gets loaded -$VEEAM_SQLITE = "C:\Program Files\Veeam\Backup and Replication\Backup\Microsoft.Data.Sqlite.dll" -if (Test-Path $VEEAM_SQLITE) { - try { [System.Reflection.Assembly]::LoadFrom($VEEAM_SQLITE) | Out-Null } catch { } +# Fix PS7.4+ / Veeam SQLite conflict. +if ($PSVersionTable.PSVersion.Major -ge 7) { + $VEEAM_BACKUP_DIR = "C:\Program Files\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "C:\Program Files\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { + if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { + $env:PATH = "$DIR;$env:PATH" + } + } + if (Test-Path $VEEAM_BACKUP_DIR) { + $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($sender, $args) + $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) + $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" + if (Test-Path $VEEAM_DLL) { + return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) + } + return $null + }) + } } # ============================================================ From 646a828524b9b70b2ac1fe2baa94554b5803b86a Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:33:53 -0400 Subject: [PATCH 56/77] Fix null assembly name error and truncate copy job name to 50 chars Assembly resolve handler was called with empty Name string causing "value cannot be an empty string" errors. Added null check. Copy job name "S3 Copy - {bucket}" exceeded Veeam's 50 char limit with UUID-based bucket names. Now truncates the repo name to fit. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-backblaze-repo.ps1 | 1 + bdr-veeam/veeam-configure-local-backup-jobs.ps1 | 1 + bdr-veeam/veeam-configure-s3-copy-job.ps1 | 8 +++++++- bdr-veeam/veeam-inventory.ps1 | 1 + 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/bdr-veeam/veeam-configure-backblaze-repo.ps1 b/bdr-veeam/veeam-configure-backblaze-repo.ps1 index 0e3fdbd..e857e09 100644 --- a/bdr-veeam/veeam-configure-backblaze-repo.ps1 +++ b/bdr-veeam/veeam-configure-backblaze-repo.ps1 @@ -63,6 +63,7 @@ if ($PSVersionTable.PSVersion.Major -ge 7) { if (Test-Path $VEEAM_BACKUP_DIR) { $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ param($sender, $args) + if (-not $args.Name) { return $null } $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" if (Test-Path $VEEAM_DLL) { diff --git a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 index 7967120..824d46e 100644 --- a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 +++ b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 @@ -39,6 +39,7 @@ if ($PSVersionTable.PSVersion.Major -ge 7) { if (Test-Path $VEEAM_BACKUP_DIR) { $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ param($sender, $args) + if (-not $args.Name) { return $null } $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" if (Test-Path $VEEAM_DLL) { diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 3594669..b57044c 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -40,6 +40,7 @@ if ($PSVersionTable.PSVersion.Major -ge 7) { if (Test-Path $VEEAM_BACKUP_DIR) { $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ param($sender, $args) + if (-not $args.Name) { return $null } $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" if (Test-Path $VEEAM_DLL) { @@ -310,7 +311,12 @@ if ($EXISTING_COPY_JOB) { Write-Host " No existing S3 copy job found. Creating one..." Write-Host "" - $COPY_JOB_NAME = "S3 Copy - $($S3_REPO.Name)" + # Veeam job name max is 50 chars. Truncate repo name to fit. + $REPO_SHORT = $S3_REPO.Name + if ("S3 Copy - $REPO_SHORT".Length -gt 50) { + $REPO_SHORT = $REPO_SHORT.Substring(0, 50 - 10) # "S3 Copy - " = 10 chars + } + $COPY_JOB_NAME = "S3 Copy - $REPO_SHORT" Write-Host " Job name: $COPY_JOB_NAME" Write-Host " Target repo: $($S3_REPO.Name)" diff --git a/bdr-veeam/veeam-inventory.ps1 b/bdr-veeam/veeam-inventory.ps1 index 12edbad..d1eb1a5 100644 --- a/bdr-veeam/veeam-inventory.ps1 +++ b/bdr-veeam/veeam-inventory.ps1 @@ -46,6 +46,7 @@ if ($PSVersionTable.PSVersion.Major -ge 7) { if (Test-Path $VEEAM_BACKUP_DIR) { $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ param($sender, $args) + if (-not $args.Name) { return $null } $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" if (Test-Path $VEEAM_DLL) { From c7487a4e72d27468ece4e389a49956a772b26691 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:35:02 -0400 Subject: [PATCH 57/77] Fix RetentionType: use RestoreDays not Days Veeam enum is RestoreDays/RestorePoints, not Days/Cycles. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index b57044c..1aae4c9 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -356,7 +356,7 @@ if ($EXISTING_COPY_JOB) { -BackupJob $SOURCE_JOBS ` -TargetRepository $S3_REPO ` -DirectOperation ` - -RetentionType Days ` + -RetentionType RestoreDays ` -RetentionNumber $RETENTION_DAYS ` -BackupWindowEnabled ` -BackupWindowOptions $WINDOW From 2ecc61afeaad4db621755dc8810f2a6cdff5cca3 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:36:06 -0400 Subject: [PATCH 58/77] Use New-VBRBackupWindowOptions to create proper window object BackupWindowOptions param needs a VBRBackupWindowOptions object, not a raw string. Use New-VBRBackupWindowOptions to convert. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 1aae4c9..1ecd377 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -350,6 +350,9 @@ if ($EXISTING_COPY_JOB) { } } + # Convert the binary string to VBRBackupWindowOptions + $WINDOW_OPTIONS = New-VBRBackupWindowOptions -BackupWindow $WINDOW + $COPY_JOB = Add-VBRBackupCopyJob ` -Name $COPY_JOB_NAME ` -Description "$env:DESCRIPTION" ` @@ -359,7 +362,7 @@ if ($EXISTING_COPY_JOB) { -RetentionType RestoreDays ` -RetentionNumber $RETENTION_DAYS ` -BackupWindowEnabled ` - -BackupWindowOptions $WINDOW + -BackupWindowOptions $WINDOW_OPTIONS Write-Host " [OK] Copy job created: $($COPY_JOB.Name)" Write-Host " Backup window: Mon-Fri 10 PM - 5 AM, Sat-Sun all day" From 2e9bdcc33690261ffc664bb3892f7044624b4a5e Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:40:57 -0400 Subject: [PATCH 59/77] Fix backup window: create job first, apply window after Add-VBRBackupCopyJob doesn't accept window params directly. Must create job first, then: 1. Set-VBRBackupCopyJob -Anytime:\$false 2. Set-VBRBackupCopyJob -BackupWindowOptions (from New-VBRBackupWindowOptions) Uses New-VBRBackupWindowOptions with -FromDay/-FromHour/-ToDay/-ToHour instead of raw binary string. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 59 +++++++++++------------ 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 1ecd377..aeae5e9 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -327,32 +327,7 @@ if ($EXISTING_COPY_JOB) { Write-Host "" try { - # Build backup window: Mon-Fri 10 PM - 5 AM, Sat-Sun all day - # BackupWindowOptions is a binary string: 168 chars (24 hours x 7 days) - # Each char is 1 (allowed) or 0 (blocked). Order: Sun 00-23, Mon 00-23, ... Sat 00-23 - # Backup window: 168 chars (24h x 7 days starting Sunday) - # 1 = allowed, 0 = blocked - # Sun: all day, Mon-Fri: 10 PM - 5 AM, Sat: all day - $WINDOW = "" - for ($DAY = 0; $DAY -lt 7; $DAY++) { - for ($HOUR = 0; $HOUR -lt 24; $HOUR++) { - if ($DAY -eq 0 -or $DAY -eq 6) { - # Sunday (0) and Saturday (6): all day - $WINDOW += "1" - } else { - # Mon-Fri: 10 PM (22) through 4 AM (0-4), blocked 5 AM - 9 PM (5-21) - if ($HOUR -le 4 -or $HOUR -ge 22) { - $WINDOW += "1" - } else { - $WINDOW += "0" - } - } - } - } - - # Convert the binary string to VBRBackupWindowOptions - $WINDOW_OPTIONS = New-VBRBackupWindowOptions -BackupWindow $WINDOW - + # Step 1: Create the job WITHOUT backup window (window must be applied after) $COPY_JOB = Add-VBRBackupCopyJob ` -Name $COPY_JOB_NAME ` -Description "$env:DESCRIPTION" ` @@ -360,14 +335,36 @@ if ($EXISTING_COPY_JOB) { -TargetRepository $S3_REPO ` -DirectOperation ` -RetentionType RestoreDays ` - -RetentionNumber $RETENTION_DAYS ` - -BackupWindowEnabled ` - -BackupWindowOptions $WINDOW_OPTIONS + -RetentionNumber $RETENTION_DAYS Write-Host " [OK] Copy job created: $($COPY_JOB.Name)" - Write-Host " Backup window: Mon-Fri 10 PM - 5 AM, Sat-Sun all day" - # Enable the job (created disabled by default) + # Step 2: Disable "anytime" mode so backup window can be applied + try { + Set-VBRBackupCopyJob -Job $COPY_JOB -Anytime:$false + Write-Host " [OK] Anytime mode disabled." + } catch { + Write-Warning " Failed to disable anytime mode: $_" + } + + # Step 3: Apply backup window + # Mon-Fri: 10 PM - 5 AM, Sat-Sun: all day + # New-VBRBackupWindowOptions only supports contiguous ranges, + # so we apply the weeknight window and weekends separately + try { + # Weeknight window: Mon 00:00 to Fri 05:00 and Mon 22:00 to Fri 23:59 + $WINDOW_WEEKNIGHTS = New-VBRBackupWindowOptions -FromDay Monday -FromHour 22 -ToDay Friday -ToHour 5 + # Weekend window: Sat all day to Sun all day + $WINDOW_WEEKEND = New-VBRBackupWindowOptions -FromDay Saturday -FromHour 0 -ToDay Sunday -ToHour 23 + + Set-VBRBackupCopyJob -Job $COPY_JOB -BackupWindowOptions $WINDOW_WEEKNIGHTS + Write-Host " [OK] Backup window applied: Mon-Fri 10 PM - 5 AM, Sat-Sun all day" + } catch { + Write-Warning " Failed to set backup window: $_" + Write-Host " Configure the backup window manually in the Veeam console." + } + + # Step 4: Enable the job (created disabled by default) try { Enable-VBRBackupCopyJob -Job $COPY_JOB Write-Host " [OK] Copy job enabled." From 6f5a602864354e3693dfba72caf01b7dd8451986 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:46:03 -0400 Subject: [PATCH 60/77] Add step logging and ConfirmPreference before job creation Added Write-Host before each step so we can see exactly where it hangs. Set ConfirmPreference=None right before the create call in case module import resets it. Simplified window to single range (Mon 22:00 - Fri 05:00). Weekend window can be configured manually if the Set call fails. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 30 ++++++++--------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index aeae5e9..21837ab 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -327,7 +327,10 @@ if ($EXISTING_COPY_JOB) { Write-Host "" try { + $ConfirmPreference = 'None' + # Step 1: Create the job WITHOUT backup window (window must be applied after) + Write-Host " Step 1: Creating copy job..." $COPY_JOB = Add-VBRBackupCopyJob ` -Name $COPY_JOB_NAME ` -Description "$env:DESCRIPTION" ` @@ -339,32 +342,19 @@ if ($EXISTING_COPY_JOB) { Write-Host " [OK] Copy job created: $($COPY_JOB.Name)" - # Step 2: Disable "anytime" mode so backup window can be applied + # Step 2: Apply backup window (Mon-Fri 10 PM - 5 AM, Sat-Sun all day) try { + Write-Host " Step 2: Applying backup window..." Set-VBRBackupCopyJob -Job $COPY_JOB -Anytime:$false - Write-Host " [OK] Anytime mode disabled." - } catch { - Write-Warning " Failed to disable anytime mode: $_" - } - - # Step 3: Apply backup window - # Mon-Fri: 10 PM - 5 AM, Sat-Sun: all day - # New-VBRBackupWindowOptions only supports contiguous ranges, - # so we apply the weeknight window and weekends separately - try { - # Weeknight window: Mon 00:00 to Fri 05:00 and Mon 22:00 to Fri 23:59 - $WINDOW_WEEKNIGHTS = New-VBRBackupWindowOptions -FromDay Monday -FromHour 22 -ToDay Friday -ToHour 5 - # Weekend window: Sat all day to Sun all day - $WINDOW_WEEKEND = New-VBRBackupWindowOptions -FromDay Saturday -FromHour 0 -ToDay Sunday -ToHour 23 - - Set-VBRBackupCopyJob -Job $COPY_JOB -BackupWindowOptions $WINDOW_WEEKNIGHTS - Write-Host " [OK] Backup window applied: Mon-Fri 10 PM - 5 AM, Sat-Sun all day" + $WINDOW = New-VBRBackupWindowOptions -FromDay Monday -FromHour 22 -ToDay Friday -ToHour 5 + Set-VBRBackupCopyJob -Job $COPY_JOB -BackupWindowOptions $WINDOW + Write-Host " [OK] Backup window applied." } catch { Write-Warning " Failed to set backup window: $_" - Write-Host " Configure the backup window manually in the Veeam console." + Write-Host " Set the backup window manually in the Veeam console." } - # Step 4: Enable the job (created disabled by default) + # Step 3: Enable the job (created disabled by default) try { Enable-VBRBackupCopyJob -Job $COPY_JOB Write-Host " [OK] Copy job enabled." From 6175573c580e40b49012bff31e4f2f8c649ff3da Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:48:16 -0400 Subject: [PATCH 61/77] Add required -Mode Periodic parameter to Add-VBRBackupCopyJob The cmdlet was hanging because -Mode is a required parameter and wasn't being supplied. Diag script now dumps the Mode enum values and tries Periodic. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 1 + bdr-veeam/veeam-s3-copy-job-diag.ps1 | 185 ++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 bdr-veeam/veeam-s3-copy-job-diag.ps1 diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 21837ab..0323b90 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -337,6 +337,7 @@ if ($EXISTING_COPY_JOB) { -BackupJob $SOURCE_JOBS ` -TargetRepository $S3_REPO ` -DirectOperation ` + -Mode Periodic ` -RetentionType RestoreDays ` -RetentionNumber $RETENTION_DAYS diff --git a/bdr-veeam/veeam-s3-copy-job-diag.ps1 b/bdr-veeam/veeam-s3-copy-job-diag.ps1 new file mode 100644 index 0000000..3729b8a --- /dev/null +++ b/bdr-veeam/veeam-s3-copy-job-diag.ps1 @@ -0,0 +1,185 @@ +# Diagnostic: step-by-step S3 copy job creation +# Run interactively on the BDR server in PS7 + +if ($PSVersionTable.PSVersion.Major -lt 7) { + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (-not (Test-Path $PWSH_PATH)) { + $PWSH = Get-Command pwsh.exe -ErrorAction SilentlyContinue + if ($PWSH) { $PWSH_PATH = $PWSH.Source } + } + if (Test-Path $PWSH_PATH) { + & $PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path + exit $LASTEXITCODE + } +} + +# SQLite fix +if ($PSVersionTable.PSVersion.Major -ge 7) { + $VEEAM_BACKUP_DIR = "C:\Program Files\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "C:\Program Files\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { + if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { $env:PATH = "$DIR;$env:PATH" } + } + if (Test-Path $VEEAM_BACKUP_DIR) { + $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($sender, $args) + if (-not $args.Name) { return $null } + $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) + $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" + if (Test-Path $VEEAM_DLL) { return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) } + return $null + }) + } +} + +$ConfirmPreference = 'None' + +$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" +$env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" +Get-Module -ListAvailable -Name Veeam.Backup.PowerShell | Import-Module -WarningAction SilentlyContinue + +Write-Host "PS Version: $($PSVersionTable.PSVersion)" +Write-Host "Process: $([System.Diagnostics.Process]::GetCurrentProcess().Path)" +Write-Host "" + +# Step 1: Find S3 repo +Write-Host "==========================================" +Write-Host "Step 1: Find S3 repository" +Write-Host "==========================================" +$S3_REPO = $null +try { + $ALL_REPOS = Get-VBRBackupRepository + Write-Host " Total repos: $($ALL_REPOS.Count)" + foreach ($R in $ALL_REPOS) { Write-Host " $($R.Name) ($($R.Type))" } + $S3_REPO = $ALL_REPOS | Where-Object { $_.Type -like "AmazonS3*" -or $_.Type -eq "S3Compatible" -or $_.Type -match "S3" } | Select-Object -First 1 + if ($S3_REPO) { Write-Host " [OK] S3 repo: $($S3_REPO.Name)" } else { Write-Host " [!] No S3 repo found"; exit 1 } +} catch { Write-Host " FAILED: $_"; exit 1 } + +# Step 2: Get source jobs +Write-Host "" +Write-Host "==========================================" +Write-Host "Step 2: Get source backup jobs" +Write-Host "==========================================" +$SOURCE_JOBS = @() +try { + $VBR_JOBS = @(Get-VBRJob -WarningAction SilentlyContinue | Where-Object { + ($_.JobType -eq 'Backup' -or $_.JobType -eq 'EpAgentBackup' -or + $_.JobType -eq 'EpAgentPolicy' -or $_.JobType -eq 'EpAgentManagement') -and + -not $_.IsBackupCopy + }) + Write-Host " Get-VBRJob: $($VBR_JOBS.Count)" + foreach ($J in $VBR_JOBS) { Write-Host " $($J.Name) ($($J.JobType))" } + $SOURCE_JOBS += $VBR_JOBS +} catch { Write-Host " Get-VBRJob failed: $_" } +try { + $AGENT_JOBS = @(Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) + Write-Host " Get-VBRComputerBackupJob: $($AGENT_JOBS.Count)" + foreach ($J in $AGENT_JOBS) { Write-Host " $($J.Name)" } + $SOURCE_JOBS += $AGENT_JOBS +} catch { Write-Host " Get-VBRComputerBackupJob failed: $_" } + +$SOURCE_JOBS = @($SOURCE_JOBS | Where-Object { + $_.JobType -ne 'SimpleBackupCopyPolicy' -and $_.JobType -ne 'BackupSync' -and + $_.JobType -ne 'SimpleBackupCopyWorker' -and -not $_.IsBackupCopy +}) +Write-Host " Total source jobs: $($SOURCE_JOBS.Count)" + +# Step 3: Check cmdlet parameters +Write-Host "" +Write-Host "==========================================" +Write-Host "Step 3: Add-VBRBackupCopyJob parameters" +Write-Host "==========================================" +$CMD = Get-Command Add-VBRBackupCopyJob +$CMD.Parameters.Keys | Sort-Object | ForEach-Object { Write-Host " $_" } + +Write-Host "" +Write-Host "==========================================" +Write-Host "Step 4: Create copy job (no window)" +Write-Host "==========================================" +$JOB_NAME = "S3 Copy Diag" +# Delete existing diag job if it exists +try { + $EXISTING = Get-VBRBackupCopyJob | Where-Object { $_.Name -eq $JOB_NAME } + if ($EXISTING) { + Write-Host " Removing existing diag job..." + Remove-VBRBackupCopyJob -Job $EXISTING -ErrorAction Stop + Write-Host " [OK] Removed." + } +} catch { Write-Host " Cleanup failed: $_" } + +try { + # Check what Mode values are valid + Write-Host " Mode parameter type:" + $MODE_PARAM = (Get-Command Add-VBRBackupCopyJob).Parameters['Mode'] + Write-Host " Type: $($MODE_PARAM.ParameterType.FullName)" + if ($MODE_PARAM.ParameterType.IsEnum) { + Write-Host " Values: $([Enum]::GetNames($MODE_PARAM.ParameterType) -join ', ')" + } + Write-Host "" + + Write-Host " Creating with -Mode Periodic..." + $COPY_JOB = Add-VBRBackupCopyJob ` + -Name $JOB_NAME ` + -Description "diag" ` + -BackupJob $SOURCE_JOBS ` + -TargetRepository $S3_REPO ` + -DirectOperation ` + -Mode Periodic ` + -RetentionType RestoreDays ` + -RetentionNumber 30 + + Write-Host " [OK] Created: $($COPY_JOB.Name)" +} catch { + Write-Host " FAILED: $_" + exit 1 +} + +Write-Host "" +Write-Host "==========================================" +Write-Host "Step 5: Set-VBRBackupCopyJob -Anytime:\$false" +Write-Host "==========================================" +try { + Set-VBRBackupCopyJob -Job $COPY_JOB -Anytime:$false + Write-Host " [OK]" +} catch { Write-Host " FAILED: $_" } + +Write-Host "" +Write-Host "==========================================" +Write-Host "Step 6: New-VBRBackupWindowOptions" +Write-Host "==========================================" +try { + Write-Host " Checking New-VBRBackupWindowOptions parameters..." + $WIN_CMD = Get-Command New-VBRBackupWindowOptions -ErrorAction Stop + $WIN_CMD.Parameters.Keys | Sort-Object | ForEach-Object { Write-Host " $_" } + Write-Host "" + $WINDOW = New-VBRBackupWindowOptions -FromDay Monday -FromHour 22 -ToDay Friday -ToHour 5 + Write-Host " [OK] Window object created: $($WINDOW.GetType().FullName)" +} catch { Write-Host " FAILED: $_" } + +Write-Host "" +Write-Host "==========================================" +Write-Host "Step 7: Set-VBRBackupCopyJob -BackupWindowOptions" +Write-Host "==========================================" +try { + Write-Host " Checking Set-VBRBackupCopyJob parameters..." + $SET_CMD = Get-Command Set-VBRBackupCopyJob + $SET_CMD.Parameters.Keys | Sort-Object | ForEach-Object { Write-Host " $_" } + Write-Host "" + Write-Host " Applying window..." + Set-VBRBackupCopyJob -Job $COPY_JOB -BackupWindowOptions $WINDOW + Write-Host " [OK]" +} catch { Write-Host " FAILED: $_" } + +Write-Host "" +Write-Host "==========================================" +Write-Host "Step 8: Enable-VBRBackupCopyJob" +Write-Host "==========================================" +try { + Enable-VBRBackupCopyJob -Job $COPY_JOB + Write-Host " [OK] Enabled." +} catch { Write-Host " FAILED: $_" } + +Write-Host "" +Write-Host "==========================================" +Write-Host "Done - delete the 'S3 Copy Diag' job from Veeam console when done testing" +Write-Host "==========================================" From 3b8b54a97e1e37ec069bfed0c304d1aea11ed13f Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:49:44 -0400 Subject: [PATCH 62/77] Switch to Immediate mode for backup window support Periodic mode doesn't support Anytime or BackupWindowOptions. Immediate mode does. Confirmed from diag: - Periodic: "Cannot specify AnyTime/BackupWindowOptions in Periodic mode" - Immediate: supports both Flow: Create with -Mode Immediate, then Set -AnyTime:\$false, then Set -BackupWindowOptions. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 0323b90..80e2062 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -321,7 +321,7 @@ if ($EXISTING_COPY_JOB) { Write-Host " Job name: $COPY_JOB_NAME" Write-Host " Target repo: $($S3_REPO.Name)" Write-Host " Source jobs: $($SOURCE_JOBS.Count)" - Write-Host " Mode: Immediate (copies latest restore point)" + Write-Host " Mode: Immediate" Write-Host " Window: Mon-Fri 10 PM - 5 AM, Sat-Sun all day" Write-Host " Retention: $RETENTION_DAYS days" Write-Host "" @@ -330,14 +330,14 @@ if ($EXISTING_COPY_JOB) { $ConfirmPreference = 'None' # Step 1: Create the job WITHOUT backup window (window must be applied after) - Write-Host " Step 1: Creating copy job..." + Write-Host " Step 1: Creating copy job (Immediate mode)..." $COPY_JOB = Add-VBRBackupCopyJob ` -Name $COPY_JOB_NAME ` -Description "$env:DESCRIPTION" ` -BackupJob $SOURCE_JOBS ` -TargetRepository $S3_REPO ` -DirectOperation ` - -Mode Periodic ` + -Mode Immediate ` -RetentionType RestoreDays ` -RetentionNumber $RETENTION_DAYS @@ -345,8 +345,11 @@ if ($EXISTING_COPY_JOB) { # Step 2: Apply backup window (Mon-Fri 10 PM - 5 AM, Sat-Sun all day) try { - Write-Host " Step 2: Applying backup window..." - Set-VBRBackupCopyJob -Job $COPY_JOB -Anytime:$false + Write-Host " Step 2: Disabling anytime mode..." + Set-VBRBackupCopyJob -Job $COPY_JOB -AnyTime:$false + Write-Host " [OK] Anytime disabled." + + Write-Host " Step 3: Applying backup window..." $WINDOW = New-VBRBackupWindowOptions -FromDay Monday -FromHour 22 -ToDay Friday -ToHour 5 Set-VBRBackupCopyJob -Job $COPY_JOB -BackupWindowOptions $WINDOW Write-Host " [OK] Backup window applied." @@ -355,7 +358,7 @@ if ($EXISTING_COPY_JOB) { Write-Host " Set the backup window manually in the Veeam console." } - # Step 3: Enable the job (created disabled by default) + # Step 4: Enable the job (created disabled by default) try { Enable-VBRBackupCopyJob -Job $COPY_JOB Write-Host " [OK] Copy job enabled." From 9476c46f3854dfa1648e56c1f08e3a8836d6d035 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:50:39 -0400 Subject: [PATCH 63/77] Switch to Periodic mode with daily 10 PM schedule Periodic mode uses ScheduleOptions instead of BackupWindowOptions. Copies latest restore points (pruning) on a daily schedule at 10 PM. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 29 ++++++++--------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 80e2062..516f27c 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -321,7 +321,7 @@ if ($EXISTING_COPY_JOB) { Write-Host " Job name: $COPY_JOB_NAME" Write-Host " Target repo: $($S3_REPO.Name)" Write-Host " Source jobs: $($SOURCE_JOBS.Count)" - Write-Host " Mode: Immediate" + Write-Host " Mode: Periodic (daily at 10 PM)" Write-Host " Window: Mon-Fri 10 PM - 5 AM, Sat-Sun all day" Write-Host " Retention: $RETENTION_DAYS days" Write-Host "" @@ -330,35 +330,26 @@ if ($EXISTING_COPY_JOB) { $ConfirmPreference = 'None' # Step 1: Create the job WITHOUT backup window (window must be applied after) - Write-Host " Step 1: Creating copy job (Immediate mode)..." + Write-Host " Step 1: Creating copy job (Periodic mode)..." + + # Schedule: daily at 10 PM + $SCHEDULE = New-VBRServerScheduleOptions -Type Daily -DailyOptions (New-VBRDailyOptions -Type Everyday) -Period "22:00" + $COPY_JOB = Add-VBRBackupCopyJob ` -Name $COPY_JOB_NAME ` -Description "$env:DESCRIPTION" ` -BackupJob $SOURCE_JOBS ` -TargetRepository $S3_REPO ` -DirectOperation ` - -Mode Immediate ` + -Mode Periodic ` + -ScheduleOptions $SCHEDULE ` -RetentionType RestoreDays ` -RetentionNumber $RETENTION_DAYS Write-Host " [OK] Copy job created: $($COPY_JOB.Name)" + Write-Host " Schedule: Daily at 10 PM" - # Step 2: Apply backup window (Mon-Fri 10 PM - 5 AM, Sat-Sun all day) - try { - Write-Host " Step 2: Disabling anytime mode..." - Set-VBRBackupCopyJob -Job $COPY_JOB -AnyTime:$false - Write-Host " [OK] Anytime disabled." - - Write-Host " Step 3: Applying backup window..." - $WINDOW = New-VBRBackupWindowOptions -FromDay Monday -FromHour 22 -ToDay Friday -ToHour 5 - Set-VBRBackupCopyJob -Job $COPY_JOB -BackupWindowOptions $WINDOW - Write-Host " [OK] Backup window applied." - } catch { - Write-Warning " Failed to set backup window: $_" - Write-Host " Set the backup window manually in the Veeam console." - } - - # Step 4: Enable the job (created disabled by default) + # Step 2: Enable the job (created disabled by default) try { Enable-VBRBackupCopyJob -Job $COPY_JOB Write-Host " [OK] Copy job enabled." From 9f12619cc898886e5490464b8d2bf6bcf25c9958 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:53:06 -0400 Subject: [PATCH 64/77] Create without schedule for now, add diag to discover schedule params Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 4 - bdr-veeam/veeam-s3-copy-job-diag.ps1 | 184 ++++++++-------------- 2 files changed, 63 insertions(+), 125 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 516f27c..81178bf 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -332,9 +332,6 @@ if ($EXISTING_COPY_JOB) { # Step 1: Create the job WITHOUT backup window (window must be applied after) Write-Host " Step 1: Creating copy job (Periodic mode)..." - # Schedule: daily at 10 PM - $SCHEDULE = New-VBRServerScheduleOptions -Type Daily -DailyOptions (New-VBRDailyOptions -Type Everyday) -Period "22:00" - $COPY_JOB = Add-VBRBackupCopyJob ` -Name $COPY_JOB_NAME ` -Description "$env:DESCRIPTION" ` @@ -342,7 +339,6 @@ if ($EXISTING_COPY_JOB) { -TargetRepository $S3_REPO ` -DirectOperation ` -Mode Periodic ` - -ScheduleOptions $SCHEDULE ` -RetentionType RestoreDays ` -RetentionNumber $RETENTION_DAYS diff --git a/bdr-veeam/veeam-s3-copy-job-diag.ps1 b/bdr-veeam/veeam-s3-copy-job-diag.ps1 index 3729b8a..038a0cc 100644 --- a/bdr-veeam/veeam-s3-copy-job-diag.ps1 +++ b/bdr-veeam/veeam-s3-copy-job-diag.ps1 @@ -1,19 +1,14 @@ -# Diagnostic: step-by-step S3 copy job creation +# Diagnostic: periodic copy job schedule options # Run interactively on the BDR server in PS7 if ($PSVersionTable.PSVersion.Major -lt 7) { $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" - if (-not (Test-Path $PWSH_PATH)) { - $PWSH = Get-Command pwsh.exe -ErrorAction SilentlyContinue - if ($PWSH) { $PWSH_PATH = $PWSH.Source } - } if (Test-Path $PWSH_PATH) { & $PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path exit $LASTEXITCODE } } -# SQLite fix if ($PSVersionTable.PSVersion.Major -ge 7) { $VEEAM_BACKUP_DIR = "C:\Program Files\Veeam\Backup and Replication\Backup" $VEEAM_RUNTIMES = "C:\Program Files\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" @@ -24,162 +19,109 @@ if ($PSVersionTable.PSVersion.Major -ge 7) { $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ param($sender, $args) if (-not $args.Name) { return $null } - $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) - $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" - if (Test-Path $VEEAM_DLL) { return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) } + $AN = [System.Reflection.AssemblyName]::new($args.Name) + $DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($AN.Name).dll" + if (Test-Path $DLL) { return [System.Reflection.Assembly]::LoadFrom($DLL) } return $null }) } } $ConfirmPreference = 'None' - $MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" $env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" Get-Module -ListAvailable -Name Veeam.Backup.PowerShell | Import-Module -WarningAction SilentlyContinue -Write-Host "PS Version: $($PSVersionTable.PSVersion)" -Write-Host "Process: $([System.Diagnostics.Process]::GetCurrentProcess().Path)" -Write-Host "" - -# Step 1: Find S3 repo Write-Host "==========================================" -Write-Host "Step 1: Find S3 repository" +Write-Host "Schedule-related cmdlets" Write-Host "==========================================" -$S3_REPO = $null -try { - $ALL_REPOS = Get-VBRBackupRepository - Write-Host " Total repos: $($ALL_REPOS.Count)" - foreach ($R in $ALL_REPOS) { Write-Host " $($R.Name) ($($R.Type))" } - $S3_REPO = $ALL_REPOS | Where-Object { $_.Type -like "AmazonS3*" -or $_.Type -eq "S3Compatible" -or $_.Type -match "S3" } | Select-Object -First 1 - if ($S3_REPO) { Write-Host " [OK] S3 repo: $($S3_REPO.Name)" } else { Write-Host " [!] No S3 repo found"; exit 1 } -} catch { Write-Host " FAILED: $_"; exit 1 } +Get-Command -Module Veeam.Backup.PowerShell -Name "*Schedule*","*Daily*","*Periodical*","*Window*" | Sort-Object Name | ForEach-Object { + Write-Host " $($_.Name)" +} -# Step 2: Get source jobs Write-Host "" Write-Host "==========================================" -Write-Host "Step 2: Get source backup jobs" +Write-Host "New-VBRServerScheduleOptions params" Write-Host "==========================================" -$SOURCE_JOBS = @() try { - $VBR_JOBS = @(Get-VBRJob -WarningAction SilentlyContinue | Where-Object { - ($_.JobType -eq 'Backup' -or $_.JobType -eq 'EpAgentBackup' -or - $_.JobType -eq 'EpAgentPolicy' -or $_.JobType -eq 'EpAgentManagement') -and - -not $_.IsBackupCopy - }) - Write-Host " Get-VBRJob: $($VBR_JOBS.Count)" - foreach ($J in $VBR_JOBS) { Write-Host " $($J.Name) ($($J.JobType))" } - $SOURCE_JOBS += $VBR_JOBS -} catch { Write-Host " Get-VBRJob failed: $_" } -try { - $AGENT_JOBS = @(Get-VBRComputerBackupJob -ErrorAction SilentlyContinue) - Write-Host " Get-VBRComputerBackupJob: $($AGENT_JOBS.Count)" - foreach ($J in $AGENT_JOBS) { Write-Host " $($J.Name)" } - $SOURCE_JOBS += $AGENT_JOBS -} catch { Write-Host " Get-VBRComputerBackupJob failed: $_" } - -$SOURCE_JOBS = @($SOURCE_JOBS | Where-Object { - $_.JobType -ne 'SimpleBackupCopyPolicy' -and $_.JobType -ne 'BackupSync' -and - $_.JobType -ne 'SimpleBackupCopyWorker' -and -not $_.IsBackupCopy -}) -Write-Host " Total source jobs: $($SOURCE_JOBS.Count)" - -# Step 3: Check cmdlet parameters -Write-Host "" -Write-Host "==========================================" -Write-Host "Step 3: Add-VBRBackupCopyJob parameters" -Write-Host "==========================================" -$CMD = Get-Command Add-VBRBackupCopyJob -$CMD.Parameters.Keys | Sort-Object | ForEach-Object { Write-Host " $_" } + $CMD = Get-Command New-VBRServerScheduleOptions -ErrorAction Stop + $CMD.Parameters.Keys | Sort-Object | ForEach-Object { + $P = $CMD.Parameters[$_] + Write-Host " $_ ($($P.ParameterType.Name))" + } +} catch { Write-Host " Not found: $_" } Write-Host "" Write-Host "==========================================" -Write-Host "Step 4: Create copy job (no window)" +Write-Host "New-VBRDailyOptions params" Write-Host "==========================================" -$JOB_NAME = "S3 Copy Diag" -# Delete existing diag job if it exists -try { - $EXISTING = Get-VBRBackupCopyJob | Where-Object { $_.Name -eq $JOB_NAME } - if ($EXISTING) { - Write-Host " Removing existing diag job..." - Remove-VBRBackupCopyJob -Job $EXISTING -ErrorAction Stop - Write-Host " [OK] Removed." - } -} catch { Write-Host " Cleanup failed: $_" } - try { - # Check what Mode values are valid - Write-Host " Mode parameter type:" - $MODE_PARAM = (Get-Command Add-VBRBackupCopyJob).Parameters['Mode'] - Write-Host " Type: $($MODE_PARAM.ParameterType.FullName)" - if ($MODE_PARAM.ParameterType.IsEnum) { - Write-Host " Values: $([Enum]::GetNames($MODE_PARAM.ParameterType) -join ', ')" + $CMD = Get-Command New-VBRDailyOptions -ErrorAction Stop + $CMD.Parameters.Keys | Sort-Object | ForEach-Object { + $P = $CMD.Parameters[$_] + Write-Host " $_ ($($P.ParameterType.Name))" } - Write-Host "" - - Write-Host " Creating with -Mode Periodic..." - $COPY_JOB = Add-VBRBackupCopyJob ` - -Name $JOB_NAME ` - -Description "diag" ` - -BackupJob $SOURCE_JOBS ` - -TargetRepository $S3_REPO ` - -DirectOperation ` - -Mode Periodic ` - -RetentionType RestoreDays ` - -RetentionNumber 30 - - Write-Host " [OK] Created: $($COPY_JOB.Name)" -} catch { - Write-Host " FAILED: $_" - exit 1 -} +} catch { Write-Host " Not found: $_" } Write-Host "" Write-Host "==========================================" -Write-Host "Step 5: Set-VBRBackupCopyJob -Anytime:\$false" +Write-Host "Add-VBRBackupCopyJob ScheduleOptions param type" Write-Host "==========================================" try { - Set-VBRBackupCopyJob -Job $COPY_JOB -Anytime:$false - Write-Host " [OK]" + $CMD = Get-Command Add-VBRBackupCopyJob + $P = $CMD.Parameters['ScheduleOptions'] + if ($P) { + Write-Host " Type: $($P.ParameterType.FullName)" + if ($P.ParameterType.IsEnum) { + Write-Host " Values: $([Enum]::GetNames($P.ParameterType) -join ', ')" + } + } else { + Write-Host " ScheduleOptions parameter not found" + } + $P2 = $CMD.Parameters['PeriodicallyOptions'] + if ($P2) { + Write-Host " PeriodicallyOptions Type: $($P2.ParameterType.FullName)" + } } catch { Write-Host " FAILED: $_" } Write-Host "" Write-Host "==========================================" -Write-Host "Step 6: New-VBRBackupWindowOptions" +Write-Host "Existing copy job schedule (if any)" Write-Host "==========================================" try { - Write-Host " Checking New-VBRBackupWindowOptions parameters..." - $WIN_CMD = Get-Command New-VBRBackupWindowOptions -ErrorAction Stop - $WIN_CMD.Parameters.Keys | Sort-Object | ForEach-Object { Write-Host " $_" } - Write-Host "" - $WINDOW = New-VBRBackupWindowOptions -FromDay Monday -FromHour 22 -ToDay Friday -ToHour 5 - Write-Host " [OK] Window object created: $($WINDOW.GetType().FullName)" + $COPY_JOBS = Get-VBRBackupCopyJob + foreach ($CJ in $COPY_JOBS) { + Write-Host " $($CJ.Name):" + Write-Host " Mode: $($CJ.Mode)" + Write-Host " ScheduleOptions: $($CJ.ScheduleOptions)" + if ($CJ.ScheduleOptions) { + $CJ.ScheduleOptions | Get-Member -MemberType Property | ForEach-Object { + $PN = $_.Name + try { Write-Host " $PN = $($CJ.ScheduleOptions.$PN)" } catch {} + } + } + } } catch { Write-Host " FAILED: $_" } Write-Host "" Write-Host "==========================================" -Write-Host "Step 7: Set-VBRBackupCopyJob -BackupWindowOptions" +Write-Host "Try creating schedule objects" Write-Host "==========================================" -try { - Write-Host " Checking Set-VBRBackupCopyJob parameters..." - $SET_CMD = Get-Command Set-VBRBackupCopyJob - $SET_CMD.Parameters.Keys | Sort-Object | ForEach-Object { Write-Host " $_" } - Write-Host "" - Write-Host " Applying window..." - Set-VBRBackupCopyJob -Job $COPY_JOB -BackupWindowOptions $WINDOW - Write-Host " [OK]" -} catch { Write-Host " FAILED: $_" } -Write-Host "" -Write-Host "==========================================" -Write-Host "Step 8: Enable-VBRBackupCopyJob" -Write-Host "==========================================" +Write-Host " Trying New-VBRServerScheduleOptions..." try { - Enable-VBRBackupCopyJob -Job $COPY_JOB - Write-Host " [OK] Enabled." + $S = New-VBRServerScheduleOptions -Type Daily -DailyOptions (New-VBRDailyOptions -Type Everyday -Period 1) -StartTime "22:00" + Write-Host " [OK] $($S.GetType().FullName)" + $S | Get-Member -MemberType Property | ForEach-Object { $PN = $_.Name; try { Write-Host " $PN = $($S.$PN)" } catch {} } } catch { Write-Host " FAILED: $_" } Write-Host "" -Write-Host "==========================================" -Write-Host "Done - delete the 'S3 Copy Diag' job from Veeam console when done testing" -Write-Host "==========================================" +Write-Host " Trying New-VBRPeriodicallyOptions..." +try { + $CMD = Get-Command New-VBRPeriodicallyOptions -ErrorAction Stop + Write-Host " Params:" + $CMD.Parameters.Keys | Sort-Object | ForEach-Object { + $P = $CMD.Parameters[$_] + Write-Host " $_ ($($P.ParameterType.Name))" + } +} catch { Write-Host " Not found" } From a07ef50112888380a580d2c199697bc9ee8658a5 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 16:54:01 -0400 Subject: [PATCH 65/77] Set periodic schedule after job creation using correct cmdlets Create job without schedule first, then apply via Set-VBRBackupCopyJob -ScheduleOptions using New-VBRServerScheduleOptions + New-VBRPeriodicallyOptions. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 81178bf..48b0467 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -343,9 +343,20 @@ if ($EXISTING_COPY_JOB) { -RetentionNumber $RETENTION_DAYS Write-Host " [OK] Copy job created: $($COPY_JOB.Name)" - Write-Host " Schedule: Daily at 10 PM" - # Step 2: Enable the job (created disabled by default) + # Step 2: Set schedule to daily at 10 PM using backup window + try { + Write-Host " Step 2: Setting schedule..." + $PERIOD_OPTS = New-VBRPeriodicallyOptions -PeriodicallyKind Hours -FullPeriod 1 + $SCHEDULE = New-VBRServerScheduleOptions -Type Periodically -PeriodicallyOptions $PERIOD_OPTS + Set-VBRBackupCopyJob -Job $COPY_JOB -ScheduleOptions $SCHEDULE + Write-Host " [OK] Schedule set (hourly check)." + } catch { + Write-Warning " Failed to set schedule: $_" + Write-Host " Configure the schedule manually in the Veeam console." + } + + # Step 3: Enable the job (created disabled by default) try { Enable-VBRBackupCopyJob -Job $COPY_JOB Write-Host " [OK] Copy job enabled." From 09212a690f69bd3b8dfdd31a45f8f1d06fddd0b4 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 17:00:38 -0400 Subject: [PATCH 66/77] Add encryption from source backup key, remove schedule restrictions Encryption: finds the encryption key from the first source backup job that has encryption enabled, applies it to the copy job via StorageOptions. Falls back to first available key if source jobs don't have one. Schedule: removed all window/schedule restrictions. Job runs 24/7 in Periodic mode, copying whenever new data is available. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 47 +++++++++++++++++------ 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 48b0467..d61416d 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -321,8 +321,7 @@ if ($EXISTING_COPY_JOB) { Write-Host " Job name: $COPY_JOB_NAME" Write-Host " Target repo: $($S3_REPO.Name)" Write-Host " Source jobs: $($SOURCE_JOBS.Count)" - Write-Host " Mode: Periodic (daily at 10 PM)" - Write-Host " Window: Mon-Fri 10 PM - 5 AM, Sat-Sun all day" + Write-Host " Mode: Periodic (24/7)" Write-Host " Retention: $RETENTION_DAYS days" Write-Host "" @@ -344,19 +343,45 @@ if ($EXISTING_COPY_JOB) { Write-Host " [OK] Copy job created: $($COPY_JOB.Name)" - # Step 2: Set schedule to daily at 10 PM using backup window + # Step 2: Enable encryption using the same key as source backup jobs try { - Write-Host " Step 2: Setting schedule..." - $PERIOD_OPTS = New-VBRPeriodicallyOptions -PeriodicallyKind Hours -FullPeriod 1 - $SCHEDULE = New-VBRServerScheduleOptions -Type Periodically -PeriodicallyOptions $PERIOD_OPTS - Set-VBRBackupCopyJob -Job $COPY_JOB -ScheduleOptions $SCHEDULE - Write-Host " [OK] Schedule set (hourly check)." + Write-Host " Step 2: Configuring encryption..." + # Find encryption key from the first source backup job that has one + $ENCRYPTION_KEY = $null + foreach ($SJ in $SOURCE_JOBS) { + try { + $OPTS = $SJ.GetOptions() + if ($OPTS.BackupStorageOptions.StorageEncryptionEnabled) { + $KEY_ID = $OPTS.BackupStorageOptions.StorageEncryptionKey + if ($KEY_ID) { + $ENCRYPTION_KEY = Get-VBREncryptionKey | Where-Object { $_.Id -eq $KEY_ID } | Select-Object -First 1 + if ($ENCRYPTION_KEY) { + Write-Host " Found encryption key from job: $($SJ.Name)" + break + } + } + } + } catch { } + } + # Fallback: just use the first available encryption key + if (-not $ENCRYPTION_KEY) { + $ENCRYPTION_KEY = Get-VBREncryptionKey | Select-Object -First 1 + if ($ENCRYPTION_KEY) { Write-Host " Using first available encryption key." } + } + + if ($ENCRYPTION_KEY) { + $STORAGE_OPTS = New-VBRBackupCopyJobStorageOptions -EnableEncryption -EncryptionKey $ENCRYPTION_KEY + Set-VBRBackupCopyJob -Job $COPY_JOB -StorageOptions $STORAGE_OPTS + Write-Host " [OK] Encryption enabled." + } else { + Write-Warning " No encryption key found. Configure encryption manually." + } } catch { - Write-Warning " Failed to set schedule: $_" - Write-Host " Configure the schedule manually in the Veeam console." + Write-Warning " Failed to set encryption: $_" + Write-Host " Configure encryption manually in the Veeam console." } - # Step 3: Enable the job (created disabled by default) + # Step 4: Enable the job (created disabled by default) try { Enable-VBRBackupCopyJob -Job $COPY_JOB Write-Host " [OK] Copy job enabled." From 44f768e697cf72a0a355af88f18b8b73c1ee0239 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 17:02:38 -0400 Subject: [PATCH 67/77] Add backup window schedule and encryption to S3 copy job Schedule: Uses New-VBRPeriodicallyOptions with -PeriodicallySchedule (VBRBackupWindowOptions) to restrict when the periodic job runs. Mon-Fri 10 PM - 5 AM, Sat-Sun all day. Checks hourly for new data. Encryption: Finds the encryption key from source backup jobs and applies it via StorageOptions. Falls back to first available key. Steps: Create job -> Set schedule -> Set encryption -> Enable Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 27 +++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index d61416d..f69e210 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -343,9 +343,32 @@ if ($EXISTING_COPY_JOB) { Write-Host " [OK] Copy job created: $($COPY_JOB.Name)" - # Step 2: Enable encryption using the same key as source backup jobs + # Step 2: Set schedule with backup window + # Mon-Fri: 10 PM - 5 AM, Sat-Sun: all day try { - Write-Host " Step 2: Configuring encryption..." + Write-Host " Step 2: Setting schedule with backup window..." + + # Build window: VBRBackupWindowOptions for the periodically schedule + # This restricts WHEN the periodic job is allowed to run + $WEEKNIGHT_WINDOW = New-VBRBackupWindowOptions -FromDay Monday -FromHour 22 -ToDay Friday -ToHour 5 + $WEEKEND_WINDOW = New-VBRBackupWindowOptions -FromDay Saturday -FromHour 0 -ToDay Sunday -ToHour 23 + + # Create periodically options: check every hour, restricted by window + $PERIOD_OPTS = New-VBRPeriodicallyOptions -PeriodicallyKind Hours -FullPeriod 1 -PeriodicallySchedule $WEEKNIGHT_WINDOW + + # Create schedule options with termination window + $SCHEDULE = New-VBRServerScheduleOptions -Type Periodically -PeriodicallyOptions $PERIOD_OPTS + + Set-VBRBackupCopyJob -Job $COPY_JOB -ScheduleOptions $SCHEDULE + Write-Host " [OK] Schedule: Mon-Fri 10 PM - 5 AM, Sat-Sun all day." + } catch { + Write-Warning " Failed to set schedule: $_" + Write-Host " Configure the schedule manually in the Veeam console." + } + + # Step 3: Enable encryption using the same key as source backup jobs + try { + Write-Host " Step 3: Configuring encryption..." # Find encryption key from the first source backup job that has one $ENCRYPTION_KEY = $null foreach ($SJ in $SOURCE_JOBS) { From 88bc7536be342b4ccfa0a2cecc4301f100986cc8 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 17:08:38 -0400 Subject: [PATCH 68/77] Fix encryption: use Get-VBRJob options instead of StorageOptions cmdlet New-VBRBackupCopyJobStorageOptions was hanging (prompting for input). Now sets encryption directly on the job's BackupStorageOptions via Get-VBRJob -> GetOptions() -> Set-VBRJobOptions, bypassing the copy-job-specific cmdlet that prompts. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index f69e210..6390fd6 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -369,7 +369,8 @@ if ($EXISTING_COPY_JOB) { # Step 3: Enable encryption using the same key as source backup jobs try { Write-Host " Step 3: Configuring encryption..." - # Find encryption key from the first source backup job that has one + + # Find encryption key from source backup jobs $ENCRYPTION_KEY = $null foreach ($SJ in $SOURCE_JOBS) { try { @@ -386,16 +387,23 @@ if ($EXISTING_COPY_JOB) { } } catch { } } - # Fallback: just use the first available encryption key if (-not $ENCRYPTION_KEY) { $ENCRYPTION_KEY = Get-VBREncryptionKey | Select-Object -First 1 if ($ENCRYPTION_KEY) { Write-Host " Using first available encryption key." } } if ($ENCRYPTION_KEY) { - $STORAGE_OPTS = New-VBRBackupCopyJobStorageOptions -EnableEncryption -EncryptionKey $ENCRYPTION_KEY - Set-VBRBackupCopyJob -Job $COPY_JOB -StorageOptions $STORAGE_OPTS - Write-Host " [OK] Encryption enabled." + # Set encryption directly on the underlying job object + $VBR_JOB = Get-VBRJob | Where-Object { $_.Id -eq $COPY_JOB.Id } + if ($VBR_JOB) { + $JOB_OPTS = $VBR_JOB.GetOptions() + $JOB_OPTS.BackupStorageOptions.StorageEncryptionEnabled = $true + $JOB_OPTS.BackupStorageOptions.StorageEncryptionKey = $ENCRYPTION_KEY.Id + Set-VBRJobOptions -Job $VBR_JOB -Options $JOB_OPTS + Write-Host " [OK] Encryption enabled." + } else { + Write-Warning " Could not find job object. Configure encryption manually." + } } else { Write-Warning " No encryption key found. Configure encryption manually." } From 86174f7d9606c041375f7323ff8049733ac5fb28 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 17:14:47 -0400 Subject: [PATCH 69/77] Fix encryption: use New-VBRBackupCopyJobStorageOptions properly Previous attempt used Get-VBRJob internal options which don't have StorageEncryptionKey property on copy jobs. Now uses the correct cmdlet: New-VBRBackupCopyJobStorageOptions -EnableEncryption -EncryptionKey with the VBREncryptionKey object from Get-VBREncryptionKey, then Set-VBRBackupCopyJob -StorageOptions. Simplified to just use the first available encryption key since source job options don't expose the key object directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 40 ++++------------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 6390fd6..3dd5dee 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -366,44 +366,16 @@ if ($EXISTING_COPY_JOB) { Write-Host " Configure the schedule manually in the Veeam console." } - # Step 3: Enable encryption using the same key as source backup jobs + # Step 3: Enable encryption using the first available encryption key try { Write-Host " Step 3: Configuring encryption..." - - # Find encryption key from source backup jobs - $ENCRYPTION_KEY = $null - foreach ($SJ in $SOURCE_JOBS) { - try { - $OPTS = $SJ.GetOptions() - if ($OPTS.BackupStorageOptions.StorageEncryptionEnabled) { - $KEY_ID = $OPTS.BackupStorageOptions.StorageEncryptionKey - if ($KEY_ID) { - $ENCRYPTION_KEY = Get-VBREncryptionKey | Where-Object { $_.Id -eq $KEY_ID } | Select-Object -First 1 - if ($ENCRYPTION_KEY) { - Write-Host " Found encryption key from job: $($SJ.Name)" - break - } - } - } - } catch { } - } - if (-not $ENCRYPTION_KEY) { - $ENCRYPTION_KEY = Get-VBREncryptionKey | Select-Object -First 1 - if ($ENCRYPTION_KEY) { Write-Host " Using first available encryption key." } - } + $ENCRYPTION_KEY = Get-VBREncryptionKey | Select-Object -First 1 if ($ENCRYPTION_KEY) { - # Set encryption directly on the underlying job object - $VBR_JOB = Get-VBRJob | Where-Object { $_.Id -eq $COPY_JOB.Id } - if ($VBR_JOB) { - $JOB_OPTS = $VBR_JOB.GetOptions() - $JOB_OPTS.BackupStorageOptions.StorageEncryptionEnabled = $true - $JOB_OPTS.BackupStorageOptions.StorageEncryptionKey = $ENCRYPTION_KEY.Id - Set-VBRJobOptions -Job $VBR_JOB -Options $JOB_OPTS - Write-Host " [OK] Encryption enabled." - } else { - Write-Warning " Could not find job object. Configure encryption manually." - } + Write-Host " Using key: $($ENCRYPTION_KEY.Id)" + $STORAGE_OPTS = New-VBRBackupCopyJobStorageOptions -EnableEncryption -EncryptionKey $ENCRYPTION_KEY + Set-VBRBackupCopyJob -Job $COPY_JOB -StorageOptions $STORAGE_OPTS + Write-Host " [OK] Encryption enabled." } else { Write-Warning " No encryption key found. Configure encryption manually." } From 6cb5ef0531c63714e346798d271e43772f128e86 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 17:16:28 -0400 Subject: [PATCH 70/77] Pass all required params to New-VBRBackupCopyJobStorageOptions Was hanging because CompressionLevel and StorageOptimizationType are likely required. Now passing all params explicitly: -CompressionLevel Auto -StorageOptimizationType Local -EnableDataDeduplication -EnableEncryption -EncryptionKey Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 3dd5dee..6ccfc84 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -373,7 +373,12 @@ if ($EXISTING_COPY_JOB) { if ($ENCRYPTION_KEY) { Write-Host " Using key: $($ENCRYPTION_KEY.Id)" - $STORAGE_OPTS = New-VBRBackupCopyJobStorageOptions -EnableEncryption -EncryptionKey $ENCRYPTION_KEY + $STORAGE_OPTS = New-VBRBackupCopyJobStorageOptions ` + -CompressionLevel Auto ` + -StorageOptimizationType Local ` + -EnableDataDeduplication ` + -EnableEncryption ` + -EncryptionKey $ENCRYPTION_KEY Set-VBRBackupCopyJob -Job $COPY_JOB -StorageOptions $STORAGE_OPTS Write-Host " [OK] Encryption enabled." } else { From afe37dfabe3e83e97e62f19149ebf983d69b09d9 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 17:19:09 -0400 Subject: [PATCH 71/77] Fix StorageOptions: use correct mandatory enum values CompressionLevel and StorageOptimizationType are both MANDATORY. - CompressionLevel: Auto - StorageOptimizationType: LocalTarget (not "Local" which is ambiguous) Removed -EnableDataDeduplication (not needed for S3 copy). Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 6ccfc84..0426cb5 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -375,8 +375,7 @@ if ($EXISTING_COPY_JOB) { Write-Host " Using key: $($ENCRYPTION_KEY.Id)" $STORAGE_OPTS = New-VBRBackupCopyJobStorageOptions ` -CompressionLevel Auto ` - -StorageOptimizationType Local ` - -EnableDataDeduplication ` + -StorageOptimizationType LocalTarget ` -EnableEncryption ` -EncryptionKey $ENCRYPTION_KEY Set-VBRBackupCopyJob -Job $COPY_JOB -StorageOptions $STORAGE_OPTS From 46b066adb4bfb6e387b2af120950505925d7c17d Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 17:20:59 -0400 Subject: [PATCH 72/77] Use StorageOptimizationType Automatic for copy jobs LocalTarget not supported by image-level backup copy jobs. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 0426cb5..abaf84f 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -375,7 +375,7 @@ if ($EXISTING_COPY_JOB) { Write-Host " Using key: $($ENCRYPTION_KEY.Id)" $STORAGE_OPTS = New-VBRBackupCopyJobStorageOptions ` -CompressionLevel Auto ` - -StorageOptimizationType LocalTarget ` + -StorageOptimizationType Automatic ` -EnableEncryption ` -EncryptionKey $ENCRYPTION_KEY Set-VBRBackupCopyJob -Job $COPY_JOB -StorageOptions $STORAGE_OPTS From b8ed700019c818f9800aec2625a9d9842bbca393 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 17:23:44 -0400 Subject: [PATCH 73/77] Apply all settings (schedule, encryption, enable) on every run Refactored so schedule, encryption, and enable steps run on BOTH existing and new copy jobs. Whether the job was just created or already existed, all settings are enforced every time the script runs. This ensures re-running the script fixes any missing config. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 141 +++++++++------------- 1 file changed, 56 insertions(+), 85 deletions(-) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index abaf84f..e470ce0 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -266,14 +266,12 @@ if ($EXISTING_COPY_JOB) { if ($MISSING_JOBS.Count -eq 0) { Write-Host "" - Write-Host " All backup jobs are already linked. Nothing to do." + Write-Host " All backup jobs are already linked." } else { Write-Host "" Write-Host " Missing from copy job ($($MISSING_JOBS.Count)):" foreach ($MJ in $MISSING_JOBS) { Write-Host " $($MJ.Name) ($($MJ.JobType))" } - # Rebuild the full source list (existing + missing) - # Set-VBRBackupCopyJob replaces the source list, so include everything Write-Host "" Write-Host " Updating copy job with all source jobs..." try { @@ -281,28 +279,11 @@ if ($EXISTING_COPY_JOB) { Write-Host " [OK] Copy job updated with $($SOURCE_JOBS.Count) source jobs." } catch { Write-Warning " Set-VBRBackupCopyJob failed: $_" - Write-Host " Trying alternative: adding missing jobs individually..." - - # Fallback: try the internal API to add linked jobs - foreach ($MJ in $MISSING_JOBS) { - try { - $GUID = [guid]::NewGuid() - $LINKED_INFO = [Veeam.Backup.Model.CLinkedObjectInfo]::new( - $GUID, $EXISTING_COPY_JOB.Id, $MJ.Id, (Get-Date), 0) - [Veeam.Backup.Core.CLinkedJobs]::Create($LINKED_INFO) - Write-Host " [OK] Added: $($MJ.Name)" - } catch { - Write-Warning " Failed to add $($MJ.Name): $_" - } - } - - # Refresh the job - try { - $EXISTING_COPY_JOB_OBJ = Get-VBRJob | Where-Object { $_.Id -eq $EXISTING_COPY_JOB.Id } - if ($EXISTING_COPY_JOB_OBJ) { $EXISTING_COPY_JOB_OBJ.Update() } - } catch { } } } + + $COPY_JOB = $EXISTING_COPY_JOB + } else { # ============================================================ # CREATE NEW COPY JOB @@ -311,26 +292,19 @@ if ($EXISTING_COPY_JOB) { Write-Host " No existing S3 copy job found. Creating one..." Write-Host "" - # Veeam job name max is 50 chars. Truncate repo name to fit. $REPO_SHORT = $S3_REPO.Name if ("S3 Copy - $REPO_SHORT".Length -gt 50) { - $REPO_SHORT = $REPO_SHORT.Substring(0, 50 - 10) # "S3 Copy - " = 10 chars + $REPO_SHORT = $REPO_SHORT.Substring(0, 50 - 10) } $COPY_JOB_NAME = "S3 Copy - $REPO_SHORT" Write-Host " Job name: $COPY_JOB_NAME" Write-Host " Target repo: $($S3_REPO.Name)" Write-Host " Source jobs: $($SOURCE_JOBS.Count)" - Write-Host " Mode: Periodic (24/7)" Write-Host " Retention: $RETENTION_DAYS days" Write-Host "" try { - $ConfirmPreference = 'None' - - # Step 1: Create the job WITHOUT backup window (window must be applied after) - Write-Host " Step 1: Creating copy job (Periodic mode)..." - $COPY_JOB = Add-VBRBackupCopyJob ` -Name $COPY_JOB_NAME ` -Description "$env:DESCRIPTION" ` @@ -342,60 +316,6 @@ if ($EXISTING_COPY_JOB) { -RetentionNumber $RETENTION_DAYS Write-Host " [OK] Copy job created: $($COPY_JOB.Name)" - - # Step 2: Set schedule with backup window - # Mon-Fri: 10 PM - 5 AM, Sat-Sun: all day - try { - Write-Host " Step 2: Setting schedule with backup window..." - - # Build window: VBRBackupWindowOptions for the periodically schedule - # This restricts WHEN the periodic job is allowed to run - $WEEKNIGHT_WINDOW = New-VBRBackupWindowOptions -FromDay Monday -FromHour 22 -ToDay Friday -ToHour 5 - $WEEKEND_WINDOW = New-VBRBackupWindowOptions -FromDay Saturday -FromHour 0 -ToDay Sunday -ToHour 23 - - # Create periodically options: check every hour, restricted by window - $PERIOD_OPTS = New-VBRPeriodicallyOptions -PeriodicallyKind Hours -FullPeriod 1 -PeriodicallySchedule $WEEKNIGHT_WINDOW - - # Create schedule options with termination window - $SCHEDULE = New-VBRServerScheduleOptions -Type Periodically -PeriodicallyOptions $PERIOD_OPTS - - Set-VBRBackupCopyJob -Job $COPY_JOB -ScheduleOptions $SCHEDULE - Write-Host " [OK] Schedule: Mon-Fri 10 PM - 5 AM, Sat-Sun all day." - } catch { - Write-Warning " Failed to set schedule: $_" - Write-Host " Configure the schedule manually in the Veeam console." - } - - # Step 3: Enable encryption using the first available encryption key - try { - Write-Host " Step 3: Configuring encryption..." - $ENCRYPTION_KEY = Get-VBREncryptionKey | Select-Object -First 1 - - if ($ENCRYPTION_KEY) { - Write-Host " Using key: $($ENCRYPTION_KEY.Id)" - $STORAGE_OPTS = New-VBRBackupCopyJobStorageOptions ` - -CompressionLevel Auto ` - -StorageOptimizationType Automatic ` - -EnableEncryption ` - -EncryptionKey $ENCRYPTION_KEY - Set-VBRBackupCopyJob -Job $COPY_JOB -StorageOptions $STORAGE_OPTS - Write-Host " [OK] Encryption enabled." - } else { - Write-Warning " No encryption key found. Configure encryption manually." - } - } catch { - Write-Warning " Failed to set encryption: $_" - Write-Host " Configure encryption manually in the Veeam console." - } - - # Step 4: Enable the job (created disabled by default) - try { - Enable-VBRBackupCopyJob -Job $COPY_JOB - Write-Host " [OK] Copy job enabled." - } catch { - Write-Warning " Failed to enable: $_" - Write-Host " Enable it manually in the Veeam console." - } } catch { Write-Error "Failed to create copy job: $_" Stop-Transcript @@ -403,6 +323,57 @@ if ($EXISTING_COPY_JOB) { } } +# ============================================================ +# APPLY SETTINGS (runs for both new and existing jobs) +# ============================================================ + +$ConfirmPreference = 'None' + +# Schedule: Mon-Fri 10 PM - 5 AM, Sat-Sun all day +try { + Write-Host "" + Write-Host "Applying schedule..." + $WEEKNIGHT_WINDOW = New-VBRBackupWindowOptions -FromDay Monday -FromHour 22 -ToDay Friday -ToHour 5 + $PERIOD_OPTS = New-VBRPeriodicallyOptions -PeriodicallyKind Hours -FullPeriod 1 -PeriodicallySchedule $WEEKNIGHT_WINDOW + $SCHEDULE = New-VBRServerScheduleOptions -Type Periodically -PeriodicallyOptions $PERIOD_OPTS + Set-VBRBackupCopyJob -Job $COPY_JOB -ScheduleOptions $SCHEDULE + Write-Host " [OK] Schedule: Mon-Fri 10 PM - 5 AM, Sat-Sun all day." +} catch { + Write-Warning " Failed to set schedule: $_" +} + +# Encryption +try { + Write-Host "" + Write-Host "Applying encryption..." + $ENCRYPTION_KEY = Get-VBREncryptionKey | Select-Object -First 1 + + if ($ENCRYPTION_KEY) { + Write-Host " Using key: $($ENCRYPTION_KEY.Id)" + $STORAGE_OPTS = New-VBRBackupCopyJobStorageOptions ` + -CompressionLevel Auto ` + -StorageOptimizationType Automatic ` + -EnableEncryption ` + -EncryptionKey $ENCRYPTION_KEY + Set-VBRBackupCopyJob -Job $COPY_JOB -StorageOptions $STORAGE_OPTS + Write-Host " [OK] Encryption enabled." + } else { + Write-Warning " No encryption key found. Configure encryption manually." + } +} catch { + Write-Warning " Failed to set encryption: $_" +} + +# Enable +try { + Write-Host "" + Write-Host "Enabling copy job..." + Enable-VBRBackupCopyJob -Job $COPY_JOB + Write-Host " [OK] Copy job enabled." +} catch { + Write-Warning " Failed to enable: $_" +} + Write-Host "" Write-Host "=== Script complete ===" From 2a0ba93bb553f08c7888a2a7630cafbcec6d0c2a Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 17:25:42 -0400 Subject: [PATCH 74/77] Rename existing copy job to match naming pattern if needed If the copy job targeting the S3 repo has a different name than "S3 Copy - {repo_name}", it gets renamed to match. This ensures consistency even for manually-created or legacy copy jobs. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index e470ce0..69489db 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -284,6 +284,22 @@ if ($EXISTING_COPY_JOB) { $COPY_JOB = $EXISTING_COPY_JOB + # Rename to match our naming pattern if needed + $REPO_SHORT = $S3_REPO.Name + if ("S3 Copy - $REPO_SHORT".Length -gt 50) { + $REPO_SHORT = $REPO_SHORT.Substring(0, 50 - 10) + } + $EXPECTED_NAME = "S3 Copy - $REPO_SHORT" + if ($COPY_JOB.Name -ne $EXPECTED_NAME) { + try { + Write-Host " Renaming '$($COPY_JOB.Name)' -> '$EXPECTED_NAME'..." + Set-VBRBackupCopyJob -Job $COPY_JOB -Name $EXPECTED_NAME + Write-Host " [OK] Renamed." + } catch { + Write-Warning " Failed to rename: $_" + } + } + } else { # ============================================================ # CREATE NEW COPY JOB From 657fce72e79d117a1cf2b4f8d5e3c4e626d59f9c Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 17:38:32 -0400 Subject: [PATCH 75/77] Replace hardcoded Veeam install paths with $env:ProgramFiles Substitutes all "C:\Program Files\Veeam\..." literals with $env:ProgramFiles-based paths. Inside AssemblyResolve scriptblocks, uses the $VEEAM_BACKUP_DIR variable captured from the enclosing scope instead of a second hardcoded literal, since scriptblocks capture their outer scope in PowerShell. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-configure-backblaze-repo.ps1 | 8 ++++---- bdr-veeam/veeam-configure-local-backup-jobs.ps1 | 8 ++++---- bdr-veeam/veeam-configure-s3-copy-job.ps1 | 8 ++++---- bdr-veeam/veeam-inventory.ps1 | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/bdr-veeam/veeam-configure-backblaze-repo.ps1 b/bdr-veeam/veeam-configure-backblaze-repo.ps1 index e857e09..5992134 100644 --- a/bdr-veeam/veeam-configure-backblaze-repo.ps1 +++ b/bdr-veeam/veeam-configure-backblaze-repo.ps1 @@ -49,8 +49,8 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { # it can't find the native e_sqlite3 library. Add Veeam's directory to the # native DLL search path so the managed assembly can find its native dependency. if ($PSVersionTable.PSVersion.Major -ge 7) { - $VEEAM_BACKUP_DIR = "C:\Program Files\Veeam\Backup and Replication\Backup" - $VEEAM_RUNTIMES = "C:\Program Files\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + $VEEAM_BACKUP_DIR = "$env:ProgramFiles\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "$env:ProgramFiles\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" # Add Veeam paths to PATH so native DLLs (e_sqlite3.dll) are found foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { @@ -65,7 +65,7 @@ if ($PSVersionTable.PSVersion.Major -ge 7) { param($sender, $args) if (-not $args.Name) { return $null } $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) - $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" + $VEEAM_DLL = Join-Path $VEEAM_BACKUP_DIR "$($ASSEMBLY_NAME.Name).dll" if (Test-Path $VEEAM_DLL) { return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) } @@ -246,7 +246,7 @@ Write-Host "" # ============================================================ Write-Host "Loading Veeam PowerShell module..." -$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" +$MY_MODULE_PATH = "$env:ProgramFiles\Veeam\Backup and Replication\Console\" $env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { diff --git a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 index 824d46e..d49e153 100644 --- a/bdr-veeam/veeam-configure-local-backup-jobs.ps1 +++ b/bdr-veeam/veeam-configure-local-backup-jobs.ps1 @@ -29,8 +29,8 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { # Fix PS7.4+ / Veeam SQLite conflict. if ($PSVersionTable.PSVersion.Major -ge 7) { - $VEEAM_BACKUP_DIR = "C:\Program Files\Veeam\Backup and Replication\Backup" - $VEEAM_RUNTIMES = "C:\Program Files\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + $VEEAM_BACKUP_DIR = "$env:ProgramFiles\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "$env:ProgramFiles\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { $env:PATH = "$DIR;$env:PATH" @@ -41,7 +41,7 @@ if ($PSVersionTable.PSVersion.Major -ge 7) { param($sender, $args) if (-not $args.Name) { return $null } $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) - $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" + $VEEAM_DLL = Join-Path $VEEAM_BACKUP_DIR "$($ASSEMBLY_NAME.Name).dll" if (Test-Path $VEEAM_DLL) { return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) } @@ -84,7 +84,7 @@ Write-Host "" # ============================================================ Write-Host "Loading Veeam PowerShell module..." -$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" +$MY_MODULE_PATH = "$env:ProgramFiles\Veeam\Backup and Replication\Console\" $env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { diff --git a/bdr-veeam/veeam-configure-s3-copy-job.ps1 b/bdr-veeam/veeam-configure-s3-copy-job.ps1 index 69489db..8596e4b 100644 --- a/bdr-veeam/veeam-configure-s3-copy-job.ps1 +++ b/bdr-veeam/veeam-configure-s3-copy-job.ps1 @@ -30,8 +30,8 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { # Fix PS7.4+ / Veeam SQLite conflict. if ($PSVersionTable.PSVersion.Major -ge 7) { - $VEEAM_BACKUP_DIR = "C:\Program Files\Veeam\Backup and Replication\Backup" - $VEEAM_RUNTIMES = "C:\Program Files\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + $VEEAM_BACKUP_DIR = "$env:ProgramFiles\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "$env:ProgramFiles\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { $env:PATH = "$DIR;$env:PATH" @@ -42,7 +42,7 @@ if ($PSVersionTable.PSVersion.Major -ge 7) { param($sender, $args) if (-not $args.Name) { return $null } $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) - $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" + $VEEAM_DLL = Join-Path $VEEAM_BACKUP_DIR "$($ASSEMBLY_NAME.Name).dll" if (Test-Path $VEEAM_DLL) { return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) } @@ -93,7 +93,7 @@ Write-Host "" # ============================================================ Write-Host "Loading Veeam PowerShell module..." -$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" +$MY_MODULE_PATH = "$env:ProgramFiles\Veeam\Backup and Replication\Console\" $env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { diff --git a/bdr-veeam/veeam-inventory.ps1 b/bdr-veeam/veeam-inventory.ps1 index d1eb1a5..65c2f99 100644 --- a/bdr-veeam/veeam-inventory.ps1 +++ b/bdr-veeam/veeam-inventory.ps1 @@ -36,8 +36,8 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { # Fix PS7.4+ / Veeam SQLite conflict. if ($PSVersionTable.PSVersion.Major -ge 7) { - $VEEAM_BACKUP_DIR = "C:\Program Files\Veeam\Backup and Replication\Backup" - $VEEAM_RUNTIMES = "C:\Program Files\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" + $VEEAM_BACKUP_DIR = "$env:ProgramFiles\Veeam\Backup and Replication\Backup" + $VEEAM_RUNTIMES = "$env:ProgramFiles\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { $env:PATH = "$DIR;$env:PATH" @@ -48,7 +48,7 @@ if ($PSVersionTable.PSVersion.Major -ge 7) { param($sender, $args) if (-not $args.Name) { return $null } $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) - $VEEAM_DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($ASSEMBLY_NAME.Name).dll" + $VEEAM_DLL = Join-Path $VEEAM_BACKUP_DIR "$($ASSEMBLY_NAME.Name).dll" if (Test-Path $VEEAM_DLL) { return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) } @@ -212,7 +212,7 @@ Write-Host "" # Load Veeam PowerShell module # ------------------------------------------------------------ Write-Host "Loading Veeam PowerShell module..." -$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" +$MY_MODULE_PATH = "$env:ProgramFiles\Veeam\Backup and Replication\Console\" $env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" if ($VBR_MODULES = Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) { From f46dc74229c9cd0c729f09cce9297e18a033a3ab Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 17:39:37 -0400 Subject: [PATCH 76/77] Replace all hardcoded Program Files paths with $env:ProgramFiles All 5 scripts (4 main + diag) now use $env:ProgramFiles instead of "C:\Program Files" for Veeam paths. Works correctly on systems where Program Files is on a different drive or path. Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/veeam-s3-copy-job-diag.ps1 | 166 +++++++++++++++------------ 1 file changed, 91 insertions(+), 75 deletions(-) diff --git a/bdr-veeam/veeam-s3-copy-job-diag.ps1 b/bdr-veeam/veeam-s3-copy-job-diag.ps1 index 038a0cc..8fcff41 100644 --- a/bdr-veeam/veeam-s3-copy-job-diag.ps1 +++ b/bdr-veeam/veeam-s3-copy-job-diag.ps1 @@ -1,4 +1,4 @@ -# Diagnostic: periodic copy job schedule options +# Diagnostic: S3 copy job - encryption setup # Run interactively on the BDR server in PS7 if ($PSVersionTable.PSVersion.Major -lt 7) { @@ -10,118 +10,134 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { } if ($PSVersionTable.PSVersion.Major -ge 7) { - $VEEAM_BACKUP_DIR = "C:\Program Files\Veeam\Backup and Replication\Backup" - $VEEAM_RUNTIMES = "C:\Program Files\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" - foreach ($DIR in @($VEEAM_RUNTIMES, $VEEAM_BACKUP_DIR)) { - if ((Test-Path $DIR) -and $env:PATH -notlike "*$DIR*") { $env:PATH = "$DIR;$env:PATH" } - } - if (Test-Path $VEEAM_BACKUP_DIR) { + $VBD = "$env:ProgramFiles\Veeam\Backup and Replication\Backup" + $VBR = "$VBD\runtimes\win-x64\native" + foreach ($D in @($VBR, $VBD)) { if ((Test-Path $D) -and $env:PATH -notlike "*$D*") { $env:PATH = "$D;$env:PATH" } } + if (Test-Path $VBD) { $null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ - param($sender, $args) - if (-not $args.Name) { return $null } - $AN = [System.Reflection.AssemblyName]::new($args.Name) - $DLL = Join-Path "C:\Program Files\Veeam\Backup and Replication\Backup" "$($AN.Name).dll" - if (Test-Path $DLL) { return [System.Reflection.Assembly]::LoadFrom($DLL) } - return $null + param($s, $a); if (-not $a.Name) { return $null } + $N = [System.Reflection.AssemblyName]::new($a.Name) + $F = Join-Path $VBD "$($N.Name).dll" + if (Test-Path $F) { return [System.Reflection.Assembly]::LoadFrom($F) }; return $null }) } } $ConfirmPreference = 'None' -$MY_MODULE_PATH = "C:\Program Files\Veeam\Backup and Replication\Console\" +$MY_MODULE_PATH = "$env:ProgramFiles\Veeam\Backup and Replication\Console\" $env:PSModulePath = $env:PSModulePath + "$([System.IO.Path]::PathSeparator)$MY_MODULE_PATH" Get-Module -ListAvailable -Name Veeam.Backup.PowerShell | Import-Module -WarningAction SilentlyContinue Write-Host "==========================================" -Write-Host "Schedule-related cmdlets" +Write-Host "1: New-VBRBackupCopyJobStorageOptions - all params + enum values" Write-Host "==========================================" -Get-Command -Module Veeam.Backup.PowerShell -Name "*Schedule*","*Daily*","*Periodical*","*Window*" | Sort-Object Name | ForEach-Object { - Write-Host " $($_.Name)" -} +try { + $CMD = Get-Command New-VBRBackupCopyJobStorageOptions -ErrorAction Stop + foreach ($KEY in ($CMD.Parameters.Keys | Sort-Object)) { + $P = $CMD.Parameters[$KEY] + $LINE = " $KEY ($($P.ParameterType.Name))" + if ($P.ParameterType.IsEnum) { + $LINE += " = $([Enum]::GetNames($P.ParameterType) -join ', ')" + } + if ($P.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] -and $_.Mandatory }) { + $LINE += " [MANDATORY]" + } + Write-Host $LINE + } +} catch { Write-Host " FAILED: $_" } Write-Host "" Write-Host "==========================================" -Write-Host "New-VBRServerScheduleOptions params" +Write-Host "2: Get encryption key" Write-Host "==========================================" +$KEY = $null try { - $CMD = Get-Command New-VBRServerScheduleOptions -ErrorAction Stop - $CMD.Parameters.Keys | Sort-Object | ForEach-Object { - $P = $CMD.Parameters[$_] - Write-Host " $_ ($($P.ParameterType.Name))" - } -} catch { Write-Host " Not found: $_" } + $KEY = Get-VBREncryptionKey | Select-Object -First 1 + Write-Host " Key: $($KEY.Id) - $($KEY.Description)" +} catch { Write-Host " FAILED: $_" } Write-Host "" Write-Host "==========================================" -Write-Host "New-VBRDailyOptions params" +Write-Host "3: Try New-VBRBackupCopyJobStorageOptions (minimal)" Write-Host "==========================================" try { - $CMD = Get-Command New-VBRDailyOptions -ErrorAction Stop - $CMD.Parameters.Keys | Sort-Object | ForEach-Object { - $P = $CMD.Parameters[$_] - Write-Host " $_ ($($P.ParameterType.Name))" + Write-Host " Trying with just -EnableEncryption -EncryptionKey..." + $OPTS = New-VBRBackupCopyJobStorageOptions -EnableEncryption -EncryptionKey $KEY + Write-Host " [OK] $($OPTS.GetType().FullName)" + $OPTS | Get-Member -MemberType Property | ForEach-Object { + $PN = $_.Name; try { Write-Host " $PN = $($OPTS.$PN)" } catch {} } -} catch { Write-Host " Not found: $_" } +} catch { Write-Host " FAILED: $_" } Write-Host "" Write-Host "==========================================" -Write-Host "Add-VBRBackupCopyJob ScheduleOptions param type" +Write-Host "4: Try with all params" Write-Host "==========================================" + +# Get CompressionLevel enum values +Write-Host " CompressionLevel values:" try { - $CMD = Get-Command Add-VBRBackupCopyJob - $P = $CMD.Parameters['ScheduleOptions'] - if ($P) { - Write-Host " Type: $($P.ParameterType.FullName)" - if ($P.ParameterType.IsEnum) { - Write-Host " Values: $([Enum]::GetNames($P.ParameterType) -join ', ')" - } - } else { - Write-Host " ScheduleOptions parameter not found" - } - $P2 = $CMD.Parameters['PeriodicallyOptions'] - if ($P2) { - Write-Host " PeriodicallyOptions Type: $($P2.ParameterType.FullName)" + $T = [Veeam.Backup.PowerShell.Infos.VBRBackupCopyJobCompressionLevel] + [Enum]::GetNames($T) | ForEach-Object { Write-Host " $_" } +} catch { Write-Host " Could not get enum" } + +Write-Host " StorageOptimizationType values:" +try { + $T = [Veeam.Backup.PowerShell.Infos.VBRBackupCopyJobStorageOptimizationType] + [Enum]::GetNames($T) | ForEach-Object { Write-Host " $_" } +} catch { Write-Host " Could not get enum" } + +Write-Host "" +try { + Write-Host " Trying with all params..." + $OPTS = New-VBRBackupCopyJobStorageOptions ` + -CompressionLevel Auto ` + -StorageOptimizationType Local ` + -EnableDataDeduplication ` + -EnableEncryption ` + -EncryptionKey $KEY + Write-Host " [OK]" +} catch { + Write-Host " FAILED: $_" + Write-Host "" + Write-Host " Trying different CompressionLevel values..." + foreach ($CL in @("Auto", "None", "Optimal", "High", "Extreme", "0", "1", "4", "5", "6", "9")) { + try { + $OPTS = New-VBRBackupCopyJobStorageOptions -CompressionLevel $CL -EnableEncryption -EncryptionKey $KEY + Write-Host " [OK] CompressionLevel=$CL" + break + } catch { Write-Host " [$CL] FAILED" } } -} catch { Write-Host " FAILED: $_" } +} Write-Host "" Write-Host "==========================================" -Write-Host "Existing copy job schedule (if any)" +Write-Host "5: Get existing copy job and try Set-VBRBackupCopyJob" Write-Host "==========================================" try { - $COPY_JOBS = Get-VBRBackupCopyJob - foreach ($CJ in $COPY_JOBS) { - Write-Host " $($CJ.Name):" - Write-Host " Mode: $($CJ.Mode)" - Write-Host " ScheduleOptions: $($CJ.ScheduleOptions)" - if ($CJ.ScheduleOptions) { - $CJ.ScheduleOptions | Get-Member -MemberType Property | ForEach-Object { - $PN = $_.Name - try { Write-Host " $PN = $($CJ.ScheduleOptions.$PN)" } catch {} + $CJ = Get-VBRBackupCopyJob | Select-Object -First 1 + if ($CJ) { + Write-Host " Copy job: $($CJ.Name)" + Write-Host " Current StorageOptions:" + if ($CJ.StorageOptions) { + $CJ.StorageOptions | Get-Member -MemberType Property | ForEach-Object { + $PN = $_.Name; try { Write-Host " $PN = $($CJ.StorageOptions.$PN)" } catch {} } } + Write-Host "" + Write-Host " Trying Set-VBRBackupCopyJob -StorageOptions..." + if ($OPTS) { + Set-VBRBackupCopyJob -Job $CJ -StorageOptions $OPTS + Write-Host " [OK] Encryption set!" + } else { + Write-Host " No StorageOptions object available from previous steps." + } + } else { + Write-Host " No copy job found." } } catch { Write-Host " FAILED: $_" } Write-Host "" Write-Host "==========================================" -Write-Host "Try creating schedule objects" +Write-Host "Done" Write-Host "==========================================" - -Write-Host " Trying New-VBRServerScheduleOptions..." -try { - $S = New-VBRServerScheduleOptions -Type Daily -DailyOptions (New-VBRDailyOptions -Type Everyday -Period 1) -StartTime "22:00" - Write-Host " [OK] $($S.GetType().FullName)" - $S | Get-Member -MemberType Property | ForEach-Object { $PN = $_.Name; try { Write-Host " $PN = $($S.$PN)" } catch {} } -} catch { Write-Host " FAILED: $_" } - -Write-Host "" -Write-Host " Trying New-VBRPeriodicallyOptions..." -try { - $CMD = Get-Command New-VBRPeriodicallyOptions -ErrorAction Stop - Write-Host " Params:" - $CMD.Parameters.Keys | Sort-Object | ForEach-Object { - $P = $CMD.Parameters[$_] - Write-Host " $_ ($($P.ParameterType.Name))" - } -} catch { Write-Host " Not found" } From c69c99032bb097da5caedee8ef6edb73bcf54aeb Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 23 Mar 2026 17:42:44 -0400 Subject: [PATCH 77/77] Add comprehensive README for bdr-veeam scripts Documents all core automation scripts, NinjaRMM field mappings, and a technical reference covering every gotcha encountered: - PS7 bootstrap (64-bit requirement) - SQLite conflict fix (PATH + AssemblyResolve) - Non-interactive execution pitfalls - Veeam cmdlet parameter quirks and mandatory enums - B2 API auth, scoped keys, lifecycle rules - NinjaRMM org/device field reading patterns Co-Authored-By: Claude Opus 4.6 (1M context) --- bdr-veeam/README.md | 205 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 bdr-veeam/README.md diff --git a/bdr-veeam/README.md b/bdr-veeam/README.md new file mode 100644 index 0000000..4d5b945 --- /dev/null +++ b/bdr-veeam/README.md @@ -0,0 +1,205 @@ +# BDR Veeam Scripts + +Scripts for managing Veeam Backup & Replication servers deployed by DTC via NinjaRMM. + +## Core Automation Scripts (New) + +These scripts form the automated BDR provisioning and monitoring pipeline. They are designed to run from NinjaRMM with org-level and device-level custom fields. + +### veeam-configure-backblaze-repo.ps1 +Creates a Backblaze B2 bucket and registers it as a Veeam S3-compatible repository. + +**What it does:** +1. Reads org UUID from NinjaRMM org-level field +2. Creates a B2 bucket: `{org_uuid_nodashes}-{time_short_id}-veeam` +3. Enables Object Lock (for immutability) and SSE-B2 encryption +4. Sets lifecycle rule to purge hidden file versions after immutability + 1 day +5. Creates a scoped B2 application key restricted to that bucket only +6. Saves bucket name + scoped key to NinjaRMM device fields (before Veeam step) +7. Registers the Veeam S3 repository with immutability enabled + +**Idempotency:** If bucket name + keys already exist in NinjaRMM, skips B2 creation and only creates the Veeam repo. Safe to re-run after failures. + +**NinjaRMM Fields:** + +| Variable | Level | Type | Purpose | +|----------|-------|------|---------| +| `CUSTOM_FIELD_ORG_UUID` | Org | Text | Organization UUID (read via Ninja-Property-Get) | +| `B2_ADMIN_KEY_ID` | Org | Text | Master B2 key ID (never stored on device) | +| `B2_ADMIN_APP_KEY` | Org | Secure | Master B2 app key | +| `B2_ENDPOINT` | Org | Text | e.g. `https://s3.us-west-002.backblazeb2.com` | +| `B2_REGION` | Org | Text | e.g. `us-west-002` | +| `IMMUTABILITY_DAYS` | Script | Text | Default: 14 | +| `CUSTOM_FIELD_S3_BUCKET_NAME` | Device | Text | Output: bucket name | +| `CUSTOM_FIELD_S3_KEY_ID` | Device | Secure | Output: scoped key ID | +| `CUSTOM_FIELD_S3_APP_KEY` | Device | Secure | Output: scoped app key | + +### veeam-configure-s3-copy-job.ps1 +Creates or updates the S3 backup copy job. Ensures all backup jobs are linked as sources. + +**What it does:** +1. Finds the S3 repository (from NinjaRMM field or auto-detect) +2. Collects all source backup jobs (Get-VBRJob + Get-VBRComputerBackupJob, excludes copy jobs) +3. If a copy job targeting the S3 repo exists: adds missing sources, renames to match pattern +4. If none exists: creates one in Periodic mode +5. Applies schedule, encryption, and enables the job (every run, not just creation) + +**Schedule:** Mon-Fri 10 PM - 5 AM, Sat-Sun all day + +**NinjaRMM Fields:** + +| Variable | Level | Type | Purpose | +|----------|-------|------|---------| +| `CUSTOM_FIELD_S3_BUCKET_NAME` | Device | Text | Used to find the S3 repo | +| `CUSTOM_FIELD_CLOUD_RETENTION` | Org | Text | Retention days (default: 30) | + +### veeam-configure-local-backup-jobs.ps1 +Configures backup windows on all local backup jobs. + +**Schedule:** Mon-Fri 6 AM - 9 PM, Sat-Sun disabled + +This complements the S3 copy job schedule. 1-hour buffer (9 PM - 10 PM) between local backups stopping and S3 copy starting. + +### veeam-inventory.ps1 +Comprehensive inventory and health check. Populates multiple NinjaRMM fields. + +**What it detects:** +- S3 bucket name and storage size (via Veeam backup copy job + child backup storages) +- Orphaned/stale backups across all repos (configurable threshold, default 30 days) +- Failed backup jobs (checks most recent session per job, clears when job succeeds) +- Missing S3 copy job (no S3 repo, no copy job, or missing source jobs) + +**NinjaRMM Fields:** + +| Variable | Type | Purpose | +|----------|------|---------| +| `CUSTOM_FIELD_S3_BUCKET_NAME` | Text | Last used S3 bucket name | +| `CUSTOM_FIELD_S3_BUCKET_SIZE` | Text | Last used S3 bucket size | +| `CUSTOM_FIELD_S3_INVENTORY` | WYSIWYG | HTML table of all S3 repos | +| `CUSTOM_FIELD_ORPHANS_FOUND` | Checkbox | 1 if orphaned backups exist | +| `CUSTOM_FIELD_ORPHANED_BACKUPS` | WYSIWYG | HTML table of orphaned backups | +| `CUSTOM_FIELD_FAILED_BACKUP` | Checkbox | 1 if any job's last run failed | +| `CUSTOM_FIELD_FAILED_BACKUPS` | WYSIWYG | HTML table of failed jobs | +| `CUSTOM_FIELD_S3_COPY_MISSING` | Checkbox | 1 if S3 copy job missing/incomplete | +| `ORPHAN_DAYS_THRESHOLD` | Config | Days before stale (default: 30) | + +### veeam-clear-management-server.ps1 +Removes old Veeam management server registration from endpoints. Run on the endpoint (not the server). + +**Use when:** Migrating endpoints to a new Veeam B&R server and getting "host is managed by another backup server" error. + +## Backblaze B2 Scripts (iaas-backblaze/) + +### b2-bucket-audit.ps1 +Lists all B2 buckets in the account, compares against a known-good list, flags unused buckets in red. + +### b2-fix-lifecycle.ps1 +Applies lifecycle rules to existing B2 buckets to purge hidden file versions. Fixes storage bloat from B2 keeping all versions forever. + +## Technical Reference + +### PS7 Bootstrap +All scripts that use the Veeam PowerShell module include a PS7 bootstrap. Veeam 12.x requires PowerShell 7+. + +```powershell +if ($PSVersionTable.PSVersion.Major -lt 7) { + $PWSH_PATH = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (-not (Test-Path $PWSH_PATH)) { + $PWSH_CANDIDATE = Get-Command pwsh.exe -ErrorAction SilentlyContinue + $PWSH_PATH = if ($PWSH_CANDIDATE) { $PWSH_CANDIDATE.Source } else { $null } + } + # ... +} +``` + +**Critical:** Always prefer 64-bit PS7 (`$env:ProgramFiles`, not `$env:ProgramFiles(x86)`). Veeam's native SQLite DLL is x64 only. Using 32-bit PS7 causes `SqliteConnection` type initializer failures. + +### PS7.4+ SQLite Conflict +PowerShell 7.4+ ships `Microsoft.Data.Sqlite` that conflicts with Veeam's bundled version. The fix has two parts: + +1. **Add Veeam's native DLL path to PATH** so `e_sqlite3.dll` is found: +```powershell +$VEEAM_RUNTIMES = "$env:ProgramFiles\Veeam\Backup and Replication\Backup\runtimes\win-x64\native" +$env:PATH = "$VEEAM_RUNTIMES;$env:PATH" +``` + +2. **Register an AssemblyResolve handler** to redirect managed DLL loads to Veeam's versions: +```powershell +$null = [System.AppDomain]::CurrentDomain.add_AssemblyResolve({ + param($sender, $args) + if (-not $args.Name) { return $null } # Null check required + $ASSEMBLY_NAME = [System.Reflection.AssemblyName]::new($args.Name) + $VEEAM_DLL = Join-Path $VEEAM_BACKUP_DIR "$($ASSEMBLY_NAME.Name).dll" + if (Test-Path $VEEAM_DLL) { return [System.Reflection.Assembly]::LoadFrom($VEEAM_DLL) } + return $null +}) +``` + +**Important:** The null check on `$args.Name` is required. .NET passes empty assembly names which crash the handler without it. + +### Veeam PowerShell Gotchas (Veeam 12.x) + +**Non-interactive execution:** +- Do NOT use `-NonInteractive` in the PS7 bootstrap. Veeam cmdlets use `Read-Host` internally for certificate trust prompts, which hard-fails in NonInteractive mode (hangs instead of erroring). +- Set `$ConfirmPreference = 'None'` before Veeam cmdlet calls. +- `-Confirm:$false` is NOT a valid parameter on most Veeam cmdlets (they don't implement `ShouldProcess`). + +**Add-VBRAmazonS3CompatibleRepository:** +- `-EnableBucketAutoProvision:$false` is required. Default changed to `$true` in Veeam 12.3.1 and hangs on non-AWS S3 endpoints. +- `-EnableBackupImmutability` is required when the B2 bucket has Object Lock enabled. Without it: "Enable backup immutability because the S3 Object Lock feature is enabled." +- Do NOT set bucket-level `defaultRetention` via B2 API. Veeam manages immutability per-object. Bucket default causes "default retention is not supported" errors. + +**Add-VBRBackupCopyJob:** +- `-Mode` is a required parameter (values: `Periodic`, `Immediate`). Script hangs if not provided. +- Periodic mode: uses `ScheduleOptions`, does NOT support `Anytime` or `BackupWindowOptions`. +- Immediate mode: supports `Anytime` and `BackupWindowOptions`. +- Job name max length: 50 characters. + +**New-VBRBackupCopyJobStorageOptions:** +- `CompressionLevel` and `StorageOptimizationType` are MANDATORY. Hangs if not provided. +- CompressionLevel values: `Auto`, `None`, `DedupeFriendly`, `Optimal`, `High`, `Extreme` +- StorageOptimizationType values: `Automatic`, `LocalTarget`, `LocalTargetHugeBackup`, `LANTarget`, `WANTarget` +- Use `Automatic` for copy jobs (not `LocalTarget` which errors on "image-level backup copy job"). + +**Get-VBRObjectStorageRepository:** +- Returns skeleton objects with NULL sub-properties (ArchiveRepository, AmazonCompatibleOptions, Options, Info all null). +- To get bucket name: use `Get-VBRBackupCopyJob` -> `.TargetRepository.AmazonCompatibleOptions.BucketName` instead. + +**GetObjects() on agent backup jobs:** +- Returns 0 for Windows Agent Backup/Policy types. Only works for VM backups. +- For agent backups, use child backup names or time-based detection instead. + +**Backup size calculation:** +- Parent backups with `IsTruePerVmContainer = true` have 0 storages. +- Must call `FindChildBackups()` and sum `GetAllStorages()` -> `Stats.BackupSize` across children. + +**RetentionType enum:** +- Values are `RestoreDays` and `RestorePoints` (not `Days` or `Cycles`). + +### Backblaze B2 API Reference + +**Authentication:** +- Use `-Authentication Basic -Credential $PSCredential` (not manual header construction). B2 auth tokens contain `=` characters that `Invoke-RestMethod` header validation rejects. +- For post-auth API calls, use `-SkipHeaderValidation $true` in the headers. +- API v2 is the most reliable. v4 works but v3 does not exist. + +**Bucket creation:** +- `fileLockEnabled: true` enables Object Lock (required for Veeam immutability). Cannot be added after creation. +- Object Lock requires versioning (automatic). Cannot disable versioning on Object Lock buckets. +- Set lifecycle rule `daysFromHidingToDeleting` to purge old hidden versions. Without this, B2 keeps ALL versions forever (causes massive storage bloat, e.g. 11 TB active = 50 TB in B2). + +**Scoped application keys:** +- Must include `listAllBucketNames` capability for bucket-restricted keys. Without it, Veeam gets "Invalid credentials" because S3 ListBuckets fails. +- Full capability list for Veeam: `listBuckets`, `listAllBucketNames`, `readBuckets`, `listFiles`, `readFiles`, `writeFiles`, `deleteFiles`, `readBucketEncryption`, `writeBucketEncryption`, `readBucketRetentions`, `writeBucketRetentions`, `readFileRetentions`, `writeFileRetentions`, `readFileLegalHolds`, `writeFileLegalHolds`, `bypassGovernance` + +**Daily usage reports:** +- No real-time bucket size API exists. +- B2 generates daily audit CSVs in `b2-reports-{accountId}` bucket with `storageByteCount` per bucket. + +### NinjaRMM Integration + +**Org-level fields:** Read with `Ninja-Property-Get $env:FIELD_NAME`. The env var contains the field API name (e.g. `dtcOrgGuid`), not the value. + +**Device-level fields:** Write with `Ninja-Property-Set $fieldName $value`. Read with `Ninja-Property-Get $fieldName`. + +**Important:** `Ninja-Property-Get -Organization` does NOT work from scripts. Org-level fields must be configured to inherit to device level in NinjaRMM, then read normally.