Terraform Azure VM Extensions

Having recently gone through getting Terraform to deploy a virtual machine and a VM extension to register Desired State Configuration (DSC) with Azure Automation, I thought I’d note the method and code here for future reference.

This presumes a functioning Azure Automation account with a DSC configuration and generated node configurations.

 

First, I specify my variables in a file “Variables.tf”:

// For all VMs
 
variable "subscription" { type = "string" }
variable "location" { type= "string" }
variable "vmsize" { type = "map" }
variable "username" { type = "string" }
variable "clientcode" { type = "string" }
variable "password" { type = "map" }
variable "networkipaddress" { type = "map" }
variable "serveripaddress" { type = "map" }
variable "dnsservers" { type = "list" }
variable "dcdnsservers" { type = "list" }
//DSC related
# The key for the Azure Automation account
variable "dsc_key" { type = "string" }
# Endpoint, also referred to as the Registration URL
variable "dsc_endpoint" { type = "string" }
# This can be ApplyAndMonitor, ApplyandAutoCorrect, among others
variable "dsc_mode" { type = "string" }
# Heres where you define the node configuration that you actually want to apply to the VM
variable "dsc_nodeconfigname" { type = "map" }
 
variable "dsc_configfrequency" { type = "string" }
variable "dsc_refreshfrequency" { type = "string" }

Then I deploy the VM, along with the required dependencies:

# Initial Resource Group
resource "azurerm_resource_group" "Default" {
  name     = "az${var.clientcode}"
  location = "${var.location}"
}
 
resource "azurerm_virtual_network" "VirtualNetwork" {
  name                = "azcx${var.clientcode}"
  address_space       = ["${lookup(var.networkipaddress, "VMNet")}"]
  location            = "${var.location}"
  resource_group_name = "${azurerm_resource_group.Default.name}"
  dns_servers         = "${var.dnsservers}"
}
 
resource "azurerm_subnet" "subnet-lan" {
  name                 = "az${var.clientcode}-lan"
  resource_group_name  = "${azurerm_resource_group.Default.name}"
  virtual_network_name = "${azurerm_virtual_network.VirtualNetwork.name}"
  address_prefix       = "${lookup(var.networkipaddress, "subnet-lan")}"
}
resource "azurerm_network_interface" "dc1nic1" {
  name                = "az${var.clientcode}1nic1"
  location            = "${var.location}"
  resource_group_name = "${azurerm_resource_group.Default.name}"
  # reverse DNS for the domain controller
  dns_servers = "${var.dcdnsservers}"
 
  ip_configuration {
    name                          = "ipconfig1"
    subnet_id                     = "${azurerm_subnet.subnet-lan.id}"
    private_ip_address_allocation = "static"
    private_ip_address            = "${lookup(var.serveripaddress, "az${var.clientcode}1")}"
 
  }
}
resource "azurerm_virtual_machine" "dc1" {
  name                             = "az${var.clientcode}1"
  location                         = "${var.location}"
  resource_group_name              = "${azurerm_resource_group.Default.name}"
  network_interface_ids            = ["${azurerm_network_interface.dc1nic1.id}"]
  vm_size                          = "${lookup(var.vmsize, "az${var.clientcode}1")}"
  delete_os_disk_on_termination    = true
  delete_data_disks_on_termination = true
 
  storage_image_reference {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2012-R2-Datacenter"
    version   = "latest"
  }
 
  storage_os_disk {
    name              = "az${var.clientcode}1_c"
    caching           = "ReadWrite"
    create_option     = "FromImage"
    managed_disk_type = "Standard_LRS"
    disk_size_gb      = "128"
  }
 
  storage_data_disk {
    name              = "az${var.clientcode}1_d"
    managed_disk_type = "Standard_LRS"
    caching           = "None"
    create_option     = "Empty"
    lun               = 0
    disk_size_gb      = "64"
  }
 
  os_profile {
    computer_name  = "az${var.clientcode}1"
    admin_username = "${var.username}"
    admin_password = "${lookup(var.password, "az${var.clientcode}1")}"
  }
 
  os_profile_windows_config {
    provision_vm_agent        = true
    enable_automatic_upgrades = false
  }
}

