Files
DeployCenter/Modules/Deployment.psm1
T
benoit 8d5014b4aa Ajout de la partie application des changements sur le disque
Ajout de l'installation de Windows
Correction du chargement des images iso.
Correction de la définition de la taille de WinRE.
2026-10-17 03:56:53 +02:00

578 lines
32 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DeployCenter - Partitionnement et installation d'une edition Windows
# Compatible Windows PowerShell 5.1 / WinPE.
function Write-DeployCenterDeploymentLog {
param([AllowNull()][scriptblock]$LogAction, [string]$Message, [string]$Level = 'INFO')
if ($LogAction) { & $LogAction $Message $Level }
}
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
}
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.])(?<percent>\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'
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)
$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 ($isDism -and (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
$nativeOutputLines = New-Object System.Collections.Generic.List[string]
foreach ($outputPath in @($stdoutPath, $stderrPath)) {
if (Test-Path -LiteralPath $outputPath -PathType Leaf) {
foreach ($line in [System.IO.File]::ReadAllLines($outputPath)) {
if (-not [string]::IsNullOrWhiteSpace($line)) {
[void]$nativeOutputLines.Add([string]$line)
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO'
}
}
}
}
if ($ProgressAction -and $isDism) {
$nativePercent = Get-DeployCenterNativeProgressPercent -OutputPath $stdoutPath
if ($null -ne $nativePercent) { & $ProgressAction $null $nativePercent }
}
if ($null -eq $exitCode -and $isDism -and @($nativeOutputLines | Where-Object { $_ -match '(?i)completed successfully|a r.ussi' }).Count -gt 0) {
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message 'DISM a confirmé la réussite de l’opération, mais WinPE n’a pas fourni de code de sortie exploitable.' `
-Level 'WARNING'
return
}
if ($null -eq $exitCode -and [IO.Path]::GetFileName($FilePath) -ieq 'diskpart.exe') {
# Dans certains WinPE, Start-Process ne renseigne pas ExitCode même
# lorsque DiskPart a terminé. L’appelant vérifie ensuite l’état réel
# des partitions et des lettres avant de poursuivre.
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message "DiskPart n'a pas fourni de code de sortie exploitable ; le résultat sera confirmé par vérification du disque." `
-Level 'WARNING'
return
}
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'
}
}
throw "La commande '$FilePath' s'est terminée avec le code '$exitCode'. $(($diagnostic -join ' | '))"
}
} 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-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 ConvertTo-DeployCenterUnattendXml {
param([Parameter(Mandatory=$true)][string]$HostName, [Parameter(Mandatory=$true)][string]$Architecture)
$processorArchitecture = switch -Regex ($Architecture.ToLowerInvariant()) {
'arm64' { 'arm64'; break }
'x86|32' { 'x86'; break }
default { 'amd64' }
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="$processorArchitecture" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>$HostName</ComputerName>
</component>
</settings>
</unattend>
"@
}
function Invoke-DeployCenterWindowsDeployment {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)]$DiskInfo,
[Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile,
[AllowNull()]$SizingState,
[Parameter(Mandatory=$true)]$Edition,
[Parameter(Mandatory=$true)][string]$HostName,
[Parameter(Mandatory=$true)][string]$Firmware,
[Parameter(Mandatory=$true)][string]$TemporaryRoot,
[AllowNull()][scriptblock]$LogAction = $null,
[AllowNull()][scriptblock]$ProgressAction = $null
)
if ($HostName -notmatch '^(?=.*[A-Za-z])[A-Za-z0-9](?:[A-Za-z0-9-]{0,13}[A-Za-z0-9])?$') {
throw "Le nom d'hôte doit respecter les règles NetBIOS et ne pas dépasser 15 caractères."
}
if ([string]$Firmware -ne [string]$Profile.Firmware) {
throw "Le profil '$($Profile.Name)' exige le firmware $($Profile.Firmware), détecté : $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 -lt 990) {
throw "La partition WinRE doit être configurée à au moins 990 Mo. 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." }
$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' }
$reagentcPath = Join-Path $env:WINDIR 'System32\reagentc.exe'
if (-not (Test-Path -LiteralPath $reagentcPath -PathType Leaf)) { $reagentcPath = 'reagentc.exe' }
$scriptLines = New-Object System.Collections.Generic.List[string]
$scriptLines.Add("select disk $($disk.Number)")
$scriptLines.Add('clean')
$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 ($definition.GptTypeId) { $scriptLines.Add("set id=$($definition.GptTypeId)") }
if ($definition.GptAttributes) { $scriptLines.Add("gpt attributes=$($definition.GptAttributes)") }
if ($driveLetters.ContainsKey($role)) { $scriptLines.Add("assign letter=$($driveLetters[$role])") }
}
$diskpartPath = Join-Path $TemporaryRoot ("partition_{0}.txt" -f ([guid]::NewGuid().ToString('N')))
$unattendPath = Join-Path $TemporaryRoot ("unattend_{0}.xml" -f ([guid]::NewGuid().ToString('N')))
$partitionCreated = $false
$lettersReleased = $false
try {
[System.IO.File]::WriteAllLines($diskpartPath, $scriptLines.ToArray(), [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)
$partitionCreated = $true
Invoke-DeployCenterNativeCommand -FilePath 'diskpart.exe' -Arguments @('/s', $diskpartPath) `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
$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)
$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'
$recoveryLetter = [string]$driveLetters['Recovery']
$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."
}
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 ($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)
$minimumFreeBytes = [long]($winreFile.Length + (250 * 1MB))
$recoveryFreeBytes = [long]$recoveryDriveInfo.AvailableFreeSpace
$expectedFreeBytes = $recoveryFreeBytes - [long]$winreFile.Length
if ([long]$recoveryPartition.Size -lt $minimumPartitionBytes -or
$recoveryFreeBytes -lt $minimumFreeBytes) {
$winreSizeForLog = [math]::Ceiling(([double]$winreFile.Length / 1MB))
$availableAfterCopyMB = [math]::Floor(([double]$expectedFreeBytes / 1MB))
$message = "La partition WinRE configurée à $winrePartitionSizeMB Mo ne respecte pas les prérequis Microsoft : taille de winre.wim $winreSizeForLog Mo, espace libre requis après copie 250 Mo, estimé $availableAfterCopyMB Mo. L’installation est interrompue avant l’activation de WinRE."
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message $message -Level 'ERROR'
throw $message
}
$winreSizeForLog = [math]::Ceiling(([double]$winreFile.Length / 1MB))
$availableAfterCopyMB = [math]::Floor(([double]$expectedFreeBytes / 1MB))
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 ($ProgressAction) { & $ProgressAction "Configuration du nom d’hôte…" }
$editionArchitecture = if ($Edition.Edition -and $Edition.Edition.Architecture) { [string]$Edition.Edition.Architecture } else { [string]$Edition.Architecture }
$unattendXml = ConvertTo-DeployCenterUnattendXml -HostName $HostName -Architecture $editionArchitecture
[System.IO.File]::WriteAllText($unattendPath, $unattendXml, (New-Object System.Text.UTF8Encoding($false)))
Invoke-DeployCenterNativeCommand -FilePath $dismPath -Arguments @(
"/Image:$windowsPath", "/Apply-Unattend:$unattendPath"
) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
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
Invoke-DeployCenterNativeCommand -FilePath $reagentcPath -Arguments @(
'/SetReImage', '/Path', $recoveryPath, '/Target', $windowsRoot
) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
Invoke-DeployCenterNativeCommand -FilePath $reagentcPath -Arguments @(
'/Enable', '/Target', $windowsRoot
) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
if ($ProgressAction) { & $ProgressAction 'Finalisation des partitions…' }
$removeLines = New-Object System.Collections.Generic.List[string]
foreach ($letter in @($driveLetters.Values | Select-Object -Unique)) {
$removeLines.Add("select volume $letter")
$removeLines.Add("remove letter=$letter")
}
$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
$remainingLetters = @(
Get-Partition -DiskNumber ([int]$disk.Number) -ErrorAction Stop |
Where-Object { $_.DriveLetter -and ([string]$_.DriveLetter).ToUpperInvariant() -in @($driveLetters.Values | ForEach-Object { ([string]$_).ToUpperInvariant() }) }
)
if ($remainingLetters.Count -gt 0) {
$remaining = @($remainingLetters | ForEach-Object { ([string]$_.DriveLetter).ToUpperInvariant() } | Select-Object -Unique) -join ', '
throw "DiskPart n’a pas libéré toutes les lettres temporaires du disque : $remaining."
}
$lettersReleased = $true
} finally {
if (Test-Path -LiteralPath $removePath -PathType Leaf) { Remove-Item -LiteralPath $removePath -Force -ErrorAction SilentlyContinue }
}
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Déploiement terminé : Windows {0}, disque {1}, hôte {2}." -f $Edition.Name, $disk.Number, $HostName) -Level 'SUCCESS'
} finally {
if ($partitionCreated -and -not $lettersReleased) {
# En cas d’échec après création, libérer les lettres temporaires même si
# l’image n’a pas pu être entièrement déployée.
foreach ($letter in @($driveLetters.Values | Select-Object -Unique)) {
try {
$cleanup = Join-Path $TemporaryRoot ("release_{0}.txt" -f ([guid]::NewGuid().ToString('N')))
[System.IO.File]::WriteAllLines($cleanup, @("select volume $letter", "remove letter=$letter"), [System.Text.Encoding]::ASCII)
Invoke-DeployCenterNativeCommand -FilePath 'diskpart.exe' -Arguments @('/s', $cleanup) `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
Remove-Item -LiteralPath $cleanup -Force -ErrorAction SilentlyContinue
} catch { }
}
}
foreach ($temporaryFile in @($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
}
}
Export-ModuleMember -Function Invoke-DeployCenterWindowsDeployment