Runtime/Invoke-OSDAppRunner.ps1
|
[CmdletBinding()] param( [Parameter(Mandatory)] [string]$StagedPath, [string[]]$Name ) $ErrorActionPreference = 'Stop' $manifestPath = Join-Path $StagedPath 'DeviceManifest.json' $workRoot = Join-Path $StagedPath 'Work' function Initialize-RunnerLog { param( [Parameter(Mandatory)] [string]$ConfiguredPath ) $resolvedPath = [Environment]::ExpandEnvironmentVariables($ConfiguredPath) $logDirectory = Split-Path -Path $resolvedPath -Parent New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null $resolvedPath } function Write-RunnerLog { param( [Parameter(Mandatory)][string]$LogPath, [Parameter(Mandatory)][string]$Event, [ValidateSet('Info','Warning','Error')][string]$Level = 'Info', [string]$Message, [hashtable]$Data, [int]$MaxSizeMB = 1, [int]$RetainFiles = 3 ) if (Test-Path -LiteralPath $LogPath -PathType Leaf) { $maxBytes = $MaxSizeMB * 1MB if ((Get-Item -LiteralPath $LogPath).Length -ge $maxBytes) { for ($i = $RetainFiles - 1; $i -ge 1; $i--) { $source = "$LogPath.$i" $target = "$LogPath.$($i + 1)" if (Test-Path -LiteralPath $source) { Move-Item -LiteralPath $source -Destination $target -Force } } Move-Item -LiteralPath $LogPath -Destination "$LogPath.1" -Force $oldest = "$LogPath.$($RetainFiles + 1)" if (Test-Path -LiteralPath $oldest) { Remove-Item -LiteralPath $oldest -Force } } } $type = switch ($Level) { 'Warning' { 2 } 'Error' { 3 } default { 1 } } $details = @() if ($Data) { foreach ($key in @($Data.Keys | Sort-Object)) { $value = $Data[$key] if ($value -is [System.Collections.IEnumerable] -and $value -isnot [string]) { $value = @($value) -join ',' } $details += ('{0}={1}' -f $key, $value) } } $logMessage = if ($Message) { "[$Event] $Message" } else { "[$Event]" } if ($details.Count -gt 0) { $logMessage += ' | ' + ($details -join '; ') } $logMessage = $logMessage -replace '\]LOG\]!>', ']LOG]! >' $now = (Get-Date).ToUniversalTime() $time = $now.ToString('HH:mm:ss.fff') + '+000' $date = $now.ToString('MM-dd-yyyy') $thread = [System.Threading.Thread]::CurrentThread.ManagedThreadId $line = '<![LOG[{0}]LOG]!><time="{1}" date="{2}" component="Install" context="" type="{3}" thread="{4}" file="">' -f $logMessage, $time, $date, $type, $thread Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 } function Start-RunnerCleanup { param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$LogPath ) $cleanupScript = Join-Path $env:TEMP ("OSDApps-Cleanup-{0}.cmd" -f ([guid]::NewGuid().ToString('N'))) $cleanupContent = @( '@echo off' 'timeout /t 2 /nobreak >nul' ('rd /s /q "{0}"' -f $Path) 'del /f /q "%~f0"' ) Set-Content -LiteralPath $cleanupScript -Value $cleanupContent -Encoding ASCII Write-RunnerLog -LogPath $LogPath -Event 'CleanupScheduled' -Message 'Runtime source cleanup was scheduled.' -Data @{ Path = $Path; CleanupScript = $cleanupScript } Start-Process -FilePath $env:ComSpec -ArgumentList @('/d','/c',('"{0}"' -f $cleanupScript)) -WindowStyle Hidden | Out-Null } $logPath = $null $runnerStopwatch = [System.Diagnostics.Stopwatch]::StartNew() try { if (-not (Test-Path -LiteralPath $manifestPath)) { throw "Device manifest not found: $manifestPath" } $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json if (-not $manifest.Runtime) { throw 'Device manifest Runtime configuration is missing.' } if (-not $manifest.Runtime.CleanupMode) { throw 'Device manifest Runtime.CleanupMode is missing.' } if (-not $manifest.Runtime.LogPath) { throw 'Device manifest Runtime.LogPath is missing.' } $cleanupMode = [string]$manifest.Runtime.CleanupMode if ($cleanupMode -notin @('OnSuccess','Never')) { throw "Unsupported CleanupMode '$cleanupMode'." } $logPath = Initialize-RunnerLog -ConfiguredPath ([string]$manifest.Runtime.LogPath) Write-RunnerLog -LogPath $logPath -Event 'InstallStart' -Message 'OSD App Runner started.' -Data @{ StagedPath = $StagedPath } $apps = if ($manifest.PSObject.Properties.Name -contains 'Apps') { @($manifest.Apps) } else { @() } if ($Name) { $apps = @($apps | Where-Object { $_.Id -in $Name }) } New-Item -ItemType Directory -Path $workRoot -Force | Out-Null function Invoke-RepositoryPackage { param([Parameter(Mandatory)]$package) $packageRoot = Join-Path $StagedPath (Join-Path 'Packages' $package.Id) $archivePath = Join-Path $packageRoot 'Package.zip' if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { throw "Package.zip not found for '$($package.Id)': $archivePath" } if ($package.Archive -and $package.Archive.Sha256) { $actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash if ($actualHash -ne $package.Archive.Sha256) { Write-RunnerLog -LogPath $logPath -Event 'PackageIntegrityFailed' -Level 'Error' -Message 'Staged package SHA-256 validation failed.' -Data @{ Id = $package.Id; ExpectedSha256 = $package.Archive.Sha256; ActualSha256 = $actualHash; Archive = $archivePath } throw "SHA-256 validation failed for staged package '$($package.Id)'." } Write-RunnerLog -LogPath $logPath -Event 'PackageIntegrityValidated' -Message 'Staged package SHA-256 validation succeeded.' -Data @{ Id = $package.Id; Sha256 = $actualHash; Archive = $archivePath } } $packageWork = Join-Path $workRoot $package.Id if (Test-Path -LiteralPath $packageWork) { Remove-Item -LiteralPath $packageWork -Recurse -Force } New-Item -ItemType Directory -Path $packageWork -Force | Out-Null Write-RunnerLog -LogPath $logPath -Event 'PackageExtractStart' -Message 'Extracting package archive.' -Data @{ Id = $package.Id; Version = $package.Version; Archive = $archivePath } Expand-Archive -LiteralPath $archivePath -DestinationPath $packageWork -Force $payloadRoot = Join-Path $packageWork 'Package' $installScript = Join-Path $payloadRoot 'Install.ps1' if (-not (Test-Path -LiteralPath $installScript -PathType Leaf)) { throw "Package '$($package.Id)' does not contain Package\Install.ps1." } Write-RunnerLog -LogPath $logPath -Event 'PackageInstallStart' -Message 'Starting package installation.' -Data @{ Id = $package.Id; Version = $package.Version } $process = Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$installScript) -WorkingDirectory $payloadRoot -Wait -PassThru $successCodes = @(0,3010) if ($package.SuccessCodes) { $successCodes = @($package.SuccessCodes | ForEach-Object { [int]$_ }) } if ($process.ExitCode -notin $successCodes) { throw "Installation of '$($package.Id)' failed with exit code $($process.ExitCode)." } Write-RunnerLog -LogPath $logPath -Event 'PackageInstallComplete' -Message 'Package installation completed.' -Data @{ Id = $package.Id; Version = $package.Version; ExitCode = $process.ExitCode } } function Invoke-BuiltInApp { param([Parameter(Mandatory)]$app) switch ($app.Type) { 'OfficeDeploymentTool' { $setupPath = Join-Path $StagedPath $app.Setup $configurationPath = Join-Path $StagedPath $app.Configuration $workingDirectory = Split-Path $setupPath -Parent if (-not (Test-Path -LiteralPath $setupPath -PathType Leaf)) { throw "Office setup executable not found: $setupPath" } if (-not (Test-Path -LiteralPath $configurationPath -PathType Leaf)) { throw "Office configuration XML not found: $configurationPath" } $officeDataPath = Join-Path $workingDirectory 'Office\Data' $offlineOffice = Test-Path -LiteralPath $officeDataPath -PathType Container Write-RunnerLog -LogPath $logPath -Event 'BuiltInInstallStart' -Message 'Starting built-in Microsoft 365 Apps installation.' -Data @{ Id = $app.Id; Setup = $setupPath; Configuration = $configurationPath; Offline = $offlineOffice } $process = Start-Process -FilePath $setupPath -ArgumentList @('/configure', $configurationPath) -WorkingDirectory $workingDirectory -Wait -PassThru if ($process.ExitCode -notin @(0,3010)) { throw "Installation of '$($app.Id)' failed with exit code $($process.ExitCode)." } Write-RunnerLog -LogPath $logPath -Event 'BuiltInInstallComplete' -Message 'Built-in Microsoft 365 Apps installation completed.' -Data @{ Id = $app.Id; ExitCode = $process.ExitCode } } 'MicrosoftTeamsBootstrapper' { $setupPath = Join-Path $StagedPath $app.Setup $workingDirectory = Split-Path $setupPath -Parent if (-not (Test-Path -LiteralPath $setupPath -PathType Leaf)) { throw "Teams bootstrapper not found: $setupPath" } $arguments = @('-p') if ($app.OfflinePackage) { $offlinePackagePath = Join-Path $StagedPath $app.OfflinePackage if (-not (Test-Path -LiteralPath $offlinePackagePath -PathType Leaf)) { throw "Teams offline MSIX not found: $offlinePackagePath" } $arguments += '-o' $arguments += $offlinePackagePath } if ($app.InstallMeetingAddin -eq $true) { $arguments += '--installTMA' } Write-RunnerLog -LogPath $logPath -Event 'BuiltInInstallStart' -Message 'Starting built-in Microsoft Teams provisioning.' -Data @{ Id = $app.Id; Setup = $setupPath; Arguments = ($arguments -join ' '); Offline = [bool]$app.OfflinePackage } $process = Start-Process -FilePath $setupPath -ArgumentList $arguments -WorkingDirectory $workingDirectory -Wait -PassThru if ($process.ExitCode -ne 0) { throw "Installation of '$($app.Id)' failed with exit code $($process.ExitCode)." } Write-RunnerLog -LogPath $logPath -Event 'BuiltInInstallComplete' -Message 'Built-in Microsoft Teams provisioning completed.' -Data @{ Id = $app.Id; ExitCode = $process.ExitCode } } 'AdobeAcrobatUnifiedZip' { $packagePath = Join-Path $StagedPath $app.Package if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) { throw "Adobe Acrobat Unified package not found: $packagePath" } $adobeWork = Join-Path $workRoot 'AdobeAcrobatUnified' if (Test-Path -LiteralPath $adobeWork) { Remove-Item -LiteralPath $adobeWork -Recurse -Force } New-Item -ItemType Directory -Path $adobeWork -Force | Out-Null Write-RunnerLog -LogPath $logPath -Event 'BuiltInExtractStart' -Message 'Extracting Adobe Acrobat Unified package.' -Data @{ Id=$app.Id; Archive=$packagePath; Destination=$adobeWork } Expand-Archive -LiteralPath $packagePath -DestinationPath $adobeWork -Force $setupPath = Get-ChildItem -LiteralPath $adobeWork -Filter 'Setup.exe' -File -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName if (-not $setupPath) { throw "Adobe Acrobat Unified Setup.exe was not found after extracting '$packagePath'." } $arguments = if ($app.InstallArguments) { [string]$app.InstallArguments } else { '/sAll /msi ADDLOCAL=ALL' } $workingDirectory = Split-Path $setupPath -Parent $timeoutMinutes = if ($app.InstallTimeoutMinutes) { [int]$app.InstallTimeoutMinutes } else { 15 } Write-RunnerLog -LogPath $logPath -Event 'BuiltInInstallStart' -Message 'Starting built-in Adobe Acrobat Unified installation.' -Data @{ Id=$app.Id; Setup=$setupPath; Arguments=$arguments; Offline=$true; TimeoutMinutes=$timeoutMinutes } $process = Start-Process -FilePath $setupPath -ArgumentList $arguments -WorkingDirectory $workingDirectory -PassThru $installStopwatch = [System.Diagnostics.Stopwatch]::StartNew() $nextHeartbeat = [TimeSpan]::FromMinutes(1) while (-not $process.HasExited) { if ($installStopwatch.Elapsed.TotalMinutes -ge $timeoutMinutes) { Write-RunnerLog -LogPath $logPath -Event 'BuiltInInstallTimeout' -Level 'Error' -Message 'Adobe Acrobat Unified installation exceeded its timeout.' -Data @{ Id=$app.Id; ProcessId=$process.Id; ElapsedSeconds=[math]::Round($installStopwatch.Elapsed.TotalSeconds); TimeoutMinutes=$timeoutMinutes } try { Start-Process -FilePath 'taskkill.exe' -ArgumentList @('/PID',[string]$process.Id,'/T','/F') -WindowStyle Hidden -Wait | Out-Null } catch { } throw "Installation of '$($app.Id)' exceeded the $timeoutMinutes minute timeout." } if ($installStopwatch.Elapsed -ge $nextHeartbeat) { Write-RunnerLog -LogPath $logPath -Event 'BuiltInInstallWaiting' -Message 'Adobe Acrobat Unified installation is still running.' -Data @{ Id=$app.Id; ProcessId=$process.Id; ElapsedSeconds=[math]::Round($installStopwatch.Elapsed.TotalSeconds) } $nextHeartbeat = $nextHeartbeat.Add([TimeSpan]::FromMinutes(1)) } Start-Sleep -Seconds 2 $process.Refresh() } $installStopwatch.Stop() if ($process.ExitCode -notin @(0,3010)) { throw "Installation of '$($app.Id)' failed with exit code $($process.ExitCode)." } Write-RunnerLog -LogPath $logPath -Event 'BuiltInInstallComplete' -Message 'Built-in Adobe Acrobat Unified installation completed.' -Data @{ Id=$app.Id; ExitCode=$process.ExitCode; DurationSeconds=[math]::Round($installStopwatch.Elapsed.TotalSeconds) } } 'VendorMsi' { $packagePath = Join-Path $StagedPath $app.Package if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) { throw "MSI package not found for '$($app.Id)': $packagePath" } $displayName = if ($app.DisplayName) { [string]$app.DisplayName } else { [string]$app.Id } $timeoutMinutes = if ($app.InstallTimeoutMinutes) { [int]$app.InstallTimeoutMinutes } else { 10 } $arguments = @('/i', ('"{0}"' -f $packagePath), '/qn', '/norestart') if ($app.PSObject.Properties.Name -contains 'MsiProperties' -and $app.MsiProperties) { $arguments += @($app.MsiProperties) } Write-RunnerLog -LogPath $logPath -Event 'BuiltInInstallStart' -Message "Starting built-in $displayName MSI installation." -Data @{ Id=$app.Id; Package=$packagePath; Arguments=($arguments -join ' '); Offline=$true; TimeoutMinutes=$timeoutMinutes } $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $arguments -PassThru $installStopwatch = [System.Diagnostics.Stopwatch]::StartNew() $nextHeartbeat = [TimeSpan]::FromMinutes(1) while (-not $process.HasExited) { if ($installStopwatch.Elapsed.TotalMinutes -ge $timeoutMinutes) { Write-RunnerLog -LogPath $logPath -Event 'BuiltInInstallTimeout' -Level 'Error' -Message "$displayName installation exceeded its timeout." -Data @{ Id=$app.Id; ProcessId=$process.Id; ElapsedSeconds=[math]::Round($installStopwatch.Elapsed.TotalSeconds); TimeoutMinutes=$timeoutMinutes } try { Start-Process -FilePath 'taskkill.exe' -ArgumentList @('/PID',[string]$process.Id,'/T','/F') -WindowStyle Hidden -Wait | Out-Null } catch { } throw "Installation of '$($app.Id)' exceeded the $timeoutMinutes minute timeout." } if ($installStopwatch.Elapsed -ge $nextHeartbeat) { Write-RunnerLog -LogPath $logPath -Event 'BuiltInInstallWaiting' -Message "$displayName installation is still running." -Data @{ Id=$app.Id; ProcessId=$process.Id; ElapsedSeconds=[math]::Round($installStopwatch.Elapsed.TotalSeconds) } $nextHeartbeat = $nextHeartbeat.Add([TimeSpan]::FromMinutes(1)) } Start-Sleep -Seconds 2 $process.Refresh() } $installStopwatch.Stop() if ($process.ExitCode -notin @(0,3010)) { throw "Installation of '$($app.Id)' failed with exit code $($process.ExitCode)." } Write-RunnerLog -LogPath $logPath -Event 'BuiltInInstallComplete' -Message "Built-in $displayName installation completed." -Data @{ Id=$app.Id; ExitCode=$process.ExitCode; DurationSeconds=[math]::Round($installStopwatch.Elapsed.TotalSeconds) } } default { throw "Unsupported built-in application type '$($app.Type)' for '$($app.Id)'." } } } Write-RunnerLog -LogPath $logPath -Event 'InstallOrder' -Message 'Resolved application installation order.' -Data @{ Order = @($apps | ForEach-Object { "$($_.Source):$($_.Id)" }) } foreach ($app in $apps) { $applicationStopwatch = [System.Diagnostics.Stopwatch]::StartNew() try { switch ([string]$app.Source) { 'Repository' { Invoke-RepositoryPackage -package $app } 'BuiltIn' { Invoke-BuiltInApp -app $app } default { throw "Unsupported application source '$($app.Source)' for '$($app.Id)'." } } $applicationStopwatch.Stop() Write-RunnerLog -LogPath $logPath -Event 'ApplicationInstallComplete' -Message 'Application processing completed.' -Data @{ Id = $app.Id Source = $app.Source DurationSeconds = [math]::Round($applicationStopwatch.Elapsed.TotalSeconds, 1) } } catch { if ($applicationStopwatch.IsRunning) { $applicationStopwatch.Stop() } Write-RunnerLog -LogPath $logPath -Event 'ApplicationInstallFailed' -Level 'Error' -Message $_.Exception.Message -Data @{ Id = $app.Id Source = $app.Source DurationSeconds = [math]::Round($applicationStopwatch.Elapsed.TotalSeconds, 1) } throw } } $runnerStopwatch.Stop() Write-RunnerLog -LogPath $logPath -Event 'InstallComplete' -Message 'OSD App Runner completed successfully.' -Data @{ ApplicationCount = @($apps).Count CleanupMode = $cleanupMode DurationSeconds = [math]::Round($runnerStopwatch.Elapsed.TotalSeconds, 1) } if ($cleanupMode -eq 'Never') { Write-RunnerLog -LogPath $logPath -Event 'CleanupSkipped' -Message 'Runtime source cleanup was skipped by configuration.' -Data @{ Path = $StagedPath; CleanupMode = $cleanupMode } } else { Start-RunnerCleanup -Path $StagedPath -LogPath $logPath } exit 0 } catch { if (-not $logPath) { try { $fallbackDir = Join-Path $env:ProgramData 'OSDApps\Logs' New-Item -ItemType Directory -Path $fallbackDir -Force | Out-Null $logPath = Join-Path $fallbackDir 'Runtime.log' } catch { } } if ($logPath) { if ($runnerStopwatch.IsRunning) { $runnerStopwatch.Stop() } $failureMessage = $_.Exception.Message $failureType = if ($failureMessage -match 'SHA-256') { 'HashMismatch' } elseif ($failureMessage -match 'not found|does not contain') { 'SourceMissing' } elseif ($failureMessage -match 'timeout|exceeded') { 'Timeout' } elseif ($failureMessage -match 'exit code') { 'InstallerFailed' } else { 'RuntimeError' } Write-RunnerLog -LogPath $logPath -Event 'InstallFailed' -Level 'Error' -Message $failureMessage -Data @{ FailureType = $failureType DurationSeconds = [math]::Round($runnerStopwatch.Elapsed.TotalSeconds, 1) } } exit 1 } |