From 3d31a390990b7f467c62201cd819aafa7d4329cf Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 1 Jun 2026 20:21:58 +0000 Subject: [PATCH] kms-website: verify ODT install completion + capture ODT log; reboot-aware after uninstall Invoke-Odt returned $true unconditionally after setup.exe, so a failed or not-yet-finished Click-to-Run install surfaced only as a bare "ospp.vbs not found after install". Root-cause fixes: - in the config XML -> a capture dir, read back on failure so the real setup.exe exit code / error is reported (and sent as telemetry). - setup.exe run with -PassThru; non-zero (not 0/3010) exit -> fail + log tail. - Wait-OfficeInstalled polls on-disk state (ospp.vbs + ProductReleaseIds) instead of trusting setup.exe's early return under Display Level=None. - After removing incompatible consumer Office (e.g. O365HomePremRetail), a pending reboot now stops the run with reboot+re-run guidance rather than half-completing the VL install in the same session (idempotent on re-run). --- static/scripts/kms-bootstrap.ps1 | 76 ++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/static/scripts/kms-bootstrap.ps1 b/static/scripts/kms-bootstrap.ps1 index a276669..3897904 100644 --- a/static/scripts/kms-bootstrap.ps1 +++ b/static/scripts/kms-bootstrap.ps1 @@ -253,8 +253,47 @@ function Get-LatestOfficeProduct([string]$label) { # we serve a known-good copy from our own /scripts). $env:KMS_ODT_URL overrides. # Invoke-Odt runs ONE setup.exe /configure with the given XML and # is reused for both install () and uninstall (). -$script:ODT_URL = $(if ($env:KMS_ODT_URL) { $env:KMS_ODT_URL } else { 'https://kms.viktorbarzin.me/scripts/odt-setup.exe' }) +$script:ODT_URL = $(if ($env:KMS_ODT_URL) { $env:KMS_ODT_URL } else { 'https://kms.viktorbarzin.me/scripts/odt-setup.exe' }) +# ODT writes its log here (the config XMLs point at it). Read back on +# failure so a non-zero / incomplete install reports the real error code instead +# of a bare "ospp.vbs not found". Cleared at the start of every Invoke-Odt run. +$script:OdtLogDir = Join-Path $env:TEMP 'kms-odt-logs' + +# Tail of the newest ODT log - the setup.exe error code lives here on failure. +function Get-OdtLogTail([int]$lines = 20) { + $log = Get-ChildItem -Path $script:OdtLogDir -Filter *.log -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if (-not $log) { return '' } + return ((Get-Content -LiteralPath $log.FullName -Tail $lines -ErrorAction SilentlyContinue) -join ' | ') +} + +# "A reboot is pending" probe. C2R half-completes an install when a prior removal +# left the servicing stack pending - the usual cause of an install run right after +# uninstalling the bundled consumer Office. +function Test-PendingReboot { + if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') { return $true } + if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') { return $true } + $sm = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue + return [bool]$sm.PendingFileRenameOperations +} + +# Poll until the C2R install actually lands. setup.exe /configure can return before +# the Click-to-Run service finishes the multi-GB install, so trust on-disk state +# (ospp.vbs + the product in ProductReleaseIds), not setup.exe's return. Heartbeat +# so a long download does not look hung. +function Wait-OfficeInstalled([string]$product, [int]$timeoutSec = 1800) { + $sw = [Diagnostics.Stopwatch]::StartNew(); $next = 0 + while ($sw.Elapsed.TotalSeconds -lt $timeoutSec) { + if ((Find-Ospp) -and ((Get-AllOfficeC2R) -contains $product)) { return $true } + if ($sw.Elapsed.TotalSeconds -ge $next) { Write-Host " still installing $product... ($([int]$sw.Elapsed.TotalSeconds)s)"; $next += 20 } + Start-Sleep -Seconds 5 + } + return $false +} + function Invoke-Odt([string]$configXml, [string]$stepMsg) { + Remove-Item -Path (Join-Path $script:OdtLogDir '*') -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $script:OdtLogDir | Out-Null $tmp = Join-Path $env:TEMP "kms-odt-$(Get-Random)" New-Item -ItemType Directory -Force -Path $tmp | Out-Null try { @@ -268,7 +307,18 @@ function Invoke-Odt([string]$configXml, [string]$stepMsg) { $cfg = Join-Path $tmp 'config.xml' $configXml | Set-Content -Path $cfg -Encoding UTF8 Step $stepMsg - Start-Process -FilePath $setup -ArgumentList '/configure', "`"$cfg`"" -Wait + $p = Start-Process -FilePath $setup -ArgumentList '/configure', "`"$cfg`"" -Wait -PassThru + $code = $p.ExitCode + # 0 = success, 3010 = success/reboot-required. Anything else is a real ODT + # failure - surface the log tail (carries the error code) so we can diagnose. + if ($code -ne 0 -and $code -ne 3010) { + $tail = Get-OdtLogTail + Bad "Office Deployment Tool failed (setup.exe exit $code)." + if ($tail) { Write-Host " ODT log: $tail" } + Send-Diag 'odt' 'fail' "exit=$code; $tail" + return $false + } + if ($code -eq 3010) { Warn "ODT reports a reboot is required to finish." } return $true } finally { Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue } @@ -283,9 +333,18 @@ function Reinstall-OfficeVL([string]$product, [string]$channel) { + "@ - return (Invoke-Odt $xml "Installing $product (multi-GB download + reinstall; several minutes)") + if (-not (Invoke-Odt $xml "Installing $product (multi-GB download + reinstall; several minutes)")) { return $false } + # setup.exe returning is NOT proof Office is on disk - verify it actually landed. + if (Wait-OfficeInstalled $product) { OK "$product installed"; return $true } + $reboot = Test-PendingReboot + $hint = if ($reboot) { 'A reboot is pending - reboot, then re-run the one-liner (it skips straight to the install).' } else { 'Re-run the one-liner; if it still fails, reboot first to clear the servicing stack.' } + Bad "$product install did not complete (ospp.vbs / $product not registered). $hint" + $tail = Get-OdtLogTail; if ($tail) { Write-Host " ODT log: $tail" } + Send-Diag 'odt' 'install-incomplete' "product=$product; reboot=$reboot; $tail" + return $false } # Uninstall specific Click-to-Run products (the incompatible retail/M365 Office @@ -300,6 +359,7 @@ $items + "@ return (Invoke-Odt $xml "Uninstalling incompatible Office: $($products -join ', ')") @@ -332,6 +392,16 @@ Consequences:$rm if ($incompat) { if (-not (Uninstall-OfficeC2R $incompat)) { Bad "Uninstall of incompatible Office failed; aborting before VL install."; Send-Diag $label.ToLower() 'uninstall-fail' ($incompat -join ','); return $null } Send-Diag $label.ToLower() 'uninstalled-incompatible' ($incompat -join ',') + # Removing the bundled consumer Office usually leaves the servicing stack + # pending a reboot; a VL install in the SAME session then half-completes with + # no ospp.vbs. Stop here and have the user reboot + re-run - the script is + # idempotent and (no incompatible Office left) goes straight to the install. + if (Test-PendingReboot) { + Warn "Incompatible Office removed, but a reboot is required before the Volume License install." + Write-Host " Please REBOOT, then re-run the one-liner - it will skip the uninstall and install $target directly." + Send-Diag $label.ToLower() 'reboot-required-after-uninstall' $target + return $null + } } if (-not (Reinstall-OfficeVL $target $channel)) { return $null } return $target