Private/Autoupdate.ps1
|
# Keeping Gutcheck current without asking the Technician to do anything. # # A corrected threshold is worthless if it is sitting in the Gallery while Technicians run # last month's copy. So a Run brings itself up to date before it starts, and the Technician # does nothing. # # Two rules shape all of this. A Run never changes version midway, because a Report # assembled by two versions is a Report nobody can reason about - so updating means # installing and starting again, not reloading. And updating can never block a diagnosis: # an unreachable Gallery, a locked-down machine or absent permission costs a Finding and # nothing else, because the Technician is standing in front of a machine that is broken now. $script:AutoupdateModuleName = 'Gutcheck' function Get-UpdateDecision { <# .SYNOPSIS Whether this Run should update itself, and why. Pure. .DESCRIPTION The whole of the update logic that can be reasoned about. Installing and starting again cannot be tested without a Gallery and a second process; deciding can, and deciding is where this goes wrong in ways nobody notices - a Run that updates in a loop, or one that quietly never updates at all. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][version]$Installed, [AllowNull()]$Available, [bool]$Disabled = $false, [bool]$AlreadyUpdated = $false ) # Checked before anything else so that -NoUpdate means what it says: a Report can be # reproduced on a known version without the Gallery being consulted at all. if ($Disabled) { return New-UpdateDecision -Action 'none' -Reason (Get-Text 'Update.Disabled') } # The stop that keeps a relaunch from being a loop. A Run started by an update has # already done this once, and a machine that cannot install what it just installed # would otherwise try forever. if ($AlreadyUpdated) { return New-UpdateDecision -Action 'none' -Reason (Get-Text 'Update.AlreadyUpdated' $Installed) } if (-not $Available) { return New-UpdateDecision -Action 'none' ` -Reason (Get-Text 'Update.GalleryUnreachable' $Installed) } $availableVersion = [version]$Available if ($availableVersion -le $Installed) { return New-UpdateDecision -Action 'none' -Reason (Get-Text 'Update.Newest' $Installed) } New-UpdateDecision -Action 'update' -Reason (Get-Text 'Update.Updating' $Installed $availableVersion) ` -Version $availableVersion } function New-UpdateDecision { [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][ValidateSet('update', 'none')][string]$Action, [Parameter(Mandatory)][string]$Reason, [AllowNull()]$Version ) [pscustomobject]@{ PSTypeName = 'Gutcheck.UpdateDecision' Action = $Action Reason = $Reason Version = $Version } } function New-UpdateFinding { <# .SYNOPSIS What happened about updating, as a Finding. .DESCRIPTION Always INFO, whatever happened. A Gallery that could not be reached is not a fact about the Target Machine, and a Technician reading a WARN here would go looking for a fault in a machine whose only problem is that it is behind a proxy. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Decision) New-Finding -Category Gutcheck -Check (Get-Text 'Update.Check') -Severity INFO -Value $Decision.Reason } function Find-GalleryVersion { <# .SYNOPSIS The newest version in the Gallery, or $null when it cannot be asked. Untested. #> [CmdletBinding()] [OutputType([version])] param([string]$Name = $script:AutoupdateModuleName) Set-FetchTls try { $module = Find-Module -Name $Name -ErrorAction Stop | Sort-Object Version -Descending | Select-Object -First 1 if ($module) { return [version]$module.Version } $null } catch { $null } } function Install-GutcheckUpdate { <# .SYNOPSIS Installs the newer version for the current user. Untested: it needs a Gallery. .DESCRIPTION Current user scope deliberately. It needs no admin rights, which is the point - the Technician running this is not an administrator of the Customer's machine - and it keeps Gutcheck out of the system-wide module path on a machine that is not MERLIN's. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)][version]$Version, [string]$Name = $script:AutoupdateModuleName ) try { Install-Module -Name $Name -RequiredVersion $Version -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop $true } catch { $false } } function ConvertTo-RelaunchArgument { <# .SYNOPSIS Renders the parameters this Run was started with, for the Run that replaces it. Pure. .DESCRIPTION A Technician who asked for particular applications, or told the Run not to elevate, asked the diagnosis and not this process. The replacement has to carry all of it - plus -Updated, which is what stops it updating again. The two internal parameters are dropped: a Main Part relaunches as a Main Part, and a transfer directory belongs to the elevation that created it. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][hashtable]$BoundParameter) $rendered = foreach ($name in ($BoundParameter.Keys | Sort-Object)) { if ($name -in 'Part', 'TransferPath', 'Updated') { continue } $value = $BoundParameter[$name] if ($value -is [switch]) { # A switch given as false was still given, and can mean something a missing # one does not: -KeepSignIn:$false is "no, do not ask", where leaving it out is # "ask". Dropping it would change the answer across an update. if ($value.IsPresent) { '-{0}' -f $name } else { '-{0}:$false' -f $name } continue } if ($value -is [array]) { '-{0} {1}' -f $name, (@($value | ForEach-Object { "'{0}'" -f ("$_" -replace "'", "''") }) -join ',') continue } '-{0} ''{1}''' -f $name, ("$value" -replace "'", "''") } (@($rendered) + '-Updated') -join ' ' } function Invoke-GutcheckRelaunch { <# .SYNOPSIS Starts the Run again on the version just installed. Untested: it is a new process. .DESCRIPTION A new process, not a module reload. The version has to change between Runs and never during one, and a process that has already loaded a module cannot honestly replace it underneath itself. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][hashtable]$BoundParameter) $executable = Join-Path $PSHOME $(if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh.exe' } else { 'powershell.exe' }) $arguments = ConvertTo-RelaunchArgument -BoundParameter $BoundParameter $command = 'Import-Module {0} -Force; Invoke-Gutcheck {1}' -f $script:AutoupdateModuleName, $arguments Write-Host (Get-Text 'Console.Update.Restarting') -ForegroundColor Gray Start-Process -FilePath $executable ` -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ('"{0}"' -f $command)) ` -Wait -NoNewWindow -ErrorAction Stop } function Invoke-ModuleUpdate { <# .SYNOPSIS Brings this Run up to date, or explains why it did not. Never throws. .DESCRIPTION Returns whether the Run was replaced. When it was, the caller stops: the replacement wrote the Report. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][version]$Installed, [bool]$Disabled = $false, [bool]$AlreadyUpdated = $false, [hashtable]$BoundParameter = @{} ) # Guarded here as well as inside Find-GalleryVersion, because this is the function # that promises never to throw and a diagnosis must not be lost to a package provider # failing in a way nobody anticipated. The Gallery is not asked at all when there is # nothing it could change. $available = $null if (-not $Disabled -and -not $AlreadyUpdated) { try { $available = Find-GalleryVersion } catch { $available = $null } } $decision = Get-UpdateDecision -Installed $Installed -Available $available ` -Disabled $Disabled -AlreadyUpdated $AlreadyUpdated if ($decision.Action -ne 'update') { return [pscustomobject]@{ PSTypeName = 'Gutcheck.UpdateOutcome'; Relaunched = $false; Decision = $decision } } Write-Host (' {0}' -f $decision.Reason) -ForegroundColor Gray if (-not (Install-GutcheckUpdate -Version $decision.Version)) { # Absent permission, a locked-down machine, no package provider. None of it stops # a diagnosis; all of it is worth a line in the Report. return [pscustomobject]@{ PSTypeName = 'Gutcheck.UpdateOutcome' Relaunched = $false Decision = (New-UpdateDecision -Action 'none' ` -Reason ((Get-Text 'Update.InstallFailed') -f $decision.Version, $Installed)) } } try { Invoke-GutcheckRelaunch -BoundParameter $BoundParameter [pscustomobject]@{ PSTypeName = 'Gutcheck.UpdateOutcome'; Relaunched = $true; Decision = $decision } } catch { [pscustomobject]@{ PSTypeName = 'Gutcheck.UpdateOutcome' Relaunched = $false Decision = (New-UpdateDecision -Action 'none' ` -Reason ((Get-Text 'Update.RestartFailed') -f $decision.Version, $Installed)) } } } |