diff --git a/public/Copy-DbaDatabase.ps1 b/public/Copy-DbaDatabase.ps1 index 245567eb41e..a8c4b0ec7c7 100644 --- a/public/Copy-DbaDatabase.ps1 +++ b/public/Copy-DbaDatabase.ps1 @@ -93,6 +93,11 @@ function Copy-DbaDatabase { Sets source databases to read-only before migration to prevent data changes during the process. Use this to ensure data consistency when databases must remain accessible at the source during migration. + .PARAMETER SetSourceOffline + Sets source databases offline before migration to prevent any connections during the process. + Use this to ensure complete isolation when databases must be completely inaccessible at the source during migration. + When combined with -Reattach, databases are brought back online after being reattached to the source. + .PARAMETER ReuseSourceFolderStructure Maintains the exact file path structure from the source instance on the destination. Use this when destination servers have identical drive layouts or when preserving specific organizational folder structures. @@ -130,10 +135,6 @@ function Copy-DbaDatabase { Use this to distinguish migrated databases (e.g., 'DEV_' prefix for development copies). Cannot be used together with -NewName parameter. - .PARAMETER SetSourceOffline - Sets source databases to offline status after successful migration. - Use this for cutover scenarios where source databases should be unavailable after migration. - .PARAMETER KeepCDC Preserves Change Data Capture (CDC) configuration and data during migration. Use this when destination databases need to maintain CDC tracking for auditing or replication. @@ -258,6 +259,9 @@ function Copy-DbaDatabase { [parameter(ParameterSetName = "DbBackup")] [parameter(ParameterSetName = "DbAttachDetach")] [switch]$SetSourceReadOnly, + [parameter(ParameterSetName = "DbBackup")] + [parameter(ParameterSetName = "DbAttachDetach")] + [switch]$SetSourceOffline, [Alias("ReuseFolderStructure")] [parameter(ParameterSetName = "DbBackup")] [parameter(ParameterSetName = "DbAttachDetach")] @@ -276,7 +280,6 @@ function Copy-DbaDatabase { [switch]$KeepCDC, [parameter(ParameterSetName = "DbBackup")] [switch]$KeepReplication, - [switch]$SetSourceOffline, [string]$NewName, [string]$Prefix, [switch]$Force, @@ -1172,6 +1175,7 @@ function Copy-DbaDatabase { } $sourceDbReadOnly = $sourceServer.Databases[$dbName].ReadOnly + $sourceDbOffline = $sourceServer.Databases[$dbName].Status -like "*Offline*" if ($SetSourceReadOnly) { If ($Pscmdlet.ShouldProcess($source, "Set $dbName to read-only")) { @@ -1184,6 +1188,18 @@ function Copy-DbaDatabase { } } + if ($SetSourceOffline -and $DetachAttach) { + # For DetachAttach, set offline before detach to kill connections + If ($Pscmdlet.ShouldProcess($source, "Set $dbName to offline")) { + Write-Message -Level Verbose -Message "Setting database to offline." + try { + $result = Set-DbaDbState -SqlInstance $sourceServer -Database $dbName -Offline -EnableException -Force + } catch { + Stop-Function -Continue -Message "Couldn't set database to offline. Aborting routine for this database" -ErrorRecord $_ + } + } + } + if ($BackupRestore) { if ($UseLastBackup) { $whatifmsg = "Gathering last backup information for $dbName from $Source and restoring" @@ -1235,6 +1251,17 @@ function Copy-DbaDatabase { $backupCollection += $backupTmpResult } } + + # For BackupRestore, set source offline after backup completes but before restore + if ($SetSourceOffline) { + Write-Message -Level Verbose -Message "Setting source database $dbName to offline after backup." + try { + $null = Set-DbaDbState -SqlInstance $sourceServer -Database $dbName -Offline -EnableException -Force + } catch { + Stop-Function -Continue -Message "Couldn't set database to offline after backup. Aborting routine for this database" -ErrorRecord $_ + } + } + Write-Message -Level Verbose -Message "Reuse = $ReuseSourceFolderStructure." try { $msg = $null @@ -1305,6 +1332,16 @@ function Copy-DbaDatabase { } } + if ($SetSourceOffline) { + If ($Pscmdlet.ShouldProcess($destServer.Name, "Set $dbName to online after source was set to offline")) { + try { + $null = Set-DbaDbState -SqlInstance $destServer -Database $dbName -Online -EnableException -Force + } catch { + Stop-Function -Message "Couldn't set $dbName to online on $($destserver.Name)" -ErrorRecord $_ + } + } + } + $dbFinish = Get-Date if ($NoRecovery -eq $false) { If ($Pscmdlet.ShouldProcess($destServer.Name, "Setting db owner to $dbowner for $destinationDbName")) { @@ -1361,6 +1398,14 @@ function Copy-DbaDatabase { Stop-Function -Message "Couldn't set database to read-only" -ErrorRecord $_ } } + + if ($SetSourceOffline -or $sourceDbOffline) { + try { + $result = Set-DbaDbState -SqlInstance $sourceServer -Database $dbName -Offline -EnableException -Force + } catch { + Stop-Function -Message "Couldn't set database to offline" -ErrorRecord $_ + } + } Write-Message -Level Verbose -Message "Successfully reattached $dbName to $source." } else { Write-Message -Level Verbose -Message "Could not reattach $dbName to $source." @@ -1463,12 +1508,6 @@ function Copy-DbaDatabase { $copyDatabaseStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject } - if ($SetSourceOffline -and $copyDatabaseStatus.Status -eq "Successful" -and $sourceServer.databases[$dbName].status -notlike '*offline*') { - if ($Pscmdlet.ShouldProcess($source, "Setting $dbName offline")) { - Set-DbaDbState -SqlInstance $sourceServer -Database $dbName -Offline -Force - } - } - $dbTotalTime = $dbFinish - $dbStart $dbTotalTime = ($dbTotalTime.ToString().Split(".")[0]) diff --git a/public/Copy-DbaServerRole.ps1 b/public/Copy-DbaServerRole.ps1 index 858a4e151e3..6d514c9f75a 100644 --- a/public/Copy-DbaServerRole.ps1 +++ b/public/Copy-DbaServerRole.ps1 @@ -111,7 +111,7 @@ function Copy-DbaServerRole { return } - $sourceRoles = $sourceServer.Roles | Where-Object IsFixedRole -eq $false + $sourceRoles = $sourceServer.Roles | Where-Object { $PSItem.IsFixedRole -eq $false -and $PSItem.Name -ne "public" } if ($Force) { $ConfirmPreference = "none" } } diff --git a/public/Start-DbaMigration.ps1 b/public/Start-DbaMigration.ps1 index 4e7ba353f26..e4bef4bd674 100644 --- a/public/Start-DbaMigration.ps1 +++ b/public/Start-DbaMigration.ps1 @@ -97,6 +97,11 @@ function Start-DbaMigration { This prevents data changes during migration and helps ensure data consistency. When combined with -Reattach, databases remain read-only after being reattached to the source. + .PARAMETER SetSourceOffline + Sets migrated databases offline on the source server before migration begins. + This prevents any connections to the source databases during migration, ensuring complete isolation. + When combined with -Reattach, databases are brought back online after being reattached to the source. + .PARAMETER AzureCredential Specifies the name of a SQL Server credential for accessing Azure Storage when SharedPath points to an Azure Storage account. The credential must already exist on both source and destination servers with proper access to the Azure Storage container. @@ -201,6 +206,11 @@ function Start-DbaMigration { Migrates databases using detach/copy/attach. Reattach at source and set source databases read-only. Also migrates everything else. + .EXAMPLE + PS C:\> Start-DbaMigration -Verbose -Source sqlcluster -Destination sql2016 -BackupRestore -SharedPath "\\fileserver\backups" -SetSourceOffline + + Migrates databases using backup/restore method. Sets source databases offline before migration to prevent any connections during the process. + .EXAMPLE PS C:\> $PSDefaultParameters = @{ >> "dbatools:Source" = "sqlcluster" @@ -225,6 +235,7 @@ function Start-DbaMigration { [switch]$WithReplace, [switch]$NoRecovery, [switch]$SetSourceReadOnly, + [switch]$SetSourceOffline, [switch]$ReuseSourceFolderStructure, [switch]$IncludeSupportDbs, [PSCredential]$SourceSqlCredential, @@ -392,6 +403,7 @@ function Start-DbaMigration { Destination = $Destination DestinationSqlCredential = $DestinationSqlCredential SetSourceReadOnly = $SetSourceReadOnly + SetSourceOffline = $SetSourceOffline ReuseSourceFolderStructure = $ReuseSourceFolderStructure AllDatabases = $true Force = $Force @@ -424,7 +436,9 @@ function Start-DbaMigration { } } - Copy-DbaDatabase @CopyDatabaseSplat + Copy-DbaDatabase @CopyDatabaseSplat | ForEach-Object { + $PSItem + } } if ($Exclude -notcontains 'Logins') { diff --git a/tests/Start-DbaMigration.Tests.ps1 b/tests/Start-DbaMigration.Tests.ps1 index 5f1a5202b5d..3744d20ef6b 100644 --- a/tests/Start-DbaMigration.Tests.ps1 +++ b/tests/Start-DbaMigration.Tests.ps1 @@ -20,6 +20,7 @@ Describe $CommandName -Tag UnitTests { "WithReplace", "NoRecovery", "SetSourceReadOnly", + "SetSourceOffline", "ReuseSourceFolderStructure", "IncludeSupportDbs", "SourceSqlCredential", @@ -194,4 +195,63 @@ Describe $CommandName -Tag IntegrationTests { $sourceDbs.Owner | Should -Be $destDbs.Owner } } + + Context "When using SetSourceOffline parameter" { + BeforeAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + # Create a dedicated database for offline testing + $offlineTestDb = "dbatoolsci_offline$random" + + # Clean up any existing test database + Remove-DbaDatabase -SqlInstance $TestConfig.instance2, $TestConfig.instance3 -Database $offlineTestDb -ErrorAction SilentlyContinue + + # Create test database on source + $splatCreateOfflineDb = @{ + SqlInstance = $TestConfig.instance2 + Query = "CREATE DATABASE $offlineTestDb; ALTER DATABASE $offlineTestDb SET AUTO_CLOSE OFF WITH ROLLBACK IMMEDIATE" + } + Invoke-DbaQuery @splatCreateOfflineDb + + # Create a backup so UseLastBackup can find it + $null = Backup-DbaDatabase -SqlInstance $TestConfig.instance2 -Database $offlineTestDb -BackupDirectory $backupPath + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + + # Run migration with SetSourceOffline + $splatOfflineMigration = @{ + Source = $TestConfig.instance2 + Destination = $TestConfig.instance3 + BackupRestore = $true + UseLastBackup = $true + SetSourceOffline = $true + Force = $true + Exclude = "Logins", "SpConfigure", "SysDbUserObjects", "AgentServer", "CentralManagementServer", "ExtendedEvents", "PolicyManagement", "ResourceGovernor", "Endpoints", "ServerAuditSpecifications", "Audits", "LinkedServers", "SystemTriggers", "DataCollector", "DatabaseMail", "BackupDevices", "Credentials", "StartupProcedures", "MasterCertificates" + } + $offlineResults = Start-DbaMigration @splatOfflineMigration + } + + AfterAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + # Bring database back online before cleanup + Set-DbaDbState -SqlInstance $TestConfig.instance2 -Database $offlineTestDb -Online -Force -ErrorAction SilentlyContinue + + # Clean up + Remove-DbaDatabase -SqlInstance $TestConfig.instance2, $TestConfig.instance3 -Database $offlineTestDb -ErrorAction SilentlyContinue + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + It "Should set source database offline after successful migration" { + $sourceDb = Get-DbaDatabase -SqlInstance $TestConfig.instance2 -Database $offlineTestDb + $sourceDb.Status | Should -BeLike "*Offline*" + } + + It "Should have destination database online" { + $destDb = Get-DbaDatabase -SqlInstance $TestConfig.instance3 -Database $offlineTestDb + $destDb | Should -Not -BeNullOrEmpty + $destDb.Status | Should -Be "Normal" + } + } } \ No newline at end of file