The Case of the unknown Azure Disk
Problem Statement
There is often a gap between Infrastructure as Code (the VM Deployment) and Configuration as Code where certain information must be shared across both domains.
Recently, I wanted to deploy a Windows virtual machine on Azure with a couple of data disks attached and configure the machine using Desired State Configuration. I failed miserably for quiet some time as I never knew for sure how I could identify the disks in the DSC configuration as using the Disk Number leads to issues as described within the docs of StorageDSC.
Using the location
property also failed sometimes based on Azure Managed Disk SKU selection and Virtual Machine SKU as shown below:
Disk DataDisk
{
DiskId = 'Integrated : Bus 0 : Device 63667 : Function 30747 : Adapter 3 : Port 0 : Target 0 : LUN 10'
DriveLetter = 'E'
AllocationUnitSize = 65536
DiskIdType = 'Location'
FSFormat = 'NTFS'
FSLabel = 'DATA'
PartitionStyle = 'GPT'
AllowDestructive = $false
}
Disk LogDisk
{
DiskId = 'Integrated : Bus 0 : Device 63667 : Function 30747 : Adapter 3 : Port 0 : Target 0 : LUN 11'
DriveLetter = 'F'
AllocationUnitSize = 65536
DiskIdType = 'Location'
FSFormat = 'NTFS'
FSLabel = 'LOG'
PartitionStyle = 'GPT'
AllowDestructive = $false
}
Terraform-based Solution
As I stepped back from using DSC for Disk Formatting, I found a blog from Jack Ropper on the web that proposed to use a CustomScriptExtension
resource for formatting the drives.
This is elegant, but I needed to extend on it to bundle it within the Virtual Machine Module we're using as we allow multiple data disks where some of them might be formatted and some not.
Variable Setup
Within the variables.tf
, there is a variable for additional data disks.
variable "disk_config" {
type = list(object(
{
disk_number = number
disk_storage_account_type = string
disk_size_gb = string
data_disk_cache = string
disk_label = optional(string)
disk_file_system = optional(string)
disk_drive_letter = optional(string)
}
))
}
Disk Creation
In addition to that, the disks are flattened to be used in for_each constructs for disk creation and attachment as shown below.
locals {
disk_config = flatten([
for disk in var.disk_config : {
disk_number = disk.disk_number
disk_storage_account_type = disk.disk_storage_account_type
disk_size_gb = disk.disk_size_gb
data_disk_cache = disk.data_disk_cache
disk_label = disk.disk_label
disk_file_system = disk.disk_file_system
disk_drive_letter = disk.disk_drive_letter
}
])
}
resource "azurerm_managed_disk" "disk" {
for_each = {
for disk_config in local.disk_config : "${disk_config.disk_number}" => disk_config
}
name = "disk-${var.name}-${each.value.disk_number}"
location = var.location
resource_group_name = var.resource_group_name
storage_account_type = each.value.disk_storage_account_type
create_option = "Empty"
disk_size_gb = each.value.disk_size_gb
tags = var.tags
lifecycle {
ignore_changes = [
create_option,
source_resource_id,
]
}
}
resource "azurerm_virtual_machine_data_disk_attachment" "vm-disk" {
for_each = {
for disk_config in local.disk_config : "${disk_config.disk_number}" => disk_config
}
managed_disk_id = azurerm_managed_disk.disk[each.value.disk_number].id
virtual_machine_id = var.vm_os_type == "Windows" ? azurerm_windows_virtual_machine.vm[0].id : azurerm_linux_virtual_machine.vm.id
lun = 10 + each.value.disk_number
caching = each.value.data_disk_cache
write_accelerator_enabled = var.write_accelerator_enabled
}
Formatting the Drive(s)
PowerShell Script for Templating
At scripts/FormatDisk.ps1
, we generate a file containing the following snippet:
Get-Disk | Where-Object {($_.Location -split ' ' )[-1] -eq ${lun_id}} | Initialize-Disk -PartitionStyle ${format_style} -PassThru | New-Partition -UseMaximumSize -DriveLetter ${drive_letter} | Format-Volume -FileSystem NTFS -NewFileSystemLabel ${disk_label} -Confirm:$false
This template gets rendered for every disk that has a disk_drive_letter
assigned.
data "template_file" "diskpart_windows" {
for_each = {
for disk_config in local.disk_config : "${disk_config.disk_number}" => disk_config
if disk_config.disk_drive_letter != null
}
template = "${file("${path.module}/scripts/FormatDisk.ps1")}"
vars = {
lun_id = 10 + each.value.disk_number
format_style = each.value.disk_file_system
drive_letter = each.value.disk_drive_letter
disk_label = each.value.disk_label
}
}
Another locals
block is used to flatten all templates:
locals {
rendered_diskpart_list = flatten([
for template in data.template_file.diskpart_windows : {
rendered = template.rendered
}])
}
NOTE: Azure only allows one CustomScriptExtension resource. Therefor, the templates need to be concatenated into a single file.
Creating the Script Resource
As a last step, all rendered template files will be base64-encoded and wrapped within a CustomScriptResource
resource "azurerm_virtual_machine_extension" "disk_init" {
count = try(data.template_file.diskpart_windows, null) != null ? 1 : 0
name = "vm-disk-init-ext"
virtual_machine_id = azurerm_windows_virtual_machine.vm.id
publisher = "Microsoft.Compute"
type = "CustomScriptExtension"
type_handler_version = "1.9"
auto_upgrade_minor_version = "true"
settings = <<SETTINGS
{
"commandToExecute": "powershell -command \"[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${base64encode(join("\n",local.rendered_diskpart_list.*.rendered))}')) | Out-File -filepath FormatDisk.ps1\" && powershell -ExecutionPolicy Unrestricted -File FormatDisk.ps1"
}
SETTINGS
tags = var.tags
depends_on = [
azurerm_virtual_machine_data_disk_attachment.vm-disk,
azurerm_virtual_machine_extension.vm-domjoin
]
}
Wrapping things up
It's getting messy sometimes when integrating IaC and CaC solutions like Terraform, Ansible or DSC as there is always need for interaction or data exchange.
Generally speaking, most actions that happen from within a given resource should be managed using a CaC tool, but there are exceptions to the rule as shown in this post.