Public/Invoke-VmPowerPlan.ps1
|
function Invoke-VmPowerPlan { <# .SYNOPSIS Carry out a plan, after the guards agree to it .DESCRIPTION Takes the decisions Get-VmPowerPlan produced and performs the ones the guards allow. The plan is computed once and consumed here, so an armed run and a dry run cannot drift apart. Four guards stand between a plan and a machine, and all four refuse loudly rather than quietly: Confirm Every action goes through ShouldProcess, so -WhatIf previews the whole run. Protected A machine carrying the exclusion tag is never touched, whatever the plan says. Blast radius A run that would act on more machines than -MaximumActions performs none of them. A mistake broad enough to matter becomes a report, not an incident. Dwell A machine this tool acted on within -MinimumDwellMinutes is left alone. Azure bills a five-minute minimum per start, so a flapping schedule costs money as well as being wrong. It needs -LastActionAt: with no memory of the previous run there is nothing to compare against, and the guard stands down. Deallocation is the only action version one performs, and it uses Stop-AzVM, which requests a graceful shutdown and then releases the host. A machine already in PowerState/stopped has no running operating system to shut down, so in practice this is a pure deallocate. The Az context is pointed at each machine's own subscription before it is acted on. Stop-AzVM and Start-AzVM take no subscription: they act wherever the context happens to point, while discovery answers for every subscription the identity can read. Left alone, a plan spanning three subscriptions fails on two of them - or worse, finds a machine of the same name in the same resource group name somewhere else and deallocates that one instead. .PARAMETER Plan Decisions from Get-VmPowerPlan. Items with an action of None are counted and skipped. .PARAMETER MaximumActions Refuse the entire run if more than this many machines would be acted on. There is no default that is right for somebody else's estate, so this is mandatory. .PARAMETER MinimumDwellMinutes Leave a machine alone if this tool acted on it more recently than this. Zero switches the check off entirely, whatever a schedule asks for. Above zero it is a floor: a schedule carrying its own minimumDwellMinutes can ask for longer, never shorter. A guard the thing being guarded can weaken is not a guard. .PARAMETER LastActionAt Map of resource id to the time this tool last acted on it, for the dwell check. Values may be DateTime or a round-trip string. Empty - the default - means there is no memory of a previous run, and the dwell check has nothing to compare against. The runbook passes what it recorded in the Automation variable PM_LastActionAt. .EXAMPLE # Preview everything an armed run would do, and change nothing Get-VmPowerPlan -ActionableOnly | Invoke-VmPowerPlan -MaximumActions 50 -WhatIf .EXAMPLE # Deallocate the machines that are powered off and still billed, at most twenty of them Get-VmPowerPlan -ActionableOnly | Invoke-VmPowerPlan -MaximumActions 20 .INPUTS AzureVMPowerManagement.VmPowerPlan .OUTPUTS AzureVMPowerManagement.VmPowerPlanResult .NOTES RequiredPermissions: Microsoft.Compute/virtualMachines/deallocate/action and Microsoft.Compute/virtualMachines/read on every machine in scope, or their resource groups. Virtual Machine Contributor covers both and a great deal more, so a custom role limited to those two actions plus Microsoft.Compute/virtualMachines/start/action is the narrower choice and is what deploy/main.bicep creates. Prerequisites: PowerShell 7.2 or later, and the modules Az.Accounts and Az.Compute. Writes: Deallocates virtual machines. This is the command that can cost somebody an outage, which is why every guard above defaults to refusing. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType('AzureVMPowerManagement.VmPowerPlanResult')] param( [Parameter(Mandatory, ValueFromPipeline)] [PSTypeName('AzureVMPowerManagement.VmPowerPlan')] [object[]]$Plan, [Parameter(Mandatory)] [ValidateRange(1, 10000)] [int]$MaximumActions, [Parameter()] [ValidateRange(0, 1440)] [int]$MinimumDwellMinutes = 30, [Parameter()] [hashtable]$LastActionAt = @{} ) begin { $collected = [System.Collections.Generic.List[object]]::new() } process { foreach ($item in @($Plan)) { if ($null -ne $item) { $collected.Add($item) } } } end { $actionable = @($collected | Where-Object { $_.Action -ne 'None' -and -not $_.Protected }) $now = [datetime]::UtcNow # The blast radius is checked against the whole plan before anything happens. Checking it # per machine would let a run act on the first n and then stop, which is the worst of both. if ($actionable.Count -gt $MaximumActions) { throw ("The plan would act on $($actionable.Count) machine(s), more than the $MaximumActions allowed. " + 'Nothing was done. Raise -MaximumActions if this is expected, or look at the plan first: ' + 'a jump in this number usually means a tag or a schedule changed, not that the estate did.') } foreach ($item in $collected) { $skip = switch ($true) { ($item.Protected) { $item.ProtectedReason; break } ($item.Action -eq 'None') { $item.Reason; break } default { $null } } # The run-wide setting is a floor, not the last word: a schedule whose machines take # twenty minutes to come up may ask for a longer dwell than the default thirty. It # cannot ask for a shorter one. Zero on the run-wide setting means off, and a schedule # cannot turn it back on either - the operator's switch beats the author's. $dwell = 0 if ($MinimumDwellMinutes -gt 0) { $dwell = $MinimumDwellMinutes if ($item.PSObject.Properties.Name -contains 'MinimumDwellMinutes' -and $null -ne $item.MinimumDwellMinutes) { $dwell = [Math]::Max($dwell, [int]$item.MinimumDwellMinutes) } } if (-not $skip -and $dwell -gt 0 -and $LastActionAt.ContainsKey($item.Id)) { # An unreadable timestamp leaves the machine alone and says so. Treating it as # "no memory" would silently stand the guard down, which is the one outcome a # corrupt safety store must not produce. $lastUtc = $null try { $lastUtc = ConvertTo-VmPowerUtcTime -Value $LastActionAt[$item.Id] } catch { $skip = "The dwell store holds '$($LastActionAt[$item.Id])' for this machine, which is not a time. Nothing was done to it." } if ($lastUtc) { $since = $now - $lastUtc if ($since.TotalMinutes -lt $dwell) { $skip = "Acted on $([int]$since.TotalMinutes) minute(s) ago, inside the $dwell minute dwell" } } } if ($skip) { [pscustomobject]@{ PSTypeName = $script:TypeName.Result Id = $item.Id Name = $item.Name Action = $item.Action Status = 'Skipped' Detail = $skip Timestamp = $now } continue } $target = "$($item.Name) in $($item.ResourceGroup)" if (-not $PSCmdlet.ShouldProcess($target, "$($item.Action) - $($item.Reason)")) { [pscustomobject]@{ PSTypeName = $script:TypeName.Result Id = $item.Id Name = $item.Name Action = $item.Action Status = 'WhatIf' Detail = $item.Explanation Timestamp = $now } continue } $status, $detail = try { Switch-VmPowerSubscriptionContext -SubscriptionId ([string]$item.SubscriptionId) switch ($item.Action) { 'Deallocate' { $null = Stop-AzVM -ResourceGroupName $item.ResourceGroup -Name $item.Name -Force -ErrorAction Stop 'Done', $item.Explanation } 'Start' { $null = Start-AzVM -ResourceGroupName $item.ResourceGroup -Name $item.Name -ErrorAction Stop 'Done', $item.Explanation } default { 'Skipped', "No handler for action $($item.Action)" } } } catch { # One machine failing is not a reason to abandon the rest, and the reason has to # survive into the ledger or nobody can answer why a machine is still running. 'Failed', $_.Exception.Message } [pscustomobject]@{ PSTypeName = $script:TypeName.Result Id = $item.Id Name = $item.Name Action = $item.Action Status = $status Detail = $detail Timestamp = $now } } } } |