Public/New-SPMigrationPlan.ps1
|
function New-SPMigrationPlan { <# .SYNOPSIS Charge et valide un plan de migration déclaratif. .DESCRIPTION Remplace la configuration par une série de Read-Host, qui rendait toute exécution non surveillée impossible. Le plan est un fichier JSON (ou YAML si le module powershell-yaml est présent) validé à la lecture. Les valeurs de la forme ${NOM_VARIABLE} sont résolues depuis les variables d'environnement : aucun secret n'a à figurer dans le fichier. .PARAMETER Path Chemin du fichier de plan (.json, .yaml ou .yml). .PARAMETER InputObject Plan déjà matérialisé sous forme de hashtable ou d'objet — utile pour les tests. .EXAMPLE $plan = New-SPMigrationPlan -Path .\examples\plan-sharepoint-to-sharepoint.json Test-SPMigrationPlan -Plan $plan .OUTPUTS Objet plan normalisé, consommable par Test-SPMigrationPlan et Invoke-SPMigration. #> [CmdletBinding(DefaultParameterSetName = 'Path')] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ParameterSetName = 'Path', Position = 0)] [string]$Path, [Parameter(Mandatory, ParameterSetName = 'Object')] [object]$InputObject ) # ---------------------------------------------------------------- lecture if ($PSCmdlet.ParameterSetName -eq 'Path') { if (-not (Test-Path -LiteralPath $Path)) { throw "Plan de migration introuvable : $Path" } $raw = Get-Content -LiteralPath $Path -Raw -Encoding utf8 $extension = [System.IO.Path]::GetExtension($Path).ToLowerInvariant() switch ($extension) { '.json' { $parsed = $raw | ConvertFrom-Json -AsHashtable } { $_ -in '.yaml', '.yml' } { if (-not (Get-Module powershell-yaml -ListAvailable)) { throw "Le format YAML requiert le module powershell-yaml. " + "Installez-le (Install-Module powershell-yaml -Scope CurrentUser) " + "ou fournissez le plan au format JSON." } Import-Module powershell-yaml -ErrorAction Stop $parsed = ConvertFrom-Yaml $raw } default { throw "Extension de plan non prise en charge : '$extension' (attendu .json, .yaml ou .yml)" } } } else { $parsed = if ($InputObject -is [hashtable]) { $InputObject } else { $InputObject | ConvertTo-Json -Depth 12 | ConvertFrom-Json -AsHashtable } } if ($null -eq $parsed) { throw 'Plan de migration vide.' } # ------------------------------------------- résolution des ${VARIABLES} $parsed = Resolve-SPMPlanVariable -Value $parsed # ------------------------------------------------------------ validation # Note : Set-StrictMode -Version Latest fait échouer l'accès à une clé # absente d'une hashtable. Toute lecture passe donc par Get-SPMPlanValue. $problems = [System.Collections.Generic.List[string]]::new() $source = Get-SPMPlanValue $parsed 'source' $destination = Get-SPMPlanValue $parsed 'destination' $auth = Get-SPMPlanValue $parsed 'auth' if ($null -eq $destination) { $problems.Add("Section 'destination' manquante") } elseif (-not (Get-SPMPlanValue $destination 'url')) { $problems.Add("'destination.url' manquant") } $sourceType = 'sharepoint' if ($null -eq $source) { $problems.Add("Section 'source' manquante") } else { $declaredType = Get-SPMPlanValue $source 'type' if ($declaredType) { $sourceType = "$declaredType".ToLowerInvariant() } if ($sourceType -notin @('sharepoint', 'fileshare')) { $problems.Add("'source.type' doit valoir 'sharepoint' ou 'fileshare' (reçu : $sourceType)") } if ($sourceType -eq 'sharepoint' -and -not (Get-SPMPlanValue $source 'url')) { $problems.Add("'source.url' est obligatoire pour source.type = sharepoint") } if ($sourceType -eq 'fileshare' -and -not (Get-SPMPlanValue $source 'path')) { $problems.Add("'source.path' est obligatoire pour source.type = fileshare") } } $authMode = 'interactive' if ($null -ne $auth) { $declaredMode = Get-SPMPlanValue $auth 'mode' if ($declaredMode) { $authMode = "$declaredMode".ToLowerInvariant() } if ($authMode -notin @('interactive', 'devicelogin', 'certificate', 'managedidentity')) { $problems.Add("'auth.mode' invalide : $authMode") } if ($authMode -in @('interactive', 'devicelogin', 'certificate') -and -not (Get-SPMPlanValue $auth 'clientId')) { $problems.Add("'auth.clientId' est obligatoire pour le mode $authMode " + "(l'application PnP multi-tenant par défaut a été retirée en 2024)") } if ($authMode -eq 'certificate') { if (-not (Get-SPMPlanValue $auth 'tenant')) { $problems.Add("'auth.tenant' est obligatoire pour le mode certificate") } if (-not (Get-SPMPlanValue $auth 'thumbprint') -and -not (Get-SPMPlanValue $auth 'certificatePath')) { $problems.Add("'auth.thumbprint' ou 'auth.certificatePath' est obligatoire pour le mode certificate") } } } if ($problems.Count -gt 0) { throw "Plan de migration invalide :`n - " + ($problems -join "`n - ") } # ----------------------------------------------------------- normalisation $options = Merge-SPMPlanSection -Defaults @{ preserveVersions = $true preservePermissions = $false incremental = $false overwrite = $false recurse = $true allowSchemaMismatch = $false # async = $false tant que le suivi de travail (Receive-PnPCopyMoveJobStatus) # n'est pas branché : une soumission asynchrone ne peut être confirmée, et # un défaut à $true faisait rapporter comme migré ce qui n'était que soumis. async = $false # 'item' : une unité par fichier et par dossier. Reprise partielle, # avancement visible, repli par élément. Un travail de copie # par élément — plus lent, mais reprenable. # 'root' : une seule unité pour tout le sous-arbre. Le plus rapide, # mais un échec en fin de course fait tout recommencer. granularity = 'item' # Réapplique la métadonnée source après transfert au lieu de la supposer # préservée. Écriture idempotente (SystemUpdate). applyMetadata = $true # Compare la destination à la source en fin de migration. Le rapport dit # ce que le moteur croit avoir fait ; cette passe dit ce qui s'y trouve. # Coûteuse — elle relit tout — donc explicite, jamais implicite. verify = $false # Repli téléchargement/téléversement si le travail de copie est refusé. # PERD L'HISTORIQUE DES VERSIONS : désactivé par défaut, pour que la # perte soit un choix et non une surprise. allowLocalFallback = $false } -Provided (Get-SPMPlanValue $parsed 'options') if (Get-SPMPlanValue (Get-SPMPlanValue $parsed 'options') 'throttle' | ForEach-Object { Get-SPMPlanValue $_ 'maxParallel' }) { Write-SPMLog -Level WARNING -Operation 'Plan' -Message ( "'throttle.maxParallel' est présent dans le plan mais IGNORÉ : le moteur ne parallélise " + 'pas côté client. Retirez-le pour éviter de croire à un réglage de débit inexistant.' ) } $throttle = Merge-SPMPlanSection -Defaults @{ # maxParallel a été RETIRÉ. Il figurait ici, était documenté, et n'était # lu par aucune ligne du moteur : la parallélisation repose entièrement # sur l'asynchronisme du service. Une option qui ne fait rien laisse # croire à un réglage de débit qui n'existe pas. Un plan qui la porte # encore reste accepté — elle est simplement ignorée, et signalée. maxRetries = 6 baseDelayMs = 1000 maxDelayMs = 120000 } -Provided (Get-SPMPlanValue $options 'throttle') $validation = Merge-SPMPlanSection -Defaults @{ maxUrlLength = 400 blockedExtensions = @() failOnCheckedOut = $true allowEmptyFiles = $false # Une étiquette de rétention perdue ne se voit dans aucun rapport, et se # découvre en audit. Le pré-vol BLOQUE donc sur des éléments étiquetés ou # déclarés enregistrements, jusqu'à ce que la perte possible soit # explicitement acceptée. Le défaut protège ; l'exception se déclare. acceptLabelLoss = $false # Que faire d'un élément que le pré-vol refuse. # 'stop' : la migration n'a pas lieu. Défaut. # 'exclude' : l'élément est mis en QUARANTAINE — écarté, listé dans un # fichier dédié, compté dans le bilan — et le reste part. # Sur des dizaines de milliers de fichiers il y aura toujours un # « ~$brouillon.docx » ou un fichier vide. La seule échappatoire était # -SkipPreflight, qui désactive TOUS les contrôles, y compris la # longueur d'URL : une porte de sortie qui coûte plus cher que le # problème qu'elle contourne. onError = 'stop' } -Provided (Get-SPMPlanValue $parsed 'validation') if ("$($validation.onError)" -notin 'stop', 'exclude') { throw "validation.onError doit valoir 'stop' ou 'exclude', pas '$($validation.onError)'." } $planName = Get-SPMPlanValue $parsed 'name' if (-not $planName) { $planName = if ($PSCmdlet.ParameterSetName -eq 'Path') { [System.IO.Path]::GetFileNameWithoutExtension($Path) } else { 'plan' } } $plan = [pscustomobject]@{ Version = (Get-SPMPlanValue $parsed 'version') ?? 1 Name = $planName Source = [pscustomobject]@{ Type = $sourceType Url = Get-SPMPlanValue $source 'url' Path = Get-SPMPlanValue $source 'path' } Destination = [pscustomobject]@{ Url = Get-SPMPlanValue $destination 'url' } Auth = [pscustomobject]@{ Mode = $authMode ClientId = Get-SPMPlanValue $auth 'clientId' Tenant = Get-SPMPlanValue $auth 'tenant' Thumbprint = Get-SPMPlanValue $auth 'thumbprint' CertificatePath = Get-SPMPlanValue $auth 'certificatePath' } Options = [pscustomobject]$options Throttle = [pscustomobject]$throttle Validation = [pscustomobject]$validation Tasks = @((Get-SPMPlanValue $parsed 'tasks') ?? @()) SourceFile = if ($PSCmdlet.ParameterSetName -eq 'Path') { (Resolve-Path $Path).Path } else { $null } } Write-SPMLog -Level SUCCESS -Operation 'Plan' -Message ( "Plan '$($plan.Name)' chargé : $($plan.Source.Type) -> $($plan.Destination.Url), " + "$($plan.Tasks.Count) tâche(s), versions=$($plan.Options.preserveVersions), incrémental=$($plan.Options.incremental)" ) return $plan } |