Private/Expand-VmPowerSchedule.ps1
|
function Expand-VmPowerSchedule { <# .SYNOPSIS Normalise a schedule into the long form the engine reads, or say precisely what is wrong. .DESCRIPTION The stored form is a list of actions, because that is how Microsoft.ComputeSchedule models one: a scheduled action carries exactly one actionType. "Office hours" is two actions, not a window. Authoring in that form is tedious, so a shorthand compiles into it and everything downstream - the calendar, the rules, and one day the handover to Azure - sees only the long form. Nothing here calls Azure, and nothing here guesses. A schedule that cannot be understood throws with the reason rather than being silently repaired into something nobody asked for. .PARAMETER Schedule A schedule as a hashtable or an object, in either the shorthand or the long form. #> [CmdletBinding()] [OutputType('AzureVMPowerManagement.VmPowerSchedule')] param( [Parameter(Mandatory)] $Schedule ) # A collection here means somebody handed over a list where one schedule belongs. Without this # the name comes out empty and the error blames the schedule for a mistake made two functions # earlier - which is exactly what happened on 2026-09-10. if ($Schedule -is [System.Collections.IEnumerable] -and $Schedule -isnot [string] -and $Schedule -isnot [System.Collections.IDictionary]) { throw 'Expand-VmPowerSchedule was given a collection. It takes one schedule; pass them one at a time.' } $raw = ConvertTo-VmPowerHashtable -InputObject $Schedule $name = [string]$raw['name'] # -cnotmatch, not -notmatch. PowerShell's -match is case-insensitive, so a lowercase-only # pattern silently accepts 'Office-Hours' - which is exactly the value this check exists to # reject, because it has to survive being a tag value and a policy allowedValues entry. if ($name -cnotmatch $script:ScheduleNamePattern) { throw ("Schedule name '$name' is not usable. It has to be 3 to 40 characters of lowercase " + 'letters, digits and hyphens, starting and ending with a letter or digit, because the name ' + 'is also a tag value and an allowedValues entry in the generated policy.') } $timeZoneId = [string]$raw['timeZone'] if (-not $timeZoneId) { throw "Schedule '$name' has no timeZone. There is no sensible default: a schedule without one is a schedule in somebody else's morning." } # Normalised to the Windows form, not stored as written. The sandbox that runs a schedule cannot # translate an IANA id - it has no CLDR data at all - while every platform that can author one # resolves a Windows id fine. So the conversion happens here, where ICU is available, and what # reaches the catalogue is what the runbook can read. See Resolve-VmPowerTimeZone and ADR 0008. try { $null = Resolve-VmPowerTimeZone -Id $timeZoneId } catch { throw "Schedule '$name': $($_.Exception.Message)" } $timeZoneId = ConvertTo-VmPowerPortableTimeZoneId -Id $timeZoneId # Shorthand first: weekdays '07:30-18:30' is the case almost everybody wants, and writing it as # two action entries by hand is how people end up with a stop and no start. $actions = [System.Collections.Generic.List[object]]::new() $shorthand = [string]$raw['weekdays'] $shorthandDaily = [string]$raw['daily'] if ($shorthand -and $shorthandDaily) { throw "Schedule '$name' sets both 'weekdays' and 'daily'. Pick one." } if (($shorthand -or $shorthandDaily) -and $raw['actions']) { throw "Schedule '$name' mixes a shorthand with an explicit 'actions' list. Pick one." } if ($shorthand -or $shorthandDaily) { $span = if ($shorthand) { $shorthand } else { $shorthandDaily } $days = if ($shorthand) { $script:WeekDayNames[0..4] } else { $script:WeekDayNames } if ($span -notmatch '^\s*(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})\s*$') { throw "Schedule '$name' has the span '$span', which is not 'HH:mm-HH:mm'." } $actions.Add(@{ action = 'Start'; at = $Matches[1]; weekDays = $days }) $actions.Add(@{ action = 'Deallocate'; at = $Matches[2]; weekDays = $days }) } else { foreach ($entry in @($raw['actions'])) { if ($null -eq $entry) { continue } $actions.Add((ConvertTo-VmPowerHashtable -InputObject $entry)) } } if (-not $actions.Count) { throw "Schedule '$name' has no actions. A schedule that does nothing is better expressed by not tagging the machine." } $normalised = [System.Collections.Generic.List[object]]::new() foreach ($entry in $actions) { $action = [string]$entry['action'] if ($action -notin $script:ScheduleActions) { throw "Schedule '$name' has the action '$action'. Allowed: $($script:ScheduleActions -join ', ')." } $at = [string]$entry['at'] if ($at -notmatch '^(\d{1,2}):(\d{2})$' -or [int]$Matches[1] -gt 23 -or [int]$Matches[2] -gt 59) { throw "Schedule '$name' has the time '$at' on its $action action. Expected HH:mm in 24-hour form." } $at = '{0:00}:{1:00}' -f [int]$Matches[1], [int]$Matches[2] $weekDays = Resolve-VmPowerNameList -Value $entry['weekDays'] -Allowed $script:WeekDayNames -Label "weekDays on the $action action of '$name'" $months = Resolve-VmPowerNameList -Value $entry['months'] -Allowed $script:MonthNames -Label "months on the $action action of '$name'" $daysOfMonth = @() foreach ($day in @($entry['daysOfMonth'])) { if ($null -eq $day -or "$day" -eq '') { continue } $number = 0 if (-not [int]::TryParse("$day", [ref]$number) -or $number -lt 1 -or $number -gt 31) { throw "Schedule '$name' has the day of month '$day' on its $action action. Expected 1 to 31." } $daysOfMonth += $number } $normalised.Add([pscustomobject]@{ Action = $action At = $at WeekDays = $weekDays Months = $months DaysOfMonth = @($daysOfMonth | Sort-Object -Unique) }) } $exceptDates = @() foreach ($date in @($raw['exceptDates'])) { if ($null -eq $date -or "$date" -eq '') { continue } $parsed = [datetime]::MinValue if (-not [datetime]::TryParseExact("$date", 'yyyy-MM-dd', [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$parsed)) { throw "Schedule '$name' has the exception date '$date'. Expected yyyy-MM-dd." } $exceptDates += $parsed.ToString('yyyy-MM-dd') } $dwell = 0 if ($null -ne $raw['minimumDwellMinutes'] -and "$($raw['minimumDwellMinutes'])" -ne '') { if (-not [int]::TryParse("$($raw['minimumDwellMinutes'])", [ref]$dwell) -or $dwell -lt 0 -or $dwell -gt 1440) { throw "Schedule '$name' has minimumDwellMinutes '$($raw['minimumDwellMinutes'])'. Expected 0 to 1440." } } # How long after a scheduled start this schedule keeps trying to start a machine that is down. # Inside it, a machine that is down is a start that did not work. Outside it, a machine that is # down is a machine somebody turned off - see docs/decisions/0007. $startGrace = 120 if ($null -ne $raw['startGraceMinutes'] -and "$($raw['startGraceMinutes'])" -ne '') { if (-not [int]::TryParse("$($raw['startGraceMinutes'])", [ref]$startGrace) -or $startGrace -lt 0 -or $startGrace -gt 1440) { throw "Schedule '$name' has startGraceMinutes '$($raw['startGraceMinutes'])'. Expected 0 to 1440." } } [pscustomobject]@{ PSTypeName = $script:TypeName.Schedule Name = $name DisplayName = if ($raw['displayName']) { [string]$raw['displayName'] } else { $name } TimeZone = $timeZoneId Actions = @($normalised) ExceptDates = @($exceptDates | Sort-Object -Unique) MinimumDwellMinutes = $dwell StartGraceMinutes = $startGrace } } |