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))
        }
    }
}