Public/Get-HDTBootImage.ps1

function Get-HDTBootImage {
    <#
        .SYNOPSIS
            Lists the boot images built on a deployment share, and which of them
            the share still declares.
 
        .DESCRIPTION
            A WORKSPACE DECLARES ONE BOOT IMAGE. bootImage is an object in
            workspace.schema.json rather than an array, the way an MDT deployment
            share has one LiteTouch image. So this command is not "the share's
            boot images" - it is what is sitting in Boot\, which is a different
            question and usually a longer answer.
 
            RENAMING THE IMAGE IS WHAT MAKES THE DIFFERENCE. Update-HDTBootImage
            writes <name>.wim, <name>.iso and <name>.manifest.json. Change the
            name on the Windows PE window and the next build writes a new trio
            beside the old one; nothing references the old name afterwards and
            nothing reported it. The lab share reached three names and about two
            gigabytes that way, which is what this command exists to show.
 
            IT READS THE MANIFESTS, NOT THE ARTIFACTS. Sizes and hashes come out
            of Boot\<name>.manifest.json, which Update-HDTBootImage wrote at
            build time precisely so nothing has to hash a 500 MB ISO to answer a
            question about it.
 
            IT NEVER THROWS FOR A BAD FILE. One truncated manifest among good
            ones is a row with Status 'Error', not a command that fails - the
            same three-answer shape Get-HDTConsoleBootImage uses for the declared
            image, and for the same reason: a fault in one build should not hide
            the other four.
 
            IT REMOVES NOTHING. Naming an orphan is useful; deciding to delete
            half a gigabyte off somebody's deployment share is not a thing a Get-
            command should do. Pipe it to Remove-Item by hand once you have
            looked at it.
 
        .PARAMETER Root
            The workspace root - a local path or a UNC share.
 
        .PARAMETER Orphan
            Only the builds the workspace does not declare.
 
        .PARAMETER FileSystem
            An IFileSystem. Defaults to the real one.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject[] - one row per manifest
            in Boot\, newest build first, with Name, Declared, Status, Error,
            BuildId, BuiltUtc, BuiltOn, EngineVersion, Architecture, Language,
            ManifestPath, WimPath, WimSizeBytes, IsoPath, IsoSizeBytes and
            SizeBytes.
 
        .EXAMPLE
            Get-HDTBootImage -Root 'C:\HDTLab\Share'
 
            Every build on the share, the declared one marked.
 
        .EXAMPLE
            Get-HDTBootImage -Root 'C:\HDTLab\Share' -Orphan |
                Format-Table Name, BuiltUtc, @{ n = 'GB'; e = { [math]::Round($_.SizeBytes / 1GB, 2) } }
 
            What renaming the image has left behind, and what it is costing.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string] $Root,

        [Parameter()]
        [switch] $Orphan,

        [Parameter()]
        [ValidateNotNull()]
        [object] $FileSystem = (New-HDTFileSystem)
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $bootFolder = Get-HDTWorkspacePath -Root $Root -Kind Boot

    # A SHARE BEING SET UP HAS NO Boot FOLDER, which is a legitimate state and
    # not an error - the console draws it as Missing rather than as a fault, and
    # an empty answer here is the same statement.
    if (-not $FileSystem.TestPath($bootFolder)) { return [pscustomobject[]] @() }

    # WHAT THE SHARE DECLARES, read once. A workspace that will not parse is not
    # a reason to refuse to list what is on the disk: the orphan question is
    # exactly the one somebody asks when the document is in a state they are
    # trying to understand.
    $declared = ''

    try {
        $workspacePath = [System.IO.Path]::Combine($Root, 'workspace.yaml')

        if ($FileSystem.TestPath($workspacePath)) {
            $workspace = Import-HDTWorkspaceDocument -Path $workspacePath -FileSystem $FileSystem
            $declared = [string] $workspace.BootImage.Name
        }
    } catch {
        $declared = ''
    }

    $row = New-Object -TypeName System.Collections.ArrayList

    foreach ($child in @($FileSystem.GetChildItem($bootFolder))) {

        $leaf = [System.IO.Path]::GetFileName([string] $child)
        if ($leaf -notlike '*.manifest.json') { continue }

        # THE NAME IS THE FILE NAME MINUS THE SUFFIX, not a key inside the
        # document: a manifest that will not parse still has a name, and it is
        # the name the artifacts beside it carry.
        $name = $leaf.Substring(0, $leaf.Length - '.manifest.json'.Length)

        $entry = [pscustomobject] @{
            Name          = $name
            Declared      = [bool] ($name -eq $declared)
            Status        = 'Ok'
            Error         = ''
            BuildId       = ''
            BuiltUtc      = $null
            BuiltOn       = ''
            EngineVersion = ''
            Architecture  = ''
            Language      = ''
            ManifestPath  = [string] $child
            WimPath       = ''
            WimSizeBytes  = [long] 0
            IsoPath       = ''
            IsoSizeBytes  = [long] 0
            SizeBytes     = [long] 0
        }

        try {
            $manifest = ConvertFrom-Json -InputObject ([string] $FileSystem.ReadAllText([string] $child))

            $artifact = Get-HDTConsoleJsonProperty -InputObject $manifest -Name 'artifacts' -Default $null
            $wim = Get-HDTConsoleJsonProperty -InputObject $artifact -Name 'wim' -Default $null
            $iso = Get-HDTConsoleJsonProperty -InputObject $artifact -Name 'iso' -Default $null

            $entry.BuildId = [string] (Get-HDTConsoleJsonProperty -InputObject $manifest -Name 'buildId')
            $entry.BuiltOn = [string] (Get-HDTConsoleJsonProperty -InputObject $manifest -Name 'builtOn')
            $entry.EngineVersion = [string] (Get-HDTConsoleJsonProperty -InputObject $manifest -Name 'engineVersion')
            $entry.Architecture = [string] (Get-HDTConsoleJsonProperty -InputObject $manifest -Name 'architecture')
            $entry.Language = [string] (Get-HDTConsoleJsonProperty -InputObject $manifest -Name 'language')

            # THE SAME TIMEZONE TRAP Get-HDTConsoleBootImage DOCUMENTS.
            # ConvertFrom-Json coerces an ISO-8601 value to a [datetime] on its
            # own and 5.1 and pwsh 7 disagree about the resulting Kind, so a
            # DateTime is taken as one and only a genuine string is parsed.
            $builtUtcValue = Get-HDTConsoleJsonProperty -InputObject $manifest -Name 'builtUtc' -Default $null

            if ($builtUtcValue -is [datetime]) {
                $entry.BuiltUtc = ([datetime] $builtUtcValue).ToUniversalTime()
            } elseif (-not [string]::IsNullOrWhiteSpace([string] $builtUtcValue)) {
                $entry.BuiltUtc = [datetime]::Parse([string] $builtUtcValue, [cultureinfo]::InvariantCulture,
                    ([System.Globalization.DateTimeStyles]::AssumeUniversal -bor
                        [System.Globalization.DateTimeStyles]::AdjustToUniversal))
            }

            $entry.WimPath = [string] (Get-HDTConsoleJsonProperty -InputObject $wim -Name 'path')
            $entry.WimSizeBytes = [long] (Get-HDTConsoleJsonProperty -InputObject $wim -Name 'sizeBytes' -Default 0)
            $entry.IsoPath = [string] (Get-HDTConsoleJsonProperty -InputObject $iso -Name 'path')
            $entry.IsoSizeBytes = [long] (Get-HDTConsoleJsonProperty -InputObject $iso -Name 'sizeBytes' -Default 0)
            $entry.SizeBytes = $entry.WimSizeBytes + $entry.IsoSizeBytes
        } catch {
            # A MANIFEST THAT IS THERE AND WILL NOT READ IS A FAULT, and a
            # different one from a build that never happened. Collapsing the two
            # would tell somebody to rebuild when the real problem is a truncated
            # file on the share.
            $entry.Status = 'Error'
            $entry.Error = ("the manifest at '{0}' could not be read: {1}" -f $child, $_.Exception.Message)
        }

        [void] $row.Add($entry)
    }

    # NEWEST BUILD FIRST. The interesting row is nearly always the last build,
    # and what is under it is what has been sitting there since.
    #
    # A MANIFEST WITH NO DATE SORTS TO THE TOP, not the bottom. It is the one
    # that would not parse, and it is the row somebody needs to see - under five
    # good builds is where it would be missed. Sort-Object puts $null LAST on a
    # descending sort, so the date is substituted rather than the comparison
    # inverted: MaxValue is 'newer than every real build' and says in one
    # expression what a second sort key would take two to say.
    $ordered = @($row | Sort-Object -Property @{
            Expression = {
                if ($null -eq $_.BuiltUtc) { return [datetime]::MaxValue }
                return $_.BuiltUtc
            }
            Descending = $true
        })

    if ($Orphan) { $ordered = @($ordered | Where-Object { -not $_.Declared }) }

    return [pscustomobject[]] @($ordered)
}