Public/Move-sqmDatabaseFile.ps1
|
<#
.SYNOPSIS Verschiebt Datenbankdateien (MDF/NDF/LDF) auf ein anderes Laufwerk oder in ein anderes Verzeichnis. .DESCRIPTION Verschiebt die Dateien einer Datenbank an einen neuen Ort und fuehrt dabei genau die Schritte aus, die SQL Server dafuer verlangt. Angegeben wird entweder nur der Datenbankname (dann werden alle Dateien bzw. alle Dateien eines Typs verschoben) oder ueber -LogicalFileName eine einzelne Datei. Es gibt drei Ablaeufe, die sich nach der Datenbank richten: Benutzerdatenbank OFFLINE -> Dateien kopieren -> ALTER DATABASE MODIFY FILE -> ONLINE -> alte Dateien entfernen tempdb ALTER DATABASE MODIFY FILE -> Instanz-Neustart -> alte Dateien entfernen. Es wird NICHTS kopiert: tempdb wird beim Start neu angelegt. Genau deshalb bleiben die alten Dateien sonst als Leichen auf dem alten Laufwerk liegen. model / msdb ALTER DATABASE MODIFY FILE -> Dienst stoppen -> Dateien kopieren -> Dienst starten -> alte Dateien entfernen master wird bewusst abgelehnt. Dafuer muessen die Startparameter -d/-l/-e geaendert werden (Set-DbaStartupParameter), das ist ein anderer Vorgang mit anderen Risiken. Vor der ersten Aenderung wird geprueft: 1. Datenbankzustand ONLINE bzw. OFFLINE, kein Snapshot, kein master. 2. AlwaysOn Ist die Datenbank Mitglied einer AG, wird abgebrochen. Eine AG-Datenbank laesst sich nicht offline setzen; das Verschieben muss repliktweise erfolgen. Mit -Force wird die Pruefung uebergangen. 3. Zielverzeichnis Existiert es aus Sicht der INSTANZ (Test-DbaPath, also xp_fileexist)? Fehlt es, wird es angelegt, die ACL des Quellverzeichnisses uebernommen und dem Dienstkonto der Engine explizit Vollzugriff eingeraeumt. 4. Schreibprobe Auf jedem Zielverzeichnis wird testweise eine 8-MB-Datenbank angelegt und sofort wieder verworfen. Erst das beweist, dass das Dienstkonto dort wirklich Dateien anlegen darf. Bei tempdb ist das der entscheidende Test: ein nicht beschreibbarer tempdb-Pfad bedeutet, dass die Instanz nach dem Neustart gar nicht mehr hochkommt. Abschaltbar mit -SkipWriteProbe. 5. Platz Freier Platz am Ziel gegen die Summe der Dateigroessen plus Puffer (-SpaceBufferPercent, Standard 20). Quelle ist Get-DbaDiskSpace (kennt Mountpoints), Rueckfallebene xp_fixeddrives. Alte Dateien werden nur entfernt, nachdem die Datenbank am neuen Ort nachweislich laeuft und die neue Datei per Test-DbaPath bestaetigt ist. -KeepOldFiles unterdrueckt das Aufraeumen. .PARAMETER SqlInstance Ziel-Instanz (z. B. "SQL01" oder "SQL01\INST"). Ohne Angabe wird $env:COMPUTERNAME verwendet. .PARAMETER SqlCredential Alternative SQL-Anmeldeinformationen. Ohne Angabe wird Windows-Authentifizierung verwendet. .PARAMETER Database Name der Datenbank, deren Dateien verschoben werden (z. B. "SalesDB" oder "tempdb"). .PARAMETER FileDestination Zielverzeichnis fuer die zu verschiebenden Dateien (z. B. "G:\MSSQL\Data"). Der Pfad wird aus Sicht des SQL Servers interpretiert, nicht aus Sicht der aufrufenden Maschine. .PARAMETER LogFileDestination Optionales abweichendes Zielverzeichnis fuer Logdateien. Ohne Angabe landen auch die Logdateien in -FileDestination. .PARAMETER FileType Welche Dateien verschoben werden: All (Standard), Data (MDF/NDF) oder Log (LDF). Wird ignoriert, wenn -LogicalFileName angegeben ist. .PARAMETER LogicalFileName Logische Namen einzelner Dateien (z. B. "tempdev" oder "SalesDB_log"). Damit laesst sich gezielt eine einzelne MDF/NDF/LDF verschieben statt der ganzen Datenbank. .PARAMETER Credential Windows-Anmeldeinformationen fuer die Dateioperationen und die Dienststeuerung auf dem Zielhost. Nur noetig, wenn die Instanz auf einem anderen Rechner laeuft und der aktuelle Benutzerkontext dort nicht ausreicht. .PARAMETER KeepOldFiles Laesst die Dateien am alten Ort liegen, statt sie nach erfolgreichem Verschieben zu entfernen. .PARAMETER NoRestart Nur fuer tempdb: setzt ALTER DATABASE MODIFY FILE ab, startet die Instanz aber NICHT neu. Die Aenderung wird erst beim naechsten Neustart wirksam, die alten Dateien bleiben liegen (Status PendingRestart). Fuer model/msdb ist -NoRestart nicht zulaessig, weil die Instanz sonst beim naechsten Start die bereits umgetragenen Dateien am neuen Ort nicht faende. .PARAMETER SkipWriteProbe Ueberspringt die Schreibprobe im Zielverzeichnis (Punkt 4 oben). Nur sinnvoll, wenn das Anlegen einer temporaeren Datenbank aus Audit-Gruenden unerwuenscht ist. .PARAMETER SkipSpaceCheck Ueberspringt die Pruefung des freien Speicherplatzes am Ziel. .PARAMETER SpaceBufferPercent Sicherheitsaufschlag in Prozent auf die benoetigte Groesse bei der Platzpruefung. Standard: 20. .PARAMETER RestartTimeoutSeconds Wie lange nach einer Dienstaktion auf die Erreichbarkeit der Instanz gewartet wird. Standard: 300 Sekunden. .PARAMETER Force Beendet offene Verbindungen beim Offline-Setzen einer Benutzerdatenbank und uebergeht die AlwaysOn-Sperre. .PARAMETER EnableException Wirft Ausnahmen weiter, statt sie als Ergebniszeile mit Status "Failed" zu melden. .EXAMPLE Move-sqmDatabaseFile -SqlInstance SQL01 -Database tempdb -FileDestination 'G:\MSSQL\TempDB' Verschiebt alle tempdb-Dateien nach G:\MSSQL\TempDB, startet die Instanz neu und entfernt anschliessend die alten tempdb-Dateien. .EXAMPLE Move-sqmDatabaseFile -SqlInstance SQL01 -Database tempdb -FileDestination 'G:\MSSQL\TempDB' -WhatIf Fuehrt alle Vorpruefungen aus und zeigt den geplanten Ablauf, ohne etwas zu veraendern. .EXAMPLE Move-sqmDatabaseFile -SqlInstance SQL01 -Database SalesDB -LogicalFileName 'SalesDB_log' -FileDestination 'H:\MSSQL\Log' Verschiebt nur die Logdatei der Datenbank SalesDB. .EXAMPLE Move-sqmDatabaseFile -SqlInstance SQL01 -Database SalesDB -FileDestination 'G:\MSSQL\Data' -LogFileDestination 'H:\MSSQL\Log' -Force Verschiebt Daten- und Logdateien auf getrennte Laufwerke und beendet dabei offene Verbindungen. .OUTPUTS PSCustomObject je Schritt mit SqlInstance, Database, Step (Precheck/Move/Restart/Cleanup), LogicalName, OldPath, NewPath, SizeMB, Status und Message. .NOTES Benoetigt dbatools und sysadmin auf der Instanz. Fuer Neustart und Dateioperationen auf einer entfernten Instanz wird zusaetzlich Windows-Zugriff auf den Host benoetigt (administrative Freigabe und Dienststeuerung), notfalls ueber -Credential. Bewusst NICHT ueber Move-DbaDbFile umgesetzt: dieses Cmdlet lehnt master, model, msdb und tempdb grundsaetzlich ab und kennt weder Schreibprobe noch Platzpruefung noch WhatIf. #> function Move-sqmDatabaseFile { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $false, Position = 0)] [string]$SqlInstance, [Parameter(Mandatory = $false)] [System.Management.Automation.PSCredential]$SqlCredential, [Parameter(Mandatory = $true, Position = 1)] [ValidateNotNullOrEmpty()] [string]$Database, [Parameter(Mandatory = $true, Position = 2)] [ValidateNotNullOrEmpty()] [string]$FileDestination, [Parameter(Mandatory = $false)] [string]$LogFileDestination, [Parameter(Mandatory = $false)] [ValidateSet('All', 'Data', 'Log')] [string]$FileType = 'All', [Parameter(Mandatory = $false)] [string[]]$LogicalFileName, [Parameter(Mandatory = $false)] [System.Management.Automation.PSCredential]$Credential, [Parameter(Mandatory = $false)] [switch]$KeepOldFiles, [Parameter(Mandatory = $false)] [switch]$NoRestart, [Parameter(Mandatory = $false)] [switch]$SkipWriteProbe, [Parameter(Mandatory = $false)] [switch]$SkipSpaceCheck, [Parameter(Mandatory = $false)] [ValidateRange(0, 100)] [int]$SpaceBufferPercent = 20, [Parameter(Mandatory = $false)] [ValidateRange(30, 3600)] [int]$RestartTimeoutSeconds = 300, [Parameter(Mandatory = $false)] [switch]$Force, [Parameter(Mandatory = $false)] [switch]$EnableException ) begin { $functionName = $MyInvocation.MyCommand.Name if (-not $PSBoundParameters.ContainsKey('SqlInstance') -or [string]::IsNullOrWhiteSpace($SqlInstance)) { $SqlInstance = $env:COMPUTERNAME Write-Verbose "Keine SqlInstance angegeben. Verwende Standard: $SqlInstance" } if (-not $script:dbatoolsAvailable) { $errMsg = "dbatools-Modul nicht gefunden. Bitte installieren Sie es mit: Install-Module dbatools" Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR" throw $errMsg } $connParams = @{ SqlInstance = $SqlInstance; ErrorAction = 'Stop' } if ($SqlCredential) { $connParams['SqlCredential'] = $SqlCredential } # Windows-seitige Parameter (Dienststeuerung). Bewusst getrennt von $connParams: Dienste # steuert man ueber WMI/CIM mit einem Windows-Konto, nicht ueber die SQL-Verbindung. $svcParams = @{ SqlInstance = $SqlInstance; ErrorAction = 'Stop' } if ($Credential) { $svcParams['Credential'] = $Credential } $FileDestination = $FileDestination.TrimEnd('\') if ($LogFileDestination) { $LogFileDestination = $LogFileDestination.TrimEnd('\') } $results = [System.Collections.Generic.List[PSCustomObject]]::new() function _AddResult([string]$Step, [string]$LogicalName, [string]$OldPath, [string]$NewPath, $SizeMB, [string]$Status, [string]$Message) { $results.Add([PSCustomObject]@{ SqlInstance = $SqlInstance Database = $Database Step = $Step LogicalName = $LogicalName OldPath = $OldPath NewPath = $NewPath SizeMB = $SizeMB Status = $Status Message = $Message }) } # Pfade auf dem SQL-Host zusammensetzen, OHNE Join-Path. Join-Path loest den # Laufwerksbuchstaben gegen die PSDrives DIESER Maschine auf: fuer ein 'G:\...' auf einem # entfernten Server, das es hier nicht gibt, liefert es einen Fehler und einen LEEREN # String - und damit ein ALTER DATABASE ... FILENAME = N''. function _CombineServerPath([string]$Dir, [string]$Leaf) { return ($Dir.TrimEnd('\') + '\' + $Leaf.TrimStart('\')) } # Serverpfad -> von DIESER Maschine erreichbarer Pfad. Lokal unveraendert, sonst ueber die # administrative Freigabe (C:\Data\x.mdf -> \\HOST\C$\Data\x.mdf). function _ToWindowsPath([string]$Path) { if ($isLocalHost) { return $Path } if ($Path -like '\\*') { return $Path } if ($Path -match '^[A-Za-z]:\\') { return ('\\{0}\{1}$\{2}' -f $sqlHostName, $Path.Substring(0, 1), $Path.Substring(3)) } return $Path } # Freier Platz am Zielpfad. Get-DbaDiskSpace kennt Mountpoints, xp_fixeddrives nur # Laufwerksbuchstaben - deshalb erst das eine, dann als Rueckfallebene das andere. function _GetFreeSpaceMb([string]$Path) { try { $dsParams = @{ ComputerName = $sqlHostName; ErrorAction = 'Stop' } if ($Credential) { $dsParams['Credential'] = $Credential } $volumes = Get-DbaDiskSpace @dsParams $match = $volumes | Where-Object { $_.Name -and $Path.ToUpperInvariant().StartsWith($_.Name.ToUpperInvariant()) } | Sort-Object { $_.Name.Length } -Descending | Select-Object -First 1 if ($match) { return [PSCustomObject]@{ FreeMB = [double]$match.Free.Megabyte Volume = "$($match.Name)" Source = 'Get-DbaDiskSpace' } } } catch { Invoke-sqmLogging -Message "Get-DbaDiskSpace fuer '$sqlHostName' nicht moeglich ($($_.Exception.Message)). Weiche auf xp_fixeddrives aus." -FunctionName $functionName -Level "DEBUG" } if ($Path -match '^([A-Za-z]):') { $letter = $Matches[1].ToUpperInvariant() try { $drives = Invoke-DbaQuery @connParams -Database master -Query 'EXEC master.dbo.xp_fixeddrives' -As PSObject -EnableException $row = $drives | Where-Object { "$($_.drive)".ToUpperInvariant() -eq $letter } | Select-Object -First 1 if ($row) { return [PSCustomObject]@{ FreeMB = [double]$row.'MB free' Volume = ($letter + ':\') Source = 'xp_fixeddrives' } } } catch { Invoke-sqmLogging -Message "xp_fixeddrives fehlgeschlagen: $($_.Exception.Message)" -FunctionName $functionName -Level "DEBUG" } } return $null } # Beweist, dass das Dienstkonto im Zielverzeichnis Dateien anlegen darf - indem SQL Server # selbst es versucht. Alles andere (ACL lesen, Test-DbaPath) beantwortet nur, ob der Pfad # sichtbar ist, nicht ob er beschreibbar ist. function _TestSqlWriteAccess([string]$DirPath) { $probeName = 'sqmMoveProbe_' + ([guid]::NewGuid().ToString('N').Substring(0, 8)) $probeData = _CombineServerPath $DirPath ('{0}.mdf' -f $probeName) $probeLog = _CombineServerPath $DirPath ('{0}_log.ldf' -f $probeName) $createSql = "CREATE DATABASE [$probeName] ON PRIMARY (NAME = N'$probeName', FILENAME = N'$probeData', SIZE = 8MB) " + "LOG ON (NAME = N'$($probeName)_log', FILENAME = N'$probeLog', SIZE = 8MB);" $probeOk = $false $probeMsg = '' try { $null = Invoke-DbaQuery @connParams -Database master -Query $createSql -EnableException $probeOk = $true $probeMsg = 'Schreibprobe erfolgreich.' } catch { $probeMsg = $_.Exception.Message } try { $null = Invoke-DbaQuery @connParams -Database master -Query "IF DB_ID(N'$probeName') IS NOT NULL DROP DATABASE [$probeName];" -EnableException } catch { Invoke-sqmLogging -Message "Probe-Datenbank '$probeName' konnte nicht entfernt werden: $($_.Exception.Message)" -FunctionName $functionName -Level "WARNING" } return [PSCustomObject]@{ Success = $probeOk; Message = $probeMsg } } # Zustand des SQL-Agent vor einer Dienstaktion merken, um ihn danach wiederherzustellen. # Start-DbaService -Type Engine startet den Agent NICHT mit - ein gestoppter Agent nach # einem Dateiumzug faellt sonst erst auf, wenn nachts die Sicherung ausbleibt. function _GetAgentState { try { $agent = Get-DbaService @svcParams -Type Agent | Select-Object -First 1 if ($agent) { return "$($agent.State)" } } catch { Invoke-sqmLogging -Message "Agent-Status nicht ermittelbar: $($_.Exception.Message)" -FunctionName $functionName -Level "DEBUG" } return $null } function _WaitForInstance([int]$TimeoutSeconds) { $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { try { $null = Invoke-DbaQuery @connParams -Database master -Query 'SELECT 1 AS Up' -EnableException return $true } catch { Start-Sleep -Seconds 5 } } return $false } Invoke-sqmLogging -Message "Starte $functionName fuer '$Database' auf '$SqlInstance'. Ziel: '$FileDestination'." -FunctionName $functionName -Level "INFO" } process { try { # --------------------------------------------------------------------------------- # 1. Verbindung und Grundzustand # --------------------------------------------------------------------------------- $server = Connect-DbaInstance @connParams $sqlHostName = "$($server.ComputerName)" if ([string]::IsNullOrWhiteSpace($sqlHostName)) { $sqlHostName = ($SqlInstance -split '\\')[0] } $isLocalHost = $sqlHostName -in @($env:COMPUTERNAME, 'localhost', '127.0.0.1', '.') $dbKind = 'User' if ($Database -in @('tempdb', 'model', 'msdb')) { $dbKind = 'System' } if ($Database -eq 'master') { $msg = "master wird von dieser Funktion nicht verschoben. Die Dateipfade von master stehen in den Startparametern (-d/-l/-e), nicht in ALTER DATABASE. Vorgehen: Set-DbaStartupParameter -SqlInstance $SqlInstance -MasterData/-MasterLog/-ErrorLog, Instanz stoppen, Dateien kopieren, starten." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $FileDestination $null 'Blocked' $msg if ($EnableException) { throw $msg } return } if ($dbKind -eq 'System' -and $NoRestart -and $Database -ne 'tempdb') { $msg = "-NoRestart ist fuer '$Database' nicht zulaessig. ALTER DATABASE MODIFY FILE wuerde den neuen Pfad eintragen, die Datei laege aber noch am alten Ort - die Instanz kaeme beim naechsten Start nicht mehr hoch. Nur bei tempdb darf aufgeschoben werden, weil die Dateien dort ohnehin neu angelegt werden." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $FileDestination $null 'Blocked' $msg if ($EnableException) { throw $msg } return } $dbEscaped = $Database -replace "'", "''" $dbInfo = Invoke-DbaQuery @connParams -Database master -As PSObject -EnableException -Query @" SELECT d.name AS DbName, d.state_desc AS StateDesc, d.is_read_only AS IsReadOnly, d.source_database_id AS SourceDatabaseId, d.recovery_model_desc AS RecoveryModel FROM sys.databases d WHERE d.name = N'$dbEscaped'; "@ if (-not $dbInfo) { $msg = "Datenbank '$Database' existiert auf '$SqlInstance' nicht." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $null $null 'NotFound' $msg if ($EnableException) { throw $msg } return } if ($null -ne $dbInfo.SourceDatabaseId -and $dbInfo.SourceDatabaseId -isnot [DBNull]) { $msg = "'$Database' ist ein Datenbank-Snapshot. Snapshot-Dateien lassen sich nicht verschieben." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $null $null 'Blocked' $msg if ($EnableException) { throw $msg } return } if ("$($dbInfo.StateDesc)" -notin @('ONLINE', 'OFFLINE')) { $msg = "'$Database' ist im Zustand '$($dbInfo.StateDesc)'. Verschoben werden kann nur aus ONLINE oder OFFLINE heraus." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $null $null 'Blocked' $msg if ($EnableException) { throw $msg } return } if ("$($server.IsClustered)" -eq 'True') { $msg = "Failover-Cluster-Instanz erkannt: das Ziellaufwerk muss als Abhaengigkeit der SQL-Rolle eingetragen sein (Ausnahme: tempdb darf seit SQL 2012 auf lokalem Speicher liegen)." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "WARNING" _AddResult 'Precheck' $null $null $FileDestination $null 'Warning' $msg } # --------------------------------------------------------------------------------- # 2. AlwaysOn - eine AG-Datenbank laesst sich nicht offline setzen # --------------------------------------------------------------------------------- if ($dbKind -eq 'User') { try { $agParams = @{ SqlInstance = $SqlInstance; Database = $Database } if ($SqlCredential) { $agParams['SqlCredential'] = $SqlCredential } $agInfo = Get-sqmDatabaseAgMembership @agParams if ($agInfo.IsAgDatabase) { $agMsg = "'$Database' ist Mitglied der Availability Group '$($agInfo.AvailabilityGroupName)'. Ein Offline-Setzen lehnt SQL Server ab; die Dateien muessen auf jedem Replikat einzeln verschoben werden (Datenbank aus der AG nehmen, verschieben, wieder aufnehmen)." if (-not $Force) { Invoke-sqmLogging -Message $agMsg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $FileDestination $null 'Blocked' ($agMsg + " Mit -Force wird diese Sperre uebergangen.") if ($EnableException) { throw $agMsg } return } Invoke-sqmLogging -Message ($agMsg + " -Force gesetzt: es wird trotzdem fortgefahren.") -FunctionName $functionName -Level "WARNING" _AddResult 'Precheck' $null $null $FileDestination $null 'Warning' ($agMsg + " -Force gesetzt.") } } catch { $msg = "AlwaysOn-Mitgliedschaft von '$Database' konnte nicht geklaert werden: $($_.Exception.Message). Solange das offen ist, wird nicht offline gesetzt." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $null $null 'Blocked' $msg if ($EnableException) { throw } return } } # --------------------------------------------------------------------------------- # 3. Dateien auswaehlen. sys.master_files statt sys.database_files, weil es auch fuer # eine OFFLINE-Datenbank lesbar ist und bei tempdb die konfigurierte Startgroesse # zeigt - genau die Groesse, die nach dem Neustart am Ziel gebraucht wird. # --------------------------------------------------------------------------------- $allFiles = Invoke-DbaQuery @connParams -Database master -As PSObject -EnableException -Query @" SELECT mf.file_id AS FileId, mf.name AS LogicalName, mf.physical_name AS PhysicalName, mf.type_desc AS TypeDesc, CAST(mf.size * 8.0 / 1024 AS decimal(18,2)) AS SizeMB FROM sys.master_files mf WHERE mf.database_id = DB_ID(N'$dbEscaped') ORDER BY mf.type, mf.file_id; "@ if (-not $allFiles) { $msg = "Keine Dateien fuer '$Database' in sys.master_files gefunden." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $null $null 'Failed' $msg if ($EnableException) { throw $msg } return } $selected = @($allFiles) if ($LogicalFileName) { $selected = @($allFiles | Where-Object { $_.LogicalName -in $LogicalFileName }) $missing = @($LogicalFileName | Where-Object { $_ -notin @($allFiles.LogicalName) }) foreach ($miss in $missing) { $msg = "Logische Datei '$miss' existiert in '$Database' nicht." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "WARNING" _AddResult 'Precheck' $miss $null $null $null 'NotFound' $msg } } elseif ($FileType -eq 'Data') { $selected = @($allFiles | Where-Object { $_.TypeDesc -eq 'ROWS' }) } elseif ($FileType -eq 'Log') { $selected = @($allFiles | Where-Object { $_.TypeDesc -eq 'LOG' }) } if (-not $selected -or $selected.Count -eq 0) { $msg = "Keine passenden Dateien ausgewaehlt (FileType '$FileType', LogicalFileName '$($LogicalFileName -join ', ')')." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "WARNING" _AddResult 'Precheck' $null $null $null $null 'NoSelection' $msg if ($EnableException) { throw $msg } return } # Zielpfade bestimmen, bereits am Ziel liegende Dateien aussortieren $plan = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($f in $selected) { $isLog = ($f.TypeDesc -eq 'LOG') $destDir = if ($isLog -and $LogFileDestination) { $LogFileDestination } else { $FileDestination } $fileName = Split-Path -Path "$($f.PhysicalName)" -Leaf $newPath = _CombineServerPath $destDir $fileName $oldDir = Split-Path -Path "$($f.PhysicalName)" -Parent if ($oldDir.TrimEnd('\').ToUpperInvariant() -eq $destDir.ToUpperInvariant()) { $msg = "'$($f.LogicalName)' liegt bereits in '$destDir'." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "INFO" _AddResult 'Move' "$($f.LogicalName)" "$($f.PhysicalName)" $newPath $f.SizeMB 'AlreadyInPlace' $msg continue } $plan.Add([PSCustomObject]@{ LogicalName = "$($f.LogicalName)" OldPath = "$($f.PhysicalName)" OldDir = $oldDir NewPath = $newPath DestDir = $destDir SizeMB = [double]$f.SizeMB IsLog = $isLog }) } if ($plan.Count -eq 0) { Invoke-sqmLogging -Message "Nichts zu tun: alle ausgewaehlten Dateien liegen bereits am Ziel." -FunctionName $functionName -Level "INFO" return } # --------------------------------------------------------------------------------- # 4. Zielverzeichnisse: sichtbar, berechtigt, beschreibbar, gross genug # --------------------------------------------------------------------------------- $destDirs = @($plan | Select-Object -ExpandProperty DestDir -Unique) $precheckFailed = $false foreach ($destDir in $destDirs) { $dirVisible = $false try { $dirVisible = [bool](Test-DbaPath @connParams -Path $destDir) } catch { $dirVisible = $false } if (-not $dirVisible) { $winDir = _ToWindowsPath $destDir $sampleSourceDir = @($plan | Where-Object { $_.DestDir -eq $destDir })[0].OldDir if ($PSCmdlet.ShouldProcess($sqlHostName, "Lege Zielverzeichnis '$destDir' an und uebernehme die Berechtigungen von '$sampleSourceDir'")) { try { $null = New-Item -Path $winDir -ItemType Directory -Force -ErrorAction Stop Invoke-sqmLogging -Message "Zielverzeichnis '$destDir' angelegt." -FunctionName $functionName -Level "INFO" # ACL der Quelle uebernehmen: dort hat das Dienstkonto nachweislich # Rechte, ein frisch angelegter Ordner erbt dagegen nur die Wurzel-ACL. try { $srcAcl = Get-Acl -Path (_ToWindowsPath $sampleSourceDir) -ErrorAction Stop Set-Acl -Path $winDir -AclObject $srcAcl -ErrorAction Stop Invoke-sqmLogging -Message "Berechtigungen von '$sampleSourceDir' auf '$destDir' uebernommen." -FunctionName $functionName -Level "INFO" } catch { Invoke-sqmLogging -Message "Berechtigungen konnten nicht von '$sampleSourceDir' uebernommen werden: $($_.Exception.Message)" -FunctionName $functionName -Level "WARNING" } # Dienstkonto zusaetzlich explizit berechtigen try { $engineSvc = Get-DbaService @svcParams -Type Engine | Select-Object -First 1 if ($engineSvc -and $engineSvc.StartName) { $acl = Get-Acl -Path $winDir -ErrorAction Stop $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( "$($engineSvc.StartName)", 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') $acl.AddAccessRule($rule) Set-Acl -Path $winDir -AclObject $acl -ErrorAction Stop Invoke-sqmLogging -Message "Dienstkonto '$($engineSvc.StartName)' hat Vollzugriff auf '$destDir'." -FunctionName $functionName -Level "INFO" } } catch { Invoke-sqmLogging -Message "Dienstkonto konnte nicht explizit berechtigt werden: $($_.Exception.Message)" -FunctionName $functionName -Level "WARNING" } try { $dirVisible = [bool](Test-DbaPath @connParams -Path $destDir) } catch { $dirVisible = $false } } catch { $msg = "Zielverzeichnis '$destDir' konnte nicht angelegt werden ($winDir): $($_.Exception.Message)" Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $destDir $null 'Failed' $msg $precheckFailed = $true continue } } else { _AddResult 'Precheck' $null $null $destDir $null 'WhatIfSkipped' "WhatIf: Zielverzeichnis '$destDir' wuerde angelegt." } } if (-not $dirVisible -and -not $WhatIfPreference) { $msg = "Zielverzeichnis '$destDir' ist fuer die Instanz nicht sichtbar (Test-DbaPath). Pruefen Sie Pfad und Rechte des Dienstkontos." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $destDir $null 'Failed' $msg $precheckFailed = $true continue } # Schreibprobe if ($SkipWriteProbe) { _AddResult 'Precheck' $null $null $destDir $null 'Skipped' "Schreibprobe uebersprungen (-SkipWriteProbe)." } elseif (-not $dirVisible) { _AddResult 'Precheck' $null $null $destDir $null 'WhatIfSkipped' "WhatIf: Schreibprobe erst nach Anlegen des Verzeichnisses moeglich." } else { $probe = _TestSqlWriteAccess $destDir if ($probe.Success) { Invoke-sqmLogging -Message "Schreibprobe in '$destDir' erfolgreich." -FunctionName $functionName -Level "INFO" _AddResult 'Precheck' $null $null $destDir $null 'Success' "Schreibprobe in '$destDir' erfolgreich." } else { $msg = "Schreibprobe in '$destDir' fehlgeschlagen: $($probe.Message). Das Dienstkonto der Engine kann dort keine Dateien anlegen." if ($Database -eq 'tempdb') { $msg += " tempdb dorthin zu verschieben wuerde bedeuten, dass die Instanz nach dem Neustart nicht mehr startet." } Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $destDir $null 'Failed' $msg $precheckFailed = $true continue } } # Platzbedarf if ($SkipSpaceCheck) { _AddResult 'Precheck' $null $null $destDir $null 'Skipped' "Platzpruefung uebersprungen (-SkipSpaceCheck)." } else { $sumMb = (@($plan | Where-Object { $_.DestDir -eq $destDir }) | Measure-Object -Property SizeMB -Sum).Sum $neededMb = [math]::Round($sumMb * (1 + $SpaceBufferPercent / 100), 1) $space = _GetFreeSpaceMb $destDir if (-not $space) { $msg = "Freier Platz fuer '$destDir' konnte nicht ermittelt werden. Benoetigt werden rund $neededMb MB." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "WARNING" _AddResult 'Precheck' $null $null $destDir $neededMb 'Warning' $msg } elseif ($space.FreeMB -lt $neededMb) { $msg = "Zu wenig Platz auf '$($space.Volume)': frei $([math]::Round($space.FreeMB, 1)) MB, benoetigt rund $neededMb MB inkl. $SpaceBufferPercent % Puffer (Quelle: $($space.Source))." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $destDir $neededMb 'Failed' $msg $precheckFailed = $true continue } else { _AddResult 'Precheck' $null $null $destDir $neededMb 'Success' "Platz auf '$($space.Volume)' ausreichend: frei $([math]::Round($space.FreeMB, 1)) MB, benoetigt rund $neededMb MB (Quelle: $($space.Source))." } } } if ($precheckFailed) { $msg = "Vorpruefung fehlgeschlagen. Es wurde nichts veraendert." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Precheck' $null $null $null $null 'Aborted' $msg if ($EnableException) { throw $msg } return } # --------------------------------------------------------------------------------- # 5. Eine einzige Rueckfrage fuer den gesamten Vorgang # --------------------------------------------------------------------------------- $restartText = if ($dbKind -eq 'System' -and -not $NoRestart) { 'ja (Instanz-Neustart)' } elseif ($dbKind -eq 'System') { 'nein (-NoRestart, wirkt erst beim naechsten Start)' } else { 'nein (Datenbank wird offline/online geschaltet)' } $cleanupText = if ($KeepOldFiles) { 'nein (-KeepOldFiles)' } else { 'ja' } $planText = "Verschiebe $($plan.Count) Datei(en) von '$((@($plan.OldDir) | Select-Object -Unique) -join ', ')' nach '$($destDirs -join ', ')'. Neustart: $restartText. Alte Dateien entfernen: $cleanupText." Invoke-sqmLogging -Message $planText -FunctionName $functionName -Level "INFO" if (-not $PSCmdlet.ShouldProcess($SqlInstance, $planText)) { foreach ($p in $plan) { _AddResult 'Move' $p.LogicalName $p.OldPath $p.NewPath $p.SizeMB 'WhatIfSkipped' $planText } return } $agentStateBefore = $null if ($dbKind -eq 'System') { $agentStateBefore = _GetAgentState } $movedFiles = [System.Collections.Generic.List[PSCustomObject]]::new() $appliedAlter = [System.Collections.Generic.List[PSCustomObject]]::new() $moveFailed = $false # --------------------------------------------------------------------------------- # 6a. Benutzerdatenbank: OFFLINE -> kopieren -> MODIFY FILE -> ONLINE # --------------------------------------------------------------------------------- if ($dbKind -eq 'User') { $wasOffline = ("$($dbInfo.StateDesc)" -eq 'OFFLINE') if (-not $wasOffline) { try { $state = Set-DbaDbState @connParams -Database $Database -Offline -Force:$Force -Confirm:$false -EnableException if ("$($state.Status)" -notmatch '^(?i)offline$') { throw "Set-DbaDbState meldete Status '$($state.Status)'." } Invoke-sqmLogging -Message "Datenbank '$Database' ist offline." -FunctionName $functionName -Level "INFO" _AddResult 'Move' $null $null $null $null 'Success' "Datenbank '$Database' ist offline." } catch { $msg = "Datenbank '$Database' konnte nicht offline gesetzt werden: $($_.Exception.Message). Ohne -Force bleiben offene Verbindungen bestehen und verhindern das Offline-Setzen." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Move' $null $null $null $null 'Failed' $msg if ($EnableException) { throw } return } } foreach ($p in $plan) { try { $srcWin = _ToWindowsPath $p.OldPath $dstWin = _ToWindowsPath $p.NewPath Copy-Item -LiteralPath $srcWin -Destination $dstWin -Force -ErrorAction Stop $srcLen = (Get-Item -LiteralPath $srcWin -ErrorAction Stop).Length $dstLen = (Get-Item -LiteralPath $dstWin -ErrorAction Stop).Length if ($srcLen -ne $dstLen) { throw "Groesse weicht ab (Quelle $srcLen Byte, Ziel $dstLen Byte)." } $alter = "ALTER DATABASE [$Database] MODIFY FILE (NAME = N'$($p.LogicalName -replace "'", "''")', FILENAME = N'$($p.NewPath -replace "'", "''")');" $null = Invoke-DbaQuery @connParams -Database master -Query $alter -EnableException $appliedAlter.Add($p) $movedFiles.Add($p) Invoke-sqmLogging -Message "'$($p.LogicalName)': '$($p.OldPath)' -> '$($p.NewPath)'" -FunctionName $functionName -Level "INFO" _AddResult 'Move' $p.LogicalName $p.OldPath $p.NewPath $p.SizeMB 'Success' "Kopiert und umgetragen." } catch { $msg = "'$($p.LogicalName)' konnte nicht verschoben werden: $($_.Exception.Message)" Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Move' $p.LogicalName $p.OldPath $p.NewPath $p.SizeMB 'Failed' $msg $moveFailed = $true break } } # Bei einem Fehler mitten im Vorgang: bereits umgetragene Dateien zuruecksetzen, # damit die Datenbank wieder mit den alten Pfaden online gehen kann. if ($moveFailed -and $appliedAlter.Count -gt 0) { foreach ($p in $appliedAlter) { try { $revert = "ALTER DATABASE [$Database] MODIFY FILE (NAME = N'$($p.LogicalName -replace "'", "''")', FILENAME = N'$($p.OldPath -replace "'", "''")');" $null = Invoke-DbaQuery @connParams -Database master -Query $revert -EnableException _AddResult 'Move' $p.LogicalName $p.NewPath $p.OldPath $p.SizeMB 'RolledBack' "Pfad auf den alten Ort zurueckgesetzt." } catch { _AddResult 'Move' $p.LogicalName $p.NewPath $p.OldPath $p.SizeMB 'Failed' "Ruecksetzen des Pfades fehlgeschlagen: $($_.Exception.Message)" } } $movedFiles.Clear() } if (-not $wasOffline) { try { $null = Set-DbaDbState @connParams -Database $Database -Online -Confirm:$false -EnableException Invoke-sqmLogging -Message "Datenbank '$Database' ist wieder online." -FunctionName $functionName -Level "INFO" _AddResult 'Move' $null $null $null $null 'Success' "Datenbank '$Database' ist wieder online." } catch { $msg = "Datenbank '$Database' konnte nicht online gesetzt werden: $($_.Exception.Message). Alte Dateien werden nicht entfernt." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Move' $null $null $null $null 'Failed' $msg $movedFiles.Clear() if ($EnableException) { throw } } } } # --------------------------------------------------------------------------------- # 6b. Systemdatenbank: MODIFY FILE -> Dienstaktion -> (model/msdb) kopieren # --------------------------------------------------------------------------------- else { foreach ($p in $plan) { try { $alter = "ALTER DATABASE [$Database] MODIFY FILE (NAME = N'$($p.LogicalName -replace "'", "''")', FILENAME = N'$($p.NewPath -replace "'", "''")');" $null = Invoke-DbaQuery @connParams -Database master -Query $alter -EnableException $appliedAlter.Add($p) Invoke-sqmLogging -Message "'$($p.LogicalName)' umgetragen: '$($p.OldPath)' -> '$($p.NewPath)' (wirkt mit dem Neustart)." -FunctionName $functionName -Level "INFO" _AddResult 'Move' $p.LogicalName $p.OldPath $p.NewPath $p.SizeMB 'Success' "Pfad umgetragen, wird mit dem Neustart wirksam." } catch { $msg = "'$($p.LogicalName)' konnte nicht umgetragen werden: $($_.Exception.Message)" Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Move' $p.LogicalName $p.OldPath $p.NewPath $p.SizeMB 'Failed' $msg $moveFailed = $true break } } if ($moveFailed -and $appliedAlter.Count -gt 0) { foreach ($p in $appliedAlter) { try { $revert = "ALTER DATABASE [$Database] MODIFY FILE (NAME = N'$($p.LogicalName -replace "'", "''")', FILENAME = N'$($p.OldPath -replace "'", "''")');" $null = Invoke-DbaQuery @connParams -Database master -Query $revert -EnableException _AddResult 'Move' $p.LogicalName $p.NewPath $p.OldPath $p.SizeMB 'RolledBack' "Pfad auf den alten Ort zurueckgesetzt, ein Neustart ist nicht noetig." } catch { _AddResult 'Move' $p.LogicalName $p.NewPath $p.OldPath $p.SizeMB 'Failed' "Ruecksetzen des Pfades fehlgeschlagen: $($_.Exception.Message). ACHTUNG: vor dem naechsten Neustart pruefen." } } $appliedAlter.Clear() } elseif ($NoRestart) { $msg = "Pfade sind umgetragen, die Instanz wurde auf Wunsch (-NoRestart) NICHT neu gestartet. Die neuen tempdb-Dateien entstehen beim naechsten Start; die alten bleiben bis dahin liegen und wurden nicht entfernt." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "WARNING" _AddResult 'Restart' $null $null $null $null 'PendingRestart' $msg } else { $restartOk = $false try { if ($Database -eq 'tempdb') { # tempdb wird beim Start neu angelegt: ein Neustart genuegt, es wird # nichts kopiert. -Force nimmt die abhaengigen Dienste mit. $null = Restart-DbaService @svcParams -Type Engine -Force -Confirm:$false -EnableException } else { # model/msdb: die Dateien muessen bei gestopptem Dienst physisch an den # neuen Ort, sonst findet die Instanz sie beim Start nicht. $null = Stop-DbaService @svcParams -Type Engine -Force -Confirm:$false -EnableException foreach ($p in $appliedAlter) { Copy-Item -LiteralPath (_ToWindowsPath $p.OldPath) -Destination (_ToWindowsPath $p.NewPath) -Force -ErrorAction Stop Invoke-sqmLogging -Message "'$($p.LogicalName)' kopiert nach '$($p.NewPath)'." -FunctionName $functionName -Level "INFO" } $null = Start-DbaService @svcParams -Type Engine -Confirm:$false -EnableException } $restartOk = $true } catch { $msg = "Dienstaktion fehlgeschlagen: $($_.Exception.Message). Die Pfade sind bereits umgetragen - die Instanz muss manuell in einen konsistenten Zustand gebracht werden. Ist-Stand pruefen mit: SELECT name, physical_name FROM sys.master_files WHERE database_id = DB_ID('$Database')." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Restart' $null $null $null $null 'Failed' $msg if ($EnableException) { throw } } if ($restartOk) { if (_WaitForInstance $RestartTimeoutSeconds) { Invoke-sqmLogging -Message "Instanz '$SqlInstance' ist nach der Dienstaktion wieder erreichbar." -FunctionName $functionName -Level "INFO" _AddResult 'Restart' $null $null $null $null 'Success' "Instanz nach der Dienstaktion wieder erreichbar." # Erst jetzt gilt eine Datei als verschoben: der neue Pfad muss da sein. foreach ($p in $appliedAlter) { $exists = $false try { $exists = [bool](Test-DbaPath @connParams -Path $p.NewPath) } catch { $exists = $false } if ($exists) { $movedFiles.Add($p) } else { $msg = "Datei '$($p.NewPath)' ist nach dem Neustart nicht vorhanden. Die alte Datei wird nicht entfernt." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "WARNING" _AddResult 'Move' $p.LogicalName $p.OldPath $p.NewPath $p.SizeMB 'Failed' $msg } } } else { $msg = "Instanz '$SqlInstance' war nach $RestartTimeoutSeconds Sekunden nicht erreichbar. Alte Dateien werden NICHT entfernt. Pruefen Sie das SQL-Fehlerprotokoll: ein nicht erreichbarer tempdb-Pfad verhindert den Start." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" _AddResult 'Restart' $null $null $null $null 'Failed' $msg if ($EnableException) { throw $msg } } } # SQL Agent wieder in den Zustand von vorher bringen if ($agentStateBefore -eq 'Running') { if ((_GetAgentState) -ne 'Running') { try { $null = Start-DbaService @svcParams -Type Agent -Confirm:$false -EnableException _AddResult 'Restart' $null $null $null $null 'Success' "SQL Server Agent wieder gestartet (lief vor der Aktion)." } catch { $msg = "SQL Server Agent lief vor der Aktion, konnte aber nicht gestartet werden: $($_.Exception.Message)" Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "WARNING" _AddResult 'Restart' $null $null $null $null 'Warning' $msg } } } } } # --------------------------------------------------------------------------------- # 7. Alte Dateien entfernen - nur fuer nachweislich verschobene Dateien # --------------------------------------------------------------------------------- if ($movedFiles.Count -eq 0) { return } if ($KeepOldFiles) { foreach ($p in $movedFiles) { _AddResult 'Cleanup' $p.LogicalName $p.OldPath $p.NewPath $p.SizeMB 'Skipped' "Alte Datei bleibt liegen (-KeepOldFiles)." } return } foreach ($p in $movedFiles) { $oldWin = _ToWindowsPath $p.OldPath try { if (Test-Path -LiteralPath $oldWin) { Remove-Item -LiteralPath $oldWin -Force -ErrorAction Stop Invoke-sqmLogging -Message "Alte Datei entfernt: '$($p.OldPath)'" -FunctionName $functionName -Level "INFO" _AddResult 'Cleanup' $p.LogicalName $p.OldPath $p.NewPath $p.SizeMB 'Success' "Alte Datei entfernt." } else { _AddResult 'Cleanup' $p.LogicalName $p.OldPath $p.NewPath $p.SizeMB 'Skipped' "Alte Datei war nicht mehr vorhanden." } } catch { $msg = "Alte Datei '$($p.OldPath)' konnte nicht entfernt werden: $($_.Exception.Message)" Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "WARNING" _AddResult 'Cleanup' $p.LogicalName $p.OldPath $p.NewPath $p.SizeMB 'Failed' $msg } } } catch { $errMsg = "Allgemeiner Fehler in $functionName : $($_.Exception.Message)" Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR" if ($EnableException) { throw } _AddResult 'Move' $null $null $null $null 'GlobalError' $errMsg } } end { Invoke-sqmLogging -Message "$functionName abgeschlossen. $($results.Count) Ergebniszeilen." -FunctionName $functionName -Level "INFO" return $results } } |