How to run PowerShell scripts on Azure VMs with Terraform

This post has been republished via RSS; it originally appeared at: Microsoft Tech Community - Latest Blogs - .

The other day I published a comprehensive blog post about deploying gMSA on AKS with Terraform. As part of that blog post, I deployed an Azure VM and ran a script on it to deploy Active Directory Domain Services. Today, I wanted to explore that portion in a bit more depth.

 

Terraform Azure provider

The Terraform Azure provider is our starting point to understand what can be done when deploying Azure VMs with Terraform. The provider is constantly updated with fixes and new features, and we’ll be using the following resources:

  • azurerm_resource_group to create a Resource Group.
  • azurerm_virtual_network to deploy and an Azure vNet.
  • azurerm_subnet to deploy a subnet to the previously created vNet.
  • azurerm_network_interface to create a NIC for the VM. This is necessary with Terraform to ensure the VM gets an IP address from the right subnet. With AzCLI or Azure Portal, this is more transparent.
  • azurerm_windows_virtual_machine to create a Windows VM. This resource is a replacement from the old azurerm_virtual_machine resource.

The above items are Terraform resources, which means they are Azure resources that will be created via Terraform.

 

In addition to resources, we need Data to be passed on as part of the creation of the resources. For this example, the only one needed is the “template_file” data type. This is a data source that usually corresponds to a file. The nice thing about this data source is that you can pass on variables that the script will use from the variables that you want to input from Terraform. For example, if the script needs a username and/or password to execute, you might want to set them up as variables that the user will input when the Terraform template is executed.

 

Finally, there’s another resource we will use to actually execute the script on the Azure side: azurerm_virtual_machine_extension. This resource calls an Azure VM extension, which is the method in Azure to execute a command inside the VM using the Azure API, rather than connecting to the VM.

 

Terraform template

Here is the terraform template I’ll use to create an Azure VM and then execute the PowerShell script inside of it:

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "=3.57.0"
    }
  }
}

provider "azurerm" {
  features {
  }
}

#Creates the Azure Resource Group
resource "azurerm_resource_group" "rg" {
  name     = var.resource_group
  location = var.location
}

#Creates the Azure Virtual Network
resource "azurerm_virtual_network" "vnet" {
  name                = "testvm"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  address_space       = ["192.168.10.0/24"]
}

#Creates the subnet
resource "azurerm_subnet" "testvmsubnet" {
  name                 = "testvmsubnet"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = ["192.168.10.0/24"]
}

#Creates a vNIC for the VM
resource "azurerm_network_interface" "dc01_nic" {
  name                = "dc01_nic"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  ip_configuration {
    name                          = "dc01_nic"
    subnet_id                     = azurerm_subnet.testvmsubnet.id
    private_ip_address_allocation = "Dynamic"
  }
}

#Creates the Azure VM
resource "azurerm_windows_virtual_machine" "dc01" {
  name                = "DC01"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  size                = "Standard_D4s_v3"
  admin_username      = var.win_username
  admin_password      = var.win_userpass
  network_interface_ids = [
    azurerm_network_interface.dc01_nic.id
  ]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2022-Datacenter"
    version   = "latest"
  }
}

#Install Active Directory on the DC01 VM
resource "azurerm_virtual_machine_extension" "install_ad" {
  name                 = "install_ad"
#  resource_group_name  = azurerm_resource_group.main.name
  virtual_machine_id   = azurerm_windows_virtual_machine.dc01.id
  publisher            = "Microsoft.Compute"
  type                 = "CustomScriptExtension"
  type_handler_version = "1.9"

  protected_settings = <<SETTINGS
  {    
    "commandToExecute": "powershell -command \"[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${base64encode(data.template_file.ADDS.rendered)}')) | Out-File -filepath ADDS.ps1\" && powershell -ExecutionPolicy Unrestricted -File ADDS.ps1 -Domain_DNSName ${data.template_file.ADDS.vars.Domain_DNSName} -Domain_NETBIOSName ${data.template_file.ADDS.vars.Domain_NETBIOSName} -SafeModeAdministratorPassword ${data.template_file.ADDS.vars.SafeModeAdministratorPassword}"
  }
  SETTINGS
}

