Public/Complete-InPlaceUpgrade.ps1

function Complete-InPlaceUpgrade {
    <#
    .SYNOPSIS
    Check a VM whose upgrade was started and report Completed, InProgress or Failed

    .DESCRIPTION
    The Check half of the state machine (ADR 0003). Reads the build number, Setup processes and
    the scheduled task result from the guest and decides: Completed when the guest reports the
    target build, InProgress while Setup runs or the guest is rebooting, Failed when the task
    ended with an error, the VM was stopped, or -StartedAt is older than -TimeoutMinutes.
    On a final state the media disk is detached and deleted and the scheduled task removed; the
    snapshot is kept as the rollback point. It evaluates whichever VM it is given - the caller
    decides which ones are in flight. Safe to run every few minutes.

    .PARAMETER VM
    The VM object from Get-AzVM. Accepts
    pipeline input.

    .PARAMETER ResourceGroupName
    Resource group of the VM when -Name is used instead of -VM.

    .PARAMETER Name
    Name of the VM when -ResourceGroupName is used instead of -VM.

    .PARAMETER Snapshot
    Name of the OS disk snapshot Start-InPlaceUpgrade took before the upgrade. Carried into the
    telemetry record and into the result so a rollback target is never guessed. Comes from the
    StartResult; pass it yourself only when you are completing a run you started by other means.

    .PARAMETER MediaDisk
    Name of the managed disk holding the upgrade media that Start attached. Used to detach and
    delete it once the upgrade succeeded, unless -KeepMediaDisk is set. Also from the StartResult.

    .PARAMETER Engine
    Which engine Start used: MediaDisk (Microsoft's upgrade media as a managed disk, the proven
    path) or FeatureUpdate (Windows Update). It decides how the completion is judged and what
    cleanup is needed. Defaults to MediaDisk.

    .PARAMETER StartedAt
    When the upgrade was started, as recorded by Start-InPlaceUpgrade. -TimeoutMinutes is measured
    from it. Without it the timeout cannot be applied and the command warns and keeps waiting, so
    pass it for any unattended run.

    .PARAMETER TimeoutMinutes
    How old -StartedAt may be before a VM that has not reached the target build is declared
    Failed.

    .PARAMETER KeepMediaDisk
    Keep the media disk after a final state. Costs money; useful when debugging.

    .PARAMETER LogIngestionEndpoint
    Logs ingestion endpoint of a data collection endpoint. With -DataCollectionRuleId, every
    evaluation (InProgress, Completed, Failed) is written to the InPlaceUpgrade_CL table.

    .PARAMETER DataCollectionRuleId
    Immutable id (dcr-...) of the data collection rule that routes Custom-InPlaceUpgrade_CL.

    .EXAMPLE
    # Check one VM whose upgrade is in flight
    Complete-InPlaceUpgrade -ResourceGroupName rg-apps-prod-weu -Name vm-app-prod-weu-01 -Target WS2025 |
        Select-Object VMName, Result, Reason

    .EXAMPLE
    # One VM, keep the media disk for inspection
    Complete-InPlaceUpgrade -ResourceGroupName rg-apps-prod-weu -Name vm-app-prod-weu-01 -KeepMediaDisk

    .INPUTS
    Microsoft.Azure.Commands.Compute.Models.PSVirtualMachine

    .OUTPUTS
    AzureInPlaceUpgrade.CompleteResult

    .NOTES
    Author: Simon Vedder (simonvedder.com)
    Version: 0.1.0
    Created: 2026-09-05
    LastModified: 2026-09-05
    RequiredPermissions: Microsoft.Compute/virtualMachines/read, write, instanceView/read, runCommand/action;
                         Microsoft.Compute/disks/read, delete
    Prerequisites: PowerShell 7.2+, Az.Compute, Az.Resources; an established Azure context

    .LINK
    https://github.com/simon-vedder/azure-vm-inplace-upgrade
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'ByObject')]
    [OutputType('AzureInPlaceUpgrade.CompleteResult')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'ByObject', ValueFromPipeline)]
        $VM,

        [Parameter(Mandatory, ParameterSetName = 'ByName')]
        [string]$ResourceGroupName,

        [Parameter(Mandatory, ParameterSetName = 'ByName')]
        [string]$Name,

        # What Start left behind. Pass the values, or splat the whole StartResult with -StartResult.
        [Parameter(Mandatory, ParameterSetName = 'ByName')]
        [Parameter(Mandatory, ParameterSetName = 'ByObject')]
        [string]$Target,

        [Parameter()]
        [string]$Snapshot,

        [Parameter()]
        [string]$MediaDisk,

        [Parameter()]
        [ValidateSet('MediaDisk', 'FeatureUpdate')]
        [string]$Engine = 'MediaDisk',

        [Parameter()]
        [datetime]$StartedAt,

        [Parameter()]
        [ValidateRange(30, 10080)]
        [int]$TimeoutMinutes = 240,

        [Parameter()]
        [switch]$KeepMediaDisk,

        [Parameter()]
        [string]$LogIngestionEndpoint,

        [Parameter()]
        [string]$DataCollectionRuleId
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ByName') {
            $VM = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $Name -ErrorAction Stop
        }
        $vmName = [string]$VM.Name
        $rg = [string]$VM.ResourceGroupName

        $targetName = $Target
        $snapshotName = $Snapshot
        $engineTag = $Engine
        $status = $null
        $decision = $null
        $logExcerpt = $null
        $mediaRemoved = $null

        function ConvertTo-CompleteResult {
            param([string]$Result, [string]$Reason)
            [pscustomobject]@{
                PSTypeName        = 'AzureInPlaceUpgrade.CompleteResult'
                VMName            = $vmName
                ResourceGroupName = $rg
                Target            = $targetName
                Result            = $Result
                Reason            = $Reason
                Build             = if ($status) { $status.Build } else { $null }
                ProductName       = if ($status) { $status.ProductName } else { $null }
                TaskState         = if ($status) { $status.TaskState } else { $null }
                TaskResult        = if ($status -and $null -ne $status.TaskResult) { '0x{0:X8}' -f [uint32]$status.TaskResult } else { $null }
                AgeMinutes        = if ($decision) { $decision.AgeMinutes } else { $null }
                Snapshot          = $snapshotName
                MediaDiskRemoved  = $mediaRemoved
                LogExcerpt        = $logExcerpt
            }
        }

        $targetObject = Get-InPlaceUpgradeTarget -Name $targetName

        $startedAt = if ($PSBoundParameters.ContainsKey('StartedAt')) { $StartedAt } else { $null }
        if (-not $startedAt) { Write-Warning "[$vmName] No -StartedAt was given; the timeout cannot be applied." }

        $powerState = Get-VMPowerState -ResourceGroupName $rg -VMName $vmName
        if ($powerState -eq 'running') {
            Write-Verbose "[$vmName] Reading upgrade status from the guest."
            $status = Get-GuestUpgradeStatus -ResourceGroupName $rg -VMName $vmName
        }

        $decision = Resolve-UpgradeCompletion -Target $targetObject -PowerState $powerState -Status $status -StartedAt $startedAt -TimeoutMinutes $TimeoutMinutes
        Write-Verbose "[$vmName] $($decision.Result): $($decision.Reason)"

        $mediaRef = $MediaDisk
        $mediaDiskRg = $rg
        $mediaDiskName = Get-UpgradeMediaDiskName -VMName $vmName -Target $targetObject.Name
        if ($mediaRef -and $mediaRef.Contains('/')) {
            $mediaDiskRg = $mediaRef.Substring(0, $mediaRef.IndexOf('/'))
            $mediaDiskName = $mediaRef.Substring($mediaRef.IndexOf('/') + 1)
        }

        switch ($decision.Result) {
            'Completed' {
                if ($PSCmdlet.ShouldProcess($vmName, "Mark upgrade Completed ($($decision.Reason)) and remove the media disk")) {
                    if (-not (Remove-GuestUpgradeTask -ResourceGroupName $rg -VMName $vmName)) { Write-Verbose "[$vmName] Scheduled task could not be removed; harmless." }
                    if (-not $KeepMediaDisk) {
                        $cleanup = Remove-UpgradeMediaDisk -ResourceGroupName $rg -VMName $vmName -DiskResourceGroupName $mediaDiskRg -DiskName $mediaDiskName
                        $mediaRemoved = $cleanup.Removed
                        if (-not $cleanup.Removed) { Write-Warning "[$vmName] Media disk cleanup failed: $($cleanup.Error)" }
                    }
                }
            }
            'Failed' {
                if ($decision.TaskFailed -and $status) {
                    Write-Verbose "[$vmName] Fetching the Setup log excerpt."
                    $logExcerpt = Get-GuestSetupLogTail -ResourceGroupName $rg -VMName $vmName
                }
                if ($PSCmdlet.ShouldProcess($vmName, "Mark upgrade Failed ($($decision.Reason)) and remove the media disk")) {
                    if (-not $KeepMediaDisk) {
                        $cleanup = Remove-UpgradeMediaDisk -ResourceGroupName $rg -VMName $vmName -DiskResourceGroupName $mediaDiskRg -DiskName $mediaDiskName
                        $mediaRemoved = $cleanup.Removed
                        if (-not $cleanup.Removed) { Write-Warning "[$vmName] Media disk cleanup failed: $($cleanup.Error)" }
                    }
                }
            }
            default { }
        }

        $recordState = switch ($decision.Result) { 'Completed' { $script:State.Completed } 'Failed' { $script:State.Failed } default { $script:State.UpgradeStarted } }
        $record = ConvertTo-UpgradeRecord -VMName $vmName -ResourceGroupName $rg -State $recordState -Result $decision.Result -Reason $decision.Reason `
            -Target $targetObject.Name -Engine $(if ($engineTag) { $engineTag } elseif ($mediaRef) { 'MediaDisk' } else { 'FeatureUpdate' }) -TargetBuild $targetObject.TargetBuild `
            -SourceBuild $(if ($status) { $status.Build } else { $null }) -DurationMinutes $decision.AgeMinutes `
            -TaskResult $(if ($status -and $null -ne $status.TaskResult) { '0x{0:X8}' -f [uint32]$status.TaskResult } else { '' }) `
            -Snapshot $(if ($snapshotName) { $snapshotName } else { '' }) -MediaDisk $(if ($mediaRef) { $mediaRef } else { '' }) -Mode 'Check' `
            -LogExcerpt $(if ($logExcerpt) { $logExcerpt } else { '' })
        $null = Write-UpgradeRecord -Record $record -LogIngestionEndpoint $LogIngestionEndpoint -DataCollectionRuleId $DataCollectionRuleId

        return ConvertTo-CompleteResult $decision.Result $decision.Reason
    }
}