Terraform map expansion - interface ranges
Typical terraform deployments use maps (and not - ideally never arrays/lists) to reflect multiples of provisioned resources, for example if you need five databases, you will just create a map and loop around the module.
This is simple, as long as you can easily count the number of instances.
I'm currently working on integrating our core network infrastructure into automation of the deployments. Typically this is done through ansible - in our case we prefer terraform for this task (if possible) due to the overall integration in the propagation of values.
Assume the following dimensions:
- You have TOR switches with each 16 downlink and 2 uplink ports
- Within this rack you need between 1024 and 2048 VLANs
The API (due to the possibility of allowing untagged VLANs on ports) needs to get each VLAN mapped seperately - and each port might have different configurations. Overall, building this as a map results in (16+2)*1024
- (16+2)*2048
map items, rougly 20k.
The main task is: Automate this stuff. My personal perception of automation is not to trade implementation for writing repetive configuration. Luckily - we don't need to.
If you've some networking past, you probably know that most switches allow doing commands in ranges. For example
# selection
interface range xg1-xg24
interface range te1/2/1-te1/2/24
# configuration
switchport trunk allowed vlans 42,73,1024-2047
range selection and configuration
This is a very concise and well readable form - perfect for humans to be able to focus on the actual configuration content.
So, I decided to go with such an approach:
ethernet_interfaces:
ether1-ether24:
mtu: 9216
sfp-sfpplus1:
mtu: 9216
sfp-sfpplus2:
mtu: 9216
range selection for eth
bridge_interfaces:
br1:
enabled: true
mtu: 9216
members:
ether1-ether22:
portMode: edge
mode: access
access:
vlan_id: 2110
ether24:
portMode: edge
mode: trunk
trunk:
native_vlan_id: 2110
vlans:
- 2104-2115
sfp-sfpplus1:
mode: trunk
trunk:
native_vlan_id: 2110
vlans:
- 2104-2115
sfp-sfpplus2:
mode: trunk
trunk:
native_vlan_id: 2110
vlans:
- 2104-2115
range selection for bridges
You can see - the ethernet interfaces are one dimension, each interface has a single set of values. Fairly simple.
The bridge interfaces are slightly more complex: you can have multiple bridges with multiple members with multiple vlans - that's three dimensions (we will focus on two of them).
Expand the ranges
So the idea is simple: Instead of using the variable directly, we will once pipe it through some unfolding logic and use the (local) variable then for the actual iteration.
ethernet_interfaces_expanded = merge([
for k, v in coalesce(var.ethernet_interfaces, {}) : (
can(regex(var.interface_range_regex, k)) ?
# Range case: expand into multiple interfaces
{
for i in range(
tonumber(regex(var.interface_range_regex, k).from),
tonumber(regex(var.interface_range_regex, k).to) + 1
) : format("%s%d", regex(var.interface_range_regex, k).prefix, i) => v
} :
# Single case: keep as-is
{ (k) = v }
)
]...)
expand eth
You can see the approach here - we iterate over all interfaces, use some regex to match the pattern and then extract the relevant (named) groups to create our range object. The value of the original group will be just used on each key.
The regex for the expansion is as follows: ^(?P<prefix>[a-zA-Z][\w+-/]*[a-zA-Z+-/])(?P<from>\d+)-(?P<prefix2>[a-zA-Z][\w+-/]*[a-zA-Z+-/])(?P<to>\d+)$
This means we will virtually do this:
# variable version
ethernet_interfaces:
ether1-ether3:
mtu: 9216
# local version
ethernet_interfaces:
ether1:
mtu: 9216
ether2:
mtu: 9216
ether3:
mtu: 9216
expanded value
The bridge version is slightly more complex, here for your reference:
bridge_interfaces = {
for k, v in coalesce(var.bridge_interfaces, {}) : k =>
provider::deepmerge::mergo(
var.bridge_interfaces_defaults,
{ for mk, mv in v : mk => mv if mv != null },
{ members = null },
{ members = merge([
for mk, mv in coalesce(v.members, {}) : (
can(regex(var.interface_range_regex, mk)) ?
# Range case: expand into multiple interfaces
{
for i in range(
tonumber(regex(var.interface_range_regex, mk).from),
tonumber(regex(var.interface_range_regex, mk).to) + 1
) : format("%s%d", regex(var.interface_range_regex, mk).prefix, i) => mv
} :
# Single case: keep as-is
{ (mk) = mv }
)
]...)
}
)
}
bridge expansion
You can see that we need to iterate over the bridges, then over the members. As we're merging the bridge values, we need to insert an explicit null on the members field to make sure to clear the original value.
Writing this kind of code is not the most straight forward way and it will (or might) take a few minutes. Still - the improvement on the format of resulting configuration is worth the effort.