AZ Machine power action

Perform a power action.
A wait time of zero will not wait for the action to complete.
Version 2.7.14
Created on 2021-11-25
Modified on 2022-03-17
Created by Guy Leech
Downloads: 42

The Script Copy Script Copied to clipboard
#require -version 3.0

<#
.SYNOPSIS
    Perform an action on the Azure VM passed

.DESCRIPTION
    Using REST API calls
    
.PARAMETER azid
    The Azure id of the VM to perform the action on

.PARAMETER action
    The action to perform on the Azure VM
    
.PARAMETER maxWaitTimeSeconds
    The maximum number of seconds to wait for the action to complete. If not specified or less than or equal to zero, no waiting will be done

.PARAMETER sleepMilliseconds
    The period to sleep for in milliseconds between calls to get the status of the operation

.PARAMETER AZtenantId
    The azure tenant ID

.EXAMPLE
    & '.\AZ VM action.ps1' -AZid /subscriptions/58ffa3cb-4242-4f2e-a06d-deadbeefdead/resourceGroups/WVD/providers/Microsoft.Compute/virtualMachines/GLW10WVD-0 -action deallocate -maxWaitTimeSeconds 60

    Stop and deallocate the VM GLW10WVD-0 in resource group WVD using the saved credentials for the user running the script

.NOTES
    Saved credentials for the user running the script must be available in the file "C:\ProgramData\ControlUp\ScriptSupport\%username%_AZ_Cred.xml" - there is a ControlUp script to create them

    Version:        0.1
    Author:         Guy Leech, BSc based on code from Esther Barthel, MSc
    Creation Date:  2021-11-25
    Updated:        2022-01-17  Guy Leech    Fix for tenant id handling
                    2022-01-19  Guy Leech    Change to OAuth v2. Added confirmation option
#>

[CmdletBinding()]

Param
(
    [string]$AZid , ## passed by CU as the URL to the VM minus the FQDN ,
    [string]$AZtenantId ,
    [ValidateSet('start','stop','shutdown','turnoff','restart','deallocate','hibernate','redeploy','delete')]
    [string]$action ,
    [ValidateSet('Yes','No','True','False')]
    [string]$confirmAction = 'No' ,
    [int]$maxWaitTimeSeconds = 0 ,
    [int]$sleepMilliseconds = 2500
)

$VerbosePreference = $(if( $PSBoundParameters[ 'verbose' ] ) { $VerbosePreference } else { 'SilentlyContinue' })
$DebugPreference = $(if( $PSBoundParameters[ 'debug' ] ) { $DebugPreference } else { 'SilentlyContinue' })
$ErrorActionPreference = $(if( $PSBoundParameters[ 'erroraction' ] ) { $ErrorActionPreference } else { 'Stop' })
$ProgressPreference = 'SilentlyContinue'

[int]$outputWidth = 250
if( ( $PSWindow = (Get-Host).UI.RawUI ) -and ( $WideDimensions = $PSWindow.BufferSize ) )
{
    $WideDimensions.Width = $outputWidth
    $PSWindow.BufferSize = $WideDimensions
}

## mandatory parameters best avoided in CU scripts as can cause scripts to hang if missing since willbe promoptinng, siliently, for missing parameters
if( [string]::IsNullOrEmpty( $AZid ) )
{
    Throw "Missing Azure id parameter"
}

if( [string]::IsNullOrEmpty( $action ) )
{
    Throw "Missing action parameter"
}

[string]$computeApiVersion = '2021-07-01'
[string]$insightsApiVersion = '2015-04-01'
[string]$baseURL = 'https://management.azure.com'
[string]$credentialType = 'Azure'

Write-Verbose -Message "AZid is $AZid"

