Public/Invoke-SPMigration.ps1
|
function Invoke-SPMigration { <# .SYNOPSIS Exécute un plan de migration. .DESCRIPTION Moteur de migration reposant sur les travaux de copie serveur-à-serveur de SharePoint Online (Copy-PnPFolder / Copy-PnPFile), et non plus sur un cycle download vers disque temporaire puis upload. Apports par rapport au moteur précédent : * historique des versions, dates et auteurs préservés par le service * le contenu ne transite plus par le poste d'exécution * état persistant : -Resume reprend là où l'exécution précédente s'est arrêtée * -Incremental ne re-migre que ce qui a changé (taille + date, pas seulement l'existence) * throttling 429/503 absorbé par le socle de résilience * -WhatIf simule intégralement sans aucune écriture .PARAMETER Plan Plan produit par New-SPMigrationPlan. .PARAMETER StatePath Fichier d'état NDJSON. Par défaut : .\Logs\<nom-du-plan>.state.ndjson .PARAMETER Resume Reprend une exécution : les unités déjà terminées sont ignorées. .PARAMETER Incremental Ne migre que les unités dont la signature source a changé. Surcharge options.incremental du plan. .PARAMETER SkipPreflight Saute la validation pré-vol. Déconseillé : à réserver aux relances. .EXAMPLE $plan = New-SPMigrationPlan -Path .\plan.json Invoke-SPMigration -Plan $plan -WhatIf .EXAMPLE Invoke-SPMigration -Plan $plan -Resume .OUTPUTS Objet de synthèse { Succeeded, Failed, Skipped, Duration, StatePath, Findings }. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ValueFromPipeline)] [pscustomobject]$Plan, [string]$StatePath, [switch]$Resume, [switch]$Incremental, [switch]$SkipPreflight, # Table de correspondance d'identités, utilisée par le report des # permissions quand les principaux diffèrent entre les deux tenants. [string]$IdentityMapPath, # Fait mesurer au pré-vol ce que le tenant autorise — copie serveur # comprise — au lieu de le découvrir sur la première unité. Écrit un # dossier temporaire à destination, supprimé ensuite. [switch]$ProbeCapability ) process { $started = Get-Date # ------------------------------------------------------- configuration Set-SPMConfiguration ` -MaxRetries $Plan.Throttle.maxRetries ` -BaseDelayMs $Plan.Throttle.baseDelayMs ` -MaxDelayMs $Plan.Throttle.maxDelayMs if (-not $StatePath) { $safeName = ($Plan.Name -replace '[^\w\-]', '_') $StatePath = Join-Path (Join-Path (Get-Location) 'Logs') "$safeName.state.ndjson" } # ------------------------------------------------------- journalisation # Une migration qui ne laisse pas de trace sur disque n'est pas # auditable : quand le resultat surprend, il ne reste que la memoire de # l'operateur. Le fichier d'etat dit CE QUI a ete transfere ; il ne dit # rien de la metadonnee, des replis ni des champs qui ont resiste. # On ouvre donc un journal a cote de l'etat, sauf si l'appelant en a # deja configure un. $config = Get-SPMConfiguration if (-not $config.LogPath -and -not $config.JsonLogPath) { $logDir = Split-Path -Parent $StatePath if ($logDir -and -not (Test-Path -LiteralPath $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } $base = [System.IO.Path]::GetFileNameWithoutExtension($StatePath) -replace '\.state$', '' $stamp = Get-Date -Format 'yyyyMMdd_HHmmss' Set-SPMConfiguration ` -LogPath (Join-Path $logDir "$base`_$stamp.log") ` -JsonLogPath (Join-Path $logDir "$base`_$stamp.ndjson") } $stateInfo = Initialize-SPMState -Path $StatePath -Fresh:(-not $Resume) $journal = (Get-SPMConfiguration).LogPath Write-SPMLog -Level INFO -Operation 'Migration' -Message ( "Démarrage de '$($Plan.Name)' — état : $StatePath ($($stateInfo.Completed)/$($stateInfo.Units) déjà terminées)" + $(if ($journal) { " — journal : $journal" }) ) # Le chemin du journal s'affiche aussi à l'écran : quand un appelant en a # déjà configuré un, le nôtre n'est pas là où on le cherche. if ($journal) { Write-Host " Journal : $journal" -ForegroundColor DarkGray } $useIncremental = $Incremental -or [bool]$Plan.Options.incremental # Chemins écartés par le pré-vol et non transférés. Renseigné seulement # en mode quarantaine ; comparé en minuscules, comme les clés d'état. $quarantaine = [System.Collections.Generic.HashSet[string]]::new() # ------------------------------------------------------------ pré-vol $preflight = $null if (-not $SkipPreflight) { $preflight = Test-SPMigrationPlan -Plan $Plan -Online -ProbeCapability:$ProbeCapability if ($preflight.PSObject.Properties['Capability'] -and $preflight.Capability) { Write-SPMLog -Level INFO -Operation 'Migration' -Message ( "Capacité mesurée du tenant : transfert — $($preflight.Capability.RecommendedTransfer) ; " + "schéma — $($preflight.Capability.RecommendedSchemaMethod)" ) } if (-not $preflight.Ok) { # QUARANTAINE. Sur des dizaines de milliers de fichiers, il y # aura toujours un « ~$brouillon.docx ». Tout arrêter pour lui, # ou passer -SkipPreflight — donc désactiver AUSSI le contrôle # des 400 caractères —, sont deux mauvaises réponses. $bloquants = @($preflight.Findings | Where-Object Severity -eq 'Error') $surElement = @($bloquants | Where-Object { $_.PSObject.Properties['Path'] -and $_.Path }) $surLePlan = @($bloquants | Where-Object { -not ($_.PSObject.Properties['Path'] -and $_.Path) }) if ("$($Plan.Validation.onError)" -eq 'exclude' -and -not $surLePlan.Count) { foreach ($f in $surElement) { [void]$quarantaine.Add("$($f.Path)".ToLowerInvariant()) } Write-SPMLog -Level WARNING -Operation 'Migration' -Message ( "$($quarantaine.Count) élément(s) mis en QUARANTAINE (validation.onError = 'exclude') : " + 'ils sont écartés du lot, listés dans un fichier dédié, et la migration continue.' ) } else { # Une erreur qui ne porte sur aucun élément — source vide, # copie serveur refusée, étiquettes de rétention — ne # s'écarte pas : elle condamne le lot entier. L'exclure # reviendrait à migrer en ignorant le motif de l'arrêt. $motif = if ($surLePlan.Count) { "$($surLePlan.Count) erreur(s) portent sur le plan lui-même, pas sur un élément : la quarantaine ne s'y applique pas" } else { "$($preflight.ErrorCount) erreur(s) bloquante(s)" } Write-SPMLog -Level ERROR -Operation 'Migration' -Message "Pré-vol en échec : $motif. Migration annulée." return [pscustomobject]@{ Succeeded = 0; Failed = 0; Skipped = 0 Aborted = $true Reason = "Pré-vol en échec — $motif" Duration = (Get-Date) - $started StatePath = $StatePath Findings = $preflight.Findings } } } } else { Write-SPMLog -Level WARNING -Operation 'Migration' -Message 'Pré-vol ignoré (-SkipPreflight) : des échecs en cours de route sont possibles.' } # ------------------------------------------------- construction du lot $units = Build-SPMWorkUnit -Plan $Plan Write-SPMLog -Level INFO -Operation 'Migration' -Message "$($units.Count) unité(s) racine" $succeeded = 0 $submitted = 0 $failed = 0 $skipped = 0 $simulated = 0 $fallbackUsed = 0 $reconnects = 0 $metadataApplied = 0 $metadataFailed = 0 $labelsApplied = 0 $recordsLost = 0 $complianceProblems = 0 # Deux connexions, deux rôles distincts : # * la SOURCE soumet le travail de copie — CreateCopyJobs s'invoque sur # le site qui détient le contenu à exporter ; # * la DESTINATION prépare la cible (création des dossiers). # N'ouvrir que la destination, comme le faisait la version précédente, # revenait à demander au site cible d'exporter un contenu qu'il n'a pas. $destConnection = $null $srcConnection = $null if ($PSCmdlet.ShouldProcess($Plan.Destination.Url, 'Se connecter aux deux sites')) { $destConnection = Get-SPMPlanConnection -Plan $Plan -Which Destination if ($Plan.Source.Type -eq 'sharepoint') { $srcConnection = Get-SPMPlanConnection -Plan $Plan -Which Source } } # Un téléversement depuis un partage réseau part forcément de la # destination : il n'y a pas de site source à interroger. $submitConnection = $srcConnection ?? $destConnection # ------------------------------------------------- éclatement en unités # Sans éclatement, tout le site tient dans UNE unité : une seule clé # d'état, donc aucune reprise partielle et aucun avancement visible. if ($srcConnection -and "$($Plan.Options.granularity)" -eq 'item') { $expanded = [System.Collections.Generic.List[object]]::new() foreach ($unit in $units) { foreach ($u in (Expand-SPMWorkUnit -Unit $unit -Connection $srcConnection)) { $expanded.Add($u) } } if ($expanded.Count) { $units = $expanded.ToArray() } $echecs = @(Get-SPMEnumerationFailure) if ($echecs.Count) { Write-SPMLog -Level ERROR -Operation 'Migration' -Message ( "$($echecs.Count) dossier(s) illisible(s) : leur contenu ne sera PAS migré. " + 'Traitez-les avant de considérer la migration comme complète.' ) } } # ------------------------------------------------------- quarantaine # L'exclusion a lieu APRÈS l'éclatement en unités : le pré-vol désigne # des éléments, pas des racines. Écarter avant reviendrait à perdre # tout le sous-arbre d'un dossier fautif. $ecartes = 0 if ($quarantaine.Count) { $retenues = [System.Collections.Generic.List[object]]::new() $exclues = [System.Collections.Generic.List[object]]::new() foreach ($unit in $units) { if ($quarantaine.Contains("$($unit.Source)".ToLowerInvariant())) { $exclues.Add($unit) } else { $retenues.Add($unit) } } $units = $retenues.ToArray() $ecartes = $exclues.Count $fichierQuarantaine = [System.IO.Path]::ChangeExtension($StatePath, $null) + 'quarantine.ndjson' $dir = Split-Path -Parent $fichierQuarantaine if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } # Un fichier, une ligne par élément écarté, avec le motif. Un # compteur seul ne permettrait pas de reprendre le travail. $lignes = foreach ($u in $exclues) { $motifs = @($preflight.Findings | Where-Object { $_.Severity -eq 'Error' -and "$($_.Path)".ToLowerInvariant() -eq "$($u.Source)".ToLowerInvariant() } | ForEach-Object { "$($_.Rule) : $($_.Message)" }) $ligne = [ordered]@{ ts = (Get-Date).ToString('o') source = $u.Source target = $u.Target kind = "$($u.Kind)" reasons = $motifs } $ligne | ConvertTo-Json -Compress -Depth 4 } Set-Content -LiteralPath $fichierQuarantaine -Value $lignes -Encoding utf8 Write-SPMLog -Level WARNING -Operation 'Migration' -Message ( "$ecartes unité(s) écartée(s) et NON transférée(s) : $fichierQuarantaine" ) } Write-SPMLog -Level INFO -Operation 'Migration' -Message "$($units.Count) unité(s) de travail à traiter" # Ce qui a été transféré, pour la passe de métadonnée. On n'y remet que # les unités réellement traitées : réappliquer sur une unité ignorée # ferait mentir le bilan. $migrees = [System.Collections.Generic.List[object]]::new() # ------------------------------------------------------------ exécution # Une migration dure des heures. Sans avancement, l'opérateur ne peut # pas distinguer un travail lent d'un travail bloqué, et la seule # information dont il dispose arrive à la fin. $traitees = 0 $debut = Get-Date foreach ($unit in $units) { $traitees++ if ($units.Count -gt 1) { $ecoule = (Get-Date) - $debut $reste = if ($traitees -gt 1 -and $ecoule.TotalSeconds -gt 0) { $parUnite = $ecoule.TotalSeconds / ($traitees - 1) [timespan]::FromSeconds([math]::Round($parUnite * ($units.Count - $traitees))) } else { $null } Write-Progress -Id 1 -Activity "Migration « $($Plan.Name) »" ` -Status "$traitees / $($units.Count) — $($unit.Kind) $(Split-Path -Leaf $unit.Source)" ` -PercentComplete ([math]::Min(100, [int](100 * $traitees / $units.Count))) ` -SecondsRemaining $(if ($reste) { [int]$reste.TotalSeconds } else { -1 }) } $key = Get-SPMStateKey -SourceUrl $unit.Source -TargetUrl $unit.Target $signature = if ($useIncremental) { $unit.Signature } else { $null } if (Test-SPMUnitCompleted -Key $key -SourceSignature $signature) { $skipped++ Write-SPMLog -Level DEBUG -Operation 'Migration' -Message "Déjà à jour, ignorée : $($unit.Source)" continue } # ------------------------------------------------- idempotence # L'état persistant ne suffit pas : une exécution sans -Resume le # repart à neuf, et le contenu se retrouve copié une seconde fois. # Mesuré : 63 éléments à la source, 180 à destination après trois # exécutions. On interroge donc la DESTINATION, seule source de # vérité sur ce qui s'y trouve déjà. if ($destConnection -and -not $Plan.Options.overwrite) { $cible = if ($unit.Kind -eq 'File') { "$($unit.Target)/$(Split-Path -Leaf $unit.Source)" } else { $unit.Target } if (Test-SPMPathExists -ServerRelativeUrl $cible -Connection $destConnection) { $skipped++ $migrees.Add($unit) # présent à destination : sa métadonnée reste à poser Write-SPMStateRecord -Key $key -Status Skipped -SourceUrl $unit.Source -TargetUrl $unit.Target Write-SPMLog -Level DEBUG -Operation 'Migration' -Message "Déjà présent à destination : $cible" continue } } Write-SPMStateRecord -Key $key -Status Running -SourceUrl $unit.Source -TargetUrl $unit.Target # ---------------------------------------- dossier en granularité item # Un travail de copie sur un DOSSIER emporte tout son sous-arbre : # le service est récursif par nature. Comme la granularité « item » # émet aussi une unité par fichier, chaque fichier était copié deux # fois — d'où 180 éléments à destination pour 63 à la source. # # En granularité « item », un dossier se contente donc d'exister. # Ses fichiers sont copiés un à un, ce qui préserve la reprise # partielle et l'avancement, sans doubler quoi que ce soit. if ($unit.Kind -eq 'Folder' -and "$($Plan.Options.granularity)" -eq 'item' -and $destConnection) { try { if ($PSCmdlet.ShouldProcess($unit.Target, 'Créer le dossier')) { Resolve-SPMTargetFolder -ServerRelativeUrl $unit.Target -Connection $destConnection $succeeded++ $migrees.Add($unit) Write-SPMStateRecord -Key $key -Status Completed -SourceUrl $unit.Source ` -TargetUrl $unit.Target -SourceSignature $unit.Signature } else { $simulated++ } } catch { $failed++ Write-SPMStateRecord -Key $key -Status Failed -SourceUrl $unit.Source ` -TargetUrl $unit.Target -Error ($_.Exception.Message -split [char]10)[0] } continue } $result = Invoke-SPMCopyJob ` -SourceUrl $unit.SourceUrl ` -LocalPath $unit.LocalPath ` -TargetUrl $unit.Target ` -Kind $unit.Kind ` -PreserveVersions ([bool]$Plan.Options.preserveVersions) ` -Overwrite:([bool]$Plan.Options.overwrite) ` -Recurse:([bool]$Plan.Options.recurse) ` -AllowSchemaMismatch:([bool]$Plan.Options.allowSchemaMismatch) ` -NoWait:([bool]$Plan.Options.async) ` -Connection $submitConnection ` -DestinationConnection $destConnection if ($result.Simulated) { $simulated++ continue } # ---------------------------------------------- repli niveau 2 # La bascule est décidée PAR UNITÉ. Un drapeau global condamnerait # toute la suite de l'exécution sur un échec isolé — un fichier # verrouillé suffirait à faire perdre l'historique de tous les autres. if (-not $result.Success -and $Plan.Options.allowLocalFallback -and $unit.Kind -eq 'File' -and $srcConnection) { Write-SPMLog -Level WARNING -Operation 'Transfer' -Message ( "Copie serveur refusée sur $($unit.Source), repli local — " + 'HISTORIQUE DES VERSIONS PERDU pour cet élément.' ) $repli = Invoke-SPMLocalTransfer -SourceUrl $unit.SourceUrl ` -TargetFolderServerRelative $unit.Target ` -SourceConnection $srcConnection -DestinationConnection $destConnection if ($repli.Success) { $result = [pscustomobject]@{ Success = $true; Simulated = $false; JobId = $null Duration = $result.Duration; Source = $result.Source Target = $result.Target; Error = $null Mechanism = 'local'; VersionsLost = $true } $fallbackUsed++ } } # ------------------------------------------- jeton expiré, rejeu # Une migration dure des heures, un jeton interactif environ une. # Sans cette reprise, l'expiration faisait échouer DÉFINITIVEMENT # toutes les unités restantes : des milliers d'échecs pour une # connexion à rouvrir. Le plan porte les paramètres d'authen- # tification ; la connexion en pool, elle, ne les porte pas — c'est # donc ici, et pas dans Invoke-SPMWithRetry, que la réparation a lieu. if (-not $result.Success -and (Test-SPMAuthExpired -Message $result.Error)) { Write-SPMLog -Level WARNING -Operation 'Migration' -Message ( "Authentification expirée sur $($unit.Source) : réouverture des connexions puis rejeu." ) try { $destConnection = Get-SPMPlanConnection -Plan $Plan -Which Destination -Validate if ($Plan.Source.Type -eq 'sharepoint') { $srcConnection = Get-SPMPlanConnection -Plan $Plan -Which Source -Validate } $submitConnection = $srcConnection ?? $destConnection $reconnects++ $result = Invoke-SPMCopyJob ` -SourceUrl $unit.SourceUrl ` -LocalPath $unit.LocalPath ` -TargetUrl $unit.Target ` -Kind $unit.Kind ` -PreserveVersions ([bool]$Plan.Options.preserveVersions) ` -Overwrite:([bool]$Plan.Options.overwrite) ` -Recurse:([bool]$Plan.Options.recurse) ` -AllowSchemaMismatch:([bool]$Plan.Options.allowSchemaMismatch) ` -NoWait:([bool]$Plan.Options.async) ` -Connection $submitConnection ` -DestinationConnection $destConnection } catch { Write-SPMLog -Level ERROR -Operation 'Migration' -Message ( "Réouverture impossible : $(($_.Exception.Message -split [char]10)[0]). " + 'Les unités suivantes échoueront de la même façon.' ) } } if ($result.Success) { $migrees.Add($unit) # Une soumission asynchrone n'est pas une réussite : le service # a accepté le travail, il ne l'a pas encore exécuté. L'écrire # « Completed » produisait un rapport à 100 % de réussite sur une # destination restée vide. if ($Plan.Options.async) { $submitted++ Write-SPMStateRecord -Key $key -Status Submitted -SourceUrl $unit.Source -TargetUrl $unit.Target ` -JobId $result.JobId ` -Data @{ durationMs = [int]$result.Duration.TotalMilliseconds } } else { $succeeded++ Write-SPMStateRecord -Key $key -Status Completed -SourceUrl $unit.Source -TargetUrl $unit.Target ` -SourceSignature $unit.Signature -JobId $result.JobId ` -Data @{ durationMs = [int]$result.Duration.TotalMilliseconds } } } else { $failed++ Write-SPMStateRecord -Key $key -Status Failed -SourceUrl $unit.Source -TargetUrl $unit.Target ` -Error $result.Error } } Write-Progress -Id 1 -Activity "Migration « $($Plan.Name) »" -Completed # ------------------------------------------- passe de métadonnée # EN ORDRE INVERSE, et c'est tout l'intérêt : déposer un fichier dans un # dossier met à jour le Modified et le « Modifié par » de ce dossier. # L'énumération rend les parents avant leurs enfants ; les parcourir à # l'envers garantit donc qu'un dossier est estampillé APRÈS son contenu. if ($Plan.Options.applyMetadata -and $srcConnection -and $destConnection -and $migrees.Count) { Write-SPMLog -Level INFO -Operation 'Metadata' -Message ( "Application de la métadonnée sur $($migrees.Count) élément(s), du plus profond au plus haut" ) Clear-SPMFieldCache $libraryTitle = Get-SPMListTitleForPath -ServerRelativeUrl $units[0].Target -Connection $destConnection if (-not $libraryTitle) { Write-SPMLog -Level WARNING -Operation 'Metadata' -Message ( "Bibliothèque de destination non identifiée pour $($units[0].Target) : métadonnée non appliquée." ) } else { for ($i = $migrees.Count - 1; $i -ge 0; $i--) { $unit = $migrees[$i] Write-Progress -Id 2 -Activity 'Application de la métadonnée' ` -Status "$($migrees.Count - $i) / $($migrees.Count) — $($unit.Relative)" ` -PercentComplete ([math]::Min(100, [int](100 * ($migrees.Count - $i) / $migrees.Count))) try { $srcItem = Get-SPMItemForPath -ServerRelativeUrl $unit.Source -Kind $unit.Kind ` -Connection $srcConnection $dstPath = if ($unit.Kind -eq 'File') { "$($unit.Target)/$(Split-Path -Leaf $unit.Source)" } else { $unit.Target } $dstItem = Get-SPMItemForPath -ServerRelativeUrl $dstPath -Kind $unit.Kind ` -Connection $destConnection if (-not $srcItem -or -not $dstItem) { $metadataFailed++ # Dire LEQUEL manque : « métadonnée non appliquée » # sans le chemin ne se diagnostique pas. $manquant = if (-not $srcItem) { "source $($unit.Source)" } else { "destination $dstPath" } Write-SPMLog -Level WARNING -Operation 'Metadata' -Message ( "Element introuvable, métadonnée non appliquée : $manquant" ) continue } $meta = Set-SPMItemMetadata -SourceItem $srcItem -TargetItem $dstItem ` -LibraryTitle $libraryTitle -Connection $destConnection if ($meta.FailedFields.Count) { $metadataFailed++ } else { $metadataApplied++ } # ----------------------------- rétention et modération # Ces états ne voyagent pas avec le fichier : un document # sous étiquette « Conserver 7 ans » arrivait sans # étiquette, donc supprimable, et rien ne le disait. $conf = Set-SPMItemCompliance -SourceItem $srcItem -TargetItem $dstItem ` -LibraryTitle $libraryTitle -Connection $destConnection if ($conf.Label -like 'réappliquée*') { $labelsApplied++ } if ($conf.Record -eq 'NON restauré') { $recordsLost++ } foreach ($p in $conf.Problems) { $complianceProblems++ Write-SPMLog -Level WARNING -Operation 'Compliance' -Message "$($unit.Relative) — $p" } Write-SPMLog -Level DEBUG -Operation 'Metadata' -Message ( "$($unit.Kind) $($unit.Relative) — colonnes $($meta.Fields), " + "authorship $($meta.Authorship), type $($meta.ContentType)" + $(if ($meta.FailedFields.Count) { " — resistent : $($meta.FailedFields -join ', ')" }) ) } catch { $metadataFailed++ Write-SPMLog -Level WARNING -Operation 'Metadata' -Message ( "Métadonnée non appliquée sur $($unit.Source) : $(($_.Exception.Message -split [char]10)[0])" ) } } } } Write-Progress -Id 2 -Activity 'Application de la métadonnée' -Completed # ------------------------------------------------------ permissions # options.preservePermissions existait dans le plan, était documentée, et # n'était lue que pour émettre un avertissement : la commande n'était # appelée que depuis le menu. Une option qui ne fait rien est pire qu'une # option absente — elle fait croire au travail accompli. $permissions = $null if ($Plan.Options.preservePermissions -and $srcConnection -and $destConnection -and $migrees.Count) { $libSource = Get-SPMListTitleForPath -ServerRelativeUrl (Get-SPMServerRelativePath -Url $Plan.Source.Url) ` -Connection $srcConnection $libCible = Get-SPMListTitleForPath -ServerRelativeUrl $units[0].Target -Connection $destConnection if (-not $libSource -or -not $libCible) { Write-SPMLog -Level WARNING -Operation 'Permissions' -Message ( 'Bibliothèques non identifiées : permissions NON reportées. ' + 'Les éléments à héritage rompu restent sur les droits du parent à destination.' ) } else { $permissions = Copy-SPMigrationPermission ` -SourceSiteUrl (Get-SPMSiteUrlFromResourceUrl -Url $Plan.Source.Url) ` -TargetSiteUrl (Get-SPMSiteUrlFromResourceUrl -Url $Plan.Destination.Url) ` -SourceList $libSource -TargetList $libCible ` -SourceConnection $srcConnection -DestinationConnection $destConnection ` -IdentityMapPath $IdentityMapPath -Confirm:$false -WhatIf:$WhatIfPreference if (-not $permissions.Ok) { Write-SPMLog -Level ERROR -Operation 'Permissions' -Message ( "Report des permissions INCOMPLET : $($permissions.Failures.Count) échec(s), " + "$($permissions.Surplus.Count) surplus de droit. La destination ne reflète pas la source." ) } } } # ----------------------------------------------- preuve de fidélité # Le bilan qui suit dérive de l'état : il dit ce que le moteur croit # avoir fait. Cette passe-ci va voir ce qui se trouve à destination. $fidelite = $null if ($Plan.Options.verify -and $srcConnection -and $destConnection -and -not $WhatIfPreference) { try { $fidelite = Test-SPMigrationFidelity -SourceUrl $Plan.Source.Url -TargetUrl $Plan.Destination.Url ` -SourceConnection $srcConnection -DestinationConnection $destConnection if (-not $fidelite.Ok) { Write-SPMLog -Level ERROR -Operation 'Migration' -Message ( "Fidélité : $($fidelite.Errors) écart(s) mesuré(s) à destination sur " + "$($fidelite.Compared) élément(s) comparé(s). Le transfert a réussi ; la copie n'est pas conforme." ) } } catch { Write-SPMLog -Level ERROR -Operation 'Migration' -Message ( "Vérification de fidélité impossible : $(($_.Exception.Message -split [char]10)[0]). " + 'Le résultat de la migration reste donc NON vérifié à destination.' ) } } # ------------------------------------- ce qui n'a PAS été migré # Un rapport qui ne liste que les réussites laisse croire à une copie # exhaustive. Ces exclusions sont volontaires et documentées : elles # doivent figurer dans le bilan, pas dans la mémoire de celui qui a # écrit le moteur. $nonMigre = [System.Collections.Generic.List[string]]::new() $nonMigre.Add('Dossiers système (Forms, _vti_history, _catalogs) — recréés avec la bibliothèque') if (-not $Plan.Options.preservePermissions) { $nonMigre.Add('Permissions uniques — options.preservePermissions est à faux') } elseif ($permissions) { $nonMigre.Add( "Permissions : $($permissions.Applied) appliquée(s), $($permissions.Failures.Count) échec(s), " + "$($permissions.Surplus.Count) surplus de droit" ) } $nonMigre.Add('Liens de partage et étiquettes de confidentialité') $nonMigre.Add('Pages, composants WebPart, affichages de liste personnalisés') $nonMigre.Add('Alertes, abonnements, corbeille') if ($metadataFailed) { $nonMigre.Add("Champs de métadonnée sur $metadataFailed élément(s) — voir le journal") } if ($ecartes) { $nonMigre.Add("$ecartes élément(s) EN QUARANTAINE — refusés par le pré-vol, voir le fichier .quarantine.ndjson") } if ($recordsLost) { # Un enregistrement se DÉCLARE, il ne s'écrit pas : le passer sous # silence ferait croire à une copie conforme d'un document qui a # perdu son statut — la perte qu'un audit révèle deux ans plus tard. $nonMigre.Add("Statut d'enregistrement de $recordsLost élément(s) — À REDÉCLARER à destination") } if ($complianceProblems -and -not $recordsLost) { $nonMigre.Add("Rétention ou modération sur $complianceProblems élément(s) — voir le journal") } if ($fallbackUsed) { $nonMigre.Add("Historique des versions de $fallbackUsed élément(s) passés par le repli local") } Write-SPMLog -Level INFO -Operation 'Migration' -Message ( "Non migré, par décision : $($nonMigre -join ' | ')" ) if ($Plan.Options.applyMetadata) { $niveau = if ($metadataFailed) { 'WARNING' } else { 'SUCCESS' } Write-SPMLog -Level $niveau -Operation 'Metadata' -Message ( "Métadonnée : $metadataApplied élément(s) traité(s) sans réserve, $metadataFailed avec au moins un champ non appliqué" ) -Data @{ applied = $metadataApplied; failed = $metadataFailed } } if ($fallbackUsed) { Write-SPMLog -Level WARNING -Operation 'Migration' -Message ( "$fallbackUsed élément(s) transféré(s) par repli local : leur HISTORIQUE DE VERSIONS " + "est perdu. Seule la version courante est arrivée." ) } if ($labelsApplied -or $recordsLost) { $niveauConf = if ($recordsLost) { 'WARNING' } else { 'SUCCESS' } Write-SPMLog -Level $niveauConf -Operation 'Compliance' -Message ( "Rétention : $labelsApplied étiquette(s) réappliquée(s)" + $(if ($recordsLost) { ", $recordsLost enregistrement(s) NON restauré(s) — leur statut doit être redéclaré à destination" }) ) -Data @{ labels = $labelsApplied; records = $recordsLost; problems = $complianceProblems } } if ($reconnects) { Write-SPMLog -Level WARNING -Operation 'Migration' -Message ( "$reconnects réouverture(s) de connexion après expiration du jeton. " + "Sur une fenêtre aussi longue, une authentification par certificat évite l'interruption." ) } if ($submitted) { Write-SPMLog -Level WARNING -Operation 'Migration' -Message ( "$submitted travail(aux) SOUMIS et non confirmés (options.async). " + "Leur exécution par le service n'est pas vérifiée : ne les comptez pas comme migrés " + "tant que le suivi de travail n'est pas branché. Posez options.async = false pour " + "obtenir un résultat confirmé." ) } $duration = (Get-Date) - $started $spmStats = Get-SPMStatistics Write-SPMLog -Level $(if ($failed -eq 0 -and $submitted -eq 0) { 'SUCCESS' } else { 'WARNING' }) -Operation 'Migration' -Message ( "Terminé en $($duration.ToString('hh\:mm\:ss')) — $succeeded confirmée(s), $failed en échec, $skipped ignorée(s)" + $(if ($submitted) { ", $submitted SOUMISE(S) NON CONFIRMÉE(S)" }) + $(if ($simulated) { ", $simulated simulée(s)" }) ) -Data @{ succeeded = $succeeded submitted = $submitted failed = $failed skipped = $skipped simulated = $simulated fallbackUsed = $fallbackUsed reconnects = $reconnects metadataApplied = $metadataApplied metadataFailed = $metadataFailed durationMs = [int]$duration.TotalMilliseconds retries = $spmStats.Retries throttleEvents = $spmStats.ThrottleEvents } if ($failed -gt 0) { Write-SPMLog -Level INFO -Operation 'Migration' -Message ( "Relancez avec -Resume pour ne retraiter que les $failed unité(s) en échec." ) } return [pscustomobject]@{ Succeeded = $succeeded Submitted = $submitted Failed = $failed Skipped = $skipped Simulated = $simulated FallbackUsed = $fallbackUsed VersionsLost = $fallbackUsed Reconnects = $reconnects MetadataApplied = $metadataApplied MetadataFailed = $metadataFailed LabelsReapplied = $labelsApplied RecordsNotRestored = $recordsLost Permissions = $permissions Fidelity = $fidelite Quarantined = $ecartes NotMigrated = $nonMigre.ToArray() Aborted = $false Reason = $null Duration = $duration StatePath = $StatePath Resilience = $spmStats Findings = if ($preflight) { $preflight.Findings } else { @() } } } } |