Azure Pipelines – Route Table Creation

Posted by Lucas Jackson on Saturday, November 7, 2020

Route Table

By default, Microsoft Azure is configured to automatically route network traffic within your environment.

But - what if, you want to control this? You may want network traffic to flow a certain way.

In comes a need to possibly automate the creation of Route Tables in a dynamic way. Certainly this can done with some like Terraform, but I prefer a different combination of tools for this job.

We will setup an Azure DevOps pipeline that runs on a schedule. The pipeline will execute a PowerShell script that looks for the latest ServiceTags file from Microsoft. The script will create a new Route Table and apply it to subnets specified.

You could use the PowerShell cmdlet Get-AzNetworkServiceTag, however I preferred to use this other method as you’ll see below.

Create an App Registration

  1. Log into the Azure Portal and navigate to Azure Active Directory.
  2. On the left menu, click App registrations.
  3. Near the top, click New registration.

  1. Create a new App registration using the default values.

Create a Client Secret

  1. On the overview page for the App registration you create, click Certificates and secrets on the left menu.
  2. Near the bottom on the pane, click new Client secret.

  1. Copy the auto generated secret, this will be used in a subsequent step.

Grant Permissions to Modify Route Tables

  1. Go to Subscriptions, and select the Subscription we will be implementing the automation on.
  2. On the overview page for the Subscription, go to Access Control (IAM) on the left menu.
  3. Click Add+ > Add role assignment.
  4. Grant the App registration, Network Contributor role at the Subscription level. (If you store Route Tables in a specific resource group, you can target the resource group instead)

Create a Service Connection

  1. Go to Azure DevOps.
  2. Select your project.
  3. Click Project settings in the bottom left corner.
  4. Select Service connections from the left menu bar.
  5. Set up a new Service connection using the details from your App registration.

Source the PowerShell Script

Source the following PowerShell script, it is required for the Azure DevOps YAML pipeline. You can source this script from my GitHub repository as well.

<#
    .SYNOPSIS
      Creates a New Route Table based on Azure IP Ranges and Service Tags file from Microsoft.
    .PARAMETER Subscription
      The name of the Subscription that you want to target.
    .PARAMETER ResourceGroupName
      Destination Rresource Group for the new Route Table, in the same Subscription.
    .PARAMETER AssociateSubnets
      Feature toggle to associate subnets after Route Table creation.
    .PARAMETER Cleanup
      Feature toggle to delete old unassigned Route Tables.
    .DESCRIPTION
      This script downloads the latest Azure IP Ranges and Service Tags file from Microsoft.

      It finds all AzureCloud.canadacentral and AzureCloud.canadaeast routes and automates their creation.

      Default route is also applied.

      The new Route Table is then created with all routes in the Subscription and Resource Group defined.

      Route Table can be associated using the AssociateSubnets feature flag.
    .NOTES
      Version: 1.0
      Author:  Lucas Jackson
      Date:    10/25/2020
    .LINK
      https://www.devopspertise.com
    .EXAMPLE
      Creates a new Azure Route Table in ResourceGroupName01 within Subscription01.

      The Route Table is then assigned if associations are defined in the script.

      Unassigned Routes Tables with certain filter are deleted - n-2 is kept.
      .\New-AzAutomatedRouteTable.ps1 -Subscription "Subscription01" -ResourceGroupName "ResourceGroupName01" -AssociateSubnets $true -Cleanup $true
#>

param(
      [Parameter(Mandatory = $true)]
      [ValidateNotNullorEmpty()]
      [String]$Subscription,

      [Parameter(Mandatory = $true)]
      [ValidateNotNullorEmpty()]
      [String]$ResourceGroupName,

      [Parameter(Mandatory = $true)]
      [ValidateNotNullorEmpty()]
      [Bool]$AssociateSubnets,

      [Parameter(Mandatory = $true)]
      [ValidateNotNullorEmpty()]
      [Bool]$Cleanup
)

$ErrorActionPreference = "Stop"

