Public/Resolve-AzLocalSideloadPlan.ps1

function Resolve-AzLocalSideloadPlan {
    <#
    .SYNOPSIS
        Builds the per-cluster sideload plan: which solution-update version each
        UpdateAuthAccountId-tagged Azure Local cluster should have sideloaded, and
        whether it is due within the configured lead window.
 
    .DESCRIPTION
        Public planner for the v0.8.7 on-prem sideloading automation. It does NOT
        change any state - it only reads the fleet (Azure Resource Graph), the
        apply-updates schedule, the auth map and the catalog, then emits one plan
        object per cluster (plus error rows for misconfigurations such as an
        unknown auth account id, a version not in the allow-list, or a missing
        catalog entry).
 
        The plan objects feed Invoke-AzLocalSideloadUpdate (the re-entrant Step.6
        state machine). Selection reuses Select-AzLocalNextUpdateForCluster so the
        sideload path and the apply path agree on which update is "next".
 
    .PARAMETER SchedulePath
        Path to the apply-updates schedule YAML (Get-AzLocalApplyUpdatesScheduleConfig).
 
    .PARAMETER AuthMapPath
        Path to the sideload auth-map CSV (Get-AzLocalSideloadAuthMap).
 
    .PARAMETER CatalogPath
        Path to the sideload catalog YAML (Get-AzLocalSideloadCatalog).
 
    .PARAMETER LeadDays
        How many days before a cluster's next apply window the media should be
        sideloaded. A cluster is "due" when (nextWindow - LeadDays) <= Now.
 
    .PARAMETER UpdateRingValue
        Optional UpdateRing tag filter (single, list, or '***' wildcard for all
        rings). When omitted, all clusters carrying an UpdateAuthAccountId tag are
        considered.
 
    .PARAMETER FqdnSuffix
        Global default FQDN suffix appended to a cluster name to form the remoting
        host when the auth-map row does not override it.
 
    .PARAMETER Now
        The reference time (UTC). Defaults to now.
 
    .PARAMETER SubscriptionId
        Optional subscription scope for the Resource Graph query.
 
    .PARAMETER ClusterResourceId
        Exact ARM cluster ID. Requires exactly one eligible result; never falls
        back to fleet scope.
 
    .PARAMETER SingleClusterValidation
        Bypasses lead-time eligibility for one cluster, not version policy.
        Requires ClusterResourceId and ValidationUpdateName. Policy is resolved
        for the next matching ring day, even when that day is outside lead time.
 
    .PARAMETER ValidationUpdateName
        Exact Ready update name to validate. Does not override the allow-list.
 
    .OUTPUTS
        [PSCustomObject[]] plan + error rows.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string]$SchedulePath,
        [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string]$AuthMapPath,
        [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string]$CatalogPath,
        [Parameter(Mandatory = $false)][ValidateRange(0, 365)][int]$LeadDays = 7,
        [Parameter(Mandatory = $false)][string]$UpdateRingValue = '***',
        [Parameter(Mandatory = $false)][string]$FqdnSuffix = '',
        [Parameter(Mandatory = $false)][datetime]$Now = [datetime]::UtcNow,
        [Parameter(Mandatory = $false)][string]$SubscriptionId,
        [Parameter(Mandatory = $false)]
        [ValidatePattern('^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.AzureStackHCI/clusters/[^/]+$')]
        [string]$ClusterResourceId,
        [Parameter(Mandatory = $false)][switch]$SingleClusterValidation,
        [Parameter(Mandatory = $false)][string]$ValidationUpdateName
    )

    if ($SingleClusterValidation -and ([string]::IsNullOrWhiteSpace($ClusterResourceId) -or [string]::IsNullOrWhiteSpace($ValidationUpdateName))) {
        throw 'Single-cluster validation requires ClusterResourceId and ValidationUpdateName.'
    }
    if ($ValidationUpdateName -and -not $SingleClusterValidation) {
        throw 'ValidationUpdateName requires SingleClusterValidation.'
    }

    $config = Get-AzLocalApplyUpdatesScheduleConfig -Path $SchedulePath
    $authMap = Get-AzLocalSideloadAuthMap -Path $AuthMapPath
    $catalog = @(Get-AzLocalSideloadCatalog -Path $CatalogPath)

    $ringFilter = ConvertTo-AzLocalUpdateRingKqlFilter -UpdateRingValue $UpdateRingValue
    $globalTagFilter = Get-AzLocalClusterTagFilterKqlClause
    $argQuery = "resources | where type =~ 'microsoft.azurestackhci/clusters' $globalTagFilter | where isnotempty(tags['UpdateAuthAccountId']) $ringFilter | project id, name, resourceGroup, subscriptionId, tags"
    if ($ClusterResourceId) {
        $escapedClusterId = $ClusterResourceId.Replace('\', '\\').Replace("'", "\'")
        $argQuery += " | where id =~ '$escapedClusterId'"
    }

    $clusterRows = if ($PSBoundParameters.ContainsKey('SubscriptionId') -and $SubscriptionId) {
        Invoke-AzResourceGraphQuery -Query $argQuery -SubscriptionId $SubscriptionId
    }
    else {
        Invoke-AzResourceGraphQuery -Query $argQuery
    }

    if ($ClusterResourceId) {
        $clusterRows = @($clusterRows | Where-Object { [string]$_.id -eq $ClusterResourceId })
        if ($clusterRows.Count -ne 1) {
            throw 'Single-cluster sideload scope must resolve exactly one eligible cluster. Check resource ID, auth tag, ring filter, and fleet scope.'
        }
    }

    # Pre-compute the next 366 days of firings so we can find each cluster's next window.
    $firings = @(Get-AzLocalApplyUpdatesScheduleNextFirings -Schedule $config -StartDate $Now.Date -Days 366)

    $results = New-Object System.Collections.Generic.List[object]

    foreach ($cluster in $clusterRows) {
        $clusterName = [string]$cluster.name
        $accountId = [string]$cluster.tags.UpdateAuthAccountId
        $ringTag = [string]$cluster.tags.UpdateRing

        $row = [ordered]@{
            ClusterName         = $clusterName
            ClusterResourceId   = [string]$cluster.id
            ResourceGroup       = [string]$cluster.resourceGroup
            SubscriptionId      = [string]$cluster.subscriptionId
            UpdateAuthAccountId = $accountId
            Ring                = $ringTag
            NextWindowUtc       = $null
            LeadDays            = $LeadDays
            DueNow              = $false
            AllowedUpdateVersions = @()
            SingleClusterValidation = [bool]$SingleClusterValidation
            SelectedVersion     = ''
            SelectedUpdateName  = ''
            PackageType         = ''
            CatalogEntry        = $null
            RemotingHost        = ''
            TargetPath          = ''
            AuthRow             = $null
            Status              = 'Planned'
            Message             = ''
        }

        # Auth-map row.
        if (-not $authMap.ContainsKey($accountId)) {
            $row.Status = 'UnknownAuthAccountId'
            $row.Message = "Cluster '$clusterName' has UpdateAuthAccountId '$accountId' which is not present in the auth map."
            $results.Add([PSCustomObject]$row); continue
        }
        $authRow = $authMap[$accountId]
        $row.AuthRow = $authRow

        # Remoting host + target path.
        $remotingHost = if (-not [string]::IsNullOrWhiteSpace([string]$authRow.RemotingTargetFqdn)) {
            [string]$authRow.RemotingTargetFqdn
        }
        else {
            $suffix = if (-not [string]::IsNullOrWhiteSpace([string]$authRow.FqdnSuffix)) { [string]$authRow.FqdnSuffix } else { $FqdnSuffix }
            if (-not [string]::IsNullOrWhiteSpace($suffix)) { ('{0}{1}' -f $clusterName, $suffix) } else { $clusterName }
        }
        $row.RemotingHost = $remotingHost
        $row.TargetPath = Resolve-AzLocalSideloadTargetPath -RemotingHost $remotingHost -ImportSharePath ([string]$authRow.ImportSharePath)

        # Next apply window for this cluster's ring.
        $nextFiring = $firings | Where-Object {
            $_.Rings -and ($_.Rings -contains $ringTag -or $_.Rings -contains '***') -and ($_.DateUtc -ge $Now.Date)
        } | Sort-Object DateUtc | Select-Object -First 1
        if ($nextFiring) {
            $row.NextWindowUtc = $nextFiring.DateUtc
            $dueDate = ([datetime]$nextFiring.DateUtc).AddDays(-$LeadDays)
            $row.DueNow = ($Now -ge $dueDate)
        }

        if (-not $nextFiring) {
            $row.Status = 'NotScheduled'
            $row.Message = 'No matching future ring schedule was found. No sideload operation is planned.'
            $results.Add([PSCustomObject]$row); continue
        }
        $ringInfo = Resolve-AzLocalCurrentUpdateRing -Schedule $config -Now $nextFiring.DateUtc -UpdateRingValue $ringTag
        $allowed = @($ringInfo.AllowedUpdateVersions)
        $row.AllowedUpdateVersions = $allowed
        if ($SingleClusterValidation) { $row.DueNow = $true }

        # Available Ready updates -> adapter shape for Select-AzLocalNextUpdateForCluster.
        $available = @(Get-AzLocalAvailableUpdates -ClusterResourceId ([string]$cluster.id) -ErrorAction SilentlyContinue)
        $readyAdapters = @(
            $available | Where-Object { [string]$_.UpdateState -eq 'Ready' } | ForEach-Object {
                [PSCustomObject]@{
                    name        = [string]$_.UpdateName
                    PackageType = [string]$_.PackageType
                    properties  = [PSCustomObject]@{ version = [string]$_.Version; state = [string]$_.UpdateState }
                }
            }
        )

        if ($SingleClusterValidation) {
            $readyAdapters = @($readyAdapters | Where-Object { $_.name -eq $ValidationUpdateName })
            if ($readyAdapters.Count -ne 1) {
                $row.Status = 'UpdateNotFound'
                $row.Message = 'The exact validation update must be offered in Ready state. No fallback update is selected.'
                $results.Add([PSCustomObject]$row); continue
            }
        }
        $selection = Select-AzLocalNextUpdateForCluster -ReadyUpdates $readyAdapters -AllowedUpdateVersions $allowed
        switch ($selection.Reason) {
            'Selected' {
                $sel = $selection.SelectedUpdate
                $version = [string]$sel.properties.version
                if ([string]::IsNullOrWhiteSpace($version)) { $version = [string]$sel.name }
                $row.SelectedVersion = $version
                $row.SelectedUpdateName = [string]$sel.name
                $row.PackageType = [string]$sel.PackageType

                $entry = $catalog | Where-Object {
                    [string]$_.Version -eq $version -and
                    (-not $row.PackageType -or [string]$_.PackageType -eq $row.PackageType)
                } | Select-Object -First 1
                if ($null -eq $entry) {
                    $row.Status = 'NoCatalogEntry'
                    $row.Message = "Selected version '$version' for cluster '$clusterName' has no matching package-type entry in the sideload catalog."
                }
                else {
                    $row.CatalogEntry = $entry
                    if (-not $row.PackageType) { $row.PackageType = [string]$entry.PackageType }
                    $row.Status = if ($row.DueNow) { 'Planned' } else { 'NotDue' }
                    $row.Message = if ($row.DueNow) {
                        "Cluster '$clusterName' due for sideload of '$version' (window $($row.NextWindowUtc))."
                    }
                    else {
                        "Cluster '$clusterName' not yet within the $LeadDays-day lead window for '$version'."
                    }
                }
            }
            'NotInAllowList' {
                $row.Status = 'NotInAllowList'
                $row.Message = "No Ready update for cluster '$clusterName' matches the allow-list [$($selection.AllowDisplay)]. Ready: [$($selection.ReadyDisplay)]."
            }
            'NoneReady' {
                $row.Status = 'NoneReady'
                $row.Message = "Cluster '$clusterName' has no Ready updates available."
            }
            default {
                $row.Status = [string]$selection.Reason
                $row.Message = "Cluster '$clusterName' selection reason: $($selection.Reason)."
            }
        }

        $results.Add([PSCustomObject]$row)
    }

    return $results.ToArray()
}