Private/New-HDTBootImageManifest.ps1
|
function New-HDTBootImageManifest { <# .SYNOPSIS Builds the boot image build manifest as JSON text. .DESCRIPTION THE ANSWER TO BOOT IMAGE DRIFT: the build is deterministic and repeatable, and records a manifest of exactly what went in. Boot image drift - where nobody remembers what's in the WIM - is a real MDT operational problem. It is written to <workspace>\Boot\<name>.manifest.json LAST, after both artifacts exist, so a manifest that is there describes a build that finished. IT IS PURE, AND IT RETURNS TEXT. It is handed everything it records, so every claim in it can be asserted without a fifteen-minute build; and it returns the JSON rather than writing it, so Update-HDTBootImage writes it through IFileSystem like everything else and the write is provable with nothing on disk. TWO THINGS IN IT ARE ASSERTIONS AN OPERATOR CAN MAKE WITHOUT THE TEST SUITE, AND THAT IS THE POINT OF RECORDING THEM: artifacts.isoBootWimSha256 === artifacts.wim.sha256 The one-build-two-artifacts guarantee, written into the file it is a guarantee about. The WIM inside the ISO and the standalone WIM are one file copied, not two exports, so "a bug reproduced from the ISO is a bug in the PXE path" is checkable by anyone holding this file. credential.username, and NEVER the secret The manifest sits in Boot\ beside the WIM. It says WHICH account the image carries so an operator can audit it, and it carries nothing that would let them use it. THE SECRET IS DROPPED BY CONSTRUCTION, not by filtering: only Username, Embedded and PromptForCredential are read out of -CredentialRecord, so a caller that passes a password in that hashtable cannot leak it here. A unit test greps the serialised text for the protected string to prove it. EVERY LIST IS FORCED TO A JSON ARRAY with -AsArray-equivalent wrapping, because a one-element list serialised as an object breaks an operator's jq and the integration suite's own read-back, and a one-component build is a perfectly ordinary build. .PARAMETER BuildId The build's GUID, also written into bootstrap.json so a booted machine's RESULT.json names the image it came from. .PARAMETER BuiltUtc The build timestamp, as the ISO 8601 string it is recorded as. It is a string rather than a [datetime] because pwsh 7 and Windows PowerShell 5.1 disagree about round-tripping one through JSON (05-03's bootstrap trap). .PARAMETER BuiltOn The build host's computer name. .PARAMETER EngineVersion The Hephaestus module version that built it - and, because the module is staged from its own ModuleBase, the version inside the image. .PARAMETER WorkspaceId workspace.yaml's id. .PARAMETER Architecture amd64 or arm64. .PARAMETER Language The language pack folder, en-us by default. .PARAMETER Adk Root, Oscdimg, WinPeWim and WinPeWimSha256. .PARAMETER Component The rows Get-HDTBootImageComponent produced, in the order they were applied. .PARAMETER Driver The rows AddDriver reported back. .PARAMETER Payload What HDT staged into the image: Destination, Source, FileCount, SizeBytes. .PARAMETER ExtraContent What the administrator asked for: Source, Destination, FileCount. Kept separate from Payload because they are different promises. .PARAMETER Startnet The exact text written to startnet.cmd. .PARAMETER CredentialRecord Username, Embedded, PromptForCredential. Anything else in this hashtable is ignored. .PARAMETER Wim Path, Sha256, SizeBytes. .PARAMETER Iso Path, Sha256, SizeBytes, Firmware, NoPromptForKey, Skipped. Under -SkipIso only Skipped is set. .PARAMETER IsoBootWimSha256 The hash of sources\boot.wim in the media the ISO was built from. Empty under -SkipIso. .INPUTS None. This command does not accept pipeline input. .OUTPUTS System.String - the manifest as JSON. .EXAMPLE New-HDTBootImageManifest -BuildId $id -BuiltUtc $utc -BuiltOn $env:COMPUTERNAME ` -EngineVersion '0.1.0' -WorkspaceId 'HDT-LAB' -Architecture amd64 -Language en-us ` -Adk $adk -Component $component -Driver $driver -Payload $payload ` -ExtraContent $extra -Startnet $startnet -CredentialRecord $credential ` -Wim $wim -Iso $iso -IsoBootWimSha256 $wim.Sha256 #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds a string; it changes no state. The caller writes it through IFileSystem under its own ShouldProcess.')] [CmdletBinding()] [OutputType([string])] param( [Parameter()] [AllowEmptyString()] [string] $BuildId = '', [Parameter()] [AllowEmptyString()] [string] $BuiltUtc = '', [Parameter()] [AllowEmptyString()] [string] $BuiltOn = '', [Parameter()] [AllowEmptyString()] [string] $EngineVersion = '', [Parameter()] [AllowEmptyString()] [string] $WorkspaceId = '', [Parameter()] [AllowEmptyString()] [string] $Architecture = '', [Parameter()] [AllowEmptyString()] [string] $Language = '', [Parameter()] [AllowNull()] [hashtable] $Adk, [Parameter()] [AllowNull()] [AllowEmptyCollection()] [object[]] $Component, [Parameter()] [AllowNull()] [AllowEmptyCollection()] [object[]] $Driver, [Parameter()] [AllowNull()] [AllowEmptyCollection()] [object[]] $Payload, [Parameter()] [AllowNull()] [AllowEmptyCollection()] [object[]] $ExtraContent, [Parameter()] [AllowEmptyString()] [string] $Startnet = '', [Parameter()] [AllowNull()] [hashtable] $CredentialRecord, [Parameter()] [AllowNull()] [hashtable] $Wim, [Parameter()] [AllowNull()] [hashtable] $Iso, [Parameter()] [AllowEmptyString()] [string] $IsoBootWimSha256 = '' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Reads a key off a hashtable that may be $null or may not carry it. Engine # code runs under Set-StrictMode -Version Latest, where a missing key on a # hashtable is $null but a missing PROPERTY throws, and these arrive as both. $valueOf = { param([hashtable] $Table, [string] $Name, [object] $Default) if ($null -eq $Table) { return $Default } if (-not $Table.ContainsKey($Name)) { return $Default } if ($null -eq $Table[$Name]) { return $Default } return $Table[$Name] } $propertyOf = { param([object] $Row, [string] $Name, [object] $Default) if ($null -eq $Row) { return $Default } if ($Row -is [System.Collections.IDictionary]) { if (-not $Row.Contains($Name)) { return $Default } if ($null -eq $Row[$Name]) { return $Default } return $Row[$Name] } $member = $Row.PSObject.Properties[$Name] if ($null -eq $member) { return $Default } if ($null -eq $member.Value) { return $Default } return $member.Value } # -- what went in --------------------------------------------------------- $componentRow = New-Object -TypeName System.Collections.ArrayList foreach ($item in @($Component)) { [void] $componentRow.Add([ordered] @{ order = [int] (& $propertyOf $item 'Order' 0) name = [string] (& $propertyOf $item 'Name' '') cab = [string] (& $propertyOf $item 'CabPath' '') languageCab = [string] (& $propertyOf $item 'LanguageCabPath' '') required = [bool] (& $propertyOf $item 'Required' $false) }) } $driverRow = New-Object -TypeName System.Collections.ArrayList foreach ($item in @($Driver)) { [void] $driverRow.Add([ordered] @{ inf = [string] (& $propertyOf $item 'Inf' '') provider = [string] (& $propertyOf $item 'Provider' '') version = [string] (& $propertyOf $item 'Version' '') date = [string] (& $propertyOf $item 'Date' '') }) } $payloadRow = New-Object -TypeName System.Collections.ArrayList foreach ($item in @($Payload)) { [void] $payloadRow.Add([ordered] @{ destination = [string] (& $propertyOf $item 'Destination' '') source = [string] (& $propertyOf $item 'Source' '') fileCount = [int] (& $propertyOf $item 'FileCount' 0) sizeBytes = [long] (& $propertyOf $item 'SizeBytes' [long] 0) }) } $extraRow = New-Object -TypeName System.Collections.ArrayList foreach ($item in @($ExtraContent)) { [void] $extraRow.Add([ordered] @{ source = [string] (& $propertyOf $item 'Source' '') destination = [string] (& $propertyOf $item 'Destination' '') fileCount = [int] (& $propertyOf $item 'FileCount' 0) }) } # -- the document --------------------------------------------------------- # # THE CREDENTIAL BLOCK READS THREE KEYS AND NO OTHERS. A caller that put a # password in that hashtable cannot leak it through here. $document = [ordered] @{ schemaVersion = 1 buildId = $BuildId builtUtc = $BuiltUtc builtOn = $BuiltOn engineVersion = $EngineVersion workspaceId = $WorkspaceId architecture = $Architecture language = $Language adk = [ordered] @{ root = [string] (& $valueOf $Adk 'Root' '') oscdimg = [string] (& $valueOf $Adk 'Oscdimg' '') winpeWim = [string] (& $valueOf $Adk 'WinPeWim' '') winpeWimSha256 = [string] (& $valueOf $Adk 'WinPeWimSha256' '') } optionalComponents = [object[]] @($componentRow) drivers = [object[]] @($driverRow) payload = [object[]] @($payloadRow) extraContent = [object[]] @($extraRow) startnet = $Startnet credential = [ordered] @{ username = [string] (& $valueOf $CredentialRecord 'Username' '') embedded = [bool] (& $valueOf $CredentialRecord 'Embedded' $false) promptForCredential = [bool] (& $valueOf $CredentialRecord 'PromptForCredential' $false) } artifacts = [ordered] @{ wim = [ordered] @{ path = [string] (& $valueOf $Wim 'Path' '') sha256 = [string] (& $valueOf $Wim 'Sha256' '') sizeBytes = [long] (& $valueOf $Wim 'SizeBytes' ([long] 0)) } iso = [ordered] @{ path = [string] (& $valueOf $Iso 'Path' '') sha256 = [string] (& $valueOf $Iso 'Sha256' '') sizeBytes = [long] (& $valueOf $Iso 'SizeBytes' ([long] 0)) firmware = [string] (& $valueOf $Iso 'Firmware' '') noPromptForKey = [bool] (& $valueOf $Iso 'NoPromptForKey' $false) skipped = [bool] (& $valueOf $Iso 'Skipped' $false) } isoBootWimSha256 = $IsoBootWimSha256 } } # Depth 6: adk / artifacts.wim / artifacts.iso are two levels down and the # component rows are one, so the default of 2 would render them as type # names. -Compress is deliberately NOT set - this file is read by people. $text = ConvertTo-Json -InputObject $document -Depth 6 # A ONE-ELEMENT LIST SERIALISES AS AN OBJECT ON BOTH ENGINES, and an operator # indexing optionalComponents[0] would get nothing. ConvertTo-Json -AsArray # does not exist under Windows PowerShell 5.1, so the arrays are forced by # wrapping them above; this is the assertion that they came out as arrays, # made here rather than left to a caller to discover. foreach ($name in @('optionalComponents', 'drivers', 'payload', 'extraContent')) { if ($text -notmatch ('"{0}":\s*(\[|null)' -f $name)) { throw ("The boot image manifest serialised '{0}' as an object rather than an array. That would break every consumer that indexes it." -f $name) } } return $text } |