#region AzureFunctions
function Get-AzSPStoredCredentials {
    <#
    .SYNOPSIS
        Retrieve the Azure Service Principal Stored Credentials
    .EXAMPLE
        Get-AzSPStoredCredentials
    .CONTEXT
        Azure
    .NOTES
        Version:        0.1
        Author:         Esther Barthel, MSc
        Creation Date:  2020-08-03
        Purpose:        WVD Administration, through REST API calls
    #>
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        [string]$system ,
        [string]$tenantId
    )

    $strAzSPCredFolder = [System.IO.Path]::Combine( [environment]::GetFolderPath('CommonApplicationData') , 'ControlUp' , 'ScriptSupport' )
    $AzSPCredentials = $null

    Write-Verbose -Message "Get-AzSPStoredCredentials $system"

    [string]$credentialsFile = $(if( -Not [string]::IsNullOrEmpty( $tenantId ) )
        {
            [System.IO.Path]::Combine( $strAzSPCredFolder , "$($env:USERNAME)_$($tenantId)_$($System)_Cred.xml" )
        }
        else
        {
            [System.IO.Path]::Combine( $strAzSPCredFolder , "$($env:USERNAME)_$($System)_Cred.xml" )
        })

    Write-Verbose -Message "`tCredentials file is $credentialsFile"

    If (Test-Path -Path $credentialsFile)
    {
        try
        {
            if( ( $AzSPCredentials = Import-Clixml -Path $credentialsFile ) -and -Not [string]::IsNullOrEmpty( $tenantId ) -and -Not $AzSPCredentials.ContainsKey( 'tenantid' ) )
            {
                $AzSPCredentials.Add(  'tenantID' , $tenantId )
            }
        }
        catch
        {
            Write-Error -Message "The required PSCredential object could not be loaded from $credentialsFile : $_"
        }
    }
    Elseif( $system -eq 'Azure' )
    {
        ## try old Azure file name 
        $azSPCredentials = Get-AzSPStoredCredentials -system 'AZ' -tenantId $AZtenantId 
    }
    
    if( -not $AzSPCredentials )
    {
        Write-Error -Message "The Azure Service Principal Credentials file stored for this user ($($env:USERNAME)) cannot be found at $credentialsFile.`nCreate the file with the Set-AzSPCredentials script action (prerequisite)."
    }
    return $AzSPCredentials
}

function Get-AzBearerToken {
    <#
    .SYNOPSIS
        Retrieve the Azure Bearer Token for an authentication session
    .EXAMPLE
        Get-AzBearerToken -SPCredentials <PSCredentialObject> -TenantID <string>
    .CONTEXT
        Azure
    .NOTES
        Version:        0.1
        Author:         Esther Barthel, MSc
        Creation Date:  2020-03-22
        Updated:        2020-05-08
                        Created a separate Azure Credentials function to support ARM architecture and REST API scripted actions
        Purpose:        WVD Administration, through REST API calls
    #>
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true, HelpMessage='Azure Service Principal credentials' )]
        [ValidateNotNullOrEmpty()]
        [System.Management.Automation.PSCredential] $SPCredentials,

        [Parameter(Mandatory=$true, HelpMessage='Azure Tenant ID' )]
        [ValidateNotNullOrEmpty()]
        [string] $TenantID
    )

    
    ## https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow
    [string]$uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token"

    [hashtable]$body = @{
        grant_type    = 'client_credentials'
        client_Id     = $SPCredentials.UserName
        client_Secret = $SPCredentials.GetNetworkCredential().Password
        scope         = "$baseURL/.default"
    }

    [hashtable]$invokeRestMethodParams = @{
        Uri             = $uri
        Body            = $body
        Method          = 'POST'
        ContentType     = 'application/x-www-form-urlencoded'
    }

    Invoke-RestMethod @invokeRestMethodParams | Select-Object -ExpandProperty access_token -ErrorAction SilentlyContinue
}

