Private/Convert-AzLocalUpdateWindowToCron.ps1

function Convert-AzLocalUpdateWindowToCron {
    <#
    .SYNOPSIS
        Derives the recommended cron expression(s) needed to fire an apply-updates
        pipeline at the opening edge of every maintenance window encoded in an
        UpdateStartWindow tag value.
    .DESCRIPTION
        Used by Test-AzLocalApplyUpdatesScheduleCoverage. Reuses the existing
        ConvertFrom-AzLocalUpdateWindow parser, then for each parsed segment:
 
          - computes the fire time = StartTime - LeadTimeMinutes
            (with day wrap when the fire time goes negative)
          - converts the DayOfWeek[] set to cron DoW notation
            (Sun=0, Mon=1, ..., Sat=6 - contiguous sets emit ranges, others emit comma lists)
          - emits one cron string '<M> <H> * * <DoW>' per window opening edge
 
        Same-day window: fire at (start - lead) on each day in the set.
        Overnight window: the window opens on the listed day(s); fire only on the
                           opening edge - the runtime gate (Test-AzLocalUpdateScheduleAllowed)
                           handles the wrap into the next day.
 
        Multi-segment windows like 'Mon-Fri_22:00-04:00;Sat-Sun_02:00-10:00'
        produce one cron string per segment.
 
        Belt-and-braces (FiresPerWindow=2, default 1 for back-compat):
        When -FiresPerWindow 2 is supplied, each segment ALSO emits a second
        "retry" cron INSIDE the window. The retry fire time is computed as
        the lesser of:
          - the window midpoint (StartTime + (WindowDurationMinutes / 2)), or
          - 60 minutes after the window opens.
        The cap keeps retries quick on long windows (a 24h window retries at
        +60min, not at +12h). Day-shift handling mirrors the opening-edge
        logic but in the FORWARD direction (e.g. a 23:30-00:30 window with
        a 30min retry offset retries at 00:00 the NEXT day).
        Each row's IsRetry property tags whether it is the opening-edge cron
        (IsRetry=$false, first row per segment) or a retry cron
        (IsRetry=$true, subsequent rows).
    .PARAMETER UpdateStartWindow
        The raw UpdateStartWindow tag value.
    .PARAMETER LeadTimeMinutes
        Minutes before the window opens that the pipeline should fire. Default 7
        to avoid crowded five-minute scheduler boundaries.
    .PARAMETER FiresPerWindow
        How many cron entries to emit per window segment. Default 1 (opening
        edge only - back-compat for v0.7.91 and earlier callers). Set to 2
        for the belt-and-braces pattern: opening edge + one mid-window
        retry. Range 1-2; values above 2 are reserved for future use.
    .OUTPUTS
        PSCustomObject[] - one per window segment when FiresPerWindow=1, two
        per segment when FiresPerWindow=2. Each row carries:
            Segment - the raw window segment string
            Days - DayOfWeek[]
            CronDoWSet - sorted int[] (cron 0-6) of firing days
            CronExpression - '<M> <H> * * <DoW>' string suitable for GitHub Actions / ADO
            FireMinute - int 0-59
            FireHour - int 0-23
            DayShift - $true if the fire time landed on a different
                             day from the window opening (lead-time pushed
                             backward, or retry offset pushed forward)
            IsRetry - $false for the opening-edge cron, $true for the
                             belt-and-braces mid-window retry
    .EXAMPLE
        Convert-AzLocalUpdateWindowToCron -UpdateStartWindow 'Sat-Sun_02:00-06:00'
        # Returns one row, CronExpression = '53 1 * * 6,0'
    .EXAMPLE
        Convert-AzLocalUpdateWindowToCron -UpdateStartWindow 'Mon-Fri_20:00-23:00' -FiresPerWindow 2
        # Returns two rows:
        # '53 19 * * 1-5' (IsRetry=$false, opening edge minus 7min lead)
        # '53 20 * * 1-5' (IsRetry=$true, de-contented retry before the +60min target)
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$UpdateStartWindow,

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 60)]
        [int]$LeadTimeMinutes = 7,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 2)]
        [int]$FiresPerWindow = 1
    )

    # DayOfWeek enum value -> cron DoW int. Cron: Sun=0, Mon=1, ..., Sat=6 -
        # this also matches the .NET DayOfWeek enum numeric values.
    $dowToCron = @{
        [System.DayOfWeek]::Sunday    = 0
        [System.DayOfWeek]::Monday    = 1
        [System.DayOfWeek]::Tuesday   = 2
        [System.DayOfWeek]::Wednesday = 3
        [System.DayOfWeek]::Thursday  = 4
        [System.DayOfWeek]::Friday    = 5
        [System.DayOfWeek]::Saturday  = 6
    }

    $parsed = ConvertFrom-AzLocalUpdateWindow -WindowString $UpdateStartWindow

    $output = New-Object System.Collections.Generic.List[PSCustomObject]
    foreach ($w in $parsed) {
        # Compute fire time = StartTime - LeadTimeMinutes. If this crosses
        # midnight backwards, push each firing day back by one (e.g. Mon 00:05
        # window with 10min lead fires at Sun 23:55).
        $startMinutes = ($w.StartTime.Hours * 60) + $w.StartTime.Minutes
        $fireMinutes  = $startMinutes - $LeadTimeMinutes
        $dayShift     = $false
        if ($fireMinutes -lt 0) {
            $fireMinutes += (24 * 60)
            $dayShift = $true
        }
        $fireHour   = [int]([math]::Floor($fireMinutes / 60))
        $fireMinute = $fireMinutes - ($fireHour * 60)

        # Translate firing days (with optional shift) to cron DoW ints.
        $cronDows = New-Object System.Collections.Generic.List[int]
        foreach ($d in $w.Days) {
            $cronDow = $dowToCron[$d]
            if ($dayShift) {
                $cronDow = ($cronDow + 6) % 7   # shift back one day, wrap Sun->Sat
            }
            $cronDows.Add($cronDow)
        }
        $cronDowSet = @($cronDows | Sort-Object -Unique)

        # Render DoW set: a contiguous range becomes '<a>-<b>', otherwise a comma list.
        # Special-case Sun (0) merged with Sat (6) - the parser may emit {0,6}
        # which is logically Sat-Sun but cron can't express a wrap range, so
        # emit as '6,0' (Sat first, then Sun) to read like the human tag value
        # 'Sat-Sun'. Cron treats day lists as unordered so '6,0' and '0,6' are
        # equivalent at runtime - this is purely cosmetic.
        $dowStr = if ($cronDowSet.Count -eq 1) {
            "$($cronDowSet[0])"
        }
        elseif ($cronDowSet.Count -eq 2 -and $cronDowSet[0] -eq 0 -and $cronDowSet[1] -eq 6) {
            '6,0'
        }
        elseif ($cronDowSet.Count -gt 1 -and ($cronDowSet[-1] - $cronDowSet[0]) -eq ($cronDowSet.Count - 1)) {
            "$($cronDowSet[0])-$($cronDowSet[-1])"
        }
        else {
            ($cronDowSet -join ',')
        }

        $output.Add([PSCustomObject]@{
            Segment        = $w.Raw
            Days           = $w.Days
            CronDoWSet     = $cronDowSet
            CronExpression = "$fireMinute $fireHour * * $dowStr"
            FireMinute     = $fireMinute
            FireHour       = $fireHour
            DayShift       = $dayShift
            IsRetry        = $false
        })

        # Belt-and-braces retry cron (FiresPerWindow=2). Fires INSIDE the
        # window at min(midpoint, +60min after open) so:
        # - GitHub Actions scheduled-workflow jitter (~15 min) cannot
        # cause the opening-edge cron to miss the window entirely
        # without a second chance,
        # - a transient first-fire failure (auth, runner exhaustion,
        # module install hiccup) is retried while the gate is still
        # open,
        # - long windows do not wait half the window for the retry
        # (a 24h window retries at +60min, not at +12h).
        # Test-AzLocalUpdateScheduleAllowed + the in-flight guard ensure
        # the retry never double-triggers a cluster whose first run is
        # already in progress.
        if ($FiresPerWindow -ge 2) {
            # Window duration in minutes, supporting overnight wrap.
            $endMinutes = ($w.EndTime.Hours * 60) + $w.EndTime.Minutes
            $windowMinutes = $endMinutes - $startMinutes
            if ($windowMinutes -le 0) { $windowMinutes += (24 * 60) }

            $retryOffset = [int][math]::Min(($windowMinutes / 2), 60)
            # Retry must be strictly after the opening edge; if a tiny
            # window (<= 2min) collapses the offset to 0, skip the retry
            # rather than emit a duplicate cron.
            if ($retryOffset -le 0) { continue }

            # Prefer seven minutes before the natural retry target so generated
            # schedules do not land on crowded :00/:05 boundaries. For short
            # windows, clamp to one minute after opening so the retry remains
            # strictly inside the window.
            $retryFireMinutes = [math]::Max(($startMinutes + 1), ($startMinutes + $retryOffset - 7))
            $retryDayShift    = $false
            if ($retryFireMinutes -ge (24 * 60)) {
                $retryFireMinutes -= (24 * 60)
                $retryDayShift = $true
            }
            $retryHour   = [int]([math]::Floor($retryFireMinutes / 60))
            $retryMinute = $retryFireMinutes - ($retryHour * 60)

            # Retry day shift is FORWARD (next day) - opposite to the
            # lead-time shift which is BACKWARD (previous day).
            $retryDows = New-Object System.Collections.Generic.List[int]
            foreach ($d in $w.Days) {
                $cronDow = $dowToCron[$d]
                if ($retryDayShift) {
                    $cronDow = ($cronDow + 1) % 7
                }
                $retryDows.Add($cronDow)
            }
            $retryDowSet = @($retryDows | Sort-Object -Unique)

            $retryDowStr = if ($retryDowSet.Count -eq 1) {
                "$($retryDowSet[0])"
            }
            elseif ($retryDowSet.Count -eq 2 -and $retryDowSet[0] -eq 0 -and $retryDowSet[1] -eq 6) {
                '6,0'
            }
            elseif ($retryDowSet.Count -gt 1 -and ($retryDowSet[-1] - $retryDowSet[0]) -eq ($retryDowSet.Count - 1)) {
                "$($retryDowSet[0])-$($retryDowSet[-1])"
            }
            else {
                ($retryDowSet -join ',')
            }

            $output.Add([PSCustomObject]@{
                Segment        = $w.Raw
                Days           = $w.Days
                CronDoWSet     = $retryDowSet
                CronExpression = "$retryMinute $retryHour * * $retryDowStr"
                FireMinute     = $retryMinute
                FireHour       = $retryHour
                DayShift       = $retryDayShift
                IsRetry        = $true
            })
        }
    }

    # WARNING: Callers MUST use direct assignment ($x = func ...) and NEVER
    # wrap with @(func ...). The unary-comma return below preserves Object[N]
    # shape for any N including 0 and 1, but @() at the call site collapses
    # to Object[1] containing the inner array, silently producing one-row
    # output instead of N rows. See `docs/MODULE-REVIEW-AND-RECOMMENDATIONS.md`
    # Finding 1 for the v0.7.75 incident.
    return , $output.ToArray()
}