Now with the VM deployed, the Extension can be applied. This will pass in the proper variables defined, install the VM extension, and register with the Azure Automation account to begin the initial DSC deployment. Note, due to WordPress formatting, the line below that says “setting = SETTINGS” should look like this: “setting = <<SETTINGS”. The double bracket should exist for the PROTECTED_SETTINGS line too.

resource "azurerm_virtual_machine_extension" "dc1-dsc" {
  name                 = "Microsoft.Powershell.DSC"
  location             = "${var.location}"
  resource_group_name  = "${azurerm_resource_group.Default.name}"
  virtual_machine_name = "az${var.clientcode}1"
  publisher            = "Microsoft.Powershell"
  type                 = "DSC"
  auto_upgrade_minor_version = true
  type_handler_version = "2.76"
  depends_on           = ["azurerm_virtual_machine.dc1"]
 
  settings = SETTINGS
        {
            "WmfVersion": "latest",
            "advancedOptions": {
	              "forcePullAndApply": true 
                },
            "Properties": {
                "RegistrationKey": {
                  "UserName": "PLACEHOLDER_DONOTUSE",
                  "Password": "PrivateSettingsRef:registrationKeyPrivate"
                },
                "RegistrationUrl": "${var.dsc_endpoint}",
                "NodeConfigurationName": "${lookup(var.dsc_nodeconfigname, "dc1")}",
                "ConfigurationMode": "${var.dsc_mode}",
                "ConfigurationModeFrequencyMins": ${var.dsc_configfrequency},
                "RefreshFrequencyMins": ${var.dsc_refreshfrequency},
                "RebootNodeIfNeeded": true,
                "ActionAfterReboot": "continueConfiguration",
                "AllowModuleOverwrite": true
            }
        }
    SETTINGS
  protected_settings = PROTECTED_SETTINGS
    {
      "Items": {
        "registrationKeyPrivate" : "${var.dsc_key}"
      }
    }
PROTECTED_SETTINGS
}

For passing in the variable values, I use two files. One titled inputs.auto.tfvars with the following:

# This file contains input values for variables defined in "CLIENT_Variables.tf"
# As this file doesnt contain secrets, it can be committed to source control.
 
subscription = "bc5242b8"
 
location = "eastus2"
 
vmsize = {
  "azclient1" = "Standard_A1_v2"
  "azclientweb" = "Standard_A1_v2"
 }
 
username = "admin"
 
clientcode = "client"
 
serveripaddress = {
  "azclient1" = "10.0.0.211"
  "azclientweb" = "10.0.0.71"
}
 
networkipaddress = {
  "VMNet"                    = "10.0.0.0/23"
  "subnet-lan"               = "10.0.0.0/26"
}
 
resource_group_name = "azclient"
 
dnsservers = ["10.0.0.10", "10.0.0.111"]
dcdnsservers = ["10.0.0.111","10.0.0.10"]
 
#This is the registration URL of Azure Automation
dsc_endpoint = "https://eus2-agentservice-prod-1.azure-automation.net/accounts/guid"
dsc_mode = "ApplyandAutoCorrect"
dsc_configfrequency = "240"
dsc_refreshfrequency = "720"
dsc_nodeconfigname = {
  "dc1"   = "deploymentconfig.domaincontroller"
  "web1"   = "deploymentconfig.webserver"
}

And then another titled secrets.auto.tfvars which does not get uploaded to source control:

password = {
  "azclient1" = "password"
  "azclientweb" = "password"
}
 
dsc_key = "insert key here"

Get Inner Error from Azure RM command

Today I’m working on an ARM Template to deploy some resources into an Azure subscription. After building my JSON files and prepping parameters, I used the cmdlet “Test-AzureRmResourceGroupDeployment” in order to validate my template.

This failed with the error:

Code    : InvalidTemplateDeployment
Message : The template deployment 'e76887a9' is not valid according to the validation
          procedure. The tracking id is '6df0fffb'. See inner errors for details. Please
          see https://aka.ms/arm-deploy for usage details.
Details : {Microsoft.Azure.Commands.ResourceManager.Cmdlets.SdkModels.PSResourceManagerError}

