diff --git a/VB365 Config Migrator/Export-VB365Config_v2.ps1 b/VB365 Config Migrator/Export-VB365Config_v2.ps1 new file mode 100644 index 0000000..5952983 --- /dev/null +++ b/VB365 Config Migrator/Export-VB365Config_v2.ps1 @@ -0,0 +1,746 @@ +<# +.SYNOPSIS + Exports Veeam Backup for Microsoft 365 organizational configuration and application registrations. + +.DESCRIPTION + Connects to a VB365 server and exports: + • Server info, version, component status + • License details + • All settings (email, internet proxy, REST API, security, restore portal, + tenant authentication, history, RBAC roles) + • Backup proxy servers and pools + • Repositories (local, Amazon S3, Azure Blob, S3-compatible) + • Encryption keys + • Cloud credential registrations: + – Azure service accounts (app registrations used for Azure object storage) + – Azure Blob storage accounts + – Amazon S3 accounts (access key ID exported; secret key is not readable) + – S3-compatible accounts + • All organizations with: + – Backup application certificates (PFX exported from Windows cert store) + – Version backup options and retention exclusions + • Backup jobs with schedule and items (requires -IncludeJobs) + • Backup copy jobs (requires -IncludeJobs) + • Federated authentication authorities + + NOTE: Cloud account secret keys and passwords are stored encrypted inside VB365 + and cannot be retrieved via PowerShell. Only account metadata (IDs, names, access + keys) is exported. Secret keys must be re-entered manually during a restore. + + The resulting folder can be used to restore/recreate a VB365 configuration. + +.PARAMETER Server + VB365 server hostname or IP. Defaults to localhost. + +.PARAMETER Port + Optional. Only specify if your VB365 management service was moved to a non-default port. + +.PARAMETER Credential + PSCredential for the VB365 server. Omit to use the current Windows session. + +.PARAMETER OutputPath + Root folder for the export. A timestamped sub-folder is created inside. + +.PARAMETER CertificatePassword + SecureString password for every exported PFX file. Prompted if omitted. + +.PARAMETER IncludeJobs + Also export backup job and backup copy job configuration. + +.EXAMPLE + # Run locally on the VB365 server + .\Export-VB365Config.ps1 -OutputPath C:\VB365_Backup + +.EXAMPLE + # Remote, explicit credentials, include jobs + $cred = Get-Credential + .\Export-VB365Config.ps1 -Server vb365.corp.local -Credential $cred ` + -OutputPath D:\Exports -IncludeJobs + +.EXAMPLE + # Override only if the VB365 management service was moved to a custom port + .\Export-VB365Config.ps1 -OutputPath C:\VB365_Backup -Port 9191 +#> + +[CmdletBinding()] +param( + [string] $Server = 'localhost', + # Only set this if your management service runs on a non-default port. + [Nullable[int]] $Port, + [PSCredential] $Credential, + [Parameter(Mandatory)] + [string] $OutputPath, + [string] $CertificatePassword, + [switch] $IncludeJobs +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +#region ── Helpers ──────────────────────────────────────────────────────────── + +function Write-Step { param([string]$m) Write-Host " [*] $m" -ForegroundColor Cyan } +function Write-Ok { param([string]$m) Write-Host " [+] $m" -ForegroundColor Green } +function Write-Warn { param([string]$m) Write-Host " [!] $m" -ForegroundColor Yellow } + +function ConvertTo-SafeFileName { + param([string]$Name) + $Name -replace '[\\/:*?"<>|]', '_' +} + +function Invoke-Collect { + # Run a scriptblock, return the result; on error warn and return $null. + param([string]$Label, [scriptblock]$ScriptBlock) + try { $r = & $ScriptBlock; Write-Ok $Label; return $r } + catch { Write-Warn "$Label — skipped: $_"; return $null } +} + +function ConvertTo-PropertyHashtable { + # Reflect all readable properties of any object into an ordered hashtable. + # Depth controls how many levels of nested objects are expanded (default 1 = flat). + # Primitive types (string, int, bool, Guid, DateTime, enum) are always stringified. + # Complex nested objects are expanded up to $Depth levels; beyond that .ToString() is used. + param($Object, [int]$Depth = 1) + + $primitiveTypes = @( + [string], [int], [long], [double], [float], [bool], + [datetime], [guid], [timespan] + ) + + $h = [ordered]@{} + $Object.PSObject.Properties | + Where-Object { $_.MemberType -in 'NoteProperty','Property' } | + ForEach-Object { + $val = $_.Value + if ($null -eq $val) { + $h[$_.Name] = $null + } elseif ($val.GetType().IsEnum -or ($primitiveTypes | Where-Object { $val -is $_ })) { + $h[$_.Name] = try { $val.ToString() } catch { $null } + } elseif ($Depth -gt 0) { + # Recurse into nested object + try { $h[$_.Name] = ConvertTo-PropertyHashtable $val ($Depth - 1) } + catch { $h[$_.Name] = try { $val.ToString() } catch { $null } } + } else { + $h[$_.Name] = try { $val.ToString() } catch { $null } + } + } + $h +} + +function Export-CertificateFromStore { + param( + [string] $Thumbprint, + [string] $DestinationPath, + [SecureString] $Password + ) + $clean = $Thumbprint -replace '\s', '' + $stores = 'Cert:\LocalMachine\My','Cert:\CurrentUser\My','Cert:\LocalMachine\Root','Cert:\LocalMachine\CA' + foreach ($store in $stores) { + $cert = Get-ChildItem $store -ErrorAction SilentlyContinue | + Where-Object { $_.Thumbprint -ieq $clean } | Select-Object -First 1 + if ($cert) { + try { + Export-PfxCertificate -Cert $cert -FilePath $DestinationPath -Password $Password -Force | Out-Null + return $true + } catch { Write-Warn "Found cert in $store but export failed: $_"; return $false } + } + } + Write-Warn "Certificate $clean not found in any local certificate store." + return $false +} + +#endregion + +#region ── Initialise output directory ─────────────────────────────────────── + +$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' +$exportRoot = Join-Path $OutputPath "VB365_Export_$timestamp" +$certDir = Join-Path $exportRoot 'Certificates' + +New-Item -ItemType Directory -Path $exportRoot -Force | Out-Null +New-Item -ItemType Directory -Path $certDir -Force | Out-Null + +Write-Host "`nVeeam Backup for Microsoft 365 — Configuration Export" -ForegroundColor White +Write-Host "Output : $exportRoot`n" + +#endregion + +#region ── Certificate password ────────────────────────────────────────────── + +if (-not $CertificatePassword) { + Write-Host "A password is required to protect exported PFX files." -ForegroundColor Yellow + $CertificatePasswordSecure = Read-Host -AsSecureString -Prompt "PFX export password" +} else { + $CertificatePasswordSecure = $CertificatePassword | ConvertTo-SecureString -AsPlainText -Force +} + +#endregion + +#region ── Load module ─────────────────────────────────────────────────────── + +Write-Step "Loading Veeam.Archiver.PowerShell module" +if (-not (Get-Module -ListAvailable -Name 'Veeam.Archiver.PowerShell')) { + throw "Veeam.Archiver.PowerShell module not found. Run this script on the VB365 server." +} +Import-Module Veeam.Archiver.PowerShell -DisableNameChecking -ErrorAction Stop +Write-Ok "Module loaded" + +#endregion + +#region ── Connect ─────────────────────────────────────────────────────────── + +Write-Step "Connecting to VB365 server: $Server" + +$connectParams = @{} +# Only pass Server/Port when connecting remotely — supplying 'localhost' explicitly +# causes a handshake failure; omitting it lets the module connect to the local service directly. +if ($Server -notin @('localhost', '127.0.0.1', $env:COMPUTERNAME)) { + $connectParams['Server'] = $Server + if ($null -ne $Port) { $connectParams['Port'] = $Port } +} +if ($Credential) { $connectParams['Credential'] = $Credential } + +Connect-VBOServer @connectParams +Write-Ok "Connected" + +#endregion + +#region ── SERVER INFO ─────────────────────────────────────────────────────── + +Write-Step "Collecting server information" + +$serverInfo = [ordered]@{ + ExportDate = (Get-Date -Format 'o') + Server = $Server + Port = $Port +} + +$serverInfo['Version'] = Invoke-Collect "Version" { + $v = Get-VBOVersion + [ordered]@{ ProductVersion = $v.ProductVersion; Build = if ($v.PSObject.Properties['Build']) { $v.Build } else { $null } } +} + +$serverInfo['ServerComponents'] = Invoke-Collect "Server components" { + Get-VBOServerComponents | ForEach-Object { + [ordered]@{ + Name = $_.Name + Version = if ($_.PSObject.Properties['Version']) { $_.Version.ToString() } else { $null } + Status = if ($_.PSObject.Properties['Status']) { $_.Status.ToString() } else { $null } + } + } +} + +$serverInfo['License'] = Invoke-Collect "License" { + $lic = Get-VBOLicense + [ordered]@{ + Status = $lic.Status.ToString() + Type = $lic.Type.ToString() + ExpirationDate = $lic.ExpirationDate.ToString('o') + TotalNumber = $lic.TotalNumber + } +} + +$serverInfo['EmailSettings'] = Invoke-Collect "Email settings" { + $s = Get-VBOEmailSettings + [ordered]@{ + EnableNotification = $s.EnableNotification + SMTPServer = $s.SMTPServer + Port = $s.Port + UseSSL = $s.UseSSL + UseAuthentication = $s.UseAuthentication + From = $s.From + To = $s.To + } +} + +$serverInfo['InternetProxySettings'] = Invoke-Collect "Internet proxy settings" { ConvertTo-PropertyHashtable (Get-VBOInternetProxySettings) } +$serverInfo['RestAPISettings'] = Invoke-Collect "REST API settings" { ConvertTo-PropertyHashtable (Get-VBORestAPISettings) } +$serverInfo['SecuritySettings'] = Invoke-Collect "Security settings" { ConvertTo-PropertyHashtable (Get-VBOSecuritySettings) } +$serverInfo['RestorePortalSettings'] = Invoke-Collect "Restore portal settings" { ConvertTo-PropertyHashtable (Get-VBORestorePortalSettings) } +$serverInfo['OperatorAuthSettings'] = Invoke-Collect "Operator authentication settings" { ConvertTo-PropertyHashtable (Get-VBOOperatorAuthenticationSettings) } +$serverInfo['TenantAuthSettings'] = Invoke-Collect "Tenant authentication settings" { ConvertTo-PropertyHashtable (Get-VBOTenantAuthenticationSettings) } +$serverInfo['HistorySettings'] = Invoke-Collect "History settings" { ConvertTo-PropertyHashtable (Get-VBOHistorySettings) } +$serverInfo['FolderExclusions'] = Invoke-Collect "Global folder exclusions" { ConvertTo-PropertyHashtable (Get-VBOFolderExclusions) } + +$serverInfo['GlobalRetentionExclusions'] = Invoke-Collect "Global retention exclusions" { + @(Get-VBOGlobalRetentionExclusion | ForEach-Object { ConvertTo-PropertyHashtable $_ }) +} + +$serverInfo['RbacRoles'] = Invoke-Collect "RBAC roles" { + @(Get-VBORbacRole | ForEach-Object { + [ordered]@{ + Id = $_.Id.ToString() + Name = $_.Name + Description = $_.Description + RoleType = if ($_.PSObject.Properties['RoleType']) { $_.RoleType.ToString() } else { $null } + } + }) +} + +$serverInfo['FederatedAuthAuthorities'] = Invoke-Collect "Federated authentication authorities" { + @(Get-VBOFederatedAuthenticationAuthority | ForEach-Object { + [ordered]@{ + Id = $_.Id.ToString() + Name = if ($_.PSObject.Properties['Name']) { $_.Name } else { $null } + Issuer = if ($_.PSObject.Properties['Issuer']) { $_.Issuer } else { $null } + JwksUri = if ($_.PSObject.Properties['JwksUri']) { $_.JwksUri } else { $null } + } + }) +} + +$serverInfo | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $exportRoot 'ServerInfo.json') -Encoding UTF8 + +#endregion + +#region ── BACKUP PROXY SERVERS & POOLS ────────────────────────────────────── + +Write-Step "Collecting backup proxy servers" + +$proxies = Invoke-Collect "Backup proxy servers" { + @(Get-VBOProxy | ForEach-Object { + [ordered]@{ + Id = $_.Id.ToString() + Hostname = $_.Hostname + Description = $_.Description + Type = if ($_.PSObject.Properties['Type']) { $_.Type.ToString() } else { $null } + Port = if ($_.PSObject.Properties['Port']) { $_.Port } else { $null } + Status = if ($_.PSObject.Properties['Status']) { $_.Status.ToString() } else { $null } + } + }) +} +if ($proxies) { $proxies | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $exportRoot 'ProxyServers.json') -Encoding UTF8 } + +$proxyPools = Invoke-Collect "Backup proxy pools" { + @(Get-VBOProxyPool | ForEach-Object { + [ordered]@{ + Id = $_.Id.ToString() + Name = $_.Name + Description = $_.Description + Proxies = @($_.Proxies | ForEach-Object { $_.Hostname }) + } + }) +} +if ($proxyPools) { $proxyPools | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $exportRoot 'ProxyPools.json') -Encoding UTF8 } + +#endregion + +#region ── ENCRYPTION KEYS ─────────────────────────────────────────────────── + +Write-Step "Collecting encryption keys" + +$encKeys = Invoke-Collect "Encryption keys" { + @(Get-VBOEncryptionKey | ForEach-Object { + [ordered]@{ + Id = $_.Id.ToString() + Description = $_.Description + Hint = if ($_.PSObject.Properties['Hint']) { $_.Hint } else { $null } + } + }) +} +if ($encKeys) { $encKeys | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $exportRoot 'EncryptionKeys.json') -Encoding UTF8 } + +#endregion + +#region ── CLOUD CREDENTIAL REGISTRATIONS ──────────────────────────────────── +# Secret keys and passwords are stored encrypted inside VB365 and cannot be +# retrieved via PowerShell. Only account metadata is exported here. +# Secret keys must be re-entered manually when restoring the configuration. + +Write-Step "Collecting cloud credential registrations" + +$cloudCreds = [ordered]@{ + ExportNote = "Secret keys and passwords are NOT included — they are stored encrypted and cannot be retrieved. Re-enter them manually during restore." +} + +$cloudCreds['AzureServiceAccounts'] = Invoke-Collect "Azure service accounts" { + @(Get-VBOAzureServiceAccount | ForEach-Object { + $acct = $_ + $detail = ConvertTo-PropertyHashtable $acct + + $thumbprint = if ($acct.PSObject.Properties['ApplicationCertificateThumbprint']) { $acct.ApplicationCertificateThumbprint } else { $null } + $detail['CertificateExportFile'] = $null + $detail['CertificateExported'] = $false + $detail['CertificateExpiry'] = $null + + if ($thumbprint) { + $safe = ConvertTo-SafeFileName $acct.ApplicationId.ToString() + $short = $thumbprint.Substring(0, [Math]::Min(8, $thumbprint.Length)) + $pfxName = "AzureServiceAccount_{0}_{1}.pfx" -f $safe, $short + $pfxPath = Join-Path $certDir $pfxName + + $exported = Export-CertificateFromStore -Thumbprint $thumbprint -DestinationPath $pfxPath -Password $CertificatePasswordSecure + + $clean = $thumbprint -replace '\s', '' + foreach ($store in @('Cert:\LocalMachine\My','Cert:\CurrentUser\My','Cert:\LocalMachine\Root','Cert:\LocalMachine\CA')) { + $certObj = Get-ChildItem $store -ErrorAction SilentlyContinue | + Where-Object { $_.Thumbprint -ieq $clean } | Select-Object -First 1 + if ($certObj) { $detail['CertificateExpiry'] = $certObj.NotAfter.ToString('o'); break } + } + + $detail['CertificateExportFile'] = if ($exported) { $pfxName } else { $null } + $detail['CertificateExported'] = $exported + + $status = if ($exported) { "(cert exported)" } else { "(cert NOT found in local store)" } + Write-Ok " Azure service account '$($acct.Name)' | Thumbprint: $thumbprint $status" + } + + $detail + }) +} + +$cloudCreds['AzureBlobAccounts'] = Invoke-Collect "Azure Blob storage accounts" { + @(Get-VBOAzureBlobAccount | ForEach-Object { ConvertTo-PropertyHashtable $_ }) +} + +$cloudCreds['AmazonS3Accounts'] = Invoke-Collect "Amazon S3 accounts" { + @(Get-VBOAmazonS3Account | ForEach-Object { ConvertTo-PropertyHashtable $_ }) +} + +$cloudCreds['AmazonS3CompatibleAccounts'] = Invoke-Collect "Amazon S3-compatible accounts" { + @(Get-VBOAmazonS3CompatibleAccount | ForEach-Object { ConvertTo-PropertyHashtable $_ }) +} + +$cloudCreds | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $exportRoot 'CloudCredentials.json') -Encoding UTF8 + +#endregion + +#region ── REPOSITORIES ────────────────────────────────────────────────────── + +Write-Step "Collecting repositories" + +$repos = Invoke-Collect "Repositories" { + @(Get-VBORepository | ForEach-Object { + $r = $_ + + $d = ConvertTo-PropertyHashtable $r -Depth 2 + + $osRepo = if ($r.PSObject.Properties['ObjectStorageRepository']) { $r.ObjectStorageRepository } else { $null } + $isObjStorage = $osRepo -and $osRepo -isnot [string] + $d['IsObjectStorageRepo'] = ($isObjStorage -or -not [string]::IsNullOrWhiteSpace("$osRepo")) + + # Explicitly navigate the object hierarchy to extract the fields required + # for import: type, folder name, container/bucket name, and account name. + # IDs are NOT captured for account matching — IDs change on a fresh install; + # the import script matches cloud credentials by name instead. + if ($isObjStorage) { + $d['ObjectStorageType'] = try { $r.ObjectStorageRepository.Type.ToString() } catch { $null } + $d['ObjectStorageFolderName'] = $null + $d['ObjectStoragePath'] = $null + $d['ObjectStorageContainerName'] = $null + $d['ObjectStorageBucketName'] = $null + $d['ObjectStorageAccountName'] = $null + $d['ObjectStorageRegionType'] = $null + $d['ObjectStorageRegionId'] = $null + $d['ObjectStorageServicePoint'] = $null + $d['ObjectStorageCustomRegionId']= $null + $d['ObjectStorageTrustCert'] = $null + + # The Folder object exposes Name and Path directly regardless of storage type. + # Full chained access is required — intermediate variable assignment loses + # the VB365 type adapter context for Folder sub-properties. + try { $d['ObjectStorageFolderName'] = $r.ObjectStorageRepository.Folder.Name.ToString() } catch { } + try { $d['ObjectStoragePath'] = $r.ObjectStorageRepository.Folder.Path.ToString() } catch { } + + # Azure Blob: Folder → Container + try { $d['ObjectStorageContainerName'] = $r.ObjectStorageRepository.Folder.Container.Name.ToString() } catch { } + try { $d['ObjectStorageRegionType'] = $r.ObjectStorageRepository.Folder.Container.RegionType.ToString() } catch { } + try { $d['ObjectStorageAccountName'] = $r.ObjectStorageRepository.Folder.Container.ConnectionSettings.Account.ToString() } catch { } + + # S3 / S3-Compatible: Folder → Bucket + try { $d['ObjectStorageBucketName'] = $r.ObjectStorageRepository.Folder.Bucket.Name.ToString() } catch { } + try { $d['ObjectStorageRegionId'] = $r.ObjectStorageRepository.Folder.Bucket.RegionId.ToString() } catch { } + try { $d['ObjectStorageAccountName'] = $r.ObjectStorageRepository.Folder.Bucket.ConnectionSettings.Account.ToString() } catch { } + try { $d['ObjectStorageRegionType'] = $r.ObjectStorageRepository.Folder.Bucket.ConnectionSettings.RegionType.ToString() } catch { } + try { $d['ObjectStorageServicePoint'] = $r.ObjectStorageRepository.Folder.Bucket.ConnectionSettings.ServicePoint.ToString() } catch { } + try { $d['ObjectStorageCustomRegionId']= $r.ObjectStorageRepository.Folder.Bucket.ConnectionSettings.CustomRegionId.ToString() } catch { } + try { $d['ObjectStorageTrustCert'] = [bool]$r.ObjectStorageRepository.Folder.Bucket.ConnectionSettings.TrustServerCertificate } catch { } + } + + $d + }) +} +if ($repos) { $repos | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $exportRoot 'Repositories.json') -Encoding UTF8 } + +#endregion + +#region ── ORGANIZATIONS ───────────────────────────────────────────────────── + +Write-Step "Collecting organizations and application registrations" + +$allOrgs = @(Get-VBOOrganization) +$orgsExport = @() +Write-Ok "$($allOrgs.Count) organization(s) found" + +foreach ($org in $allOrgs) { + Write-Step " Processing: $($org.Name)" + + $orgDetail = [ordered]@{ + Id = $org.Id.ToString() + Name = $org.Name + Type = $org.Type.ToString() + Region = if ($org.PSObject.Properties['Region']) { $org.Region.ToString() } else { $null } + UseVeeamAADApplication = if ($org.PSObject.Properties['UseVeeamAADApplication']) { [bool]$org.UseVeeamAADApplication } else { $false } + BackupApplications = @() + VersionBackupOptions = $null + RetentionExclusions = @() + } + + # Certificate lives in the org's connection settings (Exchange + SharePoint share the same app/cert). + # Deduplicate by thumbprint so we export one PFX per unique certificate. + Invoke-Collect " Backup application certificates" { + $seen = @{} + foreach ($propName in @('Office365ExchangeConnectionSettings','Office365SharePointConnectionSettings')) { + if (-not ($org.PSObject.Properties[$propName] -and $org.$propName)) { continue } + $cs = $org.$propName + $appId = if ($cs.PSObject.Properties['ApplicationId']) { $cs.ApplicationId.ToString() } else { $null } + $thumbprint = if ($cs.PSObject.Properties['ApplicationCertificateThumbprint']) { $cs.ApplicationCertificateThumbprint } else { $null } + $authType = if ($cs.PSObject.Properties['AuthenticationType']) { $cs.AuthenticationType.ToString() } else { $null } + $impersonationAcct = if ($cs.PSObject.Properties['ImpersonationAccountName']) { $cs.ImpersonationAccountName } else { $null } + $officeOrgName = if ($cs.PSObject.Properties['OfficeOrganizationName']) { $cs.OfficeOrganizationName } else { $null } + + if (-not $thumbprint -or $seen[$thumbprint]) { continue } + $seen[$thumbprint] = $true + + $pfxName = "BackupApp_{0}_{1}.pfx" -f (ConvertTo-SafeFileName $appId), $thumbprint.Substring(0, [Math]::Min(8, $thumbprint.Length)) + $pfxPath = Join-Path $certDir $pfxName + + $exported = Export-CertificateFromStore -Thumbprint $thumbprint -DestinationPath $pfxPath -Password $CertificatePasswordSecure + + $certExpiry = $null + $clean = $thumbprint -replace '\s', '' + foreach ($store in @('Cert:\LocalMachine\My','Cert:\CurrentUser\My','Cert:\LocalMachine\Root','Cert:\LocalMachine\CA')) { + $certObj = Get-ChildItem $store -ErrorAction SilentlyContinue | + Where-Object { $_.Thumbprint -ieq $clean } | Select-Object -First 1 + if ($certObj) { $certExpiry = $certObj.NotAfter.ToString('o'); break } + } + + $orgDetail.BackupApplications += [ordered]@{ + ApplicationId = $appId + AuthenticationType = $authType + ImpersonationAccountName = $impersonationAcct + OfficeOrganizationName = $officeOrgName + CertificateThumbprint = $thumbprint + CertificateExpiry = $certExpiry + CertificateExportFile = if ($exported) { $pfxName } else { $null } + CertificateExported = $exported + } + + $status = if ($exported) { "(cert exported)" } else { "(cert NOT found in local store)" } + Write-Ok " App $appId | Thumbprint: $thumbprint $status" + } + "$($orgDetail.BackupApplications.Count) backup app cert(s)" + } | Out-Null + + $orgDetail.VersionBackupOptions = Invoke-Collect " Version backup options" { + $v = Get-VBOVersionBackupOptions -Organization $org + $h = [ordered]@{} + $v.PSObject.Properties | Where-Object { $_.MemberType -in 'NoteProperty','Property' } | + ForEach-Object { $h[$_.Name] = if ($null -ne $_.Value) { $_.Value.ToString() } else { $null } } + $h + } + + Invoke-Collect " Retention exclusions" { + $excl = @(Get-VBOOrganizationRetentionExclusion -Organization $org) + foreach ($e in $excl) { + $orgDetail.RetentionExclusions += [ordered]@{ + Id = if ($e.PSObject.Properties['Id']) { $e.Id.ToString() } else { $null } + Type = if ($e.PSObject.Properties['Type']) { $e.Type.ToString() } else { $null } + Name = if ($e.PSObject.Properties['DisplayName']) { $e.DisplayName } else { $null } + } + } + "$($excl.Count) exclusion(s)" + } | Out-Null + + $orgsExport += $orgDetail +} + +$orgsExport | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $exportRoot 'Organizations.json') -Encoding UTF8 + +#endregion + +#region ── BACKUP JOBS ─────────────────────────────────────────────────────── + +$exportedJobCount = 0 +$exportedCopyJobCount = 0 + +if ($IncludeJobs) { + $jobsDir = Join-Path $exportRoot 'Jobs' + New-Item -ItemType Directory -Path $jobsDir -Force | Out-Null + + # ── Backup jobs ─────────────────────────────────────────────────────────── + Write-Step "Collecting backup jobs" + + @(Get-VBOJob) | ForEach-Object { + $job = $_ + try { + $jd = [ordered]@{ + Id = $job.Id.ToString() + Name = $job.Name + Description = $(try { $job.Description } catch { $null }) + IsEnabled = $(try { [bool]$job.IsEnabled } catch { $true }) + IsEntireOrganization = $(try { [bool]$job.IsEntireOrganization } catch { $false }) + OrganizationName = if ($job.Organization) { $job.Organization.Name } else { $null } + RepositoryName = if ($job.Repository) { $job.Repository.Name } else { $null } + SchedulePolicy = $null + SelectedItems = @() + ExcludedItems = @() + } + + # Schedule policy — extract each field explicitly for clean re-import + try { + $sp = $job.SchedulePolicy + if ($sp) { + $jd.SchedulePolicy = [ordered]@{ + EnableSchedule = $(try { [bool]$sp.EnableSchedule } catch { $true }) + Type = $(try { $sp.Type.ToString() } catch { 'Daily' }) + DailyTime = $(try { $sp.DailyTime.ToString() } catch { '15:00:00' }) + DailyType = $(try { $sp.DailyType.ToString() } catch { 'Everyday' }) + PeriodicallyEvery = $(try { $sp.PeriodicallyEvery.ToString() } catch { $null }) + RetryEnabled = $(try { [bool]$sp.RetryEnabled } catch { $false }) + RetryNumber = $(try { [int]$sp.RetryNumber } catch { 3 }) + RetryWaitInterval = $(try { [int]$sp.RetryWaitInterval } catch { 10 }) + } + } + } catch { Write-Warn " Schedule for '$($job.Name)': $_" } + + # Selected / excluded items with workload flags and identifying properties + try { + $items = @(Get-VBOBackupItem -Job $job) + foreach ($item in $items) { + $isExcluded = if ($item.PSObject.Properties['IsExcluded']) { [bool]$item.IsExcluded } else { $false } + + $entry = [ordered]@{ + Type = $(try { $item.Type.ToString() } catch { $null }) + IsExcluded = $isExcluded + User = $null + Group = $null + Site = $null + Team = $null + } + + # Enumerate ALL boolean properties on the item so every workload flag + # is captured regardless of name. Skip only the known non-flag properties. + # Note: 'Site' is intentionally NOT skipped — for User-type items it is a + # boolean flag (include personal SharePoint site), not a sub-object. + $skipProps = @('Type','IsExcluded','User','Group','Team','Organization','Id') + foreach ($prop in $item.PSObject.Properties) { + if ($prop.Name -in $skipProps) { continue } + $v = $prop.Value + if ($v -is [bool]) { $entry[$prop.Name] = $v } + } + + if ($item.PSObject.Properties['User'] -and $item.User) { + $u = $item.User + $entry.User = [ordered]@{ + DisplayName = $(try { $u.DisplayName } catch { $null }) + UserName = $(try { $u.UserName } catch { $null }) + } + } + if ($item.PSObject.Properties['Group'] -and $item.Group) { + $g = $item.Group + $entry.Group = [ordered]@{ + DisplayName = $(try { $g.DisplayName } catch { $null }) + Name = $(try { $g.Name } catch { $null }) + } + } + # Site is a boolean flag on User-type items (personal SharePoint site). + # Only extract it as a sub-object when it is an actual site object. + if ($item.PSObject.Properties['Site'] -and $item.Site -and $item.Site -isnot [bool]) { + $s = $item.Site + $entry.Site = [ordered]@{ + Title = $(try { $s.Title } catch { $null }) + Url = $(try { $s.Url } catch { $null }) + } + } + if ($item.PSObject.Properties['Team'] -and $item.Team) { + $t = $item.Team + $entry.Team = [ordered]@{ + DisplayName = $(try { $t.DisplayName } catch { $null }) + Name = $(try { $t.Name } catch { $null }) + } + } + + if ($isExcluded) { $jd.ExcludedItems += $entry } + else { $jd.SelectedItems += $entry } + } + } catch { Write-Warn " Items for '$($job.Name)': $_" } + + $safeName = (ConvertTo-SafeFileName $job.Name) -replace '\s+', '_' + $jd | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $jobsDir "BackupJob_$safeName.json") -Encoding UTF8 + $exportedJobCount++ + Write-Ok " Backup job: $($job.Name)" + } catch { + Write-Warn "Failed to export job '$($job.Name)': $_" + } + } + Write-Ok "$exportedJobCount backup job(s) exported to Jobs\" + + # ── Backup copy jobs ────────────────────────────────────────────────────── + Write-Step "Collecting backup copy jobs" + + @(Get-VBOCopyJob) | ForEach-Object { + $cj = $_ + try { + $cd = [ordered]@{ + Id = $cj.Id.ToString() + Name = $(try { $cj.Name } catch { $null }) + IsEnabled = $(try { [bool]$cj.IsEnabled } catch { $true }) + BackupJobName = if ($cj.PSObject.Properties['BackupJob'] -and $cj.BackupJob) { $cj.BackupJob.Name } else { $null } + RepositoryName = if ($cj.PSObject.Properties['Repository'] -and $cj.Repository) { $cj.Repository.Name } else { $null } + SchedulePolicy = $null + } + + try { + $sp = $cj.SchedulePolicy + if ($sp) { + $cd.SchedulePolicy = [ordered]@{ + Type = $(try { $sp.Type.ToString() } catch { 'Immediate' }) + DailyTime = $(try { $sp.DailyTime.ToString() } catch { '15:00:00' }) + DailyType = $(try { $sp.DailyType.ToString() } catch { 'Everyday' }) + PeriodicallyEvery = $(try { $sp.PeriodicallyEvery.ToString() } catch { $null }) + } + } + } catch { Write-Warn " Copy job schedule for '$($cj.Name)': $_" } + + # Filename is based on the source backup job name so the import can correlate them + $baseName = if ($cd.BackupJobName) { $cd.BackupJobName } else { $cj.Name } + $safeName = (ConvertTo-SafeFileName $baseName) -replace '\s+', '_' + $cd | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $jobsDir "BackupCopyJob_$safeName.json") -Encoding UTF8 + $exportedCopyJobCount++ + Write-Ok " Copy job: '$($cj.Name)' → $($cd.RepositoryName)" + } catch { + Write-Warn "Failed to export copy job '$($cj.Name)': $_" + } + } + Write-Ok "$exportedCopyJobCount backup copy job(s) exported to Jobs\" +} + +#endregion + +#region ── Disconnect & summary ────────────────────────────────────────────── + +try { Disconnect-VBOServer } catch { } + +$certFiles = @(Get-ChildItem -Path $certDir -Filter '*.pfx' -ErrorAction SilentlyContinue) +$missingCerts = @($orgsExport | ForEach-Object { + $_.BackupApplications | Where-Object { $_.CertificateThumbprint -and -not $_.CertificateExported } +}).Count +$outputFiles = @(Get-ChildItem -Path $exportRoot -Filter '*.json' -Recurse) + +Write-Host "`n─── Export complete ──────────────────────────────────────────────────" -ForegroundColor White +Write-Host " Output folder : $exportRoot" +Write-Host " JSON files : $($outputFiles.Count)" +Write-Host " Organizations : $($orgsExport.Count)" +Write-Host " Certificates saved : $($certFiles.Count)" +if ($IncludeJobs) { + Write-Host " Backup jobs : $exportedJobCount" + Write-Host " Backup copy jobs : $exportedCopyJobCount" +} +if ($missingCerts -gt 0) { + Write-Warn " Missing PFX files : $missingCerts" + Write-Warn " Thumbprints recorded in Organizations.json — export those PFX files" + Write-Warn " manually from the original machine's Windows certificate store." +} +Write-Host "" + +#endregion diff --git a/VB365 Config Migrator/Import-VB365Config_v2.ps1 b/VB365 Config Migrator/Import-VB365Config_v2.ps1 new file mode 100644 index 0000000..7cc78c9 --- /dev/null +++ b/VB365 Config Migrator/Import-VB365Config_v2.ps1 @@ -0,0 +1,834 @@ +<# +.SYNOPSIS + Restores Veeam Backup for Microsoft 365 configuration from an export. + +.DESCRIPTION + Reads the output produced by Export-VB365Config_v2.ps1 (with -IncludeJobs) and + restores configuration on a fresh VB365 installation. + + Scope: + • Re-register M365 organizations with modern app-only authentication (certificate) + • Create missing cloud storage accounts (prompts for secret keys) + • Add object storage repositories to the default backup proxy and synchronise them + • Create backup jobs from exported Jobs\BackupJob_*.json files + • Create backup copy jobs from exported Jobs\BackupCopyJob_*.json files + + Idempotent: organizations, repositories, and jobs that already exist by name are skipped. + + Repository and organization matching uses NAME — IDs change on a fresh installation. + +.PARAMETER ImportPath + Root folder of a previous export — the timestamped sub-folder created by + Export-VB365Config_v2.ps1 (e.g. "C:\VB365Export\VB365_20260630_120000"). + +.PARAMETER Server + VB365 server hostname or IP. Defaults to localhost. + +.PARAMETER Port + Optional. Only needed when connecting to a remote VB365 server on a non-default port. + +.PARAMETER Credential + PSCredential for the VB365 server. Omit to use the current Windows session. + +.PARAMETER CertificatePassword + Password that was used when the PFX files were exported. Required. + +.PARAMETER WhatIf + Show what would be done without making any changes. + +.EXAMPLE + .\Import-VB365Config_v2.ps1 ` + -ImportPath "C:\VB365Export\VB365_20260630_120000" ` + -CertificatePassword (Read-Host -AsSecureString "PFX password") +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)][string] $ImportPath, + [string] $Server = 'localhost', + [Nullable[int]] $Port = $null, + [PSCredential] $Credential = $null, + [Parameter(Mandatory)][string] $CertificatePassword, + [switch] $SyncRepositories +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ── Output helpers ──────────────────────────────────────────────────────────── +function Write-Step { param($m) Write-Host " $m" -ForegroundColor Cyan } +function Write-Ok { param($m) Write-Host " [OK] $m" -ForegroundColor Green } +function Write-Warn { param($m) Write-Host " [WARN] $m" -ForegroundColor Yellow } +function Write-Fail { param($m) Write-Host " [FAIL] $m" -ForegroundColor Red } +function Write-Skip { param($m) Write-Host " [SKIP] $m" -ForegroundColor DarkGray } +function Write-Info { param($m) Write-Host " [INFO] $m" -ForegroundColor White } + +# Convert plain-text password to SecureString (parameter accepts both forms) +$CertificatePasswordSecure = $CertificatePassword | ConvertTo-SecureString -AsPlainText -Force + +# ── Validate import folder ──────────────────────────────────────────────────── +if (-not (Test-Path $ImportPath -PathType Container)) { + throw "Import path not found: $ImportPath" +} + +$orgsJson = Join-Path $ImportPath 'Organizations.json' +$certDir = Join-Path $ImportPath 'Certificates' + +if (-not (Test-Path $orgsJson)) { throw "Organizations.json not found in: $ImportPath" } +if (-not (Test-Path $certDir -PathType Container)) { throw "Certificates folder not found in: $ImportPath" } + +Write-Host "" +Write-Host "VB365 Configuration Import v2" -ForegroundColor Magenta +Write-Host " Import path : $ImportPath" +Write-Host " Server : $Server" +Write-Host "" + +# ── Load VB365 module ───────────────────────────────────────────────────────── +Write-Step "Loading Veeam.Archiver.PowerShell module" +if (-not (Get-Module -ListAvailable -Name Veeam.Archiver.PowerShell)) { + throw "Veeam.Archiver.PowerShell module not found. Run this script on a VB365 server." +} +Import-Module Veeam.Archiver.PowerShell -ErrorAction Stop +Write-Ok "Module loaded" + +# ── Connect ─────────────────────────────────────────────────────────────────── +Write-Step "Connecting to VB365 server" +$connectParams = @{} +if ($Server -notin @('localhost', '127.0.0.1', $env:COMPUTERNAME)) { + $connectParams['Server'] = $Server + if ($null -ne $Port) { $connectParams['Port'] = $Port } +} +if ($Credential) { $connectParams['Credential'] = $Credential } +Connect-VBOServer @connectParams +Write-Ok "Connected" + +# ── Read export data ────────────────────────────────────────────────────────── +Write-Step "Reading Organizations.json" +$exportedOrgs = Get-Content $orgsJson -Raw | ConvertFrom-Json +Write-Ok "$($exportedOrgs.Count) organization(s) in export" + +$existingOrgs = @(Get-VBOOrganization) +Write-Info "Organizations already registered: $($existingOrgs.Count)" + +# ── Organizations ───────────────────────────────────────────────────────────── +$stats = @{ Registered = 0; Skipped = 0; Failed = 0 } + +foreach ($exportedOrg in $exportedOrgs) { + $orgName = $exportedOrg.Name + $orgType = $exportedOrg.Type + $region = if ($exportedOrg.Region) { $exportedOrg.Region } else { 'Worldwide' } + + Write-Host "" + Write-Step "Organization: $orgName (Type: $orgType Region: $region)" + + $alreadyExists = $existingOrgs | Where-Object { $_.Name -eq $orgName } | Select-Object -First 1 + if ($alreadyExists) { + Write-Skip "$orgName is already registered — skipping" + $stats.Skipped++ + continue + } + + $useVeeamAAD = $exportedOrg.PSObject.Properties['UseVeeamAADApplication'] -and [bool]$exportedOrg.UseVeeamAADApplication + $backupApps = @($exportedOrg.BackupApplications) + + if ($useVeeamAAD) { + # ── Veeam AAD application mode — no custom cert required ───────────── + Write-Info " Auth mode : Veeam AAD application" + Write-Step " Registering organization" + if ($PSCmdlet.ShouldProcess($orgName, "Add-VBOOrganization")) { + try { + $newOrg = Add-VBOOrganization ` + -Name $orgName ` + -Region $region ` + -UseVeeamAADApplication ` + -EnableOffice365Teams + Write-Ok "Registered: $orgName" + $stats.Registered++ + $existingOrgs += $newOrg + } catch { + Write-Fail "Add-VBOOrganization failed for $orgName`: $_" + $stats.Failed++ + } + } else { + Write-Info "[WhatIf] Add-VBOOrganization -Name '$orgName' -Region '$region' -UseVeeamAADApplication" + } + } else { + # ── Custom backup application / certificate mode ────────────────────── + if ($backupApps.Count -eq 0) { + Write-Fail "No backup application entries in export for $orgName" + $stats.Failed++ + continue + } + + $appEntry = $backupApps[0] + + if (-not $appEntry.ApplicationId) { + Write-Fail "No ApplicationId in export for $orgName" + $stats.Failed++ + continue + } + if (-not $appEntry.CertificateExportFile) { + Write-Fail "No certificate export file recorded for $orgName" + $stats.Failed++ + continue + } + + $pfxPath = Join-Path $certDir $appEntry.CertificateExportFile + if (-not (Test-Path $pfxPath)) { + Write-Fail "PFX file not found: $pfxPath" + $stats.Failed++ + continue + } + + Write-Info " Application ID : $($appEntry.ApplicationId)" + Write-Info " Certificate : $($appEntry.CertificateExportFile)" + if ($appEntry.CertificateExpiry) { Write-Info " Cert expiry : $($appEntry.CertificateExpiry)" } + + $impersonationAcct = if ($appEntry.PSObject.Properties['ImpersonationAccountName']) { $appEntry.ImpersonationAccountName } else { $null } + $officeOrgName = if ($appEntry.PSObject.Properties['OfficeOrganizationName']) { $appEntry.OfficeOrganizationName } else { $null } + + $authAccountParam = if ($impersonationAcct) { + @{ ImpersonationAccountName = $impersonationAcct } + } elseif ($officeOrgName) { + @{ OfficeOrganizationName = $officeOrgName } + } else { + @{ OfficeOrganizationName = $orgName } + } + + $authLabel = if ($impersonationAcct) { "ImpersonationAccount: $impersonationAcct" } ` + elseif ($officeOrgName) { "OfficeOrganizationName: $officeOrgName" } ` + else { "OfficeOrganizationName: $orgName (fallback)" } + + Write-Step " Building connection settings ($authLabel)" + $connSettings = $null + if ($PSCmdlet.ShouldProcess($orgName, "New-VBOOffice365ApplicationOnlyConnectionSettings")) { + try { + $connSettings = New-VBOOffice365ApplicationOnlyConnectionSettings ` + -ApplicationId ([guid]$appEntry.ApplicationId) ` + -ApplicationCertificatePath $pfxPath ` + -ApplicationCertificatePassword $CertificatePasswordSecure ` + @authAccountParam + Write-Ok " Connection settings created" + } catch { + Write-Fail "New-VBOOffice365ApplicationOnlyConnectionSettings failed: $_" + $stats.Failed++ + continue + } + } else { + Write-Info "[WhatIf] New-VBOOffice365ApplicationOnlyConnectionSettings -ApplicationId '$($appEntry.ApplicationId)'" + } + + Write-Step " Registering organization" + if ($PSCmdlet.ShouldProcess($orgName, "Add-VBOOrganization")) { + try { + $newOrg = Add-VBOOrganization ` + -Name $orgName ` + -Region $region ` + -Office365ExchangeConnectionsSettings $connSettings ` + -Office365SharePointConnectionsSettings $connSettings ` + -EnableOffice365Teams + Write-Ok "Registered: $orgName" + $stats.Registered++ + $existingOrgs += $newOrg + } catch { + Write-Fail "Add-VBOOrganization failed for $orgName`: $_" + $stats.Failed++ + } + } else { + Write-Info "[WhatIf] Add-VBOOrganization -Name '$orgName' -Region '$region'" + } + } +} + +# ── Object Storage Repositories ────────────────────────────────────────────── +Write-Host "" +Write-Step "Adding object storage repositories to default backup proxy" + +$reposJson = Join-Path $ImportPath 'Repositories.json' +$credsJson = Join-Path $ImportPath 'CloudCredentials.json' + +$repoStats = @{ Added = 0; Skipped = 0; Failed = 0 } +$proxySyncNeeded = $false +$addedRepoNames = [System.Collections.Generic.List[string]]::new() + +if (-not (Test-Path $reposJson)) { + Write-Warn "Repositories.json not found — skipping repository import" +} elseif (-not (Test-Path $credsJson)) { + Write-Warn "CloudCredentials.json not found — skipping repository import" +} else { + $exportedRepos = Get-Content $reposJson -Raw | ConvertFrom-Json + $cloudCreds = Get-Content $credsJson -Raw | ConvertFrom-Json + + $defaultProxy = @(Get-VBOProxy) | Select-Object -First 1 + if (-not $defaultProxy) { + Write-Fail "No backup proxy found — cannot assign repositories" + } else { + Write-Ok "Default proxy: $($defaultProxy.Hostname)" + + $osRepos = @($exportedRepos | Where-Object { $_.IsObjectStorageRepo -eq $true }) + Write-Info "Object storage repositories in export: $($osRepos.Count)" + + # ── Pre-flight: ensure all required cloud accounts exist ────────────── + $accountCache = @{} + $requiredAccounts = [System.Collections.Generic.List[hashtable]]::new() + foreach ($repo in $osRepos) { + $osType = if ($repo.PSObject.Properties['ObjectStorageType']) { $repo.ObjectStorageType } else { $null } + $accountName = if ($repo.PSObject.Properties['ObjectStorageAccountName']) { $repo.ObjectStorageAccountName } else { $null } + if (-not $osType -or -not $accountName) { continue } + $key = "$osType|$accountName" + if (-not ($requiredAccounts | Where-Object { $_['Key'] -eq $key })) { + $requiredAccounts.Add(@{ Key = $key; Type = $osType; Name = $accountName }) + } + } + + foreach ($req in $requiredAccounts) { + $key = $req['Key'] + $osType = $req['Type'] + $accountName = $req['Name'] + + $existing = $null + try { + if ($osType -eq 'AzureBlob') { + $existing = Get-VBOAzureBlobAccount -Name $accountName -ErrorAction SilentlyContinue | Select-Object -First 1 + } elseif ($osType -eq 'AmazonS3') { + $existing = Get-VBOAmazonS3Account -AccessKey $accountName -ErrorAction SilentlyContinue | Select-Object -First 1 + } elseif ($osType -like 'AmazonS3Compatible*' -or $osType -eq 'IBMCloud' -or $osType -eq 'WasabiCloud') { + $existing = Get-VBOAmazonS3CompatibleAccount -AccessKey $accountName -ErrorAction SilentlyContinue | Select-Object -First 1 + } + } catch { $existing = $null } + + if ($existing) { + Write-Ok "Cloud account already registered: [$osType] $accountName" + $accountCache[$key] = $existing + continue + } + + Write-Host "" + Write-Warn "Cloud account not found: [$osType] $accountName" + Write-Host " Secret keys are not exportable and must be supplied manually." -ForegroundColor Yellow + + if ($PSCmdlet.ShouldProcess("$osType account '$accountName'", "Add cloud account")) { + try { + if ($osType -eq 'AzureBlob') { + $secret = Read-Host -AsSecureString " Enter Shared Key for Azure Blob account '$accountName'" + $accountCache[$key] = Add-VBOAzureBlobAccount -Name $accountName -SharedKey $secret + Write-Ok "Created Azure Blob account: $accountName" + } elseif ($osType -eq 'AmazonS3') { + $secret = Read-Host -AsSecureString " Enter Secret Access Key for S3 access key '$accountName'" + $accountCache[$key] = Add-VBOAmazonS3Account -AccessKey $accountName -SecurityKey $secret + Write-Ok "Created Amazon S3 account: $accountName" + } elseif ($osType -like 'AmazonS3Compatible*' -or $osType -eq 'IBMCloud' -or $osType -eq 'WasabiCloud') { + $secret = Read-Host -AsSecureString " Enter Secret Key for S3-Compatible access key '$accountName'" + $accountCache[$key] = Add-VBOAmazonS3CompatibleAccount -AccessKey $accountName -SecurityKey $secret + Write-Ok "Created S3-Compatible account: $accountName" + } + } catch { + Write-Fail "Failed to create account '$accountName': $_" + } + } else { + Write-Info "[WhatIf] Add cloud account [$osType] '$accountName'" + } + } + + foreach ($repo in $osRepos) { + $repoName = $repo.Name + Write-Host "" + Write-Step "Repository: $repoName" + + $existing = @(Get-VBORepository -Name $repoName -ErrorAction SilentlyContinue) | Select-Object -First 1 + if ($existing) { + Write-Skip "'$repoName' already exists" + $repoStats.Skipped++ + continue + } + + $osType = if ($repo.PSObject.Properties['ObjectStorageType']) { $repo.ObjectStorageType } else { $null } + $folderName = if ($repo.PSObject.Properties['ObjectStorageFolderName']) { $repo.ObjectStorageFolderName } else { $null } + $containerName = if ($repo.PSObject.Properties['ObjectStorageContainerName']) { $repo.ObjectStorageContainerName } else { $null } + $bucketName = if ($repo.PSObject.Properties['ObjectStorageBucketName']) { $repo.ObjectStorageBucketName } else { $null } + $accountName = if ($repo.PSObject.Properties['ObjectStorageAccountName']) { $repo.ObjectStorageAccountName } else { $null } + $regionType = if ($repo.PSObject.Properties['ObjectStorageRegionType']) { $repo.ObjectStorageRegionType } else { 'Global' } + $regionId = if ($repo.PSObject.Properties['ObjectStorageRegionId']) { $repo.ObjectStorageRegionId } else { $null } + $servicePoint = if ($repo.PSObject.Properties['ObjectStorageServicePoint']) { $repo.ObjectStorageServicePoint } else { $null } + $customRegion = if ($repo.PSObject.Properties['ObjectStorageCustomRegionId']){ $repo.ObjectStorageCustomRegionId } else { $null } + $trustCert = if ($repo.PSObject.Properties['ObjectStorageTrustCert']) { [bool]$repo.ObjectStorageTrustCert } else { $false } + + if (-not $osType) { Write-Warn "'$repoName': ObjectStorageType missing — re-run export"; $repoStats.Failed++; continue } + if (-not $folderName) { Write-Warn "'$repoName': ObjectStorageFolderName missing — re-run export"; $repoStats.Failed++; continue } + if (-not $accountName) { Write-Warn "'$repoName': ObjectStorageAccountName missing — re-run export"; $repoStats.Failed++; continue } + + Write-Info " Type: $osType Account: $accountName Folder: $folderName" + + $cacheKey = "$osType|$accountName" + $cachedAcct = if ($accountCache.ContainsKey($cacheKey)) { $accountCache[$cacheKey] } else { $null } + if (-not $cachedAcct) { + Write-Fail "Failed to add '$repoName': account '$accountName' is not available (creation failed or was skipped)" + $repoStats.Failed++ + continue + } + + try { + if ($osType -eq 'AzureBlob') { + if (-not $containerName) { throw "ObjectStorageContainerName not in export — re-run export script first" } + $connBlob = New-VBOAzureBlobConnectionSettings -Account $cachedAcct -RegionType $regionType + $container = Get-VBOAzureBlobContainer -ConnectionSettings $connBlob -Name $containerName + $folder = Get-VBOAzureBlobFolder -Container $container -Name $folderName + $settings = New-VBOAzureBlobObjectStorageSettings -Folder $folder + if ($PSCmdlet.ShouldProcess($repoName, "Add-VBOAzureBlobRepository")) { + Add-VBOAzureBlobRepository -ObjectStorageSettings $settings -Name $repoName -Proxy $defaultProxy | Out-Null + Write-Ok "Added Azure Blob repository: $repoName" + $repoStats.Added++; $proxySyncNeeded = $true; $addedRepoNames += $repoName + } else { Write-Info "[WhatIf] Add-VBOAzureBlobRepository -Name '$repoName'" } + + } elseif ($osType -eq 'AmazonS3') { + $connS3 = New-VBOAmazonS3ConnectionSettings -Account $cachedAcct -RegionType $regionType + $bucket = Get-VBOAmazonS3Bucket -AmazonS3ConnectionSettings $connS3 -Name $bucketName + $folder = Get-VBOAmazonS3Folder -Bucket $bucket -Name $folderName + if (-not $folder) { throw "Folder '$folderName' not found in bucket '$bucketName'" } + $settings = New-VBOAmazonS3ObjectStorageSettings -Folder $folder + if ($PSCmdlet.ShouldProcess($repoName, "Add-VBOAmazonS3Repository")) { + Add-VBOAmazonS3Repository -ObjectStorageSettings $settings -Name $repoName -Proxy $defaultProxy | Out-Null + Write-Ok "Added Amazon S3 repository: $repoName" + $repoStats.Added++; $proxySyncNeeded = $true; $addedRepoNames += $repoName + } else { Write-Info "[WhatIf] Add-VBOAmazonS3Repository -Name '$repoName'" } + + } elseif ($osType -like 'AmazonS3Compatible*' -or $osType -eq 'IBMCloud' -or $osType -eq 'WasabiCloud') { + if (-not $servicePoint) { throw "ObjectStorageServicePoint not in export for '$repoName'" } + $connCompat = New-VBOAmazonS3CompatibleConnectionSettings ` + -Account $cachedAcct ` + -ServicePoint $servicePoint ` + -CustomRegionId $customRegion ` + -TrustServerCertificate:$trustCert + $buckets = @(Get-VBOAmazonS3Bucket -AmazonS3CompatibleConnectionSettings $connCompat) + $folder = $null + foreach ($bkt in $buckets) { + $match = @(Get-VBOAmazonS3Folder -Bucket $bkt) | + Where-Object { $_.ToString() -eq $folderName } | Select-Object -First 1 + if ($match) { $folder = $match; break } + } + if (-not $folder) { throw "Folder '$folderName' not found in any bucket for account '$accountName'" } + $settings = New-VBOAmazonS3CompatibleObjectStorageSettings -Folder $folder + if ($PSCmdlet.ShouldProcess($repoName, "Add-VBOAmazonS3CompatibleRepository")) { + Add-VBOAmazonS3CompatibleRepository -ObjectStorageSettings $settings -Name $repoName -Proxy $defaultProxy | Out-Null + Write-Ok "Added S3-Compatible repository: $repoName" + $repoStats.Added++; $proxySyncNeeded = $true; $addedRepoNames += $repoName + } else { Write-Info "[WhatIf] Add-VBOAmazonS3CompatibleRepository -Name '$repoName'" } + + } else { + Write-Warn "'$repoName': Unknown ObjectStorageType '$osType' — skipping" + $repoStats.Failed++ + } + } catch { + Write-Fail "Failed to add '$repoName': $_" + $repoStats.Failed++ + } + } + + if ($proxySyncNeeded -and -not $SyncRepositories) { + Write-Info "Repository synchronisation skipped — use -SyncRepositories to enable" + } + if ($proxySyncNeeded -and $SyncRepositories) { + Write-Host "" + Write-Step "Rescanning default proxy: $($defaultProxy.Hostname)" + if ($PSCmdlet.ShouldProcess($defaultProxy.Hostname, "Sync-VBOProxy")) { + try { Sync-VBOProxy -Proxy $defaultProxy; Write-Ok "Proxy rescan complete" } + catch { Write-Warn "Proxy rescan failed: $_" } + } else { Write-Info "[WhatIf] Sync-VBOProxy -Proxy '$($defaultProxy.Hostname)'" } + + Write-Host "" + Write-Step "Synchronising added repositories" + foreach ($rName in $addedRepoNames) { + $repoObj = Get-VBORepository -Name $rName -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $repoObj) { Write-Warn "Could not find repository '$rName' for synchronisation"; continue } + if ($PSCmdlet.ShouldProcess($rName, "Start-VBORepositorySynchronizeSession")) { + try { Start-VBORepositorySynchronizeSession -Repository $repoObj | Out-Null; Write-Ok "Synchronisation started: $rName" } + catch { Write-Warn "Synchronisation failed for '$rName': $_" } + } else { Write-Info "[WhatIf] Start-VBORepositorySynchronizeSession -Repository '$rName'" } + } + } + } +} + +# ── Helper: build a VBOJobSchedulePolicy from exported JSON ────────────────── +function New-SchedulePolicyFromExport { + param($sp) + + $params = @{} + + # EnableSchedule is a SwitchParameter; default is enabled — only act when explicitly false + if ($sp -and $sp.PSObject.Properties['EnableSchedule'] -and $sp.EnableSchedule -eq $false) { + $params['EnableSchedule'] = $false + } else { + $params['EnableSchedule'] = $true + } + + $type = if ($sp -and $sp.PSObject.Properties['Type']) { $sp.Type } else { 'Daily' } + + if ($type -eq 'Periodically') { + $params['Type'] = 'Periodically' + if ($sp.PSObject.Properties['PeriodicallyEvery'] -and $sp.PeriodicallyEvery) { + $params['PeriodicallyEvery'] = $sp.PeriodicallyEvery + } + } else { + # Default to Daily + if ($sp -and $sp.PSObject.Properties['DailyTime'] -and $sp.DailyTime) { + try { $params['DailyTime'] = [TimeSpan]::Parse($sp.DailyTime) } catch { } + } + if ($sp -and $sp.PSObject.Properties['DailyType'] -and $sp.DailyType) { + $params['DailyType'] = $sp.DailyType + } + } + + if ($sp -and $sp.PSObject.Properties['RetryEnabled'] -and $sp.RetryEnabled) { + $params['RetryEnabled'] = $true + if ($sp.PSObject.Properties['RetryNumber'] -and $sp.RetryNumber) { $params['RetryNumber'] = [int]$sp.RetryNumber } + if ($sp.PSObject.Properties['RetryWaitInterval'] -and $sp.RetryWaitInterval) { $params['RetryWaitInterval'] = [int]$sp.RetryWaitInterval } + } + + New-VBOJobSchedulePolicy @params +} + +# ── Helper: build a VBOCopyJobSchedulePolicy from exported JSON ─────────────── +function New-CopySchedulePolicyFromExport { + param($sp) + + $type = if ($sp -and $sp.PSObject.Properties['Type']) { $sp.Type } else { 'Immediate' } + + if ($type -eq 'Immediate') { + return New-VBOCopyJobSchedulePolicy + } + + $params = @{ Type = $type } + + if ($type -eq 'Daily') { + if ($sp.PSObject.Properties['DailyTime'] -and $sp.DailyTime) { + try { $params['DailyTime'] = [TimeSpan]::Parse($sp.DailyTime) } catch { } + } + if ($sp.PSObject.Properties['DailyType'] -and $sp.DailyType) { + $params['DailyType'] = $sp.DailyType + } + } elseif ($type -eq 'Periodically') { + if ($sp.PSObject.Properties['PeriodicallyEvery'] -and $sp.PeriodicallyEvery) { + $params['PeriodicallyEvery'] = $sp.PeriodicallyEvery + } + } + + New-VBOCopyJobSchedulePolicy @params +} + +# ── Helper: build a VBOBackupItem from an exported item entry ───────────────── +function New-BackupItemFromExport { + param($item, [object]$org) + + $type = $item.Type + + # Valid New-VBOBackupItem parameter names per item type. + $validUser = @('Mailbox','ArchiveMailbox','OneDrive','Sites') + $validGroup = @('Mailbox','ArchiveMailbox','OneDrive','GroupMailbox','GroupSite') + $validOrg = @('Mailbox','ArchiveMailbox','OneDrive','Sites','Teams','TeamsChats') + $validTeam = @('TeamsChats') + + # VBOBackupItem property name → New-VBOBackupItem parameter name where they differ. + # 'Site' (singular, boolean) is the personal SharePoint site flag on User items; + # the cmdlet parameter is '-Sites' (plural). + # 'TeamsGroupChats' is how the property appears on the object; the parameter is '-TeamsChats'. + $propAliases = @{ + 'Site' = 'Sites' + 'TeamsGroupChats' = 'TeamsChats' + } + + # Build a flag hashtable for the given valid parameter names, checking both the + # direct property name and any known alias that maps to each parameter. + function Get-Flags($validParams) { + $f = @{} + foreach ($paramName in $validParams) { + if ($item.PSObject.Properties[$paramName] -and $item.$paramName -eq $true) { + $f[$paramName] = $true; continue + } + foreach ($alias in $propAliases.GetEnumerator()) { + if ($alias.Value -eq $paramName -and + $item.PSObject.Properties[$alias.Key] -and + $item.($alias.Key) -eq $true) { + $f[$paramName] = $true; break + } + } + } + $f + } + + $userFlags = Get-Flags $validUser + $groupFlags = Get-Flags $validGroup + $orgFlags = Get-Flags $validOrg + $teamFlags = Get-Flags $validTeam + + switch ($type) { + 'User' { + $userName = if ($item.User.PSObject.Properties['UserName']) { $item.User.UserName } else { $item.User.DisplayName } + $userObj = Get-VBOOrganizationUser -Organization $org -UserName $userName -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $userObj) { throw "User '$userName' not found in org '$($org.Name)'" } + if ($userFlags.Count -gt 0) { return New-VBOBackupItem -User $userObj @userFlags } + else { return New-VBOBackupItem -User $userObj -Mailbox } + } + { $_ -like '*Group*' } { + $groupDisplayName = if ($item.Group.PSObject.Properties['DisplayName'] -and $item.Group.DisplayName) { $item.Group.DisplayName } else { $item.Group.Name } + $groupObj = Get-VBOOrganizationGroup -Organization $org -DisplayName $groupDisplayName -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $groupObj) { throw "Group '$groupDisplayName' not found in org '$($org.Name)'" } + if ($groupFlags.Count -gt 0) { return New-VBOBackupItem -Group $groupObj @groupFlags } + else { return New-VBOBackupItem -Group $groupObj -Mailbox } + } + 'Site' { + $siteUrl = $item.Site.Url + $siteObj = Get-VBOOrganizationSite -Organization $org -URL $siteUrl -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $siteObj) { throw "Site '$siteUrl' not found in org '$($org.Name)'" } + return New-VBOBackupItem -Site $siteObj + } + 'Team' { + $teamDisplayName = if ($item.Team.PSObject.Properties['DisplayName'] -and $item.Team.DisplayName) { $item.Team.DisplayName } else { $item.Team.Name } + $teamObj = Get-VBOOrganizationTeam -Organization $org -DisplayName $teamDisplayName -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $teamObj) { throw "Team '$teamDisplayName' not found in org '$($org.Name)'" } + if ($teamFlags.Count -gt 0) { return New-VBOBackupItem -Team $teamObj @teamFlags } + else { return New-VBOBackupItem -Team $teamObj } + } + 'Organization' { + if ($orgFlags.Count -gt 0) { return New-VBOBackupItem -Organization $org @orgFlags } + else { return New-VBOBackupItem -Organization $org -Mailbox -OneDrive -Sites -Teams } + } + 'PersonalSites' { + return New-VBOBackupItem -PersonalSites + } + default { + throw "Unknown backup item type: $type" + } + } +} + +# ── Backup Jobs ─────────────────────────────────────────────────────────────── +Write-Host "" +Write-Step "Creating backup jobs" + +$jobsDir = Join-Path $ImportPath 'Jobs' +$jobStats = @{ Created = 0; Skipped = 0; Failed = 0 } + +if (-not (Test-Path $jobsDir -PathType Container)) { + Write-Warn "Jobs\ folder not found in export — skipping backup job import" + Write-Warn "Re-run the export with -IncludeJobs to capture job configuration." +} else { + $jobFiles = @(Get-ChildItem -Path $jobsDir -Filter 'BackupJob_*.json' | Sort-Object Name) + Write-Info "$($jobFiles.Count) backup job file(s) found" + + # Refresh org list — may have grown during this run + $existingOrgs = @(Get-VBOOrganization) + + foreach ($jobFile in $jobFiles) { + $jd = Get-Content $jobFile.FullName -Raw | ConvertFrom-Json + $jobName = $jd.Name + Write-Host "" + Write-Step "Backup job: $jobName" + + # Skip if already exists + $existingJob = Get-VBOJob -Name $jobName -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($existingJob) { + Write-Skip "'$jobName' already exists" + $jobStats.Skipped++ + continue + } + + # Resolve organization + $org = $existingOrgs | Where-Object { $_.Name -eq $jd.OrganizationName } | Select-Object -First 1 + if (-not $org) { + Write-Fail "Organization '$($jd.OrganizationName)' not found — register it first, then re-run" + $jobStats.Failed++ + continue + } + + # Resolve repository + $repo = Get-VBORepository -Name $jd.RepositoryName -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $repo) { + Write-Fail "Repository '$($jd.RepositoryName)' not found — add it first, then re-run" + $jobStats.Failed++ + continue + } + + Write-Info " Org: $($org.Name) Repo: $($repo.Name)" + + # Build schedule policy + $schedulePolicy = $null + try { + $schedulePolicy = New-SchedulePolicyFromExport $jd.SchedulePolicy + } catch { + Write-Warn " Could not build schedule policy: $_ — will use default" + $schedulePolicy = New-VBOJobSchedulePolicy + } + + # Build Add-VBOJob parameters + $addParams = @{ + Name = $jobName + Organization = $org + Repository = $repo + SchedulePolicy = $schedulePolicy + } + if ($jd.PSObject.Properties['Description'] -and $jd.Description) { + $addParams['Description'] = $jd.Description + } + + $isEntireOrg = $jd.PSObject.Properties['IsEntireOrganization'] -and $jd.IsEntireOrganization + + if (-not $isEntireOrg) { + # Build selected items list + $selectedItems = [System.Collections.Generic.List[object]]::new() + $excludedItems = [System.Collections.Generic.List[object]]::new() + $itemErrors = 0 + + foreach ($itemEntry in @($jd.SelectedItems)) { + try { + $bi = New-BackupItemFromExport -item $itemEntry -org $org + $selectedItems.Add($bi) + } catch { + Write-Warn " Could not resolve selected item ($($itemEntry.Type)): $_" + $itemErrors++ + } + } + foreach ($itemEntry in @($jd.ExcludedItems)) { + try { + $bi = New-BackupItemFromExport -item $itemEntry -org $org + $excludedItems.Add($bi) + } catch { + Write-Warn " Could not resolve excluded item ($($itemEntry.Type)): $_" + } + } + + if ($selectedItems.Count -eq 0) { + Write-Fail "No resolvable selected items for '$jobName' — skipping" + $jobStats.Failed++ + continue + } + if ($itemErrors -gt 0) { + Write-Warn " $itemErrors item(s) could not be resolved and will be omitted" + } + + $addParams['SelectedItems'] = $selectedItems.ToArray() + if ($excludedItems.Count -gt 0) { $addParams['ExcludedItems'] = $excludedItems.ToArray() } + } + + if ($PSCmdlet.ShouldProcess($jobName, "Add-VBOJob")) { + try { + if ($isEntireOrg) { + Add-VBOJob @addParams -EntireOrganization | Out-Null + } else { + Add-VBOJob @addParams | Out-Null + } + Write-Ok "Created backup job: $jobName" + $jobStats.Created++ + } catch { + Write-Fail "Add-VBOJob failed for '$jobName': $_" + $jobStats.Failed++ + } + } else { + $scope = if ($isEntireOrg) { 'EntireOrganization' } else { "$($selectedItems.Count) item(s)" } + Write-Info "[WhatIf] Add-VBOJob -Name '$jobName' -Organization '$($org.Name)' -Repository '$($repo.Name)' [$scope]" + } + } +} + +# ── Backup Copy Jobs ────────────────────────────────────────────────────────── +Write-Host "" +Write-Step "Creating backup copy jobs" + +$copyJobStats = @{ Created = 0; Skipped = 0; Failed = 0 } + +if (-not (Test-Path $jobsDir -PathType Container)) { + Write-Warn "Jobs\ folder not found — skipping backup copy job import" +} else { + $copyJobFiles = @(Get-ChildItem -Path $jobsDir -Filter 'BackupCopyJob_*.json' | Sort-Object Name) + Write-Info "$($copyJobFiles.Count) backup copy job file(s) found" + + $existingCopyJobs = @(Get-VBOCopyJob) + + foreach ($cjFile in $copyJobFiles) { + $cd = Get-Content $cjFile.FullName -Raw | ConvertFrom-Json + $backupJobName = $cd.BackupJobName + Write-Host "" + Write-Step "Copy job for backup job: $backupJobName → Repo: $($cd.RepositoryName)" + + # Skip if a copy job already exists for this backup job + $existing = $existingCopyJobs | Where-Object { + $_.PSObject.Properties['BackupJob'] -and $_.BackupJob -and $_.BackupJob.Name -eq $backupJobName + } | Select-Object -First 1 + if ($existing) { + Write-Skip "Copy job for '$backupJobName' already exists" + $copyJobStats.Skipped++ + continue + } + + # Resolve source backup job + $sourceJob = Get-VBOJob -Name $backupJobName -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $sourceJob) { + Write-Fail "Backup job '$backupJobName' not found — create it first, then re-run" + $copyJobStats.Failed++ + continue + } + + # Resolve target repository + $targetRepo = Get-VBORepository -Name $cd.RepositoryName -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $targetRepo) { + Write-Fail "Repository '$($cd.RepositoryName)' not found — add it first, then re-run" + $copyJobStats.Failed++ + continue + } + + # Build copy schedule policy + $copySchedule = $null + try { + $copySchedule = New-CopySchedulePolicyFromExport $cd.SchedulePolicy + } catch { + Write-Warn " Could not build copy schedule policy: $_ — will use default (Immediate)" + $copySchedule = New-VBOCopyJobSchedulePolicy + } + + if ($PSCmdlet.ShouldProcess("copy job for '$backupJobName'", "Add-VBOCopyJob")) { + try { + Add-VBOCopyJob -BackupJob $sourceJob -Repository $targetRepo -SchedulePolicy $copySchedule | Out-Null + Write-Ok "Created copy job for: $backupJobName" + $copyJobStats.Created++ + # Refresh list for next iteration + $existingCopyJobs = @(Get-VBOCopyJob) + } catch { + Write-Fail "Add-VBOCopyJob failed for '$backupJobName': $_" + $copyJobStats.Failed++ + } + } else { + Write-Info "[WhatIf] Add-VBOCopyJob -BackupJob '$backupJobName' -Repository '$($targetRepo.Name)'" + } + } +} + +# ── Disconnect ──────────────────────────────────────────────────────────────── +Write-Host "" +Disconnect-VBOServer -ErrorAction SilentlyContinue + +# ── Summary ─────────────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "─────────────────────────────────────────" -ForegroundColor Magenta +Write-Host " Import complete" -ForegroundColor Magenta +Write-Host "" +Write-Host " Organizations" -ForegroundColor Magenta +Write-Host " Registered : $($stats.Registered)" +Write-Host " Skipped (already exist) : $($stats.Skipped)" +Write-Host " Failed : $($stats.Failed)" +Write-Host "" +Write-Host " Object storage repositories" -ForegroundColor Magenta +Write-Host " Added : $($repoStats.Added)" +Write-Host " Skipped (already exist) : $($repoStats.Skipped)" +Write-Host " Failed : $($repoStats.Failed)" +Write-Host "" +Write-Host " Backup jobs" -ForegroundColor Magenta +Write-Host " Created : $($jobStats.Created)" +Write-Host " Skipped (already exist) : $($jobStats.Skipped)" +Write-Host " Failed : $($jobStats.Failed)" +Write-Host "" +Write-Host " Backup copy jobs" -ForegroundColor Magenta +Write-Host " Created : $($copyJobStats.Created)" +Write-Host " Skipped (already exist) : $($copyJobStats.Skipped)" +Write-Host " Failed : $($copyJobStats.Failed)" +Write-Host "─────────────────────────────────────────" -ForegroundColor Magenta +Write-Host "" diff --git a/VB365 Config Migrator/README.md b/VB365 Config Migrator/README.md new file mode 100644 index 0000000..87dcf13 --- /dev/null +++ b/VB365 Config Migrator/README.md @@ -0,0 +1,175 @@ +# VB365 Configuration Export / Import Scripts + +PowerShell scripts to back up and restore the configuration of a **Veeam Backup for Microsoft 365 (VB365)** server. Designed for migration, disaster recovery preparation, or standing up a replacement server with an identical configuration. The import does not cover everything so far, but this will grow over time. + +--- + +## Scripts + +| Script | Version | Purpose | +|---|---|---| +| `Export-VB365Config_v2.ps1` | v2 | Exports the full VB365 configuration to JSON files | +| `Import-VB365Config_v2.ps1` | v2 | Restores organizations, repositories, backup jobs, and backup copy jobs | + +--- + +## Export-VB365Config_v2.ps1 + +### .SYNOPSIS + +Exports the full configuration of a VB365 server to a timestamped folder of JSON files and PFX certificate files. + +### .DESCRIPTION + +Connects to a VB365 server (locally or remotely) and collects the following configuration data, writing each section to a separate JSON file: + +- **Server information** — product version, installed components, license status +- **Global settings** — email notifications, internet proxy, REST API, security, restore portal, tenant and operator authentication, history retention, folder exclusions, RBAC roles, federated authentication authorities +- **Infrastructure** — backup proxy servers and proxy pools +- **Repositories** — local disk repositories and object storage repositories (Azure Blob, Amazon S3, S3-compatible); for object storage the account name, container/bucket name, folder name, and region are captured +- **Encryption keys** — key descriptions and hints (key material is not exportable) +- **Cloud credentials** — Azure service accounts, Azure Blob storage accounts, Amazon S3 accounts, S3-compatible accounts; account names and access keys are exported. **Secret keys are not** since they are stored encrypted inside VB365 and cannot be retrieved, you need to enter them manually during restore. +- **Organizations** — all registered Microsoft 365 organizations including region, backup application IDs, and authentication certificates (PFX files exported from the Windows certificate store) +- **Backup jobs** *(optional, requires `-IncludeJobs`)* — job name, linked organization and repository, schedule policy, all selected and excluded items with their workload flags (Mailbox, Archive Mailbox, OneDrive, SharePoint, Teams, Teams Chats) +- **Backup copy jobs** *(optional, requires `-IncludeJobs`)* — linked backup job, target repository, and schedule policy + +Each backup job and backup copy job is written to its own file under a `Jobs\` subdirectory, named after the job. + +### .PARAMETERS + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `-OutputPath` | String | Yes | Root folder for the export. A timestamped subfolder (`VB365_Export_YYYYMMDD_HHmmss`) is created inside. | +| `-CertificatePassword` | SecureString | No | Password used to protect exported PFX files. Prompted interactively if omitted. | +| `-IncludeJobs` | Switch | No | Also exports backup job and backup copy job configuration. | +| `-Server` | String | No | VB365 server hostname or IP. Defaults to `localhost`. | +| `-Port` | Int | No | Management service port. Only needed if moved from the default. | +| `-Credential` | PSCredential | No | Credentials for the VB365 server. Omit to use the current Windows session. | + +### .OUTPUT + +``` +VB365_Export_YYYYMMDD_HHmmss\ +├── ServerInfo.json +├── ProxyServers.json +├── ProxyPools.json +├── EncryptionKeys.json +├── CloudCredentials.json +├── Repositories.json +├── Organizations.json +├── Certificates\ +│ ├── BackupApp__.pfx +│ └── AzureServiceAccount__.pfx +└── Jobs\ (only with -IncludeJobs) + ├── BackupJob_.json + └── BackupCopyJob_.json +``` + +### .NOTES + +- Must be run on a machine with the `Veeam.Archiver.PowerShell` module installed (typically the VB365 server itself). +- Certificate PFX files are exported from the **local Windows certificate store**. If the script runs on a different machine than the one holding the certificates, PFX files will be missing. The thumbprint is still recorded in `Organizations.json` so certificates can be exported manually. +- Cloud account **secret keys are never exported** — they are stored encrypted inside VB365 and cannot be retrieved via PowerShell. They must be re-entered manually during restore. +- The export is read-only and makes no changes to the VB365 configuration. + +### .EXAMPLE + +```powershell +# Run locally on the VB365 server — includes jobs +.\Export-VB365Config_v2.ps1 -OutputPath C:\VB365_Backup -IncludeJobs + +# Remote server with explicit credentials +$cred = Get-Credential +.\Export-VB365Config_v2.ps1 -Server vb365.corp.local -Credential $cred ` + -OutputPath D:\Exports -IncludeJobs +``` + +--- + +## Import-VB365Config_v2.ps1 + +### .SYNOPSIS + +Restores VB365 configuration from an export folder onto a fresh or replacement VB365 installation. + +### .DESCRIPTION + +Reads the output produced by `Export-VB365Config_v2.ps1` and recreates the configuration in the following order: + +1. **Organizations** — re-registers each Microsoft 365 organization using modern app-only authentication. The backup application certificate is imported from the exported PFX file. Organizations that already exist by name are skipped. + +2. **Cloud storage accounts** — checks whether each account referenced by an exported repository already exists. If an account is missing it is created interactively: the script prompts once per unique account for the secret key (Shared Key for Azure Blob, Secret Access Key for Amazon S3 / S3-compatible). This step runs before any repository is created so all prompts are presented up front. + +3. **Object storage repositories** — adds each exported object storage repository to the default backup proxy (the only proxy available on a fresh installation). Repositories are matched by name; existing ones are skipped. After all repositories are added the proxy is rescanned and each newly added repository is synchronised. + +4. **Backup jobs** *(if `Jobs\BackupJob_*.json` files are present)* — recreates each backup job against the matching organization and repository (resolved by name). The schedule policy is reconstructed from the export. Selected and excluded items (users, groups, sites, teams) are resolved from the target organization by display name, UPN, or URL. Jobs whose organization or repository cannot be found on the target are skipped with a warning. + +5. **Backup copy jobs** *(if `Jobs\BackupCopyJob_*.json` files are present)* — creates a backup copy job for each exported entry by locating the source backup job and target repository by name. The copy schedule policy is reconstructed. Copy jobs for a backup job that already has a copy job are skipped. + +The script is **idempotent** — it can be re-run safely. Every resource is checked by name before creation and skipped if it already exists. + +### .PARAMETERS + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `-ImportPath` | String | Yes | The timestamped export subfolder produced by the export script (e.g. `C:\VB365_Backup\VB365_Export_20260630_120000`). | +| `-CertificatePassword` | String | Yes | Password that was set when the PFX files were exported. | +| `-Server` | String | No | VB365 server hostname or IP. Defaults to `localhost`. | +| `-Port` | Int | No | Management service port. Only needed if moved from the default. | +| `-Credential` | PSCredential | No | Credentials for the VB365 server. Omit to use the current Windows session. | +| `-SyncRepositories` | Switch | No | After adding repositories, rescan the proxy and start a synchronisation session for each new repository. Off by default. | +| `-WhatIf` | Switch | No | Shows what would be done without making any changes. | + +### .OUTPUT + +Progress is written to the console with colour-coded status tags: + +| Tag | Meaning | +|---|---| +| `[OK]` | Resource created or action succeeded | +| `[SKIP]` | Resource already exists — no action taken | +| `[WARN]` | Non-fatal issue (e.g. an item could not be resolved) | +| `[FAIL]` | Resource could not be created — see message for reason | + +A summary table is printed at the end showing counts for each resource type. + +### .NOTES + +- Must be run on a machine with the `Veeam.Archiver.PowerShell` module installed. +- **Cloud account secret keys must be available** at run time. The script will prompt for them interactively if the account does not already exist. Have the keys ready before starting the import. +- Object storage repositories are assigned to the **default (first) backup proxy**. On a fresh installation this is the only available proxy. If proxy pools are required they must be configured manually after the import. +- Backup job items (users, groups, sites, teams) are resolved by **display name, UPN, or URL** against the target organization. If an object has been renamed or removed in the Microsoft 365 tenant since the export was taken, it will not resolve and a warning is shown. The job is still created with all items that did resolve. +- **Repository and organization matching uses names, not IDs.** IDs change when a resource is recreated on a new server. Ensure repository and organization names on the target match those in the export. +- Import v1 (`Import-VB365Config_v1.ps1`) covers only organizations and repositories. Use v2 for full restore including jobs. + +### .EXAMPLE + +```powershell +# Basic restore on the local VB365 server +.\Import-VB365Config_v2.ps1 ` + -ImportPath "C:\VB365_Backup\VB365_Export_20260630_120000" ` + -CertificatePassword (Read-Host -AsSecureString "PFX password") + +# Dry run — see what would happen without making changes +.\Import-VB365Config_v2.ps1 ` + -ImportPath "C:\VB365_Backup\VB365_Export_20260630_120000" ` + -CertificatePassword (Read-Host -AsSecureString "PFX password") ` + -WhatIf +``` + +--- + +## Requirements + +- **Veeam Backup for Microsoft 365** v8 or later +- **PowerShell** 5.1 or later +- `Veeam.Archiver.PowerShell` module (installed automatically with VB365) +- Scripts must run on the VB365 server, or with network access to a remote VB365 server +- For certificate export: the backup application PFX must be present in the **local Windows certificate store** of the machine running the export script + +## Limitations + +- Cloud account secret keys, encryption key material, and user passwords are **never exported** — they must be re-entered manually +- Proxy pool assignments are not restored — repositories are added to the default proxy only +- On-premises hybrid organization components are not covered +- RBAC role assignments and federated authority configuration are exported for reference but not automatically restored by the import scripts diff --git a/VB365-JetToObjectMigration/README.md b/VB365-JetToObjectMigration/README.md new file mode 100644 index 0000000..3117126 --- /dev/null +++ b/VB365-JetToObjectMigration/README.md @@ -0,0 +1,188 @@ +# Veeam Backup for Microsoft 365 - Jet to Object Storage Migration +This project has the goal to support in migrating backup data from disk Repositories to object storage Repositories. + +## Important note: +Please be aware that the provided code is only seen as examples and is not officially tested and supported by Veeam. The used commands themself are supported, since they are offered directly through the product. +Always check the offical Veeam Backup for Microsoft 365 PowerShell Reference in this limited access [Helpcenter](https://helpcenter.veeam.com/archive/vbo365/8.5/powershell_private/backup_data.html) version including the hidden comdlets. + +## Hint for commands +Most commands require some objects to run. For example, the Start-VBODataMigration cmdlet requires objects like job, repositories or proxy, depending on the run mode. These objects can be created with Get-VBORepository and Get-VBOProxy etc. + +## Important to know +- **Veeam Backup for Microsoft 365** will be called **VB365** as an acronym in this document. +- the commands must be run in PowerShell v7, which is default if started from the VB365 GUI. +- It is highly recommended to run migrations with **VB365 v8.5 or newer**. +- This migration option is only supported from **Jet to Object Storage Repositories**. +- The target Object Storage Repository can **not** have immutability enabled. +- Migration from multiple Jet Repositories to a single Object Storage Repository is currently **not** supported. +- Make sure that port 9193 is opened between the proxy servers, including the default proxy on the VB365 server. +- Disk repositories are always bound to a single windows-based Proxy. +- Subsequence migration runs can use bookmarks stored in the target repositories for Mailbox folder/items, Pharepoint list items and Sharepoint list views. Fully processed during re-runs are other data like sites, list metadata, web change tokens, web parts and most Teams data. The data is not duplicated in the target Repository but currently needs to be read and processed for consistency checks from source repository by the assigned proxy to the target proxy. +- The -Full parameter of Start-VBODataMigration will force wipe out related bookmarks in the target repository and read all items fully from source again. No duplication will be done on the target repository if the items are identical. +- Monitor the saturation of source and target Proxy, VB365 Controller (Server), PostgreSQL server and NATS server during migrations. It might be necessary to temporarily add additional compute resources, especially when running source jobs in parallel. From experiences of runs in production environments it is recommended to add around 50% more CPU and MEM on the source Proxy holding the Jet Repository. + +## Workflow overview + +1. Disable retention on the source proxy +2. Enable Data Migration feature +3. Start migration to an empty target repository +4. Monitor status until you see Success or Warning +5. Manage migrations +6. Verify data consistency by comparing source and target repository inventory reports +7. Remove migration lock to enable regular use of the target repository +8. (optional) Enable retention on the source proxy + +## Migration Scenarios +### Single source job +#### Situation + A single source job is writing to a Jet Repository. + The Schedule is frequently and might be running in between the initial migration run. + The migration should target an object storage Repository. +#### Advice + This is a supported scenario and the -SwitchJobToTargetRepository paramter should be used. + Use the -Job parameter to target the migration for this specific source job. + If the -SwitchJobToTargetRepository parameter was true, the source job will be disabled once the migration could successfully finish. + +### Multiple source jobs on same Repository +#### Situation + Multiple source jobs are writing to the same Jet Repository. + The Schedule is frequently and might be running in between the initial migration run. + The migration should target an object storage Repository. +#### Advice + This is a supported scenario and the -SwitchJobToTargetRepository paramter can be used when planned. + Use the -Organization parameter to target the migration for the whole source Repository. This will effect all jobs targeting this repository and if the -SwitchJobToTargetRepository parameter was true, the jobs will be disabled once the migration could successfully finish. + It is possible to use job based migration from the same source repository as long as the MigrationLock is on place on the target repository. + Only use Remove-VBODataMigrationLock once all data has been migrated! + +### Multiple source Repositories to a single Repository +#### Situation + Multiple source jobs are writing to different Jet Repositories. + The migration should target a single object storage Repository for these multiple source Jet Repositories. +#### Advice + This is not a supported scenario! + +## Workflow steps in detail +### 1. Disable retention on the source proxy + +#### Purpose +Prevents the retention cleanup job from deleting recently migrated data, which can cause verification errors. +#### Outcome +Disables retention for the source repository, ensuring all data remains on the source during migration. +#### Execute +``` +Set-VBOConfigurationParameter -XPath "/Veeam/Archiver/RepositoryConfig" -Key "RetentionDisabled" -Value "True" -Proxy {proxy} +``` +#### Notes +The {proxy} parameter is a VB365 Proxy object which needs to be created with the Get-VBOProxy cmdlet. +Example: +``` +$proxy = Get-VBOProxy -Hostname proxy01 +Set-VBOConfigurationParameter -XPath "/Veeam/Archiver/RepositoryConfig" -Key "RetentionDisabled" -Value "True" -Proxy $proxy +``` +### 2. Enable Data Migration feature +#### Purpose +Some Data Migration related cmdlets are disabled by default and they must be enabled first. +#### Execute +``` +[Environment]::SetEnvironmentVariable("VEEAM_DATA_MIGRATION_ENABLED", "true") +``` +#### Notes +Setting this in a PowerShell session is only kept for the current session. If needed frequently please add this variable globally in the system. + +### 3. Start migration to an empty target repository +#### Purpose +Begins the migration of data from a source repository to a target repository +#### Prerequisites +The target repository must be empty. Starting a migration creates a migration lock on the target repository, restricting its use to ongoing migration only. +#### Execute +An example script with a GUI wrapper can be found within in this folder: *VB365-JetToOsrMigration-GUI.ps1* + +The manual start of a migration can be done in the following ways: + +Job mode: +``` +Start-VBODataMigration -Job -To [-SwitchJobToTargetRepository] [-RunAsync] +``` +Organization mode: +``` +Start-VBODataMigration -Organization -From -To [-SwitchJobToTargetRepository] [-RunAsync] +``` +#### Outcome +Returns a migration session ID (JobId) for tracking progress if run with *-RunAsync* +#### Notes +If you use the *-SwitchJobToTargetRepository* parameter, the job switches only after a successful migration. If themigration finishes with errors or warnings, the switch does not occur. After switching, the job remains disabled until you perform the migration verification check and remove migration lock. + +### 4. Monitor status +#### Purpose +Tracks the status of the migration session using the session ID. +Key status values: +- Success: Migration completed successfully. +- Warning: Migration completed with non critical warnings. +- Failed: Migration failed. +- Running, Stopped, etc.: Indicates current progress/state. +#### Execute +To get the status of all migration jobs: +``` +Get-VBODataMigration +``` +To get the status of a specific migration job: +``` +Get-VBODataMigration -id +``` + +### 5. Manage migration jobs +In case it is needed to suspend or stop a migration job, the commands +*Suspend-VBODataMigration* , *Resume-VBODataMigration* and *Stop-vBODataMigration* can be used. +#### Execute +Get the object for the migration to manage: +``` +$migration = Get-VBODataMigration -id +``` +Suspend a migration: +``` +Suspend-VBODataMigration -migration $migration +``` +Resume a migration: +``` +Resume-VBODataMigration -migration $migration [-RunAsync] +``` +Stop a migration and end the process: +``` +Stop-VBODataMigration -migration $migration +``` +#### Notes +When a migration is stopped, no switch of the backup job or unlocking is taking place. +Managing migration jobs with these commands might take a moment to complete. + +### 6. Verify Data Consistency +#### Purpose +Export inventory reports from both the source and target repositories for comparison, in order to verify that all items were successfully migrated. The Verification PowerShell Script *VB365-JetToOsrVerification-GUI.ps1* in this folder can be used to compare the data. +#### Outcome +No differences should be found between source and target. If any differences are detected, it could indicate a data loss during the migration process. In such case, try to run the migration again and if the issue persists, open a support ticket. +#### Execute +Run the *VB365-JetToOsrVerification-GUI.ps1* and perform the selections as needed before starting the verification. +#### Notes +The provided script in this folder is provided to ease the process for data verification. + +### 7. Remove Migration Lock +#### Purpose +Removes the migration lock from the target repository and enables the jobs to allow normal operations such as backups and retention jobs. +#### Execute +``` +$repository = Get-VBORepository -id +Remove-VBODataMigrationLock -Repository $repository +``` +#### Note +Once the lock is removed, you cannot repeat the migration for the same data set. You can use the *Remove-VBODataMigrationLock-GUI.ps1* script to do this in a graphical UI. + +### 8. Enable retention +#### Purpose +Put back the retention cleanup for the source repositories on a proxy. +#### Outcome +Enables back the retention for all repositories on the source proxy +#### Execute +``` +Set-VBOConfigurationParameter -XPath "/Veeam/Archiver/RepositoryConfig" -Key "RetentionDisabled" -Value "False" -Proxy +``` +#### Notes +Only perform this step once all required migrations from this proxy are completed. \ No newline at end of file diff --git a/VB365-JetToObjectMigration/VB365-EnableMigrationCmdlets.ps1 b/VB365-JetToObjectMigration/VB365-EnableMigrationCmdlets.ps1 new file mode 100644 index 0000000..168fcb2 --- /dev/null +++ b/VB365-JetToObjectMigration/VB365-EnableMigrationCmdlets.ps1 @@ -0,0 +1,24 @@ +<# +.SYNOPSIS + Enable the migration related cmdlets for migrating Jet Repositories to Object Storage Repositories. + +.DESCRIPTION + This script need to be run in PowerShell v7. It enables the migration related cmdlets for migrating Jet Repositories to Object Storage Repositories and the easiest way would be to run it on the VB365 Controller. + The enablement is only for the current PowerShell session, so if you want to use the migration cmdlets in a new PowerShell session, you will need to run this script again. + +.OUTPUTS + Enabled migration related cmdlets for migrating Jet Repositories to Object Storage Repositories. + +.NOTES + NAME: VB365-EnableMigrationCmdlets.ps1 + VERSION: 1 + AUTHOR: David Bewernick + GITHUB: https://github.com/d-works + +#> + +# If VBO is installed in a different path, please replace it with your own path. +Import-Module 'C:\Program Files\Veeam\Backup365\Veeam.Archiver.PowerShell.dll' + +#enable the migration option (on VB365 server) +[Environment]::SetEnvironmentVariable("VEEAM_DATA_MIGRATION_ENABLED", "true") \ No newline at end of file diff --git a/VB365-JetToObjectMigration/VB365-JetToOsrMigration.ps1 b/VB365-JetToObjectMigration/VB365-JetToOsrMigration.ps1 new file mode 100644 index 0000000..8fc7456 --- /dev/null +++ b/VB365-JetToObjectMigration/VB365-JetToOsrMigration.ps1 @@ -0,0 +1,123 @@ +<# +.SYNOPSIS + Start the job based migration of Jet Repositories to Object Storage Repositories + +.DESCRIPTION + This script need to be run on the VB365 Controller. It reads the configuration to make a selection for a certain job possible. + After the selections have been made, the related proxy is getting modified and the migration started. + +.OUTPUTS + Start the Start-VBODataMigration process for a specific selected job, switch over to the new target and leaves the job disabled. + Before re-enabling the job, the migration needs to be verified by using the verification script. + +.NOTES + NAME: VB365-JetToObjectMigration.ps1 + VERSION: 0.5 + AUTHOR: David Bewernick + GITHUB: https://github.com/d-works + +#> + +# If VBO is installed in a different path, please replace it with your own path. +Import-Module 'C:\Program Files\Veeam\Backup365\Veeam.Archiver.PowerShell.dll' + +#enable the migration option (on VB365 server) +[Environment]::SetEnvironmentVariable("VEEAM_DATA_MIGRATION_ENABLED", "true") + +# Selection of organization, job, source and target repository +# Organization selection +Write-Host "Select Organization:" +$orgs = Get-VBOOrganization | Sort-Object Name +for($i=0; $i -lt $orgs.count; $i++) { Write-Host $i. $orgs[$i].name } +$organisationNum = Read-Host "Enter organization number" +$organization = $orgs[$organisationNum] +Write-Host + +# Validation type selection +Write-Host "Select validation type:" +Write-Host "0. Organization" +Write-Host "1. Job" +$validationTypeNum = Read-Host "Enter validation type number" +Write-Host + +if ($validationTypeNum -eq "1") { + # Job selection + Write-Host "Select Job:" + $jobs = Get-VBOJob -Organization $organization | Sort-Object Name + for($i=0; $i -lt $jobs.count; $i++) { Write-Host $i. $jobs[$i].name } + $jobNum = Read-Host "Enter job number" + $selectedJob = $jobs[$jobNum] + $validationTarget = $selectedJob + $validationType = "Job" + $inventoryDataIdColumnName = "Backup Job Id" + Write-Host + + # get the proxy object (holding the Jet based repository) + $proxy = $selectedJob.Repository.Proxy + + # get the source repository (Jet based) from the job settings + $sourceRepository = $selectedJob.Repository + +} else { + # Source Repository selection + Write-Host "Select Source Repository:" + $sourceRepos = Get-VBORepository | Where-Object{($_.ObjectStorageRepository -eq $Null) -and (Get-VBOEntityData -Repository $_ -Type Organization -Name $organization.Name) -ne $Null} | Sort-Object Name + for($i=0; $i -lt $sourceRepos.count; $i++) { Write-Host $i. $sourceRepos[$i].name } + $sourceRepoNum = Read-Host "Enter Source repository number" + $sourceRepository = $sourceRepos[$sourceRepoNum] + Write-Host + + # get the proxy object (holding the Jet based repository) + $proxy = $sourceRepository.Proxy +} + +# Target Repository selection +Write-Host "Select Target Repository:" +$targetRepos = Get-VBORepository | Where-Object{($_.ObjectStorageRepository -ne $Null)} | Sort-Object Name +for($i=0; $i -lt $targetRepos.count; $i++) { Write-Host $i. $targetRepos[$i].name } +$targetRepoNum = Read-Host "Enter Target repository number" +$targetRepository = $targetRepos[$targetRepoNum] +Write-Host + +# Switch job to target repository during migration? +Write-Host "Switch job to target repository during migration? (y/n)" +$switchJob = Read-Host + +# disable the retention for the proxy +Set-VBOConfigurationParameter -XPath "/Veeam/Archiver/RepositoryConfig" -Key "RetentionDisabled" -Value "True" -Proxy $proxy + +if ($validationTypeNum -eq "0") { + # start the job mode migration process + if($switchJob -eq "y") { + Start-VBODataMigration -Organization $organization -From $sourceRepository -To $targetRepository -SwitchJobToTargetRepository -RunAsync + } + else { + Start-VBODataMigration -Organization $organization -From $sourceRepository -To $targetRepository -RunAsync + } +} + +if ($validationTypeNum -eq "1") { + # start the job mode migration process + if($switchJob -eq "y") { + Start-VBODataMigration -Job $selectedJob -From $sourceRepository -To $targetRepository -SwitchJobToTargetRepository -RunAsync + } + else { + Start-VBODataMigration -Job $selectedJob -From $sourceRepository -To $targetRepository -RunAsync + } +} + + + +# ------------------- +# wait until migration run is finished. to check the status, start a new console and run: +# [Environment]::SetEnvironmentVariable("VEEAM_DATA_MIGRATION_ENABLED", "true") +# Get-VBODataMigration +# ------------------- + +# !! validate source and target with script "Jet to OSR migration script.ps1"!! + +# Remove taget repository lock +# Remove-VBODataMigrationLock -Repository $targetRepository + +# enable the retention for the proxy after validation is successful +# Set-VBOConfigurationParameter -XPath "/Veeam/Archiver/RepositoryConfig" -Key "RetentionDisabled" -Value "False" -Proxy $proxy \ No newline at end of file diff --git a/VB365-JetToObjectMigration/VB365-JetToOsrVerification.ps1 b/VB365-JetToObjectMigration/VB365-JetToOsrVerification.ps1 new file mode 100644 index 0000000..b0e1492 --- /dev/null +++ b/VB365-JetToObjectMigration/VB365-JetToOsrVerification.ps1 @@ -0,0 +1,265 @@ +<# +.SYNOPSIS + Verifies that a Veeam Backup for Microsoft 365 (VB365) data migration from a + JET (block storage) repository to an Object Storage Repository (OSR) completed + without data loss. + +.DESCRIPTION + This script validates a JET-to-Object-Storage migration by generating inventory + reports for both the source (JET) and target (object storage) repositories and + comparing them for discrepancies. + + The script interactively prompts the operator to select: + - The Organization to verify. + - The validation scope: an entire Organization or a single Job. + - The source repository (a JET/block repository that is not backed by object storage). + - The target repository (a repository backed by object storage). + + It then uses Get-VBORepositoryInventoryReport to produce inventory reports for the + latest restore point on both repositories (including all versions and deleted items) + and compares the following data types between source and target: + - Mailboxes + - SharePoint Sites + - Teams + - OneDrive + + Any mismatch in item counts or identifiers is reported, indicating that the migration + may not have copied all data successfully. + + Prerequisites: + - Run on a VB365 server (or a host with the VB365 PowerShell module installed). + - PowerShell 7 is required (the script uses the ternary operator). + - Adjust the $reportPath variable and the Import-Module path if VB365 is installed + in a non-default location. + +.OUTPUTS + Writes status, comparison progress, and any detected differences to the console. + Inventory report CSV files are written to the path defined by $reportPath + (default: C:\VBOMigrationReports). + + If any differences are found between the source and target inventories, the script + prints the mismatching records and throws a terminating error. If no differences are + found, it logs a success message. + +.NOTES + NAME: VB365-JetToOsrVerification.ps1 + VERSION: 0.5 + KUDOS: Special thanks to the Veeam team to provide valuable input. +#> + +# Variables - set these accordingly +$reportPath = "C:\VBOMigrationReports" + +# If VBO is installed in a different path, please replace it with your own path. +Import-Module 'C:\Program Files\Veeam\Backup365\Veeam.Archiver.PowerShell.dll' + + +# Logging Function +function Write-Log { + param( + [Parameter(Mandatory=$true)] + [string]$Message, + + [Parameter(Mandatory=$false)] + [ValidateSet("INFO", "SUCCESS", "WARNING")] + [string]$Level = "INFO" + ) + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $logMessage = "[$timestamp] [$Level] $Message" + + # Color mapping for different log levels + $colors = @{ + "INFO" = "White" + "SUCCESS" = "Green" + "WARNING" = "Yellow" + } + + $color = $colors[$Level] + + if ($NoNewLine) { + Write-Host $logMessage -ForegroundColor $color -NoNewline + } else { + Write-Host $logMessage -ForegroundColor $color + } +} + +# Helper functions for different log levels +function Write-LogInfo($Message) { Write-Log -Message $Message -Level "INFO" } +function Write-LogSuccess($Message) { Write-Log -Message $Message -Level "SUCCESS" } +function Write-LogWarning($Message) { Write-Log -Message $Message -Level "WARNING" } + +# Function to format comparison differences +function Format-ComparisonDifferences { + param( + [Parameter(Mandatory=$true)] + [object]$DiffObject, + + [Parameter(Mandatory=$true)] + [string]$DataType + ) + + Write-LogWarning "$DataType data mismatch detected! Differences:" + $DiffObject | Select-Object @{Name="Repository";Expression={ + switch ($_.SideIndicator) { + "=>" { "Target" } + "<=" { "Source" } + } + }}, * | Select-Object -ExcludeProperty SideIndicator | Format-Table -AutoSize +} + +# Function to compare data between source and target inventories +function Compare-InventoryData { + param( + [Parameter(Mandatory=$true)] + [AllowEmptyString()] + [AllowNull()] + [string]$SourceInventoryReport, + + [Parameter(Mandatory=$true)] + [AllowEmptyString()] + [AllowNull()] + [string]$TargetInventoryReport, + + [Parameter(Mandatory=$true)] + [string]$DataType, + + [Parameter(Mandatory=$true)] + [string[]]$CompareProperties + ) + + $SourceInventoryReportIsMissing = [string]::IsNullOrEmpty($SourceInventoryReport) + $TargetInventoryReportIsMissing = [string]::IsNullOrEmpty($TargetInventoryReport) + + if (-not $SourceInventoryReportIsMissing -and -not $TargetInventoryReportIsMissing) { + Write-LogInfo "Comparing $DataType..." + $sourceCsv = Import-Csv -Path $SourceInventoryReport + $targetCsv = Import-Csv -Path $TargetInventoryReport + $diff = Compare-Object -ReferenceObject $sourceCsv -DifferenceObject $targetCsv -Property $CompareProperties + return $diff + } else { + $missingReports = @() + if ($SourceInventoryReportIsMissing) { $missingReports += "Source" } + if ($TargetInventoryReportIsMissing) { $missingReports += "Target" } + $missingText = $missingReports -join " and " + + if ((-not $SourceInventoryReportIsMissing -and $TargetInventoryReportIsMissing) -or ($SourceInventoryReportIsMissing -and -not $TargetInventoryReportIsMissing)) { + if (-not $SourceInventoryReportIsMissing) { $presentReport = "Source" } + if (-not $TargetInventoryReportIsMissing) { $presentReport = "Target" } + + Write-LogWarning "$DataType report found only in $presentReport inventory, but is missing in $missingText inventory. Skipping $DataType comparison. This may happen e.g. when you were migrating data for specific Job, but verifying data for whole Organization." + } else { + Write-LogInfo "No $DataType report found in $missingText inventory. Skipping $DataType comparison." + } + return $null + } +} +# --------------------------------------------------------------------------------------- + +# Organization selection +Write-Host "Select Organization:" +$orgs = Get-VBOOrganization | Sort-Object Name +for($i=0; $i -lt $orgs.count; $i++) { Write-Host $i. $orgs[$i].name } +$organisationNum = Read-Host "Enter organization number" +$organization = $orgs[$organisationNum] +Write-Host + +# Validation type selection +Write-Host "Select validation type:" +Write-Host "0. Organization" +Write-Host "1. Job" +$validationTypeNum = Read-Host "Enter validation type number" +Write-Host + +if ($validationTypeNum -eq "1") { + # Job selection + Write-Host "Select Job:" + $jobs = Get-VBOJob -Organization $organization | Sort-Object Name + for($i=0; $i -lt $jobs.count; $i++) { Write-Host $i. $jobs[$i].name } + $jobNum = Read-Host "Enter job number" + $selectedJob = $jobs[$jobNum] + $validationTarget = $selectedJob + $validationType = "Job" + $inventoryDataIdColumnName = "Backup Job Id" + Write-Host +} else { + $validationTarget = $organization + $validationType = "Organization" + $inventoryDataIdColumnName = "Organization Id" +} + +# Source Repository selection +Write-Host "Select Source Repository:" +$sourceRepos = Get-VBORepository | Where-Object{($_.ObjectStorageRepository -eq $Null) -and (Get-VBOEntityData -Repository $_ -Type Organization -Name $organization.Name) -ne $Null} | Sort-Object Name +for($i=0; $i -lt $sourceRepos.count; $i++) { Write-Host $i. $sourceRepos[$i].name } +$sourceRepoNum = Read-Host "Enter Source repository number" +$sourceRepository = $sourceRepos[$sourceRepoNum] +Write-Host + +# Target Repository selection +Write-Host "Select Target Repository:" +$targetRepos = Get-VBORepository | Where-Object{($_.ObjectStorageRepository -ne $Null) -and (Get-VBOEntityData -Repository $_ -Type Organization -Name $organization.Name) -ne $Null} | Sort-Object Name +for($i=0; $i -lt $targetRepos.count; $i++) { Write-Host $i. $targetRepos[$i].name } +$targetRepoNum = Read-Host "Enter Target repository number" +$targetRepository = $targetRepos[$targetRepoNum] +Write-Host + +# Verify Data Migration (compare inventory reports) +$latestRestorePoint = Get-VBORestorePoint -Repository $sourceRepository -Latest + +Write-LogInfo "Generating inventory reports for source repository..." +if ($validationType -eq "Organization") { + $sourceInventory = Get-VBORepositoryInventoryReport -OutputPath $reportPath -BackupTime $latestRestorePoint.BackupTime -Repository $sourceRepository -Organization $organization -IncludeAllVersions -IncludeDeleted +} else { + $sourceInventory = Get-VBORepositoryInventoryReport -OutputPath $reportPath -BackupTime $latestRestorePoint.BackupTime -Repository $sourceRepository -Job $selectedJob -IncludeAllVersions -IncludeDeleted +} + +Write-LogInfo "Generating inventory reports for target repository..." +if ($validationType -eq "Organization") { + $targetInventory = Get-VBORepositoryInventoryReport -OutputPath $reportPath -BackupTime $latestRestorePoint.BackupTime -Repository $targetRepository -Organization $organization -IncludeAllVersions -IncludeDeleted +} else { + $targetInventory = Get-VBORepositoryInventoryReport -OutputPath $reportPath -BackupTime $latestRestorePoint.BackupTime -Repository $targetRepository -Job $selectedJob -IncludeAllVersions -IncludeDeleted +} +Write-Host + +# Compare Mailboxes +$mailDiff = Compare-InventoryData -SourceInventoryReport ($sourceInventory ? $sourceInventory.MailboxesReport : $null) -TargetInventoryReport ($targetInventory ? $targetInventory.MailboxesReport : $null) -DataType "Mailboxes" -CompareProperties @($inventoryDataIdColumnName, "Mailbox ID", "Mailbox Name", "Mailbox Folder Count", "Mailbox Item Count") + +# Compare SharePoint Sites +$websDiff = Compare-InventoryData -SourceInventoryReport ($sourceInventory ? $sourceInventory.RootWebsReport : $null) -TargetInventoryReport ($targetInventory ? $targetInventory.RootWebsReport : $null) -DataType "SharePoint Sites" -CompareProperties @($inventoryDataIdColumnName, "Site ID", "Root Site ID", "Root Site URL", "Root Site Hierarchy Item Version Count") + +# Compare Teams +$teamsDiff = Compare-InventoryData -SourceInventoryReport ($sourceInventory ? $sourceInventory.TeamsReport : $null) -TargetInventoryReport ($targetInventory ? $targetInventory.TeamsReport : $null) -DataType "Teams" -CompareProperties @($inventoryDataIdColumnName, "Team ID", "Team Name", "Team Channel Message Count", "Team Tab Count", "Team Channel Count", "Team File Count", "Team User Count") + +# Compare OneDrive +$oneDrivesDiff = Compare-InventoryData -SourceInventoryReport ($sourceInventory ? $sourceInventory.OneDrivesReport : $null) -TargetInventoryReport ($targetInventory ? $targetInventory.OneDrivesReport : $null) -DataType "OneDrive" -CompareProperties @($inventoryDataIdColumnName, "Account Name", "OneDrive Item Count") + +# Print all differences, if any +Write-Host +$hasDiff = $false + +if ($mailDiff) { + Format-ComparisonDifferences -DiffObject $mailDiff -DataType "Mailboxes" + $hasDiff = $true +} + +if ($websDiff) { + Format-ComparisonDifferences -DiffObject $websDiff -DataType "SharePoint" + $hasDiff = $true +} + +if ($teamsDiff) { + Format-ComparisonDifferences -DiffObject $teamsDiff -DataType "Teams" + $hasDiff = $true +} + +if ($oneDrivesDiff) { + Format-ComparisonDifferences -DiffObject $oneDrivesDiff -DataType "OneDrive" + $hasDiff = $true +} + +if ($hasDiff) { + throw "Data verification failed! See above for details on mismatches." +} + +Write-LogSuccess "Data verification completed successfully. No differences found." \ No newline at end of file diff --git a/VB365-JetToObjectMigration/VB365-RemoveVBODataMigrationLock-GUI.ps1 b/VB365-JetToObjectMigration/VB365-RemoveVBODataMigrationLock-GUI.ps1 new file mode 100644 index 0000000..77fb06c --- /dev/null +++ b/VB365-JetToObjectMigration/VB365-RemoveVBODataMigrationLock-GUI.ps1 @@ -0,0 +1,146 @@ +<# +.SYNOPSIS + GUI wrapper to remove the data migration lock from a Veeam Backup for + Microsoft 365 repository. + +.DESCRIPTION + Presents a window to select a VBO repository, runs Remove-VBODataMigrationLock + against it, and then shows the resulting MigrationLock state of the repository. + Requires the Veeam.Archiver.PowerShell module (Veeam Backup for Microsoft 365). + Run this script in a PowerShell session on the VB365 server. + +.NOTES + NAME: VB365-JetToObjectMigration.ps1 + VERSION: 0.5 + AUTHOR: David Bewernick + GITHUB: https://github.com/d-works +#> + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +# --- Load the Veeam module -------------------------------------------------- +try { + Import-Module Veeam.Archiver.PowerShell -ErrorAction Stop +} +catch { + [System.Windows.Forms.MessageBox]::Show( + "Unable to load the Veeam Backup for Microsoft 365 PowerShell module.`n`n$($_.Exception.Message)", + "Module Error", + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null + return +} + +# enable the migration option (on VB365 server) +[Environment]::SetEnvironmentVariable("VEEAM_DATA_MIGRATION_ENABLED", "true") + +# --- Retrieve the repositories ---------------------------------------------- +try { + $repositories = Get-VBORepository -ErrorAction Stop | Sort-Object Name +} +catch { + [System.Windows.Forms.MessageBox]::Show( + "Unable to retrieve repositories.`n`n$($_.Exception.Message)", + "Repository Error", + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null + return +} + +if (-not $repositories) { + [System.Windows.Forms.MessageBox]::Show( + "No repositories were found.", + "No Repositories", + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Warning) | Out-Null + return +} + +# --- Build the form --------------------------------------------------------- +$form = New-Object System.Windows.Forms.Form +$form.Text = "Remove VBO Data Migration Lock" +$form.Size = New-Object System.Drawing.Size(520, 360) +$form.StartPosition = "CenterScreen" +$form.FormBorderStyle = "FixedDialog" +$form.MaximizeBox = $false +$form.MinimizeBox = $false + +# Label for the repository selector +$lblRepo = New-Object System.Windows.Forms.Label +$lblRepo.Text = "Select repository:" +$lblRepo.Location = New-Object System.Drawing.Point(15, 20) +$lblRepo.AutoSize = $true +$form.Controls.Add($lblRepo) + +# ComboBox with the repositories +$cmbRepo = New-Object System.Windows.Forms.ComboBox +$cmbRepo.Location = New-Object System.Drawing.Point(15, 45) +$cmbRepo.Size = New-Object System.Drawing.Size(475, 24) +$cmbRepo.DropDownStyle = "DropDownList" +foreach ($repo in $repositories) { + [void]$cmbRepo.Items.Add($repo.Name) +} +$cmbRepo.SelectedIndex = 0 +$form.Controls.Add($cmbRepo) + +# Button to start removing the lock +$btnRun = New-Object System.Windows.Forms.Button +$btnRun.Text = "Remove Migration Lock" +$btnRun.Location = New-Object System.Drawing.Point(15, 80) +$btnRun.Size = New-Object System.Drawing.Size(180, 30) +$form.Controls.Add($btnRun) + +# Output text box +$txtOutput = New-Object System.Windows.Forms.TextBox +$txtOutput.Location = New-Object System.Drawing.Point(15, 125) +$txtOutput.Size = New-Object System.Drawing.Size(475, 150) +$txtOutput.Multiline = $true +$txtOutput.ReadOnly = $true +$txtOutput.ScrollBars = "Vertical" +$txtOutput.Font = New-Object System.Drawing.Font("Consolas", 9) +$form.Controls.Add($txtOutput) + +# OK button (closes the window) +$btnOk = New-Object System.Windows.Forms.Button +$btnOk.Text = "OK" +$btnOk.Location = New-Object System.Drawing.Point(415, 285) +$btnOk.Size = New-Object System.Drawing.Size(75, 30) +$btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK +$form.Controls.Add($btnOk) +$form.AcceptButton = $btnOk + +# --- Button click logic ----------------------------------------------------- +$btnRun.Add_Click({ + $selectedName = $cmbRepo.SelectedItem + $repository = $repositories | Where-Object { $_.Name -eq $selectedName } | Select-Object -First 1 + + if (-not $repository) { + $txtOutput.Text = "Could not resolve the selected repository." + return + } + + $btnRun.Enabled = $false + $form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor + $txtOutput.Text = "Removing data migration lock from '$($repository.Name)'..." + $form.Refresh() + + try { + Remove-VBODataMigrationLock -Repository $repository -ErrorAction Stop -Confirm:$false + + # Re-query the repository to show the resulting MigrationLock state + $result = Get-VBORepository -Id $repository.Id | Select-Object Name, MigrationLock + $txtOutput.Text = ($result | Format-List | Out-String).Trim() + } + catch { + $txtOutput.Text = "An error occurred:`r`n$($_.Exception.Message)" + } + finally { + $form.Cursor = [System.Windows.Forms.Cursors]::Default + $btnRun.Enabled = $true + } +}) + +# --- Show the form ---------------------------------------------------------- +[void]$form.ShowDialog() +$form.Dispose() diff --git a/VB365-JetToObjectMigration/experimental/VB365-JetToOsrMigration-GUI.ps1 b/VB365-JetToObjectMigration/experimental/VB365-JetToOsrMigration-GUI.ps1 new file mode 100644 index 0000000..9d48429 --- /dev/null +++ b/VB365-JetToObjectMigration/experimental/VB365-JetToOsrMigration-GUI.ps1 @@ -0,0 +1,330 @@ +<# +.SYNOPSIS + GUI wrapper to start the job based migration of Jet Repositories to Object Storage Repositories + +.DESCRIPTION + This script needs to be run on the VB365 Controller. It provides a graphical front end for selecting the + organization, validation type (Organization / Job), source and target repository and whether the job should be + switched over to the target repository during migration. The selections mirror the original console script + (VB365-JetToOsrMigration.ps1). All status output is written into the output text box at the bottom of the window. + +.OUTPUTS + Starts the Start-VBODataMigration process for the selected scope, switches over to the new target (if requested) + and leaves the job disabled. Before re-enabling the job, the migration needs to be verified by using the + verification script. + +.NOTES + NAME: VB365-JetToOsrMigration-GUI.ps1 + VERSION: 0.5 + AUTHOR: David Bewernick + GITHUB: https://github.com/d-works +#> + +# --------------------------------------------------------------------------- +# Pre-requisites +# --------------------------------------------------------------------------- +# IMPORTANT: load the Veeam module BEFORE the WinForms assemblies. In PowerShell 7 +# the Veeam module performs its service endpoint handshake over gRPC. If WinForms / +# System.Drawing are loaded first, they win assembly binding for shared dependencies +# and the handshake fails with "Failed to perform endpoint handshake". Loading the +# Veeam stack first (and forcing the handshake to complete) avoids the conflict. + +# If VBO is installed in a different path, please replace it with your own path. +Import-Module 'C:\Program Files\Veeam\Backup365\Veeam.Archiver.PowerShell.dll' + +# enable the migration option (on VB365 server) +[Environment]::SetEnvironmentVariable("VEEAM_DATA_MIGRATION_ENABLED", "true") + +# Force the endpoint handshake to complete now, while only the Veeam stack is loaded. +$null = Get-VBOOrganization -ErrorAction SilentlyContinue + +# Now load the WinForms assemblies on top of the already-initialised gRPC stack. +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +# --------------------------------------------------------------------------- +# Build the form +# --------------------------------------------------------------------------- +$form = New-Object System.Windows.Forms.Form +$form.Text = "VB365 - Jet to Object Storage Migration" +$form.Size = New-Object System.Drawing.Size(640, 640) +$form.StartPosition = "CenterScreen" +$form.FormBorderStyle = "FixedDialog" +$form.MaximizeBox = $false + +$font = New-Object System.Drawing.Font("Segoe UI", 9) +$form.Font = $font + +# --- Output box -------------------------------------------------------------- +$txtOutput = New-Object System.Windows.Forms.TextBox +$txtOutput.Multiline = $true +$txtOutput.ScrollBars = "Vertical" +$txtOutput.ReadOnly = $true +$txtOutput.Location = New-Object System.Drawing.Point(15, 360) +$txtOutput.Size = New-Object System.Drawing.Size(595, 220) +$txtOutput.Font = New-Object System.Drawing.Font("Consolas", 9) +$form.Controls.Add($txtOutput) + +function Write-Log { + param([string]$Message) + $stamp = (Get-Date).ToString("HH:mm:ss") + $txtOutput.AppendText("[$stamp] $Message`r`n") + $txtOutput.Refresh() +} + +# --- Organization ------------------------------------------------------------ +$lblOrg = New-Object System.Windows.Forms.Label +$lblOrg.Text = "Organization:" +$lblOrg.Location = New-Object System.Drawing.Point(15, 20) +$lblOrg.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblOrg) + +$cmbOrg = New-Object System.Windows.Forms.ComboBox +$cmbOrg.DropDownStyle = "DropDownList" +$cmbOrg.Location = New-Object System.Drawing.Point(180, 17) +$cmbOrg.Size = New-Object System.Drawing.Size(430, 22) +$form.Controls.Add($cmbOrg) + +# --- Validation type --------------------------------------------------------- +$lblType = New-Object System.Windows.Forms.Label +$lblType.Text = "Validation type:" +$lblType.Location = New-Object System.Drawing.Point(15, 60) +$lblType.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblType) + +$cmbType = New-Object System.Windows.Forms.ComboBox +$cmbType.DropDownStyle = "DropDownList" +$cmbType.Location = New-Object System.Drawing.Point(180, 57) +$cmbType.Size = New-Object System.Drawing.Size(430, 22) +[void]$cmbType.Items.Add("Organization") +[void]$cmbType.Items.Add("Job") +$cmbType.SelectedIndex = 0 +$form.Controls.Add($cmbType) + +# --- Job (shown when validation type = Job) ---------------------------------- +$lblJob = New-Object System.Windows.Forms.Label +$lblJob.Text = "Job:" +$lblJob.Location = New-Object System.Drawing.Point(15, 100) +$lblJob.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblJob) + +$cmbJob = New-Object System.Windows.Forms.ComboBox +$cmbJob.DropDownStyle = "DropDownList" +$cmbJob.Location = New-Object System.Drawing.Point(180, 97) +$cmbJob.Size = New-Object System.Drawing.Size(430, 22) +$form.Controls.Add($cmbJob) + +# --- Source repository (shown when validation type = Organization) ----------- +$lblSource = New-Object System.Windows.Forms.Label +$lblSource.Text = "Source Repository:" +$lblSource.Location = New-Object System.Drawing.Point(15, 140) +$lblSource.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblSource) + +$cmbSource = New-Object System.Windows.Forms.ComboBox +$cmbSource.DropDownStyle = "DropDownList" +$cmbSource.Location = New-Object System.Drawing.Point(180, 137) +$cmbSource.Size = New-Object System.Drawing.Size(430, 22) +$form.Controls.Add($cmbSource) + +# --- Target repository ------------------------------------------------------- +$lblTarget = New-Object System.Windows.Forms.Label +$lblTarget.Text = "Target Repository:" +$lblTarget.Location = New-Object System.Drawing.Point(15, 180) +$lblTarget.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblTarget) + +$cmbTarget = New-Object System.Windows.Forms.ComboBox +$cmbTarget.DropDownStyle = "DropDownList" +$cmbTarget.Location = New-Object System.Drawing.Point(180, 177) +$cmbTarget.Size = New-Object System.Drawing.Size(430, 22) +$form.Controls.Add($cmbTarget) + +# --- Switch job checkbox ----------------------------------------------------- +$chkSwitch = New-Object System.Windows.Forms.CheckBox +$chkSwitch.Text = "Switch job to target repository during migration" +$chkSwitch.Location = New-Object System.Drawing.Point(180, 215) +$chkSwitch.Size = New-Object System.Drawing.Size(430, 22) +$form.Controls.Add($chkSwitch) + +# --- Buttons ----------------------------------------------------------------- +$btnRefresh = New-Object System.Windows.Forms.Button +$btnRefresh.Text = "Refresh" +$btnRefresh.Location = New-Object System.Drawing.Point(180, 250) +$btnRefresh.Size = New-Object System.Drawing.Size(120, 30) +$form.Controls.Add($btnRefresh) + +$btnStart = New-Object System.Windows.Forms.Button +$btnStart.Text = "Start Migration" +$btnStart.Location = New-Object System.Drawing.Point(310, 250) +$btnStart.Size = New-Object System.Drawing.Size(150, 30) +$form.Controls.Add($btnStart) + +$btnQuit = New-Object System.Windows.Forms.Button +$btnQuit.Text = "Quit" +$btnQuit.Location = New-Object System.Drawing.Point(470, 250) +$btnQuit.Size = New-Object System.Drawing.Size(120, 30) +$form.Controls.Add($btnQuit) + +$lblOutput = New-Object System.Windows.Forms.Label +$lblOutput.Text = "Output:" +$lblOutput.Location = New-Object System.Drawing.Point(15, 335) +$lblOutput.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblOutput) + +# --------------------------------------------------------------------------- +# Data loading / event logic +# --------------------------------------------------------------------------- + +# Toggle the Job vs Source Repository fields based on the validation type. +function Update-FieldVisibility { + $isJob = ($cmbType.SelectedItem -eq "Job") + $lblJob.Visible = $isJob + $cmbJob.Visible = $isJob + $lblSource.Visible = -not $isJob + $cmbSource.Visible = -not $isJob +} + +# Populate the organization-dependent lists (jobs / source repositories). +function Load-OrgDependentData { + $cmbJob.Items.Clear() + $cmbSource.Items.Clear() + + if ($cmbOrg.SelectedItem -eq $null) { return } + + $organization = $script:orgs[$cmbOrg.SelectedIndex] + + try { + # Jobs for the selected organization + $script:jobs = @(Get-VBOJob -Organization $organization | Sort-Object Name) + foreach ($j in $script:jobs) { [void]$cmbJob.Items.Add($j.Name) } + if ($cmbJob.Items.Count -gt 0) { $cmbJob.SelectedIndex = 0 } + + # Jet based source repositories holding data for the selected organization + $script:sourceRepos = @(Get-VBORepository | + Where-Object { ($_.ObjectStorageRepository -eq $null) -and + ((Get-VBOEntityData -Repository $_ -Type Organization -Name $organization.Name) -ne $null) } | + Sort-Object Name) + foreach ($r in $script:sourceRepos) { [void]$cmbSource.Items.Add($r.Name) } + if ($cmbSource.Items.Count -gt 0) { $cmbSource.SelectedIndex = 0 } + + Write-Log ("Loaded {0} job(s) and {1} source repository(ies) for organization '{2}'." -f ` + $script:jobs.Count, $script:sourceRepos.Count, $organization.Name) + } + catch { + Write-Log ("ERROR loading data for organization: {0}" -f $_.Exception.Message) + } +} + +# Load organizations and target repositories (org independent). +function Load-InitialData { + $cmbOrg.Items.Clear() + $cmbTarget.Items.Clear() + + try { + $script:orgs = @(Get-VBOOrganization | Sort-Object Name) + foreach ($o in $script:orgs) { [void]$cmbOrg.Items.Add($o.Name) } + + $script:targetRepos = @(Get-VBORepository | + Where-Object { $_.ObjectStorageRepository -ne $null } | Sort-Object Name) + foreach ($r in $script:targetRepos) { [void]$cmbTarget.Items.Add($r.Name) } + if ($cmbTarget.Items.Count -gt 0) { $cmbTarget.SelectedIndex = 0 } + + Write-Log ("Loaded {0} organization(s) and {1} target repository(ies)." -f ` + $script:orgs.Count, $script:targetRepos.Count) + + if ($cmbOrg.Items.Count -gt 0) { + $cmbOrg.SelectedIndex = 0 # triggers Load-OrgDependentData + } + } + catch { + Write-Log ("ERROR loading initial data: {0}" -f $_.Exception.Message) + } +} + +# --- Wire up events ---------------------------------------------------------- +$cmbType.Add_SelectedIndexChanged({ Update-FieldVisibility }) +$cmbOrg.Add_SelectedIndexChanged({ Load-OrgDependentData }) +$btnRefresh.Add_Click({ Load-InitialData }) +$btnQuit.Add_Click({ $form.Close() }) + +$btnStart.Add_Click({ + # Validate selections + if ($cmbOrg.SelectedItem -eq $null) { Write-Log "Please select an organization."; return } + if ($cmbTarget.SelectedItem -eq $null) { Write-Log "Please select a target repository."; return } + + $organization = $script:orgs[$cmbOrg.SelectedIndex] + $targetRepository = $script:targetRepos[$cmbTarget.SelectedIndex] + $isJob = ($cmbType.SelectedItem -eq "Job") + $switchJob = $chkSwitch.Checked + + if ($isJob) { + if ($cmbJob.SelectedItem -eq $null) { Write-Log "Please select a job."; return } + $selectedJob = $script:jobs[$cmbJob.SelectedIndex] + $sourceRepository = $selectedJob.Repository + $proxy = $selectedJob.Repository.Proxy + } + else { + if ($cmbSource.SelectedItem -eq $null) { Write-Log "Please select a source repository."; return } + $sourceRepository = $script:sourceRepos[$cmbSource.SelectedIndex] + $proxy = $sourceRepository.Proxy + } + + # Confirm before starting + $scope = if ($isJob) { "Job '$($selectedJob.Name)'" } else { "Organization '$($organization.Name)'" } + $msg = "Start migration for {0}?`r`nFrom: {1}`r`nTo: {2}`r`nSwitch job to target: {3}" -f ` + $scope, $sourceRepository.Name, $targetRepository.Name, $switchJob + $answer = [System.Windows.Forms.MessageBox]::Show($msg, "Confirm Migration", + [System.Windows.Forms.MessageBoxButtons]::YesNo, + [System.Windows.Forms.MessageBoxIcon]::Question) + if ($answer -ne [System.Windows.Forms.DialogResult]::Yes) { + Write-Log "Migration cancelled by user." + return + } + + $btnStart.Enabled = $false + try { + # disable the retention for the proxy + Write-Log "Disabling retention for the proxy..." + Set-VBOConfigurationParameter -XPath "/Veeam/Archiver/RepositoryConfig" -Key "RetentionDisabled" -Value "True" -Proxy $proxy + + Write-Log ("Starting migration ({0}) From '{1}' To '{2}' (SwitchJob={3})..." -f ` + $scope, $sourceRepository.Name, $targetRepository.Name, $switchJob) + + if ($isJob) { + if ($switchJob) { + Start-VBODataMigration -Job $selectedJob -From $sourceRepository -To $targetRepository -SwitchJobToTargetRepository -Confirm:$false -RunAsync + } + else { + Start-VBODataMigration -Job $selectedJob -From $sourceRepository -To $targetRepository -Confirm:$false -RunAsync + } + } + else { + if ($switchJob) { + Start-VBODataMigration -Organization $organization -From $sourceRepository -To $targetRepository -SwitchJobToTargetRepository -Confirm:$false -RunAsync + } + else { + Start-VBODataMigration -Organization $organization -From $sourceRepository -To $targetRepository -Confirm:$false -RunAsync + } + } + + Write-Log "Migration started (running asynchronously)." + Write-Log "To check the status, run 'Get-VBODataMigration' in a console after setting VEEAM_DATA_MIGRATION_ENABLED=true." + Write-Log "Remember to validate source and target before re-enabling the job and re-enabling retention." + } + catch { + Write-Log ("ERROR starting migration: {0}" -f $_.Exception.Message) + } + finally { + $btnStart.Enabled = $true + } +}) + +# --------------------------------------------------------------------------- +# Initialize and show +# --------------------------------------------------------------------------- +Update-FieldVisibility +Load-InitialData + +[void]$form.ShowDialog() +$form.Dispose() diff --git a/VB365-JetToObjectMigration/experimental/VB365-JetToOsrVerification-GUI.ps1 b/VB365-JetToObjectMigration/experimental/VB365-JetToOsrVerification-GUI.ps1 new file mode 100644 index 0000000..32d1928 --- /dev/null +++ b/VB365-JetToObjectMigration/experimental/VB365-JetToOsrVerification-GUI.ps1 @@ -0,0 +1,445 @@ +<# +.SYNOPSIS + GUI wrapper to verify a Jet to Object Storage Repository migration by comparing inventory reports. + +.DESCRIPTION + This script validates a JET-to-Object-Storage migration by generating inventory + reports for both the source (JET) and target (object storage) repositories and + comparing them for discrepancies. It provides a graphical front end for selection. + + The script asks the operator to select: + - The Organization to verify. + - The validation scope: an entire Organization or a single Job. + - The source repository (a JET/block repository that is not backed by object storage). + - The target repository (a repository backed by object storage). + + It then uses Get-VBORepositoryInventoryReport to produce inventory reports for the + latest restore point on both repositories (including all versions and deleted items) + and compares the following data types between source and target: + - Mailboxes + - SharePoint Sites + - Teams + - OneDrive + + Any mismatch in item counts or identifiers is reported, indicating that the migration + may not have copied all data successfully. + + Prerequisites: + - Run on a VB365 server (or a host with the VB365 PowerShell module installed). + - PowerShell 7 is required (the script uses the ternary operator). + + +.OUTPUTS + Inventory reports written to the selected report path and a pass/fail verification result with any detected + differences shown in the output box. + +.NOTES + NAME: VB365-JetToOsrVerification-GUI.ps1 + VERSION: 0.5 + AUTHOR: TBD, David Bewernick +#> + +# --------------------------------------------------------------------------- +# Pre-requisites +# --------------------------------------------------------------------------- +# IMPORTANT: load the Veeam module BEFORE the WinForms assemblies. In PowerShell 7 +# the Veeam module performs its service endpoint handshake over gRPC. If WinForms / +# System.Drawing are loaded first, they win assembly binding for shared dependencies +# and the handshake fails with "Failed to perform endpoint handshake". Loading the +# Veeam stack first (and forcing the handshake to complete) avoids the conflict. + +# If VBO is installed in a different path, please replace it with your own path. +Import-Module 'C:\Program Files\Veeam\Backup365\Veeam.Archiver.PowerShell.dll' + +# Force the endpoint handshake to complete now, while only the Veeam stack is loaded. +$null = Get-VBOOrganization -ErrorAction SilentlyContinue + +# Now load the WinForms assemblies on top of the already-initialised gRPC stack. +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +# --------------------------------------------------------------------------- +# Build the form +# --------------------------------------------------------------------------- +$form = New-Object System.Windows.Forms.Form +$form.Text = "VB365 - Jet to Object Storage Verification" +$form.Size = New-Object System.Drawing.Size(960, 680) +$form.StartPosition = "CenterScreen" +$form.FormBorderStyle = "FixedDialog" +$form.MaximizeBox = $false + +$font = New-Object System.Drawing.Font("Segoe UI", 9) +$form.Font = $font + +# --- Output box (colored) ---------------------------------------------------- +$txtOutput = New-Object System.Windows.Forms.RichTextBox +$txtOutput.ReadOnly = $true +$txtOutput.Location = New-Object System.Drawing.Point(15, 400) +$txtOutput.Size = New-Object System.Drawing.Size(915, 230) +$txtOutput.Font = New-Object System.Drawing.Font("Consolas", 9) +$txtOutput.BackColor = [System.Drawing.Color]::White +$form.Controls.Add($txtOutput) + +# GUI logging helpers (mirror INFO / SUCCESS / WARNING colors of the original script). +function Write-Log { + param( + [Parameter(Mandatory=$true)][string]$Message, + [ValidateSet("INFO", "SUCCESS", "WARNING", "ERROR")][string]$Level = "INFO" + ) + $colors = @{ + "INFO" = [System.Drawing.Color]::Black + "SUCCESS" = [System.Drawing.Color]::Green + "WARNING" = [System.Drawing.Color]::DarkOrange + "ERROR" = [System.Drawing.Color]::Red + } + $stamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + $txtOutput.SelectionStart = $txtOutput.TextLength + $txtOutput.SelectionLength = 0 + $txtOutput.SelectionColor = $colors[$Level] + $txtOutput.AppendText("[$stamp] [$Level] $Message`r`n") + $txtOutput.SelectionColor = $txtOutput.ForeColor + $txtOutput.ScrollToCaret() + $txtOutput.Refresh() +} +function Write-LogInfo($Message) { Write-Log -Message $Message -Level "INFO" } +function Write-LogSuccess($Message) { Write-Log -Message $Message -Level "SUCCESS" } +function Write-LogWarning($Message) { Write-Log -Message $Message -Level "WARNING" } +function Write-LogError($Message) { Write-Log -Message $Message -Level "ERROR" } + +# Print comparison differences into the output box as a formatted table. +function Format-ComparisonDifferences { + param( + [Parameter(Mandatory=$true)][object]$DiffObject, + [Parameter(Mandatory=$true)][string]$DataType + ) + Write-LogWarning "$DataType data mismatch detected! Differences:" + $table = $DiffObject | Select-Object @{Name="Repository";Expression={ + switch ($_.SideIndicator) { + "=>" { "Target" } + "<=" { "Source" } + } + }}, * | Select-Object -ExcludeProperty SideIndicator | Format-Table -AutoSize | Out-String + $txtOutput.AppendText($table.TrimEnd() + "`r`n") + $txtOutput.ScrollToCaret() + $txtOutput.Refresh() +} + +# Compare data between source and target inventories. +function Compare-InventoryData { + param( + [Parameter(Mandatory=$true)][AllowEmptyString()][AllowNull()][string]$SourceInventoryReport, + [Parameter(Mandatory=$true)][AllowEmptyString()][AllowNull()][string]$TargetInventoryReport, + [Parameter(Mandatory=$true)][string]$DataType, + [Parameter(Mandatory=$true)][string[]]$CompareProperties + ) + $SourceInventoryReportIsMissing = [string]::IsNullOrEmpty($SourceInventoryReport) + $TargetInventoryReportIsMissing = [string]::IsNullOrEmpty($TargetInventoryReport) + + if (-not $SourceInventoryReportIsMissing -and -not $TargetInventoryReportIsMissing) { + Write-LogInfo "Comparing $DataType..." + $sourceCsv = Import-Csv -Path $SourceInventoryReport + $targetCsv = Import-Csv -Path $TargetInventoryReport + $diff = Compare-Object -ReferenceObject $sourceCsv -DifferenceObject $targetCsv -Property $CompareProperties + return $diff + } else { + $missingReports = @() + if ($SourceInventoryReportIsMissing) { $missingReports += "Source" } + if ($TargetInventoryReportIsMissing) { $missingReports += "Target" } + $missingText = $missingReports -join " and " + + if ((-not $SourceInventoryReportIsMissing -and $TargetInventoryReportIsMissing) -or ($SourceInventoryReportIsMissing -and -not $TargetInventoryReportIsMissing)) { + if (-not $SourceInventoryReportIsMissing) { $presentReport = "Source" } + if (-not $TargetInventoryReportIsMissing) { $presentReport = "Target" } + Write-LogWarning "$DataType report found only in $presentReport inventory, but is missing in $missingText inventory. Skipping $DataType comparison. This may happen e.g. when you were migrating data for specific Job, but verifying data for whole Organization." + } else { + Write-LogInfo "No $DataType report found in $missingText inventory. Skipping $DataType comparison." + } + return $null + } +} + +# --- Report path ------------------------------------------------------------- +$lblReport = New-Object System.Windows.Forms.Label +$lblReport.Text = "Report path:" +$lblReport.Location = New-Object System.Drawing.Point(15, 20) +$lblReport.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblReport) + +$txtReport = New-Object System.Windows.Forms.TextBox +$txtReport.Location = New-Object System.Drawing.Point(180, 17) +$txtReport.Size = New-Object System.Drawing.Size(650, 22) +$txtReport.Text = "C:\VBOMigrationReports" +$form.Controls.Add($txtReport) + +$btnBrowse = New-Object System.Windows.Forms.Button +$btnBrowse.Text = "Browse..." +$btnBrowse.Location = New-Object System.Drawing.Point(835, 16) +$btnBrowse.Size = New-Object System.Drawing.Size(95, 24) +$form.Controls.Add($btnBrowse) + +# --- Organization ------------------------------------------------------------ +$lblOrg = New-Object System.Windows.Forms.Label +$lblOrg.Text = "Organization:" +$lblOrg.Location = New-Object System.Drawing.Point(15, 60) +$lblOrg.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblOrg) + +$cmbOrg = New-Object System.Windows.Forms.ComboBox +$cmbOrg.DropDownStyle = "DropDownList" +$cmbOrg.Location = New-Object System.Drawing.Point(180, 57) +$cmbOrg.Size = New-Object System.Drawing.Size(750, 22) +$form.Controls.Add($cmbOrg) + +# --- Validation type --------------------------------------------------------- +$lblType = New-Object System.Windows.Forms.Label +$lblType.Text = "Validation type:" +$lblType.Location = New-Object System.Drawing.Point(15, 100) +$lblType.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblType) + +$cmbType = New-Object System.Windows.Forms.ComboBox +$cmbType.DropDownStyle = "DropDownList" +$cmbType.Location = New-Object System.Drawing.Point(180, 97) +$cmbType.Size = New-Object System.Drawing.Size(750, 22) +[void]$cmbType.Items.Add("Organization") +[void]$cmbType.Items.Add("Job") +$cmbType.SelectedIndex = 0 +$form.Controls.Add($cmbType) + +# --- Job (shown when validation type = Job) ---------------------------------- +$lblJob = New-Object System.Windows.Forms.Label +$lblJob.Text = "Job:" +$lblJob.Location = New-Object System.Drawing.Point(15, 140) +$lblJob.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblJob) + +$cmbJob = New-Object System.Windows.Forms.ComboBox +$cmbJob.DropDownStyle = "DropDownList" +$cmbJob.Location = New-Object System.Drawing.Point(180, 137) +$cmbJob.Size = New-Object System.Drawing.Size(750, 22) +$form.Controls.Add($cmbJob) + +# --- Source repository ------------------------------------------------------- +$lblSource = New-Object System.Windows.Forms.Label +$lblSource.Text = "Source Repository:" +$lblSource.Location = New-Object System.Drawing.Point(15, 180) +$lblSource.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblSource) + +$cmbSource = New-Object System.Windows.Forms.ComboBox +$cmbSource.DropDownStyle = "DropDownList" +$cmbSource.Location = New-Object System.Drawing.Point(180, 177) +$cmbSource.Size = New-Object System.Drawing.Size(750, 22) +$form.Controls.Add($cmbSource) + +# --- Target repository ------------------------------------------------------- +$lblTarget = New-Object System.Windows.Forms.Label +$lblTarget.Text = "Target Repository:" +$lblTarget.Location = New-Object System.Drawing.Point(15, 220) +$lblTarget.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblTarget) + +$cmbTarget = New-Object System.Windows.Forms.ComboBox +$cmbTarget.DropDownStyle = "DropDownList" +$cmbTarget.Location = New-Object System.Drawing.Point(180, 217) +$cmbTarget.Size = New-Object System.Drawing.Size(750, 22) +$form.Controls.Add($cmbTarget) + +# --- Buttons ----------------------------------------------------------------- +$btnRefresh = New-Object System.Windows.Forms.Button +$btnRefresh.Text = "Refresh" +$btnRefresh.Location = New-Object System.Drawing.Point(180, 260) +$btnRefresh.Size = New-Object System.Drawing.Size(120, 30) +$form.Controls.Add($btnRefresh) + +$btnVerify = New-Object System.Windows.Forms.Button +$btnVerify.Text = "Verify Migration" +$btnVerify.Location = New-Object System.Drawing.Point(310, 260) +$btnVerify.Size = New-Object System.Drawing.Size(150, 30) +$form.Controls.Add($btnVerify) + +$btnQuit = New-Object System.Windows.Forms.Button +$btnQuit.Text = "Quit" +$btnQuit.Location = New-Object System.Drawing.Point(470, 260) +$btnQuit.Size = New-Object System.Drawing.Size(120, 30) +$form.Controls.Add($btnQuit) + +$lblOutput = New-Object System.Windows.Forms.Label +$lblOutput.Text = "Output:" +$lblOutput.Location = New-Object System.Drawing.Point(15, 375) +$lblOutput.Size = New-Object System.Drawing.Size(160, 22) +$form.Controls.Add($lblOutput) + +# --------------------------------------------------------------------------- +# Data loading / event logic +# --------------------------------------------------------------------------- + +# Toggle the Job field based on the validation type. +function Update-FieldVisibility { + $isJob = ($cmbType.SelectedItem -eq "Job") + $lblJob.Visible = $isJob + $cmbJob.Visible = $isJob +} + +# Populate the organization-dependent lists (jobs / source / target repositories). +function Load-OrgDependentData { + $cmbJob.Items.Clear() + $cmbSource.Items.Clear() + $cmbTarget.Items.Clear() + + if ($cmbOrg.SelectedItem -eq $null) { return } + + $organization = $script:orgs[$cmbOrg.SelectedIndex] + + try { + # Jobs for the selected organization + $script:jobs = @(Get-VBOJob -Organization $organization | Sort-Object Name) + foreach ($j in $script:jobs) { [void]$cmbJob.Items.Add($j.Name) } + if ($cmbJob.Items.Count -gt 0) { $cmbJob.SelectedIndex = 0 } + + # Jet based source repositories holding data for the selected organization + $script:sourceRepos = @(Get-VBORepository | + Where-Object { ($_.ObjectStorageRepository -eq $null) -and + ((Get-VBOEntityData -Repository $_ -Type Organization -Name $organization.Name) -ne $null) } | + Sort-Object Name) + foreach ($r in $script:sourceRepos) { [void]$cmbSource.Items.Add($r.Name) } + if ($cmbSource.Items.Count -gt 0) { $cmbSource.SelectedIndex = 0 } + + # Object storage target repositories holding data for the selected organization + $script:targetRepos = @(Get-VBORepository | + Where-Object { ($_.ObjectStorageRepository -ne $null) -and + ((Get-VBOEntityData -Repository $_ -Type Organization -Name $organization.Name) -ne $null) } | + Sort-Object Name) + foreach ($r in $script:targetRepos) { [void]$cmbTarget.Items.Add($r.Name) } + if ($cmbTarget.Items.Count -gt 0) { $cmbTarget.SelectedIndex = 0 } + + Write-LogInfo ("Loaded {0} job(s), {1} source and {2} target repository(ies) for organization '{3}'." -f ` + $script:jobs.Count, $script:sourceRepos.Count, $script:targetRepos.Count, $organization.Name) + } + catch { + Write-LogError ("Error loading data for organization: {0}" -f $_.Exception.Message) + } +} + +# Load organizations. +function Load-InitialData { + $cmbOrg.Items.Clear() + try { + $script:orgs = @(Get-VBOOrganization | Sort-Object Name) + foreach ($o in $script:orgs) { [void]$cmbOrg.Items.Add($o.Name) } + Write-LogInfo ("Loaded {0} organization(s)." -f $script:orgs.Count) + if ($cmbOrg.Items.Count -gt 0) { + $cmbOrg.SelectedIndex = 0 # triggers Load-OrgDependentData + } + } + catch { + Write-LogError ("Error loading organizations: {0}" -f $_.Exception.Message) + } +} + +# --- Wire up events ---------------------------------------------------------- +$cmbType.Add_SelectedIndexChanged({ Update-FieldVisibility }) +$cmbOrg.Add_SelectedIndexChanged({ Load-OrgDependentData }) +$btnRefresh.Add_Click({ Load-InitialData }) +$btnQuit.Add_Click({ $form.Close() }) + +$btnBrowse.Add_Click({ + $dialog = New-Object System.Windows.Forms.FolderBrowserDialog + $dialog.Description = "Select the report output path" + if (Test-Path $txtReport.Text) { $dialog.SelectedPath = $txtReport.Text } + if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { + $txtReport.Text = $dialog.SelectedPath + } +}) + +$btnVerify.Add_Click({ + # Validate selections + if ($cmbOrg.SelectedItem -eq $null) { Write-LogWarning "Please select an organization."; return } + if ($cmbSource.SelectedItem -eq $null) { Write-LogWarning "Please select a source repository."; return } + if ($cmbTarget.SelectedItem -eq $null) { Write-LogWarning "Please select a target repository."; return } + if ([string]::IsNullOrWhiteSpace($txtReport.Text)) { Write-LogWarning "Please specify a report path."; return } + + $reportPath = $txtReport.Text + $organization = $script:orgs[$cmbOrg.SelectedIndex] + $sourceRepository = $script:sourceRepos[$cmbSource.SelectedIndex] + $targetRepository = $script:targetRepos[$cmbTarget.SelectedIndex] + $isJob = ($cmbType.SelectedItem -eq "Job") + + if ($isJob) { + if ($cmbJob.SelectedItem -eq $null) { Write-LogWarning "Please select a job."; return } + $selectedJob = $script:jobs[$cmbJob.SelectedIndex] + $validationType = "Job" + $inventoryDataIdColumnName = "Backup Job Id" + } + else { + $validationType = "Organization" + $inventoryDataIdColumnName = "Organization Id" + } + + $btnVerify.Enabled = $false + $btnRefresh.Enabled = $false + try { + if (-not (Test-Path $reportPath)) { + Write-LogInfo "Creating report path '$reportPath'..." + New-Item -ItemType Directory -Path $reportPath -Force | Out-Null + } + + # Verify Data Migration (compare inventory reports) + $latestRestorePoint = Get-VBORestorePoint -Repository $sourceRepository -Latest + + Write-LogInfo "Generating inventory reports for source repository..." + if ($validationType -eq "Organization") { + $sourceInventory = Get-VBORepositoryInventoryReport -OutputPath $reportPath -BackupTime $latestRestorePoint.BackupTime -Repository $sourceRepository -Organization $organization -IncludeAllVersions -IncludeDeleted + } else { + $sourceInventory = Get-VBORepositoryInventoryReport -OutputPath $reportPath -BackupTime $latestRestorePoint.BackupTime -Repository $sourceRepository -Job $selectedJob -IncludeAllVersions -IncludeDeleted + } + + Write-LogInfo "Generating inventory reports for target repository..." + if ($validationType -eq "Organization") { + $targetInventory = Get-VBORepositoryInventoryReport -OutputPath $reportPath -BackupTime $latestRestorePoint.BackupTime -Repository $targetRepository -Organization $organization -IncludeAllVersions -IncludeDeleted + } else { + $targetInventory = Get-VBORepositoryInventoryReport -OutputPath $reportPath -BackupTime $latestRestorePoint.BackupTime -Repository $targetRepository -Job $selectedJob -IncludeAllVersions -IncludeDeleted + } + + # Compare Mailboxes + $mailDiff = Compare-InventoryData -SourceInventoryReport ($sourceInventory ? $sourceInventory.MailboxesReport : $null) -TargetInventoryReport ($targetInventory ? $targetInventory.MailboxesReport : $null) -DataType "Mailboxes" -CompareProperties @($inventoryDataIdColumnName, "Mailbox ID", "Mailbox Name", "Mailbox Folder Count", "Mailbox Item Count") + + # Compare SharePoint Sites + $websDiff = Compare-InventoryData -SourceInventoryReport ($sourceInventory ? $sourceInventory.RootWebsReport : $null) -TargetInventoryReport ($targetInventory ? $targetInventory.RootWebsReport : $null) -DataType "SharePoint Sites" -CompareProperties @($inventoryDataIdColumnName, "Site ID", "Root Site ID", "Root Site URL", "Root Site Hierarchy Item Version Count") + + # Compare Teams + $teamsDiff = Compare-InventoryData -SourceInventoryReport ($sourceInventory ? $sourceInventory.TeamsReport : $null) -TargetInventoryReport ($targetInventory ? $targetInventory.TeamsReport : $null) -DataType "Teams" -CompareProperties @($inventoryDataIdColumnName, "Team ID", "Team Name", "Team Channel Message Count", "Team Tab Count", "Team Channel Count", "Team File Count", "Team User Count") + + # Compare OneDrive + $oneDrivesDiff = Compare-InventoryData -SourceInventoryReport ($sourceInventory ? $sourceInventory.OneDrivesReport : $null) -TargetInventoryReport ($targetInventory ? $targetInventory.OneDrivesReport : $null) -DataType "OneDrive" -CompareProperties @($inventoryDataIdColumnName, "Account Name", "OneDrive Item Count") + + # Print all differences, if any + $hasDiff = $false + if ($mailDiff) { Format-ComparisonDifferences -DiffObject $mailDiff -DataType "Mailboxes"; $hasDiff = $true } + if ($websDiff) { Format-ComparisonDifferences -DiffObject $websDiff -DataType "SharePoint"; $hasDiff = $true } + if ($teamsDiff) { Format-ComparisonDifferences -DiffObject $teamsDiff -DataType "Teams"; $hasDiff = $true } + if ($oneDrivesDiff) { Format-ComparisonDifferences -DiffObject $oneDrivesDiff -DataType "OneDrive"; $hasDiff = $true } + + if ($hasDiff) { + Write-LogError "Data verification failed! See above for details on mismatches." + } else { + Write-LogSuccess "Data verification completed successfully. No differences found." + } + } + catch { + Write-LogError ("Verification error: {0}" -f $_.Exception.Message) + } + finally { + $btnVerify.Enabled = $true + $btnRefresh.Enabled = $true + } +}) + +# --------------------------------------------------------------------------- +# Initialize and show +# --------------------------------------------------------------------------- +Update-FieldVisibility +Load-InitialData + +[void]$form.ShowDialog() +$form.Dispose()