PureStoragePowerShellToolkit.Exchange.psm1
<# =========================================================================== Release version: 3.0.1 Revision information: Refer to the changelog.md file --------------------------------------------------------------------------- Maintained by: FlashArray Integrations and Evangelsigm Team @ Pure Storage Organization: Pure Storage, Inc. Filename: PureStoragePowerShellToolkit.Exchange.psm1 Copyright: (c) 2023 Pure Storage, Inc. Module Name: PureStoragePowerShellToolkit.Exchange.Dba Description: PowerShell Script Module (.psm1) -------------------------------------------------------------------------- Disclaimer: The sample module and documentation are provided AS IS and are not supported by the author or the author’s employer, unless otherwise agreed in writing. You bear all risk relating to the use or performance of the sample script and documentation. The author and the author’s employer disclaim all express or implied warranties (including, without limitation, any warranties of merchantability, title, infringement or fitness for a particular purpose). In no event shall the author, the author’s employer or anyone else involved in the creation, production, or delivery of the scripts be liable for any damages whatsoever arising out of the use or performance of the sample script and documentation (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or other pecuniary loss), even if such person has been advised of the possibility of such damages. -------------------------------------------------------------------------- Contributors: Rob "Barkz" Barker @purestorage, Robert "Q" Quimbey @purestorage, Mike "Chief" Nelson, Julian "Doctor" Cates, Marcel Dussil @purestorage - https://en.pureflash.blog/ , Craig Dayton - https://github.com/cadayton , Jake Daniels - https://github.com/JakeDennis, Richard Raymond - https://github.com/data-sciences-corporation/PureStorage , The dbatools Team - https://dbatools.io , many more Puritans, and all of the Pure Code community who provide excellent advice, feedback, & scripts now and in the future. =========================================================================== #> #Requires -Version 5.1 $ErrorActionPreference = 'Stop' Import-Module 'CimCmdlets' -Global Import-Module 'Storage' -Global # Core functions $script:exch_snapin = 'Microsoft.Exchange.Management.PowerShell.SnapIn' $script:PureProvider = [guid]'781C006A-5829-4A25-81E3-D5E43BD005AB' $script:ExchangeWriter = [guid]'76FE1AC4-15F7-4BCD-987E-8E1ACB462FB7' $script:backupNameFormat = 'MM_dd_yyyy__HH_mm_ss' $script:supportedBusTypes = @('iSCSI', 'Fibre Channel') #region Helper functions function Convert-UnitOfSize { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [AllowNull()] $Value, $To = 1GB, $From = 1, $Decimals = 2 ) process { return [math]::Round($Value * $From / $To, $Decimals) } } function Write-Color { [CmdletBinding()] param( [Parameter(Position = 0, Mandatory, ValueFromPipeline)] [string[]] $Text, [ConsoleColor[]] $ForegroundColor = ([console]::ForegroundColor), [ConsoleColor[]] $BackgroundColor = ([console]::BackgroundColor), [int] $Indent = 0, [int] $LeadingSpace = 0, [int] $TrailingSpace = 0, [switch] $NoNewLine ) begin { $baseParams = @{ ForegroundColor = [console]::ForegroundColor BackgroundColor = [console]::BackgroundColor NoNewline = $true } # Add leading lines Write-Host ("`n" * $LeadingSpace) @baseParams } process { # Add TABs before text Write-Host ("`t" * $Indent) @baseParams if ($PSBoundParameters.ContainsKey('ForegroundColor') -or $PSBoundParameters.ContainsKey('BackgroundColor')) { $writeParams = $baseParams.Clone() for ($i = 0; $i -lt $Text.Count; $i++) { if ($i -lt $ForegroundColor.Count) { $writeParams['ForegroundColor'] = $ForegroundColor[$i] } if ($i -lt $BackgroundColor.Count) { $writeParams['BackgroundColor'] = $BackgroundColor[$i] } Write-Host $Text[$i] @writeParams } } else { Write-Host $Text -NoNewline } if (-not $NoNewLine) { Write-Host } } end { if (-not $NoNewLine) { Write-Host ("`n" * $TrailingSpace) @baseParams } } } #endregion Helper functions class ExchangeBackup { [string]$DatabaseName [string]$Alias [datetime]$BackupDate [string[]]$BusType [string[]]$SerialNumber hidden [IO.FileInfo]$_file ExchangeBackup([IO.FileInfo]$file, [pscustomobject]$database) { $this.DatabaseName = $file.Directory.Name $this.Alias = [IO.Path]::GetFileNameWithoutExtension($file.Name) $this.BackupDate = [DateTime]::ParseExact($this.Alias, $script:backupNameFormat, $null, 'AssumeUniversal') $this.BusType = $database.BusType $this.SerialNumber = $database.SerialNumber $this._file = $file } } class ShadowWriter { [guid]$Id [string]$Name static [ShadowWriter[]]$InBoxWriters = @( [ShadowWriter]::new('E8132975-6F93-4464-A53E-1050253AE220', 'System Writer'), [ShadowWriter]::new('2A40FD15-DFCA-4AA8-A654-1F8C654603F6', 'IIS Config Writer'), [ShadowWriter]::new('35E81631-13E1-48DB-97FC-D5BC721BB18A', 'NPS VSS Writer'), [ShadowWriter]::new('BE000CBE-11FE-4426-9C58-531AA6355FC4', 'ASR Writer'), [ShadowWriter]::new('4969D978-BE47-48B0-B100-F328F07AC1E0', 'BITS Writer'), [ShadowWriter]::new('A6AD56C2-B509-4E6C-BB19-49D8F43532F0', 'WMI Writer'), [ShadowWriter]::new('AFBAB4A2-367D-4D15-A586-71DBB18F8485', 'Registry Writer'), [ShadowWriter]::new('59B1F0CF-90EF-465F-9609-6CA8B2938366', 'IIS Metabase Writer'), [ShadowWriter]::new('7E47B561-971A-46E6-96B9-696EEAA53B2A', 'MSMQ Writer (%s)'), [ShadowWriter]::new('CD3F2362-8BEF-46C7-9181-D62844CDC0B2', 'MSSearch Service Writer'), [ShadowWriter]::new('542DA469-D3E1-473C-9F4F-7847F01FC64F', 'COM+ REGDB Writer'), [ShadowWriter]::new('4DC3BDD4-AB48-4D07-ADB0-3BEE2926FD7F', 'Shadow Copy Optimization Writer'), [ShadowWriter]::new('D46BF321-FDBA-4A35-8EC3-454DF03BC86A', 'Sync Share Service VSS Writer'), [ShadowWriter]::new('41E12264-35D8-479B-8E5C-9B23D1DAD37E', 'Cluster Database'), [ShadowWriter]::new('1072AE1C-E5A7-4EA1-9E4A-6F7964656570', 'Cluster Shared Volume VSS Writer'), [ShadowWriter]::new('41DB4DBF-6046-470E-8AD5-D5081DFB1B70', 'Dedup Writer'), [ShadowWriter]::new('2707761b-2324-473d-88eb-eb007a359533', 'DFS Replication service writer'), [ShadowWriter]::new('d76f5a28-3092-4589-ba48-2958fb88ce29', 'FRS Writer'), [ShadowWriter]::new('66841cd4-6ded-4f4b-8f17-fd23f8ddc3de', 'Microsoft Hyper-V VSS Writer'), [ShadowWriter]::new('12CE4370-5BB7-4C58-A76A-E5D5097E3674', 'FSRM Writer'), [ShadowWriter]::new('DD846AAA-A1B6-42A8-AAF8-03DCB6114BFD', 'ADAM (instance%u) Writer'), [ShadowWriter]::new('886C43B1-D455-4428-A37F-4D6B9E43F50F', 'AD RMS Writer'), [ShadowWriter]::new('B2014C9E-8711-4C5C-A5A9-3CF384484757', 'NTDS'), [ShadowWriter]::new('772C45F8-AE01-4F94-940C-94961864ACAD', 'ADFS VSS Writer'), [ShadowWriter]::new('BE9AC81E-3619-421F-920F-4C6FEA9E93AD', 'Dhcp Jet Writer'), [ShadowWriter]::new('F08C1483-8407-4A26-8C26-6C267A629741', 'WINS Jet Writer'), [ShadowWriter]::new('6F5B15B5-DA24-4D88-B737-63063E3A1F86', 'Certificate Authority'), [ShadowWriter]::new('368753EC-572E-4FC7-B4B9-CCD9BDC624CB', 'TS Gateway Writer'), [ShadowWriter]::new('5382579C-98DF-47A7-AC6C-98A6D7106E09', 'TermServLicensing'), [ShadowWriter]::new('D61D61C8-D73A-4EEE-8CDD-F6F9786B7124', 'Task Scheduler Writer'), [ShadowWriter]::new('75DFB225-E2E4-4D39-9AC9-FFAFF65DDF06', 'VSS Express Writer Metadata Store Writer'), [ShadowWriter]::new('82CB5521-68DB-4626-83A4-7FC6F88853E9', 'WDS VSS Writer'), [ShadowWriter]::new('8D5194E1-E455-434A-B2E5-51296CCE67DF', 'WIDWriter'), [ShadowWriter]::new('0BADA1DE-01A9-4625-8278-69E735F39DD2', 'Performance Counters Writer'), [ShadowWriter]::new('A65FAA63-5EA8-4EBC-9DBD-A0C4DB26912A', 'SqlServerWriter') ) ShadowWriter([guid]$id, [string]$name) { $this.Id = $id $this.Name = $name } } function Invoke-Diskshadow() { <# .SYNOPSIS Runs Diskshadow commands. .DESCRIPTION Runs Diskshadow commands in a script mode. .PARAMETER Script Specifies commands to run. #> [CmdletBinding()] param($script) if (-not (Test-Path ([IO.Path]::Combine($env:SystemRoot, 'system32', 'diskshadow.exe')))) { throw "diskshadow.exe not found." } $dsh = "./$([IO.Path]::GetRandomFileName())" $script | Set-Content $dsh -Confirm:$false try { DISKSHADOW /s "$dsh" | Write-Verbose if ($LASTEXITCODE -gt 0) { throw "DISKSHADOW command failed. Exit code $LASTEXITCODE." } } finally { Remove-Item $dsh -Confirm:$false -ea 'SilentlyContinue' } } function Get-ExchangeDatabase() { <# .SYNOPSIS Gets a mailbox database copy. .DESCRIPTION Gets a mailbox database local copy configuration. .PARAMETER DatabaseName Specifies name of the mailbox database. #> [CmdletBinding()] param([string]$name) $local_copy = Get-MailboxDatabaseCopyStatus -Identity $name -Local $database_volume = Get-ExchangeVolume -path $local_copy.DatabaseVolumeName $bus_type = @($database_volume.BusType) $serial = @($database_volume.SerialNumber) $distinct = $local_copy.DatabaseVolumeName -ne $local_copy.LogVolumeName if ($distinct) { $log_volume = Get-ExchangeVolume -path $local_copy.LogVolumeName $serial += $log_volume.SerialNumber if ($database_volume.BusType -ne $log_volume.BusType) { $bus_type += $log_volume.BusType } } else { $log_volume = $database_volume } [pscustomobject]@{ Name = $name LocalCopy = $local_copy DatabaseVolume = $database_volume.Path LogVolume = $log_volume.Path Distinct = $distinct BusType = $bus_type SerialNumber = $serial } } function Get-ExchangeVolume() { <# .SYNOPSIS Gets PURE volume. .DESCRIPTION Gets the volume for the file path specified. .PARAMETER Path Specifies the full path of a file. #> [CmdletBinding()] param([string]$path) $volume = Get-Volume -Path $path if (-not $volume) { throw "Volume '$path' not found." } $disk = $volume | Get-Partition | Get-Disk | Where-Object FriendlyName -like 'PURE*' if (-not $disk) { throw "PURE Disk '$path' not found." } [pscustomobject]@{ Path = $volume.Path UniqueId = $volume.UniqueId DriveLetter = $volume.DriveLetter DiskNumber = $disk.Number BusType = $disk.BusType SerialNumber = $disk.SerialNumber } } function Get-ExchangeRoot() { <# .SYNOPSIS Gets root directory for storing backups. .DESCRIPTION Gets fully qualified path to the root directory for storing backups metadata (.cab) files. #> [CmdletBinding()] param() $provider = Get-Provider $root = Split-Path $provider.FilePath | Split-Path if (-not $root) { $root = Split-Path $provider.FilePath } return Join-Path $root 'Exchange' } function Get-Provider() { <# .SYNOPSIS Gets PURE hardware provider service configuration. .DESCRIPTION Gets fully qualified path to the service binary file that implements the service. #> [CmdletBinding()] param() $name = 'pureprovider' $service = Get-CimInstance 'win32_service' -Filter "name='$name'" -Property 'name', 'pathName' if (-not $service) { throw "Provider '$name' not found." } return [pscustomobject]@{ Name = $service.Name FilePath = $service.PathName.Trim('"') } } function Test-BusType() { <# .SYNOPSIS Determines whether the I/O bus type is supported. .DESCRIPTION Determines whether the I/O bus type used by the disk is supported. .PARAMETER BusType The I/O bus type. #> [CmdletBinding()] param([string[]]$busType) -not @($busType | Where-Object { $script:supportedBusTypes -notcontains $_ }).Count } function Dismount-Pfa2ExchangeBackup() { <# .SYNOPSIS Unexposes a mailbox database backup. .DESCRIPTION Unexposes a mailbox database backup using Volume Shadow Copy Service (VSS) Hardware Provider. .PARAMETER DatabaseName Specifies name(s) of the mailbox database(s). .PARAMETER Alias Specifies an alias of the backup. .INPUTS System.String .OUTPUTS None .EXAMPLE Dismount-Pfa2ExchangeBackup -DatabaseName 'db_1708' -Alias '03_27_2023__21_30_06' Unexposes database db_1708 backup 03_27_2023__21_30_06. .EXAMPLE PS C:\>$backup = Get-Pfa2ExchangeBackup 'db_1708' -Latest 1 PS C:\>$mount_point = $backup | Mount-Pfa2ExchangeBackup PS C:\>$backup | Dismount-Pfa2ExchangeBackup Exposes the latest database db_1708 backup and then unexposes it. .NOTES PURE volumes should be connected via iSCSI or Fiber Channel bus, no raw device mapping (RDM) is supported in case of virtual machine. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('Name')] [string[]]$DatabaseName, [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [string]$Alias ) begin { # Requires Exchange module if (-not (Get-PSSnapin -Name $script:exch_snapin -ea SilentlyContinue)) { throw "Exchange snap-in '$script:exch_snapin' not found. Add snap-in to the current session." } $unexpose_format = 'UNEXPOSE %{0}%' $root = Get-ExchangeRoot if (-not (Test-Path $root)) { throw 'Backup not found.' } } process { foreach ($db_name in $DatabaseName) { if (-not $PSCmdlet.ShouldProcess("Database '$db_name'", "Unexpose '$Alias' backup")) { continue } $db_path = Join-Path $root $db_name if (-not (Test-Path $db_path)) { throw "Database '$db_name' not found." } $cab_path = Join-Path $db_path "$Alias.cab" if (-not (Test-Path $cab_path)) { throw "Database backup '$Alias' not found." } $db = Get-ExchangeDatabase -name $db_name if (-not (Test-BusType -busType $db.BusType)) { throw "Bus type '$($db.BusType)' not supported. Expected value is '$script:supportedBusTypes'." } $mp_root_path = Join-Path $db_path $Alias $unexpose = @($unexpose_format -f $Alias) if ($db.Distinct) { $unexpose += $unexpose_format -f "$($Alias)_log" } try { Invoke-Diskshadow -script 'RESET', 'SET VERBOSE ON', "LOAD METADATA `"$cab_path`"", $unexpose, 'MASK %VSS_SHADOW_SET%', 'EXIT' } finally { Remove-Item $mp_root_path -Recurse -Confirm:$false } } } end { } } function Enter-Pfa2ExchangeBackupExposeSession() { <# .SYNOPSIS Starts a mailbox database backup expose session. .DESCRIPTION Starts a mailbox database backup expose session using Volume Shadow Copy Service (VSS) Hardware Provider. If ScriptBlock parameter not specified, an interactive session starts. .PARAMETER DatabaseName Specifies name(s) of the mailbox database(s). .PARAMETER Alias Specifies an alias of the backup. .PARAMETER ScriptBlock Specifies commands to run. Enclose the commands in braces ({ }) to create a script block. The database name, alias and mount point are passed to the script as parameters. .INPUTS System.String .OUTPUTS None .EXAMPLE Get-Pfa2ExchangeBackup 'db_1708' -Latest 1 | Enter-Pfa2ExchangeBackupExposeSession Type 'exit' to end the expose session, cleanup and unexpose the shadow copy. [db_1708]: PS C:\Program Files\Pure Storage\VSS\Exchange\db_1708\03_29_2023__12_24_15>> dir Directory: C:\Program Files\Pure Storage\VSS\Exchange\db_1708\03_29_2023__12_24_15 Mode LastWriteTime Length Name ---- ------------- ------ ---- d----l 3/29/2023 7:43 AM db Type 'exit' to end the expose session, cleanup and unexpose the shadow copy. [db_1708]: PS C:\Program Files\Pure Storage\VSS\Exchange\db_1708\03_29_2023__12_24_15>> exit Starts an interactive session. .EXAMPLE Get-Pfa2ExchangeBackup 'db_1708' -Latest 1 | Enter-Pfa2ExchangeBackupExposeSession -ScriptBlock {"DB Name: {0}`nAlias: {1}`nMount point: {2}" -f $args} DB Name: db_1708 Alias: 03_29_2023__12_24_15 Mount point: C:\Program Files\Pure Storage\VSS\Exchange\db_1708\03_29_2023__12_24_15 Runs a script block. .NOTES PURE volumes should be connected via iSCSI or Fiber Channel bus, no raw device mapping (RDM) is supported in case of virtual machine. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('Name')] [string[]]$DatabaseName, [Parameter(Mandatory, Position = 1, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [string]$Alias, [Parameter(Position = 2, ParameterSetName = 'ScriptBlock')] [ValidateNotNullOrEmpty()] [scriptblock]$ScriptBlock ) begin { # Requires Exchange module if (-not (Get-PSSnapin -Name $script:exch_snapin -ea SilentlyContinue)) { throw "Exchange snap-in '$script:exch_snapin' not found. Add snap-in to the current session." } } process { foreach ($db_name in $DatabaseName) { if (-not $PSCmdlet.ShouldProcess("Database '$db_name'", "Enter '$Alias' backup expose session")) { continue } $mp_root_path = Mount-Pfa2ExchangeBackup -DatabaseName $db_name -Alias $Alias -Confirm:$false try { $current_location = Get-Location Set-Location $mp_root_path try { if ('ScriptBlock' -eq $PSCmdlet.ParameterSetName) { Invoke-Command $ScriptBlock -Args $db_name, $Alias, $mp_root_path } else { $cpr = $function:prompt $pr_msg = "Type 'exit' to end the expose session, cleanup and unexpose the shadow copy." function prompt { $pr_msg + "`n[$db_name]: " + $cpr.Invoke() } try { Invoke-Command {$Host.EnterNestedPrompt()} } finally { $function:prompt = $cpr } } } finally { Set-Location $current_location } } finally { Dismount-Pfa2ExchangeBackup -DatabaseName $db_name -Alias $Alias -Confirm:$false } } } end { } } function Get-Pfa2ExchangeBackup() { <# .SYNOPSIS Gets a mailbox database backup. .DESCRIPTION Gets a mailbox database backup, a set of backups that match the specified criteria, or all backups if no filter is provided. .PARAMETER DatabaseName Specifies name(s) of the mailbox database(s). .PARAMETER Alias Specifies an alias of the backup. Wildcards are supported. .PARAMETER SerialNumber Specifies a serial number of the original PURE volume. Wildcards are supported. .PARAMETER Latest Specifies a number of latest backups to get. Selects backups after other filtering parameters are applied. .PARAMETER After Specifies a date for use as a filter for backup creation date. The backup should be create after this date. .PARAMETER Before Specifies a date for use as a filter for backup creation date. The backup should be create before this date. .INPUTS System.String .EXAMPLE Get-Pfa2ExchangeBackup Gets all backups of all mailbox databases. .EXAMPLE Get-Pfa2ExchangeBackup -DatabaseName 'db_1708' Gets database db_1708 backups. .EXAMPLE Get-Pfa2ExchangeBackup -DatabaseName 'db_1708' -Alias '03_27_2023__21_30_06' Gets database db_1708 backup 03_27_2023__21_30_06. .EXAMPLE Get-Pfa2ExchangeBackup -Alias '03_27_2023__*' Gets backups with an alias that matches the pattern 03_27_2023__*. .EXAMPLE Get-Pfa2ExchangeBackup -SerialNumber '*2B8C' Gets backups with an original volume serial number that matches the pattern *2B8C. .EXAMPLE Get-Pfa2ExchangeBackup -Latest 1 Gets the most recent backup of every database. .EXAMPLE Get-Pfa2ExchangeBackup 'db_1708' -Latest 2 Gets two latest backups of database named db_1708. .EXAMPLE Get-Pfa2ExchangeBackup 'db_1708' -Before 'Friday, March 24, 2023' Gets database db_1708 backups created before Friday, March 24, 2023. .EXAMPLE Get-Pfa2ExchangeBackup -After (Get-Date).AddDays(-30) Gets backups created in last 30 days. .EXAMPLE Get-MailboxDatabase 'db_1708' | Get-Pfa2ExchangeBackup Gets database db_1708 backups. .EXAMPLE Get-MailboxDatabaseCopyStatus 'db_1708' -Local | Get-Pfa2ExchangeBackup Gets database db_1708 backups. .NOTES PURE volumes should be connected via iSCSI or Fiber Channel bus, no raw device mapping (RDM) is supported in case of virtual machine. #> [CmdletBinding()] [OutputType('ExchangeBackup')] param( [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('Name')] [string[]]$DatabaseName, [Parameter(ValueFromPipelineByPropertyName)] [SupportsWildcards()] [ValidateNotNullOrEmpty()] [string]$Alias, [Parameter(ValueFromPipelineByPropertyName)] [SupportsWildcards()] [ValidateNotNullOrEmpty()] [Alias('Serial')] [string]$SerialNumber, [ValidateRange(0, [int]::maxvalue)] [int]$Latest, [datetime]$After, [datetime]$Before ) begin { # Requires Exchange module if (-not (Get-PSSnapin -Name $script:exch_snapin -ea SilentlyContinue)) { throw "Exchange snap-in '$script:exch_snapin' not found. Add snap-in to the current session." } $root = Get-ExchangeRoot if (-not (Test-Path $root)) { return } } process { $databases = if ($DatabaseName) { $DatabaseName } else { (Get-ChildItem $root -Directory).Name } foreach ($db_name in ($databases | Sort-Object -Unique)) { $db_path = Join-Path $root $db_name if (-not (Test-Path $db_path)) { throw "Database '$db_name' not found." } $db = $null try { $db = Get-ExchangeDatabase -name $db_name } catch { # ignore } if ($SerialNumber -and -not ($db.SerialNumber -like $SerialNumber)) { continue } if (-not $Alias) { $Alias = '*' } $backups = Get-ChildItem $db_path -File -Filter "$Alias.cab" | % { [ExchangeBackup]::new($_, $db) } if ($After) { $backups = $backups | Where-Object BackupDate -gt $After } if ($Before) { $backups = $backups | Where-Object BackupDate -lt $Before } if ($Latest) { $backups = $backups | Sort-Object BackupDate -Descending | Select-Object -First $Latest } $backups } } end { } } function Mount-Pfa2ExchangeBackup() { <# .SYNOPSIS Exposes a mailbox database backup. .DESCRIPTION Exposes a mailbox database backup as a mount point using Volume Shadow Copy Service (VSS) Hardware Provider. .PARAMETER DatabaseName Specifies name(s) of the mailbox database(s). .PARAMETER Alias Specifies an alias of the backup. .INPUTS System.String .EXAMPLE Mount-Pfa2ExchangeBackup -DatabaseName 'db_1708' -Alias '03_27_2023__21_30_06' Exposes database db_1708 backup 03_27_2023__21_30_06. .EXAMPLE Get-Pfa2ExchangeBackup 'db_1708' -Latest 1 | Mount-Pfa2ExchangeBackup Exposes the latest database db_1708 backup. .EXAMPLE 'db_1708' | Mount-Pfa2ExchangeBackup -Alias '03_27_2023__21_30_06' Exposes database db_1708 backup 03_27_2023__21_30_06. .EXAMPLE Get-MailboxDatabase 'db_1708' | Mount-Pfa2ExchangeBackup -Alias '03_27_2023__21_30_06' Exposes database db_1708 backup 03_27_2023__21_30_06. .EXAMPLE Get-MailboxDatabaseCopyStatus 'db_1708' -Local | Mount-Pfa2ExchangeBackup -Alias '03_27_2023__21_30_06' Exposes database db_1708 backup 03_27_2023__21_30_06. .EXAMPLE 'db_1708', 'ha_1809' | Mount-Pfa2ExchangeBackup -Alias '03_27_2023__21_30_06' Exposes database db_1708 and database ha_1809 backup 03_27_2023__21_30_06. .EXAMPLE Get-Pfa2ExchangeBackup | Out-GridView -PassThru | Mount-Pfa2ExchangeBackup Exposes database backup selected by user. .NOTES PURE volumes should be connected via iSCSI or Fiber Channel bus, no raw device mapping (RDM) is supported in case of virtual machine. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([System.IO.DirectoryInfo])] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('Name')] [string[]]$DatabaseName, [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [string]$Alias ) begin { # Requires Exchange module if (-not (Get-PSSnapin -Name $script:exch_snapin -ea SilentlyContinue)) { throw "Exchange snap-in '$script:exch_snapin' not found. Add snap-in to the current session." } $expose_format = 'EXPOSE %{0}% "{1}"' $root = Get-ExchangeRoot if (-not (Test-Path $root)) { throw 'Backup not found.' } } process { foreach ($db_name in $DatabaseName) { if (-not $PSCmdlet.ShouldProcess("Database '$db_name'", "Expose '$Alias' backup")) { continue } $db_path = Join-Path $root $db_name if (-not (Test-Path $db_path)) { throw "Database '$db_name' not found." } $cab_path = Join-Path $db_path "$Alias.cab" if (-not (Test-Path $cab_path)) { throw "Database backup '$Alias' not found." } $db = Get-ExchangeDatabase -name $db_name if (-not (Test-BusType -busType $db.BusType)) { throw "Bus type '$($db.BusType)' not supported. Expected value is '$script:supportedBusTypes'." } $mp_root_path = Join-Path $db_path $Alias $db_mp_path = Join-Path $mp_root_path 'db' $expose = @($expose_format -f $Alias, $db_mp_path) if ($db.Distinct) { $log_mp_path = Join-Path $mp_root_path 'log' $expose += $expose_format -f "$($Alias)_log", $log_mp_path } New-Item $db_mp_path -ItemType 'Directory' -Confirm:$false | Out-Null try { if ($log_mp_path) { New-Item $log_mp_path -ItemType 'Directory' -Confirm:$false | Out-Null } Invoke-Diskshadow -script 'RESET', 'SET VERBOSE ON', "LOAD METADATA `"$cab_path`"", 'IMPORT', $expose, 'EXIT' Get-Item $mp_root_path } catch { Remove-Item $mp_root_path -Recurse -Confirm:$false throw } } } end { } } function New-Pfa2ExchangeBackup() { <# .SYNOPSIS Creates a new mailbox database backup. .DESCRIPTION Creates a new mailbox database backup using Volume Shadow Copy Service (VSS) Hardware Provider. .PARAMETER DatabaseName Specifies name(s) of the mailbox database(s). .PARAMETER CopyBackup Specifies that the copy backup should be performed. Unlike full backup, copy backup does not truncate the transaction log for the database. By default, full backup is performed. .INPUTS System.String .EXAMPLE New-Pfa2ExchangeBackup -DatabaseName 'db_1708' Creates database db_1708 full backup. .EXAMPLE New-Pfa2ExchangeBackup 'db_1708' -CopyBackup Creates database db_1708 copy backup. .EXAMPLE 'db_1708' | New-Pfa2ExchangeBackup Creates database db_1708 full backup. .EXAMPLE Get-MailboxDatabase 'db_1708' | New-Pfa2ExchangeBackup Creates database db_1708 full backup. .EXAMPLE Get-MailboxDatabaseCopyStatus 'db_1708' -Local | New-Pfa2ExchangeBackup Creates database db_1708 full backup. .EXAMPLE 'db_1708', 'ha_1809' | New-Pfa2ExchangeBackup Creates full backups of databases db_1708 and ha_1809. .NOTES PURE volumes should be connected via iSCSI or Fiber Channel bus, no raw device mapping (RDM) is supported in case of virtual machine. #> [CmdletBinding(SupportsShouldProcess)] [OutputType('ExchangeBackup')] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('Name')] [string[]]$DatabaseName, [switch]$CopyBackup ) begin { # Requires Exchange module if (-not (Get-PSSnapin -Name $script:exch_snapin -ea SilentlyContinue)) { throw "Exchange snap-in '$script:exch_snapin' not found. Add snap-in to the current session." } function Get-BoundDatabase($copy) { $volumes = @($copy.DatabaseVolumeName, $copy.LogVolumeName) (Get-MailboxDatabaseCopyStatus -Local | ? { $_.Identity -ne $copy.Identity -and ($_.DatabaseVolumeName, $_.LogVolumeName | ? { $volumes -contains $_ }) }).DatabaseName } $writer_exclude = [ShadowWriter]::InBoxWriters | % { "WRITER EXCLUDE {0:B}" -f $_.Id } $add_volume_format = "ADD VOLUME {0} ALIAS {1} Provider {$($script:PureProvider.ToString('B'))}" $root = Get-ExchangeRoot $alias = (Get-Date).ToUniversalTime().ToString($script:backupNameFormat) } process { foreach ($db_name in $DatabaseName) { if (-not $PSCmdlet.ShouldProcess("Database '$db_name'", 'Create backup')) { continue } $db = Get-ExchangeDatabase -name $db_name if (-not (Test-BusType -busType $db.BusType)) { throw "Bus type '$($db.BusType)' not supported. Expected value is '$script:supportedBusTypes'" } if ($db.LocalCopy.Status -ne 'mounted') { throw "Database '$db_name' copy (local) status '$($db.LocalCopy.Status)' is invalid. Expected value is 'mounted'" } $bound_db = Get-BoundDatabase $db.LocalCopy if ($bound_db) { throw "Database '$db_name' share the same volume with $bound_db." } $volumes = @($add_volume_format -f $db.DatabaseVolume, $alias) if ($db.Distinct) { $volumes += $add_volume_format -f $db.LogVolume, "$($alias)_log" } $db_path = Join-Path $root $db_name if (-not (Test-Path $db_path)) { New-Item $db_path -ItemType 'Directory' -Confirm:$false | Out-Null } $cab_path = Join-Path $db_path "$alias.cab" Invoke-Diskshadow -script 'RESET', 'SET VERBOSE ON', 'SET CONTEXT PERSISTENT', 'SET OPTION TRANSPORTABLE', "SET METADATA `"$cab_path`"", $writer_exclude, $(if (-not $CopyBackup) {'BEGIN BACKUP'}), $volumes, 'CREATE', 'END BACKUP', 'EXIT' [ExchangeBackup]::new((Get-Item $cab_path), $db) } } end { } } function Remove-Pfa2ExchangeBackup() { <# .SYNOPSIS Deletes a mailbox database backup. .DESCRIPTION Deletes a mailbox database backup using Volume Shadow Copy Service (VSS) Hardware Provider. .PARAMETER DatabaseName Specifies name(s) of the mailbox database(s). .PARAMETER Alias Specifies an alias of the backup. .PARAMETER Retain Specifies a number of latest backups to retain. .PARAMETER Force Forces a backup deletion. If specified, errors that occur while deleting the backup are ignored. .INPUTS System.String .OUTPUTS None .EXAMPLE Remove-Pfa2ExchangeBackup -DatabaseName 'db_1708' -Alias '03_27_2023__21_30_06' Deletes database db_1708 backup 03_27_2023__21_30_06. .EXAMPLE Remove-Pfa2ExchangeBackup -DatabaseName 'db_1708' -Alias '03_27_2023__21_30_06' -Confirm:$false Deletes database db_1708 backup 03_27_2023__21_30_06 skipping confirmation prompt. .EXAMPLE Remove-Pfa2ExchangeBackup -DatabaseName 'db_1708' -Retain 2 Deletes old database db_1708 backups except two most recent ones. .EXAMPLE Get-Pfa2ExchangeBackup -DatabaseName 'db_1708' -Before 'Friday, March 24, 2023' | Remove-Pfa2ExchangeBackup Deletes database db_1708 backups created before Friday, March 24, 2023. .EXAMPLE Get-MailboxDatabase 'db_1708' | Remove-Pfa2ExchangeBackup -Retain 1 Deletes database db_1708 backups except the most recent one. .EXAMPLE 'db_1708', 'ha_1809' | Remove-Pfa2ExchangeBackup -Retain 1 Deletes all backups of db_1708 and ha_1809 databases retaining the most recent backup of each. .NOTES PURE volumes should be connected via iSCSI or Fiber Channel bus, no raw device mapping (RDM) is supported in case of virtual machine. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('Name')] [string[]]$DatabaseName, [Parameter(Mandatory, Position = 1, ValueFromPipelineByPropertyName, ParameterSetName = 'ByAlias')] [ValidateNotNullOrEmpty()] [string]$Alias, [Parameter(Mandatory, Position = 1, ParameterSetName = 'ByDate')] [ValidateRange(0, [int]::maxvalue)] [int]$Retain, [switch]$Force ) begin { $root = Get-ExchangeRoot if (-not (Test-Path $root)) { throw 'Backup not found.' } } process { foreach ($db_name in $DatabaseName) { $db_path = Join-Path $root $db_name if (-not (Test-Path $db_path)) { throw "Database '$db_name' not found." } $backups = $null if ('ByAlias' -eq $PSCmdlet.ParameterSetName) { $cab_path = Join-Path $db_path "$Alias.cab" if (-not (Test-Path $cab_path)) { throw "Database backup '$Alias' not found." } $cab_file = Get-Item $cab_path $backups = [ExchangeBackup]::new($cab_file, $null) } else { $backups = Get-ChildItem $db_path -File -Filter "*.cab" | ForEach-Object { [ExchangeBackup]::new($_, $null) } | Sort-Object BackupDate | Select-Object -SkipLast $Retain } foreach ($backup in $backups){ if (-not $PSCmdlet.ShouldProcess("Database '$db_name'", "Remove '$($backup.Alias)' backup")) { continue } $rem_err = $null try { Invoke-Diskshadow -script 'RESET', 'SET VERBOSE ON', "LOAD METADATA `"$($backup._file.FullName)`"", 'IMPORT', 'DELETE SHADOWS SET %VSS_SHADOW_SET%', 'EXIT' } catch { $rem_err = $_ if (-not $Force) { throw } } finally { if (-not $rem_err -or $Force) { Remove-Item $backup._file.FullName -Confirm:$false -Force:$Force } } } } } end { } } function Restore-Pfa2ExchangeBackup() { <# .SYNOPSIS Restores a mailbox database backup. .DESCRIPTION Restores a mailbox database backup to the original location using Volume Shadow Copy Service (VSS) Hardware Provider. The mailbox database will be dismounted during this process. .PARAMETER DatabaseName Specifies name(s) of the mailbox database(s). .PARAMETER Alias Specifies an alias of the backup. .PARAMETER ExcludeLog Specifies that the transaction log volume should not be restored. This option is not applicable if the database and transaction logs are on the same volume. If specified, the mailbox database will remain dismounted after the restore operation completes. By default, both the database volume and the transaction log volume are restored. .PARAMETER Force Forces a mailbox database mount. If specified, errors or warnings (including data loss warnings) that occur while mounting the mailbox database are ignored. .INPUTS System.String .OUTPUTS None .EXAMPLE Restore-Pfa2ExchangeBackup -DatabaseName 'db_1708' -Alias '03_27_2023__21_30_06' Restores database db_1708 backup 03_27_2023__21_30_06. .EXAMPLE Get-Pfa2ExchangeBackup 'db_1708' -Latest 1 | Restore-Pfa2ExchangeBackup Restores the latest database db_1708 backup. .EXAMPLE Get-Pfa2ExchangeBackup 'db_1708' -Latest 1 | Restore-Pfa2ExchangeBackup -Confirm:$false Restores the latest database db_1708 backup skipping confirmation prompt. .EXAMPLE Restore-Pfa2ExchangeBackup -DatabaseName 'ha_1809' -Alias '03_27_2023__21_30_06' -ExcludeLog Restores database ha_1809 backup 03_27_2023__21_30_06 excluding transaction log volume. .EXAMPLE 'db_1708' | Restore-Pfa2ExchangeBackup -Alias '03_27_2023__21_30_06' Restores database db_1708 backup 03_27_2023__21_30_06. .EXAMPLE Get-MailboxDatabase 'db_1708' | Restore-Pfa2ExchangeBackup -Alias '03_27_2023__21_30_06' Restores database db_1708 backup 03_27_2023__21_30_06. .EXAMPLE Get-MailboxDatabaseCopyStatus 'db_1708' -Local | Restore-Pfa2ExchangeBackup -Alias '03_27_2023__21_30_06' Restores database db_1708 backup 03_27_2023__21_30_06. .EXAMPLE 'db_1708', 'ha_1809' | Restore-Pfa2ExchangeBackup -Alias '03_27_2023__21_30_06' Restores database db_1708 and database ha_1809 backup 03_27_2023__21_30_06. .EXAMPLE Get-Pfa2ExchangeBackup | Out-GridView -PassThru | Restore-Pfa2ExchangeBackup Restores database backup selected by user. .NOTES PURE volumes should be connected via iSCSI or Fiber Channel bus, no raw device mapping (RDM) is supported in case of virtual machine. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('Name')] [string[]]$DatabaseName, [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [string]$Alias, [switch]$ExcludeLog, [switch]$Force ) begin { # Requires Exchange module if (-not (Get-PSSnapin -Name $script:exch_snapin -ea SilentlyContinue)) { throw "Exchange snap-in '$script:exch_snapin' not found. Add snap-in to the current session." } function ShouldProcess() { $sp_msg = "Restore database '$db_name' from backup '$Alias'. " + "Database will be dismounted during this process." $PSCmdlet.ShouldProcess($sp_msg, "Are you sure you want to perform this action?`n$sp_msg", 'Confirm') } $add_shadow_format = 'ADD SHADOW %{0}%' $root = Get-ExchangeRoot if (-not (Test-Path $root)) { throw 'Backup not found.' } } process { foreach ($db_name in $DatabaseName) { if (-not (ShouldProcess)) { continue } $db_path = Join-Path $root $db_name if (-not (Test-Path $db_path)) { throw "Database '$db_name' not found." } $cab_path = Join-Path $db_path "$Alias.cab" if (-not (Test-Path $cab_path)) { throw "Database backup '$Alias' not found." } $db = Get-ExchangeDatabase -name $db_name if (-not (Test-BusType -busType $db.BusType)) { throw "Bus type '$($db.BusType)' not supported. Expected value is '$script:supportedBusTypes'." } if (-not $db.LocalCopy.ActiveCopy) { throw "Database '$db_name' copy (local) is not active." } $shadows = @($add_shadow_format -f $Alias) if ($db.Distinct) { if (-not $ExcludeLog) { $shadows += $add_shadow_format -f "$($Alias)_log" } } elseif ($ExcludeLog) { $err_msg = "'$db_name' database and transaction logs are on the same volume. " + "'ExcludeLog' switch is not applicable." throw $err_msg } $resync_err = $null $mounted = $db.LocalCopy.Status -eq 'mounted' if ($mounted) { Dismount-Database -Identity $db_name -Confirm:$false } try { Set-MailboxDatabase -Identity $db_name -AllowFileRestore $true -Confirm:$false Invoke-Diskshadow -script 'RESET', 'SET VERBOSE ON', "LOAD METADATA `"$cab_path`"", 'IMPORT', 'BEGIN RESTORE', $shadows, 'RESYNC', 'END RESTORE', 'MASK %VSS_SHADOW_SET%', 'EXIT' } catch { $resync_err = $_ throw } finally { if ($mounted) { if ($resync_err) { $warning_msg = "Database '$db_name' restore operation failed. " + "To prevent a possible data loss, the database will remain dismounted." Write-Warning $warning_msg } elseif ($ExcludeLog) { $warning_msg = "Database '$db_name' is dismounted. " + "Perform transaction log manipulation, and then mount the database." Write-Warning $warning_msg } else { Mount-Database -Identity $db_name -Confirm:$false -AcceptDataLoss:$Force -Force:$Force } } } } } end { } } # Declare exports Export-ModuleMember -Function Get-Pfa2ExchangeBackup Export-ModuleMember -Function New-Pfa2ExchangeBackup Export-ModuleMember -Function Remove-Pfa2ExchangeBackup Export-ModuleMember -Function Restore-Pfa2ExchangeBackup Export-ModuleMember -Function Enter-Pfa2ExchangeBackupExposeSession Export-ModuleMember -Function Mount-Pfa2ExchangeBackup Export-ModuleMember -Function Dismount-Pfa2ExchangeBackup # END |