I found that decidedly unhelpful, but found an effective way to determine the actual error message.

To retrieve the error details, use the following cmdlet, where the CorrelationID equals the tracking ID mentioned in the error.

get-azurermlog -correlationid 6df0fffb -detailedoutput

This will produce output which you can investigate and determine where the error lies.

In my case, I needed to create a Core quota increase request with Azure support, as my subscription had reached it’s limit.

Terraform – Variables in resource names

This is a post about resource naming in Terraform. As I was working on my first production use of Terraform to deploy resources to Azure, my goal was to parameterize the resource so that in the future I could easily re-use the .TF file with a simple replacement of my .TFVARS file.

I struggled for a while because I was trying to do something like this:

resource "azurerm_virtual_network" "AZ${var.clientcode}Net" {
  name                = "AZ${var.clientcode}"
  address_space       = ["${lookup(var.networkipaddress, "AZ${var.clientcode}")}"]
  location            = "${var.location}"
  resource_group_name = "${azurerm_resource_group.Default.name}"
  dns_servers         = "${var.dnsservers}"
}

Effectively, I wanted my resource to result in something like “AZABCNet” both in the name property and the resource identifier as well.
Then I would try and reference that in a different resource attribute, for a subnet like this:

virtual_network_name = "${azurerm_virtual_network.AZ${var.clientcode}Net.name}"

However, this gave me errors when trying to validate:

Error: Error loading C:\terraform\ABC\ABC_Network.tf: Error reading config for azurerm_subnet[${var.location}]: parse error at 1:28: expected expression but found invalid sequence "$"

 

My assumption of how the resource identifier worked was incorrect thinking; resource names should be local and static, as described here on StackOverflow.

This means that I can give my resource an identifier as “AZClientNet”, since that is used only within Terraform while the Name attribute is what will become the deployed name in Azure.

 

This leaves a little bit of a gap where I might want a resource declared with multiples of something, with attributes defined in a map variable, without having to declare multiple resource blocks.

It appears this functionality is coming in Terraform in the form of a “for each” function, according to this Github discussion. Not having used the proposed syntax in a test environment, I don’t fully have my head around it.

ARM Template to Azure Automation DSC

Either my google skills are deteriorating, or getting an ARM Template virtual machine connected to an Azure Automation DSC node configuration through the DSC extension is both not obvious and poorly documented.

I came across two different ways of achieving my goal, which was to run a single PowerShell command consisting of “New-AzureRmResourceGroupDeployment” with a template file and parameter file, and have it both deploy the resources, install the DSC extension, assign it to a node configuration, and bring it into compliance.

The first is in the official documentation, referenced here as Automation DSC Onboarding. Within that article, it provides this GitHub quick start template as the reference. With this template, you specify your external Automation URL directly, along with the Key to authorize the connection, and then a method to grab the “UpdateLCMforAAPull” configuration module needed, which in this case comes from the raw github repository, although I’ve seen other examples of storing it in a private Storage Account blob.

Interestingly, as of the time I’m writing this post, that GitHub page has been updated to reflect it is no longer necessary – this was not the case as I worked through this problem.

During this investigation I came across this feedback article relating to connecting an ARM deployment to Azure Automation DSC. This linked to a different GitHub quick start template which is similar but has syntax to pull the Automation URL and Key from a referenced lookup rather than statically set, and removes the need for definition of the extra configuration module. That feedback article also gives some insight into why the documentation and code examples floating around are confusing – being in a state of transition has that effect.

Two very important lessons I learned once I hit on an accurate template and began testing:

First: Using the second (more recent) example, your Automation account must be in the same resource group that you’re deploying other resources to, because within the resource template for the Automation Account, there isn’t a value for Resource Group. Perhaps this is my inexperience with ARM but looking at the API reference gives no indication this is possible, and when I had a mismatch it produced unavoidable errors.

Secondly: Watch out for Node Configuration case mismatch – as in upper vs lower case. My node configuration was compiled using configuration data, so it was named in the format <ConfigName>.<virtualMachineName>, with <virtualMachineName> being all lowercase. However when I used parameter values in my JSON file to reference that node configuration, it was done with mixed case.

