Public/Power/Get-VmKvpIpAddress.ps1

# ---------------------------------------------------------------------------
# Get-VmKvpIpAddress
# Polls Hyper-V's KVP integration services (Get-VMNetworkAdapter +
# .IPAddresses) until the supplied VM reports an IPv4 address on the
# requested switch, then returns that address. Throws on deadline.
#
# Why this lives in the module:
# The provisioner's create-vm.ps1 needs to wait for a DHCP-mode
# router VM's upstream IP before SSH-probing it; the E2E harness
# needs to do the same on the test side because the discovered IP
# never propagates back across the child-process boundary
# provision.ps1 ran behind. Both call sites converged on the same
# polling loop with the same "is the VM still Running?" guard, the
# same IPv4-vs-IPv6 filter, and the same deadline/timeout error
# surface - so it belongs in one place.
#
# What the caller still owns:
# - The UX. -OnPoll fires once per no-IP-yet iteration so the
# provisioner can paint Write-Host dots and the E2E harness can
# stay silent without forking two near-identical helpers.
# - The "is this VM static or DHCP?" branch. This helper assumes
# the caller already decided IP discovery is needed; it does not
# inspect $Vm.ipAddress itself.
# - Stamping the result back onto a VM def via Add-Member when the
# caller wants reference-shared downstream access. Returning a
# plain string keeps the helper testable without object-identity
# semantics.
#
# -SwitchName is the multi-NIC discriminator. Router VMs have two
# adapters (one on the external switch, one on a per-environment
# private switch); without -SwitchName the helper would race-pick
# whichever NIC KVP reported first. When -SwitchName is omitted the
# first adapter wins (the typical workload case where the VM has a
# single NIC).
# ---------------------------------------------------------------------------
function Get-VmKvpIpAddress {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string] $VmName,

        # If provided, only return IPv4 addresses from adapters whose
        # SwitchName matches. Required for multi-NIC VMs; omit for
        # single-NIC workloads.
        [Parameter()]
        [string] $SwitchName,

        # Total wall-clock budget for the poll. 5 min covers a slow
        # upstream DHCP lease while staying well under the provisioner's
        # outer "wait for SSH" budget.
        [Parameter()]
        [int] $TimeoutMinutes = 5,

        # Cadence between KVP reads. 2 s avoids hammering
        # Get-VMNetworkAdapter without delaying the success path more
        # than one tick past the lease arriving.
        [Parameter()]
        [int] $PollIntervalSeconds = 2,

        # Fired once per "no IP yet" iteration so the caller can drive
        # progress UX (a Write-Host dot, a log event, ...) without the
        # helper having to know about the consumer's output style.
        [Parameter()]
        [scriptblock] $OnPoll
    )

    Assert-HyperVModuleLoaded

    $deadline     = (Get-Date).AddMinutes($TimeoutMinutes)
    $discoveredIp = $null

    while ((Get-Date) -lt $deadline -and -not $discoveredIp) {
        # KVP only publishes data for a Running VM, so a stopped VM
        # would loop silently until the deadline expires. Surface that
        # case immediately with a specific message rather than a 5-min
        # "did not report" timeout that obscures the cause.
        $vmState = (Get-VM -Name $VmName).State
        if ($vmState -ne 'Running') {
            throw (
                "Hyper-V VM '$VmName' is not Running (state: $vmState). " +
                "KVP integration services only publish IP addresses " +
                "while a VM is running."
            )
        }

        $adapters = @(Get-VMNetworkAdapter -VMName $VmName)
        if ($SwitchName) {
            $adapters = @($adapters | Where-Object {
                $_.SwitchName -eq $SwitchName
            })
        }
        if ($adapters.Count -gt 0) {
            # IPv6 addresses (fe80::, etc.) get reported alongside IPv4
            # the moment the link comes up - filter them out so we wait
            # for the actual lease rather than returning a link-local.
            $discoveredIp = @($adapters[0].IPAddresses) |
                Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' } |
                Select-Object -First 1
        }
        if (-not $discoveredIp) {
            if ($null -ne $OnPoll) { & $OnPoll }
            Start-Sleep -Seconds $PollIntervalSeconds
        }
    }

    if (-not $discoveredIp) {
        $where = if ($SwitchName) {
            " on switch '$SwitchName'"
        } else { '' }
        throw (
            "VM '$VmName'$where did not report an IPv4 address via " +
            "Hyper-V KVP within $TimeoutMinutes minute(s). Check that " +
            "the VM is running, its NIC is attached to the expected " +
            "switch, and the upstream DHCP server is reachable."
        )
    }

    $discoveredIp
}