Public/Invoke-sqmSsrsMigration.ps1
|
<#
.SYNOPSIS Migrates Reporting Services content (report projects: folders, shared data sources, shared datasets, reports, resources) from one report server to another, including the data source bindings that a plain RDL copy loses. .DESCRIPTION Copying the .rdl files is not a migration. A report on the server is bound to its shared data sources and shared datasets by the catalog, not by the text in the RDL: Visual Studio deploys with TargetDataSourceFolder, and anyone can re-point a report in the web portal afterwards. Re-uploading only the RDL lands reports on the new server that either point nowhere ("The report server cannot process the report. The data source connection information has been deleted") or still point at a path that only existed on the old server. Stored data source passwords cannot be read through any API, so they are lost silently as well. This function works through the SSRS REST API v2.0 (SSRS 2017 and later, Power BI Report Server) and therefore runs under Windows PowerShell 5.1 and PowerShell 7 alike: 1. Connects to both servers and reads the version. 2. Inventories every -SourceFolder recursively: folders, shared data sources (with their definition), shared datasets, reports (with their data source bindings and shared dataset references), linked reports, resources, Power BI reports and Excel workbooks. 3. Resolves dependencies OUTSIDE the migrated folders - typically "/Data Sources" and "/Datasets" - and takes them along at their original path. -SkipDependencies turns that off. 4. Plans every item against the destination: Create, Overwrite or Skip (exists). Data sources that store a password are flagged unless -DataSourceCredential supplies it. 5. Exports the source content to disk (Source\... below the report folder) and backs up every destination item before it is overwritten (DestinationBackup\...). 6. Writes in dependency order: folders, data sources, datasets, resources, reports, linked reports, Power BI reports / workbooks. Absolute <DataSourceReference> and <SharedDataSetReference> paths in RDL/RSD content are rewritten when -DestinationFolder moves the content to a different path. 7. Re-applies the data source bindings of every report and dataset from the SOURCE catalog (not from the RDL text), with paths mapped to the destination, plus connection string replacements (-ConnectionStringMap) and stored passwords. 8. Optionally copies item security (-IncludeSecurity) for every item that does not inherit its parent's policy. 9. Verifies the bindings by reading them back from the destination. 10. Lists subscriptions on the migrated reports. They are NOT copied: they carry the owner, schedule and delivery settings of the old server and must be recreated deliberately. Nothing on the source is changed. -AssessOnly stops after step 4 and changes nothing on the destination either. .PARAMETER SourceReportServer Source report server. Accepted forms: "SRV", "https://srv:8443", "http://srv/ReportServer", "http://srv/Reports", "http://srv/ReportServer_INST" (named instance) or the full REST base ".../api/v2.0" for custom virtual directories. .PARAMETER SourceCredential Windows credential for the source (NTLM/Kerberos). Default: the current user. .PARAMETER DestinationReportServer Destination report server, same forms as -SourceReportServer. May be the same server as the source when -DestinationFolder differs from -SourceFolder (copying a project within a server). .PARAMETER DestinationCredential Windows credential for the destination. Default: the current user. .PARAMETER SourceFolder Folder(s) to migrate, recursively. Default "/" (the entire catalog). Paths start with "/". .PARAMETER DestinationFolder Destination path that replaces -SourceFolder (e.g. "/Finance" -> "/Archive/Finance"). Only allowed with a single -SourceFolder. Default: same path as on the source. .PARAMETER ItemType Item types to migrate. Default: all supported types. Folders are always created as needed. .PARAMETER ConnectionStringMap Text replacements for connection strings of shared and embedded data sources, as "OLD=>NEW" entries, case-insensitive, applied in order. Example: 'SQLOLD01=>SQLNEW01'. .PARAMETER DataSourceCredential Credentials for data sources that store a user name and password on the server ("Credentials stored securely in the report server"). The password cannot be read from the source; it is matched to the data sources by the stored user name. One PSCredential per stored user. .PARAMETER Overwrite Overwrite existing reports, datasets, resources, linked reports and Power BI items on the destination (the item keeps its ID, so destination subscriptions and history stay attached). Without it, existing items are skipped. .PARAMETER OverwriteDataSources Also overwrite existing shared data sources on the destination. Separate from -Overwrite because a data source that already exists on the destination usually carries the correct connection and password for that environment. .PARAMETER IncludeSecurity Copy item-level security (role assignments) for every folder and item that breaks inheritance. The users/groups and the role names must exist on the destination. .PARAMETER SkipDependencies Do not take along shared data sources/datasets outside -SourceFolder that the migrated reports reference. .PARAMETER SkipCertificateCheck Accept untrusted HTTPS certificates (self-signed, internal CA not trusted on this machine). .PARAMETER AssessOnly Only inventory and plan. Changes nothing on the destination. .PARAMETER OutputPath Directory for the HTML report and the content export. Default: <OutputPath config>\SsrsMigration. .PARAMETER NoOpen Do not open the report after the run. .PARAMETER NoReport Do not write the HTML report (the content export is still written). .PARAMETER EnableException Throw exceptions immediately instead of returning a result object with Status 'Failed'. .EXAMPLE Invoke-sqmSsrsMigration -SourceReportServer SSRSOLD -DestinationReportServer SSRSNEW -SourceFolder '/Finance' -AssessOnly The planning run: what is in the project, what it references outside the folder, what exists on the destination already, and which data sources need a password. .EXAMPLE $cred = Get-Credential rpt_reader Invoke-sqmSsrsMigration -SourceReportServer SSRSOLD -DestinationReportServer SSRSNEW ` -SourceFolder '/Finance' -ConnectionStringMap 'SQLOLD01=>SQLNEW01' -DataSourceCredential $cred ` -IncludeSecurity -Confirm:$false The migration, with the data sources re-pointed to the new database server. .EXAMPLE Invoke-sqmSsrsMigration -SourceReportServer SSRS01 -DestinationReportServer SSRS01 ` -SourceFolder '/Finance' -DestinationFolder '/Test/Finance' -Overwrite Copies a project within one server to a different folder; references inside the project follow the copy. .NOTES Requires SSRS 2017 or later (REST API v2.0) or Power BI Report Server on both sides, and the Content Manager role (or System Administrator for -IncludeSecurity) on the affected folders. Not migrated: subscriptions (listed), cache/history/snapshot settings, shared schedules, report parameters set on the server, KPIs and mobile reports. Paginated reports keep their report-server-side parameter defaults only when they are part of the RDL. .LINK Install-sqmSsrsReportServer Set-sqmSsrsConfiguration #> function Invoke-sqmSsrsMigration { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $true)] [string]$SourceReportServer, [Parameter(Mandatory = $false)] [System.Management.Automation.PSCredential]$SourceCredential, [Parameter(Mandatory = $true)] [string]$DestinationReportServer, [Parameter(Mandatory = $false)] [System.Management.Automation.PSCredential]$DestinationCredential, [Parameter(Mandatory = $false)] [string[]]$SourceFolder = @('/'), [Parameter(Mandatory = $false)] [string]$DestinationFolder, [Parameter(Mandatory = $false)] [ValidateSet('DataSource', 'DataSet', 'Report', 'LinkedReport', 'Resource', 'PowerBIReport', 'ExcelWorkbook')] [string[]]$ItemType = @('DataSource', 'DataSet', 'Report', 'LinkedReport', 'Resource', 'PowerBIReport', 'ExcelWorkbook'), [Parameter(Mandatory = $false)] [string[]]$ConnectionStringMap, [Parameter(Mandatory = $false)] [System.Management.Automation.PSCredential[]]$DataSourceCredential, [Parameter(Mandatory = $false)] [switch]$Overwrite, [Parameter(Mandatory = $false)] [switch]$OverwriteDataSources, [Parameter(Mandatory = $false)] [switch]$IncludeSecurity, [Parameter(Mandatory = $false)] [switch]$SkipDependencies, [Parameter(Mandatory = $false)] [switch]$SkipCertificateCheck, [Parameter(Mandatory = $false)] [switch]$AssessOnly, [Parameter(Mandatory = $false)] [string]$OutputPath = (Join-Path (Get-sqmDefaultOutputPath) 'SsrsMigration'), [Parameter(Mandatory = $false)] [switch]$NoOpen, [Parameter(Mandatory = $false)] [switch]$NoReport, [Parameter(Mandatory = $false)] [switch]$EnableException ) begin { $functionName = $MyInvocation.MyCommand.Name $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $steps = [System.Collections.Generic.List[PSCustomObject]]::new() $addStep = { param ([string]$Step, [string]$Status, [string]$Detail) $steps.Add([PSCustomObject]@{ Step = $Step Status = $Status Detail = $Detail Timestamp = Get-Date }) $level = 'INFO' if ($Status -eq 'Failed') { $level = 'ERROR' } elseif ($Status -eq 'Blocked' -or $Status -eq 'Warning') { $level = 'WARNING' } Invoke-sqmLogging -Message "[$Status] $Step - $Detail" -FunctionName $functionName -Level $level } $toPlain = { param ([System.Security.SecureString]$Secure) if (-not $Secure) { return '' } $ptr = [System.IntPtr]::Zero try { $ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) return [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($ptr) } finally { if ($ptr -ne [System.IntPtr]::Zero) { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } } } # Ordnerpfade normalisieren: fuehrender '/', kein abschliessender. Die Wurzel wird intern # als Leerstring gefuehrt, damit "liegt darunter" und "Pfad abbilden" ohne Sonderfall gehen. $normalizeFolder = { param ([string]$Path) $p = ('/' + $Path.Trim().Trim('/')).TrimEnd('/') return $p } $htmlEncode = { param ($Value) if ($null -eq $Value) { return '' } return ([string]$Value -replace '&', '&' -replace '<', '<' -replace '>', '>') } $buildReportBody = { param ($Result, $StepList) $sb = [System.Text.StringBuilder]::new() $null = $sb.AppendLine("<h2>Ergebnis</h2><table><tr><th>Feld</th><th>Wert</th></tr>") foreach ($pair in @( @('Quelle', $Result.SourceApi), @('Ziel', $Result.DestinationApi), @('Version Quelle', $Result.SourceVersion), @('Version Ziel', $Result.DestinationVersion), @('Ordner', ($Result.SourceFolder -join ', ')), @('Zielordner', $Result.DestinationFolder), @('Aktion', $Result.Action), @('Status', $Result.Status), @('Begruendung', $Result.Reason), @('Elemente', $Result.ItemCount), @('Export', $Result.ExportPath))) { $null = $sb.AppendLine("<tr><td>$(& $htmlEncode $pair[0])</td><td>$(& $htmlEncode $pair[1])</td></tr>") } $null = $sb.AppendLine("</table>") $null = $sb.AppendLine("<h2>Elemente</h2><table><tr><th>Typ</th><th>Quelle</th><th>Ziel</th><th>Plan</th><th>Status</th><th>Detail</th></tr>") foreach ($i in $Result.Items) { $cls = 'ok' if ($i.Status -eq 'Failed') { $cls = 'crit' } elseif ($i.Status -in @('Warning', 'Skipped', 'NotSupported', 'Planned', 'WhatIf')) { $cls = 'warn' } $null = $sb.AppendLine("<tr><td>$(& $htmlEncode $i.Type)</td><td>$(& $htmlEncode $i.SourcePath)</td><td>$(& $htmlEncode $i.DestinationPath)</td><td>$(& $htmlEncode $i.Action)</td><td class='$cls'>$(& $htmlEncode $i.Status)</td><td>$(& $htmlEncode $i.Detail)</td></tr>") } $null = $sb.AppendLine("</table>") $subs = @($Result.Subscriptions) if ($subs.Count -gt 0) { $null = $sb.AppendLine("<h2>Abonnements (nicht migriert, auf dem Ziel neu anlegen)</h2>") $null = $sb.AppendLine("<table><tr><th>Bericht</th><th>Beschreibung</th><th>Besitzer</th><th>Zustellung</th><th>Datengesteuert</th><th>Letzter Status</th></tr>") foreach ($s in $subs) { $null = $sb.AppendLine("<tr><td>$(& $htmlEncode $s.Report)</td><td>$(& $htmlEncode $s.Description)</td><td>$(& $htmlEncode $s.Owner)</td><td>$(& $htmlEncode $s.DeliveryExtension)</td><td>$(& $htmlEncode $s.IsDataDriven)</td><td>$(& $htmlEncode $s.LastStatus)</td></tr>") } $null = $sb.AppendLine("</table>") } $null = $sb.AppendLine("<h2>Schritte</h2><table><tr><th>Zeit</th><th>Schritt</th><th>Status</th><th>Detail</th></tr>") foreach ($s in $StepList) { $cls = 'ok' if ($s.Status -eq 'Failed') { $cls = 'crit' } elseif ($s.Status -in @('Blocked', 'Warning', 'WhatIf')) { $cls = 'warn' } $null = $sb.AppendLine("<tr><td>$($s.Timestamp.ToString('HH:mm:ss'))</td><td>$(& $htmlEncode $s.Step)</td><td class='$cls'>$(& $htmlEncode $s.Status)</td><td>$(& $htmlEncode $s.Detail)</td></tr>") } $null = $sb.AppendLine("</table>") return $sb.ToString() } } process { $srcApi = ConvertTo-sqmSsrsApiBase -ReportServer $SourceReportServer $dstApi = ConvertTo-sqmSsrsApiBase -ReportServer $DestinationReportServer $roots = @($SourceFolder | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { & $normalizeFolder $_ } | Select-Object -Unique) if ($roots.Count -eq 0) { $roots = @('') } $destRoot = $null if ($PSBoundParameters.ContainsKey('DestinationFolder')) { $destRoot = & $normalizeFolder $DestinationFolder } $items = [System.Collections.Generic.List[PSCustomObject]]::new() $subscriptions = [System.Collections.Generic.List[PSCustomObject]]::new() $result = [PSCustomObject]@{ SourceApi = $srcApi DestinationApi = $dstApi SourceVersion = $null DestinationVersion = $null SourceFolder = @($roots | ForEach-Object { if ($_ -eq '') { '/' } else { $_ } }) DestinationFolder = $DestinationFolder Action = 'Migrate' Status = 'Unknown' Reason = '' ItemCount = 0 Items = $items Subscriptions = $subscriptions ExportPath = $null Steps = $steps ReportFile = $null Timestamp = Get-Date } if ($AssessOnly) { $result.Action = 'Assess' } $tempDir = $null try { # ============================================================== # 0. Parameter, die sich gegenseitig ausschliessen # ============================================================== if ($null -ne $destRoot -and $roots.Count -gt 1) { throw "-DestinationFolder ist nur mit genau einem -SourceFolder moeglich (angegeben: $($roots.Count))." } $sameServer = ($srcApi.TrimEnd('/') -ieq $dstApi.TrimEnd('/')) if ($sameServer -and ($null -eq $destRoot -or $roots -contains $destRoot)) { throw "Quelle und Ziel sind derselbe Report Server und derselbe Ordner. Fuer eine Kopie innerhalb des Servers -DestinationFolder angeben." } if ($sameServer) { foreach ($r in $roots) { if ($destRoot.StartsWith("$r/", [System.StringComparison]::OrdinalIgnoreCase) -or $r -eq '') { throw "-DestinationFolder '$destRoot' liegt innerhalb des Quellordners '$(if ($r) { $r } else { '/' })'. Die Kopie wuerde sich selbst erneut erfassen." } } } $csMap = [System.Collections.Generic.List[object]]::new() foreach ($entry in @($ConnectionStringMap)) { if ([string]::IsNullOrWhiteSpace($entry)) { continue } $idx = $entry.IndexOf('=>') if ($idx -lt 1) { throw "-ConnectionStringMap: Eintrag '$entry' hat nicht das Format 'ALT=>NEU'." } $csMap.Add(@($entry.Substring(0, $idx).Trim(), $entry.Substring($idx + 2).Trim())) } $srcCall = { param ([string]$Rel, [string]$Method = 'GET', $Body = $null, [string]$OutFile) $a = @{ Uri = "$srcApi/$Rel"; Method = $Method; SkipCertificateCheck = $SkipCertificateCheck } if ($SourceCredential) { $a['Credential'] = $SourceCredential } if ($null -ne $Body) { $a['Body'] = $Body } if ($OutFile) { $a['OutFile'] = $OutFile } Invoke-sqmSsrsRest @a } $dstCall = { param ([string]$Rel, [string]$Method = 'GET', $Body = $null, [string]$OutFile) $a = @{ Uri = "$dstApi/$Rel"; Method = $Method; SkipCertificateCheck = $SkipCertificateCheck } if ($DestinationCredential) { $a['Credential'] = $DestinationCredential } if ($null -ne $Body) { $a['Body'] = $Body } if ($OutFile) { $a['OutFile'] = $OutFile } Invoke-sqmSsrsRest @a } # Liegt ein Quellpfad in einem der migrierten Ordner? Liefert die Wurzel oder $null. $rootOf = { param ([string]$Path) foreach ($r in $roots) { if ($r -eq '' -or $Path -ieq $r -or $Path.StartsWith("$r/", [System.StringComparison]::OrdinalIgnoreCase)) { return $r } } return $null } # Quellpfad -> Zielpfad. Abhaengigkeiten ausserhalb der Ordner behalten ihren Pfad. $mapPath = { param ([string]$Path) if ($null -eq $destRoot) { return $Path } $r = & $rootOf $Path if ($null -eq $r) { return $Path } $mapped = $destRoot + $Path.Substring($r.Length) if ($mapped -eq '') { return '/' } return $mapped } $parentOf = { param ([string]$Path) $i = $Path.LastIndexOf('/') if ($i -le 0) { return '/' } return $Path.Substring(0, $i) } $mapConnection = { param ([string]$ConnectionString) if ([string]::IsNullOrEmpty($ConnectionString)) { return $ConnectionString } $cs = $ConnectionString foreach ($m in $csMap) { $cs = [regex]::Replace($cs, [regex]::Escape($m[0]), $m[1].Replace('$', '$$'), [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) } return $cs } # Gespeichertes Kennwort zu einem Benutzernamen; "DOM\user" passt auch auf "user". $findSecret = { param ([string]$UserName) if ([string]::IsNullOrEmpty($UserName)) { return $null } $short = ($UserName -split '\\')[-1] foreach ($c in @($DataSourceCredential)) { if (-not $c) { continue } $cu = $c.UserName if ($cu -ieq $UserName -or ($cu -split '\\')[-1] -ieq $short) { return $c } } return $null } $getProp = { param ($Object, [string]$Name) if ($null -eq $Object) { return $null } $p = $Object.PSObject.Properties[$Name] if ($p) { return $p.Value } return $null } # Windows-taugliche Dateipfade fuer den Export; SSRS erlaubt Zeichen, die NTFS nicht mag. $localPath = { param ([string]$BaseDir, [string]$CatalogPath, [string]$Extension) $invalid = [System.IO.Path]::GetInvalidFileNameChars() $segments = @($CatalogPath.Trim('/') -split '/' | ForEach-Object { $s = $_ foreach ($ch in $invalid) { $s = $s.Replace([string]$ch, '_') } $s }) $file = Join-Path $BaseDir ($segments -join '\') if ($Extension -and -not $file.EndsWith($Extension, [System.StringComparison]::OrdinalIgnoreCase)) { $file += $Extension } $dir = Split-Path $file -Parent if (-not (Test-Path -LiteralPath $dir)) { $null = New-Item -ItemType Directory -Path $dir -Force -WhatIf:$false } return $file } # Typ-spezifische Endpunkte. Anlegen ueber die Typ-Collection, nicht ueber CatalogItems: # verknuepfte Berichte gehen nur ueber /LinkedReports. $endpointFor = @{ DataSource = 'DataSources' DataSet = 'DataSets' Report = 'Reports' LinkedReport = 'LinkedReports' Resource = 'Resources' PowerBIReport = 'PowerBIReports' ExcelWorkbook = 'ExcelWorkbooks' } $extensionFor = @{ Report = '.rdl' DataSet = '.rsd' PowerBIReport = '.pbix' ExcelWorkbook = '.xlsx' Resource = '' } # ============================================================== # 1. Verbindung zu beiden Servern # ============================================================== foreach ($side in @(@('Quelle', $srcCall, 'SourceVersion'), @('Ziel', $dstCall, 'DestinationVersion'))) { try { $sys = & $side[1] 'System' $ver = & $getProp $sys 'ProductVersion' $prod = & $getProp $sys 'ProductName' $result.($side[2]) = (("$prod $ver").Trim()) & $addStep "Verbindung $($side[0])" 'Success' $result.($side[2]) } catch { $hint = '' $code = $_.Exception.Data['StatusCode'] if ($code -eq 401) { $hint = ' Anmeldung abgelehnt: -SourceCredential/-DestinationCredential pruefen (Windows-Konto, kein SQL-Login).' } elseif ($code -eq 404) { $hint = ' REST-API nicht gefunden: SSRS 2017 oder neuer noetig; bei eigenem virtuellem Verzeichnis die volle .../api/v2.0-URL angeben.' } throw "Report Server ($($side[0])) nicht erreichbar: $($_.Exception.Message)$hint" } } # ============================================================== # 2. Inventar der Quelle # ============================================================== $sourceById = @{} $sourceByPath = @{} $folders = [System.Collections.Generic.List[object]]::new() $registerItem = { param ($Item, [bool]$IsDependency) $path = [string]$Item.Path if ($sourceByPath.ContainsKey($path)) { return $sourceByPath[$path] } $entry = [PSCustomObject]@{ Id = [string]$Item.Id Name = [string]$Item.Name Path = $path Type = [string]$Item.Type Description = (& $getProp $Item 'Description') Hidden = [bool](& $getProp $Item 'Hidden') ContentType = (& $getProp $Item 'ContentType') IsDependency = $IsDependency Definition = $null DataSources = @() SharedDataSets = @() DataSetSourceRefs = @() Link = $null Policy = $null LocalFile = $null } $sourceByPath[$path] = $entry $sourceById[$entry.Id] = $entry return $entry } $rootFolders = @{} foreach ($r in $roots) { $rp = $r if ($rp -eq '') { $rp = '/' } try { $rf = & $srcCall "Folders$(ConvertTo-sqmSsrsPathKey $rp)" $rootFolders[$r] = $rf } catch { if ($_.Exception.Data['StatusCode'] -eq 404) { throw "Quellordner '$rp' existiert auf $srcApi nicht." } throw } $queue = [System.Collections.Generic.Queue[string]]::new() $queue.Enqueue($rp) while ($queue.Count -gt 0) { $current = $queue.Dequeue() $children = @((& $srcCall "Folders$(ConvertTo-sqmSsrsPathKey $current)/CatalogItems").value) foreach ($c in $children) { if ([string]$c.Type -eq 'Folder') { $folders.Add($c) $queue.Enqueue([string]$c.Path) } else { $null = & $registerItem $c $false } } } } $byType = @($sourceByPath.Values | Group-Object Type | ForEach-Object { "$($_.Count) $($_.Name)" }) -join ', ' & $addStep 'Inventar Quelle' 'Success' "$($folders.Count) Ordner; $byType" # ============================================================== # 3. Details und Referenzen # ============================================================== $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "sqmSsrsMig_$timestamp" $null = New-Item -ItemType Directory -Path $tempDir -Force -WhatIf:$false $pendingDeps = [System.Collections.Generic.Queue[string]]::new() $missingRefs = [System.Collections.Generic.List[string]]::new() $noteRef = { param ([string]$RefPath, [string]$From) if ([string]::IsNullOrEmpty($RefPath)) { return } if ($sourceByPath.ContainsKey($RefPath)) { return } if ($null -ne (& $rootOf $RefPath)) { $missingRefs.Add("$From -> $RefPath"); return } if (-not $SkipDependencies) { $pendingDeps.Enqueue("$RefPath|$From") } } # Absolute Referenzpfade in RDL/RSD; relative bleiben relativ zum Ordner des Elements. $resolveRef = { param ([string]$Ref, [string]$ItemPath) if ([string]::IsNullOrWhiteSpace($Ref)) { return $null } if ($Ref.StartsWith('/')) { return $Ref } $base = & $parentOf $ItemPath $parts = [System.Collections.Generic.List[string]]::new() foreach ($seg in @(($base.Trim('/') -split '/') + ($Ref -split '/'))) { if ($seg -eq '' -or $seg -eq '.') { continue } if ($seg -eq '..') { if ($parts.Count -gt 0) { $parts.RemoveAt($parts.Count - 1) }; continue } $parts.Add($seg) } return '/' + ($parts -join '/') } $loadDetails = { param ($Entry) switch ($Entry.Type) { 'DataSource' { $Entry.Definition = & $srcCall "DataSources($($Entry.Id))" } 'Report' { $Entry.DataSources = @((& $srcCall "Reports($($Entry.Id))/DataSources").value) foreach ($ds in $Entry.DataSources) { if ((& $getProp $ds 'IsReference') -eq $true) { & $noteRef ([string](& $getProp $ds 'Path')) $Entry.Path } } try { $Entry.SharedDataSets = @((& $srcCall "Reports($($Entry.Id))/SharedDataSets").value) foreach ($sd in $Entry.SharedDataSets) { & $noteRef ([string]$sd.Path) $Entry.Path } } catch { & $addStep "Datasets lesen: $($Entry.Path)" 'Warning' $_.Exception.Message } } 'DataSet' { $file = Join-Path $tempDir "$($Entry.Id).rsd" & $srcCall "CatalogItems($($Entry.Id))/Content/`$value" -OutFile $file $text = [System.IO.File]::ReadAllText($file) $refs = @([regex]::Matches($text, '<DataSourceReference>([^<]*)</DataSourceReference>') | ForEach-Object { & $resolveRef ([System.Net.WebUtility]::HtmlDecode($_.Groups[1].Value)) $Entry.Path }) # Die Bindung im Katalog hat Vorrang vor dem Text in der RSD. try { $bound = @((& $srcCall "DataSets($($Entry.Id))/DataSources").value) $boundRefs = @($bound | Where-Object { (& $getProp $_ 'IsReference') -eq $true } | ForEach-Object { [string](& $getProp $_ 'Path') } | Where-Object { $_ }) if ($boundRefs.Count -gt 0) { $refs = $boundRefs } $Entry.DataSources = $bound } catch { } $Entry.DataSetSourceRefs = @($refs | Where-Object { $_ }) foreach ($ref in $Entry.DataSetSourceRefs) { & $noteRef $ref $Entry.Path } } 'LinkedReport' { $lr = & $srcCall "LinkedReports($($Entry.Id))" $Entry.Link = [string](& $getProp $lr 'Link') & $noteRef $Entry.Link $Entry.Path } } } foreach ($e in @($sourceByPath.Values)) { if ($ItemType -notcontains $e.Type -and $e.Type -in @('DataSource', 'DataSet', 'Report', 'LinkedReport', 'Resource', 'PowerBIReport', 'ExcelWorkbook')) { continue } try { & $loadDetails $e } catch { & $addStep "Details: $($e.Path)" 'Failed' $_.Exception.Message } } # Abhaengigkeiten ausserhalb der Ordner nachladen (koennen selbst wieder referenzieren). $depCount = 0 while ($pendingDeps.Count -gt 0) { $pair = $pendingDeps.Dequeue() -split '\|', 2 $depPath = $pair[0] if ($sourceByPath.ContainsKey($depPath)) { continue } try { $dep = & $srcCall "CatalogItems$(ConvertTo-sqmSsrsPathKey $depPath)" $entry = & $registerItem $dep $true $depCount++ & $loadDetails $entry } catch { if ($_.Exception.Data['StatusCode'] -eq 404) { $missingRefs.Add("$($pair[1]) -> $depPath") } else { & $addStep "Abhaengigkeit: $depPath" 'Failed' $_.Exception.Message } } } if ($depCount -gt 0) { & $addStep 'Abhaengigkeiten' 'Success' "$depCount Element(e) ausserhalb der Quellordner werden am gleichen Pfad mitgenommen." } foreach ($m in $missingRefs) { & $addStep 'Referenz ins Leere' 'Warning' "$m - existiert schon auf der Quelle nicht; der Bericht laeuft auch dort nicht." } if ($IncludeSecurity) { $secTargets = @($folders) + @($sourceByPath.Values) foreach ($rk in $rootFolders.Keys) { if ($rk -ne '') { $secTargets += $rootFolders[$rk] } } $policyCount = 0 foreach ($t in $secTargets) { try { $pol = & $srcCall "CatalogItems($($t.Id))/Policies" if ((& $getProp $pol 'InheritParentPolicy') -eq $false) { if ($sourceByPath.ContainsKey([string]$t.Path)) { $sourceByPath[[string]$t.Path].Policy = $pol } else { $t | Add-Member -NotePropertyName sqmPolicy -NotePropertyValue $pol -Force } $policyCount++ } } catch { & $addStep "Rechte lesen: $($t.Path)" 'Warning' $_.Exception.Message } } & $addStep 'Rechte Quelle' 'Success' "$policyCount Element(e) mit eigenen Rechten (ohne Vererbung)." } # Abonnements: nur auflisten. try { $allSubs = @((& $srcCall 'Subscriptions').value) foreach ($s in $allSubs) { $rp = [string](& $getProp $s 'Report') if ($rp -and $sourceByPath.ContainsKey($rp) -and -not $sourceByPath[$rp].IsDependency) { $subscriptions.Add([PSCustomObject]@{ Report = $rp Description = (& $getProp $s 'Description') Owner = (& $getProp $s 'Owner') DeliveryExtension = (& $getProp $s 'DeliveryExtension') IsDataDriven = (& $getProp $s 'IsDataDriven') LastStatus = (& $getProp $s 'LastStatus') }) } } if ($subscriptions.Count -gt 0) { & $addStep 'Abonnements' 'Warning' "$($subscriptions.Count) Abonnement(s) auf migrierten Berichten werden nicht uebernommen und muessen auf dem Ziel neu angelegt werden (Liste im Bericht)." } } catch { & $addStep 'Abonnements' 'Warning' "Konnten nicht gelesen werden: $($_.Exception.Message)" } # ============================================================== # 4. Plan gegen das Ziel # ============================================================== $typeOrder = @{ DataSource = 1; DataSet = 2; Resource = 3; Report = 4; LinkedReport = 5; PowerBIReport = 6; ExcelWorkbook = 7 } $plan = [System.Collections.Generic.List[PSCustomObject]]::new() $destLookup = { param ([string]$Path) try { return (& $dstCall "CatalogItems$(ConvertTo-sqmSsrsPathKey $Path)") } catch { if ($_.Exception.Data['StatusCode'] -eq 404) { return $null } throw } } foreach ($e in @($sourceByPath.Values | Sort-Object @{ Expression = { $o = $typeOrder[$_.Type]; if ($null -eq $o) { 99 } else { $o } } }, Path)) { $destPath = & $mapPath $e.Path $p = [PSCustomObject]@{ Type = $e.Type SourcePath = $e.Path DestinationPath = $destPath Action = '' Status = 'Planned' Detail = '' DestinationId = $null MissingSecret = $false Source = $e } if (-not $typeOrder.ContainsKey($e.Type)) { $p.Action = 'NotSupported'; $p.Status = 'NotSupported' $p.Detail = "Typ '$($e.Type)' wird nicht migriert." } elseif ($ItemType -notcontains $e.Type) { $p.Action = 'Excluded'; $p.Status = 'Skipped'; $p.Detail = 'Durch -ItemType ausgeschlossen.' } else { $existing = & $destLookup $destPath if ($existing) { $p.DestinationId = [string]$existing.Id if ([string]$existing.Type -ne $e.Type) { $p.Action = 'Conflict'; $p.Status = 'Failed' $p.Detail = "Auf dem Ziel existiert unter diesem Pfad ein Element vom Typ '$($existing.Type)'." } elseif ($e.Type -eq 'DataSource' -and -not $OverwriteDataSources) { $p.Action = 'Skip'; $p.Detail = 'Datenquelle existiert auf dem Ziel und bleibt unveraendert (-OverwriteDataSources zum Ersetzen).' } elseif ($e.Type -ne 'DataSource' -and -not $Overwrite) { $p.Action = 'Skip'; $p.Detail = 'Existiert auf dem Ziel (-Overwrite zum Ersetzen).' } else { $p.Action = 'Overwrite' } } else { $p.Action = 'Create' } } # Kennwortpruefung fuer gespeicherte Anmeldeinformationen $credUsers = @() if ($e.Type -eq 'DataSource' -and $e.Definition -and $p.Action -in @('Create', 'Overwrite')) { if ([string](& $getProp $e.Definition 'CredentialRetrieval') -ieq 'Store') { $credUsers += [string](& $getProp (& $getProp $e.Definition 'CredentialsInServer') 'UserName') } } if ($e.Type -eq 'Report' -and $p.Action -in @('Create', 'Overwrite')) { foreach ($ds in $e.DataSources) { if ((& $getProp $ds 'IsReference') -ne $true -and [string](& $getProp $ds 'CredentialRetrieval') -ieq 'Store') { $credUsers += [string](& $getProp (& $getProp $ds 'CredentialsInServer') 'UserName') } } } $missing = @($credUsers | Where-Object { $_ -and -not (& $findSecret $_) } | Select-Object -Unique) if ($missing.Count -gt 0) { $p.MissingSecret = $true $p.Detail = (("$($p.Detail) Gespeichertes Kennwort fuer '$($missing -join "', '")' fehlt (-DataSourceCredential); es laesst sich von der Quelle nicht auslesen.").Trim()) & $addStep "Kennwort: $($e.Path)" 'Warning' "Gespeicherte Anmeldeinformationen fuer '$($missing -join "', '")' - ohne -DataSourceCredential kann die Datenquelle auf dem Ziel keine Verbindung herstellen." } $plan.Add($p) } foreach ($p in $plan) { $items.Add([PSCustomObject]@{ Type = $p.Type SourcePath = $p.SourcePath DestinationPath = $p.DestinationPath Action = $p.Action Status = $p.Status Detail = $p.Detail }) } $result.ItemCount = $plan.Count $planSummary = @($plan | Group-Object Action | ForEach-Object { "$($_.Count) $($_.Name)" }) -join ', ' & $addStep 'Plan' 'Success' $planSummary if ($AssessOnly) { $result.Status = 'Assessed' $conflicts = @($plan | Where-Object { $_.Action -eq 'Conflict' }) if ($conflicts.Count -gt 0) { $result.Status = 'Blocked' $result.Reason = "$($conflicts.Count) Typkonflikt(e) auf dem Ziel." } else { $result.Reason = "Bewertung abgeschlossen: $planSummary." } } else { $work = @($plan | Where-Object { $_.Action -in @('Create', 'Overwrite') }) $doIt = $PSCmdlet.ShouldProcess($dstApi, "$($work.Count) Element(e) aus '$($result.SourceFolder -join ', ')' migrieren") if (-not $doIt) { foreach ($p in $work) { $p.Status = 'WhatIf' } & $addStep 'Migration' 'WhatIf' "Es wurde nichts geaendert. Geplant: $planSummary." } else { # ============================================================== # 5. Export der Quelle, Sicherung der Ziel-Elemente # ============================================================== $runDir = Join-Path $OutputPath "SsrsMigration_$timestamp" $exportDir = Join-Path $runDir 'Source' $backupDir = Join-Path $runDir 'DestinationBackup' $null = New-Item -ItemType Directory -Path $exportDir -Force $result.ExportPath = $runDir foreach ($p in $plan) { $e = $p.Source try { if ($extensionFor.ContainsKey($e.Type)) { $file = & $localPath $exportDir $e.Path $extensionFor[$e.Type] & $srcCall "CatalogItems($($e.Id))/Content/`$value" -OutFile $file $e.LocalFile = $file } else { $file = & $localPath $exportDir $e.Path '.json' $def = [ordered]@{ Item = $e.Path; Type = $e.Type; Definition = $e.Definition; DataSources = $e.DataSources; Link = $e.Link } [System.IO.File]::WriteAllText($file, (ConvertTo-Json -InputObject $def -Depth 10), [System.Text.UTF8Encoding]::new($false)) } } catch { $p.Status = 'Failed'; $p.Detail = "Export: $($_.Exception.Message)" } if ($p.Action -eq 'Overwrite' -and $p.Status -ne 'Failed') { try { if ($extensionFor.ContainsKey($e.Type)) { $bfile = & $localPath $backupDir $p.DestinationPath $extensionFor[$e.Type] & $dstCall "CatalogItems($($p.DestinationId))/Content/`$value" -OutFile $bfile } elseif ($e.Type -eq 'DataSource') { $bfile = & $localPath $backupDir $p.DestinationPath '.json' $bdef = & $dstCall "DataSources($($p.DestinationId))" [System.IO.File]::WriteAllText($bfile, (ConvertTo-Json -InputObject $bdef -Depth 10), [System.Text.UTF8Encoding]::new($false)) } } catch { $p.Status = 'Failed'; $p.Detail = "Sicherung des Ziel-Elements fehlgeschlagen, nicht ueberschrieben: $($_.Exception.Message)" } } } & $addStep 'Export' 'Success' "Quellinhalt nach '$exportDir' exportiert." # ============================================================== # 6. Ordner anlegen # ============================================================== $folderPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($f in $folders) { $null = $folderPaths.Add((& $mapPath ([string]$f.Path))) } foreach ($p in $work) { $null = $folderPaths.Add((& $parentOf $p.DestinationPath)) } if ($null -ne $destRoot -and $destRoot -ne '') { $null = $folderPaths.Add($destRoot) } # Jede Ebene einzeln, damit auch Zwischenordner entstehen. $allLevels = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($fp in $folderPaths) { $acc = '' foreach ($seg in ($fp.Trim('/') -split '/')) { if ($seg -eq '') { continue } $acc = "$acc/$seg" $null = $allLevels.Add($acc) } } $createdFolders = 0 $folderErrors = 0 foreach ($fp in @($allLevels | Sort-Object { ($_ -split '/').Count }, { $_ })) { try { $ex = & $destLookup $fp if ($ex) { if ([string]$ex.Type -ne 'Folder') { throw "Unter '$fp' existiert ein Element vom Typ '$($ex.Type)'." } continue } $name = ($fp -split '/')[-1] $null = & $dstCall 'Folders' 'POST' @{ Name = $name; Path = $fp } $createdFolders++ } catch { $folderErrors++ & $addStep "Ordner: $fp" 'Failed' $_.Exception.Message } } & $addStep 'Ordner' $(if ($folderErrors) { 'Warning' } else { 'Success' }) "$createdFolders angelegt, $($allLevels.Count - $createdFolders - $folderErrors) vorhanden, $folderErrors Fehler." # ============================================================== # 7. Elemente schreiben # ============================================================== # Referenzen im Inhalt umschreiben (nur absolute Pfade in migrierten Ordnern). $rewriteContent = { param ([byte[]]$Bytes) if ($null -eq $destRoot) { return $Bytes } $enc = New-Object System.Text.UTF8Encoding($false) $hasBom = ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) $text = $enc.GetString($Bytes) if ($hasBom) { $text = $text.Substring(1) } $new = [regex]::Replace($text, '<(DataSourceReference|SharedDataSetReference)>([^<]*)</\1>', { param ($m) $val = [System.Net.WebUtility]::HtmlDecode($m.Groups[2].Value) if (-not $val.StartsWith('/')) { return $m.Value } $mapped = & $mapPath $val if ($mapped -eq $val) { return $m.Value } return "<$($m.Groups[1].Value)>$([System.Security.SecurityElement]::Escape($mapped))</$($m.Groups[1].Value)>" }) if ($new -eq $text) { return $Bytes } $out = $enc.GetBytes($new) if ($hasBom) { $out = [byte[]](@(0xEF, 0xBB, 0xBF) + $out) } return $out } $credentialsBlock = { param ($Def) $retrieval = ([string](& $getProp $Def 'CredentialRetrieval')).ToLowerInvariant() $block = @{} if ($retrieval -ieq 'Store') { $cis = & $getProp $Def 'CredentialsInServer' $user = [string](& $getProp $cis 'UserName') $secret = & $findSecret $user $pw = $null if ($secret) { $pw = & $toPlain $secret.Password } $block['CredentialsInServer'] = @{ UserName = $user Password = $pw UseAsWindowsCredentials = [bool](& $getProp $cis 'UseAsWindowsCredentials') ImpersonateAuthenticatedUser = [bool](& $getProp $cis 'ImpersonateAuthenticatedUser') } } elseif ($retrieval -ieq 'Prompt') { $cbu = & $getProp $Def 'CredentialsByUser' $block['CredentialsByUser'] = @{ DisplayText = [string](& $getProp $cbu 'DisplayText') UseAsWindowsCredentials = [bool](& $getProp $cbu 'UseAsWindowsCredentials') } } return $block } foreach ($p in $work) { if ($p.Status -eq 'Failed') { continue } $e = $p.Source try { $payload = [ordered]@{ '@odata.type' = "#Model.$($e.Type)" Name = $e.Name Path = $p.DestinationPath Description = $e.Description Hidden = $e.Hidden } if ($e.Type -eq 'DataSource') { $def = $e.Definition $cs = & $mapConnection ([string](& $getProp $def 'ConnectionString')) $payload['ConnectionString'] = $cs # Ohne IsConnectionStringOverridden=true verwirft SSRS die Verbindungszeichenfolge # beim Anlegen und Aendern kommentarlos (HTTP 201, Datenquelle danach leer). if (-not [string]::IsNullOrEmpty($cs)) { $payload['IsConnectionStringOverridden'] = $true } $payload['DataSourceType'] = [string](& $getProp $def 'DataSourceType') # Die API nimmt die Werte nur klein geschrieben an ('Store' -> HTTP 400). $payload['CredentialRetrieval'] = ([string](& $getProp $def 'CredentialRetrieval')).ToLowerInvariant() $enabled = & $getProp $def 'IsEnabled' $payload['IsEnabled'] = ($null -eq $enabled -or [bool]$enabled) $cb = & $credentialsBlock $def foreach ($k in $cb.Keys) { $payload[$k] = $cb[$k] } } elseif ($e.Type -eq 'LinkedReport') { $payload['Link'] = & $mapPath $e.Link # /LinkedReports erwartet als Path den Elternordner, alle anderen Typen den # vollstaendigen Pfad des neuen Elements. $payload['Path'] = & $parentOf $p.DestinationPath } else { $bytes = [System.IO.File]::ReadAllBytes($e.LocalFile) if ($e.Type -in @('Report', 'DataSet')) { $bytes = & $rewriteContent $bytes } $payload['Content'] = [System.Convert]::ToBase64String($bytes) $ct = $e.ContentType if (-not $ct) { $ct = '' } $payload['ContentType'] = $ct } $endpoint = $endpointFor[$e.Type] if ($p.Action -eq 'Overwrite') { $payload.Remove('Path') $null = & $dstCall "$endpoint($($p.DestinationId))" 'PATCH' $payload $p.Status = 'Success'; $p.Detail = (("$($p.Detail) Ueberschrieben (ID bleibt).").Trim()) } else { $created = & $dstCall $endpoint 'POST' $payload $newId = & $getProp $created 'Id' if (-not $newId) { $chk = & $destLookup $p.DestinationPath if ($chk) { $newId = $chk.Id } } if (-not $newId) { throw "Angelegt gemeldet, aber unter '$($p.DestinationPath)' nicht auffindbar." } $p.DestinationId = [string]$newId $p.Status = 'Success' } if ($p.MissingSecret) { $p.Status = 'Warning' } } catch { $p.Status = 'Failed' $p.Detail = (("$($p.Detail) $($_.Exception.Message)").Trim()) } } $okCount = @($work | Where-Object { $_.Status -eq 'Success' }).Count $errCount = @($work | Where-Object { $_.Status -eq 'Failed' }).Count & $addStep 'Elemente' $(if ($errCount) { 'Warning' } else { 'Success' }) "$okCount geschrieben, $errCount Fehler." # ============================================================== # 8. Datenquellen-Bindungen aus dem Quellkatalog uebertragen # ============================================================== $bindingFor = { param ($SourceDataSources) $list = [System.Collections.Generic.List[object]]::new() foreach ($ds in @($SourceDataSources)) { if ((& $getProp $ds 'IsReference') -eq $true) { $list.Add([ordered]@{ Name = [string]$ds.Name IsReference = $true Path = & $mapPath ([string](& $getProp $ds 'Path')) }) } else { # Gespeichertes Kennwort unbekannt: Eintrag weglassen, sonst wuerde der PUT die # Anmeldedaten mit leerem Kennwort ueberschreiben. Gewarnt wurde schon im Plan. if (([string](& $getProp $ds 'CredentialRetrieval')) -ieq 'store' -and -not (& $findSecret ([string](& $getProp (& $getProp $ds 'CredentialsInServer') 'UserName')))) { continue } $origCs = [string](& $getProp $ds 'ConnectionString') $entry = [ordered]@{ Name = [string]$ds.Name IsReference = $false ConnectionString = & $mapConnection $origCs DataSourceType = [string](& $getProp $ds 'DataSourceType') CredentialRetrieval = ([string](& $getProp $ds 'CredentialRetrieval')).ToLowerInvariant() IsEnabled = $true } if ([bool](& $getProp $ds 'IsConnectionStringOverridden') -or $entry.ConnectionString -ne $origCs) { $entry['IsConnectionStringOverridden'] = $true } $cb = & $credentialsBlock $ds foreach ($k in $cb.Keys) { $entry[$k] = $cb[$k] } $list.Add($entry) } } return , $list } $bindOk = 0; $bindErr = 0 foreach ($p in @($work | Where-Object { $_.Status -in @('Success', 'Warning') -and $_.Type -in @('Report', 'DataSet') })) { $e = $p.Source if (@($e.DataSources).Count -eq 0) { continue } $segment = 'Reports' if ($e.Type -eq 'DataSet') { $segment = 'DataSets' } try { $binding = & $bindingFor $e.DataSources $null = & $dstCall "$segment($($p.DestinationId))/DataSources" 'PUT' (ConvertTo-Json -InputObject @($binding) -Depth 10 -Compress) $bindOk++ } catch { $bindErr++ $p.Status = 'Warning' $p.Detail = (("$($p.Detail) Bindung der Datenquellen fehlgeschlagen: $($_.Exception.Message)").Trim()) } } if ($bindOk + $bindErr -gt 0) { & $addStep 'Datenquellen-Bindungen' $(if ($bindErr) { 'Warning' } else { 'Success' }) "$bindOk gesetzt, $bindErr Fehler." } # ============================================================== # 9. Rechte # ============================================================== if ($IncludeSecurity) { $secOk = 0; $secErr = 0 $secItems = [System.Collections.Generic.List[object]]::new() foreach ($f in $folders) { if ($f.PSObject.Properties['sqmPolicy']) { $secItems.Add(@((& $mapPath ([string]$f.Path)), $f.sqmPolicy)) } } foreach ($rk in $rootFolders.Keys) { $rf = $rootFolders[$rk] if ($rf.PSObject.Properties['sqmPolicy']) { $target = & $mapPath ([string]$rf.Path) $secItems.Add(@($target, $rf.sqmPolicy)) } } foreach ($p in $work) { if ($p.Status -ne 'Failed' -and $p.Source.Policy) { $secItems.Add(@($p.DestinationPath, $p.Source.Policy)) } } foreach ($si in $secItems) { try { $tgt = & $destLookup $si[0] if (-not $tgt) { throw "Ziel '$($si[0])' nicht gefunden." } $body = @{ InheritParentPolicy = $false Policies = @(@($si[1].Policies) | ForEach-Object { @{ GroupUserName = [string]$_.GroupUserName Roles = @(@($_.Roles) | ForEach-Object { @{ Name = [string]$_.Name; Description = [string](& $getProp $_ 'Description') } }) } }) } $null = & $dstCall "CatalogItems($($tgt.Id))/Policies" 'PUT' $body $secOk++ } catch { $secErr++ & $addStep "Rechte: $($si[0])" 'Failed' $_.Exception.Message } } & $addStep 'Rechte' $(if ($secErr) { 'Warning' } else { 'Success' }) "$secOk Element(e) gesetzt, $secErr Fehler." } # ============================================================== # 10. Pruefung: Bindungen vom Ziel zuruecklesen # ============================================================== $verifyErr = 0 foreach ($p in @($work | Where-Object { $_.Status -in @('Success', 'Warning') -and $_.Type -eq 'Report' })) { try { $actual = @((& $dstCall "Reports($($p.DestinationId))/DataSources").value) $problems = [System.Collections.Generic.List[string]]::new() foreach ($src in @($p.Source.DataSources)) { $a = $actual | Where-Object { $_.Name -eq $src.Name } | Select-Object -First 1 if (-not $a) { $problems.Add("'$($src.Name)' fehlt"); continue } if ((& $getProp $src 'IsReference') -eq $true) { $expected = & $mapPath ([string](& $getProp $src 'Path')) $got = [string](& $getProp $a 'Path') if ($got -ne $expected) { $problems.Add("'$($src.Name)' zeigt auf '$got' statt '$expected'") } } } if ($problems.Count -gt 0) { $verifyErr++ $p.Status = 'Warning' $p.Detail = (("$($p.Detail) Pruefung: $($problems -join '; ').").Trim()) } } catch { $verifyErr++ $p.Status = 'Warning' $p.Detail = (("$($p.Detail) Pruefung nicht moeglich: $($_.Exception.Message)").Trim()) } } & $addStep 'Pruefung' $(if ($verifyErr) { 'Warning' } else { 'Success' }) "Bindungen zurueckgelesen, $verifyErr Bericht(e) mit Abweichung." } # Status der Plan-Eintraege ins Ergebnis uebernehmen for ($i = 0; $i -lt $plan.Count; $i++) { if ($plan[$i].Status -eq 'Planned' -and $plan[$i].Action -eq 'Skip') { $plan[$i].Status = 'Skipped' } $items[$i].Status = $plan[$i].Status $items[$i].Detail = $plan[$i].Detail } if ($result.Status -eq 'Unknown') { $failedItems = @($items | Where-Object { $_.Status -eq 'Failed' }) $failedSteps = @($steps | Where-Object { $_.Status -eq 'Failed' }) if (@($steps | Where-Object { $_.Status -eq 'WhatIf' }).Count -gt 0) { $result.Status = 'WhatIf' $result.Reason = 'WhatIf-Lauf, es wurde nichts geaendert.' } elseif ($failedItems.Count -gt 0 -or $failedSteps.Count -gt 0) { $result.Status = 'Failed' $result.Reason = "$($failedItems.Count) Element(e) und $($failedSteps.Count) Schritt(e) fehlgeschlagen." } elseif (@($items | Where-Object { $_.Status -eq 'Warning' }).Count -gt 0 -or @($steps | Where-Object { $_.Status -eq 'Warning' }).Count -gt 0) { $result.Status = 'Warning' $result.Reason = 'Migration abgeschlossen, Hinweise beachten.' } else { $result.Status = 'Success' $result.Reason = "$(@($items | Where-Object { $_.Status -eq 'Success' }).Count) Element(e) migriert." } } } } catch { $result.Status = 'Failed' $result.Reason = $_.Exception.Message & $addStep 'Abbruch' 'Failed' $_.Exception.Message if ($EnableException) { if ($tempDir -and (Test-Path -LiteralPath $tempDir)) { Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue -WhatIf:$false } throw } } finally { if ($tempDir -and (Test-Path -LiteralPath $tempDir)) { Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue -WhatIf:$false } } # ============================================================== # 11. Bericht # ============================================================== if (-not $NoReport) { try { if (-not (Test-Path -LiteralPath $OutputPath)) { $null = New-Item -ItemType Directory -Path $OutputPath -Force -ErrorAction Stop -WhatIf:$false } $reportFile = Join-Path $OutputPath "SsrsMigration_$timestamp.html" $bodyHtml = & $buildReportBody $result $steps $html = ConvertTo-sqmHtmlReport -Title 'SSRS-Migration' ` -Subtitle "$srcApi -> $dstApi ($($result.Action), Status: $($result.Status))" ` -BodyHtml $bodyHtml [System.IO.File]::WriteAllText($reportFile, $html, [System.Text.UTF8Encoding]::new($false)) $result.ReportFile = $reportFile Invoke-sqmOpenReport -HtmlFile $reportFile -NoOpen:$NoOpen } catch { Invoke-sqmLogging -Message "Bericht konnte nicht geschrieben werden: $($_.Exception.Message)" -FunctionName $functionName -Level "WARNING" } } return $result } } |