The result of this was that the virtual machine would get deployed successfully, as would the DSC extension. It would begin applying the DSC configuration according to the logs, and running “Get-AzureRmAutomationDSCNode” listed my nodes with check-in timestamps and a status of “Compliant”. However, these nodes would NOT appear in the Portal under “DSC Nodes”. I suspect this is related to the filtering happening at the top of the page, where it pre-selects all node configurations (which are lower case) and thus the filter doesn’t match.

Once I corrected this in my template and re-ran the deployment, they appeared properly in the GUI.

 

For anyone else looking to achieve this, here’s my config (this is a single VM and VNIC attaching to a pre-existing subnet in Azure):

Parameter.JSON file:

{
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "ClientCode": {
            "value": "ABCD"
        },
        "TimeZone": {
            "value": "Central Standard Time"
        },
        "ProdWeb": {
            "value": {
                "StaticIP": "10.7.12.212",
                "VMSize": "Standard_A1_V2",
                "NameDigit": "2"
            }
        }
        "ProdWebPassword": {
            "reference": {
                "keyVault": {
                    "id": "/subscriptions/insertsubscriptionID/resourceGroups/insertResourceGroupName/providers/Microsoft.KeyVault/vaults/VaultName"
                },
                "secretName": "ProdWeb-Admin"
            }
        }
    }
}

Deployment.JSON file:

