Public/Steps/Invoke-HDTApplyImageStep.ps1
|
function Invoke-HDTApplyImageStep { <# .SYNOPSIS Applies Windows to the volume the partition step created. .DESCRIPTION Applying an image, as a step: - name: Apply OS type: ApplyImage os: "%HDTOSImage%" # a catalog id ... or image: a path index: 1 # or name: or edition: target: primary # 'primary' = %HDTOSVolume%, or a letter timeoutMinutes: 60 THE INDEX IS RESOLVED, NEVER GUESSED. Get-HDTOperatingSystem reads the catalog and Resolve-HDTImageIndex chooses, matching each criterion independently and intersecting them - so 'edition: ServerStandard' with 'index: 2' resolves on the real Server 2025 media where the edition alone is ambiguous, and two images matching one request is an HDTAmbiguousImageError listing both rather than a coin toss. A TARGET THAT RESOLVES TO NOTHING IS A FAILURE NAMING THE VARIABLE, AND NEVER A GUESS AT C:. Applying an image over whatever happens to be on C: is the second most destructive thing this toolkit could do, and 'target: primary' with no HDTOSVolume means the partition step did not run - which is a sequence to fix, not a drive letter to invent. THE ELAPSED TIME IS LOGGED. A lab test measured 95 seconds for a 4 GB WIM applied over SMB, so the duration is the number that tells a technician whether a slow deployment was the network or the disk. The native.exec record carries the image, the index and the target at Info. AN EXPLICIT image: PATH BYPASSES THE CATALOG, for media too large to bring into the share. The indices are then read through IImageService.GetImageInfo, which is the same list Import-HDTOperatingSystem would have written into os.yaml. THE CONTENT PROVIDER IS NOT ASKED ABOUT IT EITHER: an explicit path is explicit, and a provider must not second-guess it. THE IMAGE IS RESOLVED THROUGH THE CONTENT PROVIDER when the run was started with one - $Context.Service.Content, handed to Get-HDTOperatingSystem, which is the whole change this step needed to close the seam 04-02 marked. It never learns whether that provider is Local or Smb, and the claim that it cannot tell is asserted by running this step through both and comparing the ordered list of every service call. .PARAMETER Step A flattened step from Import-HDTSequenceDocument. .PARAMETER Context A New-HDTExecutionContext context. Its Service catalog must carry Image and FileSystem services. .OUTPUTS A New-HDTStepResult. Data carries imagePath, index, target and durationMs, or errorId on a refusal. .EXAMPLE Invoke-HDTApplyImageStep -Step $step -Context $context #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] [object] $Step, [Parameter(Mandatory = $true)] [ValidateNotNull()] [object] $Context ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $fail = { param([string] $Message, [string] $ErrorId) $data = [ordered] @{} if (-not [string]::IsNullOrWhiteSpace($ErrorId)) { $data['errorId'] = $ErrorId } Write-HDTLog -Context $Context.Log -Message $Message -Severity Error -Event step.fail ` -Component 'ApplyImage' -Data $data return (New-HDTStepResult -Status Failed -Message $Message -Data $data) } try { $osId = Get-HDTStepProperty -Step $Step -Name 'os' -Context $Context -Expand -As String $imageProperty = Get-HDTStepProperty -Step $Step -Name 'image' -Context $Context -Expand -As String $index = Get-HDTStepProperty -Step $Step -Name 'index' -Context $Context -Expand -As Int $name = Get-HDTStepProperty -Step $Step -Name 'name' -Context $Context -Expand -As String $edition = Get-HDTStepProperty -Step $Step -Name 'edition' -Context $Context -Expand -As String $target = Get-HDTStepProperty -Step $Step -Name 'target' -Default 'primary' -Context $Context -Expand -As String # AND WHAT THE AUTHOR WROTE, unexpanded. A target of %HDTOSVolume% that # nothing published arrives here as an empty string, and the refusal has # to be able to name the variable that was meant to fill it. $targetWritten = Get-HDTStepProperty -Step $Step -Name 'target' -Default 'primary' -As String } catch { return (& $fail ([string] $_.Exception.Message) 'HDTConfigurationError') } try { $imageService = $Context.Service.GetRequired('Image', 'ApplyImage') $fileSystem = $Context.Service.GetRequired('FileSystem', 'ApplyImage') } catch { return (& $fail ([string] $_.Exception.Message) '') } # -- which image ------------------------------------------------------ $imagePath = '' $imageRow = @() $defaultIndex = 0 if (-not [string]::IsNullOrWhiteSpace($imageProperty)) { $imagePath = $imageProperty try { $imageRow = @($imageService.GetImageInfo($imagePath)) } catch { return (& $fail ("the image '{0}' could not be read: {1}" -f $imagePath, [string] $_.Exception.Message) '') } } elseif (-not [string]::IsNullOrWhiteSpace($osId)) { try { # THE WHOLE OF THIS STEP'S SHARE OF DESIGN 6. The catalog resolves # its image through the provider when the run was started with one, # and this step does not know or care which provider it is - which is # DESIGN 6.2's "a content projection plus a provider swap, not a # parallel code path", asserted by the operation-list equality test # in tests/unit/Invoke-HDTApplyImageStep.Tests.ps1. $catalog = Get-HDTOperatingSystem -WorkspaceRoot ([string] $Context.WorkspaceRoot) -Id $osId ` -FileSystem $fileSystem -Content $Context.Service.Content } catch { return (& $fail ([string] $_.Exception.Message) (([string] $_.FullyQualifiedErrorId).Split(',')[0])) } $imagePath = [string] $catalog.ImagePath $imageRow = @($catalog.Images) $defaultIndex = [int] $catalog.DefaultIndex } else { return (& $fail ("step '{0}' declares neither os nor image. An ApplyImage step names a catalog id or an image path." -f $Step.Name) 'HDTConfigurationError') } # -- which index ------------------------------------------------------ $resolveArgument = @{ Image = [object[]] $imageRow } if ($null -ne $index) { $resolveArgument['Index'] = [int] $index } if (-not [string]::IsNullOrWhiteSpace($name)) { $resolveArgument['Name'] = $name } if (-not [string]::IsNullOrWhiteSpace($edition)) { $resolveArgument['Edition'] = $edition } if ($defaultIndex -gt 0) { $resolveArgument['DefaultIndex'] = $defaultIndex } try { $resolved = Resolve-HDTImageIndex @resolveArgument } catch { return (& $fail ([string] $_.Exception.Message) (([string] $_.FullyQualifiedErrorId).Split(',')[0])) } $resolvedIndex = [int] $resolved.Index # -- where ------------------------------------------------------------ $letter = $target if ($target -eq 'primary') { $letter = [string] $Context.Variable['HDTOSVolume'] if ([string]::IsNullOrWhiteSpace($letter)) { return (& $fail ("step '{0}' applies to the primary volume and HDTOSVolume is not set. The partition step publishes it; HDT will not guess a drive letter to apply {1} GB of Windows over." -f $Step.Name, [math]::Round([long] $resolved.SizeBytes / 1073741824, 1)) 'HDTConfigurationError') } } $letter = $letter.Trim().TrimEnd('\').TrimEnd(':') # A TARGET THAT RESOLVED TO NOTHING IS THE SITUATION 'primary' NAMES, said a # different way: the partition step did not publish the volume. It used to # report "target '' is not a drive letter", which describes the symptom and # none of the cause - on the one step that writes an operating system # somewhere. if ($letter.Length -eq 0) { $said = "step '{0}': target '{1}' resolved to nothing." -f $Step.Name, $target if ($targetWritten -match '^\s*%([^%]+)%\s*$') { $said = ("step '{0}' applies to %{1}%, which is not set. The partition step publishes it; HDT will not guess a drive letter to apply an operating system over." -f $Step.Name, [string] $Matches[1]) } return (& $fail $said 'HDTConfigurationError') } # AND ONE LETTER IS ONE LETTER. Taking the first character of whatever # arrived meant 'target: the big disk' applied Windows to T:\ without a word # - a wrong disk, chosen by a typo, and reported as success. if ($letter -notmatch '^[A-Za-z]$') { return (& $fail ("step '{0}': target '{1}' is not a drive letter." -f $Step.Name, $target) 'HDTConfigurationError') } $applyPath = '{0}:\' -f $letter.Substring(0, 1).ToUpperInvariant() # -- the apply -------------------------------------------------------- $data = [ordered] @{ imagePath = $imagePath index = $resolvedIndex imageName = [string] $resolved.Name target = $applyPath } Write-HDTLog -Context $Context.Log -Event 'native.exec' -Component 'ApplyImage' ` -Message ("applying {0} (index {1}, {2}) to {3}" -f $imagePath, $resolvedIndex, [string] $resolved.Name, $applyPath) ` -Data $data $clock = $Context.Service.Clock $startedUtc = $clock.GetUtcNow() # THE ONLY NUMBER THAT MOVES FOR THE NEXT NINE MINUTES. Everything else on # the screen - the counter, the sequence percentage, the step name - is # correct and motionless for the whole of an 18 GB apply, and a motionless # screen is indistinguishable from a hung machine to the person standing in # front of it. dism prints a percentage as it works; the adapter hands every # line it prints to this. # # STILL NO SECOND CHANNEL (DESIGN 11.1). This writes a record to the JSONL # and asks the display to re-read it, exactly as the step loop does between # steps. The screen and the log cannot disagree because they are the same # facts. # # EVERY FIVE POINTS, AND ALWAYS AT A HUNDRED. dism prints about a hundred # meter lines and each one would otherwise be a log record and a re-read of # the log by the display; five is the granularity a bar on a wall is read # at. A hundred is reported whether or not it clears the threshold, because # the last thing the log says about an apply should be that it finished. $progressState = @{ Percent = 0 } # THE CALLBACK RUNS IN SOMEBODY ELSE'S MODULE, AND COMMAND RESOLUTION # FOLLOWS THE CALLER RATHER THAN THE CLOSURE. The real IImageService is a # pscustomobject built in this module, but the fake is a PowerShell CLASS in # HDTFakes - and a scriptblock invoked from a class method resolves its # commands in the class's module, where ConvertFrom-HDTDismProgressLine, # private to Hephaestus, does not exist. That failure landed in the catch # below and looked exactly like an apply that printed no percentages: every # other assertion still passed. # # SO THEY ARE RESOLVED HERE, WHERE THIS FUNCTION IS, and captured. A # CommandInfo invoked with & does not care whose scope it is called from. $parseProgress = Get-Command -Name 'ConvertFrom-HDTDismProgressLine' $writeLog = Get-Command -Name 'Write-HDTLog' $updateDisplay = Get-Command -Name 'Update-HDTProgressDisplay' $onOutput = { param([string] $Line) # A BAR DOES NOT GET TO FAIL A DEPLOYMENT. This runs inside the apply, # on a machine part-way through writing Windows to a disk; a log write # that lost its RAM disk or a display whose runspace has died is not a # reason to stop building a computer. try { $percent = & $parseProgress -Line $Line if ($null -eq $percent) { return } $reported = [int] $progressState['Percent'] if ([int] $percent -le $reported) { return } if ([int] $percent -lt ($reported + 5) -and [int] $percent -lt 100) { return } $progressState['Percent'] = [int] $percent & $writeLog -Context $Context.Log -Event 'step.progress' -Component 'ApplyImage' ` -Message ('applying {0} (index {1}) to {2}: {3}%' -f $imagePath, $resolvedIndex, $applyPath, [int] $percent) ` -Data ([ordered] @{ imagePath = $imagePath index = $resolvedIndex target = $applyPath percent = [int] $percent }) & $updateDisplay -Context $Context } catch { # Kept where a debugger can reach it rather than thrown away: an # empty catch is how a percentage that never appeared stays a # mystery. $progressState['Error'] = [string] $_.Exception.Message } }.GetNewClosure() try { $imageService.ApplyImage($imagePath, $resolvedIndex, $applyPath, $onOutput) } catch { return (& $fail ("applying {0} (index {1}) to {2} failed: {3}" -f $imagePath, $resolvedIndex, $applyPath, [string] $_.Exception.Message) '') } $durationMillisecond = [long] (($clock.GetUtcNow()) - $startedUtc).TotalMilliseconds $data['durationMs'] = $durationMillisecond $Context.Variable['HDTImageIndex'] = $resolvedIndex $message = 'applied {0} (index {1}) to {2} in {3} ms.' -f $imagePath, $resolvedIndex, $applyPath, $durationMillisecond Write-HDTLog -Context $Context.Log -Event 'native.exec' -Component 'ApplyImage' -Message $message -Data $data return (New-HDTStepResult -Status Completed -Message $message -Data $data) } |