Files
DeployCenter/Modules/Deployment.psm1
T
benoit e067721763 Ajout du support de unattend
Ajout du script cmd à utiliser pour lancer DeployCenter
2026-10-17 20:41:41 +02:00

709 lines
39 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.
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.])(?<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'
$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+(?<guid>\{[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=(?<letter>[A-Za-z]):\\?\s*$') {
$currentEntry.Device = $Matches['letter'].ToUpperInvariant()
} elseif ($line -match '^\s*osdevice\s+partition=(?<letter>[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)
}
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." }
$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."
}
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)
$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 '; '
$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
[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
}
}
Export-ModuleMember -Function Invoke-DeployCenterWindowsDeployment, Restart-DeployCenterWinPE