{
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "ClientCode": {
            "type": "string",
            "metadata": {
                "description": "Client Code to be used for Deployment (uppercase)"
            }
        },
        "TimeZone": {
            "type": "string",
            "metadata": {
                "description": "Define the time zone to apply to virtual machines"
            }
        },
        "windowsOSVersion": {
            "defaultValue": "2012-R2-Datacenter",
            "allowedValues": [
                "2012-R2-Datacenter",
                "2016-Datacenter"
            ],
            "type": "string",
            "metadata": {
                "description": "The Windows version for the VM. "
            }
        },
        "ProdWeb": {
            "type": "object",
            "metadata": {
                "description": "Object parameter containing information about PextWeb2 virtual machine"
            }
        }
        "ProdWebPassword": {
            "type": "securestring"
        }
    },
    "variables": {
        "ProdWebName": "[concat('AZ',parameters('ClientCode'),'ProdWeb', parameters('ProdWeb').NameDigit)]",
        "virtualNetwork": "[resourceID('Microsoft.Network/virtualNetworks', concat('AZ',toupper(parameters('ClientCode'))))]",
        "Subnet-Prod-Web-Servers": "[concat(variables('virtualNetwork'),'/Subnets/', concat('AZ',toupper(parameters('ClientCode')),'-Prod-Web-Servers'))]",
        "AdminUser": "ouradmin",
        "storageAccountType": "Standard_LRS",
        "automationAccountName": "AutomationDSC",
        "automationAccountLocation": "EastUS2",
        "ConfigurationName": "ProdWebConfig",
        "configurationModeFrequencyMins": "240",
        "refreshFrequencyMins": "720"
    },
    "resources": [
        {
            "comments": "Automation account for DSC",
            "apiVersion": "2015-10-31",
            "location": "[variables('automationAccountLocation')]",
            "name": "[variables('automationAccountName')]",
            "type": "Microsoft.Automation/automationAccounts",
            "properties": {
                "sku": {
                    "name": "Basic"
                }
            }
        },
        {
            "comments": "VNIC for ProdWeb",
            "apiVersion": "2017-06-01",
            "type": "Microsoft.Network/networkInterfaces",
            "name": "[concat(variables('ProdWebName'),'nic1')]",
            "location": "[resourceGroup().location]",
            "properties": {
                "ipConfigurations": [
                    {
                        "name": "ipconfig1",
                        "properties": {
                            "privateIPAllocationMethod": "Static",
                            "privateIPAddress": "[parameters('ProdWeb').StaticIP]",
                            "subnet": {
                                "id": "[variables('Subnet-Prod-Web-Servers')]"
                            }
                        }
                    }
                ]
            }
        },
        {
            "comments": "Disk E for ProdWeb",
            "type": "Microsoft.Compute/disks",
            "name": "[concat(variables('ProdWebName'),'_E')]",
            "apiVersion": "2017-03-30",
            "location": "[resourceGroup().location]",
            "properties": {
                "creationData": {
                    "createOption": "Empty"
                },
                "diskSizeGB": 64
            },
            "sku": {
                "name": "[variables('storageAccountType')]"
            }
        },
        {
            "comments": "VM for ProdWeb",
            "type": "Microsoft.Compute/virtualMachines",
            "name": "[variables('ProdWebName')]",
            "apiVersion": "2017-03-30",
            "location": "[resourceGroup().location]",
            "properties": {
                "hardwareProfile": {
                    "vmSize": "[parameters('ProdWeb').VMSize]"
                },
                "osProfile": {
                    "computerName": "[variables('ProdWebName')]",
                    "adminUsername": "[variables('AdminUser')]",
                    "adminPassword": "[parameters('ProdWebPassword')]",
                    "windowsConfiguration": {
                        "enableAutomaticUpdates": false,
                        "provisionVMAgent": true,
                        "timeZone": "[parameters('TimeZone')]"
                    }
                },
                "storageProfile": {
                    "imageReference": {
                        "publisher": "MicrosoftWindowsServer",
                        "offer": "WindowsServer",
                        "sku": "[parameters('windowsOSVersion')]",
                        "version": "latest"
                    },
                    "osDisk": {
                        "name": "[concat(variables('ProdWebName'),'_C')]",
                        "createOption": "FromImage",
                        "diskSizeGB": 128,
                        "caching": "ReadWrite",
                        "osType": "Windows",
                        "managedDisk": {
                            "storageAccountType": "[variables('storageAccountType')]"
                        }
                    },
                    "dataDisks": [
                        {
                            "lun": 0,
                            "createOption": "Attach",
                            "caching": "ReadWrite",
                            "managedDisk": {
                                "id": "[resourceId('Microsoft.Compute/disks', concat(variables('ProdWebName'),'_E'))]"
                            }
                        }
                    ]
                },
                "networkProfile": {
                    "networkInterfaces": [
                        {
                            "id": "[resourceId('Microsoft.Network/networkInterfaces',concat(variables('ProdWebName'),'nic1'))]"
                        }
                    ]
                },
                "diagnosticsProfile": {
                    "bootDiagnostics": {
                        "enabled": false
                    }
                }
            },
            "dependsOn": [
                "[concat('Microsoft.Network/networkInterfaces/', concat(variables('ProdWebName'),'nic1'))]",
                "[concat('Microsoft.Compute/disks/', concat(variables('ProdWebName'),'_E'))]"
            ]
        },
        {
            "comments": "DSC extension config for ProdWeb",
            "type": "Microsoft.Compute/virtualMachines/extensions",
            "name": "[concat(variables('ProdWebName'),'/Microsoft.Powershell.DSC')]",
            "apiVersion": "2017-03-30",
            "location": "[resourceGroup().location]",
            "dependsOn": [
                "[concat('Microsoft.Compute/virtualMachines/', variables('ProdWebName'))]"
            ],
            "properties": {
                "publisher": "Microsoft.Powershell",
                "type": "DSC",
                "typeHandlerVersion": "2.75",
                "autoUpgradeMinorVersion": true,
                "protectedSettings": {
                    "Items": {
                        "registrationKeyPrivate": "[listKeys(resourceId('Microsoft.Automation/automationAccounts/', variables('automationAccountName')), '2015-01-01-preview').Keys[0].value]"
                    }
                },
                "settings": {
                    "Properties": [
                        {
                            "Name": "RegistrationKey",
                            "Value": {
                                "UserName": "PLACEHOLDER_DONOTUSE",
                                "Password": "PrivateSettingsRef:registrationKeyPrivate"
                            },
                            "TypeName": "System.Management.Automation.PSCredential"
                        },
                        {
                            "Name": "RegistrationUrl",
                            "Value": "[reference(concat('Microsoft.Automation/automationAccounts/', variables('automationAccountName'))).registrationUrl]",
                            "TypeName": "System.String"
                        },
                        {
                            "Name": "NodeConfigurationName",
                            "Value": "[concat(variables('ConfigurationName'),'.',tolower(variables('ProdWebName')))]",
                            "TypeName": "System.String"
                        },
                        {
                            "Name": "ConfigurationMode",
                            "Value": "ApplyandAutoCorrect",
                            "TypeName": "System.String"
                        },
                        {
                            "Name": "RebootNodeIfNeeded",
                            "Value": true,
                            "TypeName": "System.Boolean"
                        },
                        {
                            "Name": "ActionAfterReboot",
                            "Value": "ContinueConfiguration",
                            "TypeName": "System.String"
                        },
                        {
                            "Name": "ConfigurationModeFrequencyMins",
                            "Value": "[variables('configurationModeFrequencyMins')]",
                            "TypeName": "System.Int32"
                        },
                        {
                            "Name": "RefreshFrequencyMins",
                            "Value": "[variables('refreshFrequencyMins')]",
                            "TypeName": "System.Int32"
                        }
                    ]
                }
            }
        }
    ],
    "outputs": {}
}

 

 

