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