#Variable input for the ADDS.ps1 script
data "template_file" "ADDS" {
    template = "${file("ADDS.ps1")}"
    vars = {
        Domain_DNSName          = "${var.Domain_DNSName}"
        Domain_NETBIOSName      = "${var.netbios_name}"
        SafeModeAdministratorPassword = "${var.SafeModeAdministratorPassword}"
  }
}

You can save the above as main.tf. You’ll also need a variables.tf file:

variable "resource_group" {
    type = string
    description = "Resource group name"
    default = "TestAzVM"
}

variable "location" {
    type = string
    description = "RG and resources location"
    default = "West US 2"
}

variable "win_username" {
  description = "Windows node username"
  type        = string
  sensitive   = false
}

variable "win_userpass" {
  description = "Windows node password"
  type        = string
  sensitive   = true
}

variable "Domain_DNSName" {
  description = "FQDN for the Active Directory forest root domain"
  type        = string
  sensitive   = false
}

variable "netbios_name" {
  description = "NETBIOS name for the AD domain"
  type        = string
  sensitive   = false
}

variable "SafeModeAdministratorPassword" {
  description = "Password for AD Safe Mode recovery"
  type        = string
  sensitive   = true
}

These are the Terraform files, but you’ll also need the PowerShell script you want to execute.

 

Using Azure VM extension with Terraform

The main thing when I was creating this deployment was: Should I use the Terraform template to run the script via Azure VM extension or should I use Terraform to just execute the command that runs the script. It seems like the same, but there’s a main difference. The latter, implies that you have a script that is ready to go – as is. That would be ok, if I were simply running a script that does something like changing a registry key or changing the file/folder structure. However, my script to deploy ADDS requires that the user inform a DNS domain name, a NETBIOS domain name and a Safe Mode Admin Password. These are requirements to deploy ADDS via PowerShell. In this case, I don’t want to hard code this information – especially the password – in a PowerShell script. Because of that, I chose to run the script via Terraform calling an Azure VM extension.

 

First, you’ll need the PowerShell script file that you want to use. Of course, this is the file you want or have already. For my environment and to explain the concept in this blog post, here’s the file I’ll use:

[CmdletBinding()]

param 
( 
    [Parameter(ValuefromPipeline=$true,Mandatory=$true)] [string]$Domain_DNSName,
    [Parameter(ValuefromPipeline=$true,Mandatory=$true)] [string]$Domain_NETBIOSName,
    [Parameter(ValuefromPipeline=$true,Mandatory=$true)] [String]$SafeModeAdministratorPassword
)

$SMAP = ConvertTo-SecureString -AsPlainText $SafeModeAdministratorPassword -Force

Install-windowsfeature -name AD-Domain-Services -IncludeManagementTools
Install-ADDSForest -CreateDnsDelegation:$false -DatabasePath "C:\Windows\NTDS" -DomainMode "WinThreshold" -DomainName $Domain_DNSName -DomainNetbiosName $Domain_NETBIOSName -ForestMode "WinThreshold" -InstallDns:$true -LogPath "C:\Windows\NTDS" -NoRebootOnCompletion:$false -SysvolPath "C:\Windows\SYSVOL" -Force:$true -SkipPreChecks -SafeModeAdministratorPassword $SMAP

I saved this as ADDS.ps1 on the same folder as the main.tf and variables.tf files.

Notice that the PowerShell script has three parameters declared – the same ones I mentioned that are required to deploy ADDS on the VM. Both the DNS Domain Name and NETBIOS name are regular strings, but the Safe Mode Admin Password needs to be converted to secure string, hence the store on the other $SMAP variable. The important thing here is that we’re setting up the script to have the parameters passed on at execution time. That means when someone runs the command to execute the parameter, it will also pass the variables to be used. The final portion of the script is just the execution of the ADDS commands itself – but notice the variables being used.

 

Now let’s look again at how the Azure VM extension block is composed on the Terraform main.tf file:

