Public/Test-SPMigrationFidelity.ps1
|
function Test-SPMigrationFidelity { <# .SYNOPSIS Compare la destination à la source, élément par élément, après migration. .DESCRIPTION Le rapport de migration dérive du fichier d'état : il dit ce que le moteur CROIT avoir fait. Cette fonction dit ce qui se trouve réellement à destination. Les deux sont nécessaires, et le second n'a longtemps existé que dans un script hors module, avec trois trous qui lui retiraient l'essentiel de sa valeur : * il n'échantillonnait que cinq éléments par bibliothèque, pris dans l'ordre du serveur ; * il ne contrôlait QUE des fichiers — jamais les dossiers, alors que la ré-estampille d'un dossier est le défaut le plus courant ; * il ne parcourait jamais la destination, donc ne voyait aucun élément surnuméraire. Ici : 100 % des éléments par défaut, l'échantillonnage devient une option explicite ; les dossiers sont inclus ; ce qui est à destination sans être à la source est rapporté. Et le verdict ne dit jamais « fidélité confirmée » : il dit « aucun écart détecté SUR LE PÉRIMÈTRE CONTRÔLÉ », suivi de ce qui n'a pas été contrôlé. Une vérification qui tait ses angles morts vaut moins que pas de vérification du tout — elle donne une confiance qu'elle n'a pas mesurée. .PARAMETER SampleSize Limite le nombre d'éléments comparés. 0 = tous, et c'est le défaut. Toute troncature est inscrite dans le résultat et dans le périmètre non contrôlé. .PARAMETER SkipVersions N'interroge pas l'historique des versions. Le contrôle le plus coûteux, et le plus structurant : ne le couper qu'en connaissance de cause. .EXAMPLE Test-SPMigrationFidelity -SourceUrl $src -TargetUrl $dst .OUTPUTS Objet { Ok, Compared, Errors, Warnings, Findings, Details, Scope }. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory, Position = 0)][string]$SourceUrl, [Parameter(Mandatory, Position = 1)][string]$TargetUrl, $SourceConnection, $DestinationConnection, [ValidateRange(0, [int]::MaxValue)][int]$SampleSize = 0, [switch]$SkipVersions, [string]$OutputPath, [string]$ClientId = $env:SPM_CLIENT_ID, [string]$Tenant = $env:SPM_TENANT, [string]$Thumbprint = $env:SPM_CERT_THUMBPRINT ) $srcConn = $SourceConnection $dstConn = $DestinationConnection if (-not $srcConn -or -not $dstConn) { $splat = @{ ClientId = $ClientId } if ($Thumbprint) { if (-not $Tenant) { throw 'Tenant est obligatoire avec un certificat.' } $splat.Thumbprint = $Thumbprint; $splat.Tenant = $Tenant } else { $splat.Interactive = $true } if (-not $srcConn) { $srcConn = Connect-SPMSite @splat -Url (Get-SPMSiteUrlFromResourceUrl -Url $SourceUrl) } if (-not $dstConn) { $dstConn = Connect-SPMSite @splat -Url (Get-SPMSiteUrlFromResourceUrl -Url $TargetUrl) } } $srcPath = (Get-SPMServerRelativePath -Url $SourceUrl).TrimEnd('/') $dstPath = (Get-SPMServerRelativePath -Url $TargetUrl).TrimEnd('/') Write-SPMLog -Level INFO -Operation 'Fidelity' -Message "Comparaison de $srcPath vers $dstPath" Clear-SPMEnumerationFailure $srcItems = @(Get-SPMFolderInventory -FolderUrl $srcPath -Connection $srcConn) $echecsSource = @(Get-SPMEnumerationFailure) Clear-SPMEnumerationFailure $dstItems = @(Get-SPMFolderInventory -FolderUrl $dstPath -Connection $dstConn) $echecsDest = @(Get-SPMEnumerationFailure) $findings = [System.Collections.Generic.List[object]]::new() $rows = [System.Collections.Generic.List[object]]::new() $horsPerimetre = [System.Collections.Generic.List[string]]::new() # Un sous-arbre illisible fausse la comparaison ENTIÈRE : sans lui, des # éléments manquants passeraient pour absents de la source. foreach ($e in @($echecsSource + $echecsDest)) { $findings.Add([pscustomobject]@{ Severity = 'Error'; Item = $e.FolderUrl; Aspect = 'Enumeration' Detail = "Dossier illisible : la comparaison de ce sous-arbre n'a pas eu lieu — $($e.Error)" }) } $relatif = { param($url, $racine) $v = "$url" if ($v.StartsWith($racine, [StringComparison]::OrdinalIgnoreCase)) { $v = $v.Substring($racine.Length) } $v.TrimStart('/').ToLowerInvariant() } $indexDest = @{} foreach ($d in $dstItems) { $indexDest[(& $relatif $d.ServerRelativeUrl $dstPath)] = $d } $aComparer = $srcItems if ($SampleSize -gt 0 -and $srcItems.Count -gt $SampleSize) { $aComparer = $srcItems[0..($SampleSize - 1)] $horsPerimetre.Add( "$($srcItems.Count - $SampleSize) élément(s) NON comparés : échantillon de $SampleSize demandé" ) } $vus = [System.Collections.Generic.HashSet[string]]::new() foreach ($src in $aComparer) { $cle = & $relatif $src.ServerRelativeUrl $srcPath [void]$vus.Add($cle) $ligne = [ordered]@{ Element = $cle Type = if ($src.IsFolder) { 'dossier' } else { 'fichier' } Presence = 'OK'; Taille = '-'; Versions = '-'; Authorship = '-'; Retention = '-' } if (-not $indexDest.ContainsKey($cle)) { $ligne.Presence = 'ABSENT' $findings.Add([pscustomobject]@{ Severity = 'Error'; Item = $cle; Aspect = 'Presence' Detail = 'Présent à la source, absent à destination' }) $rows.Add([pscustomobject]$ligne) continue } $dst = $indexDest[$cle] # ------------------------------------------------------------ taille if (-not $src.IsFolder) { if ($src.SizeBytes -eq $dst.SizeBytes) { $ligne.Taille = 'OK' } else { $ligne.Taille = "$($src.SizeBytes) vs $($dst.SizeBytes)" $findings.Add([pscustomobject]@{ Severity = 'Error'; Item = $cle; Aspect = 'Contenu' Detail = "Taille différente : $($src.SizeBytes) contre $($dst.SizeBytes)" }) } } # ---------------------------------------------------------- versions if (-not $src.IsFolder -and -not $SkipVersions) { $vSrc = @(Invoke-SPMWithRetry -Operation 'Get-PnPFileVersion/src' -PassThruErrors -ScriptBlock { Get-PnPFileVersion -Url $src.ServerRelativeUrl -Connection $srcConn -ErrorAction Stop }) $vDst = @(Invoke-SPMWithRetry -Operation 'Get-PnPFileVersion/dst' -PassThruErrors -ScriptBlock { Get-PnPFileVersion -Url $dst.ServerRelativeUrl -Connection $dstConn -ErrorAction Stop }) # +1 : la version courante ne figure pas dans l'historique. $nSrc = $vSrc.Count + 1 $nDst = $vDst.Count + 1 if ($nSrc -eq $nDst) { $ligne.Versions = "OK ($nSrc)" # « Versions OK (1) » ne prouve rien : un fichier sans historique # ne peut pas en perdre. Le dire empêche de conclure trop vite. if ($nSrc -le 1) { $ligne.Versions = 'OK (1 — sans historique, ne prouve rien)' } } else { $ligne.Versions = "$nSrc -> $nDst" $findings.Add([pscustomobject]@{ Severity = 'Error'; Item = $cle; Aspect = 'Versions' Detail = "$nSrc version(s) à la source, $nDst à destination" }) } } elseif ($SkipVersions) { $ligne.Versions = 'non contrôlé' } # ------------------------------------------- dates, auteurs, rétention $srcItem = Get-SPMItemForPath -ServerRelativeUrl $src.ServerRelativeUrl ` -Kind $(if ($src.IsFolder) { 'Folder' } else { 'File' }) -Connection $srcConn $dstItem = Get-SPMItemForPath -ServerRelativeUrl $dst.ServerRelativeUrl ` -Kind $(if ($dst.IsFolder) { 'Folder' } else { 'File' }) -Connection $dstConn if (-not $srcItem -or -not $dstItem) { $ligne.Authorship = 'non lisible' $findings.Add([pscustomobject]@{ Severity = 'Warning'; Item = $cle; Aspect = 'Metadonnees' Detail = 'Élément de liste non lisible d''un côté : dates et auteurs non comparés' }) $rows.Add([pscustomobject]$ligne) continue } $ecarts = [System.Collections.Generic.List[string]]::new() foreach ($champ in 'Created', 'Modified', 'Author', 'Editor') { $a = Format-SPMFieldForCompare -Value (Get-SPMFieldValue -Item $srcItem -Name $champ) $b = Format-SPMFieldForCompare -Value (Get-SPMFieldValue -Item $dstItem -Name $champ) if ($a -ne $b) { $ecarts.Add("$champ : '$a' -> '$b'") } } $ligne.Authorship = if ($ecarts.Count) { "$($ecarts.Count) DIFF" } else { 'OK' } foreach ($e in $ecarts) { $findings.Add([pscustomobject]@{ Severity = 'Error'; Item = $cle; Aspect = 'Authorship'; Detail = $e }) } # Rétention en colonne dédiée : une étiquette perdue rend un document # supprimable, ce qui n'est pas de même nature qu'une colonne absente. $confSrc = Get-SPMComplianceState -Item $srcItem $confDst = Get-SPMComplianceState -Item $dstItem $ecartsConf = [System.Collections.Generic.List[string]]::new() if ("$($confSrc.Label)" -ne "$($confDst.Label)") { $ecartsConf.Add("étiquette '$($confSrc.Label)' -> '$($confDst.Label)'") } if ($confSrc.IsRecord -and -not $confDst.IsRecord) { $ecartsConf.Add('enregistrement non redéclaré à destination') } if ("$($confSrc.ModerationStatus)" -ne "$($confDst.ModerationStatus)") { $ecartsConf.Add("modération '$($confSrc.ModerationStatus)' -> '$($confDst.ModerationStatus)'") } $ligne.Retention = if ($ecartsConf.Count) { "$($ecartsConf.Count) DIFF" } else { 'OK' } foreach ($e in $ecartsConf) { $findings.Add([pscustomobject]@{ Severity = 'Error'; Item = $cle; Aspect = 'Retention'; Detail = $e }) } $rows.Add([pscustomobject]$ligne) } # ------------------------------------------- éléments SURNUMÉRAIRES # Une comparaison qui ne parcourt que la source ne voit jamais ce qui a été # copié deux fois. C'est exactement ainsi que 63 éléments source sont # devenus 180 à destination sans qu'aucun contrôle ne s'en aperçoive. if ($SampleSize -le 0) { foreach ($cle in $indexDest.Keys) { if ($vus.Contains($cle)) { continue } $findings.Add([pscustomobject]@{ Severity = 'Warning'; Item = $cle; Aspect = 'Surnumeraire' Detail = 'Présent à destination, absent de la source' }) } } else { $horsPerimetre.Add('Éléments surnuméraires à destination : non recherchés (échantillonnage actif)') } # ---------------------------------------------------- périmètre non couvert if ($SkipVersions) { $horsPerimetre.Add('Historique des versions : non contrôlé (-SkipVersions)') } $horsPerimetre.Add('Permissions uniques : non comparées ici — voir Copy-SPMigrationPermission') $horsPerimetre.Add('Colonnes de site, types de contenu et taxonomie : non comparés') $horsPerimetre.Add('Liens de partage, alertes et corbeille : hors périmètre du moteur') $errors = @($findings | Where-Object Severity -eq 'Error') $warnings = @($findings | Where-Object Severity -eq 'Warning') $resultat = [pscustomobject]@{ Ok = ($errors.Count -eq 0) Compared = $rows.Count SourceItems = $srcItems.Count DestinationItems = $dstItems.Count Errors = $errors.Count Warnings = $warnings.Count Findings = $findings.ToArray() Details = $rows.ToArray() Scope = $horsPerimetre.ToArray() Truncated = ($SampleSize -gt 0 -and $srcItems.Count -gt $SampleSize) } # Le verdict ne prononce jamais « fidélité confirmée » : il borne ce qu'il a # regardé. Zéro comparaison n'est pas un succès — c'est une absence de # mesure, et c'est le résultat le plus trompeur qui soit. if ($rows.Count -eq 0) { Write-SPMLog -Level ERROR -Operation 'Fidelity' -Message ( 'AUCUN élément comparé : ce contrôle ne prouve rien. Vérifiez les URL et les droits.' ) $resultat.Ok = $false } else { $verdict = if ($resultat.Ok) { "Aucun écart détecté SUR LE PÉRIMÈTRE CONTRÔLÉ — $($rows.Count) élément(s) comparé(s)" } else { "$($errors.Count) écart(s) détecté(s) sur $($rows.Count) élément(s) comparé(s)" } Write-SPMLog -Level $(if ($resultat.Ok) { 'SUCCESS' } else { 'ERROR' }) -Operation 'Fidelity' -Message $verdict } foreach ($h in $horsPerimetre) { Write-SPMLog -Level WARNING -Operation 'Fidelity' -Message "Hors périmètre contrôlé : $h" } if ($OutputPath) { $dir = Split-Path -Parent $OutputPath if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $resultat | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $OutputPath -Encoding utf8 Write-SPMLog -Level SUCCESS -Operation 'Fidelity' -Message "Rapport de fidélité écrit : $OutputPath" } return $resultat } |