String and set comparaison in azurerm NSG with Terraform
NSG - Network Security Group
Network Security Group are object part of the Azure Environment playing a crucial role to filter network flow either on a subnet or on NIC of a machine. It allows granularity and can be seen as Access Control List (ACL).
Long story short - Take away
In order to represent in Terraform for
hashicorp/azurermin version3.109.0and bellow :
- An empty set : prefer the usage of
toset(null)instead of[].- An empty/null string : prefer the usage of
tostring(null)instead of"".
Details
When using a NSG rule object azurerm_network_security_rule, the source and address of a filtering rule can be set with 2 variables.
These variables are the prefix and prefixes where prefix expect a string containing a CIDR or a tag (*, VirtualNetwork, Internet, etc.) and prefixes expect a set of CIDR string.
It should be noted that prefixes does not handle tags.
Exemple of code to set rule based on a list of rules :
resource "azurerm_network_security_rule" "security_rule" {
for_each = { for rule in var.list_nsg_rules : rule.name => rule }
name = each.value.name
priority = each.value.priority
direction = each.value.direction
access = each.value.access
protocol = each.value.protocol
source_address_prefixes = each.value.source_address_prefixes
source_port_range = each.value.source_port_range
destination_address_prefixes = each.value.destination_address_prefixes
destination_port_ranges = each.value.destination_port_ranges
network_security_group_name = each.value.network_security_group_name
resource_group_name = each.value.resource_group_name
depends_on = [azurerm_network_security_group.security_group]
}Within the following 2 set of variables, the variables prefix and prefixes cannot both contains non-null values.
For convenience, the NSG rules can be set into a CSV file like this one :
nsg_name,name,priority,direction,access,protocol,source_address_prefixes,source_port_range,destination_address_prefixes,destination_port_ranges
nsg001-rg001,allow-bastion-to-subnet-Tcp,2000,Inbound,Allow,Tcp,10.0.0.0/26,*,VirtualNetwork,22;3389
nsg002-rg001,allow-http-to-subnet,2001,Inbound,Allow,Tcp,*,*,10.1.1.0/24;10.2.2.0/24,80This CSV shape allows the operator to include either a CIDR, a tag or multiple CIDR if separated by a semicolon (;).
Considering the terraform code we have 2 chose to either fill the prefix or prefixes settings because the CSV file has only one parameter that can contains both allowed and disallowed values for prefix and prefixes parameters.
To do so, it is possible to check the length of the value passed by the CSV in order to define if the values is a set or not. To do so, the length can be checked.
The following code can be used for the source_address_prefixes and is similar for destination_address_prefixes :
length(toset(flatten([for source_address in split(";", each.value.source_address_prefixes) : split(",", source_address)]))) == 1 ? each.value.source_address_prefixes : ""The following code can be used for the source_address_prefix and is similar for destination_address_prefix :
length(toset(flatten([for source_address in split(";", each.value.source_address_prefixes) : split(",", source_address)]))) > 1 ? toset(flatten([for source_address in split(";", each.value.source_address_prefixes) : split(",", source_address)])) : []Translated into the previous code :
resource "azurerm_network_security_rule" "security_rule" {
for_each = { for rule in var.list_nsg_rules : rule.name => rule }
name = each.value.name
priority = each.value.priority
direction = each.value.direction
access = each.value.access
protocol = each.value.protocol
source_address_prefix = length(toset(flatten([for source_address in split(";", each.value.source_address_prefixes) : split(",", source_address)]))) == 1 ? each.value.source_address_prefixes : tostring(null)
source_address_prefixes = length(toset(flatten([for source_address in split(";", each.value.source_address_prefixes) : split(",", source_address)]))) > 1 ? toset(flatten([for source_address in split(";", each.value.source_address_prefixes) : split(",", source_address)])) : toset(null)
source_port_range = each.value.source_port_range
destination_port_ranges = each.value.destination_port_ranges == "*" ? ["0-65535"] : (each.value.destination_port_ranges == "" ? [] : toset(flatten([for port_str in split(";", each.value.destination_port_ranges) : split(",", port_str)])))
destination_address_prefix = length(toset(flatten([for destination_address in split(";", each.value.destination_address_prefixes) : split(",", destination_address)]))) == 1 ? each.value.destination_address_prefixes : tostring(null)
destination_address_prefixes = length(toset(flatten([for destination_address in split(";", each.value.destination_address_prefixes) : split(",", destination_address)]))) > 1 ? toset(flatten([for destination_address in split(";", each.value.destination_address_prefixes) : split(",", destination_address)])) : toset(null)
network_security_group_name = each.value
resource_group_name = tostring(split("-", each.value)[1])
depends_on = [azurerm_network_security_group.security_group]
}But this would not work, because Terraform would yield some errors with prefix and prefixes values both set
Using the terraform console -plan command, it is possible to check created values and observe that :
- When
prefixvalues are not set, Terraform set the value attostring(null). - When
prefixesvalues are not set, Terraform set the value attostring(null).
The code was transformed to match Terraform expectations :
resource "azurerm_network_security_rule" "security_rule" {
for_each = { for rule in var.list_nsg_rules : rule.name => rule }
name = each.value.name
priority = each.value.priority
direction = each.value.direction
access = each.value.access
protocol = each.value.protocol
source_address_prefix = length(toset(flatten([for source_address in split(";", each.value.source_address_prefixes) : split(",", source_address)]))) == 1 ? each.value.source_address_prefixes : tostring(null)
source_address_prefixes = length(toset(flatten([for source_address in split(";", each.value.source_address_prefixes) : split(",", source_address)]))) > 1 ? toset(flatten([for source_address in split(";", each.value.source_address_prefixes) : split(",", source_address)])) : toset(null)
source_port_range = each.value.source_port_range
destination_port_ranges = each.value.destination_port_ranges == "*" ? ["0-65535"] : (each.value.destination_port_ranges == "" ? [] : toset(flatten([for port_str in split(";", each.value.destination_port_ranges) : split(",", port_str)])))
destination_address_prefix = length(toset(flatten([for destination_address in split(";", each.value.destination_address_prefixes) : split(",", destination_address)]))) == 1 ? each.value.destination_address_prefixes : tostring(null)
destination_address_prefixes = length(toset(flatten([for destination_address in split(";", each.value.destination_address_prefixes) : split(",", destination_address)]))) > 1 ? toset(flatten([for destination_address in split(";", each.value.destination_address_prefixes) : split(",", destination_address)])) : toset(null)
network_security_group_name = each.value
resource_group_name = tostring(split("-", each.value)[1])
depends_on = [azurerm_network_security_group.security_group]
}Conclusion
So, it is not intuitive but for terraform with the hashicorp/azurerm version 3.109.0 and bellow :
""might not equivalent to a null/empty string andtostring(null)must be preferred ;[]might not equivalent to an empty set andtoset(null)must be preferred.