#Install Active Directory on the DC01 VM
resource "azurerm_virtual_machine_extension" "install_ad" {
  name                 = "install_ad"
#  resource_group_name  = azurerm_resource_group.main.name
  virtual_machine_id   = azurerm_windows_virtual_machine.dc01.id
  publisher            = "Microsoft.Compute"
  type                 = "CustomScriptExtension"
  type_handler_version = "1.9"

  protected_settings = <<SETTINGS
  {    
    "commandToExecute": "powershell -command \"[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${base64encode(data.template_file.ADDS.rendered)}')) | Out-File -filepath ADDS.ps1\" && powershell -ExecutionPolicy Unrestricted -File ADDS.ps1 -Domain_DNSName ${data.template_file.ADDS.vars.Domain_DNSName} -Domain_NETBIOSName ${data.template_file.ADDS.vars.Domain_NETBIOSName} -SafeModeAdministratorPassword ${data.template_file.ADDS.vars.SafeModeAdministratorPassword}"
  }
  SETTINGS
}

One of the nicest thing about Terraform is that it figures out the order of executing items on the template. One of the ways it does that, is by calling out which resource precedes this one. In our case, you’ll notice that in order to execute the VM extension, it needs a Virtual Machine ID. This means the VM extension will only run when the VM is properly deployed.

 

We also pass on some attributes about the VM extension, such as type and handler version. You might need to change this depending on the type of script you’re using.

 

The most important thing here, though is the protected_settings block. This is where you are saying to the Azure VM extension what command to execute. It starts by saying this needs to be run in a PowerShell session and then the following command.

There’s a bunch of translation that needs to happen between your PowerShell script, Terraform, and the PowerShell session that is being executed on the Azure VM extension. This is why the command starts with the rendering of Base 64 string. This necessary and I have to point out the source on StackOverflow on which I found this.

 

The next section of the command is an important one. Here we are basically setting up the session to Unrestricted Execution Policy and calling out the ADDS.ps1 script. Note that – as mentioned above – the parameters for the script are being provided here. Not just that, we’re actually passing these variables from the Terraform variables. This makes the whole process not only easier, but way more secure. 

 

However, in order to declare the above variables to be used inside the script, we need to set up as data to be used by Terraform. The next block is what does that:

#Variable input for the ADDS.ps1 script
data "template_file" "ADDS" {
    template = "${file("ADDS.ps1")}"
    vars = {
        Domain_DNSName          = "${var.Domain_DNSName}"
        Domain_NETBIOSName      = "${var.netbios_name}"
        SafeModeAdministratorPassword = "${var.SafeModeAdministratorPassword}"
  }
}

The above is simply a map of the variables the script will need from the variables that were input by the user when the Terraform plan is applied.

 

Running the script

To run the above scenario, save the three files (main.tf, variables.tf, and ADDS.ps1) on the same folder, open a PowerShell session on that folder and run the following:

az login
az account set <subscription ID>
terraform init
terraform apply

When you apply the Terraform config, it will ask you for the parameters needed not only for the Azure resources, but the PowerShell script as well:

VMExtension_TF01.png

It will then require that you approve the Terraform plan. It should show the creation of 6 new resources.

 

Once the deployment is complete, you can open the Azure Portal, connect to the VM and see that ADDS has been installed correctly. Still on the Azure Portal, you can also check the status of the VM extension by opening the VM and clicking Extensions + applications tab, selecting the script we deployed, and checking the information:

VMExtension_TF02.png

 

Conclusion

It is possible to run PowerShell scripts on Azure VMs via Azure VM extensions with Terraform. You can set up variables to be used by the user at deployment time that are then passed on the script, which not only allows for greater flexibility, but also enhances security as you don’t have to hard code usernames and passwords on your Terraform template or PowerShell script.

 

I hope this is helpful for you. As always, the above sample is available on our GitHub repo, so you can leverage it, improve it, and collaborate with us if you have any suggestions. And if you have questions, please let us know on the comments below!

Leave a Reply

Your email address will not be published. Required fields are marked *

*

This site uses Akismet to reduce spam. Learn how your comment data is processed.