function Invoke-AzureRestMethod {

    [CmdletBinding()]
    Param(
        [Parameter( Mandatory=$true, HelpMessage='A valid Azure bearer token' )]
        [ValidateNotNullOrEmpty()]
        [string]$BearerToken ,
        [string]$uri ,
        [ValidateSet('GET','POST','PUT','DELETE','PATCH')] ## add others as necessary
        [string]$method = 'GET' ,
        $body , ## not typed because could be hashtable or pscustomobject
        [string]$property = 'value' ,
        [string]$contentType = 'application/json'
    )

    [hashtable]$header = @{
        'Authorization' = "Bearer $BearerToken"
    }

    if( ! [string]::IsNullOrEmpty( $contentType ) )
    {
        $header.Add( 'Content-Type'  , $contentType )
    }

    [hashtable]$invokeRestMethodParams = @{
        Uri             = $uri
        Method          = $method
        Headers         = $header
    }

    if( $PSBoundParameters[ 'body' ] )
    {
        $invokeRestMethodParams.Add( 'Body' , ( $body | ConvertTo-Json -Depth 20))
    }

    $responseHeaders = $null

    if( $PSVersionTable.PSVersion -ge [version]'7.0.0.0' )
    {
        $invokeRestMethodParams.Add( 'ResponseHeadersVariable' , 'responseHeaders' )
    }

    if( -not [String]::IsNullOrEmpty( $property ) )
    {
        Invoke-RestMethod @invokeRestMethodParams | Select-Object -ErrorAction SilentlyContinue -ExpandProperty $property
    }
    else
    {
        Invoke-RestMethod @invokeRestMethodParams ## don't pipe through select as will slow script down for large result sets if processed again after rreturn
    }
}

#endregion AzureFunctions

[hashtable]$operationMappings = @{
    'shutdown' = 'poweroff'
    'stop' = 'deallocate'
    'turnoff' = 'poweroff'
    'hibernate' = 'deallocate'
}

[hashtable]$parameterMappings = @{
    'turnoff' = 'skipShutdown=true&'
    'hibernate' = 'hibernate=true&'
}

