Private/Resolve-VmPowerAction.ps1
|
function Resolve-VmPowerAction { <# .SYNOPSIS Pure decision function: one machine in, one decision out. .DESCRIPTION Every rule this tool applies lives here, and nothing in here calls Azure. That is what makes the rules provable on fixtures, and it is what makes the dry run honest - the armed and disarmed paths compute the same decision and differ only in whether anybody acts on it. Rules are evaluated in order and the first match wins: 1. An exclusion tag protects the machine, whatever else is true. 2. A power state that cannot be read produces no action. Not knowing is not a reason to act. 3. A transitional state (starting, stopping, deallocating) produces no action. Something is already happening to this machine. 4. PowerState/stopped means allocated on a host, not running, and billed for compute. That is the machine to deallocate, and it wins over the schedule: somebody already shut this machine down, so the only question left is whether to keep paying for it. The next scheduled start brings it back if the schedule says so. 5. The schedule decides. Up and deallocated starts it; Down and running deallocates it; anything already in the state the schedule wants is left alone. 6. No schedule and nothing stranded means no rule applies. Rule 4 is the one with no running workload to interrupt, which is why it is the rule that ships first and the rule that can be armed first. .PARAMETER Machine One record from the Resource Graph inventory: id, name, powerState, tags. .PARAMETER ScheduleTag Tag key that opts a machine in. Its value names a schedule; version one only needs its presence. .PARAMETER ExclusionTag Tag key that protects a machine from every rule. .PARAMETER ScheduleState Desired state per schedule name, as Get-VmPowerScheduleState works it out: State, how many minutes ago the schedule last acted, and the schedule's start grace. Computed once per schedule by the caller rather than per machine, because a thousand machines usually share a handful of schedules. .PARAMETER IncludeUntagged Allow a decision on a machine carrying no schedule tag. Off by default, because opt-in is the rule (docs/decisions/0004) and a machine nobody has thought about is one nobody has decided about. A stranded machine is still reported when this is off; it is reported as not onboarded. #> [CmdletBinding()] [OutputType('AzureVMPowerManagement.VmPowerPlan')] param( [Parameter(Mandatory)] $Machine, [Parameter()] [string]$ScheduleTag = 'PowerSchedule', [Parameter()] [string]$ExclusionTag = 'PowerSchedule-Exclude', [Parameter()] [hashtable]$ScheduleState = @{}, [Parameter()] [switch]$IncludeUntagged ) $id = [string](Get-PropertyOrDefault -InputObject $Machine -Name 'id' -Default '') $name = [string](Get-PropertyOrDefault -InputObject $Machine -Name 'name' -Default '(unnamed)') $powerState = [string](Get-PropertyOrDefault -InputObject $Machine -Name 'powerState' -Default '') $tags = Get-PropertyOrDefault -InputObject $Machine -Name 'tags' -Default @{} # Azure is inconsistent about the case of a tag key, and Resource Graph hands tags back as a # PSCustomObject from Search-AzGraph and as a hashtable from a fixture. A lookup that misses # reads as "not tagged", which here would mean acting on a machine somebody excluded - so both # shapes are flattened into one case-insensitive table before anything is asked of them. $tagLookup = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) if ($tags -is [System.Collections.IDictionary]) { foreach ($key in @($tags.Keys)) { $tagLookup[[string]$key] = [string]$tags[$key] } } elseif ($null -ne $tags) { foreach ($property in @($tags.PSObject.Properties)) { $tagLookup[$property.Name] = [string]$property.Value } } $schedule = if ($tagLookup.ContainsKey($ScheduleTag)) { $tagLookup[$ScheduleTag] } else { '' } $excluded = $tagLookup.ContainsKey($ExclusionTag) $action, $reason, $explanation = switch ($true) { $excluded { 'None', 'Excluded', "Carries the $ExclusionTag tag, so no rule applies to it." break } ([string]::IsNullOrWhiteSpace($powerState)) { 'None', 'PowerStateUnknown', 'Resource Graph returned no power state for this machine. Not knowing is not a reason to act.' break } ($powerState -in $script:TransitionalPowerStates) { 'None', 'InTransition', "Power state is $powerState, so something is already happening to this machine." break } ($powerState -eq $script:PowerState.Stopped) { if ($schedule -or $IncludeUntagged) { 'Deallocate', 'StoppedNotDeallocated', 'Powered off but still allocated on a host, so still billed for compute. Deallocating it interrupts nothing that is running.' } else { 'None', 'StrandedButNotOnboarded', "Powered off but still allocated, so still billed for compute. It carries no $ScheduleTag tag, so it is reported rather than acted on." } break } ($schedule -and $ScheduleState.ContainsKey($schedule)) { $desired = $ScheduleState[$schedule] $wanted = [string]$desired.State $sinceMinutes = [int]$desired.MinutesSince $grace = [int]$desired.StartGraceMinutes switch ($true) { ($wanted -eq 'Up' -and $powerState -eq $script:PowerState.Deallocated -and $sinceMinutes -le $grace) { 'Start', 'ShouldBeRunning', "Schedule '$schedule' started machines $sinceMinutes minute(s) ago and this one is deallocated, so the start did not take." break } ($wanted -eq 'Up' -and $powerState -eq $script:PowerState.Deallocated) { 'None', 'DownSinceTheStartWindow', "Schedule '$schedule' wants it up, but its start was $sinceMinutes minute(s) ago, past the $grace minute grace. A machine that has been down that long was turned off on purpose - see docs/decisions/0007." break } ($wanted -eq 'Down' -and $powerState -eq $script:PowerState.Running) { 'Deallocate', 'ShouldBeStopped', "Schedule '$schedule' has it down at this time and it is running." break } ($wanted -eq 'Unknown') { 'None', 'ScheduleStateUnknown', "Schedule '$schedule' has no action in the lookback window, so there is nothing to compare against." break } default { 'None', 'MatchesSchedule', "Already $powerState, which is what schedule '$schedule' wants at this time." } } break } # [bool] is not decoration. switch ($true) does not coerce the way -eq does: $true -eq # 'office-hours-ch' is True, but ('office-hours-ch') as a case label never matches, because # switch compares the case value against the switch value as a string. Written without the # cast this branch is dead and the machine falls through to a reason that is not true. ([bool]$schedule) { 'None', 'ScheduleNotInCatalogue', "The tag names the schedule '$schedule', which is not in the catalogue this run was given. A machine is not acted on by a rule nobody can read." break } default { 'None', 'NoRuleMatched', "Power state is $powerState, the machine carries no schedule, and nothing else applies." } } [pscustomobject]@{ PSTypeName = $script:TypeName.Plan Id = $id Name = $name ResourceGroup = [string](Get-PropertyOrDefault -InputObject $Machine -Name 'resourceGroup' -Default '') SubscriptionId = [string](Get-PropertyOrDefault -InputObject $Machine -Name 'subscriptionId' -Default '') Location = [string](Get-PropertyOrDefault -InputObject $Machine -Name 'location' -Default '') VmSize = [string](Get-PropertyOrDefault -InputObject $Machine -Name 'vmSize' -Default '') PowerState = $powerState Schedule = if ($schedule) { $schedule } else { '' } Action = $action Reason = $reason Explanation = $explanation Protected = [bool]$excluded ProtectedReason = if ($excluded) { "Excluded by the $ExclusionTag tag" } else { '' } } } |