This post is intended as an exercise in deploying immutable infrastructure in Azure, using several different methods.
The first method involves using platform agnostic tools, including Terraform. The second method involves using ARM (Azure Resource Manager), and the third a combination of ARM and Bicep.
In each case, we’ll end up deploying the same resources we’ve laid out in each by using build agents in Azure DevOps. To make sure that this goes smoothly every time, we’ll introduce a release agent with some basic testing as well.
In part 2, we’ll go back and add some provisioning to our pipeline YAMLs using Bash and Ansible.
Once that’s done, we’ll implement testing using Inspec and Powershell in part 3.
To start with, let’s just deploy a Windows Server 2019 virtual machine.
Method 1 Write a Terraform script to describe said virtual machine, and make a pipeline to execute the deployment.
main.tf #<https://www.terraform.io/docs/providers/azurerm/index.html>
#version locking is good!
provider "azurerm" {
version = "=3.3.0"
features {}
}
#<https://www.terraform.io/docs/providers/azurerm/r/resource_group.html>
resource "azurerm_resource_group" "rg" {
name = "DeploymentTesting"
location = "eastus"
}
#<https://www.terraform.io/docs/providers/azurerm/r/availability_set.html>
resource "azurerm_availability_set" "DemoAset" {
name = "example-aset"
location = azurerm_resource_group . rg . location
resource_group_name = azurerm_resource_group . rg . name
}
#<https://www.terraform.io/docs/providers/azurerm/r/virtual_network.html>
resource "azurerm_virtual_network" "vnet" {
name = "vNet"
address_space = [ "10.0.0.0/16" ]
location = azurerm_resource_group . rg . location
resource_group_name = azurerm_resource_group . rg . name
}
#<https://www.terraform.io/docs/providers/azurerm/r/subnet.html>
resource "azurerm_subnet" "subnet" {
name = "internal"
resource_group_name = azurerm_resource_group . rg . name
virtual_network_name = azurerm_virtual_network . vnet . name
address_prefix = "10.0.2.0/24"
}
#<https://www.terraform.io/docs/providers/azurerm/r/network_interface.html>
resource "azurerm_network_interface" "example" {
name = "example-nic"
location = azurerm_resource_group . rg . location
resource_group_name = azurerm_resource_group . rg . name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet . subnet . id
private_ip_address_allocation = "Dynamic"
}
}
#<https://www.terraform.io/docs/providers/azurerm/r/windows_virtual_machine.html>
resource "azurerm_windows_virtual_machine" "example" {
name = "example-machine"
resource_group_name = azurerm_resource_group . rg . name
location = azurerm_resource_group . rg . location
size = "Standard_F2"
admin_username = "adminuser"
admin_password = "secure-password"
availability_set_id = azurerm_availability_set . DemoAset . id
network_interface_ids = [
azurerm_network_interface . example . id ,
]
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "MicrosoftWindowsServer"
offer = "WindowsServer"
sku = "2019-Datacenter"
version = "latest"
}
}
This is a very basic configuration, where normally you would want to farm out a lot of the configuration variables, especially your Azure credentials and ARM information to a Secrets vault, which can then be configured in Azure Pipelines.
This time around, we’ll skip all that, and go straight to the pipe.
After running the pipeline, you should be able to see that the virtual machine has been deployed to the resource group specified.
Method 2 Write an ARM Template to declare what infrastructure we want our pipeline to deploy, and use Powershell to build it in our pipeline.
Notice that most of the parameters provided have not been given a value. This is called a minimal declaration, whereby each of those values will be passed on during deployment.
arm-template.json {
"$schema" : "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#" ,
"contentVersion" : "1.0.0.0" ,
"metadata" : {
"_generator" : {
"name" : "bicep" ,
"version" : "0.4.613.9944" ,
"templateHash" : "7822315097766237434"
}
},
"parameters" : {
"adminUsername" : {
"type" : "string" ,
"metadata" : {
"description" : "Username for the Virtual Machine."
}
},
"adminPassword" : {
"type" : "secureString" ,
"minLength" : 12 ,
"metadata" : {
"description" : "Password for the Virtual Machine."
}
},
"dnsLabelPrefix" : {
"type" : "string" ,
"defaultValue" : "[toLower(format('{0}-{1}', parameters('vmName'), uniqueString(resourceGroup().id, parameters('vmName'))))]" ,
"metadata" : {
"description" : "Unique DNS Name for the Public IP used to access the Virtual Machine."
}
},
"publicIpName" : {
"type" : "string" ,
"defaultValue" : "myPublicIP" ,
"metadata" : {
"description" : "Name for the Public IP used to access the Virtual Machine."
}
},
"publicIPAllocationMethod" : {
"type" : "string" ,
"defaultValue" : "Dynamic" ,
"allowedValues" : [
"Dynamic" ,
"Static"
],
"metadata" : {
"description" : "Allocation method for the Public IP used to access the Virtual Machine."
}
},
"publicIpSku" : {
"type" : "string" ,
"defaultValue" : "Basic" ,
"allowedValues" : [
"Basic" ,
"Standard"
],
"metadata" : {
"description" : "SKU for the Public IP used to access the Virtual Machine."
}
},
"OSVersion" : {
"type" : "string" ,
"defaultValue" : "2019-datacenter-gensecond" ,
"allowedValues" : [
"2019-datacenter-gensecond" ,
"2019-datacenter-core-gensecond" ,
"2019-datacenter-core-smalldisk-gensecond" ,
"2019-datacenter-core-with-containers-gensecond" ,
"2019-datacenter-core-with-containers-smalldisk-g2" ,
"2019-datacenter-smalldisk-gensecond" ,
"2019-datacenter-with-containers-gensecond" ,
"2019-datacenter-with-containers-smalldisk-g2" ,
"2016-datacenter-gensecond"
],
"metadata" : {
"description" : "The Windows version for the VM. This will pick a fully patched Gen2 image of this given Windows version."
}
},
"vmSize" : {
"type" : "string" ,
"defaultValue" : "Standard_D2s_v3" ,
"metadata" : {
"description" : "Size of the virtual machine."
}
},
"location" : {
"type" : "string" ,
"defaultValue" : "[resourceGroup().location]" ,
"metadata" : {
"description" : "Location for all resources."
}
},
"vmName" : {
"type" : "string" ,
"defaultValue" : "simple-vm" ,
"metadata" : {
"description" : "Name of the virtual machine."
}
}
},
"variables" : {
"storageAccountName" : "[format('bootdiags{0}', uniqueString(resourceGroup().id))]" ,
"nicName" : "myVMNic" ,
"addressPrefix" : "10.0.0.0/16" ,
"subnetName" : "Subnet" ,
"subnetPrefix" : "10.0.0.0/24" ,
"virtualNetworkName" : "MyVNET" ,
"networkSecurityGroupName" : "default-NSG"
},
"resources" : [
{
"type" : "Microsoft.Storage/storageAccounts" ,
"apiVersion" : "2021-04-01" ,
"name" : "[variables('storageAccountName')]" ,
"location" : "[parameters('location')]" ,
"sku" : {
"name" : "Standard_LRS"
},
"kind" : "Storage"
},
{
"type" : "Microsoft.Network/publicIPAddresses" ,
"apiVersion" : "2021-02-01" ,
"name" : "[parameters('publicIpName')]" ,
"location" : "[parameters('location')]" ,
"sku" : {
"name" : "[parameters('publicIpSku')]"
},
"properties" : {
"publicIPAllocationMethod" : "[parameters('publicIPAllocationMethod')]" ,
"dnsSettings" : {
"domainNameLabel" : "[parameters('dnsLabelPrefix')]"
}
}
},
{
"type" : "Microsoft.Network/networkSecurityGroups" ,
"apiVersion" : "2021-02-01" ,
"name" : "[variables('networkSecurityGroupName')]" ,
"location" : "[parameters('location')]" ,
"properties" : {
"securityRules" : [
{
"name" : "default-allow-3389" ,
"properties" : {
"priority" : 1000 ,
"access" : "Allow" ,
"direction" : "Inbound" ,
"destinationPortRange" : "3389" ,
"protocol" : "Tcp" ,
"sourcePortRange" : "*" ,
"sourceAddressPrefix" : "*" ,
"destinationAddressPrefix" : "*"
}
}
]
}
},
{
"type" : "Microsoft.Network/virtualNetworks" ,
"apiVersion" : "2021-02-01" ,
"name" : "[variables('virtualNetworkName')]" ,
"location" : "[parameters('location')]" ,
"properties" : {
"addressSpace" : {
"addressPrefixes" : [
"[variables('addressPrefix')]"
]
},
"subnets" : [
{
"name" : "[variables('subnetName')]" ,
"properties" : {
"addressPrefix" : "[variables('subnetPrefix')]" ,
"networkSecurityGroup" : {
"id" : "[resourceId('Microsoft.Network/networkSecurityGroups', variables('networkSecurityGroupName'))]"
}
}
}
]
},
"dependsOn" : [
"[resourceId('Microsoft.Network/networkSecurityGroups', variables('networkSecurityGroupName'))]"
]
},
{
"type" : "Microsoft.Network/networkInterfaces" ,
"apiVersion" : "2021-02-01" ,
"name" : "[variables('nicName')]" ,
"location" : "[parameters('location')]" ,
"properties" : {
"ipConfigurations" : [
{
"name" : "ipconfig1" ,
"properties" : {
"privateIPAllocationMethod" : "Dynamic" ,
"publicIPAddress" : {
"id" : "[resourceId('Microsoft.Network/publicIPAddresses', parameters('publicIpName'))]"
},
"subnet" : {
"id" : "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('virtualNetworkName'), variables('subnetName'))]"
}
}
}
]
},
"dependsOn" : [
"[resourceId('Microsoft.Network/publicIPAddresses', parameters('publicIpName'))]" ,
"[resourceId('Microsoft.Network/virtualNetworks', variables('virtualNetworkName'))]"
]
},
{
"type" : "Microsoft.Compute/virtualMachines" ,
"apiVersion" : "2021-03-01" ,
"name" : "[parameters('vmName')]" ,
"location" : "[parameters('location')]" ,
"properties" : {
"hardwareProfile" : {
"vmSize" : "[parameters('vmSize')]"
},
"osProfile" : {
"computerName" : "[parameters('vmName')]" ,
"adminUsername" : "[parameters('adminUsername')]" ,
"adminPassword" : "[parameters('adminPassword')]"
},
"storageProfile" : {
"imageReference" : {
"publisher" : "MicrosoftWindowsServer" ,
"offer" : "WindowsServer" ,
"sku" : "[parameters('OSVersion')]" ,
"version" : "latest"
},
"osDisk" : {
"createOption" : "FromImage" ,
"managedDisk" : {
"storageAccountType" : "StandardSSD_LRS"
}
},
"dataDisks" : [
{
"diskSizeGB" : 1023 ,
"lun" : 0 ,
"createOption" : "Empty"
}
]
},
"networkProfile" : {
"networkInterfaces" : [
{
"id" : "[resourceId('Microsoft.Network/networkInterfaces', variables('nicName'))]"
}
]
},
"diagnosticsProfile" : {
"bootDiagnostics" : {
"enabled" : true ,
"storageUri" : "[reference(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))).primaryEndpoints.blob]"
}
}
},
"dependsOn" : [
"[resourceId('Microsoft.Network/networkInterfaces', variables('nicName'))]" ,
"[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]"
]
}
],
"outputs" : {
"hostname" : {
"type" : "string" ,
"value" : "[reference(resourceId('Microsoft.Network/publicIPAddresses', parameters('publicIpName'))).dnsSettings.fqdn]"
}
}
}
The best way to do this, in our case, will be to create an additional parameters file. We don’t need to provide values for every parameter however, as the unspecified values will be passed the default values that we’ve set earlier.
arm-template-parameters.json {
"$schema" : "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#" ,
"contentVersion" : "1.0.0.0" ,
"parameters" : {
"adminUsername" : { "value" : "user" },
"adminPassword" : { "value" : "secure-password" }
}
}
Method 3 Write a Bicep file to build the infrastructure we want, and have the pipeline execute it.
@description('Admin username')
param adminUsername string
@description('Admin password')
@secure()
param adminPassword string
var virtualNetworkName_var = 'dscVNET'
var location = resourceGroup().location
var artifactsLocationSasToken = ''
var artifactsLocation = ''
var configurationFunction = 'win2019conf.ps1\\win2019'
var moduleFilePath = 'powershell/win2019conf.ps1.zip'
var imageSKU = '2019-Datacenter'
var vmSize = 'Standard_D2s_v3'
var vmName_var = 'win2019-iis-vm1'
var diskType = 'Standard_LRS'
var vnetAddressPrefix = '10.0.0.0/16'
var subnet1Name = 'dscSubnet-1'
var subnet1Prefix = '10.0.0.0/24'
var subnet1Ref = resourceId('Microsoft.Network/virtualNetworks/subnets', virtualNetworkName_var, subnet1Name)
var publicIPAddressType = 'Dynamic'
var publicIPAddressName_var = 'dscPubIP'
var nicName_var = 'dscNIC'
var imagePublisher = 'MicrosoftWindowsServer'
var imageOffer = 'WindowsServer'
var vmExtensionName = 'dscExtension'
var networkSecurityGroupName_var = 'default-NSG'
resource publicIPAddressName 'Microsoft.Network/publicIPAddresses@2020-05-01' = {
name: publicIPAddressName_var
location: location
properties: {
publicIPAllocationMethod: publicIPAddressType
}
}
resource networkSecurityGroupName 'Microsoft.Network/networkSecurityGroups@2020-05-01' = {
name: networkSecurityGroupName_var
location: location
properties: {
securityRules: [
{
name: 'default-allow-80'
properties: {
priority: 1000
access: 'Allow'
direction: 'Inbound'
destinationPortRange: '80'
protocol: 'Tcp'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: '*'
}
}
{
name: 'default-allow-443'
properties: {
priority: 1001
access: 'Allow'
direction: 'Inbound'
destinationPortRange: '443'
protocol: 'Tcp'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: '*'
}
}
{
name: 'default-allow-3389'
properties: {
priority: 1002
access: 'Allow'
direction: 'Inbound'
destinationPortRange: '3389'
protocol: 'Tcp'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: '*'
}
}
]
}
}
resource virtualNetworkName 'Microsoft.Network/virtualNetworks@2020-05-01' = {
name: virtualNetworkName_var
location: location
properties: {
addressSpace: {
addressPrefixes: [
vnetAddressPrefix
]
}
subnets: [
{
name: subnet1Name
properties: {
addressPrefix: subnet1Prefix
networkSecurityGroup: {
id: networkSecurityGroupName.id
}
}
}
]
}
}
resource nicName 'Microsoft.Network/networkInterfaces@2020-05-01' = {
name: nicName_var
location: location
properties: {
ipConfigurations: [
{
name: 'ipconfig1'
properties: {
privateIPAllocationMethod: 'Dynamic'
publicIPAddress: {
id: publicIPAddressName.id
}
subnet: {
id: subnet1Ref
}
}
}
]
}
dependsOn: [
virtualNetworkName
]
}
resource vmName 'Microsoft.Compute/virtualMachines@2019-12-01' = {
name: vmName_var
location: location
properties: {
hardwareProfile: {
vmSize: vmSize
}
osProfile: {
computerName: vmName_var
adminUsername: adminUsername
adminPassword: adminPassword
}
storageProfile: {
imageReference: {
publisher: imagePublisher
offer: imageOffer
sku: imageSKU
version: 'latest'
}
osDisk: {
name: '${vmName_var}_OSDisk'
caching: 'ReadWrite'
createOption: 'FromImage'
managedDisk: {
storageAccountType: diskType
}
}
}
networkProfile: {
networkInterfaces: [
{
id: nicName.id
}
]
}
}
}
resource vmName_vmExtensionName 'Microsoft.Compute/virtualMachines/extensions@2019-12-01' = {
parent: vmName
name: '${vmExtensionName}'
location: location
properties: {
publisher: 'Microsoft.Powershell'
type: 'DSC'
typeHandlerVersion: '2.19'
autoUpgradeMinorVersion: true
settings: {
ModulesUrl: uri(artifactsLocation, concat(moduleFilePath, artifactsLocationSasToken))
ConfigurationFunction: configurationFunction
Properties: {
MachineName: vmName_var
}
}
}
}
In other words, we’ve created one build agent, but set up tasks for each of the 3 methods, for a total of three virtual machines.