Public/Invoke-IntuneWin32AppTest.ps1
|
function Invoke-IntuneWin32AppTest { <# .EXTERNALHELP IntuneScriptLab-Help.xml .SYNOPSIS Runs a Win32 app's detect, install or uninstall, detect flow the way the Intune agent does. #> [CmdletBinding()] [OutputType('IntuneScriptLab.Win32AppResult')] param( [string]$DetectionPath, # File, registry and product code rules (hashtables for Test-IntuneWin32Rule -Rule); with a # detection script, all of them and the script must say installed [hashtable[]]$DetectionRule, [Parameter(Mandatory)] [string]$ContentPath, [string]$InstallCommand, [string]$UninstallCommand, [ValidateSet('Install', 'Uninstall')] [string]$Intent = 'Install', # Dependencies, each a hashtable with Name, DetectionPath and/or DetectionRule, ContentPath, # InstallCommand and Type (autoInstall, the default, or detect), handled after this app's # first detection in the order given, as the agent was observed to (W32-DEP-PARENT) [hashtable[]]$DependsOn, # Superseded apps, each a hashtable with Name, DetectionPath and/or DetectionRule, ContentPath, # UninstallCommand and Type (update, the default, or replace); a detected replace target is # uninstalled before this app installs, an update target stays (W32-SUP-OLD-A, W32-SUP-OLD-B) [hashtable[]]$Supersedes, [ValidateSet('x86', 'x64', 'arm64')] [string]$Architecture = 'x64', [ValidateSet('User', 'System')] [string]$Context = 'User', # Run every launch (detection, requirement, install, uninstall) as this account instead of # the current user (with -Context User), through a scheduled task in the account's own # session; needs an elevated session [System.Management.Automation.PSCredential] [System.Management.Automation.Credential()] $Credential, # The app's install behavior in the portal (System or User); User only adds the observed # warning about device-targeted assignments [ValidateSet('System', 'User')] [string]$InstallContext = 'System', [hashtable]$ReturnCodes = @{ 0 = 'success'; 1707 = 'success'; 3010 = 'softReboot'; 1641 = 'hardReboot'; 1618 = 'retry' }, [ValidateRange(1, 86400)] [int]$TimeoutSeconds = 300, [ValidateRange(1, 86400)] [int]$InstallTimeoutSeconds = 600, [switch]$EnforceSignatureCheck ) Write-Verbose "Starting $($MyInvocation.MyCommand.Name) for $($PSBoundParameters.Keys -join ', ')" if (-not $DetectionPath -and -not $DetectionRule) { throw 'Give a detection script (-DetectionPath), detection rules (-DetectionRule), or both' } $command = if ($Intent -eq 'Install') { $InstallCommand } else { $UninstallCommand } if (-not $command) { throw "Intent $Intent needs -${Intent}Command" } $null = Resolve-Path -LiteralPath $ContentPath -ErrorAction Stop foreach ($related in @($DependsOn) + @($Supersedes)) { if (-not $related) { continue } if (-not $related.Name) { throw 'Every -DependsOn and -Supersedes entry needs a Name' } if (-not $related.DetectionPath -and -not $related.DetectionRule) { throw "$($related.Name) needs a DetectionPath, DetectionRule entries, or both" } if ($related.ContentPath) { $null = Resolve-Path -LiteralPath $related.ContentPath -ErrorAction Stop } } $warnings = [System.Collections.Generic.List[string]]::new() if ($InstallContext -eq 'User') { # W32-USER-INSTALL: a device-group assignment never installs a user-context app on either join type $warnings.Add('Install behavior User: an assignment to a device group never installs the app. The ' + 'agent logs "user install context and this is a userless check-in" and reports Not applicable ' + '(Applicability 1011); assign the app to users') } $detectionSplat = @{ Architecture = $Architecture Context = $Context TimeoutSeconds = $TimeoutSeconds EnforceSignatureCheck = $EnforceSignatureCheck } if ($Credential) { if ($Context -eq 'System') { throw '-Credential applies to -Context User; System runs as NT AUTHORITY\SYSTEM' } $detectionSplat.Credential = $Credential } $runAs = if ($Credential) { $Credential.UserName } elseif ($Context -eq 'System') { 'NT AUTHORITY\SYSTEM' } elseif ($env:USERDOMAIN) { "$env:USERDOMAIN\$env:USERNAME" } else { [Environment]::UserName } # One detection pass for this app or a related one: the script (if any) and every rule; all of # them must say installed (W32-MULTI-ONEFALSE, W32-MULTI-BOTHTRUE) function Invoke-DetectionPass { param([string]$Phase, [string]$Path, [hashtable[]]$Rule, [string]$Label) $script = if ($Path) { Invoke-IntuneDetectionTest -Path $Path @detectionSplat } else { $null } $rules = @(foreach ($entry in $Rule) { Test-IntuneWin32Rule -Rule $entry -RuleType Detection }) $unmet = @($rules | Where-Object { -not $_.Met }) $detected = ($null -eq $script -or $script.Detected) -and $unmet.Count -eq 0 $timedOut = $null -ne $script -and $script.TimedOut foreach ($miss in $unmet) { $warnings.Add("$Phase detection rule not met$Label ($($miss.Kind) $($miss.Operation)): " + $miss.Reason) } [pscustomobject]@{ Detected = $detected TimedOut = $timedOut Script = $script Rules = $rules Reason = if ($script -and -not $script.Detected) { $script.Reason } elseif ($unmet.Count) { ($unmet | ForEach-Object { $_.Reason }) -join '; ' } else { 'Script and rules all report installed' } } } # Runs an install or uninstall command line from a copy of a content folder through the 32-bit # cmd.exe, as the agent's own 32-bit process does function Invoke-ContentCommand { param([string]$Phase, [string]$Content, [string]$CommandLine) if ($CommandLine -match '(?i)(^|[\s"])powershell(\.exe)?([\s"]|$)') { $warnings.Add("powershell.exe in the $Phase command runs the 32-bit host (the agent is a 32-bit " + 'process); HKLM:\SOFTWARE and Program Files are redirected there') } $source = (Resolve-Path -LiteralPath $Content).ProviderPath $runId = [guid]::NewGuid().ToString('N') # Another account cannot reach the caller's temp folder; its copy lives under ProgramData $cache = if ($Credential) { Join-Path -Path $env:ProgramData -ChildPath "IntuneScriptLab\Runs\$runId" } else { Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "IntuneScriptLab\$runId" } $copy = Join-Path -Path $cache -ChildPath 'content' $null = New-Item -ItemType Directory -Path $copy -Force Copy-Item -Path (Join-Path -Path $source -ChildPath '*') -Destination $copy -Recurse -Force if ($Credential) { Grant-IslFolderAccess -Path $cache -Account $Credential.UserName } try { $cmdFolder = if ([Environment]::Is64BitOperatingSystem) { 'SysWOW64' } else { 'System32' } $cmd = Join-Path -Path (Join-Path -Path $env:WINDIR -ChildPath $cmdFolder) -ChildPath 'cmd.exe' $processSplat = @{ FilePath = $cmd Arguments = "/C `"$CommandLine`"" WorkingDirectory = $copy WorkFolder = $cache Context = $Context TimeoutSeconds = $InstallTimeoutSeconds LauncherBitness = 'x86' } if ($Credential) { $processSplat.Credential = $Credential } $run = Invoke-IslProcess @processSplat } finally { Remove-Item -LiteralPath $cache -Recurse -Force -ErrorAction SilentlyContinue } [pscustomobject]@{ PSTypeName = 'IntuneScriptLab.RunResult' Phase = $Phase Command = $CommandLine Context = $Context ExitCode = $run.ExitCode TimedOut = $run.TimedOut StdOut = $run.StdOut StdErr = $run.StdErr Duration = $run.Duration } } function Get-Outcome { param($Run) if ($Run.TimedOut) { return 'timedOut' } if ($ReturnCodes.ContainsKey($Run.ExitCode)) { $ReturnCodes[$Run.ExitCode] } else { 'failed' } } function ConvertTo-RelatedResult { param([string]$Name, [string]$Relationship, [string]$Status, $Pre, $Install, $Uninstall, $Post) [pscustomobject]@{ PSTypeName = 'IntuneScriptLab.Win32RelatedResult' Name = $Name Relationship = $Relationship Status = $Status PreDetection = $Pre.Script PreRules = $Pre.Rules Install = $Install Uninstall = $Uninstall PostDetection = if ($Post) { $Post.Script } else { $null } PostRules = if ($Post) { $Post.Rules } else { @() } } } $pre = Invoke-DetectionPass -Phase 'Pre' -Path $DetectionPath -Rule $DetectionRule -Label '' $execution = $null $post = $null $dependencies = [System.Collections.Generic.List[object]]::new() $superseded = [System.Collections.Generic.List[object]]::new() $status = $null if ($pre.TimedOut) { $status = 'TimedOut' } elseif ($Intent -eq 'Install' -and $pre.Detected) { $status = 'Installed' } elseif ($Intent -eq 'Uninstall' -and -not $pre.Detected) { $status = 'Not installed' } # Dependencies come after this app's first detection: the child's detection, install and # detection, then this app again (W32-DEP-PARENT). A detect-only dependency that is absent stops # the parent with "1 or more dependent apps are configured to not automatically install." # (W32-DEPD-PARENT) if (-not $status -and $Intent -eq 'Install') { foreach ($dependency in @($DependsOn | Where-Object { $_ })) { $type = if ($dependency.Type) { "$($dependency.Type)" } else { 'autoInstall' } $name = "$($dependency.Name)" $detect = @{ Path = $dependency.DetectionPath; Rule = $dependency.DetectionRule; Label = " for dependency $name" } $childPre = Invoke-DetectionPass -Phase 'Pre' @detect $childInstall = $null $childPost = $null $childStatus = if ($childPre.TimedOut) { 'TimedOut' } elseif ($childPre.Detected) { 'Installed' } elseif ($type -ne 'autoInstall') { 'Not detected (detect-only dependency)' } elseif (-not $dependency.InstallCommand -or -not $dependency.ContentPath) { 'Not detected (no InstallCommand or ContentPath)' } else { $childRun = @{ Phase = 'install'; Content = $dependency.ContentPath; CommandLine = $dependency.InstallCommand } $childInstall = Invoke-ContentCommand @childRun switch (Get-Outcome -Run $childInstall) { 'timedOut' { 'TimedOut' } 'retry' { 'Retry' } 'failed' { "Install failed (exit $($childInstall.ExitCode))" } default { $childPost = Invoke-DetectionPass -Phase 'Post' @detect if ($childPost.Detected) { 'Installed after install' } else { 'Not detected after install' } } } } $relatedSplat = @{ Name = $name; Relationship = "dependency ($type)"; Status = $childStatus Pre = $childPre; Install = $childInstall; Uninstall = $null; Post = $childPost } $dependencies.Add((ConvertTo-RelatedResult @relatedSplat)) if ($childStatus -notin 'Installed', 'Installed after install') { $status = 'Not installed (dependency)' $warnings.Add("Dependency $name ($type): $childStatus. The agent does not run this app's " + 'install; an absent detect-only dependency reports Not installed with "1 or more ' + 'dependent apps are configured to not automatically install." (W32-DEPD-PARENT)') break } } } # Superseded apps are detected before this app installs; a replace target that is present is # uninstalled first (W32-SUP-OLD-B: old uninstall, then new install), an update target stays # installed (W32-SUP-OLD-A) if (-not $status -and $Intent -eq 'Install') { foreach ($old in @($Supersedes | Where-Object { $_ })) { $type = if ($old.Type) { "$($old.Type)" } else { 'update' } $name = "$($old.Name)" $detect = @{ Path = $old.DetectionPath; Rule = $old.DetectionRule; Label = " for superseded app $name" } $oldPre = Invoke-DetectionPass -Phase 'Pre' @detect $oldUninstall = $null $oldPost = $null $oldStatus = if ($oldPre.TimedOut) { 'TimedOut' } elseif (-not $oldPre.Detected) { 'Not installed' } elseif ($type -ne 'replace') { 'Installed (left in place by update)' } elseif (-not $old.UninstallCommand -or -not $old.ContentPath) { 'Installed (no UninstallCommand or ContentPath)' } else { $oldRun = @{ Phase = 'uninstall'; Content = $old.ContentPath; CommandLine = $old.UninstallCommand } $oldUninstall = Invoke-ContentCommand @oldRun switch (Get-Outcome -Run $oldUninstall) { 'timedOut' { 'TimedOut' } 'retry' { 'Retry' } 'failed' { "Uninstall failed (exit $($oldUninstall.ExitCode))" } default { $oldPost = Invoke-DetectionPass -Phase 'Post' @detect if ($oldPost.Detected) { 'Still detected after uninstall' } else { 'Uninstalled' } } } } $relatedSplat = @{ Name = $name; Relationship = "supersedence ($type)"; Status = $oldStatus Pre = $oldPre; Install = $null; Uninstall = $oldUninstall; Post = $oldPost } $superseded.Add((ConvertTo-RelatedResult @relatedSplat)) if ($type -eq 'replace' -and $oldStatus -notin 'Uninstalled', 'Not installed') { $warnings.Add("Superseded app $name (replace): $oldStatus. The agent removes a replace target " + 'before it installs the new app (W32-SUP-OLD-B), so the old app would still be present') } } } if (-not $status) { $execution = Invoke-ContentCommand -Phase $Intent.ToLower() -Content $ContentPath -CommandLine $command $outcome = Get-Outcome -Run $execution switch ($outcome) { 'timedOut' { $status = 'TimedOut' } 'retry' { $status = 'Retry' } 'failed' { $status = "$Intent failed (exit $($execution.ExitCode))" } default { if ($outcome -in 'softReboot', 'hardReboot') { $warnings.Add("$Intent returned $($execution.ExitCode): Intune records a $outcome as pending") } $post = Invoke-DetectionPass -Phase 'Post' -Path $DetectionPath -Rule $DetectionRule -Label '' if ($post.TimedOut) { $status = 'TimedOut' } elseif ($Intent -eq 'Install') { $status = if ($post.Detected) { 'Installed after install' } else { 'Not detected after install' } if (-not $post.Detected) { $warnings.Add("Post-install detection: $($post.Reason). Intune reports 0x87D1041C " + 'and retries the install at the next check-in') } } else { # Observed flow (W32-UNINSTALL): detected, uninstall command, detected again, and # "Not installed" once the detection stops saying installed $status = if ($post.Detected) { 'Still detected after uninstall' } else { 'Uninstalled' } if ($post.Detected) { $warnings.Add('Post-uninstall detection still reports installed: Intune keeps ' + 'the app as installed and retries the uninstall at the next check-in') } } } } } Write-Verbose "Completed $($MyInvocation.MyCommand.Name)" [pscustomobject]@{ PSTypeName = 'IntuneScriptLab.Win32AppResult' Status = $status Intent = $Intent PreDetection = $pre.Script PreRules = $pre.Rules Dependencies = $dependencies.ToArray() Superseded = $superseded.ToArray() Install = if ($Intent -eq 'Install') { $execution } else { $null } Uninstall = if ($Intent -eq 'Uninstall') { $execution } else { $null } PostDetection = if ($post) { $post.Script } else { $null } PostRules = if ($post) { $post.Rules } else { @() } Warnings = $warnings.ToArray() Architecture = $Architecture Context = $Context RunAs = $runAs } } |