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' $runnerStartedAtUtc = (Get-Date).ToUniversalTime().ToString('o') $resultPath = [IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables('%ProgramData%\OSDApps\Results\Latest.json')) $preInstallResult = $null $manifest = $null $apps = @() $cleanupMode = $null $logPath = $null $applicationResults = [System.Collections.Generic.List[object]]::new() $validatedRepositoryHashes = @{} 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]! >' $logMessage = $logMessage -replace '[\r\n\x00-\x1F]+', ' ' $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 } function Get-RunnerCacheInfo { param([Parameter(Mandatory)]$App) if ([string]$App.Source -ne 'BuiltIn') { return $null } $root = Join-Path $StagedPath (Join-Path 'BuiltIn' ([string]$App.Id)) if (-not (Test-Path -LiteralPath $root -PathType Container)) { return $null } $file = Get-ChildItem -LiteralPath $root -Filter 'CacheInfo.json' -File -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $file) { return $null } try { Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop } catch { Write-RunnerLog -LogPath $logPath -Event 'ResultMetadataReadFailed' -Level Warning -Message $_.Exception.Message -Data @{ Id=$App.Id; Path=$file.FullName } $null } } function New-RunnerFailureException { param( [Parameter(Mandatory)][string]$Message, [Parameter(Mandatory)][string]$FailureType, [string]$ApplicationId, [switch]$Fatal ) $exception = [System.InvalidOperationException]::new($Message) $exception.Data['OSDAppsFailureType'] = $FailureType $exception.Data['OSDAppsFatal'] = [bool]$Fatal if ($ApplicationId) { $exception.Data['OSDAppsApplicationId'] = $ApplicationId } $exception } function Get-RunnerFailureInfo { param( [Parameter(Mandatory)][System.Management.Automation.ErrorRecord]$ErrorRecord, [string]$ApplicationId ) $exception = $ErrorRecord.Exception $message = $exception.Message $failureType = $null $fatal = $false if ($exception.Data -and $exception.Data.Contains('OSDAppsFailureType')) { $failureType = [string]$exception.Data['OSDAppsFailureType'] $fatal = [bool]$exception.Data['OSDAppsFatal'] if ($exception.Data.Contains('OSDAppsApplicationId')) { $ApplicationId = [string]$exception.Data['OSDAppsApplicationId'] } } elseif ($message -match 'SHA-256') { $failureType = 'HashMismatch' $fatal = $true } elseif ($message -match 'timeout|exceeded') { $failureType = 'Timeout' } elseif ($message -match 'exit code') { $failureType = 'InstallerFailed' } elseif ($message -match 'not found|does not contain|missing') { $failureType = 'SourceMissing' } elseif ($message -match 'unsupported .*application|invalid .*manifest|duplicate .*Id') { $failureType = 'ManifestInvalid' $fatal = $true } elseif ($ApplicationId) { $failureType = 'ApplicationError' } else { $failureType = 'RuntimeError' $fatal = $true } [pscustomobject]@{ FailureType = $failureType Fatal = $fatal ApplicationId= $ApplicationId Message = $message } } function New-RunnerApplicationResult { param( [Parameter(Mandatory)]$App, [Parameter(Mandatory)][ValidateSet('Succeeded','Failed','Skipped')][string]$Status, [double]$DurationSeconds = 0, $Execution, [string]$FailureType, [string]$ErrorMessage ) $cacheInfo = Get-RunnerCacheInfo -App $App [pscustomobject]@{ Id = [string]$App.Id DisplayName = if ($App.DisplayName) { [string]$App.DisplayName } else { [string]$App.Id } Source = [string]$App.Source Type = if ($App.Type) { [string]$App.Type } else { $null } PackageType = if ($App.PackageType) { [string]$App.PackageType } else { 'Application' } Version = if ($App.Version) { [string]$App.Version } elseif ($cacheInfo -and $cacheInfo.Version) { [string]$cacheInfo.Version } else { $null } Architecture = if ($App.Architecture) { [string]$App.Architecture } elseif ($cacheInfo -and $cacheInfo.Architecture) { [string]$cacheInfo.Architecture } else { $null } Status = $Status ExitCode = if ($Execution -and $null -ne $Execution.ExitCode) { [int]$Execution.ExitCode } else { $null } DurationSeconds = [math]::Round($DurationSeconds, 1) Integrity = if ($Execution -and $Execution.Integrity) { [string]$Execution.Integrity } else { $null } CacheUsed = if ($cacheInfo -and $null -ne $cacheInfo.Cached) { [bool]$cacheInfo.Cached } else { $null } PackageUpdated = if ($cacheInfo -and $null -ne $cacheInfo.Updated) { [bool]$cacheInfo.Updated } else { $null } FailureType = $FailureType Error = $ErrorMessage } } function Complete-RunnerAbortedResults { param([Parameter(Mandatory)]$Failure) $recordedIds = @($applicationResults | ForEach-Object { [string]$_.Id }) foreach ($app in $apps) { $id = [string]$app.Id if ($id -in $recordedIds) { continue } $isFailureSource = ( $Failure.ApplicationId -and [string]::Equals($id, [string]$Failure.ApplicationId, [StringComparison]::OrdinalIgnoreCase) ) $status = if ($isFailureSource) { 'Failed' } else { 'Skipped' } $type = if ($isFailureSource) { [string]$Failure.FailureType } else { 'ExecutionAborted' } $message = if ($isFailureSource) { [string]$Failure.Message } else { "Not attempted because execution stopped after a critical $($Failure.FailureType) failure." } $applicationResults.Add((New-RunnerApplicationResult -App $app -Status $status -FailureType $type -ErrorMessage $message)) $recordedIds += $id } } function Write-RunnerResult { param( [Parameter(Mandatory)][ValidateSet('Running','Succeeded','Failed')][string]$Status, [string]$FailureType, [string]$ErrorMessage ) $directory = Split-Path -Path $resultPath -Parent New-Item -ItemType Directory -Path $directory -Force | Out-Null $temporaryPath = "$resultPath.$([guid]::NewGuid().ToString('N')).tmp" $completed = @($applicationResults | Where-Object { $_.Status -eq 'Succeeded' }).Count $failed = @($applicationResults | Where-Object { $_.Status -eq 'Failed' }).Count $skipped = @($applicationResults | Where-Object { $_.Status -eq 'Skipped' }).Count $record = [ordered]@{ SchemaVersion = '1.0' DeploymentId = if ($manifest -and $manifest.DeploymentId) { [string]$manifest.DeploymentId } else { $null } Status = $Status StartedAtUtc = $runnerStartedAtUtc CompletedAtUtc = if ($Status -eq 'Running') { $null } else { (Get-Date).ToUniversalTime().ToString('o') } DurationSeconds = [math]::Round($runnerStopwatch.Elapsed.TotalSeconds, 1) StagedPath = $StagedPath LogPath = $logPath CleanupMode = $cleanupMode ExpectedApplicationCount = @($apps).Count SuccessfulApplicationCount= $completed FailedApplicationCount = $failed SkippedApplicationCount = $skipped UnsuccessfulApplicationCount = ($failed + $skipped) PreInstall = $preInstallResult Applications = @($applicationResults | ForEach-Object { $_ }) FailureType = $FailureType Error = $ErrorMessage } try { $record | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $temporaryPath -Encoding UTF8 -ErrorAction Stop Move-Item -LiteralPath $temporaryPath -Destination $resultPath -Force -ErrorAction Stop } finally { if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) { Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue } } } function Save-RunnerResult { param( [Parameter(Mandatory)][ValidateSet('Running','Succeeded','Failed')][string]$Status, [string]$FailureType, [string]$ErrorMessage ) try { Write-RunnerResult -Status $Status -FailureType $FailureType -ErrorMessage $ErrorMessage } catch { if ($logPath) { Write-RunnerLog -LogPath $logPath -Event 'ResultWriteFailed' -Level Warning -Message $_.Exception.Message -Data @{ ResultPath=$resultPath; Status=$Status } } } } $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 ($manifest.Runtime.ResultPath) { $resultPath = [IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables([string]$manifest.Runtime.ResultPath)) } 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) $preInstallResultPath = Join-Path (Split-Path -Path $resultPath -Parent) 'PreInstall.json' if (Test-Path -LiteralPath $preInstallResultPath -PathType Leaf) { try { $candidatePreInstallResult = Get-Content -LiteralPath $preInstallResultPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ($candidatePreInstallResult.DeploymentId -and $manifest.DeploymentId -and [string]$candidatePreInstallResult.DeploymentId -ne [string]$manifest.DeploymentId) { Write-RunnerLog -LogPath $logPath -Event 'PreInstallResultIgnored' -Level Warning -Message 'PreInstall result belongs to another deployment.' -Data @{ Path=$preInstallResultPath; ExpectedDeploymentId=$manifest.DeploymentId; ActualDeploymentId=$candidatePreInstallResult.DeploymentId } } else { $preInstallResult = $candidatePreInstallResult } } catch { Write-RunnerLog -LogPath $logPath -Event 'PreInstallResultReadFailed' -Level Warning -Message $_.Exception.Message -Data @{ Path=$preInstallResultPath } } } 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 }) } $deploymentId = [guid]::Empty if (-not [guid]::TryParse([string]$manifest.DeploymentId, [ref]$deploymentId)) { throw (New-RunnerFailureException -Message 'Device manifest DeploymentId is missing or invalid.' -FailureType 'ManifestInvalid' -Fatal) } $applicationIds = @($apps | ForEach-Object { [string]$_.Id }) $invalidIds = @($applicationIds | Where-Object { $_ -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or $_ -match '\.\.' }) if ($invalidIds.Count -gt 0 -or @($applicationIds | Select-Object -Unique).Count -ne $applicationIds.Count) { throw (New-RunnerFailureException -Message 'Device manifest contains an invalid or duplicate application Id.' -FailureType 'ManifestInvalid' -Fatal) } $supportedBuiltInTypes = @('OfficeDeploymentTool','MicrosoftTeamsBootstrapper','AdobeAcrobatUnifiedZip','VendorMsi') foreach ($app in $apps) { $id = [string]$app.Id $source = [string]$app.Source if ($source -notin @('Repository','BuiltIn')) { throw (New-RunnerFailureException -Message "Unsupported application source '$source' for '$id'." -FailureType 'ManifestInvalid' -ApplicationId $id -Fatal) } if ($source -eq 'BuiltIn' -and [string]$app.Type -notin $supportedBuiltInTypes) { throw (New-RunnerFailureException -Message "Unsupported built-in application type '$($app.Type)' for '$id'." -FailureType 'ManifestInvalid' -ApplicationId $id -Fatal) } if ($source -eq 'Repository' -and (-not $app.Archive -or [string]$app.Archive.Sha256 -notmatch '^[A-Fa-f0-9]{64}$')) { throw (New-RunnerFailureException -Message "Repository package '$id' has no valid SHA-256 in the device manifest." -FailureType 'ManifestInvalid' -ApplicationId $id -Fatal) } } if ($preInstallResult -and [string]$preInstallResult.Status -eq 'Failed') { $failedPreInstallApplication = @($preInstallResult.Applications | Where-Object { [string]$_.Status -eq 'Failed' } | Select-Object -First 1) $message = if ($preInstallResult.Error) { "PreInstall failed: $($preInstallResult.Error)" } else { 'PreInstall failed before application installation could start.' } $failedPreInstallId = if ($failedPreInstallApplication.Count -gt 0) { [string]$failedPreInstallApplication[0].Id } else { $null } throw (New-RunnerFailureException -Message $message -FailureType 'PreInstallFailed' -ApplicationId $failedPreInstallId -Fatal) } Save-RunnerResult -Status Running # Validate every available repository archive before starting the first # installer. A trust failure aborts the queue; a missing archive remains an # application-local source failure so independent applications can run. foreach ($app in @($apps | Where-Object { [string]$_.Source -eq 'Repository' })) { $archivePath = Join-Path $StagedPath (Join-Path 'Packages' (Join-Path ([string]$app.Id) 'Package.zip')) if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { continue } $actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash if ($actualHash -ne [string]$app.Archive.Sha256) { Write-RunnerLog -LogPath $logPath -Event 'PackageIntegrityFailed' -Level 'Error' -Message 'Staged package SHA-256 validation failed before application execution.' -Data @{ Id=$app.Id; ExpectedSha256=$app.Archive.Sha256; ActualSha256=$actualHash; Archive=$archivePath } throw (New-RunnerFailureException -Message "SHA-256 validation failed for staged package '$($app.Id)'." -FailureType 'HashMismatch' -ApplicationId ([string]$app.Id) -Fatal) } $validatedRepositoryHashes[[string]$app.Id] = $actualHash Write-RunnerLog -LogPath $logPath -Event 'PackageIntegrityValidated' -Message 'Staged package SHA-256 validation succeeded before application execution.' -Data @{ Id=$app.Id; Sha256=$actualHash; Archive=$archivePath } } 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 (-not $validatedRepositoryHashes.ContainsKey([string]$package.Id)) { $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 (New-RunnerFailureException -Message "SHA-256 validation failed for staged package '$($package.Id)'." -FailureType 'HashMismatch' -ApplicationId ([string]$package.Id) -Fatal) } $validatedRepositoryHashes[[string]$package.Id] = $actualHash 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 } [pscustomobject]@{ ExitCode = $process.ExitCode Integrity = if ($package.Archive -and $package.Archive.Sha256) { 'Sha256Validated' } else { 'NotConfigured' } } } 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 } [pscustomobject]@{ ExitCode=$process.ExitCode; Integrity='PublisherValidatedByPreInstall' } } '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 } [pscustomobject]@{ ExitCode=$process.ExitCode; Integrity='PublisherValidatedByPreInstall' } } '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) } [pscustomobject]@{ ExitCode=$process.ExitCode; Integrity='NotAssessed' } } '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) } [pscustomobject]@{ ExitCode=$process.ExitCode; Integrity='PublisherValidatedByPreInstall' } } 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)" }) } $preInstallFailures = @{} if ($preInstallResult -and $preInstallResult.Applications) { foreach ($preInstallApplication in @($preInstallResult.Applications)) { if ([string]$preInstallApplication.Status -eq 'Failed') { $preInstallFailures[[string]$preInstallApplication.Id] = $preInstallApplication } } } foreach ($app in $apps) { $id = [string]$app.Id if ([string]$app.Source -eq 'BuiltIn' -and $preInstallFailures.ContainsKey($id)) { $preInstallFailure = $preInstallFailures[$id] $message = if ($preInstallFailure.Error) { "Installation was not attempted because PreInstall failed: $($preInstallFailure.Error)" } else { 'Installation was not attempted because PreInstall did not prepare this application.' } $failureType = if ($preInstallFailure.FailureType) { [string]$preInstallFailure.FailureType } else { 'PreInstallFailed' } $applicationResults.Add((New-RunnerApplicationResult -App $app -Status 'Skipped' -FailureType $failureType -ErrorMessage $message)) Write-RunnerLog -LogPath $logPath -Event 'ApplicationInstallSkipped' -Level 'Error' -Message $message -Data @{ Id=$id; Source=$app.Source; FailureType=$failureType } Save-RunnerResult -Status Running continue } $applicationStopwatch = [System.Diagnostics.Stopwatch]::StartNew() try { $execution = 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() $applicationResults.Add((New-RunnerApplicationResult -App $app -Status 'Succeeded' -DurationSeconds $applicationStopwatch.Elapsed.TotalSeconds -Execution $execution)) Write-RunnerLog -LogPath $logPath -Event 'ApplicationInstallComplete' -Message 'Application processing completed.' -Data @{ Id = $app.Id Source = $app.Source PackageType = if ($app.PackageType) { [string]$app.PackageType } else { 'Application' } DurationSeconds = [math]::Round($applicationStopwatch.Elapsed.TotalSeconds, 1) } Save-RunnerResult -Status Running } catch { $applicationErrorRecord = $_ if ($applicationStopwatch.IsRunning) { $applicationStopwatch.Stop() } $failure = Get-RunnerFailureInfo -ErrorRecord $applicationErrorRecord -ApplicationId $id $failedExitCode = $null if ($failure.Message -match 'exit code\s+(-?\d+)') { $failedExitCode = [int]$Matches[1] } $failedExecution = if ($null -ne $failedExitCode) { [pscustomobject]@{ ExitCode=$failedExitCode; Integrity=$null } } else { $null } $applicationResults.Add((New-RunnerApplicationResult -App $app -Status 'Failed' -DurationSeconds $applicationStopwatch.Elapsed.TotalSeconds -Execution $failedExecution -FailureType $failure.FailureType -ErrorMessage $failure.Message)) Write-RunnerLog -LogPath $logPath -Event 'ApplicationInstallFailed' -Level 'Error' -Message $failure.Message -Data @{ Id = $app.Id Source = $app.Source PackageType = if ($app.PackageType) { [string]$app.PackageType } else { 'Application' } FailureType = $failure.FailureType ContinueQueue = (-not $failure.Fatal) DurationSeconds = [math]::Round($applicationStopwatch.Elapsed.TotalSeconds, 1) } Save-RunnerResult -Status Running if ($failure.Fatal) { throw } } } $runnerStopwatch.Stop() $failedApplicationCount = @($applicationResults | Where-Object { $_.Status -eq 'Failed' }).Count $skippedApplicationCount = @($applicationResults | Where-Object { $_.Status -eq 'Skipped' }).Count if (($failedApplicationCount + $skippedApplicationCount) -gt 0) { $summary = "$failedApplicationCount application(s) failed and $skippedApplicationCount application(s) were skipped; no automatic retry is scheduled." Write-RunnerLog -LogPath $logPath -Event 'InstallCompletedWithErrors' -Level 'Error' -Message $summary -Data @{ ApplicationCount = @($apps).Count SuccessfulApplicationCount = @($applicationResults | Where-Object { $_.Status -eq 'Succeeded' }).Count FailedApplicationCount = $failedApplicationCount SkippedApplicationCount = $skippedApplicationCount CleanupMode = $cleanupMode DurationSeconds = [math]::Round($runnerStopwatch.Elapsed.TotalSeconds, 1) } Save-RunnerResult -Status Failed -FailureType 'ApplicationFailures' -ErrorMessage $summary Write-RunnerLog -LogPath $logPath -Event 'CleanupSkipped' -Level 'Warning' -Message 'Runtime source cleanup was skipped because one or more applications were unsuccessful.' -Data @{ Path=$StagedPath; CleanupMode=$cleanupMode; Reason='ApplicationFailures' } exit 1 } 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) } Save-RunnerResult -Status Succeeded 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 { $runtimeErrorRecord = $_ $failure = Get-RunnerFailureInfo -ErrorRecord $runtimeErrorRecord $failureMessage = $failure.Message if ($runnerStopwatch.IsRunning) { $runnerStopwatch.Stop() } 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 { } } Complete-RunnerAbortedResults -Failure $failure if ($logPath) { Write-RunnerLog -LogPath $logPath -Event 'InstallFailed' -Level 'Error' -Message $failureMessage -Data @{ FailureType = $failure.FailureType DurationSeconds = [math]::Round($runnerStopwatch.Elapsed.TotalSeconds, 1) } Write-RunnerLog -LogPath $logPath -Event 'CleanupSkipped' -Level 'Warning' -Message 'Runtime source cleanup was skipped after a critical execution failure.' -Data @{ Path=$StagedPath; CleanupMode=$cleanupMode; Reason=$failure.FailureType } } Save-RunnerResult -Status Failed -FailureType $failure.FailureType -ErrorMessage $failureMessage exit 1 } |