VM Power Management
One deployment, disarmed
Same module twice — on your machine it only looks, inside the Automation Account it decides. Subscription scope, because a custom role definition lives there. The runbook runs every hour from the moment it lands, decides everything, writes every decision to the job log, and touches no machine until you set one variable. Five steps from nothing to the first machine on a schedule.
# 1. Deploy the controller, disarmed.
git clone https://github.com/simon-vedder/azure-vm-power-management && cd azure-vm-power-management
az deployment sub create --location westeurope --template-file deploy/main.bicep \
--parameters moduleVersion=0.1.6-preview maximumActions=25 targetResourceGroupName=rg-target
# 2. Pick a schedule. office-hours-ch and always-on ship with the deployment; your own is one line.
New-VmPowerSchedule -Name office-hours-de -TimeZone 'Europe/Berlin' -Weekdays '08:00-19:00' |
Set-VmPowerSchedule -ResourceGroupName rg-vm-power-management-weu -AutomationAccountName aa-vm-power-management-weu
# 3. Opt one machine in. Nothing without this tag is ever touched.
az vm update --ids $VM --set tags.PowerSchedule=office-hours-ch
# 4. Leave it disarmed for a week. The job log and the workbook say what it would have done.
# 5. Arm it. One variable, not a redeployment.
Set-AzAutomationVariable -ResourceGroupName rg-vm-power-management-weu -AutomationAccountName aa-vm-power-management-weu `
-Name PM_Armed -Value $true -Encrypted $falseOne deployment creates
- An Automation Account with a system-assigned identity, the module imported from the PowerShell Gallery, and the runbook published on an hourly schedule
- A custom role with four actions and no more: read a VM, start it, deallocate it, list resource groups. Virtual Machine Contributor could also install extensions, which is code execution as SYSTEM or root on every machine in scope
- Every setting as an Automation variable, so arming it and changing the blast radius are portal edits rather than redeployments
- Two Azure Policy definitions generated from your own schedule catalogue: one catches a tag naming a schedule that does not exist, one lists the machines nobody has tagged. Both audit, neither has an identity
- A Log Analytics workspace and the workbook on the overview page. Nothing on your VMs, and no agent
targetResourceGroupName scopes the role to one resource group; leave it empty and it assigns at the subscription. Start with one. The controller discovers across every subscription the identity can read, and points the Azure context at each machine's own subscription before touching it — Stop-AzVM takes no subscription and would otherwise act wherever the context happened to point.
Under the hood
How it decides, every hour
One tag whose value is a name, not five AutoShutdown tags encoding a small language nothing validates. One edit fixes a wrong schedule for every machine that follows it, the value becomes an enum a policy can enforce, and the right to write tags stops meaning the right to decide when production shuts down.
One trigger, many schedules
An Azure Automation schedule cannot run more often than hourly, and it does not need to. One heartbeat wakes the controller, it reads the catalogue, and each schedule works out what it wants right now. Adding a schedule is a variable edit, not another trigger.
Decide, in functions that never call Azure
Every rule lives in one pure function on one machine record. That 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.
Four guards, then Azure performs it
Armed or not. More machines than you allowed, and the whole run is refused rather than half-done. Acted on too recently. Carrying the exclusion tag. Each refusal is written down with its reason, so "why didn't it stop last night" has an answer.
-ExceptDate is yours to fill in. Decision recordSet-VmPowerSchedule writes the other, and a custom entry wins on a name collision. That is why a redeployment cannot eat your schedules. Decision record