Welcome! This repo contains a set of best practices to be followed when contributing to the Palo Alto Networks terraform modules used by Professional Services.
All code should support the latest minor version of the latest Terraform GA release.
- All terraform modules should be versioned using Git tags. Git tag name should adhere to the following naming convention
Major.Minor.Patch => 0.1.0
-
Version should be pinned in the root module where the modules are called from using the
ref
argument:module "bootstrap_bucket" { source = "spring.paloaltonetworks.com/mekanayake/terraform-aws-panfw-bootstrap?ref=0.1.0" bucket_prefix = "GlobalProtect-bootstrap-" local_directory = "vmseries-bootstrap-package" }
-
Static values shouldn't be hardcoded inside the Terraform configuration, static values should be defined as Terraform variables, this extends the flexibility of the Terraform nodules for future use.
-
BAD example, all the arguments provided to the
contains()
function are hardcodedcontains(["169.254.0.0/28", "169.254.1.0/28", "169.254.2.0/28"], "169.254.1.0/28")
-
GOOD example, all the arguments provided to the
contains()
function are extracted and defined as variablesvariable "exceptionList" { default = ["169.254.0.0/28", "169.254.1.0/28", "169.254.2.0/28"] } variable "tunnelCidrRange" { default = "169.254.1.0/28" } contains(var.exceptionList, var.tunnelCidrRange)
-
-
Optional variables for resources should still be included (where sensible) to ensure future extendability of the module
-
GOOD example,
ipv6_cidr_block
is optional, however the optional argument is included and the value is provided with the help oflookup
function to ensure future extensibilityresource "aws_subnet" "in_secondary_cidr" { vpc_id = vpc_id cidr_block = each.value.cidr_block ipv6_cidr_block = lookup(each.value, "ipv6_cidr_block", null) }
-
-
for_each
looping should be used instead ofcount
when multiple resources need to be created. This results in a resourcemap
instead oflist
when the created resources are added to the Terraform state tree which allows you to remove an object from Terraform state despite the position of the object where it is inserted. Removing an item from the middle of a list will result in Terraform deleting and re-creating the resources.-
BAD example,
subnets
is a list of maps andcount
is used to create multiple subnetsvariable "subnets" { default = [ { name = "Public" az = "eu-est-2a" cidr = "192.168.0.0/24" }, { name = "Private" az = "eu-est-2b" cidr = "192.168.1.0/24" } ] } resource "aws_subnet" "this" { count = length(var.subnets) cidr_block = var.subnets[count.index].cidr availability_zone = var.subnets[count.index].az vpc_id = aws_vpc.this.id }
-
GOOD example,
subnets
is a map of maps andfor_each
is used to create multiple subnetsvariable "subnets" { default = { "Public" = { name = "Public" az = "eu-est-2a" cidr = "192.168.0.0/24" }, "Private" = { name = "Private" az = "eu-est-2b" cidr = "192.168.1.0/24" } } } resource "aws_subnet" "this" { for_each = var.subnets cidr_block = each.value.cidr availability_zone = lookup(each.value, "az", null) vpc_id = aws_vpc.this.id }
-
-
count
should only be used when creating a single resource with a conditional statement. For an example you may decide to create a resource based on an existence of an input variable-
GOOD example,
count
creates a single resource with a conditional statementresource "aws_iam_role_policy_attachment" "bs-attach" { count = var.enable_bs ? 1 : 0 role = aws_iam_role.fw-role.name policy_arn = aws_iam_policy.bs-policy[0].arn }
-
-
It is important to define your variable structure as intuitively as possible for the end user. The input variable can be easily transformed within the module code to meet your requirement later.
-
BAD example, disjoint input variables
module "vpc" { source = ... subnet_names = ["Public", "Private"] subnet_cidr = ["192.168.0.0/24", "192.168.1.0/24"] }
-
GOOD example 1, accept a
list
of maps and transform thelist
to amap
inside the module codemodule "vpc" { source = ... subnet_cidr = [ { name = "Public" az = "eu-est-2a" cidr = "192.168.0.0/24" }, { name = "Private" az = "eu-est-2b" cidr = "192.168.1.0/24" } ] }
-
GOOD example 2, accept a
map
ofobjects
that wouldn't require variable transformation within the module code. Note that thename
parameter is defined as the key of the object.module "vpc" { source = ... subnet_cidr = { "Public" = { az = "eu-est-2a" cidr = "192.168.0.0/24" }, "Private" = { az = "eu-est-2b" cidr = "192.168.1.0/24" } } }
-
Input variable structure as in good example 2 is preferred over example 1 where possible. In this particular case the user is forced to use unique names for the subnets by using subnet name as the key of the object.
In some cases it does make sense to create Public Cloud components (ie. virtual machines, load balancers, routing tables) using multiple Terraform resources to avoid deletion of the entire component upon minor modifications to the object. As an example, adding/removing an interface to an EC2 instance destroys and recreates the entire instance. Avoid it by using a distinct Terraform resource for the network interface:
```hcl2
# Input variable
variable "firewall" {
default = {
"MOJ-AW2-FW01A" = {
interfaces = [
{
name = "mgmt"
subnet_id = module.vpc.subnets["Management-A"].id
security_groups = [module.vpc.security_groups["SG_Management"].id]
public_ip = false
index = 0
},
{
name = "public"
subnet_id = module.vpc.subnets["Public-A"].id
security_groups = [module.vpc.security_groups["SG_Public"].id]
index = 1
public_ip = true
source_dest_check = false
},
{
name = "private"
subnet_id = module.vpc.subnets["Private-A"].id
security_groups = [module.vpc.security_groups["SG_Private"].id]
index = 2
source_dest_check = false
}
]
}
}
}
# Flatten variable
locals {
interfaces = flatten([
for fw_name, firewall in var.firewalls : [
for if_name, interfaecs in firewall.interfaces : [
merge(interfaecs, { "fw_name" = fw_name })
]
]
])
}
# Create instance
resource "aws_instance" "this" {
for_each = var.firewalls
disable_api_termination = false
instance_initiated_shutdown_behavior = "stop"
ebs_optimized = true
ami = var.custom_ami != null ? var.custom_ami : data.aws_ami.this.id
instance_type = var.instance_type
key_name = var.key_name
user_data = var.user_data
monitoring = false
iam_instance_profile = var.iam_instance_profile
root_block_device {
delete_on_termination = "true"
}
# Attach primary interface to the instance
network_interface {
device_index = 0
network_interface_id = aws_network_interface.primary[each.key].id
}
tags = { Name = each.key }
}
# Attach interfaces
# Create the primary interface (index 0) for the instance
resource "aws_network_interface" "primary" {
for_each = { for i in local.interfaces : i.fw_name => i if i.index == 0 }
subnet_id = each.value.subnet_id
private_ips = lookup(each.value, "private_ips", null)
security_groups = lookup(each.value, "security_groups", null)
source_dest_check = lookup(each.value, "source_dest_check", false)
description = lookup(each.value, "description", null)
tags = { "Name" = each.key }
}
resource "aws_network_interface" "this" {
for_each = { for i in local.interfaces : "${i.fw_name}-${i.name}" => i if i.index != 0 }
subnet_id = each.value.subnet_id
private_ips = lookup(each.value, "private_ips", null)
security_groups = lookup(each.value, "security_groups", null)
source_dest_check = lookup(each.value, "source_dest_check", false)
description = lookup(each.value, "description", null)
attachment {
instance = aws_instance.this[each.value.fw_name].id
device_index = lookup(each.value, "index", null)
}
tags = { "Name" = each.key }
}
```
-
Terraform output values allow you to export structured data about your resources. You can use this data to configure other parts of your infrastructure with automation tools, or as a data source for another Terraform workspace. Outputs are also necessary to share data from a child module to your root module.
-
Example - output variable for a single resource
# Output variable snippet: output "virtual_network_id" { description = "The ID of the created Virtual Network." value = azurerm_virtual_network.this.id } # Output list: $ terraform output virtual_network_id = subscriptions/[redacted]/example-vnet
This constuct is fine when you want to export data about a single resource, but if you are using the
for_each
loop to create multiple resources, the output variable should be updated accordingly to reflect this scenario.Indicate what to expect from the output return value. In the example above, the output variable has been defined as
virtual_network_id
, where theid
suffix suggest that a singular occurrence of a given resource will be presented as the output return value. When expecting multiple resources of the same kind as the output result, both the output variable name and value should be adjusted to reflect those changes, like in the example below. Notice that now we are creating a map which is using the samekey
as the resource, and theid
as the value. Therefore we changed the output variable name suffix toids
to suggest that multiple values should be expected by the user. -
Example - output variable for multiple resource occurrences
# Output variable snippet: output "virtual_network_ids" { description = "The identifiers of the created Virtual Networks." value = { for k, v in azurerm_network_security_group.this : k => v.id } } # Output list: $ terraform output virtual_network_ids = { example_vnet1 = subscriptions/[redacted]/example-vnet1 example_vnet2 = subscriptions/[redacted]/example-vnet2 }
Reference - https://learn.hashicorp.com/tutorials/terraform/outputs
-
Do not refer to the entire module call as a one object, such as x = module.mystuff
. Although it works on terraform apply
, it propagates deletions unexpectedly on all Terraform versions. Example code which uses random_pet
, a very simple Hashicorp-provided module which simply generates names, e.g. "relevant-horse":
-
producer/main.tf
```hcl2 resource "random_pet" "unrelated" { # It uses nothing, and is not used by anything. Right? } resource "random_pet" "this" {} output "id" { value = random_pet.this.id } ```
-
main.tf
```hcl2 module "producer" { source = "./producer" } locals { # BAD. Whether you pass the module.producer through a `local`, a `variable`, an `output`, the behavior is not great. module_call_as_object = module.producer } resource "random_pet" "main" { prefix = local.module_call_as_object.id } ```
So, what is the problem? When any Terraform run tries to delete the unrelated
resource, it does delete also main
resource, despite
there is no apparent dependency between them:
```sh
$ terraform apply
$ terraform destroy -target module.producer.random_pet.unrelated
Terraform will perform the following actions:
# random_pet.main will be destroyed
- resource "random_pet" "main" {
- id = "dynamic-basilisk-evident-bat" -> null
- length = 2 -> null
- prefix = "dynamic-basilisk" -> null
- separator = "-" -> null
}
# module.producer.random_pet.unrelated will be destroyed
- resource "random_pet" "unrelated" {
- id = "relevant-horse" -> null
- length = 2 -> null
- separator = "-" -> null
}
Plan: 0 to add, 0 to change, 2 to destroy.
```
To prevent production code for having such behavior, it would be better to pass around module.producer.my_output
instead of the entire module.producer
.
In this example:
```hcl2
locals {
# GOOD. The module.producer is not passed anywhere as a separate object.
module_call_result = module.producer.id
}
```
-
for_each
only accepts typeset
ormap
, therefore you may have to transform lists to maps when creating multiple resources withfor_each
-
Example - Transform subnets
list
in to amap
variable "subnets" { default = [ { name = "Public" az = "eu-est-2a" cidr = "192.168.0.0/24" }, { name = "Private" az = "eu-est-2b" cidr = "192.168.1.0/24" } ] } locals { subnet_map = { for subnet in var.subnets: subnet.name => subnet } }
-
Reference - https://www.hashicorp.com/blog/hashicorp-terraform-0-12-preview-for-and-for-each/
-
Sometimes your input data structure isn't naturally in a suitable shape for use in a
for_each
argument, andflatten
can be a useful helper function when reducing a nested data structure into a flat one.-
Example - You might have an input variable to define your routes in a routing table, however this variable structure may not be suitable to be used in
aws_route
resourcevariable "route_tables" { default = { "Public" = [ { destination_cidr = "0.0.0.0/0", target = "igw" } ], "Priv" = [ { destination_cidr = "10.0.0.0/8", target = "tgw" } ], "Mgmt" = [ { destination_cidr = "0.0.0.0/0", target = "igw" }, { destination_cidr = "192.168.100.0/24", target = "tgw" } ] } } # Create route tables resource "aws_route_table" "this" { for_each = var.route_tables vpc_id = aws_vpc.this.id tags = { Name = each.key } } # Create individual routes resource "aws_route" "this" { for_each = ?? route_table_id = ?? destination_cidr_block = ?? gateway_id = ?? }
-
Ideally, in order to declare all of the routes with a single resource block, we must first flatten the structure to produce a collection where each top-level element represents a single route
# Flatten the variable structure locals { routes = flatten([ for rtb, rts in var.route_tables : [ for rt in rts : [ merge(rt, { "rtb" = rtb }) ] ] ]) } resource "aws_route" "igw_rt" { for_each = { for r in local.routes : "${r.rtb}-${r.destination_cidr}" => r } route_table_id = aws_route_table.this[each.value.rtb].id destination_cidr_block = each.value.destination_cidr }
-
-
This technique will likely result in a minor problem on Terraform 0.12 (
Invalid for_each argument
in specific situations). The first recommendation is to use Terraform 0.13. There is another less-preferred workaround available.
Reference - https://www.terraform.io/docs/configuration/functions/flatten.html
-
In certain cases our modules will have to support brownfield resources (that is, pre-existing resources), in which case we will have the
data
object instead of theresource
object.-
Example - Support brownfield deployments where Azure
resource_group
already exists. We can usetry()
function to determine the value forlocal.rg
based on the existence ofresource "azurerm_resource_group"
ordata "azurerm_resource_group"
resource "azurerm_resource_group" "this" { count = var.existing_rg == false ? 1 : 0 name = var.resource_group_name location = var.location } data "azurerm_resource_group" "this" { count = var.existing_rg == true ? 1 : 0 name = var.resource_group_name } locals { rg = try(azurerm_resource_group.this[0], data.azurerm_resource_group.this[0]) }
-
-
When including
data
objects in reusable modules, consider including this note in the documentation:If the code needs to retain Terraform-0.12 and 0.13 compatibility, the input
var.varname
needs to be a static string and not come from another Terraform object (in a typical case, it should not come from aresource
). It is allowed for Terraform-0.14+ though.-
You may omit the note for any input if you test that you can assign it
varname = some_resource.this.field
and then plan/apply succeeds. -
Do not assume here that the test succeeding for 0.12 will succeed for 0.13, test both. In this case, incompatibility is likely to show up.
-
All the inputs that are used inside
data
blocks might be independently impacted:data "azurerm_resource" "my_data_object" { name = var.input1 // possibly impacted resource_group_name = var.input2 // also possibly impacted required_tags = var.input3 // also possibly impacted }
-
-
As long as our code supports Terraform-0.12.x, avoid creating a
resource
object and later passing it through adata
object. This may cause a known problem and thus requires careful testing. It's hard to determine before the tests whether the particular code can be ever made compatible with both 0.12 and with 0.13 at the same time. The linked page proposes workarounds. The 0.14 version is not impacted by this issue.
We will maintain one mono repo per cloud provider. Three repos, representing AWS, Azure and GCP, as we kick off our efforts.
├── README.md <- Documentation explaining the purpose of this mono repo
├── LICENSE.md <- Palo Alto Networks Script Software Agreement
├── modules/
│ ├── vpc/
│ │ ├── README.md <- Documentation on how to use submodules on its own
│ │ ├── variables.tf
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ ├── versions.tf <- Pin supported terraform provider versions
│ ├── vmseries/
│ │ ├── README.md
│ │ ├── variables.tf
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ ├── versions.tf
├── examples/ <- Place reference architecture use cases here, subdirectory per use case
│ ├── tgw-inbound-inspection/
│ │ ├── README.md <- Documentation for specific ref arch use case
│ │ ├── variables.tf
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ ├── versions.tf
│ │ ├── example.tfvars
│ ├── tgw-outbound-inspection/
To automate the process of documentation creation we use a collection of git hooks for Terraform used with the pre-commit framework.
The terraform-docs
utility will insert/update the documentation framed by the markers shown below if they are present in a README.md
file.
```txt
<!-- BEGINNING OF PRE-COMMIT-TERRAFORM DOCS HOOK -->
<!-- END OF PRE-COMMIT-TERRAFORM DOCS HOOK -->
```
This way, sections such as Requirements
, Provides
, Modules
, Resources
, Inputs
and Outputs
don't need to be updated manually every time the module configuration changes - the pre-commit web hook takes care of that task and makes the updates automatically.