709 lines
39 KiB
PowerShell
709 lines
39 KiB
PowerShell
# 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
|