Scripting automation in Terraform
As a DevOps or solution artifact professional we use different scripting languages for different purposes. We use Terraform as an IAC tool to automate infrastructure provisioning.
Here in this article, we discuss how to combine scripting language and Terraform.
We do this for automation, validation, protecting assets from unintentional changes/deletions, and many other reasons.
The basic Terraform resource to use scripting is a null_resource. There we can use local-exec or remote-exec provisioner.
variable "custom_variable" {
default = "abc"
}
resource "null_resource" "execute_script" {
triggers = {
custom_variable = var.custom_variable
}
provisioner "local-exec" {
command = "pwsh -File ./basicscript.ps1 -custom_variable ${var.custom_variable}"
}
}
Here we can write a custom PowerShell script in the basicscript.ps1 file. For example-
param (
[string]$Custom_variable,
)
Write-Host "Custom variable is : $Custom_variable"
If you want to use the Terraform configuration file as a Terraform module then you need to add working_dir = path.module in the local-exec provisioner.
Here are some use cases-
Protect resources from unintended deletion-
In terraform we can use lifecycle block and there we can use prevent_destroy meta-argument to protect resources from unintended deletion. But the problem is that we can’t pass its value (true or false) using a variable, it’ll give an error. So, we can use PowerShell or bash script to use variables in the prevent_destroy meta-argument. Example-
variable "enable_prevent_destroy" {
description = "Enable prevent_destroy for the resource group"
type = bool
default = false
}
resource "null_resource" "update_resources_file_windows" {
triggers = {
prevent_destroy = tostring(var.enable_prevent_destroy)
}
provisioner "local-exec" {
command = "pwsh -File ./protected_resources.ps1 -EnablePreventDestroy ${var.enable_prevent_destroy}"
}
}
Or we can use this for Linux-
resource "null_resource" "update_resources_file_linux" {
triggers = {
prevent_destroy = tostring(var.enable_prevent_destroy)
}
provisioner "local-exec" {
command = "bash ./protected_resources.sh ${var.enable_prevent_destroy}"
}
}
Then the protected_resources.ps1 file can be-
param (
[string]$EnablePreventDestroy
)
$EnablePreventDestroyBool = if ($EnablePreventDestroy -eq "true") { $true } else { $false }
$resourcesFilePath = "./protected_resources.tf"
if (-Not (Test-Path $resourcesFilePath)) {
Write-Error "The file protected_resources.tf does not exist in the current directory."
exit 1
}
$content = Get-Content -Path $resourcesFilePath -Raw
if ($EnablePreventDestroyBool) {
$updatedContent = $content -replace '(prevent_destroy\s*=\s*)false', '${1}true'
} else {
$updatedContent = $content -replace '(prevent_destroy\s*=\s*)true', '${1}false'
}
Set-Content -Path $resourcesFilePath -Value $updatedContent
Write-Host "Updated protected_resources.tf: prevent_destroy = $EnablePreventDestroyBool"
All the resources in the protected_resources.tf file should have a lifecycle block and prevent_destroy meta-argument whose value will be updated by the script based on the input variable enable_prevent_destroy (boolean)
Another use case is input variable validation.
In Terraform we have plenty of options to validate input variables such as
condition = can(regex("^[0-9a-zA-Z]+$", var.myVar))
condition = lower(var.anothervar) == "value1" || lower(var. anothervar) == "value2" || lower(var. anothervar) == "value3"
But sometimes we need customized validation of the input variables based on our specific requirements which can be difficult to achieve using the Terraform variable validation method.
So, there we can use PowerShell or bash script to validate input variables according to our specific requirements. For example-
variable "location" {
description = "Azure location for the resource group"
type = string
default = "East US"
}
variable "vnet_cidr" {
default = ["192.168.0.0/24"]
}
variable "vnet_name" {
default = "abcsrk"
}
resource "null_resource" "update_resources_file_windows" {
triggers = {
location = var.location
vnet_cidr = join(",", var.vnet_cidr) # Convert the list to a comma-separated string
vnet_name = var.vnet_name
}
provisioner "local-exec" {
command = "pwsh -File ./inputvalidate.ps1 -location ${replace(var.location, " ", "!")} -vnet_cidr ${var.vnet_cidr[0]} -vnet_name ${var.vnet_name}"
}
}
And the inputvalidate.ps1 file could be-
param (
[string]$location,
[string]$vnet_cidr,
[string]$vnet_name
)
$location=$location.replace("!", " ")
$errors = @()
if ($location -notin @("East US", "East US2", "West US")) {
$errors += "Location must be one of 'East US', 'East US2', 'West US', you have entered $location ."
}
if ($vnet_cidr -notmatch "^192\.168\.\d{1,3}\.\d{1,3}/\d{1,2}$") {
$errors += "VNet CIDR must be in the '192.168.*.*' range and in valid CIDR notation (e.g., 192.168.0.0/24), you have entered $vnet_cidr ."
}
if ($vnet_name -notmatch "^abc.*") {
$errors += "Vnet name must contain 'abc' as a prefix, you have entered $vnet_name ."
}
if ($errors.Count -gt 0) {
throw ("Validation failed: " + ($errors -join "`n"))
}
Write-Host "Validation passed."
This way we can add more customized validation.
To provision and manage various resources on the cloud we have cloud-specific CLI tools, for example, AWS CLI, Azure CLI etc. Under the hood of Terraform, we can use that CLI tool partially or fully. This depends on project-specific requirements and compliance.
For example, we can create an AWS EC2 instance using Terraform like this-
resource "aws_instance" "example_server" {
ami = "ami-04e914639d0cca79a"
instance_type = "t2.micro"
tags = {
Name = "ExampleEC2"
}
}
We can use AWS CLI (bash script) under the hood of Terraform using a local-exec provisioner like this-
variable "ami" {
type = string
default = "ami-04e914639d0cca79a"
}
variable "instancetype" {
type = string
default = "t2.micro"
}
variable "instancename" {
type = string
default = "t2.micro"
}
resource "null_resource" "create_ec2" {
triggers = {
ami = var.ami
instancetype = var.instancetype
instancename = var.instancename
}
provisioner "local-exec" {
command = "bash ./createc2.sh ${var.ami} ${var.instancetype} ${var.instancename} "
}
}
Then the createc2.sh could be-
#!/bin/bash
if [ "$#" -ne 3 ]; then
echo "Usage: $0 <AMI_ID> <INSTANCE_TYPE> <INSTANCE_NAME>"
exit 1
fi
AMI_ID=$1
INSTANCE_TYPE=$2
INSTANCE_NAME=$3
INSTANCE_ID=$(aws ec2 run-instances \
--image-id "$AMI_ID" \
--instance-type "$INSTANCE_TYPE" \
--key-name "my-key-pair" \
--security-groups "default" \
--query 'Instances[0].InstanceId' \
--output text)
aws ec2 create-tags \
--resources "$INSTANCE_ID" \
--tags Key=Name,Value="$INSTANCE_NAME"
echo "EC2 Instance Created: $INSTANCE_ID with Name: $INSTANCE_NAME"
The system executing the Terraform script must have AWS CLI installed and configured.
Another use case might be modifying a script by another script. For example, consider this scenario-
Terraform provisions a slave EC2 instance. A local-exec executes a script named ipreplace.sh which will fetch the slave EC2’s public IP and update another script named masterserver.sh which will be used to provision a master EC2 instance as user data.
The initial content of masterserver.sh could be
#!/bin/bash
SLAVE_SERVER={slave_server}
sudo apt update -y
sudo apt install -y apache2
echo "Master server is running. Pinging slave at $SLAVE_SERVER" | sudo tee /var/www/html/index.html
ping -c 4 $SLAVE_SERVER
sudo systemctl start apache2
sudo systemctl enable apache2
echo "Master setup complete!"
The Terraform configuration file could be
variable "slave_ami" {
type = string
default = "ami-04e914639d0cca79a"
}
variable "master_ami" {
type = string
default = "ami-04e914639d0cca79a"
}
variable "instance_type" {
type = string
default = "t2.micro"
}
resource "aws_instance" "slave" {
ami = var.slave_ami
instance_type = var.instance_type
key_name = "my-key-pair"
tags = {
Name = "Slave-EC2"
}
}
resource "null_resource" "update_master_script" {
depends_on = [aws_instance.slave]
provisioner "local-exec" {
command = "bash ./ipreplace.sh"
}
}
resource "aws_instance" "master" {
depends_on = [null_resource.update_master_script]
ami = var.master_ami
instance_type = var.instance_type
key_name = "my-key-pair"
user_data = file("masterserver.sh")
tags = {
Name = "Master-EC2"
}
}
And the content of ipreplace.sh could be
#!/bin/bash
SLAVE_IP=$(aws ec2 describe-instances \
--filters "Name=tag:Name,Values=Slave-EC2" "Name=instance-state-name,Values=running" \
--query "Reservations[0].Instances[0].PublicIpAddress" --output text)
if [ -z "$SLAVE_IP" ]; then
echo "Error: Could not fetch Slave EC2 public IP."
exit 1
fi
sed -i "s/{slave_server}/$SLAVE_IP/g" masterserver.sh
echo "Updated masterserver.sh with Slave EC2 IP: $SLAVE_IP"
There are many other ways to use scripting in Terraform. Based on specific requirements we can create and use those scripts.
Thanks for reading this article.
Please click here to get my other articles.