Start\stop Azure resources based on tags using Azure Resource Graph

Howdy,

an update to a previous post I did, which significantly improves performance by using Azure Resource Graph:

function compare-time( $actual ) {
    $result = if ( $actual -match ',' ) {
        $actual.Split(',').Trim().foreach{ parse-time $_ }
    }
    else {
        parse-time $actual
    }
    if ( $result ) { $true }
}

function parse-time( $inputillo ) {
    try {
        $inputillo = (Get-Date $inputillo).DayOfYear
    }
    catch {
        "Not Datetime" | Out-Null
    }
    if ( $inputillo -in $global:timeDate.week, $global:timeDate.day, $global:timeDate.year ) {
        $true
    }
    if ( $inputillo -eq 'start') {
        $global:entityStart = $false
    }
    if ( $inputillo -eq 'stop') {
        $global:entityStop = $false
    }
}

function parse-entity( $inputillo ) {
    try {
        $inputillo | ConvertTo-Json -Compress
        $global:entityStop = $global:entityStart = $true
        $schedule = $inputillo.schedule | ConvertFrom-Json -ErrorAction Stop
        if ( compare-time $schedule.ignore ) {
            "SKIP: {0} [ {1} ]" -f $inputillo.Id, $schedule.ignore
            continue
        }
        [int]$start, [int]$stop = $schedule.($global:timeDate.week).Split("-")
        if ( ( $start - $stop ) -eq 0 -or ( $start - $stop ) -ge 20 ) {
            $global:messages += "Error: Malformed Start\Stop at {0}" -f $inputillo.Id
            continue
        }
        if ( $global:timeDate.hour -eq ( $stop - $schedule.UTC ) -and $global:entityStop ) {
            $action = $mapper[$inputillo.Type].action
        }
        elseif ( $global:timeDate.hour -eq ( $start - $schedule.UTC ) -and $global:entityStart ) {
            $action = "start"
        }
        if ( [System.String]::IsNullOrEmpty( $action ) ) {
            "NOOP: {0}" -f $inputillo.Id
        }
        else {
            $global:processed += New-Object -TypeName psobject -Property @{
                action = $action
                apiVer = $mapper[$inputillo.Type].apiver
                delay  = if ( $schedule.delayed ) { $true } else { $false }
                id     = $inputillo.id
            }
        }
    }
    catch {
        $global:messages += "Error: Malformed JSON at {0}, proceeding with the next one" -f $inputillo.Id
    }
}

function process-entity( $inputillo, $header ) {
    try {
        "Performing action {0} on {1}" -f $inputillo.action, $inputillo.Id
        $uri = "https://management.azure.com{0}/{1}?api-version={2}" -f $inputillo.Id, $inputillo.action, $inputillo.apiVer
        $global:requests += Invoke-AzRest -Method Post -Uri $uri | Add-Member @{ id = $inputillo.Id } -PassThru
    }
    catch {
        Write-Error ( $PSItem | Out-String )
        $global:messages += "Error: start\stop operation failed at {0}" -f $inputillo.id
        $retries += $inputillo
    }
}

# Initialize variables
$processed = $requests = $messages = $retries = @(); [Int]$maxRetry = 5; [Int]$retry = 0
$dateNow = ( Get-Date ).ToUniversalTime()
$timeDate = @{
    hour = $dateNow.Hour
    day  = $dateNow.DayOfWeek
    week = if ( $dateNow.DayOfWeek -in "Sunday", "Saturday" ) { "weekends" } else { "weekdays" }
    year = $dateNow.DayOfYear
}
$mapper = @{
    'Microsoft.Network/applicationGateways'      = @{
        action = 'stop'
        apiver = '2017-04-01'
    }
    'Microsoft.Compute/virtualMachines'          = @{
        action = 'deallocate'
        apiver = '2017-03-30'
    }
    'Microsoft.Compute/virtualMachineScaleSets'  = @{
        action = 'deallocate'
        apiver = '2017-03-30'
    }
    'Microsoft.ClassicCompute/virtualMachines'   = @{
        action = 'shutdown'
        apiver = '2017-04-01'
    }
    'Microsoft.ContainerService/managedClusters' = @{
        action = 'stop'
        apiver = '2022-04-01'
    }
}

do {
    $Error.Clear()
    $retry++
    Connect-AzAccount -Identity

    $all = @()
    $all += Search-AzGraph -Query "resources | where tags contains 'schedule'" | ForEach-Object {
        $PSItem | ForEach-Object {
            [pscustomobject]@{
                Type     = $_.type
                Id       = $_.id
                Schedule = $_.Tags.schedule
            }
        }
    }
    $all += Search-AzGraph -Query "ResourceContainers | where type =~ 'microsoft.resources/subscriptions/resourcegroups' | where tags contains 'classicschedule'" | ForEach-Object {
        $tag = $PSItem.tags.classicSchedule
        $name = $PSItem.name.ToLower()
        Search-AzGraph -Query "resources | where resourceGroup == '$name' and type == 'microsoft.classiccompute/virtualmachines'" | ForEach-Object {
            $PSItem | ForEach-Object {
                [pscustomobject]@{
                    Type     = $_.type
                    Id       = $_.id
                    Schedule = $tag
                }
            }
        }
    }
} while ( $Error.Count -gt 0 -and $maxRetry -ge $retry )

if ( $Error ) {
    "FATAL: runbook failed"
    $Error
    exit 1
}

try {
    # .foreach{} method is not a loop => 'continue' skips everything => foreach() is required
    $all = $all.where{ $_.Type -in $mapper.keys }
    foreach ( $entity in $all ) { parse-entity $entity }
    $processed.where{ $_.Id -notmatch "virtualMachineScaleSets" -or $_.Action -ne "start" }.foreach{ process-entity $PSItem }
    Start-Sleep -Seconds 600 # start scalesets after databases\application gateways are up
    $processed.where{ $_.Id -match "virtualMachineScaleSets" -and $_.Action -eq "start" }.foreach{ process-entity $PSItem }
}
catch {
    # Possibly restart runbook and\or determine state and restart failed
    "bad place to be"
    Write-Error ( $PSItem | Out-String )
}

$retries.foreach{ process-entity $PSItem $header }

if ( $messages ) {
    "Error messages:"
    $messages
    "examine start\stop job state"
    $Error
    exit 1
}

All the other moving parts are the same as in the previous post. Except maybe permissions need adjusting to allow ARG queries.

Cheerios

Written on October 13, 2022