Terraform
Validate modules with custom conditions
Terraform lets you define custom conditions in your module configuration to validate resources, data sources, and outputs. When planning and applying changes to your infrastructure, Terraform evaluates these condition blocks and reports an error if a condition fails. Terraform supports preconditions, which it evaluates before it provisions the enclosing block, and postconditions, which it evaluates afterward.
In this tutorial, you will provision resources using a local module that represents an application deployment, including a load balancer and EC2 instances. While this module includes variable validation, it is still possible for users of the module to misconfigure the application. You will add condition blocks to the module to ensure that users configure the VPC and EC2 instances correctly.
Prerequisites
You can complete this tutorial using the same workflow with either Terraform Community Edition or HCP Terraform. HCP Terraform is a platform that you can use to manage and execute your Terraform projects. It includes features like remote state and execution, structured plan output, workspace resource summaries, and more.
Select the HCP Terraform tab to complete this tutorial using HCP Terraform.
This tutorial assumes that you are familiar with the standard Terraform workflow. If you are new to Terraform, complete the Get Started tutorials first.
For this tutorial, you will need:
- Terraform 1.2+ installed locally.
- An AWS account with credentials configured for Terraform.
Note
Some of the infrastructure in this tutorial does not qualify for the AWS free tier. Destroy the infrastructure at the end of the guide to avoid unnecessary charges. We are not responsible for any charges that you incur.
Clone the example repository
Clone the example repository for this tutorial, which contains Terraform configuration that uses a local module to deploy an application hosted on AWS.
$ git clone https://github.com/hashicorp-education/learn-terraform-conditions
Change into the repository directory.
$ cd learn-terraform-conditions
Review example configuration
The example configuration defines a VPC to host your application, selects an AWS AMI using a data source, and uses a local module to deploy EC2 instances and a load balancer into the VPC.
Open main.tf to review the initial configuration. The module.app
block in
main.tf
configures the example-app-deployment
module with several arguments.
main.tf
module "app" {
source = "./modules/example-app-deployment"
aws_instance_count = var.aws_instance_count
aws_instance_type = var.aws_instance_type
aws_ami_id = data.aws_ami.amazon_linux.id
aws_vpc_id = module.vpc.vpc_id
aws_public_subnet_ids = module.vpc.public_subnets
aws_private_subnet_ids = module.vpc.private_subnets
}
The module configuration in modules/example-app-deployment/main.tf
defines the
infrastructure that will host your example application, which consists of a load
balancer, security groups, and EC2 instances. This local module uses public
modules from the Terraform registry to provision your security groups and load
balancer.
The EC2 instance configuration references input variables passed from the root module to set the number of instances, the instance type, AMI ID, and private subnets to provision the instances in.
modules/example-app-deployment/main.tf
resource "aws_instance" "app" {
count = var.aws_instance_count
instance_type = var.aws_instance_type
ami = var.aws_ami_id
subnet_id = var.aws_private_subnet_ids[count.index % length(var.aws_private_subnet_ids)]
vpc_security_group_ids = [module.app_security_group.security_group_id]
}
The variables.tf
file defines several input variables for your module,
including the VPC ID and subnets to deploy the application in.
modules/example-app-deployment/variables.tf
# Input variables
variable "aws_vpc_id" {
description = "ID of the VPC to deploy in. DNS support must be enabled on this VPC."
type = string
}
variable "aws_private_subnet_ids" {
description = "VPC private subnet ids."
type = list(string)
validation {
condition = length(var.aws_private_subnet_ids) > 1
error_message = "This application requires at least two private subnets."
}
}
variable "aws_public_subnet_ids" {
description = "VPC public subnet ids."
type = list(string)
}
variable "aws_ami_id" {
description = "EC2 instance AMI ID."
type = string
}
variable "aws_instance_count" {
description = "Number of AWS instances to deploy. This number must be evenly divisible by the number of private subnets."
type = number
validation {
condition = var.aws_instance_count > 1
error_message = "This application requires at least two EC2 instances."
}
}
variable "aws_instance_type" {
description = "EC2 instance type."
type = string
}
Plan changes
Terraform configuration can be syntactically valid and deployable, but still not satisfy other constraints such as application-specific requirements. When you maintain a module, you can use custom conditions in your configuration to enforce these requirements.
In the root module, rename the terraform.tfvars.example
file to
terraform.tfvars
, so that Terraform will detect the file with end-user-configured variables in it.
$ mv terraform.tfvars.example terraform.tfvars
This file sets values for three of the variables used by the example configuration. Terraform can deploy your infrastructure with these values, but they do not meet the hypothetical requirements of the example application, which needs an EC2 instance that supports EBS optimization, and a VPC that has DNS support enabled.
terraform.tfvars
aws_instance_type = "t2.micro"
aws_instance_count = 3
enable_dns = false
If you were deploying a real application with these requirements, the application would fail on the configured infrastructure with little indication of what might be wrong. A developer familiar with the application requirements would have to diagnose the issues once the application was already deployed, and would have trace the cause to these misconfigured variables.
In this tutorial, you will add conditions to the module to ensure that:
- Each private subnet has the same number of EC2 instances.
- The EC2 instance type supports EBS optimization.
- The VPC has DNS support enabled.
By adding these conditions, you will ensure that users cannot deploy the application on infrastructure that does not meet the application's requirements.
Initialize the configuration.
$ terraform init
Initializing modules...
- app in modules/example-app-deployment
Downloading registry.terraform.io/terraform-aws-modules/security-group/aws 4.9.0 for app.app_security_group...
- app.app_security_group in .terraform/modules/app.app_security_group/modules/web
- app.app_security_group.sg in .terraform/modules/app.app_security_group
Downloading registry.terraform.io/terraform-aws-modules/elb/aws 3.0.1 for app.elb_http...
- app.elb_http in .terraform/modules/app.elb_http
- app.elb_http.elb in .terraform/modules/app.elb_http/modules/elb
- app.elb_http.elb_attachment in .terraform/modules/app.elb_http/modules/elb_attachment
Downloading registry.terraform.io/terraform-aws-modules/security-group/aws 4.9.0 for app.lb_security_group...
- app.lb_security_group in .terraform/modules/app.lb_security_group/modules/web
- app.lb_security_group.sg in .terraform/modules/app.lb_security_group
Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 3.14.0 for vpc...
- vpc in .terraform/modules/vpc
Initializing the backend...
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Reusing previous version of hashicorp/random from the dependency lock file
- Installing hashicorp/aws v4.10.0...
- Installed hashicorp/aws v4.10.0 (signed by HashiCorp)
- Installing hashicorp/random v3.1.3...
- Installed hashicorp/random v3.1.3 (signed by HashiCorp)
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
Before you add conditions to the example module, execute a plan to review the resources that Terraform will deploy.
$ terraform plan
data.aws_availability_zones.available: Reading...
data.aws_ami.amazon_linux: Reading...
data.aws_availability_zones.available: Read complete after 0s [id=us-west-2]
data.aws_ami.amazon_linux: Read complete after 2s [id=ami-00af37d1144686454]
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
+ create
<= read (data resources)
Terraform will perform the following actions:
# module.app.data.aws_subnet.public[0] will be read during apply
##...
Plan: 42 to add, 0 to change, 0 to destroy.
───────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
The plan reports that Terraform is ready to apply your configuration. Before you do so, add conditions to your module to ensure that your users configure the application correctly.
Add preconditions
Terraform allows you to add preconditions and postconditions to the lifecycle of resource, data source, or output blocks. Terraform evaluates preconditions before the enclosing block, validating that your configuration is compliant before it applies it. Terraform evaluates post conditions after the enclosing block, letting you confirm that the results of applied changes are compliant before it applies the rest of your configuration.
Update modules/example-app-deployment/main.tf
to include a data source that looks up the instance type. Add two preconditions to the aws_instance.app
block for
your EC2 instances to check the number of instances per subnet, and instance type.
modules/example-app-deployment/main.tf
data "aws_ec2_instance_type" "app" {
instance_type = var.aws_instance_type
}
resource "aws_instance" "app" {
count = var.aws_instance_count
instance_type = var.aws_instance_type
ami = var.aws_ami_id
subnet_id = var.aws_private_subnet_ids[count.index % length(var.aws_private_subnet_ids)]
vpc_security_group_ids = [module.app_security_group.security_group_id]
lifecycle {
precondition {
condition = var.aws_instance_count % length(var.aws_private_subnet_ids) == 0
error_message = "The number of instances (${var.aws_instance_count}) must be evenly divisible by the number of private subnets (${length(var.aws_private_subnet_ids)})."
}
precondition {
condition = data.aws_ec2_instance_type.app.ebs_optimized_support != "unsupported"
error_message = "The EC2 instance type (${var.aws_instance_type}) must support EBS optimization."
}
}
}
The first precondition verifies that each private subnet contains the same
number of instances. It does so by dividing the number of instances by the
number of subnets, and checking that the remainder is 0
. This condition
ensures that application traffic is spread evenly across the subnets used by
your application.
The second precondition verifies that the chosen EC2 instance type supports EBS
optimization. In order to do so, it accesses the instance type's
ebs_optimized_support
attribute from the data source.
Trigger a condition failure
Attempt to plan this configuration, and Terraform will report that the preconditions failed.
$ terraform plan
##...
╷
│ Error: Resource precondition failed
│
│ on modules/example-app-deployment/main.tf line 93, in resource "aws_instance" "app":
│ 93: condition = var.aws_instance_count % length(var.aws_private_subnet_ids) == 0
│ ├────────────────
│ │ var.aws_instance_count is 3
│ │ var.aws_private_subnet_ids is list of string with 2 elements
│
│ The number of instances (3) must be evenly divisible by the number of private
│ subnets (2).
╵
##...
╷
│ Error: Resource precondition failed
│
│ on modules/example-app-deployment/main.tf line 98, in resource "aws_instance" "app":
│ 98: condition = data.aws_ec2_instance_type.app.ebs_optimized_support != "unsupported"
│ ├────────────────
│ │ data.aws_ec2_instance_type.app.ebs_optimized_support is "unsupported"
│
│ The EC2 instance type (t2.micro) must support EBS optimization.
╵
Note
The configuration uses the count
meta-argument to create a number
of EC2 instances equal to the value of the aws_instance_count
variable,
currently set to 3
. Terraform reports errors for both preconditions for each
instance.
Terraform reports errors whenever a condition fails, and will not continue to plan or apply your configuration. You must resolve the errors before you can successfully deploy this configuration.
Plan configuration with correct values
Earlier in this tutorial, you set the number of instances and the instance type
in terraform.tfvars
. Update these to values that are compatible with the
conditions you added to the example module.
First, update the instance type to one that supports EBS optimization
(t3.micro
). Second, update the aws_instance_count
variable to deploy four
instances, so that the number of instances is evenly divisible by the number of
private subnets.
terraform.tfvars
aws_instance_type = "t3.micro"
aws_instance_count = 4
enable_dns = false
Plan this configuration again, and verify that it satisfies the preconditions.
$ terraform plan
data.aws_ami.amazon_linux: Reading...
data.aws_availability_zones.available: Reading...
module.app.data.aws_ec2_instance_type.app: Reading...
data.aws_availability_zones.available: Read complete after 1s [id=us-west-2]
module.app.data.aws_ec2_instance_type.app: Read complete after 1s [id=t3.micro]
data.aws_ami.amazon_linux: Read complete after 2s [id=ami-00af37d1144686454]
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
+ create
<= read (data resources)
Terraform will perform the following actions:
# module.app.data.aws_subnet.public[0] will be read during apply
##...
Plan: 44 to add, 0 to change, 0 to destroy.
───────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
Both of the preconditions checks now pass, and Terraform is ready to apply your configuration. Before you do so, add a postcondition to the example module.
Add a postcondition
The root configuration in this project creates a VPC using a public module. The
example-app-deployment
module expects DNS support to be enabled on the VPC.
Add a data source to the application module that looks up the created VPC by its ID and uses a
postcondition to verify that DNS support is enabled.
Add the following data source to modules/example-app-deployment/main.tf
.
modules/example-app-deployment/main.tf
data "aws_vpc" "app" {
id = var.aws_vpc_id
lifecycle {
postcondition {
condition = self.enable_dns_support == true
error_message = "The selected VPC must have DNS support enabled."
}
}
}
The postcondition refers to the data source using the self
value. Terraform
will not create the VPC until you apply the example configuration, so it cannot
validate this condition until after it has begun provisioning your
infrastructure. When you run terraform apply
, Terraform will start applying
the configuration, and will create the VPC before it reads its attributes from
the data source. After it does so, it will evaluate the postcondition and report
an error if it fails.
Apply configuration
Apply your configuration now. Respond to the confirmation prompt with a yes
,
and Terraform will begin applying your changes, and then report an error when
the postcondition on your data source fails.
$ terraform apply
data.aws_availability_zones.available: Reading...
data.aws_ami.amazon_linux: Reading...
module.app.data.aws_ec2_instance_type.app: Reading...
data.aws_availability_zones.available: Read complete after 0s [id=us-west-2]
module.app.data.aws_ec2_instance_type.app: Read complete after 0s [id=t3.micro]
data.aws_ami.amazon_linux: Read complete after 2s [id=ami-00af37d1144686454]
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
+ create
<= read (data resources)
Terraform will perform the following actions:
##...
Plan: 44 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
module.app.random_string.lb_id: Creating...
##...
module.vpc.aws_route.private_nat_gateway[1]: Creation complete after 2m46s [id=r-rtb-033b9945bd9cd2e2f1080289494]
╷
│ Error: Resource postcondition failed
│
│ on modules/example-app-deployment/main.tf line 8, in data "aws_vpc" "app":
│ 8: condition = self.enable_dns_support == true
│ ├────────────────
│ │ self.enable_dns_support is false
│
│ The selected VPC must have DNS support enabled.
╵
Resolve this error by enabling DNS support on your VPC. Update the value for the
enable_dns
variable in terraform.tfvars
.
terraform.tfvars
aws_instance_type = "t3.micro"
aws_instance_count = 4
enable_dns = true
Apply the configuration again. Respond to the confirmation prompt with a yes
.
$ terraform apply
module.app.random_string.lb_id: Refreshing state... [id=fYjWkKeE]
data.aws_availability_zones.available: Reading...
data.aws_ami.amazon_linux: Reading...
module.vpc.aws_vpc.this[0]: Refreshing state... [id=vpc-01395e9cd6796be23]
module.app.data.aws_ec2_instance_type.app: Reading...
##...
Terraform will perform the following actions:
# module.app.data.aws_vpc.app will be read during apply
# (depends on a resource or a module with changes pending)
<= data "aws_vpc" "app" {
+ arn = (known after apply)
+ cidr_block = (known after apply)
+ cidr_block_associations = (known after apply)
+ default = (known after apply)
+ dhcp_options_id = (known after apply)
+ enable_dns_hostnames = (known after apply)
+ enable_dns_support = (known after apply)
+ id = "vpc-01395e9cd6796be23"
+ instance_tenancy = (known after apply)
+ ipv6_association_id = (known after apply)
+ ipv6_cidr_block = (known after apply)
+ main_route_table_id = (known after apply)
+ owner_id = (known after apply)
+ state = (known after apply)
+ tags = (known after apply)
}
# module.vpc.aws_vpc.this[0] will be updated in-place
~ resource "aws_vpc" "this" {
~ enable_dns_support = false -> true
id = "vpc-01395e9cd6796be23"
tags = {
"Name" = ""
}
# (15 unchanged attributes hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
module.vpc.aws_vpc.this[0]: Modifying... [id=vpc-01395e9cd6796be23]
module.vpc.aws_vpc.this[0]: Still modifying... [id=vpc-01395e9cd6796be23, 10s elapsed]
module.vpc.aws_vpc.this[0]: Modifications complete after 11s [id=vpc-01395e9cd6796be23]
module.app.data.aws_vpc.app: Reading...
module.app.data.aws_vpc.app: Read complete after 0s [id=vpc-01395e9cd6796be23]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
After updating your VPC, Terraform read the new value for enable_dns_support
from the aws_vpc.app
data source inside the example-app-deployment
module
and evaluated the postcondition. Since DNS support is now enabled, the
postcondition succeeded.
Destroy infrastructure
Destroy the infrastructure you created in this tutorial. Respond to the
confirmation prompt with a yes
.
$ terraform destroy
##...
Plan: 0 to add, 0 to change, 44 to destroy.
Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure, as shown above.
There is no undo. Only 'yes' will be accepted to confirm.
Enter a value: yes
module.vpc.aws_route_table_association.private[1]: Destroying... [id=rtbassoc-05384faafb2410975]
##...
Destroy complete! Resources: 44 destroyed.
If you used HCP Terraform for this tutorial, after destroying your resources,
delete the learn-terraform-conditions
workspace from your HCP Terraform
organization.
Next steps
In this tutorial you learned about the behavior and benefits of preconditions and postconditions. Conditions allow module authors to write configuration that is easier for other people to use successfully, by validating multiple conditions either before or after resource provisioning.
For more information on topics covered in this tutorial, check out the following resources:
Read the Terraform custom conditions documentation.
Follow the Customize Terraform Configuration with Variables tutorial to learn how to create Terraform variables and how to validate the values of individual variables.
Complete the Reuse Configuration with Modules to learn how to create and publish custom Terraform modules.
Learn how to configure run tasks in HCP Terraform.