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 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. 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. .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 it changed power state more recently than this. Zero disables the check. .PARAMETER LastActionAt Map of resource id to the time this tool last acted on it, for the dwell check. The runbook passes what it recorded on the previous run. .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 } } if (-not $skip -and $MinimumDwellMinutes -gt 0 -and $LastActionAt.ContainsKey($item.Id)) { $since = $now - ([datetime]$LastActionAt[$item.Id]).ToUniversalTime() if ($since.TotalMinutes -lt $MinimumDwellMinutes) { $skip = "Acted on $([int]$since.TotalMinutes) minute(s) ago, inside the $MinimumDwellMinutes 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 ($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 } } } } |