I few notes before I continue:
- I base this script off from Automys own code, and it is heavily inspired by the commits done by other users out there in the community. I will refer to the project on GitHub where you will find contributors and authors.
- I've only tested and used the script for ARM Resources
- I removed references to credentials and certificates and it relies on using "Run As Account". Setting up "Run As Account" in Azure is very easy and quick to do.
You will find the Feature branch here:
Setup
I recommend starting by creating a new Automation Account. Yes, you can probably reuse an existing one, but creating a new account does not incur additional costs, and you can get this up and running fairly quick and easy just by following the steps in this blog post.Make sure you select "Yes" on the option of creating "Azure Run as Account". Let it create all the artifacts and while you wait you can read the rest of this post.
When the Automation account is up and running, the next step is to create a new Runbook of type "PowerShell" - just straight up PowerShell, and no fancy stuff.
Then you grab the script from my feature branch based off the original trunk. You can either take the script from this post, or take the latest from GitHub. I probably won't maintain this blog post on any future updates of the script, but I might maintain the one on GitHub. I'll put a copy down below.
So with the script added as a PowerShell Runbook, and saved. Now you need to Schedule it. This is where a small cost may incur, because it is necessary to set the Runbook to run every hour. Yes - every hour. Using Automation for free only allow for a limited number of runs, and with the Runbook running every hour throughout the day, I believe it will stop running after 20 days - per month. There is a 500 minute limit per month for free, but the cost incurred when you exceed this is extremely low.
With the script running every hour you are ready to schedule "downtime". And this is easy.
You basically just either TAG the VM or the Resource Group holding a collection of VMs.
By TAG I mean you type on the downtime you want for your resource in the VALUE of a specific TAG. The script looks for a tag named "AutoShutdownSchedule". Example of value would be "20:00->06:00, Saturday, Sunday", and you can probably guess when the server will be shutdown with that value... That is correct, all weekdays between 8 pm at night and 6 am in the morning. You can imagine the flexibility this gives.
Added Features
In addition, the script is inspired by other nice ideas from the community, like providing a TimeZone for your schedule, just to ensure your 8 pm is consistent to when the script interprets the value.Another feature added is the ability to use a "NeverStart" value keyword, to enforce the resource does not start. You can use this to schedule automatic shutdown that does not trigger startup again after the schedule ends. Example is the value "20:00->21:00,NeverStart". This would stop the resource at 8 pm, and when the RunBook runs again at 9 pm, the resource will not start even though the schedule has ended.
Finally, I want to comment the added feature of disabling the schedule without removing the schedule. If you provide an additional tag with the name "AutoShutdownDisabled" with a value of Yes/1/True. This means you can keep the schedule and temporarily disable the shutdown schedule altogether.
The script
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 | < # .SYNOPSIS This Azure Automation runbook automates the scheduled shutdown and startup of resources in an Azure subscription. .DESCRIPTION The runbook implements a solution for scheduled power management of Azure resources in combination with tags on resources or resource groups which define a shutdown schedule. Each time it runs, the runbook looks for all supported resources or resource groups with a tag named "AutoShutdownSchedule" having a value defining the schedule, e.g. "10PM -> 6AM" . It then checks the current time against each schedule entry, ensuring that resourcess with tags or in tagged groups are deallocated/shut down or started to conform to the defined schedule. This is a PowerShell runbook, as opposed to a PowerShell Workflow runbook. This script requires the "AzureRM.Resources" modules which are present by default in Azure Automation accounts. For detailed documentation and instructions, see: CREDITS: Initial version credits goes to automys from which this script started : .PARAMETER Simulate If $true , the runbook will not perform any power actions and will only simulate evaluating the tagged schedules. Use this to test your runbook to see what it will do when run normally (Simulate = $false ). .PARAMETER DefaultScheduleIfNotPresent If provided, will set the default schedule to apply on all resources that don't have any scheduled tag value defined or inherited. Description | Tag value Shut down from 10PM to 6 AM UTC every day | 10pm -> 6am Shut down from 10PM to 6 AM UTC every day (different format, same result as above) | 22:00 -> 06:00 Shut down from 8PM to 12AM and from 2AM to 7AM UTC every day (bringing online from 12-2AM for maintenance in between) | 8PM -> 12AM, 2AM -> 7AM Shut down all day Saturday and Sunday (midnight to midnight) | Saturday, Sunday Shut down from 2AM to 7AM UTC every day and all day on weekends | 2:00 -> 7:00, Saturday, Sunday Shut down on Christmas Day and New Year ? s Day | December 25, January 1 Shut down from 2AM to 7AM UTC every day, and all day on weekends, and on Christmas Day | 2:00 -> 7:00, Saturday, Sunday, December 25 Shut down always ? I don ? t want this VM online, ever | 0:00 -> 23:59:59 .PARAMETER TimeZone Defines the Timezone used when running the runbook. "GMT Standard Time" by default. Microsoft Time Zone Index Values: .EXAMPLE For testing examples, see the documentation at: .INPUTS None. .OUTPUTS Human -readable informational and error messages produced during the job. Not intended to be consumed by another runbook. #> [CmdletBinding()] param( [parameter(Mandatory= $false )] [bool] $Simulate = $false , [parameter(Mandatory= $false )] [string] $DefaultScheduleIfNotPresent , [parameter(Mandatory= $false )] [String] $Timezone = "W. Europe Standard Time" ) $VERSION = '3.3.0' $autoShutdownTagName = 'AutoShutdownSchedule' $autoShutdownOrderTagName = 'ProcessingOrder' $autoShutdownDisabledTagName = 'AutoShutdownDisabled' $defaultOrder = 1000 $ResourceProcessors = @( @{ ResourceType = 'Microsoft.ClassicCompute/virtualMachines' PowerStateAction = { param([object] $Resource , [string] $DesiredState ) (Get -AzureRmResource -ResourceId $Resource .ResourceId).Properties.InstanceView.PowerState } StartAction = { param([string] $ResourceId ) Invoke -AzureRmResourceAction -ResourceId $ResourceId -Action 'start' -Force } DeallocateAction = { param([string] $ResourceId ) Invoke -AzureRmResourceAction -ResourceId $ResourceId -Action 'shutdown' -Force } }, @{ ResourceType = 'Microsoft.Compute/virtualMachines' PowerStateAction = { param([object] $Resource , [string] $DesiredState ) $vm = Get -AzureRmVM -ResourceGroupName $Resource .ResourceGroupName -Name $Resource .Name -Status $currentStatus = $vm .Statuses | Where-Object Code -like 'PowerState*' $currentStatus .Code -replace 'PowerState/' , '' } StartAction = { param([string] $ResourceId ) Invoke -AzureRmResourceAction -ResourceId $ResourceId -Action 'start' -Force } DeallocateAction = { param([string] $ResourceId ) Invoke -AzureRmResourceAction -ResourceId $ResourceId -Action 'deallocate' -Force } }, @{ ResourceType = 'Microsoft.Compute/virtualMachineScaleSets' #since there is no way to get the status of a VMSS, we assume it is in the inverse state to force the action on the whole VMSS PowerStateAction = { param([object] $Resource , [string] $DesiredState ) if( $DesiredState -eq 'StoppedDeallocated' ) { 'Started' } else { 'StoppedDeallocated' } } StartAction = { param([string] $ResourceId ) Invoke -AzureRmResourceAction -ResourceId $ResourceId -Action 'start' -Parameters @{ instanceIds = @( '*' ) } -Force } DeallocateAction = { param([string] $ResourceId ) Invoke -AzureRmResourceAction -ResourceId $ResourceId -Action 'deallocate' -Parameters @{ instanceIds = @( '*' ) } -Force } } ) # Define function to get current date using the TimeZone Paremeter function GetCurrentDate { return [system.timezoneinfo]::ConvertTime($( Get-Date ),$([system.timezoneinfo]::GetSystemTimeZones() | ? id -eq $Timezone )) } # Define function to check current time against specified range function Test -ScheduleEntry ([string] $TimeRange ) { # Initialize variables $rangeStart , $rangeEnd , $parsedDay = $null $currentTime = GetCurrentDate $midnight = $currentTime .AddDays(1).Date try { # Parse as range if contains '->' if( $TimeRange -like '*->*' ) { $timeRangeComponents = $TimeRange -split '->' | foreach {$_.Trim()} if( $timeRangeComponents .Count -eq 2) { $rangeStart = Get-Date $timeRangeComponents [0] $rangeEnd = Get-Date $timeRangeComponents [1] # Check for crossing midnight if( $rangeStart -gt $rangeEnd ) { # If current time is between the start of range and midnight tonight, interpret start time as earlier today and end time as tomorrow if( $currentTime -ge $rangeStart -and $currentTime -lt $midnight ) { $rangeEnd = $rangeEnd .AddDays(1) } # Otherwise interpret start time as yesterday and end time as today else { $rangeStart = $rangeStart .AddDays(-1) } } } else { Write -Output "`tWARNING: Invalid time range format. Expects valid .Net DateTime-formatted start time and end time separated by '->'" } } # Otherwise attempt to parse as a full day entry, e.g. 'Monday' or 'December 25' else { # If specified as day of week, check if today if([System.DayOfWeek].GetEnumValues() -contains $TimeRange ) { if( $TimeRange -eq ( Get-Date ).DayOfWeek) { $parsedDay = Get-Date '00:00' } else { # Skip detected day of week that isn't today } } # Otherwise attempt to parse as a date, e.g. 'December 25' else { $parsedDay = Get-Date $TimeRange } if( $parsedDay -ne $null ) { $rangeStart = $parsedDay # Defaults to midnight $rangeEnd = $parsedDay .AddHours(23).AddMinutes(59).AddSeconds(59) # End of the same day } } } catch { # Record any errors and return false by default Write -Output "`tWARNING: Exception encountered while parsing time range. Details: $($_.Exception.Message). Check the syntax of entry, e.g. '<starttime> -> <endtime>', or days/dates like 'Sunday' and 'December 25'" return $false } # Check if current time falls within range if( $currentTime -ge $rangeStart -and $currentTime -le $rangeEnd ) { return $true } else { return $false } } # End function Test-ScheduleEntry # Function to handle power state assertion for resources function Assert -ResourcePowerState { param( [Parameter(Mandatory= $true )] [object] $Resource , [Parameter(Mandatory= $true )] [string] $DesiredState , [bool] $Simulate ) $processor = $ResourceProcessors | Where-Object ResourceType -eq $Resource .ResourceType if( -not $processor ) { throw ( 'Unable to find a resource processor for type ' '{0}' '. Resource: {1}' -f $Resource .ResourceType, ( $Resource | ConvertTo -Json -Depth 5000)) } # If should be started and isn't, start resource $currentPowerState = & $processor .PowerStateAction -Resource $Resource -DesiredState $DesiredState if( $DesiredState -eq 'Started' -and $currentPowerState -notmatch 'Started|Starting|running' ) { if( $Simulate ) { Write -Output "`tSIMULATION -- Would have started resource. (No action taken)" } else { Write -Output "`tStarting resource" & $processor .StartAction -ResourceId $Resource .ResourceId } } # If should be stopped and isn't, stop resource elseif( $DesiredState -eq 'StoppedDeallocated' -and $currentPowerState -notmatch 'Stopped|deallocated' ) { if( $Simulate ) { Write -Output "`tSIMULATION -- Would have stopped resource. (No action taken)" } else { Write -Output "`tStopping resource" & $processor .DeallocateAction -ResourceId $Resource .ResourceId } } # Otherwise, current power state is correct else { Write -Output "`tCurrent power state [$($currentPowerState)] is correct." } } # Main runbook content try { $currentTime = GetCurrentDate Write -Output "Runbook started. Version: $VERSION" if( $Simulate ) { Write -Output '*** Running in SIMULATE mode. No power actions will be taken. ***' } else { Write -Output '*** Running in LIVE mode. Schedules will be enforced. ***' } Write -Output "Current UTC/GMT time [$($currentTime.ToString('dddd, yyyy MMM dd HH:mm:ss'))] will be checked against schedules" $Conn = Get -AutomationConnection -Name AzureRunAsConnection $resourceManagerContext = Add -AzureRMAccount -ServicePrincipal -Tenant $Conn .TenantID -ApplicationId $Conn .ApplicationID -CertificateThumbprint $Conn .CertificateThumbprint $resourceList = @() # Get a list of all supported resources in subscription $ResourceProcessors | % { Write -Output ( 'Looking for resources of type {0}' -f $_.ResourceType) $resourceList += @(Find -AzureRmResource -ResourceType $_.ResourceType) } $ResourceList | % { if($_.Tags -and $_.Tags.ContainsKey( $autoShutdownOrderTagName ) ) { $order = $_.Tags | % { if($_.ContainsKey( $autoShutdownOrderTagName )) { $_.Item( $autoShutdownOrderTagName ) } } } else { $order = $defaultOrder } Add-Member -InputObject $_ -Name ProcessingOrder -MemberType NoteProperty -TypeName Integer -Value $order } $ResourceList | % { if($_.Tags -and $_.Tags.ContainsKey( $autoShutdownDisabledTagName ) ) { $disabled = $_.Tags | % { if($_.ContainsKey( $autoShutdownDisabledTagName )) { $_.Item( $autoShutdownDisabledTagName ) } } } else { $disabled = '0' } Add-Member -InputObject $_ -Name ScheduleDisabled -MemberType NoteProperty -TypeName String -Value $disabled } # Get resource groups that are tagged for automatic shutdown of resources $taggedResourceGroups = Find -AzureRmResourceGroup -Tag @{ "AutoShutdownSchedule" = $null } $taggedResourceGroupNames = @( $taggedResourceGroups | select Name) Write -Output "Found [$($taggedResourceGroupNames.Count)] schedule-tagged resource groups in subscription" if( $DefaultScheduleIfNotPresent ) { Write -Output "Default schedule was specified, all non tagged resources will inherit this schedule: $DefaultScheduleIfNotPresent" } # For each resource, determine # - Is it directly tagged for shutdown or member of a tagged resource group # - Is the current time within the tagged schedule # Then assert its correct power state based on the assigned schedule (if present) Write -Output "Processing [$($resourceList.Count)] resources found in subscription" foreach ( $resource in $resourceList ) { $schedule = $null if ( $resource .ScheduleDisabled) { $disabledValue = $resource .ScheduleDisabled if ( $disabledValue -eq "1" -or $disabledValue -eq "Yes" -or $disabledValue -eq "True" ) { Write -Output "[$($resource.Name)]: `r`n`tIGNORED -- Found direct resource schedule with $autoShutdownDisabledTagName value: $disabledValue." continue } } # Check for direct tag or group-inherited tag if( $resource .Tags.Count -gt 0 -and $resource .Tags.ContainsKey( $autoShutdownTagName ) -eq $true ) { # Resource has direct tag (possible for resource manager deployment model resources). Prefer this tag schedule. $schedule = $resource .Tags.Item( $autoShutdownTagName ) Write -Output "[$($resource.Name)]: `r`n`tADDING -- Found direct resource schedule tag with value: $schedule" } elseif( $taggedResourceGroupNames -contains $resource .ResourceGroupName) { # resource belongs to a tagged resource group. Use the group tag $parentGroup = $resourceGroups | Where-Object Name -eq $resource .ResourceGroupName $schedule = $parentGroup .Tags.Item($AUTOSHUTDOWNSCHEDULE_KEYWORD) Write -Output "[$($resource.Name)]: `r`n`tADDING -- Found parent resource group schedule tag with value: $schedule" } elseif( $DefaultScheduleIfNotPresent ) { $schedule = $DefaultScheduleIfNotPresent Write -Output "[$($resource.Name)]: `r`n`tADDING -- Using default schedule: $schedule" } else { # No direct or inherited tag. Skip this resource. Write -Output "[$($resource.Name)]: `r`n`tIGNORED -- Not tagged for shutdown directly or via membership in a tagged resource group. Skipping this resource." continue } # Check that tag value was succesfully obtained if( $schedule -eq $null ) { Write -Output "[$($resource.Name) `- $($resource.ProcessingOrder)]: `r`n`tIGNORED -- Failed to get tagged schedule for resource. Skipping this resource." continue } # Parse the ranges in the Tag value. Expects a string of comma-separated time ranges, or a single time range $timeRangeList = @( $schedule -split ',' | foreach {$_.Trim()}) # Check each range against the current time to see if any schedule is matched $scheduleMatched = $false $matchedSchedule = $null $neverStart = $false #if NeverStart is specified in range, do not wake-up machine foreach ( $entry in $timeRangeList ) { if((Test -ScheduleEntry -TimeRange $entry ) -eq $true ) { $scheduleMatched = $true $matchedSchedule = $entry break } if ( $entry -eq "NeverStart" ) { $neverStart = $true } } Add-Member -InputObject $resource -Name ScheduleMatched -MemberType NoteProperty -TypeName Boolean -Value $scheduleMatched Add-Member -InputObject $resource -Name MatchedSchedule -MemberType NoteProperty -TypeName Boolean -Value $matchedSchedule Add-Member -InputObject $resource -Name NeverStart -MemberType NoteProperty -TypeName Boolean -Value $neverStart } foreach ( $resource in $resourceList | Group-Object ScheduleMatched) { if( $resource .Name -eq '' ) {continue} $sortedResourceList = @() if( $resource .Name -eq $false ) { # meaning we start resources, lower to higher $sortedResourceList += @( $resource . Group | Sort ProcessingOrder) } else { $sortedResourceList += @( $resource . Group | Sort ProcessingOrder -Descending ) } foreach ( $resource in $sortedResourceList ) { # Enforce desired state for group resources based on result. if( $resource .ScheduleMatched) { # Schedule is matched. Shut down the resource if it is running. Write -Output "[$($resource.Name) `- P$($resource.ProcessingOrder)]: `r`n`tASSERT -- Current time [$currentTime] falls within the scheduled shutdown range [$($resource.MatchedSchedule)]" Add-Member -InputObject $resource -Name DesiredState -MemberType NoteProperty -TypeName String -Value 'StoppedDeallocated' } else { if ( $resource .NeverStart) { Write -Output "[$($resource.Name)]: `tIGNORED -- Resource marked with NeverStart. Keeping the resources stopped." Add-Member -InputObject $resource -Name DesiredState -MemberType NoteProperty -TypeName String -Value 'StoppedDeallocated' } else { # Schedule not matched. Start resource if stopped. Write -Output "[$($resource.Name) `- P$($resource.ProcessingOrder)]: `r`n`tASSERT -- Current time falls outside of all scheduled shutdown ranges. Start resource." Add-Member -InputObject $resource -Name DesiredState -MemberType NoteProperty -TypeName Boolean -Value 'Started' } } Assert -ResourcePowerState -Resource $resource -DesiredState $resource .DesiredState -Simulate $Simulate } } Write -Output 'Finished processing resource schedules' } catch { $errorMessage = $_.Exception.Message throw "Unexpected exception: $errorMessage" } finally { Write -Output "Runbook finished (Duration: $(('{0:hh\:mm\:ss}' -f ((GetCurrentDate) - $currentTime))))" } </endtime></starttime> |