Private/Provisioning.ps1
|
#Requires -Version 5.1 # Provisioning summary (ADR-0043): what the Enrollment Status Page tracked on this device and, read once soon after it, # what Delivery Optimization downloaded. Microsoft documents the tracking under # HKLM\SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking ("Troubleshooting the Enrollment Status Page" and the # EnrollmentStatusTracking CSP): per Win32 app an InstallationState (1 NotInstalled, 2 InProgress, 3 Completed, 4 Error) # and RebootRequired (1 NotRequired, 2 SoftReboot, 3 HardReboot) under Device\Setup\Apps\Tracking\Sidecar\Win32App_{AppID} # or the signed-in user's SID, and ESPTrackingInfo\Diagnostics, whose subkeys are named by the date and time a status was # logged. The exact name format of those subkeys, and where the Win32 apps sit under Diagnostics, are TO-VERIFY: they are # parsed leniently and an entry that does not parse is counted, not guessed. Set-StrictMode -Version Latest $script:DDEspRoot = 'SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking' $script:DDProvisioningMaxApps = 30 $script:DDProvisioningMaxLargest = 10 # Delivery Optimization's job list is read for the summary only this soon after the last status the ESP logged: later, the # jobs listed are this week's, not the provisioning's. How long DO keeps finished jobs is not documented. $script:DDProvisioningDeliveryMaxHours = 168 function Get-DDEspTrackingRaw { <# .SYNOPSIS The EnrollmentStatusTracking key as a flat list of @{ Path; Values }, Path relative to the key ('Device\Setup\...'), Values name -> string. $null when the key does not exist (the device never showed the ESP). Bounded: 10 levels, 3,000 keys, 64 values a key. #> [CmdletBinding()] param() if (-not (Test-DDWindows)) { return $null } $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64) try { $root = $base.OpenSubKey($script:DDEspRoot) if ($null -eq $root) { return $null } $rows = New-Object 'System.Collections.Generic.List[object]' $stack = New-Object 'System.Collections.Generic.Stack[object]' $stack.Push(@($root, '', 0)) while ($stack.Count -gt 0 -and $rows.Count -lt 3000) { $item = $stack.Pop() $key = $item[0] try { $values = [ordered]@{} foreach ($n in @($key.GetValueNames() | Select-Object -First 64)) { $v = $key.GetValue($n) $values[[string]$n] = $(if ($v -is [byte[]]) { '(binary)' } elseif ($v -is [string[]]) { ($v -join ';') } else { [string]$v }) } $rows.Add(@{ Path = [string]$item[1]; Values = $values }) if ($item[2] -lt 10) { foreach ($s in @($key.GetSubKeyNames())) { $sub = $key.OpenSubKey($s) if ($null -ne $sub) { $stack.Push(@($sub, $(if ($item[1]) { "$($item[1])\$s" } else { $s }), ($item[2] + 1))) } } } } finally { $key.Dispose() } } while ($stack.Count -gt 0) { $stack.Pop()[0].Dispose() } , $rows.ToArray() } finally { $base.Dispose() } } function ConvertFrom-DDEspTimestamp { # A date and time in a subkey name (the ESP logs one per status), as UTC; $null when the text holds none. Accepts # 2026/10/18-16:19:18.153, 2026-10-18T16:19:18.153Z and the like (the exact format is TO-VERIFY). [CmdletBinding()] param([string] $Text) $m = [regex]::Match([string]$Text, '(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})[T _-]?(\d{1,2}):(\d{2}):(\d{2})(?:[.,](\d{1,7}))?') if (-not $m.Success) { return $null } try { $ms = 0 if ($m.Groups[7].Success) { $ms = [int](([string]$m.Groups[7].Value + '000').Substring(0, 3)) } New-Object datetime ([int]$m.Groups[1].Value), ([int]$m.Groups[2].Value), ([int]$m.Groups[3].Value), ([int]$m.Groups[4].Value), ([int]$m.Groups[5].Value), ([int]$m.Groups[6].Value), $ms, ([System.DateTimeKind]::Utc) } catch { $null } } function ConvertTo-DDProvisioningSummary { <# .SYNOPSIS The inventory's `provisioning` section from the ESP tracking tree (Get-DDEspTrackingRaw) and, when it is read soon enough after provisioning, the DO job list (ConvertTo-DDStatusRow rows). $null without a tree. Pure. #> [CmdletBinding()] param( [AllowNull()] $Tree, [AllowNull()][AllowEmptyCollection()] $Status, [double] $MinFileSizeMB = 50, [AllowNull()] $OsInstallDate, [Parameter(Mandatory)][datetime] $Now, # DOMaxCacheAge in effect (default 259,200 s) and this device's offset from UTC in hours, for the delivery window # (ConvertTo-DDProvisioningDelivery). [double] $MaxCacheAgeSeconds = 259200, [double] $ZoneOffsetHours = 0 ) if ($null -eq $Tree) { return $null } $rows = @($Tree) $stateName = @{ '1' = 'notInstalled'; '2' = 'inProgress'; '3' = 'completed'; '4' = 'error' } $rebootName = @{ '1' = 'notRequired'; '2' = 'soft'; '3' = 'hard' } $appRe = 'Win32App_([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})' # The apps: every Win32App_{id} key with an InstallationState, under Device\ (device setup) or a user's SID (account setup). $apps = [ordered]@{} foreach ($r in $rows) { $p = [string]$r.Path if ($p -like 'ESPTrackingInfo*') { continue } $leaf = ($p -split '\\')[-1] $m = [regex]::Match($leaf, "^$appRe(?:_\d+)?$") if (-not $m.Success -or -not $r.Values.Contains('InstallationState')) { continue } $phase = $(if ($p -like 'Device\*') { 'device' } elseif ($p -match '^S-1-') { 'user' } else { $null }) if ($null -eq $phase) { continue } $id = $m.Groups[1].Value.ToLowerInvariant() $st = $stateName[[string]$r.Values['InstallationState']] $rb = $(if ($r.Values.Contains('RebootRequired')) { $rebootName[[string]$r.Values['RebootRequired']] } else { $null }) $apps["$phase|$id"] = [ordered]@{ id = $id; phase = $phase; state = $(if ($st) { $st } else { 'unknown' }); reboot = $rb; firstStatusAt = $null; lastStatusAt = $null } } # The timeline: each Diagnostics subkey named by a date and time. A Win32 app named in its path or values is dated too. $first = $null; $last = $null; $unparsed = 0 foreach ($r in $rows) { $p = [string]$r.Path if ($p -notlike 'ESPTrackingInfo\Diagnostics\*') { continue } $segments = @($p -split '\\' | Select-Object -Skip 2) $at = $null foreach ($s in $segments) { $t = ConvertFrom-DDEspTimestamp -Text $s; if ($null -ne $t) { $at = $t } } if ($null -eq $at) { if ($r.Values.Count -gt 0 -and $segments.Count -ge 2) { $unparsed++ } continue } if ($null -eq $first -or $at -lt $first) { $first = $at } if ($null -eq $last -or $at -gt $last) { $last = $at } $text = $p + ' ' + (@($r.Values.Keys) -join ' ') $phase = $(if ($p -match '\\S-1-') { 'user' } else { 'device' }) foreach ($am in [regex]::Matches($text, $appRe)) { $k = "$phase|$($am.Groups[1].Value.ToLowerInvariant())" if (-not $apps.Contains($k)) { continue } $a = $apps[$k] if ($null -eq $a['firstStatusAt'] -or $at -lt $a['firstStatusAt']) { $a['firstStatusAt'] = $at } if ($null -eq $a['lastStatusAt'] -or $at -gt $a['lastStatusAt']) { $a['lastStatusAt'] = $at } } } $counts = { param($phase) $mine = @($apps.Values | Where-Object { $_['phase'] -eq $phase }) [ordered]@{ total = $mine.Count completed = @($mine | Where-Object { $_['state'] -eq 'completed' }).Count error = @($mine | Where-Object { $_['state'] -eq 'error' }).Count inProgress = @($mine | Where-Object { $_['state'] -eq 'inProgress' }).Count notInstalled = @($mine | Where-Object { $_['state'] -eq 'notInstalled' }).Count } } # An app's time needs two different dated statuses. Until the Diagnostics layout is verified (ADR-0043), one dated status # gives no duration: it keeps when it was seen (firstStatusAt) and leaves lastStatusAt null rather than send a span of 0. foreach ($a in $apps.Values) { if ($null -ne $a['firstStatusAt'] -and $a['firstStatusAt'] -eq $a['lastStatusAt']) { $a['lastStatusAt'] = $null } } # Longest first (unknown durations last), then by id, so the 30 kept are the ones worth looking at and the order is stable. $ordered = @($apps.Values | Sort-Object -Property @{ Expression = { if ($_['firstStatusAt'] -and $_['lastStatusAt']) { ($_['lastStatusAt'] - $_['firstStatusAt']).TotalSeconds } else { -1 } }; Descending = $true }, @{ Expression = { $_['phase'] } }, @{ Expression = { $_['id'] } }) $appRows = @($ordered | Select-Object -First $script:DDProvisioningMaxApps | ForEach-Object { [ordered]@{ id = $_['id']; phase = $_['phase']; state = $_['state']; reboot = $_['reboot'] firstStatusAt = $(if ($_['firstStatusAt']) { Format-DDIso -Value $_['firstStatusAt'] } else { $null }) lastStatusAt = $(if ($_['lastStatusAt']) { Format-DDIso -Value $_['lastStatusAt'] } else { $null }) } }) [ordered]@{ esp = [ordered]@{ firstStatusAt = $(if ($first) { Format-DDIso -Value $first } else { $null }) lastStatusAt = $(if ($last) { Format-DDIso -Value $last } else { $null }) statusesUnparsed = $unparsed deviceSetup = (& $counts 'device') accountSetup = (& $counts 'user') } appsTotal = $apps.Count apps = $appRows delivery = (ConvertTo-DDProvisioningDelivery -Status $Status -MinFileSizeMB $MinFileSizeMB -EspLast $last -OsInstallDate $OsInstallDate -Now $Now ` -EspFirst $first -MaxCacheAgeSeconds $MaxCacheAgeSeconds -ZoneOffsetHours $ZoneOffsetHours) } } function ConvertTo-DDProvisioningDelivery { <# .SYNOPSIS What DO's job list held, summed by priority, when it is read within DDProvisioningDeliveryMaxHours of provisioning (the ESP's last status, or the OS install date when no status was dated). $null otherwise, or without a job list. .DESCRIPTION With a dated ESP (-EspFirst and -EspLast), only the jobs of the ESP's own time are summed, so a week of ordinary downloads read later is not counted as provisioning. A job is dated by its ExpireOn less DOMaxCacheAge (Learn: DOMaxCacheAge is how long a file is held "after downloading successfully"; DoctorDO's inference, to-verify), and kept when that falls between the ESP's first status and an hour after its last. Because the zone of the Diagnostics subkey names is to-verify (ADR-0043), the window is widened by the device's offset from UTC on both sides. A job that cannot be dated is left out; the jobs left out are counted (jobsOutsideEsp). When none is left, the result is $null (nothing attributable yet), never zero bytes. Without a cache age to subtract (0, unlimited) or a dated ESP, every job is summed, as before. #> [CmdletBinding()] param([AllowNull()][AllowEmptyCollection()] $Status, [double] $MinFileSizeMB, [AllowNull()] $EspLast, [AllowNull()] $OsInstallDate, [Parameter(Mandatory)][datetime] $Now, [AllowNull()] $EspFirst = $null, [double] $MaxCacheAgeSeconds = 259200, [double] $ZoneOffsetHours = 0) if ($null -eq $Status) { return $null } $anchor = $null; $anchorAt = $null if ($EspLast -is [datetime]) { $anchor = 'esp'; $anchorAt = $EspLast } elseif ($OsInstallDate) { $d = [datetime]::MinValue if ([datetime]::TryParse([string]$OsInstallDate, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$d)) { $anchor = 'os-install'; $anchorAt = $d } } if ($null -eq $anchorAt) { return $null } $slack = [math]::Abs($ZoneOffsetHours) $ageHours = [math]::Round(($Now.ToUniversalTime() - $anchorAt).TotalHours, 1) # A last status up to the zone offset in the future is the unverified zone, not a clock fault: read as just now. if ($ageHours -lt 0 -and $anchor -eq 'esp' -and -$ageHours -le $slack) { $ageHours = 0 } if ($ageHours -lt 0 -or $ageHours -gt $script:DDProvisioningDeliveryMaxHours) { return $null } $outside = $null $all = @($Status | Where-Object { $null -ne $_ }) if ($anchor -eq 'esp' -and $EspFirst -is [datetime] -and $MaxCacheAgeSeconds -gt 0) { $from = $EspFirst.AddHours(-$slack); $to = $EspLast.AddHours($slack + 1) $inEsp = @($all | Where-Object { $e = [datetime]::MinValue $ok = $_['expireOn'] -and [datetime]::TryParse([string]$_['expireOn'], [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$e) if ($ok) { $at = $e.AddSeconds(-$MaxCacheAgeSeconds); $ok = ($at -ge $from -and $at -le $to) } $ok }) $outside = $all.Count - $inEsp.Count if ($inEsp.Count -eq 0) { return $null } $all = $inEsp } $big = { param($v) $n = [System.Numerics.BigInteger]::Zero; [void][System.Numerics.BigInteger]::TryParse([string]$v, [ref]$n); $n } $mix = { [ordered]@{ jobs = 0; http = [System.Numerics.BigInteger]::Zero; cache = [System.Numerics.BigInteger]::Zero; lan = [System.Numerics.BigInteger]::Zero; group = [System.Numerics.BigInteger]::Zero; internet = [System.Numerics.BigInteger]::Zero; downloadMs = [int64]0 } } $fg = & $mix; $bg = & $mix $minBytes = [System.Numerics.BigInteger]([math]::Floor($MinFileSizeMB * 1MB)) $below = 0; $belowBytes = [System.Numerics.BigInteger]::Zero $rows = $all foreach ($r in $rows) { $t = $(if ([string]$r['priority'] -eq 'foreground') { $fg } else { $bg }) $t['jobs']++ $t['http'] += (& $big $r['bytesFromHttp']); $t['cache'] += (& $big $r['bytesFromCacheServer']) $t['lan'] += (& $big $r['bytesFromLanPeers']); $t['group'] += (& $big $r['bytesFromGroupPeers']); $t['internet'] += (& $big $r['bytesFromInternetPeers']) if ($null -ne $r['downloadMs']) { $t['downloadMs'] += [int64]$r['downloadMs'] } $size = & $big $r['fileSize'] if ($size -lt $minBytes) { $below++; $belowBytes += $size } } $out = { param($t) [ordered]@{ jobs = $t['jobs']; bytes = [ordered]@{ http = $t['http'].ToString(); cache = $t['cache'].ToString(); lan = $t['lan'].ToString(); group = $t['group'].ToString(); internet = $t['internet'].ToString() }; downloadMs = $t['downloadMs'] } } $largest = @($rows | Sort-Object -Property @{ Expression = { & $big $_['fileSize'] }; Descending = $true }, @{ Expression = { [string]$_['fileId'] } } | Select-Object -First $script:DDProvisioningMaxLargest | ForEach-Object { $caller = $(if ($_['predefinedCallerApplication']) { ([string]$_['predefinedCallerApplication']) } else { $null }) if ($caller -and $caller.Length -gt 64) { $caller = $caller.Substring(0, 64) } [ordered]@{ sizeBytes = (& $big $_['fileSize']).ToString(); priority = $(if ([string]$_['priority'] -eq 'foreground') { 'foreground' } else { 'background' }) http = (& $big $_['bytesFromHttp']).ToString(); cache = (& $big $_['bytesFromCacheServer']).ToString() peers = ((& $big $_['bytesFromLanPeers']) + (& $big $_['bytesFromGroupPeers']) + (& $big $_['bytesFromInternetPeers'])).ToString() downloadMs = $(if ($null -ne $_['downloadMs']) { [int64]$_['downloadMs'] } else { $null }); caller = $caller } }) $d = [ordered]@{ capturedAt = (Format-DDIso -Value $Now); anchor = $anchor; ageHours = $ageHours; jobs = $rows.Count foreground = (& $out $fg); background = (& $out $bg) belowMinFileSize = [ordered]@{ jobs = $below; bytes = $belowBytes.ToString(); minFileSizeMB = $MinFileSizeMB } largest = $largest } if ($null -ne $outside) { $d['jobsOutsideEsp'] = $outside } $d } function Resolve-DDProvisioning { # The section to send: the tracking (apps, timeline) as read now, which is final once the ESP has finished, and the # delivery snapshot as first captured, kept in the state file so later runs do not replace provisioning's downloads with # this week's. $null when the device has no ESP tracking. [CmdletBinding()] param([AllowNull()] $Fresh, [AllowNull()] $Stored) if ($null -eq $Fresh) { return $null } $out = [ordered]@{} foreach ($k in $Fresh.Keys) { $out[$k] = $Fresh[$k] } if ($Stored -is [System.Collections.IDictionary] -and $null -ne $Stored['delivery']) { $out['delivery'] = $Stored['delivery'] } $out } |