Private/AppSelection.ps1
|
# Choosing which applications a Run examines. # # A Customer's estate has more applications in it than a Technician wants to wait for, and # the one being complained about is usually known before the Run starts. Selection happens # before the first Check so that every question a Run asks - this one, and the UAC prompt # in #8 - comes at the beginning, while the Technician is still watching. # # The decision is a pure function of the candidates and what was asked for. Only the # asking is interactive, and it lives in its own function so the rule can be tested # without a console. function Get-AppCandidate { <# .SYNOPSIS The applications a Run could examine, with whether each is installed or running. .DESCRIPTION A Gatherer: it reads the machine and decides nothing beyond what is factually there. Whether a candidate is worth examining is Select-AppDefinition's call. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][AllowEmptyCollection()]$Definition) $running = @(Get-Process -ErrorAction SilentlyContinue) @(foreach ($definition in @($Definition)) { $parameters = ConvertTo-ParameterHashtable (Get-DataProperty $definition 'Parameters') $pattern = Get-Parameter $parameters 'Process' '' $name = Get-Parameter $parameters 'App' (Get-DataProperty $definition 'Name') $processes = 0 if ($pattern) { $processes = @($running | Where-Object { $_.ProcessName -match $pattern }).Count } $installed = @(Get-AppInstallation -Parameters $parameters).Count -gt 0 [pscustomobject]@{ PSTypeName = 'Gutcheck.AppCandidate' Definition = $definition Name = $name CheckName = Get-DataProperty $definition 'Name' Installed = $installed Running = $processes State = (Get-AppCandidateState -Installed $installed -Running $processes) } }) } function Get-AppCandidateState { <# .SYNOPSIS How a candidate reads in the selection list. #> [CmdletBinding()] [OutputType([string])] param([bool]$Installed, [int]$Running) $parts = @() if ($Installed) { $parts += Get-Text 'Console.Apps.State.Installed' } if ($Running -gt 0) { $parts += Get-Text 'Console.Apps.State.Running' $Running } if (-not $parts.Count) { return '-' } $parts -join ', ' } function New-AdHocAppDefinition { <# .SYNOPSIS A Check Definition for an application nobody has written one for yet. .DESCRIPTION A Technician at a Customer Site meets software MERLIN has never seen. The generic half of the App Kind - processes, resources, crashes - works on anything with a process name, so an ad-hoc name buys all of that without a Definition. What it cannot buy is Servers or the application-specific Kinds, which need one. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][string]$ProcessName) $name = $ProcessName -replace '\.exe$', '' [pscustomobject]@{ PSTypeName = 'Gutcheck.CheckDefinition' Name = "App: $name" Kind = 'App' Parameters = @{ App = $name Process = '^' + [regex]::Escape($name) + '$' Match = '^' + [regex]::Escape($name) } MinimumModuleVersion = $null } } function Select-AppDefinition { <# .SYNOPSIS Which app Check Definitions this Run performs. Pure: it asks nothing and reads nothing. .PARAMETER Requested What the Technician asked for: application names, All, Auto, None, or proc:<name>. Empty means nothing was asked, and the caller decides whether to prompt. .PARAMETER Interactive Whether the Technician can be asked. A Run with no answer and nobody to ask selects automatically rather than stopping, because a Run started by a scheduled task or a remote session has to finish on its own. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][AllowEmptyCollection()]$Candidate, [AllowNull()][AllowEmptyCollection()][string[]]$Requested = @(), [bool]$Interactive = $false ) $candidates = @($Candidate) $asked = @(@($Requested) | Where-Object { $_ -and "$_".Trim() }) if (-not $asked.Count) { # Nothing asked for and nobody to ask: everything this machine actually has. if (-not $Interactive) { return Get-DetectedAppDefinition -Candidate $candidates } return $null # the caller prompts; $null says "no answer yet", not "none" } $selected = New-Object System.Collections.Generic.List[psobject] foreach ($token in $asked) { $value = "$token".Trim() switch -Regex ($value) { '^none$' { return , @() } '^all$' { foreach ($c in $candidates) { $selected.Add($c.Definition) }; continue } '^auto$' { foreach ($definition in (Get-DetectedAppDefinition -Candidate $candidates)) { $selected.Add($definition) } continue } '^proc:(.+)$' { $selected.Add((New-AdHocAppDefinition -ProcessName $Matches[1])); continue } default { $match = @($candidates | Where-Object { $_.Name -eq $value }) | Select-Object -First 1 if ($match) { $selected.Add($match.Definition) } else { # An unknown name is far more likely to be a process the Technician can # see in Task Manager than a mistake, so it is treated as one. $selected.Add((New-AdHocAppDefinition -ProcessName $value)) } continue } } } , @($selected | Sort-Object { $_.Name } -Unique) } function Test-NoAppsRequested { <# .SYNOPSIS Whether the Technician asked for no applications at all. Pure. .DESCRIPTION None wins over anything asked with it, the same way Select-AppDefinition resolves it, so that the two cannot disagree about what a request means. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyCollection()][string[]]$Requested) [bool](@($Requested) | Where-Object { "$_".Trim() -eq 'None' }) } function Get-DetectedAppDefinition { <# .SYNOPSIS The Definitions for applications this machine has, installed or running. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][AllowEmptyCollection()]$Candidate) , @(@($Candidate) | Where-Object { $_.Installed -or $_.Running -gt 0 } | ForEach-Object { $_.Definition }) } function Read-AppSelection { <# .SYNOPSIS Asks the Technician which applications to examine. Not tested: it is a prompt. .DESCRIPTION Enter takes everything detected, which is the answer nine times in ten and the reason the detected state is shown beside each entry. #> [CmdletBinding()] [OutputType([string[]])] param([Parameter(Mandatory)][AllowEmptyCollection()]$Candidate) $candidates = @($Candidate) if (-not $candidates.Count) { return @('None') } Write-Host (Get-Text 'Console.Apps.Heading') -ForegroundColor Cyan for ($i = 0; $i -lt $candidates.Count; $i++) { $entry = $candidates[$i] $colour = $(if ($entry.Installed -or $entry.Running -gt 0) { 'White' } else { 'DarkGray' }) Write-Host (' [{0,2}] {1,-20} {2}' -f ($i + 1), $entry.Name, $entry.State) -ForegroundColor $colour } $detected = @($candidates | Where-Object { $_.Installed -or $_.Running -gt 0 }) Write-Host (Get-Text 'Console.Apps.Prompt') Write-Host ((Get-Text 'Console.Apps.Default') -f $(if ($detected.Count) { ($detected.Name) -join ', ' } else { (Get-Text 'Value.Shared.None') })) $answer = Read-Host (Get-Text 'Console.Apps.Selection') if (-not "$answer".Trim()) { return @('Auto') } ConvertFrom-AppSelectionAnswer -Answer $answer -Candidate $candidates } function ConvertFrom-AppSelectionAnswer { <# .SYNOPSIS Turns what the Technician typed into the tokens Select-AppDefinition understands. .DESCRIPTION Pure, and separate from the prompt, because index arithmetic off by one is exactly the kind of mistake that silently examines the wrong application. #> [CmdletBinding()] [OutputType([string[]])] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Answer, [Parameter(Mandatory)][AllowEmptyCollection()]$Candidate ) $candidates = @($Candidate) $tokens = @("$Answer" -split '[,;\s]+' | Where-Object { $_ }) if (-not $tokens.Count) { return @('Auto') } $names = New-Object System.Collections.Generic.List[string] foreach ($token in $tokens) { if ($token -eq '0') { return @('None') } if ($token -match '^(\d+)-(\d+)$') { foreach ($number in [int]$Matches[1]..[int]$Matches[2]) { if ($number -ge 1 -and $number -le $candidates.Count) { $names.Add($candidates[$number - 1].Name) } } continue } if ($token -match '^\d+$') { $number = [int]$token if ($number -ge 1 -and $number -le $candidates.Count) { $names.Add($candidates[$number - 1].Name) } continue } $names.Add($token) } if (-not $names.Count) { return @('None') } # No unary comma: every path out of here returns at least one token, so there is no # empty array to protect, and the extra wrapping only confuses what the caller gets. @($names) } function Test-RunInteractive { <# .SYNOPSIS Whether this Run has a Technician in front of it to ask. #> [CmdletBinding()] [OutputType([bool])] param() try { [Environment]::UserInteractive -and -not [Console]::IsInputRedirected } catch { $false } } function Resolve-AppCheck { <# .SYNOPSIS Splits a Run's Check Definitions into the app Checks it will perform and the rest. .DESCRIPTION The app Checks are the only ones a Technician chooses between. Everything else a Run performs is performed, which is why selection is a filter here rather than something each Kind has to know about. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][AllowEmptyCollection()]$Definition, [AllowNull()][AllowEmptyCollection()][string[]]$Requested = @() ) $all = @($Definition) $appKind = @($all | Where-Object { (Get-DataProperty $_ 'Kind') -eq 'App' }) # A Check of any Kind may say which application it belongs to, by carrying an App # parameter. Outlook's mail files, its add-ins, an application's database and its # Servers are all Checks about one application, and a Technician who did not select # that application did not ask for them - the script only ever ran them from inside # its per-app loop. A Check with no App parameter belongs to the machine and always # runs, which is what keeps a Customer-wide Server Check independent of app selection. $companion = @($all | Where-Object { (Get-DataProperty $_ 'Kind') -ne 'App' -and (Get-DefinitionApp -Definition $_) }) $other = @($all | Where-Object { (Get-DataProperty $_ 'Kind') -ne 'App' -and -not (Get-DefinitionApp -Definition $_) }) # Detecting which applications are present means walking three registry hives, the # Start menu and the Store package list. A Technician who said None has asked for # none of that, and doing it anyway would be a Run spending time to build a list # nothing reads. $candidates = @() if (-not (Test-NoAppsRequested -Requested $Requested)) { $candidates = @(Get-AppCandidate -Definition $appKind) } # A Run with no app Definitions at all can still be asked for an ad-hoc process, so # selection runs either way. $selected = Select-AppDefinition -Candidate $candidates -Requested $Requested ` -Interactive (Test-RunInteractive) if ($null -eq $selected) { $answer = Read-AppSelection -Candidate $candidates $selected = Select-AppDefinition -Candidate $candidates -Requested $answer -Interactive $false } # The Checks belonging to an application the Technician did choose. $chosen = @(@($selected) | ForEach-Object { Get-Parameter (ConvertTo-ParameterHashtable (Get-DataProperty $_ 'Parameters')) 'App' '' } | Where-Object { $_ }) $companions = @($companion | Where-Object { $chosen -contains (Get-DefinitionApp -Definition $_) }) [pscustomobject]@{ PSTypeName = 'Gutcheck.AppSelection' Candidate = $candidates Selected = @($selected) Companion = $companions # Definitions in Run order, with the unselected app Checks and their companions # dropped. Order matters: the App Kind consumes what the Stability and System # Checks gathered, so it has to come after them, and a companion comes after the # application it belongs to. Check = @($other) + @($selected) + @($companions) } } function Get-DefinitionApp { <# .SYNOPSIS The application a Check Definition says it belongs to, or nothing. Pure. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$Definition) $parameters = ConvertTo-ParameterHashtable (Get-DataProperty $Definition 'Parameters') "$(Get-Parameter $parameters 'App' '')".Trim() } function New-AppSelectionFinding { <# .SYNOPSIS Which applications this Run examined, as a Finding. .DESCRIPTION The script put this in the Report header. It is a Finding here because a Report read weeks later has to answer "was Outlook looked at?" without the Technician who ran it, and because an application that was on the machine and not selected is exactly the gap a second-level Technician needs to see. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Selection) $selected = @(@($Selection.Selected) | ForEach-Object { Get-DataProperty $_ 'Name' } | Where-Object { $_ }) $value = (Get-Text 'Value.Shared.None') if ($selected.Count) { $value = ($selected | Sort-Object) -join ', ' } # Applications this machine has that the Technician did not pick. Not a fault, but the # difference between "Outlook is fine" and "Outlook was never looked at". $selectedNames = @(@($Selection.Selected) | ForEach-Object { Get-DataProperty $_ 'Name' }) $skipped = @(@($Selection.Candidate) | Where-Object { $_.Installed -or $_.Running -gt 0 } | Where-Object { $selectedNames -notcontains $_.CheckName } | ForEach-Object { $_.Name }) $hint = '' if ($skipped.Count) { $hint = (Get-Text 'Hint.AppSelection.PresentButNotExamined') -f (($skipped | Sort-Object) -join ', ') } New-Finding -Category Apps -Check (Get-Text 'Check.AppSelection.ApplicationsExamined') -Severity INFO -Value $value -Hint $hint } |