Simon Vedder

Cloud Engineer · Zürich

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.

Deploy to Azure
Bicep · subscription scopeneeds Owner on the subscription, or Contributor plus User Access Administrator
# 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 $false

One 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.

  1. 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.

  2. 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.

  3. 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.

Powered off beats the schedule. Somebody shut that machine down from inside the guest. Starting it back would fight them, and it is billed until it is deallocated — so the controller stops paying for it and leaves it alone. The next scheduled start brings it back, if the schedule says so and the start is recent. Decision record
No holiday calendar ships with it. Switzerland alone has cantonal holidays, and a calendar that looks authoritative and is wrong is worse than none. -ExceptDate is yours to fill in. Decision record
The catalogue lives in two variables. The deployment owns and overwrites one; nothing but Set-VmPowerSchedule writes the other, and a custom entry wins on a name collision. That is why a redeployment cannot eat your schedules. Decision record

← Overview·Prerequisites·Commands