If ($azSPCredentials = Get-AzSPStoredCredentials -system $credentialType -tenantId $AZtenantId )
{
    # Sign in to Azure with a Service Principal with Contributor Role at Subscription level and retrieve the bearer token
    Write-Verbose -Message "Authenticating to tenant $($azSPCredentials.tenantID) as $($azSPCredentials.spCreds.Username)"
    if( -Not ( $azBearerToken = Get-AzBearerToken -SPCredentials $azSPCredentials.spCreds -TenantID $azSPCredentials.tenantID ) )
    {
        Throw "Failed to get Azure bearer token"
    }

    [string]$vmName = ($AZid -split '/')[-1]
    
    ## https://docs.microsoft.com/en-us/rest/api/compute/virtual-machines/instance-view
    [string]$instanceViewURI = "$baseURL/$azid/instanceView`?api-version=$computeApiVersion"
    if( $null -eq ( [array]$virtualMachineStatuses = @( Invoke-AzureRestMethod -BearerToken $azBearerToken -uri $instanceViewURI -property 'statuses' ) ) `
        -or $virtualMachineStatuses.Count -eq 0 )
    {
        Throw "Failed to get VM instance view via $instanceViewURI : $_"
    }

    ## code property will be an array so cannot use $matches (https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_comparison_operators?view=powershell-5.1)
    ##   ProvisioningState/updating
    ##   PowerState/running

    if( ( $line = ( $virtualMachineStatuses.code -match 'ProvisioningState/' )) -and ( $status = ($line -split '/' , 2 )[-1] ) )
    {
        ## check not performing an operation already
        Write-Verbose -Message "Current VM provisioning state is $status"
        if( $status -eq 'Updating' )
        {
            Throw "VM is already performing an operation : $($virtualMachineStatuses | Format-Table | Out-String)"
        }
    }   

    if( ( $line = ( $virtualMachineStatuses.code -match 'PowerState/' )) -and ( $powerstate = ($line -split '/' , 2 )[-1] ))
    {
        ## check not already in requested or similar state
        Write-Verbose -Message "Current VM status is $powerstate"

        if( ( $powerstate -eq 'deallocated' -and $action -in @( 'deallocate' , 'stop' , 'turnoff' , 'restart' , 'hibernate' ) ) `
            -or ( $powerstate -eq 'running' -and $action -eq 'start' ) `
            -or ( $powerstate -eq 'stopped' -and $action -in @( 'stop' , 'shutdown' , 'turnoff' , 'hibernate' )))
        {
            Throw "VM is already $powerstate so cannot $action"
        }
    }

    if( $confirmAction -ieq 'yes' -or $confirmAction -ieq 'true' )
    {
        ## if we are in session 0 then do not prompt
        [int]$thisSession = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -ExpandProperty SessionId
        if( $thisSession -eq 0 )
        {
            Write-Warning -Message "Action not confirmed by user - runnning in session 0 so unable to prompt"
            Exit 2
        }
        else
        {
            Add-Type -AssemblyName PresentationCore,PresentationFramework
            ## script must be running on console for this to work otherwise will hang indefinitely
            $answer = [Windows.MessageBox]::Show( "Are you sure you want to $action $vmName" , "Confirm Run Operation" , 'YesNo' ,'Question' )
            if( $answer -ine 'Yes' )
            {
                Write-Warning -Message "Action not confirmed by user - aborting"
                Exit 1
            }
        }
    }

    if( -Not ( $operation = $operationMappings[ $action ] ) )
    {
        $operation = $action
    }

    $parameters = $parameterMappings[ $action ]

    ## doesn't return anything
    try
    {
        [string]$operationURI = "$baseURL/$azid/$operation`?$($parameters)api-version=$computeApiVersion"
        [string]$status = 'unknown'

        Write-Verbose -Message "Performing action $operationURI"

        Invoke-AzureRestMethod -BearerToken $azBearerToken -uri $operationURI -property $null -method POST

        if( $maxWaitTimeSeconds -gt 0 )
        {
            [datetime]$start = [datetime]::Now
            [datetime]$end = $start.AddSeconds( $maxWaitTimeSeconds )

            do
            {
                if( ( $state = Invoke-AzureRestMethod -BearerToken $azBearerToken -uri $instanceViewURI -property 'statuses' ) )
                {
                    if( ( $line = ( $state.code -match 'ProvisioningState/' )) -and ( $status = ($line -split '/')[-1]) )
                    {
                        Write-Verbose -Message "`tCurrent VM provisioning state is $status"
                        if( $status -eq 'Succeeded' )
                        {
                            break
                        }
                        elseif( $status -eq 'Failed' )
                        {
                            Write-Error -Message "Provisioning failed for $vmName"
                            break
                        }
                        elseif( $status -ne 'Updating' -and $status -ne 'True' )
                        {
                            Write-Warning -Message "Unexpected provisioning state `"$status`""
                        }
                    } 
                    else
                    {
                        Write-Warning -Message "Failed call to get provisoning state of VM $vmName : $($state.code)"
                    }
                }
                else
                {
                    Write-Warning -Message "Failed call to get state of VM $vmName"
                }
                Write-Verbose -Message "$(Get-Date -Format G) : provisioning state of $vmName is $($state.code) so waiting $sleepMilliseconds ms"
                Start-Sleep -Milliseconds $sleepMilliseconds
            } while( [datetime]::Now -le $end )

            [datetime]$timeNow = [datetime]::Now

            if( -Not $state -or $status -ne 'Succeeded' )
            {
                Write-Warning -Message "VM provisioning status still $status after $([int](($timeNow - $start).TotalSeconds)) seconds"
            }
            else
            {
                Write-Output -InputObject "$action on $vmName succeeded after $([int](($timeNow - $start).TotalSeconds)) seconds"
            }
        }
        else
        {
            Write-Output -InputObject "$action on $vmName submitted ok"
        }
    }
    catch
    {
        Write-Error -Message "Error from request : $_"
    }
}