Try {

    Write-Host "Targeting Subscription -> $Subscription`n"
    Select-AzSubscription -SubscriptionName $Subscription

    Write-Host "`nChecking existence of Resource Group -> $ResourceGroupName"
    Get-AzResourceGroup -Name $ResourceGroupName

    # Azure IP Ranges and Service Tags Uri
    $uri = "https://www.microsoft.com/en-us/download/confirmation.aspx?id=56519"

    # Getting date in EST format since this runs on agent
    $dateEst = [System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId((Get-Date), "Eastern Standard Time")
    $date = Get-Date $dateEst -format "yyyyMMddHHmm"
    $routes = @()

    # Determine Subscription Route Table naming and associations
    # !!! Update this section to on-board your Subscription and Subnet associations !!!
    $nameRouteTableCleanupFilter = "routeTable-*"
    $nameRouteTable = "routeTable-$date"
    Switch ($Subscription) {
        "Subscription01" {
            $associations = @(
                @{virtualNetwork="vnet01"; subnet="vnetsubnet01"}
                @{virtualNetwork="vnet01"; subnet="vnetsubnet02"}
            )
        }
        "Subscription02" {
            $associations = @(
                @{virtualNetwork="vnet01"; subnet="vnetsubnet01"}
                @{virtualNetwork="vnet01"; subnet="vnetsubnet02"}
            )
        }
        default {
            Write-Host "`n$Subscription is not defined, please on-board $Subscription to continue."
            exit 0
        }
    }
    Write-Host "`nAzure Route Table will be named -> $nameRouteTable"

    # Download "Azure IP Ranges and Service Tags" file from Microsoft and parse direct link from web request
    # Current format: ServiceTags_Public_20200928.json
    # Set up resource tags for tracking
    Write-Host "`nQuerying Azure IP Ranges and Service Tags -> $uri"
    $uriIpRange = Invoke-WebRequest -Uri $uri -UseBasicParsing
    $uriDirect = $uriIpRange.Links | Select-Object href | Where-Object href -Like "*download.microsoft.com*"

    if ($uriDirect) {
        Write-Host "`nParsed download location ->" $uriDirect.href[0]
        $fileName = $uriDirect.href[0] | Split-Path -Leaf
        $tags = @{sourceTruthFile=$fileName;sourceTruthUri=$uriDirect.href[0]}
        Write-Host "`nSet sourceTruthFile tag ->" $tags.sourceTruthFile
        Write-Host "`nSet sourceTruthUri tag ->" $tags.sourceTruthUri
        if($env:Build_BuildId) {
            $tags += @{devOpsBuildUri="$env:System_TeamFoundationCollectionUri$env:System_TeamProject/_build/results?buildId=$env:Build_BuildId&view=results"}
        }
    }
    else {
        Write-Host "`nCould not parse download location.`nValidate the Uri is accessible -> $uri" -ForegroundColor "Red"
        exit 11
    }

    # Get all existing Route Tables in the Subscription
    # Check for new release based against sourceTruthFile tag, we are only checking uniqueness
    # We could check for gt/lt, but uniqueness works best in this scenario
    $routeTables = Get-AzRouteTable
    foreach ($tag in $routeTables.Tag.sourceTruthFile) {
        if($tag -eq $fileName) {
            Write-Host "`nMatching Route Table found with sourceTruthFile tag -> $tag" -ForegroundColor "Green"
            Write-Host "Route Table is current - no update required." -ForegroundColor "Green"
            exit 0
        }
    }

    # Import JSON file to PSObject then filter on canadacentral and canadaeast independently
    $jsonIpRange = Invoke-WebRequest -Uri $uriDirect.href[0] -UseBasicParsing | ConvertFrom-Json
    $filteredIpRangeCc = $jsonIpRange.values | Where-Object {$_.id -like "AzureCloud.canadacentral"}
    $filteredIpRangeCe = $jsonIpRange.values | Where-Object {$_.id -like "AzureCloud.canadaeast"}
    $count = 0
    Write-Host "`nGenerating routes..."

    # Check if all routes are found
    if ($filteredIpRangeCc -and $filteredIpRangeCe) {
        Write-Host "`nFound routes for canadacentral and canadaeast."
        Write-Host "AzureCloud.canadacentral Routes found ->" $filteredIpRangeCc.properties.addressPrefixes.Count "routes"
        Write-Host "AzureCloud.canadaeast Routes found ->" $filteredIpRangeCe.properties.addressPrefixes.Count "routes`n"
    }
    else {
        Write-Host "`nCould not find all routes." -ForegroundColor "Red"
        Write-Host "AzureCloud.canadacentral Routes found ->" $filteredIpRangeCc.properties.addressPrefixes.Count "routes"
        Write-Host "AzureCloud.canadaeast Routes found ->" $filteredIpRangeCe.properties.addressPrefixes.Count "routes"
        exit 12
    }
    # Add each route based for canadacentral
    foreach ($addressPrefix in $filteredIpRangeCc.properties.addressPrefixes) {
        $name = "AzureCloud-canadacentral-{0:0000}" -f $count
        Write-Host $name "->" $addressPrefix "~ Internet"
        $routes += New-AzRouteConfig -Name $name -AddressPrefix $addressPrefix -NextHopType "Internet"
        $count++
    }

    # Add each route based for canadaeast
    foreach ($addressPrefix in $filteredIpRangeCe.properties.addressPrefixes) {
        $name = "AzureCloud-canadaeast-{0:0000}" -f $count
        Write-Host $name "->" $addressPrefix "~ Internet"
        $routes += New-AzRouteConfig -Name $name -AddressPrefix $addressPrefix -NextHopType "Internet"
        $count++
    }

    # Add default route
    # !!! Update this section and add your default route !!!
    $name = "defaultRoute-{0:0000}" -f $count
    Write-Host $name "-> 0.0.0.0/0 ~ VirtualAppliance 192.168.0.4"
    $routes += New-AzRouteConfig -Name $name -AddressPrefix "0.0.0.0/0" -NextHopType "VirtualAppliance" -NextHopIpAddress "192.168.0.4"
    $count++

    Write-Host "`n$count routes were generated."

    # Create Route Table
    Write-Host "`nCreating Azure Route Table -> $nameRouteTable"
    $routeTable = New-AzRouteTable -Name $nameRouteTable -ResourceGroupName $ResourceGroupName -Location "canadacentral" -Route $routes -Tag $tags
    Write-Host "Azure Route Table was created successfully -> $nameRouteTable"

    # Apply associations, only if they exist and feature is toggled
    if($AssociateSubnets -and $associations) {
        Write-Host "`nAssigning Route Table..."

        foreach ($association in $associations) {
            Write-Host $association.virtualNetwork "->" $association.subnet
            $virtualNetwork = Get-AzVirtualNetwork -Name $association.virtualNetwork
            $subnet = $virtualNetwork | Get-AzVirtualNetworkSubnetConfig -Name $association.subnet
            Set-AzVirtualNetworkSubnetConfig -Name $association.subnet -VirtualNetwork $virtualNetwork -AddressPrefix $subnet.AddressPrefix -RouteTable $routeTable | Out-Null

            Write-Host "`nUpdating Virtual Network configuration..."
            $virtualNetwork | Set-AzVirtualNetwork | Out-Null
        }
    }

    # Cleanup old unassigned Route Tables - keep n-2
    if($Cleanup) {
        Write-Host "`nFinding unassigned Route Tables with pattern -> $nameRouteTableCleanupFilter"
        $routeTablesAutomation = Get-AzRouteTable | Where-Object Name -like $nameRouteTableCleanupFilter | Sort-Object -Property Name
        $count = $routeTablesAutomation.Count
        Write-Host "`n$count Route Tables were found."
        $routeTablesAutomation.Name
        if($count -le 3) {
            Write-Host "`nNo cleanup required (n-2)."
            exit 0
        }
        while ($count -gt 3) {
            foreach($routeTable in $routeTablesAutomation) {
                if($routeTable.Subnets.Count -gt 0) {
                    Write-Host "`nCannot delete old Route Table ->" $routeTable.Name "`nRoute Table is assigned to:"
                    $routeTable.Subnets.Id
                    $count--
                }
                else {
                    Write-Host "`nDeleting old Route Table ->" $routeTable.Name
                    Remove-AzRouteTable -ResourceGroupName $routeTable.ResourceGroupName -Name $routeTable.Name -Force
                    Write-Host "Route Table deleted ->" $routeTable.Name
                    $count--
                }
                if($count -le 3) {
                    break
                }
            }
        }
        Write-Host "`nCleanup complete."
    }

    Write-Host "`nSUCCESS!`nAzure Route Table automation has completed successfully." -ForegroundColor "Green"
    exit 0
}
Catch {
    Write-Host $error[0] -ForegroundColor "Red"
    exit 10
}

Source the YAML Pipeline

Source the following Azure DevOps YAML pipeline. You can source this YAML pipeline from my GitHub repository as well.

This pipeline runs on a schedule, every Saturday at midnight.

trigger: none
pr: none
schedules:
- cron: "0 0 * * 6"
  displayName: Weekly Route Table Update
  branches:
    include:
      - master
  always: true

pool:
  vmImage: 'vs2017-win2016'

steps:
- task: AzurePowerShell@5
  displayName: 'Update Route Table'
  inputs:
    azureSubscription: 'routeTableAutomation'
    ScriptType: 'FilePath'
    ScriptPath: '$(System.DefaultWorkingDirectory)/New-AzAutomatedRouteTable.ps1'
    ScriptArguments: '-Subscription "Subscription01" -ResourceGroupName "ResourceGroup01" -AssociateSubnets $true -Cleanup $true'
    azurePowerShellVersion: 'LatestVersion'

Successful Job Output

Once you’ve got the pipeline set up correctly you will get a result similar to the screen captures below.

Azure DevOps Pipeline

Route Table