Skip to content

Instantly share code, notes, and snippets.

@kmhuglen
Last active October 28, 2015 05:19
Show Gist options
  • Save kmhuglen/6f45566348b0165dbb8e to your computer and use it in GitHub Desktop.
Save kmhuglen/6f45566348b0165dbb8e to your computer and use it in GitHub Desktop.
Custom Assert-AutoShutdownSchedule.ps1 with TimeZone parameter
<#
.SYNOPSIS
This Azure Automation runbook automates the scheduled shutdown and startup of virtual machines in an Azure subscription.
.DESCRIPTION
The runbook implements a solution for scheduled power management of Azure virtual machines in combination with tags
on resource groups which define a shutdown schedule. Each time it runs, the runbook looks for all 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 VMs in tagged groups are shut down or started to conform to the schedule.
This runbook requires the Azure Resource Manager module to be present in the Azure Automation account. This module must be imported
separately at the time of this writing. For more details, see:
https://automys.com/library/asset/scheduled-virtual-machine-shutdown-startup-microsoft-azure
.PARAMETER AzureCredentialName
The name of the PowerShell credential asset in the Automation account that contains username and password
for the account used to connect to target Azure subscription. This user must be configured as co-administrator
of the subscription.
By default, the runbook will use the credential with name "Default Automation Credential"
For for details on credential configuration, see:
http://azure.microsoft.com/blog/2014/08/27/azure-automation-authenticating-to-azure-using-azure-active-directory/
.PARAMETER AzureSubscriptionName
The name or ID of Azure subscription in which the resources will be created. By default, the runbook will use
the value defined in the Variable setting named "Default Azure Subscription"
.EXAMPLE
For testing example, see the documentation at:
https://automys.com/library/asset/scheduled-virtual-machine-shutdown-startup-microsoft-azure
.INPUTS
None.
.OUTPUTS
Human-readable informational and error messages produced during the job. Not intended to be consumed by another runbook.
#>
workflow Assert-AutoShutdownSchedule
{
Param
(
[parameter(Mandatory=$false)]
[String] $AzureCredentialName = "Use *Default Automation Credential* Asset",
[parameter(Mandatory=$false)]
[String] $AzureSubscriptionName = "Use *Default Azure Subscription* Variable Value",
[parameter(Mandatory=$false)]
[String] $TimeZone = "W. Europe Standard Time"
)
# Note: Use of "Write-Output" is not recommended generally for recording informational or error messages
# but is used here for ease of seeing everything in the "Output" pane in the Azure portal
$currentTime = (Get-Date).ToUniversalTime()
Write-Output "Runbook started"
Write-Output "Current UTC/GMT time [$($currentTime.ToString("dddd, yyyy MMM dd HH:mm:ss"))] will be checked against schedules"
$currenTimeZone = InlineScript
{
$timeZones = [System.TimeZoneInfo]::GetSystemTimeZones() | Where-Object StandardName -Match $Using:TimeZone
$timeZones = $timeZones | Add-Member -MemberType ScriptProperty -Name "CurrentTime" -Value { [TimeZoneInfo]::ConvertTime([DateTIme]::Now, $this) } -PassThru -Force
$timeZones = $timeZones | Add-Member -MemberType ScriptProperty -Name "IsDayLightSavingTime" -Value { $this.IsDaylightSavingTime([DateTime]::Now) } -PassThru -Force
$timeZones | Add-Member -MemberType ScriptProperty -Name "HoursApart" -Value { $this.CurrentTime - [DateTime]::Now } -PassThru -Force
}
#$currenTimeZone
$currentTime = $currenTimeZone.CurrentTime
Write-Output "Current time [$($currentTime.ToString("dddd, yyyy MMM dd HH:mm:ss"))] will be checked against schedules"
# Retrieve credential name from variable asset if not specified
if($AzureCredentialName -eq "Use *Default Automation Credential* asset")
{
$azureCredential = Get-AutomationPSCredential -Name "Default Automation Credential"
if($azureCredential -eq $null)
{
Write-Output "ERROR: No automation credential name was specified, and no credential asset with name 'Default Automation Credential' was found. Either specify a stored credential name or define the default using a credential asset"
Write-Output "Exiting runbook due to error"
return
}
}
else
{
$azureCredential = Get-AutomationPSCredential -Name $AzureCredentialName
if($azureCredential -eq $null)
{
Write-Output "ERROR: Failed to get credential with name [$AzureCredentialName]"
Write-Output "Exiting runbook due to error"
return
}
}
# Connect to Azure using credential asset
$errorCollection = New-Object -Type System.Management.Automation.PSDataCollection[System.Management.Automation.ErrorRecord]
$addAccountResult = Add-AzureAccount -Credential $azureCredential 2>&1
Write-Output "Authentication result:"
Write-Output $addAccountResult
# Check for returned userID, indicating successful authentication
if(!$addAccountResult.Id)
{
Write-Output "Authentication failed. Ensure a valid Azure Active Directory user account is specified which is configured as a co-administrator on the target subscription. Verify you can log into the Azure portal using these credentials."
Write-Output "Exiting runbook due to error"
return
}
# Retrieve subscription name from variable asset if not specified
if($AzureSubscriptionName -eq "Use *Default Azure Subscription* Variable Value")
{
$AzureSubscriptionName = Get-AutomationVariable -Name "Default Azure Subscription"
if($AzureSubscriptionName.length -eq 0)
{
Write-Output "ERROR: No subscription name was specified, and no variable asset with name 'Default Azure Subscription' was found. Either specify an Azure subscription name or define the default using a variable setting"
Write-Output "Exiting runbook due to error"
return
}
}
# Validate subscription
$targetSubscriptionId = InlineScript
{
$subscriptions = Get-AzureSubscription
$subscription = $subscriptions | where {$_.SubscriptionName -eq $Using:AzureSubscriptionName -or $_.SubscriptionId -eq $Using:AzureSubscriptionName}
if($subscription.Count -eq 1)
{
# Return the matching subscription Id
$subscription.SubscriptionId
}
else
{
if($subscription.Count -eq 0)
{
Write-Output "ERROR: No accessible subscription found with name or ID [$Using:AzureSubscriptionName]. Check the runbook parameters and ensure user is a co-administrator on the target subscription."
}
else
{
Write-Output "ERROR: More than one accessible subscription found with name or ID [$Using:AzureSubscriptionName]. Please ensure your subscription names are unique, or specify ID instead"
}
}
}
# Exit if an error message returned instead of ID
if($targetSubscriptionId -like "*ERROR*")
{
Write-Output $targetSubscriptionId
Write-Output "Exiting runbook due to error"
return
}
# Select the Azure subscription we will be working against
$subscriptionResult = Select-AzureSubscription -SubscriptionId $targetSubscriptionId
$currentSubscription = Get-AzureSubscription -Current
Write-Output "Targeting subscription [$($currentSubscription.SubscriptionName)] ($targetSubscriptionId)"
InlineScript
{
# Define function to check current time against specified range
function CheckScheduleEntry ([string]$TimeRange)
{
# Initialize variables
$rangeStart, $rangeEnd, $parsedDay = $null
#$currentTime = (Get-Date).ToUniversalTime()
$timeZones = [System.TimeZoneInfo]::GetSystemTimeZones() | Where-Object StandardName -Match $Using:TimeZone
$timeZones = $timeZones | Add-Member -MemberType ScriptProperty -Name "CurrentTime" -Value { [TimeZoneInfo]::ConvertTime([DateTIme]::Now, $this) } -PassThru -Force
$timeZones = $timeZones | Add-Member -MemberType ScriptProperty -Name "IsDayLightSavingTime" -Value { $this.IsDaylightSavingTime([DateTime]::Now) } -PassThru -Force
$timeZones | Add-Member -MemberType ScriptProperty -Name "HoursApart" -Value { $this.CurrentTime - [DateTime]::Now } -PassThru -Force
$currentTime = $timeZones.CurrentTime
$currentTime
$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-Error "`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-Error "`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 CheckScheduleEntry
# Get resource groups that are tagged for automatic shutdown of resources
$taggedResourceGroups = @()
$taggedResourceGroups += Get-AzureResourceGroup | where {$_.Tags.Count -gt 0 -and $_.Tags.Name -contains "AutoShutdownSchedule"}
# If no tagged groups found, return without action
if($taggedResourceGroups.Count -eq 0)
{
Write-Output "No resource groups found with 'AutoShutdownSchedule' tag."
return
}
# Process each group, building a table of desired VM state
$targetVMState = @{}
foreach($group in $taggedResourceGroups)
{
# Get the shutdown time ranges definition tag and extract the value
$shutdownTag = $group.Tags | where Name -eq "AutoShutdownSchedule"
$shutdownTimeRangesDefinition = $shutdownTag.Value
Write-Output "Found resource group [$($group.ResourceGroupName)] with 'AutoShutdownSchedule' tag with value [$shutdownTimeRangesDefinition]. Checking schedules..."
# Parse the ranges in the Tag value. Expects a string of comma-separated time ranges, or a single time range
$timeRangeList = @()
$timeRangeList += $shutdownTimeRangesDefinition -split "," | foreach {$_.Trim()}
# Check each range against the current time to see if any schedule is matched
$scheduleMatched = $false
foreach($entry in $timeRangeList)
{
if((CheckScheduleEntry -TimeRange $entry) -eq $true)
{
$scheduleMatched = $true
break
}
}
# Record desired state for group resources based on result. If schedule is matched, shut down the VM if it is running. Otherwise start the VM if stopped.
if($scheduleMatched)
{
Write-Output "Current time falls within the range [$entry]"
# Set target state as stopped
$targetState = "StoppedDeallocated"
}
else
{
Write-Output "Current time is outside of all shutdown schedule ranges for resource group [$($group.ResourceGroupName)]"
# Set target state as stopped
$targetState = "Started"
}
# Get VM resources in group and record target state for each in table
$taggedVMs = $group | Get-AzureResource | where ResourceType -eq "Microsoft.ClassicCompute/virtualMachines"
foreach($vmResource in $taggedVMs)
{
$targetVMState.Add($vmResource.Name, $targetState)
}
}
Write-Output "Checking all virtual machines for desired power state"
# Get list of Azure VMs
$vmList = Azure\Get-AzureVM
Write-Output "Number of Virtual Machines found in subscription: [$($vmList.Count)]"
# Ensure each of the VMs is in the desired state
foreach($entry in $targetVMState.GetEnumerator())
{
# Get the VM matching this configuration entry
$vm = $vmList | where Name -eq $entry.Name
# Check for unmatched name case
if($vm.Count -eq 0)
{
Write-Output "WARNING: No virtual machine found with name from resource [$($entry.Name)]"
continue
}
# Check for duplicate name case
if($vm.Count -gt 1)
{
Write-Output "WARNING: More than one virtual machine found with name [$($entry.Name)]. Please ensure all VM names are unique in subscription. Skipping these VMs."
continue
}
# If should be started and isn't, start VM
if($entry.Value -eq "Started" -and $vm.PowerState -notmatch "Started|Starting")
{
Write-Output "Starting VM [$($entry.Name)]"
$vm | Azure\Start-AzureVM
}
# If should be stopped and isn't, stop VM
if($entry.Value -eq "StoppedDeallocated" -and $vm.PowerState -ne "Stopped")
{
Write-Output "Stopping VM [$($entry.Name)]"
$vm | Azure\Stop-AzureVM -Force
}
}
Write-Output "All VMs configured for correct power state based on current time"
}
Write-Output "Runbook completed"
# End of runbook
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment