# DeployCenter - Partitionnement et installation d'une edition Windows # Compatible Windows PowerShell 5.1 / WinPE. Import-Module (Join-Path $PSScriptRoot 'Unattend.psm1') -Force function Write-DeployCenterDeploymentLog { param([AllowNull()][scriptblock]$LogAction, [string]$Message, [string]$Level = 'INFO') if ($LogAction) { & $LogAction $Message $Level } } function Update-DeployCenterHostStorageCache { param([AllowNull()][scriptblock]$LogAction = $null) try { Update-HostStorageCache -ErrorAction Stop Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message 'Cache de stockage WinPE actualisé.' -Level 'INFO' return $true } catch { Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("Actualisation du cache de stockage WinPE indisponible : {0}" -f $_.Exception.Message) ` -Level 'WARNING' return $false } } function Restart-DeployCenterWinPE { [CmdletBinding()] param([AllowNull()][scriptblock]$LogAction = $null) $wpeutilPath = Join-Path $env:WINDIR 'System32\wpeutil.exe' if (-not (Test-Path -LiteralPath $wpeutilPath -PathType Leaf)) { throw 'wpeutil.exe est introuvable ; le redémarrage WinPE ne peut pas être demandé.' } Write-DeployCenterDeploymentLog -LogAction $LogAction -Message 'Redémarrage de WinPE demandé via wpeutil.' -Level 'INFO' $output = @(& $wpeutilPath Reboot 2>&1) $exitCode = $LASTEXITCODE foreach ($line in $output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' } } if ($null -ne $exitCode -and $exitCode -ne 0) { throw "wpeutil Reboot a renvoyé le code $exitCode." } } function ConvertTo-DeployCenterNativeArgument { param([AllowNull()][string]$Value) if ($null -eq $Value) { $Value = '' } if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') { return $Value } $builder = New-Object System.Text.StringBuilder [void]$builder.Append('"') $backslashCount = 0 foreach ($character in $Value.ToCharArray()) { if ($character -eq [char]92) { $backslashCount++ continue } if ($character -eq [char]34) { if ($backslashCount -gt 0) { [void]$builder.Append(('\' * (2 * $backslashCount))) } [void]$builder.Append('\"') $backslashCount = 0 continue } if ($backslashCount -gt 0) { [void]$builder.Append(('\' * $backslashCount)) } [void]$builder.Append($character) $backslashCount = 0 } if ($backslashCount -gt 0) { [void]$builder.Append(('\' * (2 * $backslashCount))) } [void]$builder.Append('"') return $builder.ToString() } function New-DeployCenterDismSourceAccess { param( [Parameter(Mandatory=$true)][string]$ImagePath, [Parameter(Mandatory=$true)][string]$TemporaryRoot, [AllowNull()][scriptblock]$LogAction = $null ) $lease = [pscustomobject]@{ ImagePath = $ImagePath; DriveLetter = $null; VolumeRoot = $null } if (-not $ImagePath.StartsWith('\\?\Volume{', [StringComparison]::OrdinalIgnoreCase)) { return $lease } $volumeEnd = $ImagePath.IndexOf('}\', [StringComparison]::OrdinalIgnoreCase) if ($volumeEnd -lt 0) { throw "Chemin de volume GUID invalide pour l’image : $ImagePath" } $volumeRoot = $ImagePath.Substring(0, $volumeEnd + 2) $relativePath = $ImagePath.Substring($volumeEnd + 2) $usedLetters = @{} foreach ($volume in @(Get-Volume -ErrorAction SilentlyContinue)) { if ($volume.DriveLetter) { $usedLetters[([string]$volume.DriveLetter).ToUpperInvariant()] = $true } } foreach ($drive in @(Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue)) { $usedLetters[([string]$drive.Name).ToUpperInvariant()] = $true } $letter = $null foreach ($candidate in @('Z','Y','X','W','V','U','T','S','R','Q','P','O','N','M','L','K','J','I','H','G','F','E')) { if ($candidate -notin @('C','D') -and -not $usedLetters.ContainsKey($candidate)) { $letter = $candidate break } } if (-not $letter) { throw 'Aucune lettre temporaire disponible pour accéder à la source Windows.' } $mountVolPath = Join-Path $env:WINDIR 'System32\mountvol.exe' if (-not (Test-Path -LiteralPath $mountVolPath -PathType Leaf)) { $mountVolPath = 'mountvol.exe' } $letterPath = ("{0}:\{1}" -f $letter, $relativePath) $mountError = $null try { Invoke-DeployCenterNativeCommand -FilePath $mountVolPath -Arguments @(("{0}:\" -f $letter), $volumeRoot) ` -TemporaryRoot $TemporaryRoot -LogAction $LogAction } catch { $mountError = $_.Exception } if (-not (Test-Path -LiteralPath $letterPath -PathType Leaf)) { try { Invoke-DeployCenterNativeCommand -FilePath $mountVolPath -Arguments @(("{0}:\" -f $letter), '/D') ` -TemporaryRoot $TemporaryRoot -LogAction $LogAction } catch { } throw "La source Windows n’est pas accessible après l’affectation temporaire de ${letter}:." } if ($mountError) { Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("mountvol n’a pas fourni de code de sortie exploitable, mais la source est accessible via {0}:. L’affectation est confirmée." -f $letter) ` -Level 'WARNING' } $lease.ImagePath = $letterPath $lease.DriveLetter = $letter $lease.VolumeRoot = $volumeRoot return $lease } function Remove-DeployCenterDismSourceAccess { param( [AllowNull()]$Lease, [Parameter(Mandatory=$true)][string]$TemporaryRoot, [AllowNull()][scriptblock]$LogAction = $null ) if ($null -eq $Lease -or -not $Lease.DriveLetter) { return } $mountVolPath = Join-Path $env:WINDIR 'System32\mountvol.exe' if (-not (Test-Path -LiteralPath $mountVolPath -PathType Leaf)) { $mountVolPath = 'mountvol.exe' } $releaseError = $null try { Invoke-DeployCenterNativeCommand -FilePath $mountVolPath -Arguments @(("{0}:\" -f $Lease.DriveLetter), '/D') ` -TemporaryRoot $TemporaryRoot -LogAction $LogAction } catch { $releaseError = $_.Exception } [void](Update-DeployCenterHostStorageCache -LogAction $LogAction) if (Test-Path -LiteralPath ([string]$Lease.ImagePath) -PathType Leaf) { $details = if ($releaseError) { $releaseError.Message } else { 'Le fichier source reste accessible.' } Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message "Impossible de confirmer la libération de la lettre temporaire $($Lease.DriveLetter): $details" -Level 'WARNING' } elseif ($releaseError) { Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("mountvol n’a pas fourni de code de sortie exploitable, mais la source n’est plus accessible via {0}:. La libération est confirmée." -f $Lease.DriveLetter) ` -Level 'WARNING' } } function Get-DeployCenterNativeProgressPercent { param([Parameter(Mandatory=$true)][string]$OutputPath) $stream = $null $reader = $null try { $stream = [System.IO.File]::Open($OutputPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::Default, $true) $content = $reader.ReadToEnd() } catch { return $null } finally { if ($reader) { $reader.Dispose() } elseif ($stream) { $stream.Dispose() } } $progressMatches = [regex]::Matches($content, '(?\d{1,3}(?:\.\d+)?)\s*%') if ($progressMatches.Count -eq 0) { return $null } $value = [double]$progressMatches[$progressMatches.Count - 1].Groups['percent'].Value return [int][math]::Max(0, [math]::Min(100, [math]::Floor($value))) } function Invoke-DeployCenterNativeCommand { param( [Parameter(Mandatory=$true)][string]$FilePath, [Parameter(Mandatory=$true)][string[]]$Arguments, [Parameter(Mandatory=$true)][string]$TemporaryRoot, [AllowNull()][scriptblock]$LogAction = $null, [AllowNull()][scriptblock]$ProgressAction = $null ) if (-not (Test-Path -LiteralPath $TemporaryRoot -PathType Container)) { New-Item -Path $TemporaryRoot -ItemType Directory -Force -ErrorAction Stop | Out-Null } $id = [guid]::NewGuid().ToString('N') $stdoutPath = Join-Path $TemporaryRoot ("native_{0}.out" -f $id) $stderrPath = Join-Path $TemporaryRoot ("native_{0}.err" -f $id) $dismLogPath = Join-Path $TemporaryRoot ("dism_{0}.log" -f $id) $isDism = [IO.Path]::GetFileName($FilePath) -ieq 'dism.exe' $isImageApply = $isDism -and @($Arguments | Where-Object { [string]$_ -ieq '/Apply-Image' }).Count -gt 0 if ($isDism) { $Arguments += "/LogPath:$dismLogPath" } $argumentLine = ($Arguments | ForEach-Object { ConvertTo-DeployCenterNativeArgument -Value ([string]$_) }) -join ' ' try { Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Commande native : {0} {1}" -f $FilePath, $argumentLine) $nativeOutputLines = New-Object System.Collections.Generic.List[string] if ($isImageApply) { $process = Start-Process -FilePath $FilePath -ArgumentList $argumentLine ` -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath ` -RedirectStandardError $stderrPath -ErrorAction Stop while (-not $process.WaitForExit(250)) { if ($ProgressAction) { [System.Windows.Forms.Application]::DoEvents() if (Test-Path -LiteralPath $stdoutPath -PathType Leaf) { $nativePercent = Get-DeployCenterNativeProgressPercent -OutputPath $stdoutPath if ($null -ne $nativePercent) { & $ProgressAction $null $nativePercent } } } } $process.WaitForExit() $process.Refresh() $exitCode = $process.ExitCode $nativeOutputEncoding = [System.Console]::OutputEncoding if ($null -eq $nativeOutputEncoding) { $nativeOutputEncoding = [System.Text.Encoding]::Default } foreach ($outputPath in @($stdoutPath, $stderrPath)) { if (Test-Path -LiteralPath $outputPath -PathType Leaf) { foreach ($line in [System.IO.File]::ReadAllLines($outputPath, $nativeOutputEncoding)) { if (-not [string]::IsNullOrWhiteSpace($line)) { [void]$nativeOutputLines.Add([string]$line) Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' } } } } if ($ProgressAction -and (Test-Path -LiteralPath $stdoutPath -PathType Leaf)) { $nativePercent = Get-DeployCenterNativeProgressPercent -OutputPath $stdoutPath if ($null -ne $nativePercent) { & $ProgressAction $null $nativePercent } } if ($null -eq $exitCode -and @($nativeOutputLines | Where-Object { $_ -match '(?i)completed successfully|a r.ussi' }).Count -gt 0) { Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message 'DISM confirme que Windows a été appliqué, mais le code de sortie est absent dans WinPE.' ` -Level 'WARNING' return } } else { $commandOutput = @(& $FilePath @Arguments 2>&1) $exitCode = $LASTEXITCODE foreach ($line in $commandOutput) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { [void]$nativeOutputLines.Add([string]$line) Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' } } } if ($null -eq $exitCode -or [int]$exitCode -ne 0) { $diagnostic = @() if ($isDism -and (Test-Path -LiteralPath $dismLogPath -PathType Leaf)) { $diagnostic = @(Get-Content -LiteralPath $dismLogPath -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { [string]$_ }) foreach ($line in $diagnostic) { Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("DISM : {0}" -f $line) -Level 'ERROR' } } $diagnosticMessage = $diagnostic -join ' | ' throw ("La commande {0} s'est terminée avec le code {1}. {2}" -f $FilePath, $exitCode, $diagnosticMessage) } } finally { foreach ($outputPath in @($stdoutPath, $stderrPath)) { if (Test-Path -LiteralPath $outputPath -PathType Leaf) { Remove-Item -LiteralPath $outputPath -Force -ErrorAction SilentlyContinue } } if (Test-Path -LiteralPath $dismLogPath -PathType Leaf) { Remove-Item -LiteralPath $dismLogPath -Force -ErrorAction SilentlyContinue } } } function Get-DeployCenterOfflineWindowsBootGuid { param( [Parameter(Mandatory=$true)][string]$BcdStorePath, [Parameter(Mandatory=$true)][string]$WindowsDriveLetter, [AllowNull()][scriptblock]$LogAction = $null ) if (-not (Test-Path -LiteralPath $BcdStorePath -PathType Leaf)) { throw "Magasin BCD introuvable après la création des fichiers de démarrage : $BcdStorePath" } $bcdeditPath = Join-Path $env:WINDIR 'System32\bcdedit.exe' if (-not (Test-Path -LiteralPath $bcdeditPath -PathType Leaf)) { $bcdeditPath = 'bcdedit.exe' } $output = @(& $bcdeditPath /store $BcdStorePath /enum osloader /v 2>&1) $exitCode = $LASTEXITCODE foreach ($line in $output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' } } if ($null -eq $exitCode -or [int]$exitCode -ne 0) { throw "bcdedit n’a pas pu lire le magasin BCD $BcdStorePath (code $exitCode)." } $targetLetter = $WindowsDriveLetter.TrimEnd(':').ToUpperInvariant() $entries = New-Object System.Collections.Generic.List[object] $currentEntry = $null foreach ($lineObject in $output) { $line = [string]$lineObject if ($line -match '^\s*(?:identifier|identificateur)\s+(?\{[0-9A-Fa-f-]{36}\})\s*$') { if ($null -ne $currentEntry) { $entries.Add($currentEntry) } $currentEntry = [pscustomobject]@{ Guid = [string]$Matches['guid'] Device = $null OsDevice = $null } continue } if ($null -eq $currentEntry) { continue } if ($line -match '^\s*device\s+partition=(?[A-Za-z]):\\?\s*$') { $currentEntry.Device = $Matches['letter'].ToUpperInvariant() } elseif ($line -match '^\s*osdevice\s+partition=(?[A-Za-z]):\\?\s*$') { $currentEntry.OsDevice = $Matches['letter'].ToUpperInvariant() } } if ($null -ne $currentEntry) { $entries.Add($currentEntry) } $matchingEntries = @($entries | Where-Object { $_.Device -eq $targetLetter -and $_.OsDevice -eq $targetLetter }) if ($matchingEntries.Count -ne 1) { throw "Impossible d’identifier une entrée BCD unique pour Windows sur ${targetLetter}: (entrées correspondantes : $($matchingEntries.Count))." } return [string]$matchingEntries[0].Guid } function Get-DeployCenterTemporaryDriveLetters { param([Parameter(Mandatory=$true)][int]$DiskNumber, [Parameter(Mandatory=$true)]$Profile) $targetLetters = @() try { $targetLetters = @(Get-Partition -DiskNumber $DiskNumber -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | ForEach-Object { ([string]$_.DriveLetter).ToUpperInvariant() }) } catch { } $occupied = @{} foreach ($volume in @(Get-Volume -ErrorAction SilentlyContinue)) { if (-not $volume.DriveLetter) { continue } $letter = ([string]$volume.DriveLetter).ToUpperInvariant() if ($targetLetters -contains $letter) { continue } $occupied[$letter] = $true } $lettersByRole = @{} foreach ($partition in $Profile.Partitions) { if ([string]$partition.Role -eq 'Reserved') { continue } $preferred = [string]$partition.DriveLetter if ([string]$partition.Role -eq 'System') { $preferred = 'S' } if ([string]$partition.Role -eq 'Recovery') { $preferred = 'R' } if ([string]::IsNullOrWhiteSpace($preferred)) { $preferred = 'W' } $preferred = $preferred.ToUpperInvariant() if (-not $occupied.ContainsKey($preferred)) { $selected = $preferred } else { $selected = $null foreach ($candidate in @('W','V','U','T','S','R','Q','P','O','N','M','L','K','J','I','H','G','F','E','D','C')) { if (-not $occupied.ContainsKey($candidate) -and $candidate -notin $lettersByRole.Values) { $selected = $candidate break } } if (-not $selected) { throw "Aucune lettre de lecteur libre pour la partition '$($partition.Name)'." } } if ($selected -in $lettersByRole.Values) { throw "La lettre ${selected}: est affectée à plusieurs partitions du profil." } $lettersByRole[[string]$partition.Role] = $selected $occupied[$selected] = $true } return $lettersByRole } function New-DeployCenterPartitionPlan { param( [Parameter(Mandatory=$true)]$DiskInfo, [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile, [AllowNull()]$SizingState ) if ([string]$Profile.PartitionStyle -ne 'GPT' -or [string]$Profile.Firmware -ne 'UEFI') { throw "Le profil '$($Profile.Name)' n’est pas pris en charge par le moteur actuel (UEFI/GPT requis)." } $totalMB = [long][math]::Floor(([double]$DiskInfo.SizeBytes / 1MB)) $availableMB = $totalMB - 2 $usedMB = 0L $plan = New-Object System.Collections.Generic.List[object] foreach ($partition in $Profile.Partitions) { $policy = [string]$partition.Size.Policy $sizeMB = $null switch ($policy) { 'FixedMB' { $sizeMB = [long]$partition.Size.Value } 'ConfiguredMB' { $sizeMB = [long]$partition.Size.Value } 'UserInputGB' { if ($null -eq $SizingState -or [string]$SizingState.ProfileId -ne [string]$Profile.Id) { throw "Le profil '$($Profile.Name)' nécessite une taille utilisateur, qui n’a pas été validée." } $role = [string]$partition.Role if (-not $SizingState.Inputs.Contains($role)) { throw "La taille de la partition '$role' est absente." } $sizeMB = [long][math]::Round(([double]$SizingState.Inputs[$role] * 1024), 0) if ($sizeMB -lt ([long]$partition.Size.MinimumGB * 1024)) { throw "La partition '$role' est sous sa taille minimale." } } { $_ -in @('Remaining','RemainderAfterInputs') } { $sizeMB = $availableMB - $usedMB if ($sizeMB -lt 1) { throw "Le disque ne dispose plus d’espace pour la partition '$($partition.Name)'." } if ($policy -eq 'RemainderAfterInputs' -and $sizeMB -lt ([long]$partition.Size.MinimumGB * 1024)) { throw "L’espace restant pour '$($partition.Name)' est inférieur au minimum du profil." } } default { throw "Politique de taille non prise en charge lors du partitionnement : $policy" } } if ($null -ne $sizeMB) { $usedMB += [long]$sizeMB } $plan.Add([pscustomobject]@{ Definition = $partition; SizeMB = $sizeMB }) } if ($usedMB -gt $availableMB) { throw "La taille demandée dépasse la capacité du disque sélectionné." } return $plan.ToArray() } function Invoke-DeployCenterWindowsDeployment { [CmdletBinding()] param( [Parameter(Mandatory=$true)]$DiskInfo, [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile, [AllowNull()]$SizingState, [Parameter(Mandatory=$true)]$Edition, [AllowNull()][string]$HostName = $null, [AllowNull()][System.Collections.IDictionary]$UnattendProfile = $null, [AllowNull()][System.Collections.IDictionary]$UnattendConfiguration = $null, [Parameter(Mandatory=$true)][string]$Firmware, [Parameter(Mandatory=$true)][string]$TemporaryRoot, [AllowNull()][scriptblock]$LogAction = $null, [AllowNull()][scriptblock]$ProgressAction = $null ) if ($null -ne $UnattendProfile) { if ($null -eq $UnattendConfiguration) { throw 'La configuration DeployCenter.ini est absente pour le profil Unattend sélectionné.' } Test-DeployCenterUnattendConfiguration -Profile $UnattendProfile -Configuration $UnattendConfiguration -HostName ([string]$HostName) } $winreRecommendationWarnings = @() if ([string]$Firmware -ne [string]$Profile.Firmware) { throw ("Le profil {0} exige le firmware {1}, détecté : {2}." -f $Profile.Name, $Profile.Firmware, $Firmware) } foreach ($requiredRole in @('System','Reserved','Recovery','Windows')) { $roleCount = @($Profile.Partitions | Where-Object { [string]$_.Role -eq $requiredRole }).Count if ($roleCount -ne 1) { throw "Le profil doit définir exactement une partition de rôle '$requiredRole'. Le disque n’a pas été modifié." } } $recoveryDefinitions = @($Profile.Partitions | Where-Object { [string]$_.Role -eq 'Recovery' }) if ($recoveryDefinitions.Count -ne 1 -or [string]$recoveryDefinitions[0].Size.Policy -ne 'ConfiguredMB') { throw "Le profil doit définir une taille configurée pour la partition Recovery. Le disque n’a pas été modifié." } $winrePartitionSizeMB = [long]$recoveryDefinitions[0].Size.Value if ($winrePartitionSizeMB -le 0) { throw "La taille configurée de la partition WinRE doit être strictement positive. Le disque n’a pas été modifié." } if (-not $Edition.ImagePath -or -not (Test-Path -LiteralPath ([string]$Edition.ImagePath) -PathType Leaf)) { throw "Le chemin de l’image Windows sélectionnée est introuvable." } if (-not $Edition.Index -or [int]$Edition.Index -lt 1) { throw "L’index de l’édition Windows est invalide." } $wallpaperSourcePath = $null if ($null -ne $UnattendProfile -and $null -ne $UnattendConfiguration) { $configuredWallpaperPath = [string]$UnattendConfiguration.UnattendWallpaperPath if (-not [string]::IsNullOrWhiteSpace($configuredWallpaperPath)) { $deployRoot = Split-Path -Path $PSScriptRoot -Parent if ([System.IO.Path]::IsPathRooted($configuredWallpaperPath)) { $wallpaperSourcePath = [System.IO.Path]::GetFullPath($configuredWallpaperPath) } else { $wallpaperSourcePath = [System.IO.Path]::GetFullPath((Join-Path $deployRoot $configuredWallpaperPath)) } if ([System.IO.Path]::GetExtension($wallpaperSourcePath) -notin @('.png', '.jpg', '.bmp')) { throw 'WallpaperPath doit désigner un fichier .png, .jpg ou .bmp.' } if (-not (Test-Path -LiteralPath $wallpaperSourcePath -PathType Leaf)) { throw "Image de fond introuvable : $wallpaperSourcePath" } } } $disk = Get-Disk -Number ([int]$DiskInfo.Index) -ErrorAction Stop if ($disk.IsBoot -or $disk.IsSystem) { throw 'Le disque sélectionné héberge le système WinPE et ne peut pas être effacé.' } if ($disk.IsOffline) { [void](Set-Disk -Number $disk.Number -IsOffline $false -ErrorAction Stop) } if ($disk.IsReadOnly) { [void](Set-Disk -Number $disk.Number -IsReadOnly $false -ErrorAction Stop) } if (-not (Test-Path -LiteralPath $TemporaryRoot -PathType Container)) { New-Item -Path $TemporaryRoot -ItemType Directory -Force -ErrorAction Stop | Out-Null } $sourceLease = New-DeployCenterDismSourceAccess -ImagePath ([string]$Edition.ImagePath) ` -TemporaryRoot $TemporaryRoot -LogAction $LogAction try { $deploymentImagePath = [string]$sourceLease.ImagePath $partitionSizingDisk = [pscustomobject]@{ Index = [int]$disk.Number; SizeBytes = [long]$disk.Size } $partitionPlan = @(New-DeployCenterPartitionPlan -DiskInfo $partitionSizingDisk -Profile $Profile -SizingState $SizingState) $driveLetters = Get-DeployCenterTemporaryDriveLetters -DiskNumber ([int]$disk.Number) -Profile $Profile $dismPath = Join-Path $env:WINDIR 'System32\dism.exe' if (-not (Test-Path -LiteralPath $dismPath -PathType Leaf)) { $dismPath = 'dism.exe' } $bcdbootPath = Join-Path $env:WINDIR 'System32\bcdboot.exe' if (-not (Test-Path -LiteralPath $bcdbootPath -PathType Leaf)) { $bcdbootPath = 'bcdboot.exe' } $scriptLines = New-Object System.Collections.Generic.List[string] $scriptLines.Add("select disk $($disk.Number)") $scriptLines.Add('convert gpt') foreach ($item in $partitionPlan) { $definition = $item.Definition $role = [string]$definition.Role switch ($role) { 'System' { $scriptLines.Add("create partition efi size=$($item.SizeMB)") } 'Reserved' { $scriptLines.Add("create partition msr size=$($item.SizeMB)") } default { if ($null -eq $item.SizeMB -or [long]$item.SizeMB -ge ([long][math]::Floor(([double]$disk.Size / 1MB)) - 2)) { $scriptLines.Add('create partition primary') } else { $scriptLines.Add("create partition primary size=$($item.SizeMB)") } } } if ($role -eq 'Reserved') { continue } $label = [string]$definition.Name if ($label -notmatch '^[A-Za-z0-9_-]{1,32}$') { throw "Nom de partition invalide dans le profil : $label" } if ([string]$definition.FileSystem) { $scriptLines.Add("format fs=$($definition.FileSystem) quick label=$label") } if ($role -eq 'Recovery' -and $driveLetters.ContainsKey($role)) { $scriptLines.Add("assign letter=$($driveLetters[$role])") } if ($definition.GptTypeId) { $scriptLines.Add("set id=$($definition.GptTypeId)") } if ($definition.GptAttributes) { $scriptLines.Add("gpt attributes=$($definition.GptAttributes)") } if ($driveLetters.ContainsKey($role) -and $role -ne 'Recovery') { $scriptLines.Add("assign letter=$($driveLetters[$role])") } } $cleanLines = @("select disk $($disk.Number)", 'clean') $cleanPath = Join-Path $TemporaryRoot ("clean_{0}.txt" -f ([guid]::NewGuid().ToString('N'))) $diskpartPath = Join-Path $TemporaryRoot ("partition_{0}.txt" -f ([guid]::NewGuid().ToString('N'))) $unattendPath = Join-Path $TemporaryRoot ("unattend_{0}.xml" -f ([guid]::NewGuid().ToString('N'))) try { [System.IO.File]::WriteAllLines($cleanPath, $cleanLines, [System.Text.Encoding]::ASCII) if ($ProgressAction) { & $ProgressAction 'Effacement et partitionnement du disque…' } Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Partitionnement du disque {0} avec le profil '{1}' ; partition WinRE configurée : {2} Mo." -f $disk.Number, $Profile.Name, $winrePartitionSizeMB) Invoke-DeployCenterNativeCommand -FilePath 'diskpart.exe' -Arguments @('/s', $cleanPath) ` -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction [void](Update-DeployCenterHostStorageCache -LogAction $LogAction) [System.IO.File]::WriteAllLines($diskpartPath, $scriptLines.ToArray(), [System.Text.Encoding]::ASCII) Invoke-DeployCenterNativeCommand -FilePath 'diskpart.exe' -Arguments @('/s', $diskpartPath) ` -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction [void](Update-DeployCenterHostStorageCache -LogAction $LogAction) $createdPartitions = @(Get-Partition -DiskNumber ([int]$disk.Number) -ErrorAction Stop) if ($createdPartitions.Count -ne $partitionPlan.Count) { throw "DiskPart a créé $($createdPartitions.Count) partition(s), alors que le profil en prévoit $($partitionPlan.Count). L’installation est interrompue." } $orderedCreatedPartitions = @($createdPartitions | Sort-Object -Property PartitionNumber) $recoveryPlanIndex = -1 for ($index = 0; $index -lt $partitionPlan.Count; $index++) { if ([string]$partitionPlan[$index].Definition.Role -eq 'Recovery') { $recoveryPlanIndex = $index break } } if ($recoveryPlanIndex -lt 0) { throw 'La partition Recovery est absente du plan exécuté.' } $recoveryPartition = $orderedCreatedPartitions[$recoveryPlanIndex] $recoveryLetter = [string]$driveLetters['Recovery'] $recoveryDriveRoot = '{0}:\' -f $recoveryLetter if (-not (Test-Path -LiteralPath $recoveryDriveRoot -PathType Container)) { throw "La lettre temporaire ${recoveryLetter}: de WinRE n’est pas accessible après le partitionnement." } $partitionsByLetter = @{} for ($index = 0; $index -lt $partitionPlan.Count; $index++) { $role = [string]$partitionPlan[$index].Definition.Role if ($role -eq 'Reserved') { continue } $letter = [string]$driveLetters[$role] $driveRoot = '{0}:\' -f $letter if (-not (Test-Path -LiteralPath $driveRoot -PathType Container)) { throw "La lettre temporaire ${letter}: n’est pas accessible après le partitionnement. L’installation est interrompue." } # WinPE peut ne pas exposer les partitions EFI/Recovery dans les # requêtes Get-Partition par lettre. Leur ordre sur le disque suit # directement celui des définitions exécutées par DiskPart. $partitionsByLetter[$letter] = $orderedCreatedPartitions[$index] } $windowsLetter = [string]$driveLetters['Windows'] $windowsPath = "${windowsLetter}:\" $windowsRoot = Join-Path $windowsPath 'Windows' $recoveryPath = "${recoveryLetter}:\Recovery\WindowsRE" $efiLetter = [string]$driveLetters['System'] if (-not $windowsLetter -or -not $recoveryLetter -or -not $efiLetter) { throw "Le profil doit définir les rôles System, Windows et Recovery pour permettre l’installation UEFI avec WinRE." } $wallpaperTargetPath = $null $wallpaperDestination = $null if ($wallpaperSourcePath) { $wallpaperName = [System.IO.Path]::GetFileName($wallpaperSourcePath) $wallpaperDirectory = Join-Path $windowsRoot 'Web\Wallpaper\DeployCenter' $wallpaperDestination = Join-Path $wallpaperDirectory $wallpaperName $wallpaperTargetPath = '%WINDIR%\Web\Wallpaper\DeployCenter\' + $wallpaperName if ($wallpaperDestination.Length -gt 259) { throw "Le chemin configuré du fond d’écran dépasse 259 caractères dans Windows." } } if ($ProgressAction) { & $ProgressAction "Application de Windows sur ${windowsLetter}:…" } Invoke-DeployCenterNativeCommand -FilePath $dismPath -Arguments @( '/Apply-Image', "/ImageFile:$deploymentImagePath", "/Index:$([int]$Edition.Index)", "/ApplyDir:$windowsPath", '/CheckIntegrity' ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction if ($wallpaperSourcePath) { New-Item -Path $wallpaperDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null Copy-Item -LiteralPath $wallpaperSourcePath -Destination $wallpaperDestination -Force -ErrorAction Stop Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("Image de fond copiée dans Windows : {0}." -f $wallpaperDestination) -Level 'INFO' } if ($ProgressAction) { & $ProgressAction 'Vérification de la capacité de la partition WinRE…' } $sourceWinre = Join-Path $windowsRoot 'System32\Recovery\winre.wim' if (-not (Test-Path -LiteralPath $sourceWinre -PathType Leaf)) { throw "winre.wim est absent de l’image Windows appliquée ; impossible de configurer WinRE." } $winreFile = Get-Item -LiteralPath $sourceWinre -ErrorAction Stop $recoveryPartition = $partitionsByLetter[$recoveryLetter] $recoveryDriveInfo = New-Object System.IO.DriveInfo -ArgumentList "${recoveryLetter}:\" if (-not $recoveryDriveInfo.IsReady) { throw "Le volume de récupération ${recoveryLetter}: n’est pas prêt pour vérifier l’espace WinRE." } $minimumPartitionBytes = [long](990 * 1MB) $minimumFreeAfterCopyBytes = [long](250 * 1MB) $recoveryFreeBytes = [long]$recoveryDriveInfo.AvailableFreeSpace $expectedFreeBytes = $recoveryFreeBytes - [long]$winreFile.Length $winreSizeForLog = [math]::Ceiling(([double]$winreFile.Length / 1MB)) $availableFreeMB = [math]::Floor(([double]$recoveryFreeBytes / 1MB)) $availableAfterCopyMB = [math]::Floor(([double]$expectedFreeBytes / 1MB)) if ($recoveryFreeBytes -lt [long]$winreFile.Length) { $message = "Espace insuffisant sur la partition WinRE pour copier winre.wim : fichier de $winreSizeForLog Mo, espace disponible $availableFreeMB Mo. Le déploiement ne peut pas configurer WinRE." Write-DeployCenterDeploymentLog -LogAction $LogAction -Message $message -Level 'ERROR' throw $message } $recommendationWarnings = New-Object System.Collections.Generic.List[string] if ([long]$recoveryPartition.Size -lt $minimumPartitionBytes) { $actualPartitionMB = [math]::Floor(([double]$recoveryPartition.Size / 1MB)) [void]$recommendationWarnings.Add("taille de partition : $actualPartitionMB Mo, minimum recommandé : 990 Mo") } if ($expectedFreeBytes -lt $minimumFreeAfterCopyBytes) { [void]$recommendationWarnings.Add("espace libre après copie : $availableAfterCopyMB Mo, minimum recommandé : 250 Mo") } if ($recommendationWarnings.Count -gt 0) { $warningDetails = $recommendationWarnings -join '; ' $winreRecommendationWarnings = @($recommendationWarnings.ToArray()) $message = "Avertissement WinRE : les recommandations Microsoft ne sont pas toutes respectées ($warningDetails). La copie de winre.wim reste possible ; le déploiement continue." Write-DeployCenterDeploymentLog -LogAction $LogAction -Message $message -Level 'WARNING' } else { Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Vérification WinRE réussie : partition {0} Mo, winre.wim {1} Mo, espace restant estimé après copie {2} Mo." -f $winrePartitionSizeMB, $winreSizeForLog, $availableAfterCopyMB) -Level 'SUCCESS' } if ($null -ne $UnattendProfile) { if ($ProgressAction) { & $ProgressAction 'Préparation du fichier de réponses Windows…' } $editionArchitecture = if ($Edition.Edition -and $Edition.Edition.Architecture) { [string]$Edition.Edition.Architecture } else { [string]$Edition.Architecture } $unattendXml = New-DeployCenterUnattendXml -Profile $UnattendProfile ` -Configuration $UnattendConfiguration -HostName ([string]$HostName) ` -Architecture $editionArchitecture -WallpaperPath ([string]$wallpaperTargetPath) [System.IO.File]::WriteAllText($unattendPath, $unattendXml, (New-Object System.Text.UTF8Encoding($false))) $pantherUnattendDirectory = Join-Path $windowsRoot 'Panther\Unattend' New-Item -Path $pantherUnattendDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null Copy-Item -LiteralPath $unattendPath -Destination (Join-Path $pantherUnattendDirectory 'Unattend.xml') -Force -ErrorAction Stop Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("Fichier de réponses Windows préparé avec le profil '{0}' (nom d’hôte : {1})." -f $UnattendProfile.Name, $HostName) -Level 'INFO' } if ($ProgressAction) { & $ProgressAction 'Configuration du démarrage UEFI…' } Invoke-DeployCenterNativeCommand -FilePath $bcdbootPath -Arguments @( ("{0}:\Windows" -f $windowsLetter), '/s', ("{0}:" -f $efiLetter), '/f', 'UEFI' ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction if ($ProgressAction) { & $ProgressAction 'Configuration de la partition de récupération WinRE…' } New-Item -Path $recoveryPath -ItemType Directory -Force -ErrorAction Stop | Out-Null Copy-Item -LiteralPath $sourceWinre -Destination (Join-Path $recoveryPath 'winre.wim') -Force -ErrorAction Stop $reagentcPath = Join-Path $windowsRoot 'System32\reagentc.exe' if (-not (Test-Path -LiteralPath $reagentcPath -PathType Leaf)) { $reagentcPath = Join-Path $env:WINDIR 'System32\reagentc.exe' } if (-not (Test-Path -LiteralPath $reagentcPath -PathType Leaf)) { throw 'REAgentC.exe est introuvable dans Windows appliqué et dans WinPE.' } $bcdStorePath = "${efiLetter}:\EFI\Microsoft\Boot\BCD" $windowsBootGuid = Get-DeployCenterOfflineWindowsBootGuid -BcdStorePath $bcdStorePath ` -WindowsDriveLetter $windowsLetter -LogAction $LogAction Invoke-DeployCenterNativeCommand -FilePath $reagentcPath -Arguments @( '/SetReImage', '/Path', $recoveryPath, '/Target', $windowsRoot ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction Invoke-DeployCenterNativeCommand -FilePath $reagentcPath -Arguments @( '/Enable', '/OSGUID', $windowsBootGuid ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction if ($ProgressAction) { & $ProgressAction 'Finalisation des partitions…' } $removeLines = New-Object System.Collections.Generic.List[string] $lettersToRemove = @() foreach ($roleToRemove in @('System', 'Recovery')) { $letter = [string]$driveLetters[$roleToRemove] if (-not [string]::IsNullOrWhiteSpace($letter)) { $lettersToRemove += $letter } } $lettersToRemove = @($lettersToRemove | Select-Object -Unique) foreach ($letter in $lettersToRemove) { $partitionToRemove = $partitionsByLetter[$letter] $removeLines.Add("select disk $($disk.Number)") $removeLines.Add("select partition $([int]$partitionToRemove.PartitionNumber)") $removeLines.Add("remove letter=$letter") } if ($removeLines.Count -gt 0) { $removePath = Join-Path $TemporaryRoot ("cleanup_{0}.txt" -f ([guid]::NewGuid().ToString('N'))) try { [System.IO.File]::WriteAllLines($removePath, $removeLines.ToArray(), [System.Text.Encoding]::ASCII) Invoke-DeployCenterNativeCommand -FilePath 'diskpart.exe' -Arguments @('/s', $removePath) ` -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction [void](Update-DeployCenterHostStorageCache -LogAction $LogAction) # Get-Partition peut conserver la lettre Recovery dans ses propriétés # après que DiskPart l'a effectivement retirée sous WinPE. $remainingPaths = @($lettersToRemove | Where-Object { [System.IO.Directory]::Exists(('{0}:\' -f $_)) }) if ($remainingPaths.Count -gt 0) { $remaining = (@($remainingPaths | ForEach-Object { ([string]$_).ToUpperInvariant() } | Select-Object -Unique)) -join ', ' throw "DiskPart n’a pas libéré toutes les lettres temporaires EFI/WinRE : $remaining." } } finally { if (Test-Path -LiteralPath $removePath -PathType Leaf) { Remove-Item -LiteralPath $removePath -Force -ErrorAction SilentlyContinue } } } $completionMode = if ($null -ne $UnattendProfile) { "profil Unattend '$($UnattendProfile.Name)'" } else { 'Avec OOBE' } Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Déploiement terminé : Windows {0}, disque {1}, mode {2}." -f $Edition.Name, $disk.Number, $completionMode) -Level 'SUCCESS' } finally { foreach ($temporaryFile in @($cleanPath, $diskpartPath, $unattendPath)) { if (Test-Path -LiteralPath $temporaryFile -PathType Leaf) { Remove-Item -LiteralPath $temporaryFile -Force -ErrorAction SilentlyContinue } } } } finally { Remove-DeployCenterDismSourceAccess -Lease $sourceLease -TemporaryRoot $TemporaryRoot -LogAction $LogAction } return [pscustomobject]@{ WinRERecommendationWarnings = @($winreRecommendationWarnings) WinRERecommendationsMet = ($winreRecommendationWarnings.Count -eq 0) } } Export-ModuleMember -Function Invoke-DeployCenterWindowsDeployment, Restart-DeployCenterWinPE