Terraform – Simple virtual machine

As mentioned on my Terraform – First Experience post, I began with a very simple set of resources to stand up a single virtual machine.

The following can be placed into a .TF file, and used right away with “terraform plan” and “terraform apply”. This would be much more useful if every resource wasn’t named “test”, but for the purposes of walking through each resource, understanding the structure of variables and dependency syntax its a good beginning.

provider "azurerm" {
  subscription_id = "f745d13d"
  client_id       = "7b011238"
  client_secret   = "fhSZ6YN"
  tenant_id       = "461b7ed5"
}
 
variable "location" { default = "EastUS" }
variable "username" { default = "adminuser" }
variable "password" { default = "password" }
 
resource "azurerm_resource_group" "test" {
  name     = "HelloWorld"
  location = "${var.location}"
}
 
resource "azurerm_virtual_network" "test" {
  name                = "test"
  address_space       = ["10.0.0.0/16"]
  location            = "${var.location}"
  resource_group_name = "${azurerm_resource_group.test.name}"
}
 
resource "azurerm_subnet" "test" {
  name                 = "test"
  resource_group_name  = "${azurerm_resource_group.test.name}"
  virtual_network_name = "${azurerm_virtual_network.test.name}"
  address_prefix       = "10.0.2.0/24"
}
 
resource "azurerm_network_interface" "test" {
  name                = "test"
  location            = "${var.location}"
  resource_group_name = "${azurerm_resource_group.test.name}"
  ip_configuration {
    name                          = "testconfiguration1"
    subnet_id                     = "${azurerm_subnet.test.id}"
    private_ip_address_allocation = "dynamic"
  }
}
 
resource "azurerm_storage_account" "test" {
  name                = "helloworlduniquenumbers"
  resource_group_name = "${azurerm_resource_group.test.name}"
  location            = "${var.location}"
  account_replication_type  = "LRS"
  account_tier        = "Standard"
}
 
resource "azurerm_storage_container" "test" {
  name                  = "helloworld"
  resource_group_name   = "${azurerm_resource_group.test.name}"
  storage_account_name  = "${azurerm_storage_account.test.name}"
  container_access_type = "private"
}
 
resource "azurerm_virtual_machine" "test" {
  name                  = "helloworld"
  location              = "${var.location}"
  resource_group_name   = "${azurerm_resource_group.test.name}"
  network_interface_ids = ["${azurerm_network_interface.test.id}"]
  vm_size               = "Standard_A1"
 
  storage_image_reference {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2016-Datacenter"
    version   = "latest"
  }
 
  storage_os_disk {
    name          = "myosdisk1"
    vhd_uri       = "${azurerm_storage_account.test.primary_blob_endpoint}${azurerm_storage_container.test.name}/myosdisk1.vhd"
    caching       = "ReadWrite"
    create_option = "FromImage"
  }
 
  os_profile {
    computer_name  = "helloworld"
    admin_username = "${var.username}"
    admin_password = "${var.password}"
  }
 
  os_profile_windows_config {
    provision_vm_agent = true
    enable_automatic_upgrades = true
  }
 
}