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 Get-InstalledGutcheckVersion { <# .SYNOPSIS The newest version on this machine's module path, or $null. Untested: it is the disk. .DESCRIPTION Not the version that is running. A PowerShell window keeps the module it loaded for as long as it is open, so a window opened last week runs last week's Gutcheck however often it has updated since. #> [CmdletBinding()] [OutputType([version])] param([string]$Name = $script:AutoupdateModuleName) try { $newest = Get-Module -Name $Name -ListAvailable -ErrorAction Stop | Sort-Object Version -Descending | Select-Object -First 1 if ($newest) { return [version]$newest.Version } $null } catch { $null } } function Switch-LoadedGutcheck { <# .SYNOPSIS Makes this PowerShell window use the installed version from its next command on. Never throws. The last thing a replaced Run does. .DESCRIPTION Seen on a real machine: a window that had loaded 0.4 said "Aktualisierung von 0.4 auf 0.7.1" on every Run, installed 0.7.1 again and started a second process - each time, for days, because the update was on the disk and the window never looked there again. Removes the loaded module and imports the installed one. This function is part of what it removes: nothing of this module may be called after Remove-Module, and nothing is. If the import fails the window has no Gutcheck loaded, and typing gutcheck loads the newest one by itself. #> [CmdletBinding()] param([Parameter(Mandatory)][version]$Version, [string]$Name = $script:AutoupdateModuleName) try { Remove-Module -Name $Name -Force -ErrorAction Stop Import-Module -Name $Name -RequiredVersion $Version -Global -ErrorAction Stop } catch { } } 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) { # Asking the Gallery takes a second or two, and it is the first thing a Run does: # without a word the window looks as if nothing had been started. Write-Host (Get-Text 'Console.Update.Checking') -ForegroundColor Gray -NoNewline try { $available = Find-GalleryVersion } catch { $available = $null } $answer = (Get-Text 'Console.Update.Unreachable') -f $Installed if ($available) { $answer = (Get-Text 'Console.Update.Current') -f $Installed if ([version]$available -gt $Installed) { $answer = (Get-Text 'Console.Update.Found') -f $available } } Write-Host $answer -ForegroundColor Gray } $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 } } # Already on the disk, and only this window is behind: nothing to install. $onDisk = $null try { $onDisk = Get-InstalledGutcheckVersion } catch { $onDisk = $null } # Not $installed: that is the $Installed parameter under another spelling. $present = [bool]($null -ne $onDisk -and $onDisk -ge $decision.Version) if ($present) { Write-Host (' {0}' -f ((Get-Text 'Update.Switching') -f $Installed, $decision.Version)) -ForegroundColor Gray } else { Write-Host (' {0}' -f $decision.Reason) -ForegroundColor Gray } if (-not $present -and -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)) } } } |