Public/Invoke-InPlaceUpgrade.ps1

function Invoke-InPlaceUpgrade {
    <#
    .SYNOPSIS
    Start an in-place upgrade and wait for it to finish, in one call

    .DESCRIPTION
    Full mode: Start-InPlaceUpgrade, then Complete-InPlaceUpgrade every -PollIntervalSeconds
    until the VM reaches Completed or Failed or -TimeoutMinutes pass. For local runs, Cloud Shell
    and Hybrid Runbook Workers. Do not use it in an Azure Automation cloud job: those are cancelled
    after three hours (ADR 0003); schedule Start and Complete separately there.

    .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 Target
    Target key from the matrix, for example WS2025. Defaults to the VM's UpgradeTarget tag.

    .PARAMETER Engine
    MediaDisk (default) or FeatureUpdate (experimental, WS2019/WS2022 only); see Start-InPlaceUpgrade.

    .PARAMETER MediaDiskResourceGroupName
    Resource group for the media disk. Defaults to the VM's resource group.

    .PARAMETER MediaDiskSkuName
    Storage SKU of the media disk.

    .PARAMETER ProductKey
    Explicit public KMS client setup key for setup.exe /pkey.

    .PARAMETER UseMatrixProductKey
    Pass the matrix's public KMS client setup key for the guest's edition as /pkey.

    .PARAMETER TargetImageIndex
    install.wim image index for setup.exe /installfrom and /imageindex.

    .PARAMETER MinimumFreeSpaceGB
    Free space required on C: by the preflight.

    .PARAMETER Force
    Proceed although the preflight reports NotEligible, and suppress the confirmation prompt.

    .PARAMETER KeepMediaDisk
    Keep the media disk after the final state.

    .PARAMETER TimeoutMinutes
    How long to wait for the upgrade before declaring it Failed.

    .PARAMETER PollIntervalSeconds
    Seconds between two guest checks.

    .PARAMETER LogIngestionEndpoint
    Logs ingestion endpoint for telemetry; see Start-InPlaceUpgrade.

    .PARAMETER DataCollectionRuleId
    Immutable id of the data collection rule; see Start-InPlaceUpgrade.

    .EXAMPLE
    # End to end on one lab VM, five-hour budget, checking every two minutes
    Invoke-InPlaceUpgrade -ResourceGroupName rg-ipu-lab-weu -Name vm-ipu-2022-01 -Target WS2025 -TimeoutMinutes 300 -Confirm:$false -Verbose

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

    .OUTPUTS
    AzureInPlaceUpgrade.StartResult when Start did not start anything, otherwise AzureInPlaceUpgrade.CompleteResult

    .NOTES
    Author: Simon Vedder (simonvedder.com)
    Version: 0.1.0
    Created: 2026-09-05
    LastModified: 2026-09-05
    RequiredPermissions: The union of Start-InPlaceUpgrade and Complete-InPlaceUpgrade
    Prerequisites: PowerShell 7.2+, Az.Compute, Az.Resources; an established Azure context; a session that may run for hours

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

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

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

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

        [Parameter(Mandatory)]
        [string]$Target,

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

        [Parameter()]
        [string]$MediaDiskResourceGroupName,

        [Parameter()]
        [ValidateSet('Standard_LRS', 'StandardSSD_LRS', 'Premium_LRS')]
        [string]$MediaDiskSkuName = 'Standard_LRS',

        [Parameter()]
        [ValidatePattern('^[A-Za-z0-9]{5}(-[A-Za-z0-9]{5}){4}$')]
        [string]$ProductKey,

        [Parameter()]
        [switch]$UseMatrixProductKey,

        [Parameter()]
        [ValidateRange(1, 99)]
        [int]$TargetImageIndex,

        [Parameter()]
        [ValidateRange(10, 200)]
        [int]$MinimumFreeSpaceGB = 30,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$KeepMediaDisk,

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

        [Parameter()]
        [ValidateRange(30, 900)]
        [int]$PollIntervalSeconds = 120,

        [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

        if ($Force) { $ConfirmPreference = 'None' }
        if (-not $PSCmdlet.ShouldProcess($vmName, "Upgrade in place and wait up to $TimeoutMinutes minutes")) { return }

        $startParams = @{
            Engine             = $Engine
            MediaDiskSkuName   = $MediaDiskSkuName
            MinimumFreeSpaceGB = $MinimumFreeSpaceGB
            Force              = $Force
            KeepMediaDisk      = $KeepMediaDisk
            UseMatrixProductKey = $UseMatrixProductKey
            Confirm            = $false
        }
        $startParams['Target'] = $Target
        if ($MediaDiskResourceGroupName) { $startParams['MediaDiskResourceGroupName'] = $MediaDiskResourceGroupName }
        if ($ProductKey) { $startParams['ProductKey'] = $ProductKey }
        if ($TargetImageIndex) { $startParams['TargetImageIndex'] = $TargetImageIndex }
        if ($LogIngestionEndpoint) { $startParams['LogIngestionEndpoint'] = $LogIngestionEndpoint }
        if ($DataCollectionRuleId) { $startParams['DataCollectionRuleId'] = $DataCollectionRuleId }

        $start = Start-InPlaceUpgrade -VM $VM @startParams
        if ($start.Result -ne 'Started') {
            Write-Verbose "[$vmName] Not started: $($start.Result) - $($start.Reason)"
            return $start
        }
        Write-Verbose "[$vmName] Started at $($start.StartedAt.ToString('u')); polling every $PollIntervalSeconds s for up to $TimeoutMinutes min."

        $deadline = (Get-Date).AddMinutes($TimeoutMinutes).AddSeconds($PollIntervalSeconds)
        $check = $null
        while ((Get-Date) -lt $deadline) {
            Start-Sleep -Seconds $PollIntervalSeconds
            $fresh = Get-AzVM -ResourceGroupName $rg -Name $vmName -ErrorAction Stop
            # The state stays in this process; nothing is read back off the VM.
            $completeParams = @{
                VM             = $fresh
                Target         = $Target
                Engine         = $Engine
                TimeoutMinutes = $TimeoutMinutes
                KeepMediaDisk  = $KeepMediaDisk
                Confirm        = $false
            }
            if ($start.Snapshot) { $completeParams['Snapshot'] = $start.Snapshot }
            if ($start.MediaDisk) { $completeParams['MediaDisk'] = $start.MediaDisk }
            if ($start.StartedAt) { $completeParams['StartedAt'] = $start.StartedAt }
            $check = Complete-InPlaceUpgrade @completeParams `
                -LogIngestionEndpoint $LogIngestionEndpoint -DataCollectionRuleId $DataCollectionRuleId
            Write-Verbose ("[{0}] {1:HH:mm:ss} {2}: {3}" -f $vmName, (Get-Date), $check.Result, $check.Reason)
            if ($check.Result -in @('Completed', 'Failed', 'Skipped')) { return $check }
        }
        return $check
    }
}