Hi everyone - This is the first accumulation post since I started with the Terraform weekly-tips. The idea of this specific blogpost is to gather the last 4 weeks of tips into 1 big post. To this, I will use all principals that we went through, which are:
Start simple when learning Terraform
What are providers?
How is the Terraform documentation structured for any given Hashicorp provider
Executing Terraform with minimal overhead
Lets create a simple Terraform environment, where we aim to create the following Azure resources:
Resource group
Virtual Machine
Virtual Network
Subnet
Network Security Group
Key Vault
Storage Account
How it will look in Azure:
Either clone down repo by opening a git terminal session and running:
git clone https://github.com/ChristofferWin/codeterraform.git <possible file path>
//The folder that is interesting for this post is 'weeks 1-4-05-2023'
Or create the following on your host machine:
Folder to store Terraform configuration
main.tf
variables.tf
Open your favorite IDE, set the context / path to the specific Terraform folder, and lets begin to look at some code.
First of, make sure the folder and files are ready, we will need all 3 default Terraform files:
Now, let us set ourselves inside of the main.tf file - We know that we need to define the required terraform block in this example, as the 'azurerm' Provider will be required to create all the above specified resources. More than just the Azure provider we need to define:
The 'local' Provider as it will come in handy if we want to output our configuration to a local file.
The 'random' Provider as it will help us create secure passwords to be used in our comming configuration
Lets define the required providers in our main.tf file - Because we are building a 'fresh' Environment, we will simply use the newest provider versions simply by ommiting the 'version' Attribute.
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
}
local = {
source = "hashicorp/local"
}
random = {
source = "hashicorp/random"
}
null = {
source = "hashicorp/null"
}
}
Lets now build the service account which will be used to talk to the Azure Resource Manager. We can achieve this easily by using the 'az' Cli toolset. To this we must first authenticate the CLI with Azure using a normal account. Note that this account must have as a minimum the Azure Active Directory role 'Application Administrator' And have either the RBAC role 'User access administrator' Or 'Owner' On the subscription where the service account will create resources.
In a terminal authenticate the normal user with high privileges:
//The local browser will open
az login --tenant "<your tenant id>"
Create the new service account like so:
//the 'create-for-rbac' Tag automtically assigns the RBAC role //'Contributor' To the service account
az ad sp create-for-rbac -n "demo-contributor-spn"
Record the output from the creation - The 'password' For this service account will last 6 months. Copy the password to a safe password store.
Now, lets define the 'azurerm' Provder using this newly created service account:
provider "azurerm" {
features {
}
client_id = "<insert appId"
client_secret = var.client_secret
subscription_id = "<insert sub id>"
tenant_id = "<insert tenant id>"
}
Both the 'tenant id' And 'client id' Can be copied directly into the block from the last output. The 'subscription id' Can easily be found by running the az cli command:
az account subscription list
Which gives the output:
For the 'client secret' Notice how we define a variable instead, which is simply to secure that we will NOT staticly define the password in clear text. Instead, open the variables.tf file now and define the variable:
variable "client_secret" {
description = "the password of the service user in clear text"
type = string
sensitive = true
default = null
}
Notice first the use of the attribute 'sensitive' Which tells Terraform to NOT print out this variable to the console. We set the default value to 'null' As part of a simple trick, where we can define the password in a way, where it only has to be parsed to Terraform once per terminal session. I will not explain this behaviour in detail for now, it will come in a later post where we will discuss the hierarchy of how Terraform evaluates variables.
With this variable defined, we will now use the 2nd part of the default = null trick and define the value for a specific environmental variable that Terraform will look for and evaluate at runtime. In the console, first make sure the path is set to the root folder of the Terraform files. Next up, in the terminal run:
az logout
This is to force Terraform to not use the already existing context of the user account that created the service account. Now, we have 2 ways of parsing the clear text password to Terraform to use in runtime.
Example 1: In the terminal call Terraform with the '--var' argument providing the clear text password
terraform plan --var="client_secret=<app secret>"
Using the above is not great as this 'var' Must be parsed every single time terraform plan & apply is called.
Example 2: Define the password inside the environmental variable that Terraform has access to, when looking for the service user password.
In the terminal define:
$env:ARM_CLIENT_SECRET = "<app secret"
We are now all set, as long as the same terminal window is used we do not have to show the password again, regardless of the amount of terraform apply and plan executions.
Now, initialize the terraform environment:
terraform init
Time to define our resource group:
resource "azurerm_resource_group" "demo_rg_object" {
name = "demo-rg"
location = "west europe"
}
Run terraform apply and notice how we do not need to define the password again:
terraform apply --auto-approve=true
Now, lets take advantage of the return object we get from creating the resource group. Remember things like the 'location' Of the resource group can be reused in the other resources we need to create.
Let us define a local variable to save the return value of 'location' To be reused later:
locals {
location = azurerm_resource_group.demo_rg_object.location
base_resource_name = split("-", azurerm_resource_group.demo_rg_object.name)[0]
}
We simply retrieve the location from the object of the newly created resource group. The resource group does NOT have to be created before this local variable is defined. Furthermore, we define another local variable to retrieve the prefix of 'demo' From the resource group name. It seems overkill for such a simple name, and yes it is - We could instead simply create a variable in variables.tf and reuse it accross our configuration.
Now, lets create the Azure Storage account using minimum settings:
resource "random_string" "storage_random_string_object" {
length = 3
min_numeric = 3
}
resource "azurerm_storage_account" "demo_storage_object" {
name = "${local.base_resource_name}${random_string.storage_random_string_object.result}storage"
location = local.location
account_tier = "Standard"
account_replication_type = "LRS"
resource_group_name = azurerm_resource_group.demo_rg_object.name
}
Notice how we define 2 resources, 1 for the storage account itself, but also utilizing the 'random' Provider to help us generate a unique name for the account. An Azure Storage Account must have a unique name across Azure, regardless of region. Syntax wise we utilize the fact that we can parse string variables inside a newly generated string, to create more dynamic and robust names. To this, to retrieve the return value of the random string resource, we call the attribute 'result' On the object 'random_string.storage_random_object'
The name of the Azure storage account will end up being 'demo<3 random numbers>storage' Lets run Terraform apply to prove it:
terraform apply --auto-approve=true
Result:
Its hard to read from the screenshot, but as it can be seen, the random_string generated resulted in the number '905' Which brings our new Azure Storage Account name to 'demo905storage' Names for this resourcetype must consist of only lower chars and no symbols.
With the storage account created, lets focus on building the Azure Virtual Machine.
We will be creating a Windows 11 machine with a small machine size. Now, this is where the fun really begins as a VM in Azure requires other resources to also be created:
Virtual Network
Subnet
Network Interface Card
Public IP
Network Security Group
The VM itself (The OS disk will be created as part of the VM creation)
Defining all 5 network resources first (remember, Terraform creates its own dependencies at compile time, so the actual order of defining resources does not matter.
resource "azurerm_virtual_network" "demo_vn_object" {
name = "${local.base_resource_name}-vn"
location = local.location
resource_group_name = local.rg_name
address_space = ["192.168.0.0/24"]
}
resource "azurerm_subnet" "demo_client_subnet_object" {
name = "${local.base_resource_name}-client-subnet"
resource_group_name = local.rg_name
virtual_network_name = azurerm_virtual_network.demo_vn_object.name
address_prefixes = ["192.168.0.64/26"]
}
resource "azurerm_network_security_group" "demo_client_nsg_object" {
name = "${local.base_resource_name}-client-nsg"
location = local.location
resource_group_name = local.rg_name
security_rule {
name = "ALLOW-RDP-PUBLIC"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "3389" //RDP
source_address_prefix = "<your public ip>" //Define your own public IP
destination_address_prefix = "*"
}
}
resource "azurerm_subnet_network_security_group_association" "demo_nsg_link_oject" {
subnet_id = azurerm_subnet.demo_client_subnet_object.id
network_security_group_id = azurerm_network_security_group.demo_client_nsg_object.id
}
resource "azurerm_public_ip" "demo_client_pip_object" {
name = "${local.base_resource_name}-client-pip"
resource_group_name = local.rg_name
location = local.location
sku = "Basic"
allocation_method = "Static"
}
resource "azurerm_network_interface" "demo_client_nic_object" {
name = "${local.base_resource_name}-client-nic01"
location = local.location
resource_group_name = local.rg_name
ip_configuration {
name = "client_lan_ip_configuration"
subnet_id = azurerm_subnet.demo_client_subnet_object.id
private_ip_address_allocation = "Static"
private_ip_address = "192.168.0.68" //First available address in subnet
public_ip_address_id = azurerm_public_ip.demo_client_pip_object.id
}
}
In the above configuration we define the minimum network requirements for the comming Azure Virtual machine. Notice that we have changed the way we retrieve the 'resource_group_name' Attribute - To make the configuration cleaner. We have defined a new local variable that takes the return value of the resource. The local variables section now:
locals {
location = azurerm_resource_group.demo_rg_object.location
base_resource_name = split("-", azurerm_resource_group.demo_rg_object.name)[0]
rg_name = azurerm_resource_group.demo_rg_object.name
}
To summarize, the network resources defined above creates:
A Virtual Network with an address space of '192.168.0.0/24' To this, we need to split it into 1 subnet
A Subnet with an address space of '192.168.0.64/26' And the first available address for our Network Interface Card will be '.68' As the first 3 and last 2 in every subnet is Always reserved by Azure.
Public IP
A Network Interface Card is defined with a static configuration, explicitly specifying the host address. It's worth noting how we utilize direct return values of various network resources and leverage them between each other. This approach enhances the configuration's dynamism, robustness, and most importantly, aids Terraform in creating its dependency graph. I will delve into this topic in future posts
Network Security Group to allow us to access the virtual machine - Must also be linked to the subnet
Run terraform apply:
terraform apply --auto-approve=true
Result:
Time to define the Azure Virtual machine:
resource "azurerm_virtual_machine" "demo_vm_object" {
name = "${local.base_resource_name}-vm01"
location = local.location
resource_group_name = local.rg_name
network_interface_ids = [azurerm_network_interface.demo_client_nic_object.id]
vm_size = "Standard_B2ms"
storage_image_reference {
publisher = "MicrosoftWindowsDesktop"
sku = "win11-22h2-pro"
version = "22621.1702.230505"
offer = "windows-11"
}
storage_os_disk {
name = "${local.base_resource_name}-os-disk"
create_option = "FromImage"
caching = "ReadWrite"
disk_size_gb = 128
os_type = "Windows"
}
os_profile_windows_config {
provision_vm_agent = true
enable_automatic_upgrades = true
}
os_profile {
computer_name = "${local.base_resource_name}-vm01"
admin_username = "${local.base_resource_name}admin"
admin_password = random_password.vm_demo_password_admin_object.result
}
}
resource "random_password" "vm_demo_password_admin_object" {
length = 16
special = true
override_special = "!#%&*()-_=+[]<>:?"
}
With the above configuration we build an Azure Virtual machine with a 'Windows 11' Image. Finding images in the Azure Gallery can be a pain, when using IaC. In the future, we will look into how to easily identify the different types. Furthermore, we utilize the 'random' Provider once again to generate a random password for the admin account configuration on the VM.
Run terraform apply:
terraform apply --auto-approve=true
Result:
Now, with the VM up and running, we can technically already initiate a RDP connection. This will only require us to define an output, so that we can see the actual public IP address for the VM. Before we get to that, lets make sure that the password for the admin account is securely stored inside an Azure Key vault.
Lets define the Key vault and secret:
data "azurerm_client_config" "current" {}
resource "azurerm_key_vault" "demo_kv_object" {
name = "${local.base_resource_name}-${random_string.kv_random_string_object.result}-kv"
location = local.location
resource_group_name = local.rg_name
sku_name = "standard"
tenant_id = var.tenant_id
purge_protection_enabled = true
public_network_access_enabled = true
access_policy {
object_id = data.azurerm_client_config.current.object_id
tenant_id = var.tenant_id
secret_permissions = [
"Backup",
"Delete",
"Get",
"List",
"Purge",
"Recover",
"Restore",
"Set"
]
}
}
resource "random_string" "kv_random_string_object" {
length = 3
min_numeric = 3
}
resource "azurerm_key_vault_secret" "demo_vm_admin_secret_object" {
name = "${local.base_resource_name}vmadmin"
value = "username: ${join("-", azurerm_virtual_machine.demo_vm_object.os_profile.*.admin_username)} password: ${random_password.vm_demo_password_admin_object.result}"
key_vault_id = azurerm_key_vault.demo_kv_object.id
}
First of, we take advantage of the 'data' Object that can tell us the 'object id' Of the configured Service Principal. As part of creating and configuring the Azure Key vault, we must also make sure that the service account can read / write secrets inside of it. If we dont create an access policy at creation time, we have basicly locked ourselves out. This can of course be mitigated simply by logging into the Azure Portal and assigning the correct rights on the key vault. We also define a new random string resource to help us build a unique Azure Key vault DNS name as it must be unique across Azure.
Lets run terraform apply:
terraform apply --auto-approve=true
Result:
Now, we are almost done with this entire configuration! We are only missing a couple of steps, the most important one being connecting to the Azure Virtual machine. This next part involves using Terraform to invoke a Powershell script - Both as a cool trick but also to show some of the special capabilities of using Terraform. Just be aware that the next steps will not be able to execute inside an automatic pipeline because we will invoke the 'mstsc.exe' Inside of Windows on the local machine.
OBS. if the repo is cloned down, skip the below about the Powershell script.
First of, create a new file inside the root folder of where the Terraform main file resides. Copy the following Powershell code into it and save it with the file name "Start-RDPSession.ps1"
param(
[parameter(Mandatory=$true)][string]$IPAddress,
[parameter(Mandatory=$true)][string]$Username,
[parameter(Mandatory=$true)][string]$Password
)
cmdkey /generic:$IPAddress /user:$Username /pass: $Password
mstsc /v:$IPAddress
Start-Sleep -Seconds 3
cmdkey /delete:$IPAddress
Inside the main.tf file insert the following code:
resource "null_resource" "invoke_ps_object" {
triggers = {
build_number = "${timestamp()}"
}
provisioner "local-exec" {
command = "${path.module}/Start-RDPSession.ps1 -IPAddress ${azurerm_public_ip.demo_client_pip_object.ip_address} -Username ${local.vm_admin_username} -Password ${local.vm_admin_password}"
interpreter = ["powershell.exe","-Command"]
}
}
Notice that we have also contained the username and password for the new Azure vm in 2 new local variables, therefor in the top add these to the locals block:
vm_admin_username = "${join("-", azurerm_virtual_machine.demo_vm_object.os_profile.*.admin_username)}"
vm_admin_password = "${random_password.vm_demo_password_admin_object.result}"
Run terraform apply and see the magic happen :)
terraform apply --auto-approve=true
The remote desktop shall pop-up and you simply have to click accept to connect to your new Azure Virtual machine, how crazy:
We just utilized Terraform, employing the 'null_resource' resource provider, to directly invoke Powershell.exe on the local system. Additionally, during runtime, we successfully parsed all secret information to the Powershell script Start-RDPSession.ps1, ensuring that the password was never exposed to the terminal or any directory file.
Conclusion:
By defining a simple demo environment and to give some context to the Power of IaC, I took the librety to delete all resources again using the command:
terraform destroy --auto-approve=true
To this, I started a stopwatch and reaplied the entire configuration consiting of 15 resources defined in our main.tf file. How long does it take from having 0 resources in Azure to having an active RDP connection to a newly created Azure Virtual Machine running Windows 11?
In 3 minutes and 37 seconds I am able to connect directly to the newly created VM with the credential for the admin account being automatically taken care of by PowerShell and Terraform working together.
Thank you all so much for reading along! This post was a long one, but certainly fun to create.
Remember everything can easily be cleaned up by running:
terraform destroy --auto-approve=true
In the future we will look into using a remote backend for the Terraform statefile instead of having it being created and continuously maintained on the local system.
Want to learn more about Terraform? Click here -> terraform (codeterraform.com)
Want to learn more about other cool stuff like Automation or Powershell -> powershell (codeterraform.com) / automation (codeterraform.com)
Comentários