# DeployCenter - Windows partitioning and installation # Compatible with 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', [switch]$SystemOutput) if ($LogAction) { & $LogAction $Message $Level ([bool]$SystemOutput) } } function Update-DeployCenterHostStorageCache { param([AllowNull()][scriptblock]$LogAction = $null) try { Update-HostStorageCache -ErrorAction Stop Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message 'WinPE storage cache refreshed.' -Level 'INFO' return $true } catch { Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("WinPE storage cache refresh is unavailable: {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 was not found; a WinPE restart cannot be requested.' } Write-DeployCenterDeploymentLog -LogAction $LogAction -Message 'WinPE restart requested through 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' -SystemOutput } } if ($null -ne $exitCode -and $exitCode -ne 0) { throw "wpeutil Reboot returned exit 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, [AllowNull()][scriptblock]$ProgressAction = $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 "Invalid volume GUID path for 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 'No temporary drive letter is available to access the Windows source.' } $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 -ProgressAction $ProgressAction } 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 -ProgressAction $ProgressAction } catch { } throw "The Windows source is not accessible after temporarily assigning ${letter}:." } if ($mountError) { Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("mountvol did not provide a usable exit code, but the source is accessible through {0}:. The assignment is confirmed." -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, [AllowNull()][scriptblock]$ProgressAction = $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 -ProgressAction $ProgressAction } catch { $releaseError = $_.Exception } [void](Update-DeployCenterHostStorageCache -LogAction $LogAction) if (Test-Path -LiteralPath ([string]$Lease.ImagePath) -PathType Leaf) { $details = if ($releaseError) { $releaseError.Message } else { 'The source file is still accessible.' } Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message "Could not confirm release of temporary drive letter $($Lease.DriveLetter): $details" -Level 'WARNING' } elseif ($releaseError) { Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("mountvol did not provide a usable exit code, but the source is no longer accessible through {0}:. The drive letter has been released." -f $Lease.DriveLetter) ` -Level 'WARNING' } } function Get-DeployCenterNativeProgressPercent { param([Parameter(Mandatory=$true)][string]$OutputPath) $stream = $null $reader = $null try { $stream = [System.IO.File]::Open($OutputPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::Default, $true) $content = $reader.ReadToEnd() } catch { return $null } finally { if ($reader) { $reader.Dispose() } elseif ($stream) { $stream.Dispose() } } $progressMatches = [regex]::Matches($content, '(?\d{1,3}(?:\.\d+)?)\s*%') if ($progressMatches.Count -eq 0) { return $null } $value = [double]$progressMatches[$progressMatches.Count - 1].Groups['percent'].Value return [int][math]::Max(0, [math]::Min(100, [math]::Floor($value))) } function Test-DeployCenterRobocopyCopy { param( [Parameter(Mandatory=$true)][string]$SourceDirectory, [Parameter(Mandatory=$true)][string]$DestinationDirectory, [Parameter(Mandatory=$true)][string[]]$CopyArguments ) $sourceRoot = [IO.Path]::GetFullPath($SourceDirectory).TrimEnd('\') $fileFilters = @($CopyArguments | Where-Object { -not ([string]$_).StartsWith('/') }) $sourceFiles = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Recurse -ErrorAction Stop) if ($fileFilters.Count -gt 0) { $sourceFiles = @($sourceFiles | Where-Object { $fileName = $_.Name @($fileFilters | Where-Object { $fileName -like [string]$_ }).Count -gt 0 }) } if ($sourceFiles.Count -eq 0) { return $false } foreach ($sourceFile in $sourceFiles) { $relativePath = $sourceFile.FullName.Substring($sourceRoot.Length).TrimStart('\') $destinationPath = Join-Path $DestinationDirectory $relativePath if (-not (Test-Path -LiteralPath $destinationPath -PathType Leaf)) { return $false } $destinationFile = Get-Item -LiteralPath $destinationPath -ErrorAction Stop if ([long]$sourceFile.Length -ne [long]$destinationFile.Length) { return $false } } return $true } function ConvertTo-DeployCenterPowerShellLiteral { param([AllowNull()][string]$Value) if ($null -eq $Value) { $Value = '' } return "'" + $Value.Replace("'", "''") + "'" } function Invoke-DeployCenterRobocopy { param( [Parameter(Mandatory=$true)][string]$SourceDirectory, [Parameter(Mandatory=$true)][string]$DestinationDirectory, [Parameter(Mandatory=$true)][string[]]$CopyArguments, [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 } $robocopyPath = Join-Path $env:WINDIR 'System32\robocopy.exe' if (-not (Test-Path -LiteralPath $robocopyPath -PathType Leaf)) { $robocopyCommand = Get-Command 'robocopy.exe' -ErrorAction SilentlyContinue if ($null -eq $robocopyCommand) { throw 'Robocopy.exe was not found in WinPE.' } $robocopyPath = [string]$robocopyCommand.Path } $id = [guid]::NewGuid().ToString('N') $stdoutPath = Join-Path $TemporaryRoot ("robocopy_{0}.out" -f $id) $stderrPath = Join-Path $TemporaryRoot ("robocopy_{0}.err" -f $id) $runnerPath = Join-Path $TemporaryRoot ("robocopy_{0}.ps1" -f $id) $exitCodePath = Join-Path $TemporaryRoot ("robocopy_{0}.exitcode" -f $id) $arguments = @($SourceDirectory, $DestinationDirectory) + @($CopyArguments) $argumentLine = ($arguments | ForEach-Object { ConvertTo-DeployCenterNativeArgument -Value ([string]$_) }) -join ' ' $powerShellPath = Join-Path $PSHOME 'powershell.exe' if (-not (Test-Path -LiteralPath $powerShellPath -PathType Leaf)) { $powerShellCommand = Get-Command 'powershell.exe' -ErrorAction SilentlyContinue if ($null -eq $powerShellCommand) { throw 'Windows PowerShell was not found to capture the Robocopy exit code.' } $powerShellPath = [string]$powerShellCommand.Path } try { Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Native command: {0} {1}" -f $robocopyPath, $argumentLine) $runnerCommand = '& ' + (ConvertTo-DeployCenterPowerShellLiteral -Value $robocopyPath) foreach ($argument in $arguments) { $runnerCommand += ' ' + (ConvertTo-DeployCenterPowerShellLiteral -Value ([string]$argument)) } $runnerLines = @( $runnerCommand, '$robocopyExitCode = $LASTEXITCODE', ('[System.IO.File]::WriteAllText(' + (ConvertTo-DeployCenterPowerShellLiteral -Value $exitCodePath) + ', [string]$robocopyExitCode, [System.Text.Encoding]::ASCII)') ) [System.IO.File]::WriteAllLines($runnerPath, [string[]]$runnerLines, (New-Object System.Text.UTF8Encoding($true))) $runnerArguments = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $runnerPath) $runnerArgumentLine = ($runnerArguments | ForEach-Object { ConvertTo-DeployCenterNativeArgument -Value ([string]$_) }) -join ' ' $process = Start-Process -FilePath $powerShellPath -ArgumentList $runnerArgumentLine ` -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath ` -RedirectStandardError $stderrPath -ErrorAction Stop while (-not $process.WaitForExit(250)) { if ($ProgressAction -and (Test-Path -LiteralPath $stdoutPath -PathType Leaf)) { [System.Windows.Forms.Application]::DoEvents() $copyPercent = Get-DeployCenterNativeProgressPercent -OutputPath $stdoutPath if ($null -ne $copyPercent) { & $ProgressAction $null $copyPercent } } } $process.WaitForExit() $exitCode = $null if (Test-Path -LiteralPath $exitCodePath -PathType Leaf) { $exitCodeText = [System.IO.File]::ReadAllText($exitCodePath).Trim() $parsedExitCode = 0 if ([int]::TryParse($exitCodeText, [ref]$parsedExitCode)) { $exitCode = $parsedExitCode } } $outputEncoding = [System.Console]::OutputEncoding if ($null -eq $outputEncoding) { $outputEncoding = [System.Text.Encoding]::Default } foreach ($outputPath in @($stdoutPath, $stderrPath)) { if (Test-Path -LiteralPath $outputPath -PathType Leaf) { foreach ($line in [System.IO.File]::ReadAllLines($outputPath, $outputEncoding)) { if (-not [string]::IsNullOrWhiteSpace($line)) { if ($line -match '^\s*\d{1,3}(?:[.,]\d+)?\s*%\s*$') { continue } Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' -SystemOutput } } } } if ($ProgressAction -and (Test-Path -LiteralPath $stdoutPath -PathType Leaf)) { $copyPercent = Get-DeployCenterNativeProgressPercent -OutputPath $stdoutPath if ($null -ne $copyPercent) { & $ProgressAction $null $copyPercent } } # Robocopy uses codes 0 to 7 for non-failure results; 8 and above indicate an error. if ($null -eq $exitCode) { if (-not (Test-DeployCenterRobocopyCopy -SourceDirectory $SourceDirectory ` -DestinationDirectory $DestinationDirectory -CopyArguments $CopyArguments)) { throw 'Robocopy did not report an exit code and file verification failed.' } Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message 'Robocopy did not report an exit code; file verification succeeded.' ` -Level 'WARNING' return $null } if ([int]$exitCode -ge 8) { throw ("Robocopy exited with code {0}." -f $exitCode) } return [int]$exitCode } finally { foreach ($outputPath in @($stdoutPath, $stderrPath)) { if (Test-Path -LiteralPath $outputPath -PathType Leaf) { Remove-Item -LiteralPath $outputPath -Force -ErrorAction SilentlyContinue } } foreach ($temporaryPath in @($runnerPath, $exitCodePath)) { if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) { Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue } } } } 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) $runnerPath = Join-Path $TemporaryRoot ("native_{0}.ps1" -f $id) $exitCodePath = Join-Path $TemporaryRoot ("native_{0}.exitcode" -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 ("Native command: {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' -SystemOutput } } } } 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 confirms Windows was applied, but the exit code is unavailable in WinPE.' ` -Level 'WARNING' return } } else { $powerShellPath = Join-Path $PSHOME 'powershell.exe' if (-not (Test-Path -LiteralPath $powerShellPath -PathType Leaf)) { $powerShellCommand = Get-Command 'powershell.exe' -ErrorAction SilentlyContinue if ($null -eq $powerShellCommand) { throw 'Windows PowerShell was not found to capture the native command exit code.' } $powerShellPath = [string]$powerShellCommand.Path } $runnerCommand = '& ' + (ConvertTo-DeployCenterPowerShellLiteral -Value $FilePath) foreach ($argument in $Arguments) { $runnerCommand += ' ' + (ConvertTo-DeployCenterPowerShellLiteral -Value ([string]$argument)) } $runnerLines = @( $runnerCommand, '$nativeCommandExitCode = $LASTEXITCODE', ('[System.IO.File]::WriteAllText(' + (ConvertTo-DeployCenterPowerShellLiteral -Value $exitCodePath) + ', [string]$nativeCommandExitCode, [System.Text.Encoding]::ASCII)') ) [System.IO.File]::WriteAllLines($runnerPath, [string[]]$runnerLines, (New-Object System.Text.UTF8Encoding($true))) $runnerArguments = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $runnerPath) $runnerArgumentLine = ($runnerArguments | ForEach-Object { ConvertTo-DeployCenterNativeArgument -Value ([string]$_) }) -join ' ' $process = Start-Process -FilePath $powerShellPath -ArgumentList $runnerArgumentLine ` -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath ` -RedirectStandardError $stderrPath -ErrorAction Stop while (-not $process.WaitForExit(250)) { if ($ProgressAction) { [System.Windows.Forms.Application]::DoEvents() } } $process.WaitForExit() $exitCode = $null if (Test-Path -LiteralPath $exitCodePath -PathType Leaf) { $exitCodeText = [System.IO.File]::ReadAllText($exitCodePath).Trim() $parsedExitCode = 0 if ([int]::TryParse($exitCodeText, [ref]$parsedExitCode)) { $exitCode = $parsedExitCode } } if ($null -eq $exitCode) { throw ("Could not capture the exit code from {0}." -f [IO.Path]::GetFileName($FilePath)) } $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([string]$line)) { [void]$nativeOutputLines.Add([string]$line) Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' -SystemOutput } } } } } 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 ([string]$line) -Level 'ERROR' -SystemOutput } } $diagnosticMessage = $diagnostic -join ' | ' throw ("Command {0} exited with 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 } foreach ($temporaryPath in @($runnerPath, $exitCodePath)) { if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) { Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue } } } } function Get-DeployCenterOfflineWindowsBootGuid { param( [Parameter(Mandatory=$true)][string]$BcdStorePath, [Parameter(Mandatory=$true)][string]$WindowsDriveLetter, [Parameter(Mandatory=$true)][string]$IdentifierLabel, [AllowNull()][scriptblock]$LogAction = $null ) if (-not (Test-Path -LiteralPath $BcdStorePath -PathType Leaf)) { throw "BCD store was not found after boot files were created: $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' -SystemOutput } } if ($null -eq $exitCode -or [int]$exitCode -ne 0) { throw "bcdedit could not read the BCD store $BcdStorePath (exit code $exitCode)." } $targetLetter = $WindowsDriveLetter.TrimEnd(':').ToUpperInvariant() $entries = New-Object System.Collections.Generic.List[object] $currentEntry = $null $identifierPattern = '^\s*' + [regex]::Escape($IdentifierLabel) + '\s+(?\{[0-9A-Fa-f-]{36}\})\s*$' foreach ($lineObject in $output) { $line = [string]$lineObject if ($line -match $identifierPattern) { if ($null -ne $currentEntry) { $entries.Add($currentEntry) } $currentEntry = [pscustomobject]@{ Guid = [string]$Matches['guid'] Device = $null OsDevice = $null } continue } if ($null -eq $currentEntry) { continue } if ($line -match '^\s*device\s+partition=(?[A-Za-z]):\\?\s*$') { $currentEntry.Device = $Matches['letter'].ToUpperInvariant() } elseif ($line -match '^\s*osdevice\s+partition=(?[A-Za-z]):\\?\s*$') { $currentEntry.OsDevice = $Matches['letter'].ToUpperInvariant() } } if ($null -ne $currentEntry) { $entries.Add($currentEntry) } $matchingEntries = @($entries | Where-Object { $_.Device -eq $targetLetter -and $_.OsDevice -eq $targetLetter }) if ($matchingEntries.Count -ne 1) { throw "Could not identify a unique BCD entry for Windows on ${targetLetter}: (matching entries: $($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 "No free drive letter is available for partition '$($partition.Name)'." } } if ($selected -in $lettersByRole.Values) { throw "The drive letter ${selected}: is assigned to multiple partitions in the profile." } $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 "The profile '$($Profile.Name)' is not supported by the current engine (UEFI/GPT required)." } $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 "Profile '$($Profile.Name)' requires a user-defined size, which has not been validated." } $role = [string]$partition.Role if (-not $SizingState.Inputs.Contains($role)) { throw "The size for partition '$role' is missing." } $sizeMB = [long][math]::Round(([double]$SizingState.Inputs[$role] * 1024), 0) if ($sizeMB -lt ([long]$partition.Size.MinimumGB * 1024)) { throw "Partition '$role' is below its minimum size." } } { $_ -in @('Remaining','RemainderAfterInputs') } { $sizeMB = $availableMB - $usedMB if ($sizeMB -lt 1) { throw "The disk no longer has enough space for partition '$($partition.Name)'." } if ($policy -eq 'RemainderAfterInputs' -and $sizeMB -lt ([long]$partition.Size.MinimumGB * 1024)) { throw "The remaining space for '$($partition.Name)' is below the profile minimum." } } default { throw "Unsupported partition size policy: $policy" } } if ($null -ne $sizeMB) { $usedMB += [long]$sizeMB } $plan.Add([pscustomobject]@{ Definition = $partition; SizeMB = $sizeMB }) } if ($usedMB -gt $availableMB) { throw "The requested size exceeds the selected disk capacity." } 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, [Parameter(Mandatory=$true)][string]$IdentifierLabel, [AllowNull()][System.Collections.IDictionary]$UnattendProfile = $null, [AllowNull()][System.Collections.IDictionary]$UnattendConfiguration = $null, [Parameter(Mandatory=$true)][string]$Firmware, [Parameter(Mandatory=$true)][string]$TemporaryRoot, [AllowNull()][string]$DriverPath = $null, [int]$DriverInfCount = 0, [ValidateSet('Sources', 'Drivers')] [string[]]$CopyLocally = @(), [AllowNull()][scriptblock]$LogAction = $null, [AllowNull()][scriptblock]$ProgressAction = $null ) if ($null -ne $UnattendProfile) { if ($null -eq $UnattendConfiguration) { throw 'DeployCenter.ini configuration is missing for the selected Unattend profile.' } Test-DeployCenterUnattendConfiguration -Profile $UnattendProfile -Configuration $UnattendConfiguration -HostName ([string]$HostName) } $winreRecommendationWarnings = @() if ([string]$Firmware -ne [string]$Profile.Firmware) { throw ("The profile {0} requires firmware {1}, detected: {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 "The profile must define exactly one partition with the '$requiredRole' role. The disk was not modified." } } $recoveryDefinitions = @($Profile.Partitions | Where-Object { [string]$_.Role -eq 'Recovery' }) if ($recoveryDefinitions.Count -ne 1 -or [string]$recoveryDefinitions[0].Size.Policy -ne 'ConfiguredMB') { throw "The profile must define a configured size for the Recovery partition. The disk was not modified." } $winrePartitionSizeMB = [long]$recoveryDefinitions[0].Size.Value if ($winrePartitionSizeMB -le 0) { throw "The configured WinRE partition size must be greater than zero. The disk was not modified." } if (-not $Edition.ImagePath -or -not (Test-Path -LiteralPath ([string]$Edition.ImagePath) -PathType Leaf)) { throw "The selected Windows image path was not found." } if (-not $Edition.Index -or [int]$Edition.Index -lt 1) { throw "The Windows edition index is invalid." } if (-not [string]::IsNullOrWhiteSpace($DriverPath)) { if (-not (Test-Path -LiteralPath $DriverPath -PathType Container)) { throw "The selected driver folder was not found: $DriverPath" } if ($DriverInfCount -le 0 -or @(Get-ChildItem -LiteralPath $DriverPath -Filter '*.inf' -File -Recurse -ErrorAction SilentlyContinue).Count -eq 0) { throw "The selected driver folder no longer contains an INF file: $DriverPath" } } $wallpaperSourcePath = $null if ($null -ne $UnattendProfile -and $null -ne $UnattendConfiguration) { $configuredWallpaperPath = [string]$UnattendConfiguration.UnattendWallpaperPath if (-not [string]::IsNullOrWhiteSpace($configuredWallpaperPath)) { $deployRoot = Split-Path -Path $PSScriptRoot -Parent if ([System.IO.Path]::IsPathRooted($configuredWallpaperPath)) { $wallpaperSourcePath = [System.IO.Path]::GetFullPath($configuredWallpaperPath) } else { $wallpaperSourcePath = [System.IO.Path]::GetFullPath((Join-Path $deployRoot $configuredWallpaperPath)) } if ([System.IO.Path]::GetExtension($wallpaperSourcePath) -notin @('.png', '.jpg', '.bmp')) { throw 'WallpaperPath must point to a .png, .jpg, or .bmp file.' } if (-not (Test-Path -LiteralPath $wallpaperSourcePath -PathType Leaf)) { throw "Wallpaper image not found: $wallpaperSourcePath" } } } $disk = Get-Disk -Number ([int]$DiskInfo.Index) -ErrorAction Stop if ($disk.IsBoot -or $disk.IsSystem) { throw 'The selected disk hosts WinPE and cannot be erased.' } 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 -ProgressAction $ProgressAction $windowsDeployCenterPath = $null 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 "Invalid partition name in profile: $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 'Erasing and partitioning the disk…' } Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Partitioning disk {0} with profile '{1}' ; configured WinRE partition: {2} MB." -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 created $($createdPartitions.Count) partition(s), but the profile specifies $($partitionPlan.Count). Installation has been stopped." } $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 'The Recovery partition is missing from the executed plan.' } $recoveryPartition = $orderedCreatedPartitions[$recoveryPlanIndex] $recoveryLetter = [string]$driveLetters['Recovery'] $recoveryDriveRoot = '{0}:\' -f $recoveryLetter if (-not (Test-Path -LiteralPath $recoveryDriveRoot -PathType Container)) { throw "The temporary drive letter ${recoveryLetter}: de WinRE is not accessible after partitioning." } $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 "The temporary drive letter ${letter}: is not accessible after partitioning. Installation has been stopped." } # WinPE may not expose EFI/Recovery partitions in # Get-Partition queries by drive letter. Their disk order follows # the order of the definitions executed by DiskPart. $partitionsByLetter[$letter] = $orderedCreatedPartitions[$index] } $windowsLetter = [string]$driveLetters['Windows'] $windowsPath = "${windowsLetter}:\" $windowsRoot = Join-Path $windowsPath 'Windows' $windowsDeployCenterPath = Join-Path $windowsPath 'DeployCenter' $recoveryPath = "${recoveryLetter}:\Recovery\WindowsRE" $efiLetter = [string]$driveLetters['System'] if (-not $windowsLetter -or -not $recoveryLetter -or -not $efiLetter) { throw "The profile must define the System, Windows, and Recovery roles for UEFI installation with WinRE." } $wallpaperTargetPath = $null $wallpaperDestination = $null if ($wallpaperSourcePath) { $wallpaperName = [System.IO.Path]::GetFileName($wallpaperSourcePath) $wallpaperDirectory = Join-Path $windowsRoot 'Web\Wallpaper\DeployCenter' $wallpaperDestination = Join-Path $wallpaperDirectory $wallpaperName $wallpaperTargetPath = '%WINDIR%\Web\Wallpaper\DeployCenter\' + $wallpaperName if ($wallpaperDestination.Length -gt 259) { throw "The configured wallpaper path exceeds 259 characters in Windows." } } $imageFileName = [System.IO.Path]::GetFileName($deploymentImagePath) if ([string]::IsNullOrWhiteSpace($imageFileName)) { throw "Could not determine the selected Windows image filename." } $imageStagingPath = $null $imageStagingDirectory = $null if ('Sources' -in $CopyLocally) { $imageStagingPath = Join-Path (Join-Path (Join-Path $windowsPath 'DeployCenter') 'Sources') $imageFileName $imageStagingDirectory = Split-Path -Path $imageStagingPath -Parent } else { Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("Using the Windows image directly from {0}." -f $deploymentImagePath) -Level 'INFO' } $imageFile = Get-Item -LiteralPath $deploymentImagePath -ErrorAction Stop $windowsDriveInfo = New-Object System.IO.DriveInfo -ArgumentList ("{0}:\" -f $windowsLetter) $driverPackSizeBytes = [long]0 if (-not [string]::IsNullOrWhiteSpace($DriverPath) -and 'Drivers' -in $CopyLocally) { $driverFiles = @(Get-ChildItem -LiteralPath $DriverPath -Force -File -Recurse -ErrorAction Stop) $driverPackSizeBytes = [long](($driverFiles | Measure-Object -Property Length -Sum).Sum) } $requiredStagingBytes = [long]0 if ('Sources' -in $CopyLocally) { $requiredStagingBytes += [long]$imageFile.Length } if ('Drivers' -in $CopyLocally) { $requiredStagingBytes += $driverPackSizeBytes } if ($requiredStagingBytes -gt 0 -and (-not $windowsDriveInfo.IsReady -or [long]$windowsDriveInfo.AvailableFreeSpace -lt $requiredStagingBytes)) { $availableImageSpaceMB = if ($windowsDriveInfo.IsReady) { [math]::Floor(([double]$windowsDriveInfo.AvailableFreeSpace / 1MB)) } else { 0 } $requiredStagingSpaceMB = [math]::Ceiling(([double]$requiredStagingBytes / 1MB)) throw ("Insufficient space on ${windowsLetter}: to stage the Windows image and driver pack: {0} MB required, {1} MB available." -f $requiredStagingSpaceMB, $availableImageSpaceMB) } if ('Sources' -in $CopyLocally) { $imageCopyTimer = [System.Diagnostics.Stopwatch]::StartNew() try { New-Item -Path $imageStagingDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null if ($ProgressAction) { & $ProgressAction "Copying the Windows image locally…" } Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Copying image {0} from {1} to {2} locally." -f $imageFileName, $deploymentImagePath, $imageStagingDirectory) -Level 'INFO' [void](Invoke-DeployCenterRobocopy -SourceDirectory (Split-Path -Path $deploymentImagePath -Parent) ` -DestinationDirectory $imageStagingDirectory -CopyArguments @($imageFileName, '/J', '/R:2', '/W:2', '/ETA') ` -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction) $stagedImage = Get-Item -LiteralPath $imageStagingPath -ErrorAction Stop if ([long]$stagedImage.Length -ne [long]$imageFile.Length) { throw ("The local Windows image copy is incomplete: {0} bytes copied of {1}." -f $stagedImage.Length, $imageFile.Length) } $imageCopyTimer.Stop() $imageSizeMB = [math]::Round(([double]$stagedImage.Length / 1MB), 0) Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Local Windows image copy completed in {0:N1} s ({1} MB)." -f $imageCopyTimer.Elapsed.TotalSeconds, $imageSizeMB) -Level 'SUCCESS' $deploymentImagePath = $imageStagingPath } finally { if ($imageCopyTimer.IsRunning) { $imageCopyTimer.Stop() } } } $driverDeploymentPath = $DriverPath if (-not [string]::IsNullOrWhiteSpace($DriverPath) -and 'Drivers' -in $CopyLocally) { $driverStagingPath = Join-Path $windowsDeployCenterPath 'Drivers' $driverCopyTimer = [System.Diagnostics.Stopwatch]::StartNew() try { if ($ProgressAction) { & $ProgressAction 'Copying drivers locally…' } Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("Copying {0} INF file(s) from {1} to {2} before applying Windows." -f $DriverInfCount, $DriverPath, $driverStagingPath) -Level 'INFO' New-Item -Path $driverStagingPath -ItemType Directory -Force -ErrorAction Stop | Out-Null [void](Invoke-DeployCenterRobocopy -SourceDirectory $DriverPath -DestinationDirectory $driverStagingPath ` -CopyArguments @('/E', '/J', '/R:2', '/W:2', '/ETA') -TemporaryRoot $TemporaryRoot ` -LogAction $LogAction -ProgressAction $ProgressAction) $stagedInfCount = @(Get-ChildItem -LiteralPath $driverStagingPath -Filter '*.inf' -File -Recurse -ErrorAction Stop).Count if ($stagedInfCount -ne $DriverInfCount) { throw ("The local driver copy is incomplete: {0} INF file(s) copied of {1}." -f $stagedInfCount, $DriverInfCount) } $driverCopyTimer.Stop() Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("Local driver copy completed in {0:N1} s ({1} INF file(s))." -f $driverCopyTimer.Elapsed.TotalSeconds, $stagedInfCount) -Level 'SUCCESS' $driverDeploymentPath = $driverStagingPath } finally { if ($driverCopyTimer.IsRunning) { $driverCopyTimer.Stop() } } } elseif (-not [string]::IsNullOrWhiteSpace($DriverPath)) { Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("Using the driver pack directly from {0}." -f $DriverPath) -Level 'INFO' } if ($ProgressAction) { & $ProgressAction "Applying Windows to ${windowsLetter}:…" } Invoke-DeployCenterNativeCommand -FilePath $dismPath -Arguments @( '/Apply-Image', "/ImageFile:$deploymentImagePath", "/Index:$([int]$Edition.Index)", "/ApplyDir:$windowsPath", '/CheckIntegrity' ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction if ([string]::IsNullOrWhiteSpace($DriverDeploymentPath)) { Write-DeployCenterDeploymentLog -LogAction $LogAction -Message 'No matching driver pack; injection skipped.' -Level 'INFO' } else { $driverInstallTimer = $null try { if ($ProgressAction) { & $ProgressAction 'Installing hardware drivers…' } $driverInstallTimer = [System.Diagnostics.Stopwatch]::StartNew() Invoke-DeployCenterNativeCommand -FilePath $dismPath -Arguments @( "/Image:$windowsPath", '/Add-Driver', "/Driver:$driverDeploymentPath", '/Recurse' ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction $driverInstallTimer.Stop() Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("DISM driver injection completed in {0:N1} s." -f $driverInstallTimer.Elapsed.TotalSeconds) -Level 'SUCCESS' } finally { if ($driverInstallTimer -and $driverInstallTimer.IsRunning) { $driverInstallTimer.Stop() } } } if ($wallpaperSourcePath) { New-Item -Path $wallpaperDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null Copy-Item -LiteralPath $wallpaperSourcePath -Destination $wallpaperDestination -Force -ErrorAction Stop Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("Wallpaper image copied to Windows: {0}." -f $wallpaperDestination) -Level 'INFO' } if ($ProgressAction) { & $ProgressAction 'Checking WinRE partition capacity…' } $sourceWinre = Join-Path $windowsRoot 'System32\Recovery\winre.wim' if (-not (Test-Path -LiteralPath $sourceWinre -PathType Leaf)) { throw "winre.wim is missing from the applied Windows image; WinRE cannot be configured." } $winreFile = Get-Item -LiteralPath $sourceWinre -ErrorAction Stop $recoveryPartition = $partitionsByLetter[$recoveryLetter] $recoveryDriveInfo = New-Object System.IO.DriveInfo -ArgumentList "${recoveryLetter}:\" if (-not $recoveryDriveInfo.IsReady) { throw "The recovery volume ${recoveryLetter}: is not ready to check WinRE space." } $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 = "Insufficient space on the WinRE partition to copy winre.wim: file size: $winreSizeForLog MB, available space $availableFreeMB MB. Deployment cannot configure 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("partition size: $actualPartitionMB MB, recommended minimum: 990 MB") } if ($expectedFreeBytes -lt $minimumFreeAfterCopyBytes) { [void]$recommendationWarnings.Add("free space after copy: $availableAfterCopyMB MB, recommended minimum: 250 MB") } if ($recommendationWarnings.Count -gt 0) { $warningDetails = $recommendationWarnings -join '; ' $winreRecommendationWarnings = @($recommendationWarnings.ToArray()) $message = "WinRE warning: Microsoft recommendations are not all met ($warningDetails). winre.wim can still be copied; deployment will continue." Write-DeployCenterDeploymentLog -LogAction $LogAction -Message $message -Level 'WARNING' } else { Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("WinRE validation succeeded: partition {0} MB, winre.wim {1} MB, estimated free space after copy {2} MB." -f $winrePartitionSizeMB, $winreSizeForLog, $availableAfterCopyMB) -Level 'SUCCESS' } if ($null -ne $UnattendProfile) { if ($ProgressAction) { & $ProgressAction 'Preparing the Windows answer file…' } $editionArchitecture = if ($Edition.Edition -and $Edition.Edition.Architecture) { [string]$Edition.Edition.Architecture } else { [string]$Edition.Architecture } $unattendXml = New-DeployCenterUnattendXml -Profile $UnattendProfile ` -Configuration $UnattendConfiguration -HostName ([string]$HostName) ` -Architecture $editionArchitecture -WallpaperPath ([string]$wallpaperTargetPath) [System.IO.File]::WriteAllText($unattendPath, $unattendXml, (New-Object System.Text.UTF8Encoding($false))) $pantherUnattendDirectory = Join-Path $windowsRoot 'Panther\Unattend' New-Item -Path $pantherUnattendDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null Copy-Item -LiteralPath $unattendPath -Destination (Join-Path $pantherUnattendDirectory 'Unattend.xml') -Force -ErrorAction Stop Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("Windows answer file prepared with profile '{0}' (computer name: {1})." -f $UnattendProfile.Name, $HostName) -Level 'INFO' } if ($ProgressAction) { & $ProgressAction 'Configuring UEFI boot…' } Invoke-DeployCenterNativeCommand -FilePath $bcdbootPath -Arguments @( ("{0}:\Windows" -f $windowsLetter), '/s', ("{0}:" -f $efiLetter), '/f', 'UEFI' ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction if ($ProgressAction) { & $ProgressAction 'Configuring the WinRE recovery partition…' } New-Item -Path $recoveryPath -ItemType Directory -Force -ErrorAction Stop | Out-Null if ($ProgressAction) { & $ProgressAction 'Copying winre.wim…' } $recoveryImagePath = Join-Path $recoveryPath 'winre.wim' [void](Invoke-DeployCenterRobocopy -SourceDirectory (Split-Path -Path $sourceWinre -Parent) ` -DestinationDirectory $recoveryPath -CopyArguments @('winre.wim', '/J', '/R:2', '/W:2', '/ETA') ` -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction) $copiedWinre = Get-Item -LiteralPath $recoveryImagePath -ErrorAction Stop if ([long]$copiedWinre.Length -ne [long]$winreFile.Length) { throw ("The WinRE image copy is incomplete: {0} bytes copied of {1}." -f $copiedWinre.Length, $winreFile.Length) } $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 was not found in the applied Windows image or WinPE.' } $bcdStorePath = "${efiLetter}:\EFI\Microsoft\Boot\BCD" $windowsBootGuid = Get-DeployCenterOfflineWindowsBootGuid -BcdStorePath $bcdStorePath ` -WindowsDriveLetter $windowsLetter -IdentifierLabel $IdentifierLabel -LogAction $LogAction if ($ProgressAction) { & $ProgressAction 'Registering the WinRE image…' } Invoke-DeployCenterNativeCommand -FilePath $reagentcPath -Arguments @( '/SetReImage', '/Path', $recoveryPath, '/Target', $windowsRoot ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction if ($ProgressAction) { & $ProgressAction 'Enabling WinRE…' } Invoke-DeployCenterNativeCommand -FilePath $reagentcPath -Arguments @( '/Enable', '/OSGUID', $windowsBootGuid ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction if ($ProgressAction) { & $ProgressAction 'Finalizing 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 may retain the Recovery drive letter in its properties # after DiskPart has actually removed it in 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 did not release all temporary EFI/WinRE drive letters: $remaining." } } finally { if (Test-Path -LiteralPath $removePath -PathType Leaf) { Remove-Item -LiteralPath $removePath -Force -ErrorAction SilentlyContinue } } } $completionMode = if ($null -ne $UnattendProfile) { "Unattend profile '$($UnattendProfile.Name)'" } else { 'Use OOBE' } Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Deployment complete: Windows {0}, disk {1}, mode {2}." -f $Edition.Name, $disk.Number, $completionMode) -Level 'SUCCESS' if ($ProgressAction) { & $ProgressAction 'Cleaning up temporary files…' } } finally { foreach ($temporaryFile in @($cleanPath, $diskpartPath, $unattendPath)) { if (Test-Path -LiteralPath $temporaryFile -PathType Leaf) { Remove-Item -LiteralPath $temporaryFile -Force -ErrorAction SilentlyContinue } } } } finally { if ($windowsDeployCenterPath -and (Test-Path -LiteralPath $windowsDeployCenterPath -PathType Container)) { try { Remove-Item -LiteralPath $windowsDeployCenterPath -Recurse -Force -ErrorAction Stop Write-DeployCenterDeploymentLog -LogAction $LogAction -Message 'Temporary C:\DeployCenter directory removed.' -Level 'INFO' } catch { Write-DeployCenterDeploymentLog -LogAction $LogAction ` -Message ("Unable to remove the temporary C:\DeployCenter directory: {0}" -f $_.Exception.Message) -Level 'WARNING' } } Remove-DeployCenterDismSourceAccess -Lease $sourceLease -TemporaryRoot $TemporaryRoot ` -LogAction $LogAction -ProgressAction $ProgressAction } return [pscustomobject]@{ WinRERecommendationWarnings = @($winreRecommendationWarnings) WinRERecommendationsMet = ($winreRecommendationWarnings.Count -eq 0) } } Export-ModuleMember -Function Invoke-DeployCenterWindowsDeployment, Restart-DeployCenterWinPE