Public/Get-AzLocalLatestSolutionVersion.ps1

function Get-AzLocalLatestSolutionVersion {
    <#
    .SYNOPSIS
        Queries the Microsoft Azure Edge Updates manifest to determine the latest released
        Azure Local solution version and the resulting "rolling N-month support window".
 
    .DESCRIPTION
        Azure Local solution-bundle versions follow the format <prefix>.YYMM.X.X (e.g.
        Solution12.2604.1003.1005). Microsoft publishes the catalog of currently-applicable
        solution bundles at the unauthenticated endpoint https://aka.ms/AzureEdgeUpdates (an
        XML manifest under the root element ASZSolutionBundleUpdates).
 
        This cmdlet downloads that manifest, parses the Version strings under both
        ApplicableUpdate/UpdateInfo and PackageMetadata/ServicesUpdates/Update/UpdateInfo,
        extracts the YYMM token (validated as YY=20-99 and MM=01-12), takes the highest
        YYMM as the anchor for the support window, and computes the supported YYMM list by
        stepping back month-by-month (calendar arithmetic, so 2601 -> 2512 -> 2511, etc.).
 
        The intent is to drive the SupportStatus column in the Step.6 Fleet Update Status
        pipeline: as soon as Microsoft publishes any release with a newer YYMM, the window
        slides forward and the oldest in-window YYMM falls out - even if no cluster in the
        fleet has installed the new release yet.
 
        Output is a single PSCustomObject. On any failure (network unreachable, non-2xx,
        malformed XML, no parseable YYMM tokens), the function throws a structured error;
        callers (typically the Step.6 pipeline YAML) are expected to wrap the call in
        try/catch and fall back to the legacy fleet-observed window.
 
    .PARAMETER ManifestUrl
        URL of the Azure Edge Updates manifest. Defaults to https://aka.ms/AzureEdgeUpdates
        (the official Microsoft-curated catalog). Override only for offline testing or
        air-gapped mirrors.
 
    .PARAMETER SupportWindowMonths
        Number of months in the rolling support window, inclusive of the latest YYMM.
        Defaults to 6 (i.e. LatestYYMM and the five preceding months). Must be between 1
        and 24.
 
    .PARAMETER TimeoutSeconds
        HTTP timeout in seconds for the manifest fetch. Defaults to 30.
 
    .OUTPUTS
        PSCustomObject with the following properties:
          - LatestYYMM : string (e.g. '2604') - the highest YYMM in the manifest
          - LatestVersion : string - the highest full version string at LatestYYMM
          - SupportedYYMMs : string[] - the N most-recent YYMM strings, newest first
          - AllReleases : PSCustomObject[] - UpdateName, Version, Yymm, Source (xpath)
          - ManifestUrl : string - the URL fetched
          - ManifestFetchedAt : DateTime - UTC timestamp of the successful fetch
          - SupportWindowMonths : int - the requested window size
          - Source : string - constant 'aka.ms/AzureEdgeUpdates'
 
    .EXAMPLE
        Get-AzLocalLatestSolutionVersion
        # Returns: LatestYYMM=2604, SupportedYYMMs=@('2604','2603','2602','2601','2512','2511'), ...
 
    .EXAMPLE
        # Manual SupportStatus classification for a cluster's CurrentVersion
        $manifest = Get-AzLocalLatestSolutionVersion
        $clusterYymm = ($cluster.CurrentVersion -split '\.')[1]
        if ($manifest.SupportedYYMMs -contains $clusterYymm) { 'Supported' } else { 'Unsupported' }
 
    .EXAMPLE
        # Used by Step.7_fleet-update-status pipeline; falls back to fleet-observed top-6
        # if the manifest is unreachable
        try {
            $m = Get-AzLocalLatestSolutionVersion -ErrorAction Stop
            $supportedYymms = $m.SupportedYYMMs
            $supportSource = 'Microsoft manifest'
        } catch {
            $supportedYymms = $fleetObservedYymms # legacy fallback
            $supportSource = 'fleet-observed'
        }
 
    .NOTES
        Author : Neil Bird, Microsoft
        Version : v0.7.70
        Added : v0.7.70 (Phase E - rolling support window anchored on Microsoft manifest)
        Endpoint : https://aka.ms/AzureEdgeUpdates (unauthenticated, public catalog)
        Network : Single GET request; respects $env:HTTPS_PROXY via Invoke-WebRequest defaults.
        Rate limits : None observed; manifest is small (~tens of KB) and CDN-fronted.
 
    .LINK
        https://learn.microsoft.com/azure/azure-local/whats-new
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [ValidatePattern('^https?://')]
        [string]$ManifestUrl = 'https://aka.ms/AzureEdgeUpdates',

        [Parameter()]
        [ValidateRange(1, 24)]
        [int]$SupportWindowMonths = 6,

        [Parameter()]
        [ValidateRange(5, 300)]
        [int]$TimeoutSeconds = 30
    )

    # ------------------------------------------------------------------
    # 1) Fetch the manifest
    # ------------------------------------------------------------------
    Write-Verbose "Fetching Azure Edge Updates manifest from $ManifestUrl (timeout=${TimeoutSeconds}s)."
    $fetchedAt = [DateTime]::UtcNow
    try {
        $response = Invoke-WebRequest -Uri $ManifestUrl -UseBasicParsing -TimeoutSec $TimeoutSeconds -ErrorAction Stop
    }
    catch {
        throw "Failed to fetch Azure Edge Updates manifest from '$ManifestUrl': $($_.Exception.Message)"
    }

    if (-not $response -or $response.StatusCode -lt 200 -or $response.StatusCode -ge 300) {
        $code = if ($response) { $response.StatusCode } else { '<no response>' }
        throw "Azure Edge Updates manifest fetch returned HTTP $code (expected 2xx)."
    }

    # ------------------------------------------------------------------
    # 2) Parse XML
    # ------------------------------------------------------------------
    $rawText = $null
    if ($response.Content -is [byte[]]) {
        $rawText = [System.Text.Encoding]::UTF8.GetString($response.Content)
    }
    else {
        $rawText = [string]$response.Content
    }

    if ([string]::IsNullOrWhiteSpace($rawText)) {
        throw "Azure Edge Updates manifest at '$ManifestUrl' returned an empty body."
    }

    [xml]$manifest = $null
    try {
        $manifest = [xml]$rawText
    }
    catch {
        throw "Azure Edge Updates manifest at '$ManifestUrl' is not valid XML: $($_.Exception.Message)"
    }

    if (-not $manifest.ASZSolutionBundleUpdates) {
        throw "Azure Edge Updates manifest at '$ManifestUrl' has no <ASZSolutionBundleUpdates> root element."
    }

    # ------------------------------------------------------------------
    # 3) Collect all Version + UpdateName pairs from the two known UpdateInfo locations
    # ------------------------------------------------------------------
    $releases = New-Object System.Collections.Generic.List[object]

    $applicable = $manifest.ASZSolutionBundleUpdates.ApplicableUpdate
    if ($applicable) {
        foreach ($u in @($applicable.UpdateInfo)) {
            if ($null -ne $u -and $u.Version) {
                $releases.Add([PSCustomObject]@{
                    UpdateName = [string]$u.UpdateName
                    Version    = [string]$u.Version
                    Source     = 'ApplicableUpdate.UpdateInfo'
                })
            }
        }
    }

    $services = $manifest.ASZSolutionBundleUpdates.PackageMetadata.ServicesUpdates
    if ($services) {
        foreach ($svc in @($services.Update)) {
            if ($null -ne $svc -and $svc.UpdateInfo -and $svc.UpdateInfo.Version) {
                $releases.Add([PSCustomObject]@{
                    UpdateName = [string]$svc.UpdateInfo.UpdateName
                    Version    = [string]$svc.UpdateInfo.Version
                    Source     = 'PackageMetadata.ServicesUpdates.Update.UpdateInfo'
                })
            }
        }
    }

    if ($releases.Count -eq 0) {
        throw "Azure Edge Updates manifest at '$ManifestUrl' contains no parseable UpdateInfo entries with a Version attribute."
    }

    # ------------------------------------------------------------------
    # 4) Annotate each release with its YYMM (first dotted token matching YY=20-99 & MM=01-12)
    # ------------------------------------------------------------------
    $yymmRegex = '^[0-9]{4}$'
    foreach ($r in $releases) {
        $parts = ([string]$r.Version) -split '\.'
        $foundYymm = $null
        foreach ($p in $parts) {
            if ($p -match $yymmRegex) {
                $yy = [int]$p.Substring(0, 2)
                $mm = [int]$p.Substring(2, 2)
                if ($yy -ge 20 -and $yy -le 99 -and $mm -ge 1 -and $mm -le 12) {
                    $foundYymm = $p
                    break
                }
            }
        }
        $r | Add-Member -MemberType NoteProperty -Name Yymm -Value $foundYymm -Force
    }

    $withYymm = @($releases | Where-Object { $_.Yymm })
    if ($withYymm.Count -eq 0) {
        throw "Azure Edge Updates manifest at '$ManifestUrl' contains UpdateInfo entries but none have a YYMM token in their Version string (expected format: <prefix>.YYMM.X.X)."
    }

    # ------------------------------------------------------------------
    # 5) Identify LatestYYMM and the highest full Version string at that YYMM
    # ------------------------------------------------------------------
    $latestYymm = ($withYymm | Sort-Object -Property Yymm -Descending | Select-Object -First 1).Yymm
    $atLatest = @($withYymm | Where-Object { $_.Yymm -eq $latestYymm })
    # Pick the highest Version string at LatestYYMM. Parse with [version] when possible
    # (strip the leading alpha prefix from the first token); fall back to string compare.
    $latestVersion = ($atLatest | Sort-Object -Property @{
        Expression = {
            $vstr = ($_.Version -replace '^[A-Za-z]+', '')
            try { [version]$vstr } catch { $null }
        }
    }, @{
        Expression = 'Version'
    } -Descending | Select-Object -First 1).Version

    # ------------------------------------------------------------------
    # 6) Compute the rolling support window by stepping the calendar back
    # ------------------------------------------------------------------
    $supportedYymms = New-Object System.Collections.Generic.List[string]
    $yy = [int]$latestYymm.Substring(0, 2)
    $mm = [int]$latestYymm.Substring(2, 2)
    for ($i = 0; $i -lt $SupportWindowMonths; $i++) {
        $supportedYymms.Add(('{0:D2}{1:D2}' -f $yy, $mm))
        # Decrement one month
        $mm--
        if ($mm -lt 1) { $mm = 12; $yy-- }
        # Guard against unrealistic underflow (YY < 20)
        if ($yy -lt 20) { break }
    }

    # ------------------------------------------------------------------
    # 7) Emit
    # ------------------------------------------------------------------
    $allReleases = @($releases | Sort-Object -Property Yymm, Version -Descending)
    return [PSCustomObject]@{
        LatestYYMM          = $latestYymm
        LatestVersion       = $latestVersion
        SupportedYYMMs      = $supportedYymms.ToArray()
        AllReleases         = $allReleases
        ManifestUrl         = $ManifestUrl
        ManifestFetchedAt   = $fetchedAt
        SupportWindowMonths = $SupportWindowMonths
        Source              = 'aka.ms/AzureEdgeUpdates'
    }
}