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.
This commit is contained in:
benoit committed 2026-10-17 03:56:53 +02:00
1 parent 1c67cb2630
commit 8d5014b4aa
13 files changed
+2524 -1445

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
# DeployCenter - Lecture des préférences utilisateur
# Lecture seule : ce module ne modifie jamais DeployCenter.ini.
function Read-DeployCenterConfig {
[CmdletBinding()]
param([Parameter(Mandatory=$true)][string]$ConfigPath)
$config = @{
Theme = 'Light'
SourcePaths = '%DEPLOY_VOLUME%'
VncPassword = ''
WinRESizeMB = 1024
}
if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { return $config }
$section = ''
foreach ($line in (Get-Content -LiteralPath $ConfigPath -ErrorAction SilentlyContinue)) {
$trimmed = $line.Trim()
if (-not $trimmed -or $trimmed.StartsWith(';') -or $trimmed.StartsWith('#')) { continue }
if ($trimmed -match '^\[(.+)\]$') { $section = $matches[1].Trim(); continue }
if ($trimmed -notmatch '^([^=]+)=(.*)$') { continue }
$key = $matches[1].Trim()
$value = $matches[2].Trim()
if ($section -ieq 'General' -and $key -ieq 'Theme' -and $value -match '^[A-Za-z0-9_-]+$') {
$config.Theme = $value
} elseif ($section -ieq 'Sources' -and $key -ieq 'Paths') {
$config.SourcePaths = $value
} elseif ($section -ieq 'UltraVNC' -and $key -ieq 'Password') {
$config.VncPassword = $value
} elseif ($section -ieq 'Partitioning' -and $key -ieq 'WinRESizeMB') {
$sizeMB = 0
if (-not [int]::TryParse($value, [ref]$sizeMB) -or $sizeMB -lt 990) {
throw "Le paramètre [Partitioning] WinRESizeMB doit être un entier supérieur ou égal à 990. Valeur reçue : '$value'."
}
$config.WinRESizeMB = $sizeMB
}
}
return $config
}
Export-ModuleMember -Function Read-DeployCenterConfig
+577
View File
@@ -0,0 +1,577 @@
# 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
+151
View File
@@ -0,0 +1,151 @@
function Get-HardwareInfo {
[CmdletBinding()]
param()
$info = [ordered]@{
Manufacturer = "Inconnu"
Model = "Inconnu"
SerialNumber = "Inconnu"
Firmware = "Inconnu"
Architecture = $env:PROCESSOR_ARCHITECTURE
WinPE = $false
Disks = @()
Errors = @()
}
# Fabricant et modele du PC
try {
$computer = Get-CimInstance `
-ClassName Win32_ComputerSystem `
-ErrorAction Stop
if ($computer.Manufacturer) {
$info.Manufacturer = $computer.Manufacturer.Trim()
}
if ($computer.Model) {
$info.Model = $computer.Model.Trim()
}
}
catch {
$info.Errors += "Modele du PC : $($_.Exception.Message)"
}
# Numero de serie
try {
$bios = Get-CimInstance `
-ClassName Win32_BIOS `
-ErrorAction Stop
if ($bios.SerialNumber) {
$info.SerialNumber = $bios.SerialNumber.Trim()
}
}
catch {
$info.Errors += "Numero de serie : $($_.Exception.Message)"
}
# Detection du firmware
try {
$firmwareKey = Get-ItemProperty `
-Path "HKLM:\SYSTEM\CurrentControlSet\Control" `
-Name PEFirmwareType `
-ErrorAction Stop
switch ([int]$firmwareKey.PEFirmwareType) {
1 { $info.Firmware = "BIOS (Legacy)" }
2 { $info.Firmware = "UEFI" }
default { $info.Firmware = "Indetermine" }
}
}
catch {
$info.Errors += "Mode firmware indisponible"
}
# Detection de WinPE
$info.WinPE = Test-Path `
"HKLM:\SYSTEM\CurrentControlSet\Control\MiniNT"
# Inventaire des disques physiques
try {
$disks = @(Get-Disk -ErrorAction Stop)
foreach ($disk in $disks) {
$operationalStatus = @($disk.OperationalStatus | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
$status = if ($operationalStatus.Count -gt 0) { $operationalStatus -join ', ' } else { 'Inconnu' }
$info.Disks += [PSCustomObject]@{
Index = [int]$disk.Number
Model = [string]$disk.FriendlyName
SizeBytes = [long]$disk.Size
SizeGB = [math]::Round(
([double]$disk.Size / 1GB), 1
)
BusType = [string]$disk.BusType
Status = $status
HealthStatus = [string]$disk.HealthStatus
}
}
}
catch {
$info.Errors += "Inventaire des disques : $($_.Exception.Message)"
}
# Retourner un objet exploitable par les autres modules
[PSCustomObject]$info
}
function Write-DeployInventory {
param(
[Parameter(Mandatory=$true)][string]$InventoryDir,
[Parameter(Mandatory=$true)][string]$InventoryFile,
[AllowNull()]$HardwareInfo,
[AllowNull()][string]$DeploymentIPv4,
[AllowNull()][string]$VncPassword,
[AllowNull()][scriptblock]$LogAction = $null
)
try {
if (-not (Test-Path -LiteralPath $InventoryDir -PathType Container)) {
New-Item -Path $InventoryDir -ItemType Directory -Force | Out-Null
}
$inventory = [ordered]@{
GeneratedAt = (Get-Date).ToString('o')
DeploymentIPv4 = $DeploymentIPv4
VncPassword = $VncPassword
Hardware = $HardwareInfo
}
$json = $inventory | ConvertTo-Json -Depth 8
Set-Content -LiteralPath $InventoryFile -Value $json -Encoding UTF8
# Nettoyer les anciens exports séparés après l'écriture de l'inventaire consolidé.
$legacyInventoryFiles = @(
(Join-Path $env:TEMP 'HardwareInfo.json'),
(Join-Path $InventoryDir 'hardware.json')
)
foreach ($legacyFile in $legacyInventoryFiles) {
if (Test-Path -LiteralPath $legacyFile -PathType Leaf) {
try {
Remove-Item -LiteralPath $legacyFile -Force -ErrorAction Stop
} catch {
if ($LogAction) { & $LogAction "Impossible de supprimer l'ancien export d'inventaire '$legacyFile' : $($_.Exception.Message)" 'WARNING' }
}
}
}
} catch {
if ($LogAction) { & $LogAction "Impossible d'écrire l'inventaire temporaire : $($_.Exception.Message)" 'WARNING' }
}
}
function Get-SelectedDeployDiskInfo {
param(
[AllowNull()]$HardwareInfo,
[Parameter(Mandatory=$true)][int]$DiskIndex
)
if ($null -eq $HardwareInfo) { return $null }
$matches = @($HardwareInfo.Disks | Where-Object { [int]$_.Index -eq $DiskIndex } | Select-Object -First 1)
if ($matches.Count -gt 0) { return $matches[0] }
return $null
}
Export-ModuleMember -Function Get-HardwareInfo, Write-DeployInventory, Get-SelectedDeployDiskInfo
+25 -2
View File
@@ -56,6 +56,12 @@ function Import-DeployCenterPartitionProfile {
if ([int]$partition.Size.FreeSpaceMB -lt 250) { throw "La marge libre WinRE du profil '$ProfileName' doit être d'au moins 250 Mo." }
if ([int]$partition.Size.PreviewMB -le 0) { throw "Le profil '$ProfileName' doit définir une réserve PreviewMB pour estimer la capacité disponible." }
}
'ConfiguredMB' {
if ([string]::IsNullOrWhiteSpace([string]$partition.Size.ConfigKey) -or
[int]$partition.Size.Value -lt 990 -or [int]$partition.Size.PreviewMB -lt 990) {
throw "La taille configurée de la partition '$($partition.Name)' du profil '$ProfileName' doit définir ConfigKey et une valeur d’au moins 990 Mo."
}
}
'UserInputGB' {
if ([int]$partition.Size.MinimumGB -le 0 -or [int]$partition.Size.DefaultPercent -le 0 -or [int]$partition.Size.DefaultPercent -ge 100) {
throw "La saisie de taille utilisateur du profil '$ProfileName' doit définir MinimumGB et un DefaultPercent entre 1 et 99."
@@ -85,7 +91,7 @@ function Get-DeployCenterPartitionSizingCapacity {
$reservedMB = 0.0
foreach ($partition in $Profile.Partitions) {
switch ([string]$partition.Size.Policy) {
'FixedMB' { $reservedMB += [double]$partition.Size.Value }
{ $_ -in @('FixedMB','ConfiguredMB') } { $reservedMB += [double]$partition.Size.Value }
default {
if ($partition.Size.ContainsKey('PreviewMB')) { $reservedMB += [double]$partition.Size.PreviewMB }
}
@@ -101,4 +107,21 @@ function Get-DeployCenterPartitionSizingCapacity {
}
}
Export-ModuleMember -Function Get-DeployCenterPartitionProfileNames, Import-DeployCenterPartitionProfile, Get-DeployCenterPartitionSizingCapacity
function New-DeployCenterPartitionSizingState {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile,
[Parameter(Mandatory=$true)]$DiskInfo,
[Parameter(Mandatory=$true)][System.Collections.IDictionary]$Selection
)
return [pscustomobject]@{
ProfileId = [string]$Profile.Id
DiskIndex = [int]$DiskInfo.Index
Inputs = $Selection.Inputs
RemainderRole = [string]$Selection.RemainderRole
RemainderGB = [double]$Selection.RemainderGB
AvailableGB = [double]$Selection.AvailableGB
}
}
Export-ModuleMember -Function Get-DeployCenterPartitionProfileNames, Import-DeployCenterPartitionProfile, Get-DeployCenterPartitionSizingCapacity, New-DeployCenterPartitionSizingState
+1067 -2
View File
File diff suppressed because it is too large. Load diff
+13 -13
View File
@@ -9,22 +9,22 @@ function Start-DeployCenterVnc {
$sourceIni = Join-Path $sourceVnc 'ultravnc.ini'
if ([string]::IsNullOrWhiteSpace($script:DeployIPv4)) {
Write-Log 'Aucune adresse IPv4 de déploiement détectée.' 'WARNING'
& $script:SourceLogAction 'Aucune adresse IPv4 de déploiement détectée.' 'WARNING'
}
if (-not (Test-Path -LiteralPath $sourceExe -PathType Leaf)) {
Write-Log "UltraVNC non démarré : exécutable absent ($sourceExe)." 'WARNING'
Write-DeployInventory
& $script:SourceLogAction "UltraVNC non démarré : exécutable absent ($sourceExe)." 'WARNING'
Write-DeployInventory -InventoryDir $script:InventoryDir -InventoryFile $script:InventoryFile -HardwareInfo $script:HardwareInventory -DeploymentIPv4 $script:DeployIPv4 -VncPassword $script:VncPassword -LogAction $script:SourceLogAction
return
}
if (-not (Test-Path -LiteralPath $passwordTool -PathType Leaf)) {
Write-Log "UltraVNC non démarré : createpassword64.exe absent ($passwordTool)." 'WARNING'
Write-DeployInventory
& $script:SourceLogAction "UltraVNC non démarré : createpassword64.exe absent ($passwordTool)." 'WARNING'
Write-DeployInventory -InventoryDir $script:InventoryDir -InventoryFile $script:InventoryFile -HardwareInfo $script:HardwareInventory -DeploymentIPv4 $script:DeployIPv4 -VncPassword $script:VncPassword -LogAction $script:SourceLogAction
return
}
if (-not (Test-Path -LiteralPath $sourceIni -PathType Leaf)) {
Write-Log "UltraVNC non démarré : fichier absent ($sourceIni)." 'WARNING'
Write-DeployInventory
& $script:SourceLogAction "UltraVNC non démarré : fichier absent ($sourceIni)." 'WARNING'
Write-DeployInventory -InventoryDir $script:InventoryDir -InventoryFile $script:InventoryFile -HardwareInfo $script:HardwareInventory -DeploymentIPv4 $script:DeployIPv4 -VncPassword $script:VncPassword -LogAction $script:SourceLogAction
return
}
@@ -48,13 +48,13 @@ function Start-DeployCenterVnc {
foreach ($outputLine in $wpeutilOutput) {
$outputText = [string]$outputLine
if (-not [string]::IsNullOrWhiteSpace($outputText)) {
Write-Log "wpeutil DisableFirewall : $outputText" 'INFO'
& $script:SourceLogAction "wpeutil DisableFirewall : $outputText" 'INFO'
}
}
if ($wpeutilExitCode -ne 0) {
throw "wpeutil DisableFirewall a renvoyé le code $wpeutilExitCode."
}
Write-Log "Pare-feu WinPE désactivé à la demande de -Vnc (code wpeutil : $wpeutilExitCode)." 'WARNING'
& $script:SourceLogAction "Pare-feu WinPE désactivé à la demande de -Vnc (code wpeutil : $wpeutilExitCode)." 'WARNING'
# Force le mode portable : l'INI actif est celui placé à côté de winvnc.exe.
$vncStage = 'activation du mode portable UltraVNC'
@@ -97,13 +97,13 @@ function Start-DeployCenterVnc {
throw "winvnc.exe s'est arrêté immédiatement (code $($script:VncProcess.ExitCode))."
}
$script:VncStarted = $true
Write-Log "UltraVNC démarré. IP : $($script:DeployIPv4) ; mot de passe $passwordSource." 'SUCCESS'
& $script:SourceLogAction "UltraVNC démarré. IP : $($script:DeployIPv4) ; mot de passe $passwordSource." 'SUCCESS'
} catch {
Write-Log "Échec du démarrage UltraVNC à l'étape '$vncStage' : $($_.Exception.Message)" 'ERROR'
& $script:SourceLogAction "Échec du démarrage UltraVNC à l'étape '$vncStage' : $($_.Exception.Message)" 'ERROR'
$script:VncStarted = $false
$script:VncPassword = $null
}
Write-DeployInventory
Write-DeployInventory -InventoryDir $script:InventoryDir -InventoryFile $script:InventoryFile -HardwareInfo $script:HardwareInventory -DeploymentIPv4 $script:DeployIPv4 -VncPassword $script:VncPassword -LogAction $script:SourceLogAction
}
function Stop-DeployCenterVnc {
@@ -120,7 +120,7 @@ function Stop-DeployCenterVnc {
}
}
} catch {
Write-Log "Arrêt UltraVNC : $($_.Exception.Message)" 'WARNING'
& $script:SourceLogAction "Arrêt UltraVNC : $($_.Exception.Message)" 'WARNING'
}
# Le processus winvnc peut conserver brièvement un handle sur son dossier.
# Le dossier VNC est propre à ce processus PowerShell (uvnc_<PID>) : on ne
+385
View File
@@ -0,0 +1,385 @@
# DeployCenter - Recherche et lecture des sources Windows
# Compatible Windows PowerShell 5.1
function Find-WindowsSources {
param(
[Parameter(Mandatory=$true)][string]$RootPath,
[AllowNull()][scriptblock]$LogAction = $null
)
$results = New-Object System.Collections.Generic.List[object]
$roots = @($RootPath -split ';' | ForEach-Object {
$candidate = $_.Trim().Trim('"')
if ($candidate -notmatch '^[A-Za-z]:\\$') { $candidate = $candidate.TrimEnd('\') }
$candidate
} | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
if ($roots.Count -eq 0) {
throw 'Indique au moins un chemin local et/ou un partage SMB, separes par un point-virgule.'
}
foreach ($root in $roots) {
if (-not (Test-Path -LiteralPath $root -PathType Container)) {
if ($LogAction) { & $LogAction "Source inaccessible, ignoree : $root" 'WARNING' }
continue
}
if ($LogAction) { & $LogAction "Recherche des sources Windows dans : $root" 'INFO' }
# Parcours en lecture seule. Les erreurs d'acces aux sous-dossiers sont ignorees.
# Afficher les ISO et les fichiers WIM/ESD autonomes.
# Exception : masquer boot.wim uniquement lorsqu'il fait partie d'un
# support Windows extrait dont le même dossier Sources contient install.wim/esd.
$allFiles = @(Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue)
$files = New-Object System.Collections.Generic.List[object]
foreach ($file in $allFiles) {
if ($file.Extension -ieq '.iso') {
[void]$files.Add($file)
continue
}
if ($file.Extension -ine '.wim' -and $file.Extension -ine '.esd') {
continue
}
$isBootWimInExtractedMedia = $false
if ($file.Name -ieq 'boot.wim' -and $file.Directory.Name -ieq 'Sources') {
$installWim = Join-Path $file.Directory.FullName 'install.wim'
$installEsd = Join-Path $file.Directory.FullName 'install.esd'
if ((Test-Path -LiteralPath $installWim -PathType Leaf) -or
(Test-Path -LiteralPath $installEsd -PathType Leaf)) {
$isBootWimInExtractedMedia = $true
}
}
if (-not $isBootWimInExtractedMedia) {
[void]$files.Add($file)
}
}
foreach ($file in $files) {
$kind = switch ($file.Extension.ToLowerInvariant()) {
'.iso' { 'Image ISO' }
'.wim' { 'Image WIM Windows' }
'.esd' { 'Image ESD Windows' }
}
$detail = if ($file.Name -ieq 'install.wim' -or $file.Name -ieq 'install.esd') {
"Image d’installation dans un support extrait"
} elseif ($file.Extension -ieq '.iso') {
"Support d’installation ISO"
} else {
"Image WIM/ESD autonome"
}
$results.Add([pscustomobject]@{
Type = $kind; Nom = $file.Name; Chemin = $file.FullName
Taille = ('{0:N2} Go' -f ($file.Length / 1GB)); Detail = $detail
})
}
}
if (-not ($roots | Where-Object { Test-Path -LiteralPath $_ -PathType Container })) {
throw "Aucun des chemins n'est accessible : $($roots -join '; ')`nVerifie que le lecteur local et le partage SMB sont accessibles."
}
if ($LogAction) { & $LogAction ("Detection terminee : {0} element(s) trouve(s)." -f $results.Count) 'SUCCESS' }
return @($results | Sort-Object Type, Nom, Chemin -Unique)
}
function Get-WindowsImageEditions {
param(
[Parameter(Mandatory=$true)][string]$ImagePath,
[AllowNull()][scriptblock]$LogAction = $null
)
if (-not (Test-Path -LiteralPath $ImagePath -PathType Leaf)) {
throw "Fichier image introuvable : $ImagePath"
}
$normalized = @()
$getWindowsImage = Get-Command Get-WindowsImage -ErrorAction SilentlyContinue
if ($getWindowsImage) {
try {
$images = @(Get-WindowsImage -ImagePath $ImagePath -ErrorAction Stop)
foreach ($image in $images) {
$indexValue = $null
foreach ($propertyName in @('ImageIndex', 'Index')) {
if ($image.PSObject.Properties[$propertyName] -and $image.$propertyName) {
$indexValue = [int]$image.$propertyName
break
}
}
if ($null -eq $indexValue) { continue }
$nameValue = $null
foreach ($propertyName in @('ImageName', 'Name', 'ImageDescription')) {
if ($image.PSObject.Properties[$propertyName] -and $image.$propertyName) {
$nameValue = [string]$image.$propertyName
break
}
}
$archValue = $null
foreach ($propertyName in @('Architecture', 'ImageArchitecture')) {
if ($image.PSObject.Properties[$propertyName] -and $image.$propertyName) {
$archValue = $image.$propertyName
break
}
}
$versionValue = $null
foreach ($propertyName in @('Version', 'ImageVersion')) {
if ($image.PSObject.Properties[$propertyName] -and $image.$propertyName) {
$versionValue = [string]$image.$propertyName
break
}
}
$normalized += [pscustomobject]@{
Index = $indexValue
Name = $nameValue
Description = if ($image.PSObject.Properties['ImageDescription']) { $image.ImageDescription } else { $null }
Architecture = $archValue
Version = $versionValue
Edition = if ($image.PSObject.Properties['EditionId']) { $image.EditionId } else { $null }
}
}
} catch {
if ($LogAction) { & $LogAction "Get-WindowsImage indisponible pour $ImagePath : $($_.Exception.Message)" 'WARNING' }
}
}
$dism = Join-Path $env:WINDIR 'System32\dism.exe'
if (-not (Test-Path -LiteralPath $dism)) { $dism = 'dism.exe' }
# Si le module PowerShell n'a pas renvoyé les index/noms, les récupérer avec DISM.
if ($normalized.Count -eq 0) {
$output = & $dism /English /Get-WimInfo "/WimFile:$ImagePath" 2>&1
if ($LASTEXITCODE -ne 0) {
throw "DISM n'a pas pu lire l'image : $ImagePath`n$($output -join "`n")"
}
$current = $null
foreach ($line in $output) {
$lineText = [string]$line
if ($lineText -match '^\s*Index\s*:\s*(\d+)\s*$') {
if ($null -ne $current) { $normalized += [pscustomobject]$current }
$current = @{ Index = [int]$Matches[1]; Name = ''; Description = ''; Architecture = $null; Version = $null; Edition = $null }
} elseif ($null -ne $current -and $lineText -match '^\s*Name\s*:\s*(.*?)\s*$') {
$current.Name = $Matches[1]
} elseif ($null -ne $current -and $lineText -match '^\s*Description\s*:\s*(.*?)\s*$') {
$current.Description = $Matches[1]
}
}
if ($null -ne $current) { $normalized += [pscustomobject]$current }
}
if ($normalized.Count -eq 0) {
throw "DISM n’a retourné aucune édition exploitable pour : $ImagePath"
}
# Interroger DISM pour chaque index même si Get-WindowsImage a fourni les noms :
# selon la version du module, les propriétés Architecture/Version peuvent être absentes.
foreach ($edition in $normalized) {
if ($edition.Architecture -and $edition.Version) { continue }
$detailOutput = & $dism /English /Get-WimInfo "/WimFile:$ImagePath" "/Index:$($edition.Index)" 2>&1
if ($LASTEXITCODE -ne 0) {
if ($LogAction) { & $LogAction "DISM n'a pas pu lire les metadonnees de l'index $($edition.Index) : $($detailOutput -join ' ')" 'WARNING' }
continue
}
$insideImageDetails = $false
$imageVersion = $null
$servicePackBuild = $null
foreach ($detailLine in $detailOutput) {
$detailText = [string]$detailLine
if ($detailText -match '^\s*Details for image\s*:') {
$insideImageDetails = $true
continue
}
# Ignorer la version de DISM affichée au début de sa sortie.
if (-not $insideImageDetails) { continue }
if (-not $edition.Architecture -and $detailText -match '^\s*Architecture\s*:\s*(.*?)\s*$') {
$edition.Architecture = $Matches[1]
} elseif ($detailText -match '^\s*Version\s*:\s*(.*?)\s*$') {
$imageVersion = $Matches[1]
} elseif ($detailText -match '^\s*ServicePack Build\s*:\s*(.*?)\s*$') {
$servicePackBuild = $Matches[1]
} elseif (-not $edition.Name -and $detailText -match '^\s*Name\s*:\s*(.*?)\s*$') {
$edition.Name = $Matches[1]
} elseif (-not $edition.Edition -and $detailText -match '^\s*Edition\s*:\s*(.*?)\s*$') {
$edition.Edition = $Matches[1]
}
}
if ($imageVersion) {
$edition.Version = if ($servicePackBuild -and $servicePackBuild -ne '0') {
"$imageVersion.$servicePackBuild"
} else {
$imageVersion
}
}
}
# Version globale de l'image : ne l'afficher que si les métadonnées des index
# donnent une seule version cohérente. Ne pas répéter cette valeur sur chaque ligne.
$versionValues = @($normalized | ForEach-Object {
if (-not [string]::IsNullOrWhiteSpace([string]$_.Version)) { [string]$_.Version }
} | Select-Object -Unique)
$globalVersion = $null
if ($versionValues.Count -eq 1) {
$globalVersion = $versionValues[0]
}
foreach ($edition in $normalized) {
if ($edition.Architecture -is [int] -or [string]$edition.Architecture -match '^\d+$') {
switch ([string]$edition.Architecture) {
'0' { $edition.Architecture = 'x86' }
'5' { $edition.Architecture = 'ARM' }
'9' { $edition.Architecture = 'x64' }
'12' { $edition.Architecture = 'ARM64' }
}
}
}
return [pscustomobject]@{
Editions = @($normalized)
GlobalVersion = $globalVersion
}
}
function Resolve-InstallImagePath {
param(
[Parameter(Mandatory=$true)][string]$SelectedPath,
[AllowNull()][object]$ExistingImage = $null
)
if ([IO.Path]::GetExtension($SelectedPath) -in @('.wim', '.esd')) {
return [pscustomobject]@{ SourcePath = $SelectedPath; ImagePath = $SelectedPath; MountedImage = $null }
}
if (Test-Path -LiteralPath $SelectedPath -PathType Container) {
foreach ($relative in @('sources\install.wim', 'sources\install.esd', 'Sources\install.wim', 'Sources\install.esd')) {
$candidate = Join-Path $SelectedPath $relative
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
return [pscustomobject]@{ SourcePath = $SelectedPath; ImagePath = $candidate; MountedImage = $null }
}
}
throw "Aucun fichier Sources\install.wim ou Sources\install.esd dans : $SelectedPath"
}
if ([IO.Path]::GetExtension($SelectedPath) -ieq '.iso') {
if ($ExistingImage -and $ExistingImage.SourcePath -and
[string]::Equals([IO.Path]::GetFullPath([string]$ExistingImage.SourcePath), [IO.Path]::GetFullPath($SelectedPath), [StringComparison]::OrdinalIgnoreCase) -and
$ExistingImage.MountedImage -and $ExistingImage.ImagePath) {
return $ExistingImage
}
if (-not (Get-Command Mount-DiskImage -ErrorAction SilentlyContinue)) {
throw "Le montage ISO n'est pas disponible dans cet environnement (commande Mount-DiskImage absente). Pour analyser cette source, utilise le dossier extrait ou un fichier install.wim/esd."
}
# Ne pas laisser Windows attribuer une lettre arbitraire (souvent D:) à l'ISO.
$diskImage = Mount-DiskImage -ImagePath $SelectedPath -Access ReadOnly -NoDriveLetter -PassThru -ErrorAction Stop
$mountInfo = [pscustomobject]@{
SourcePath = $SelectedPath
ImagePath = $null
MountedImage = $diskImage
MountedPartition = $null
DriveLetter = $null
}
try {
# Partir des volumes explicitement associés à cette image évite de
# confondre le lecteur CDROM virtuel et un disque physique dans WinPE.
$volumes = @(Get-Volume -DiskImage $diskImage -ErrorAction Stop)
if ($volumes.Count -eq 0) { throw 'Aucun volume ISO expose par Windows.' }
$usedLetters = @(
Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | ForEach-Object { ([string]$_.DriveLetter).ToUpperInvariant() }
Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue | ForEach-Object { ([string]$_.Name).ToUpperInvariant() }
)
$availableLetter = $null
foreach ($candidateLetter in @('Z','Y','X','W','V','U','T','S','R','Q','P','O','N','M','L','K','J','I','H','G','F','E')) {
if ($candidateLetter -notin @('C','D') -and $candidateLetter -notin $usedLetters) {
$availableLetter = $candidateLetter
break
}
}
if (-not $availableLetter) { throw 'Aucune lettre de lecteur temporaire disponible pour le montage ISO.' }
$checkedPaths = @()
foreach ($volume in $volumes) {
$volumeRoot = [string]$volume.Path
if (-not $volumeRoot.StartsWith('\\?\Volume{', [StringComparison]::OrdinalIgnoreCase) -and $volume.DriveLetter) {
$mountVolCommand = Join-Path $env:WINDIR 'System32\mountvol.exe'
if (-not (Test-Path -LiteralPath $mountVolCommand -PathType Leaf)) { $mountVolCommand = 'mountvol.exe' }
$volumeMountPath = '{0}:\' -f [string]$volume.DriveLetter
$volumeNameOutput = & $mountVolCommand $volumeMountPath '/L' 2>&1
$volumeGuidPath = @($volumeNameOutput | ForEach-Object { [string]$_ } | Where-Object { $_.StartsWith('\\?\Volume{', [StringComparison]::OrdinalIgnoreCase) }) | Select-Object -First 1
if ($volumeGuidPath) { $volumeRoot = [string]$volumeGuidPath }
}
if ($volumeRoot -and $volumeRoot.StartsWith('\\?\Volume{', [StringComparison]::OrdinalIgnoreCase)) {
if (-not $volumeRoot.EndsWith('\')) { $volumeRoot += '\' }
foreach ($relative in @('sources\install.wim', 'sources\install.esd')) {
$candidate = $volumeRoot + $relative
$checkedPaths += $candidate
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
if ([string]$volume.DriveLetter -eq 'D') {
$mountVolCommand = Join-Path $env:WINDIR 'System32\mountvol.exe'
if (-not (Test-Path -LiteralPath $mountVolCommand -PathType Leaf)) { $mountVolCommand = 'mountvol.exe' }
$removeOutput = & $mountVolCommand 'D:\' '/D' 2>&1
if ($LASTEXITCODE -ne 0) { throw "Impossible de libérer D: pour le volume ISO : $($removeOutput -join ' ')" }
}
$mountInfo.ImagePath = $candidate
return $mountInfo
}
}
}
$partition = Get-Partition -Volume $volume -ErrorAction Stop
if ($null -eq $partition) { continue }
$existingDrivePaths = @($partition.AccessPaths | Where-Object { [string]$_ -match '^[A-Za-z]:\\$' })
$reusableDrivePath = $existingDrivePaths | Where-Object { [string]$_ -notmatch '^[CDcd]:\\$' } | Select-Object -First 1
if ($reusableDrivePath) {
$driveAccessPath = [string]$reusableDrivePath
$driveLetter = $driveAccessPath.Substring(0, 1).ToUpperInvariant()
} else {
foreach ($existingDrivePath in $existingDrivePaths) {
Remove-PartitionAccessPath -InputObject $partition -AccessPath ([string]$existingDrivePath) -ErrorAction Stop | Out-Null
}
$driveLetter = $availableLetter
$driveAccessPath = '{0}:\' -f $driveLetter
Add-PartitionAccessPath -InputObject $partition -AccessPath $driveAccessPath -ErrorAction Stop
}
$mountInfo.MountedPartition = $partition
$mountInfo.DriveLetter = $driveLetter
foreach ($relative in @('sources\install.wim', 'sources\install.esd')) {
$candidate = "${driveLetter}:\$relative"
$checkedPaths += $candidate
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
$mountInfo.ImagePath = $candidate
return $mountInfo
}
}
if ($driveLetter -eq $availableLetter) {
Remove-PartitionAccessPath -InputObject $partition -AccessPath $driveAccessPath -ErrorAction SilentlyContinue | Out-Null
}
$mountInfo.MountedPartition = $null
$mountInfo.DriveLetter = $null
}
throw "Aucun fichier Sources\install.wim ou Sources\install.esd trouvé dans l'ISO. Chemins vérifiés : $($checkedPaths -join ', ')."
} catch {
try { [void](Dismount-DeployCenterInstallImage -Image $mountInfo -ErrorAction SilentlyContinue) } catch { }
throw
}
}
throw "Type de source non pris en charge pour l'analyse : $SelectedPath"
}
function Dismount-DeployCenterInstallImage {
param([AllowNull()][object]$Image)
if ($null -eq $Image -or $null -eq $Image.MountedImage) { return }
try {
if ($Image.MountedPartition -and $Image.DriveLetter) {
$driveAccessPath = '{0}:\' -f [string]$Image.DriveLetter
Remove-PartitionAccessPath -InputObject $Image.MountedPartition -AccessPath $driveAccessPath -ErrorAction Stop | Out-Null
}
} finally {
Dismount-DiskImage -InputObject $Image.MountedImage -ErrorAction Stop | Out-Null
}
}
Export-ModuleMember -Function Find-WindowsSources, Get-WindowsImageEditions, Resolve-InstallImagePath, Dismount-DeployCenterInstallImage